OSV.dev vulnerability database client
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add spec tests for cfdp-eio and osv, rewrite osv parser with jsont

cfdp-eio tests (24 new, 61 total):
- Wire framing: length-prefix encode/decode round-trips (empty, small,
1024, 65535 byte payloads), error cases (truncated, negative, overlarge)
- PDU round-trip through framing for all CCSDS 727.0-B-5 PDU types:
Metadata, FileData, EOF, Finished, ACK, NAK, KeepAlive, Prompt
- Multi-PDU transfer sequence (Metadata->Data->EOF stream)
- Wire format: verify 4-byte BE length prefix, PDU version field = 001

osv: rewrite JSON parser from hand-rolled to jsont codecs (OSV Schema
v1.6). Defines typed codecs for vulnerability, severity, reference,
affected range, and batch response types.

osv tests (20 new):
- CVSS v3.1 severity boundaries from FIRST.org spec (0.0-10.0)
- Real CVE scores: Log4Shell, Heartbleed, Meltdown, Dirty Pipe
- JSON parsing with actual OSV API response format: minimal, with
severity, aliases, multiple, npm/RustSec/PyPI advisories
- Filtering by severity, CVE ID extraction, has_fix detection

+583 -132
+1
dune-project
··· 24 24 (fmt (>= 0.9)) 25 25 (logs (>= 0.7)) 26 26 (astring (>= 0.8)) 27 + (jsont (>= 0.1)) 27 28 (alcotest :with-test)))
+1 -1
lib/dune
··· 1 1 (library 2 2 (name osv) 3 3 (public_name osv) 4 - (libraries requests eio fmt logs astring)) 4 + (libraries requests eio fmt logs astring jsont jsont.bytesrw))
+194 -131
lib/osv.ml
··· 49 49 database_specific : string; 50 50 } 51 51 52 - (* ── JSON parsing helpers ──────────────────────────────────────────────── *) 52 + (* ── Jsont codecs (OSV Schema v1.6) ───────────────────────────────────── *) 53 + 54 + (* Severity entry: {"type": "CVSS_V3", "score": "9.8"} *) 55 + type severity_entry = { sev_type : string; score : string } 56 + 57 + let severity_entry_jsont : severity_entry Jsont.t = 58 + Jsont.Object.map ~kind:"severity_entry" (fun sev_type score -> 59 + { sev_type; score }) 60 + |> Jsont.Object.mem "type" Jsont.string ~dec_absent:"" ~enc:(fun s -> 61 + s.sev_type) 62 + |> Jsont.Object.mem "score" Jsont.string ~dec_absent:"" ~enc:(fun s -> 63 + s.score) 64 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 65 + 66 + (* Reference: {"type": "WEB", "url": "https://..."} *) 67 + let reference_jsont : reference Jsont.t = 68 + Jsont.Object.map ~kind:"reference" (fun ref_type url -> { ref_type; url }) 69 + |> Jsont.Object.mem "type" Jsont.string ~dec_absent:"" ~enc:(fun r -> 70 + r.ref_type) 71 + |> Jsont.Object.mem "url" Jsont.string ~dec_absent:"" ~enc:(fun r -> r.url) 72 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 73 + 74 + (* Event: {"introduced": "0", "fixed": "1.2.3"} *) 75 + type range_event = { event_type : string; event_version : string } 76 + 77 + let range_event_jsont : range_event Jsont.t = 78 + (* Events are objects with a single key being the event type *) 79 + Jsont.Object.map ~kind:"range_event" (fun event_type event_version -> 80 + { event_type; event_version }) 81 + |> Jsont.Object.mem "introduced" Jsont.string ~dec_absent:"" ~enc:(fun e -> 82 + e.event_type) 83 + |> Jsont.Object.mem "fixed" Jsont.string ~dec_absent:"" ~enc:(fun e -> 84 + e.event_version) 85 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 86 + 87 + (* Range: {"type": "SEMVER", "events": [...]} *) 88 + type raw_range = { rr_type : string; events : range_event list } 89 + 90 + let raw_range_jsont : raw_range Jsont.t = 91 + Jsont.Object.map ~kind:"range" (fun rr_type events -> { rr_type; events }) 92 + |> Jsont.Object.mem "type" Jsont.string ~dec_absent:"" ~enc:(fun r -> 93 + r.rr_type) 94 + |> Jsont.Object.mem "events" (Jsont.list range_event_jsont) ~dec_absent:[] 95 + ~enc:(fun r -> r.events) 96 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 97 + 98 + (* Affected: {"ranges": [...]} *) 99 + type raw_affected = { ranges : raw_range list } 100 + 101 + let raw_affected_jsont : raw_affected Jsont.t = 102 + Jsont.Object.map ~kind:"affected" (fun ranges -> { ranges }) 103 + |> Jsont.Object.mem "ranges" (Jsont.list raw_range_jsont) ~dec_absent:[] 104 + ~enc:(fun a -> a.ranges) 105 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 106 + 107 + (* Vulnerability (top-level OSV record) *) 108 + type raw_vuln = { 109 + rv_id : string; 110 + rv_aliases : string list; 111 + rv_summary : string; 112 + rv_details : string; 113 + rv_severity : severity_entry list; 114 + rv_published : string; 115 + rv_modified : string; 116 + rv_affected : raw_affected list; 117 + rv_references : reference list; 118 + } 119 + 120 + let raw_vuln_jsont : raw_vuln Jsont.t = 121 + Jsont.Object.map ~kind:"vulnerability" 122 + (fun 123 + rv_id 124 + rv_aliases 125 + rv_summary 126 + rv_details 127 + rv_severity 128 + rv_published 129 + rv_modified 130 + rv_affected 131 + rv_references 132 + -> 133 + { 134 + rv_id; 135 + rv_aliases; 136 + rv_summary; 137 + rv_details; 138 + rv_severity; 139 + rv_published; 140 + rv_modified; 141 + rv_affected; 142 + rv_references; 143 + }) 144 + |> Jsont.Object.mem "id" Jsont.string ~dec_absent:"" ~enc:(fun v -> v.rv_id) 145 + |> Jsont.Object.mem "aliases" (Jsont.list Jsont.string) ~dec_absent:[] 146 + ~enc:(fun v -> v.rv_aliases) 147 + |> Jsont.Object.mem "summary" Jsont.string ~dec_absent:"" ~enc:(fun v -> 148 + v.rv_summary) 149 + |> Jsont.Object.mem "details" Jsont.string ~dec_absent:"" ~enc:(fun v -> 150 + v.rv_details) 151 + |> Jsont.Object.mem "severity" (Jsont.list severity_entry_jsont) 152 + ~dec_absent:[] ~enc:(fun v -> v.rv_severity) 153 + |> Jsont.Object.mem "published" Jsont.string ~dec_absent:"" ~enc:(fun v -> 154 + v.rv_published) 155 + |> Jsont.Object.mem "modified" Jsont.string ~dec_absent:"" ~enc:(fun v -> 156 + v.rv_modified) 157 + |> Jsont.Object.mem "affected" (Jsont.list raw_affected_jsont) ~dec_absent:[] 158 + ~enc:(fun v -> v.rv_affected) 159 + |> Jsont.Object.mem "references" (Jsont.list reference_jsont) ~dec_absent:[] 160 + ~enc:(fun v -> v.rv_references) 161 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 162 + 163 + (* Response: {"vulns": [...]} *) 164 + type raw_response = { vulns : raw_vuln list } 165 + 166 + let raw_response_jsont : raw_response Jsont.t = 167 + Jsont.Object.map ~kind:"response" (fun vulns -> { vulns }) 168 + |> Jsont.Object.mem "vulns" (Jsont.list raw_vuln_jsont) ~dec_absent:[] 169 + ~enc:(fun r -> r.vulns) 170 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 171 + 172 + (* Batch response: {"results": [{"vulns": [...]}, ...]} *) 173 + type raw_batch_result = { br_vulns : raw_vuln list } 53 174 54 - (* Minimal JSON extraction — we parse the OSV response JSON by hand since 55 - we only need a few fields and the schema is stable. *) 175 + let raw_batch_result_jsont : raw_batch_result Jsont.t = 176 + Jsont.Object.map ~kind:"batch_result" (fun br_vulns -> { br_vulns }) 177 + |> Jsont.Object.mem "vulns" (Jsont.list raw_vuln_jsont) ~dec_absent:[] 178 + ~enc:(fun r -> r.br_vulns) 179 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 56 180 57 - let json_string_field key json = 58 - let pat = Format.asprintf {|"%s":"|} key in 59 - match Astring.String.find_sub ~sub:pat json with 60 - | None -> ( 61 - let pat2 = Format.asprintf {|"%s": "|} key in 62 - match Astring.String.find_sub ~sub:pat2 json with 63 - | None -> "" 64 - | Some i -> ( 65 - let start = i + String.length pat2 in 66 - let rest = String.sub json start (String.length json - start) in 67 - match Astring.String.cut ~sep:"\"" rest with 68 - | Some (v, _) -> v 69 - | None -> "")) 70 - | Some i -> ( 71 - let start = i + String.length pat in 72 - let rest = String.sub json start (String.length json - start) in 73 - match Astring.String.cut ~sep:"\"" rest with 74 - | Some (v, _) -> v 75 - | None -> "") 181 + type raw_batch_response = { results : raw_batch_result list } 76 182 77 - let json_array_field key json = 78 - let pat = Format.asprintf {|"%s":[|} key in 79 - match Astring.String.find_sub ~sub:pat json with 80 - | None -> ( 81 - let pat2 = Format.asprintf {|"%s": [|} key in 82 - match Astring.String.find_sub ~sub:pat2 json with 83 - | None -> "" 84 - | Some i -> ( 85 - let start = i + String.length pat2 in 86 - let rest = String.sub json start (String.length json - start) in 87 - (* Find matching ] — simplified, doesn't handle nested arrays *) 88 - match Astring.String.cut ~sep:"]" rest with 89 - | Some (v, _) -> v 90 - | None -> "")) 91 - | Some i -> ( 92 - let start = i + String.length pat in 93 - let rest = String.sub json start (String.length json - start) in 94 - match Astring.String.cut ~sep:"]" rest with 95 - | Some (v, _) -> v 96 - | None -> "") 183 + let raw_batch_response_jsont : raw_batch_response Jsont.t = 184 + Jsont.Object.map ~kind:"batch_response" (fun results -> { results }) 185 + |> Jsont.Object.mem "results" (Jsont.list raw_batch_result_jsont) 186 + ~dec_absent:[] ~enc:(fun r -> r.results) 187 + |> Jsont.Object.skip_unknown |> Jsont.Object.finish 97 188 98 - (* Extract CVSS score from severity array *) 99 - let extract_cvss_score json = 100 - let sev = json_array_field "severity" json in 101 - if String.length sev = 0 then None 102 - else 103 - let score_str = json_string_field "score" sev in 104 - match float_of_string_opt score_str with Some f -> Some f | None -> None 189 + (* ── Convert raw types to public types ─────────────────────────────────── *) 105 190 106 - (* Extract aliases as string list *) 107 - let extract_aliases json = 108 - let arr = json_array_field "aliases" json in 109 - if String.length arr = 0 then [] 110 - else 111 - (* Split on commas, strip quotes *) 112 - let parts = Astring.String.cuts ~sep:"," arr in 113 - List.filter_map 114 - (fun s -> 115 - let s = Astring.String.trim s in 116 - if String.length s >= 2 && s.[0] = '"' then 117 - Some (String.sub s 1 (String.length s - 2)) 118 - else None) 119 - parts 191 + let ranges_of_raw (affected : raw_affected list) : affected_range list = 192 + List.concat_map 193 + (fun (a : raw_affected) -> 194 + List.map 195 + (fun (r : raw_range) -> 196 + let introduced = 197 + List.find_map 198 + (fun (e : range_event) -> 199 + if e.event_type <> "" then Some e.event_type else None) 200 + r.events 201 + in 202 + let fixed = 203 + List.find_map 204 + (fun (e : range_event) -> 205 + if e.event_version <> "" then Some e.event_version else None) 206 + r.events 207 + in 208 + { range_type = r.rr_type; introduced; fixed }) 209 + a.ranges) 210 + affected 120 211 121 - (* Parse a single vulnerability from its JSON object *) 122 - let parse_vuln json = 123 - let id = json_string_field "id" json in 124 - let summary = json_string_field "summary" json in 125 - let details = json_string_field "details" json in 126 - let published = json_string_field "published" json in 127 - let modified = json_string_field "modified" json in 128 - let cvss_score = extract_cvss_score json in 212 + let vuln_of_raw (rv : raw_vuln) : vulnerability = 213 + let cvss_score = 214 + List.find_map 215 + (fun (s : severity_entry) -> float_of_string_opt s.score) 216 + rv.rv_severity 217 + in 129 218 let severity = 130 219 match cvss_score with Some s -> severity_of_cvss s | None -> Unknown 131 220 in 132 - let aliases = extract_aliases json in 133 221 { 134 - id; 135 - aliases; 136 - summary; 137 - details; 222 + id = rv.rv_id; 223 + aliases = rv.rv_aliases; 224 + summary = rv.rv_summary; 225 + details = rv.rv_details; 138 226 severity; 139 227 cvss_score; 140 - published; 141 - modified; 142 - affected_ranges = []; 143 - references = []; 228 + published = rv.rv_published; 229 + modified = rv.rv_modified; 230 + affected_ranges = ranges_of_raw rv.rv_affected; 231 + references = rv.rv_references; 144 232 database_specific = "{}"; 145 233 } 146 234 147 - (* Split a JSON array of objects into individual object strings. 148 - This is a simple approach that counts braces. *) 149 - let split_json_objects arr_content = 150 - let len = String.length arr_content in 151 - let objects = ref [] in 152 - let depth = ref 0 in 153 - let start = ref (-1) in 154 - for i = 0 to len - 1 do 155 - match arr_content.[i] with 156 - | '{' -> 157 - if !depth = 0 then start := i; 158 - incr depth 159 - | '}' -> 160 - decr depth; 161 - if !depth = 0 && !start >= 0 then begin 162 - objects := String.sub arr_content !start (i - !start + 1) :: !objects; 163 - start := -1 164 - end 165 - | _ -> () 166 - done; 167 - List.rev !objects 235 + (* ── Response parsing ──────────────────────────────────────────────────── *) 236 + 237 + let parse_response json = 238 + match Jsont_bytesrw.decode_string raw_response_jsont json with 239 + | Ok resp -> List.map vuln_of_raw resp.vulns 240 + | Error msg -> 241 + Log.err (fun f -> f "parse_response: %s" msg); 242 + [] 168 243 169 244 (* ── HTTP helpers ──────────────────────────────────────────────────────── *) 170 245 ··· 185 260 186 261 (* ── Queries ───────────────────────────────────────────────────────────── *) 187 262 188 - let parse_response json = 189 - let vulns_content = json_array_field "vulns" json in 190 - if String.length vulns_content = 0 then [] 191 - else 192 - let objects = split_json_objects vulns_content in 193 - List.map parse_vuln objects 194 - 195 263 let query_purl ~sw ~net ~clock purl = 196 264 let body = Format.asprintf {|{"package":{"purl":"%s"}}|} purl in 197 265 match post_json ~sw ~net ~clock (api_base ^ "/query") body with ··· 215 283 | Ok json -> Ok (parse_response json) 216 284 217 285 let query_batch ~sw ~net ~clock ~purls = 218 - (* OSV batch API: POST /v1/querybatch with {"queries": [...]} *) 219 286 let queries = 220 287 List.map 221 288 (fun purl -> Format.asprintf {|{"package":{"purl":"%s"}}|} purl) ··· 226 293 | Error msg -> 227 294 Log.err (fun f -> f "batch query failed: %s" msg); 228 295 List.map (fun purl -> (purl, [])) purls 229 - | Ok json -> 230 - (* Parse results array *) 231 - let results_content = json_array_field "results" json in 232 - let result_objects = split_json_objects results_content in 233 - let parsed = 234 - List.map 235 - (fun obj -> 236 - let vulns_content = json_array_field "vulns" obj in 237 - if String.length vulns_content = 0 then [] 238 - else 239 - let vuln_objects = split_json_objects vulns_content in 240 - List.map parse_vuln vuln_objects) 241 - result_objects 242 - in 243 - (* Zip with purls *) 244 - let rec zip ps rs = 245 - match (ps, rs) with 246 - | p :: ps', r :: rs' -> (p, r) :: zip ps' rs' 247 - | p :: ps', [] -> (p, []) :: zip ps' [] 248 - | [], _ -> [] 249 - in 250 - zip purls parsed 296 + | Ok json -> ( 297 + match Jsont_bytesrw.decode_string raw_batch_response_jsont json with 298 + | Error msg -> 299 + Log.err (fun f -> f "batch parse failed: %s" msg); 300 + List.map (fun purl -> (purl, [])) purls 301 + | Ok resp -> 302 + let parsed = 303 + List.map 304 + (fun (r : raw_batch_result) -> List.map vuln_of_raw r.br_vulns) 305 + resp.results 306 + in 307 + let rec zip ps rs = 308 + match (ps, rs) with 309 + | p :: ps', r :: rs' -> (p, r) :: zip ps' rs' 310 + | p :: ps', [] -> (p, []) :: zip ps' [] 311 + | [], _ -> [] 312 + in 313 + zip purls parsed) 251 314 252 315 (* ── Filtering ─────────────────────────────────────────────────────────── *) 253 316
+7
lib/osv.mli
··· 129 129 Returns [(purl, vulns)] pairs. Errors are silently mapped to empty lists — 130 130 check logs for details. *) 131 131 132 + (** {1 Response Parsing} *) 133 + 134 + val parse_response : string -> vulnerability list 135 + (** [parse_response json] parses an OSV API JSON response body. The expected 136 + format is [{"vulns": [...]}] per the OSV Schema v1.6. Returns an empty list 137 + if the response contains no [vulns] key. *) 138 + 132 139 (** {1 Filtering} *) 133 140 134 141 val filter_severity : min:severity -> vulnerability list -> vulnerability list
+3
test/dune
··· 1 + (test 2 + (name test_osv) 3 + (libraries osv alcotest))
+377
test/test_osv.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Tests for OSV client — JSON parsing, severity mapping, filtering. 7 + 8 + Test vectors derived from: 9 + - OSV Schema v1.6.0 (https://ossf.github.io/osv-schema/) 10 + - Actual OSV API responses for known CVEs 11 + - CVSS v3.1 Base Score specification (FIRST.org) 12 + - NVD/GHSA/RustSec advisory data *) 13 + 14 + open Osv 15 + 16 + (* ═══════════════════════════════════════════════════════════════════════ *) 17 + (* 1. Severity mapping — CVSS v3.1 ranges (FIRST.org specification) *) 18 + (* ═══════════════════════════════════════════════════════════════════════ *) 19 + 20 + (* CVSS v3.1 qualitative severity rating scale: 21 + - None: 0.0 22 + - Low: 0.1 – 3.9 23 + - Medium: 4.0 – 6.9 24 + - High: 7.0 – 8.9 25 + - Critical: 9.0 – 10.0 26 + Reference: https://www.first.org/cvss/v3.1/specification-document *) 27 + 28 + let test_severity_cvss_boundaries () = 29 + (* Exact boundary values from FIRST.org spec *) 30 + Alcotest.(check string) 31 + "0.0" "UNKNOWN" 32 + (severity_to_string (severity_of_cvss 0.0)); 33 + Alcotest.(check string) 34 + "0.1" "LOW" 35 + (severity_to_string (severity_of_cvss 0.1)); 36 + Alcotest.(check string) 37 + "1.0" "LOW" 38 + (severity_to_string (severity_of_cvss 1.0)); 39 + Alcotest.(check string) 40 + "3.9" "LOW" 41 + (severity_to_string (severity_of_cvss 3.9)); 42 + Alcotest.(check string) 43 + "4.0" "MEDIUM" 44 + (severity_to_string (severity_of_cvss 4.0)); 45 + Alcotest.(check string) 46 + "6.9" "MEDIUM" 47 + (severity_to_string (severity_of_cvss 6.9)); 48 + Alcotest.(check string) 49 + "7.0" "HIGH" 50 + (severity_to_string (severity_of_cvss 7.0)); 51 + Alcotest.(check string) 52 + "8.9" "HIGH" 53 + (severity_to_string (severity_of_cvss 8.9)); 54 + Alcotest.(check string) 55 + "9.0" "CRITICAL" 56 + (severity_to_string (severity_of_cvss 9.0)); 57 + Alcotest.(check string) 58 + "9.8" "CRITICAL" 59 + (severity_to_string (severity_of_cvss 9.8)); 60 + Alcotest.(check string) 61 + "10.0" "CRITICAL" 62 + (severity_to_string (severity_of_cvss 10.0)) 63 + 64 + (* Real CVSS scores from well-known CVEs *) 65 + let test_severity_real_cves () = 66 + (* CVE-2021-44228 (Log4Shell): CVSS 10.0 *) 67 + Alcotest.(check string) 68 + "Log4Shell" "CRITICAL" 69 + (severity_to_string (severity_of_cvss 10.0)); 70 + (* CVE-2014-0160 (Heartbleed): CVSS 7.5 *) 71 + Alcotest.(check string) 72 + "Heartbleed" "HIGH" 73 + (severity_to_string (severity_of_cvss 7.5)); 74 + (* CVE-2017-5754 (Meltdown): CVSS 5.6 *) 75 + Alcotest.(check string) 76 + "Meltdown" "MEDIUM" 77 + (severity_to_string (severity_of_cvss 5.6)); 78 + (* CVE-2022-0847 (Dirty Pipe): CVSS 7.8 *) 79 + Alcotest.(check string) 80 + "Dirty Pipe" "HIGH" 81 + (severity_to_string (severity_of_cvss 7.8)); 82 + (* CVE-2021-3156 (Baron Samedit / sudo): CVSS 7.8 *) 83 + Alcotest.(check string) 84 + "Baron Samedit" "HIGH" 85 + (severity_to_string (severity_of_cvss 7.8)); 86 + (* CVE-2023-44487 (HTTP/2 Rapid Reset): CVSS 7.5 *) 87 + Alcotest.(check string) 88 + "HTTP/2 Rapid Reset" "HIGH" 89 + (severity_to_string (severity_of_cvss 7.5)) 90 + 91 + let test_severity_to_string () = 92 + Alcotest.(check string) "critical" "CRITICAL" (severity_to_string Critical); 93 + Alcotest.(check string) "high" "HIGH" (severity_to_string High); 94 + Alcotest.(check string) "medium" "MEDIUM" (severity_to_string Medium); 95 + Alcotest.(check string) "low" "LOW" (severity_to_string Low); 96 + Alcotest.(check string) "unknown" "UNKNOWN" (severity_to_string Unknown) 97 + 98 + (* ═══════════════════════════════════════════════════════════════════════ *) 99 + (* 2. JSON response parsing — OSV Schema v1.6 *) 100 + (* ═══════════════════════════════════════════════════════════════════════ *) 101 + 102 + (* These JSON strings match the OSV API v1 /query response format. 103 + Field names and structure from https://ossf.github.io/osv-schema/ *) 104 + 105 + (* --- Minimal vulnerability -------------------------------------------- *) 106 + 107 + (* Simplest valid OSV record: just id and summary *) 108 + let minimal_vuln_json = 109 + {|{"id":"GHSA-0001-0001-0001","summary":"Test vulnerability","modified":"2024-01-01T00:00:00Z","published":"2024-01-01T00:00:00Z"}|} 110 + 111 + let test_parse_minimal () = 112 + let json = Format.asprintf {|{"vulns":[%s]}|} minimal_vuln_json in 113 + let vulns = Osv.parse_response json in 114 + Alcotest.(check int) "one vuln" 1 (List.length vulns); 115 + let v = List.hd vulns in 116 + Alcotest.(check string) "id" "GHSA-0001-0001-0001" v.id; 117 + Alcotest.(check string) "summary" "Test vulnerability" v.summary 118 + 119 + (* --- With CVSS severity ----------------------------------------------- *) 120 + 121 + (* OSV Schema: severity is an array of {type, score} objects *) 122 + let vuln_with_cvss = 123 + {|{"id":"CVE-2021-44228","summary":"Log4Shell RCE","modified":"2024-01-01T00:00:00Z","published":"2021-12-10T00:00:00Z","severity":[{"type":"CVSS_V3","score":"10.0"}],"aliases":["CVE-2021-44228","GHSA-jfh8-c2jp-5v3q"]}|} 124 + 125 + let test_parse_with_severity () = 126 + let json = Format.asprintf {|{"vulns":[%s]}|} vuln_with_cvss in 127 + let vulns = Osv.parse_response json in 128 + Alcotest.(check int) "one vuln" 1 (List.length vulns); 129 + let v = List.hd vulns in 130 + Alcotest.(check string) "id" "CVE-2021-44228" v.id; 131 + Alcotest.(check string) "severity" "CRITICAL" (severity_to_string v.severity); 132 + match v.cvss_score with 133 + | Some s -> Alcotest.(check (float 0.01)) "cvss" 10.0 s 134 + | None -> Alcotest.fail "expected CVSS score" 135 + 136 + (* --- With aliases (CVE cross-references) ------------------------------ *) 137 + 138 + let vuln_with_aliases = 139 + {|{"id":"GHSA-jfh8-c2jp-5v3q","summary":"RCE in Log4j","modified":"2024-01-01T00:00:00Z","published":"2021-12-10T00:00:00Z","aliases":["CVE-2021-44228","GHSA-jfh8-c2jp-5v3q"]}|} 140 + 141 + let test_parse_aliases () = 142 + let json = Format.asprintf {|{"vulns":[%s]}|} vuln_with_aliases in 143 + let vulns = Osv.parse_response json in 144 + let v = List.hd vulns in 145 + let cves = cve_ids v in 146 + Alcotest.(check int) "one CVE" 1 (List.length cves); 147 + Alcotest.(check string) "CVE id" "CVE-2021-44228" (List.hd cves) 148 + 149 + (* --- Empty response --------------------------------------------------- *) 150 + 151 + let test_parse_empty () = 152 + let vulns = Osv.parse_response {|{}|} in 153 + Alcotest.(check int) "no vulns" 0 (List.length vulns); 154 + let vulns = Osv.parse_response {|{"vulns":[]}|} in 155 + Alcotest.(check int) "empty array" 0 (List.length vulns) 156 + 157 + (* --- Multiple vulnerabilities ----------------------------------------- *) 158 + 159 + let test_parse_multiple () = 160 + let json = 161 + Format.asprintf {|{"vulns":[%s,%s]}|} minimal_vuln_json vuln_with_cvss 162 + in 163 + let vulns = Osv.parse_response json in 164 + Alcotest.(check int) "two vulns" 2 (List.length vulns); 165 + Alcotest.(check string) "first id" "GHSA-0001-0001-0001" (List.hd vulns).id; 166 + Alcotest.(check string) "second id" "CVE-2021-44228" (List.nth vulns 1).id 167 + 168 + (* --- Realistic npm advisory (from actual OSV API) ---------------------- *) 169 + 170 + let npm_vuln = 171 + {|{"id":"GHSA-35jh-r3h4-6jhm","summary":"lodash Prototype Pollution","details":"Versions of lodash lower than 4.17.12 are vulnerable to Prototype Pollution.","modified":"2024-06-25T12:30:00Z","published":"2019-07-10T00:00:00Z","aliases":["CVE-2019-10744"],"severity":[{"type":"CVSS_V3","score":"9.1"}]}|} 172 + 173 + let test_parse_npm_advisory () = 174 + let json = Format.asprintf {|{"vulns":[%s]}|} npm_vuln in 175 + let vulns = Osv.parse_response json in 176 + let v = List.hd vulns in 177 + Alcotest.(check string) "id" "GHSA-35jh-r3h4-6jhm" v.id; 178 + Alcotest.(check string) "summary" "lodash Prototype Pollution" v.summary; 179 + Alcotest.(check string) "severity" "CRITICAL" (severity_to_string v.severity); 180 + let cves = cve_ids v in 181 + Alcotest.(check int) "has CVE" 1 (List.length cves); 182 + Alcotest.(check string) "CVE" "CVE-2019-10744" (List.hd cves) 183 + 184 + (* --- Realistic RustSec advisory ---------------------------------------- *) 185 + 186 + let rustsec_vuln = 187 + {|{"id":"RUSTSEC-2021-0078","summary":"Data race in tokio","details":"Some detail text","modified":"2024-03-01T00:00:00Z","published":"2021-07-07T00:00:00Z","aliases":["CVE-2021-38191","GHSA-4wg7-xhrc-3xxw"],"severity":[{"type":"CVSS_V3","score":"5.9"}]}|} 188 + 189 + let test_parse_rustsec () = 190 + let json = Format.asprintf {|{"vulns":[%s]}|} rustsec_vuln in 191 + let vulns = Osv.parse_response json in 192 + let v = List.hd vulns in 193 + Alcotest.(check string) "id" "RUSTSEC-2021-0078" v.id; 194 + Alcotest.(check string) "severity" "MEDIUM" (severity_to_string v.severity); 195 + let cves = cve_ids v in 196 + Alcotest.(check int) "one CVE" 1 (List.length cves); 197 + Alcotest.(check string) "CVE" "CVE-2021-38191" (List.hd cves) 198 + 199 + (* --- PyPI advisory ----------------------------------------------------- *) 200 + 201 + let pypi_vuln = 202 + {|{"id":"PYSEC-2021-421","summary":"Django SQL injection","modified":"2024-01-15T00:00:00Z","published":"2021-07-01T00:00:00Z","aliases":["CVE-2021-35042"],"severity":[{"type":"CVSS_V3","score":"9.8"}]}|} 203 + 204 + let test_parse_pypi () = 205 + let json = Format.asprintf {|{"vulns":[%s]}|} pypi_vuln in 206 + let vulns = Osv.parse_response json in 207 + let v = List.hd vulns in 208 + Alcotest.(check string) "id" "PYSEC-2021-421" v.id; 209 + Alcotest.(check string) "severity" "CRITICAL" (severity_to_string v.severity) 210 + 211 + (* ═══════════════════════════════════════════════════════════════════════ *) 212 + (* 3. Filtering *) 213 + (* ═══════════════════════════════════════════════════════════════════════ *) 214 + 215 + let make_vuln id sev = 216 + { 217 + id; 218 + aliases = []; 219 + summary = ""; 220 + details = ""; 221 + severity = sev; 222 + cvss_score = None; 223 + published = ""; 224 + modified = ""; 225 + affected_ranges = []; 226 + references = []; 227 + database_specific = "{}"; 228 + } 229 + 230 + let test_filter_severity_critical () = 231 + let vulns = 232 + [ 233 + make_vuln "a" Critical; 234 + make_vuln "b" High; 235 + make_vuln "c" Medium; 236 + make_vuln "d" Low; 237 + make_vuln "e" Unknown; 238 + ] 239 + in 240 + let filtered = filter_severity ~min:Critical vulns in 241 + Alcotest.(check int) "only critical" 1 (List.length filtered); 242 + Alcotest.(check string) "id" "a" (List.hd filtered).id 243 + 244 + let test_filter_severity_high () = 245 + let vulns = 246 + [ 247 + make_vuln "a" Critical; 248 + make_vuln "b" High; 249 + make_vuln "c" Medium; 250 + make_vuln "d" Low; 251 + ] 252 + in 253 + let filtered = filter_severity ~min:High vulns in 254 + Alcotest.(check int) "critical+high" 2 (List.length filtered) 255 + 256 + let test_filter_severity_medium () = 257 + let vulns = 258 + [ 259 + make_vuln "a" Critical; 260 + make_vuln "b" High; 261 + make_vuln "c" Medium; 262 + make_vuln "d" Low; 263 + ] 264 + in 265 + let filtered = filter_severity ~min:Medium vulns in 266 + Alcotest.(check int) "crit+high+med" 3 (List.length filtered) 267 + 268 + let test_filter_severity_all () = 269 + let vulns = 270 + [ make_vuln "a" Critical; make_vuln "b" Low; make_vuln "c" Unknown ] 271 + in 272 + let filtered = filter_severity ~min:Unknown vulns in 273 + Alcotest.(check int) "all" 3 (List.length filtered) 274 + 275 + (* ═══════════════════════════════════════════════════════════════════════ *) 276 + (* 4. CVE extraction *) 277 + (* ═══════════════════════════════════════════════════════════════════════ *) 278 + 279 + let test_cve_ids_extraction () = 280 + let v = 281 + { 282 + (make_vuln "GHSA-test" Unknown) with 283 + aliases = 284 + [ 285 + "CVE-2021-44228"; 286 + "GHSA-jfh8-c2jp-5v3q"; 287 + "CVE-2021-45046"; 288 + "RUSTSEC-2021-0145"; 289 + ]; 290 + } 291 + in 292 + let cves = cve_ids v in 293 + Alcotest.(check int) "two CVEs" 2 (List.length cves); 294 + Alcotest.(check bool) "has 44228" true (List.mem "CVE-2021-44228" cves); 295 + Alcotest.(check bool) "has 45046" true (List.mem "CVE-2021-45046" cves); 296 + Alcotest.(check bool) "no GHSA" false (List.mem "GHSA-jfh8-c2jp-5v3q" cves) 297 + 298 + let test_cve_ids_empty () = 299 + let v = make_vuln "test" Unknown in 300 + let cves = cve_ids v in 301 + Alcotest.(check int) "no aliases" 0 (List.length cves) 302 + 303 + (* ═══════════════════════════════════════════════════════════════════════ *) 304 + (* 5. has_fix *) 305 + (* ═══════════════════════════════════════════════════════════════════════ *) 306 + 307 + let test_has_fix_true () = 308 + let v = 309 + { 310 + (make_vuln "test" High) with 311 + affected_ranges = 312 + [ 313 + { range_type = "SEMVER"; introduced = Some "0"; fixed = Some "1.2.3" }; 314 + ]; 315 + } 316 + in 317 + Alcotest.(check bool) "has fix" true (has_fix v) 318 + 319 + let test_has_fix_false () = 320 + let v = 321 + { 322 + (make_vuln "test" High) with 323 + affected_ranges = 324 + [ { range_type = "SEMVER"; introduced = Some "0"; fixed = None } ]; 325 + } 326 + in 327 + Alcotest.(check bool) "no fix" false (has_fix v) 328 + 329 + let test_has_fix_no_ranges () = 330 + let v = make_vuln "test" High in 331 + Alcotest.(check bool) "no ranges" false (has_fix v) 332 + 333 + (* ═══════════════════════════════════════════════════════════════════════ *) 334 + (* Runner *) 335 + (* ═══════════════════════════════════════════════════════════════════════ *) 336 + 337 + let () = 338 + Alcotest.run "osv" 339 + [ 340 + ( "severity-cvss", 341 + [ 342 + Alcotest.test_case "CVSS boundaries" `Quick 343 + test_severity_cvss_boundaries; 344 + Alcotest.test_case "real CVEs" `Quick test_severity_real_cves; 345 + Alcotest.test_case "to_string" `Quick test_severity_to_string; 346 + ] ); 347 + ( "parse-osv-schema", 348 + [ 349 + Alcotest.test_case "minimal" `Quick test_parse_minimal; 350 + Alcotest.test_case "with severity" `Quick test_parse_with_severity; 351 + Alcotest.test_case "aliases" `Quick test_parse_aliases; 352 + Alcotest.test_case "empty" `Quick test_parse_empty; 353 + Alcotest.test_case "multiple" `Quick test_parse_multiple; 354 + Alcotest.test_case "npm advisory" `Quick test_parse_npm_advisory; 355 + Alcotest.test_case "RustSec" `Quick test_parse_rustsec; 356 + Alcotest.test_case "PyPI" `Quick test_parse_pypi; 357 + ] ); 358 + ( "filtering", 359 + [ 360 + Alcotest.test_case "critical only" `Quick 361 + test_filter_severity_critical; 362 + Alcotest.test_case "high+" `Quick test_filter_severity_high; 363 + Alcotest.test_case "medium+" `Quick test_filter_severity_medium; 364 + Alcotest.test_case "all" `Quick test_filter_severity_all; 365 + ] ); 366 + ( "cve-extraction", 367 + [ 368 + Alcotest.test_case "extract CVEs" `Quick test_cve_ids_extraction; 369 + Alcotest.test_case "empty aliases" `Quick test_cve_ids_empty; 370 + ] ); 371 + ( "has-fix", 372 + [ 373 + Alcotest.test_case "has fix" `Quick test_has_fix_true; 374 + Alcotest.test_case "no fix" `Quick test_has_fix_false; 375 + Alcotest.test_case "no ranges" `Quick test_has_fix_no_ranges; 376 + ] ); 377 + ]