OSV.dev vulnerability database client
0
fork

Configure Feed

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

Add cfdp-eio, osv, and sbom.license libraries

cfdp-eio: CFDP file transfer over TCP with Eio. Drives Cfdp state
machines with real I/O — Sender (Class 1) and Receiver with
length-prefixed PDU framing. Same architecture as Borealis CFDP
service but using the standalone cfdp encode/decode API.

osv: OSV.dev vulnerability database client. Queries by Package URL,
ecosystem/name/version, or commit hash. Uses requests library for
HTTP. Maps CVSS scores to severity levels, supports batch queries.

sbom.license: SPDX license expression parser and policy evaluator.
Full Annex D grammar (AND/OR/WITH/+/LicenseRef, precedence, parens).
Policy engine with correct OR semantics (passes if any alternative
is acceptable). Built-in GPL and strong-copyleft deny lists.
Integration with Spdx.document and Cyclonedx.bom.

28 tests for sbom.license verified against Python license-expression
30.4 reference implementation: 50+ SPDX identifiers, operator
precedence, 18 real-world expressions (Linux kernel, Rust, npm,
Maven, Qt), error cases, SBOM integration with nested components.

+477
+27
dune-project
··· 1 + (lang dune 3.21) 2 + 3 + (name osv) 4 + 5 + (generate_opam_files true) 6 + 7 + (license ISC) 8 + 9 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 10 + 11 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 12 + 13 + (source (tangled gazagnaire.org/ocaml-osv)) 14 + 15 + (package 16 + (name osv) 17 + (synopsis "OSV.dev vulnerability database client") 18 + (description 19 + "Query the OSV (Open Source Vulnerabilities) database for known vulnerabilities by Package URL, ecosystem, or commit hash. Aggregates data from NVD, GitHub Advisories, RustSec, PyPI, and dozens of other sources.") 20 + (depends 21 + (ocaml (>= 5.1)) 22 + (requests (>= 0.1)) 23 + (eio (>= 1.0)) 24 + (fmt (>= 0.9)) 25 + (logs (>= 0.7)) 26 + (astring (>= 0.8)) 27 + (alcotest :with-test)))
+4
lib/dune
··· 1 + (library 2 + (name osv) 3 + (public_name osv) 4 + (libraries requests eio fmt logs astring))
+268
lib/osv.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let src = Logs.Src.create "osv" ~doc:"OSV.dev vulnerability client" 7 + 8 + module Log = (val Logs.src_log src : Logs.LOG) 9 + 10 + let api_base = "https://api.osv.dev/v1" 11 + 12 + (* ── Types ─────────────────────────────────────────────────────────────── *) 13 + 14 + type severity = Critical | High | Medium | Low | Unknown 15 + 16 + let severity_to_string = function 17 + | Critical -> "CRITICAL" 18 + | High -> "HIGH" 19 + | Medium -> "MEDIUM" 20 + | Low -> "LOW" 21 + | Unknown -> "UNKNOWN" 22 + 23 + let severity_of_cvss score = 24 + if score >= 9.0 then Critical 25 + else if score >= 7.0 then High 26 + else if score >= 4.0 then Medium 27 + else if score > 0.0 then Low 28 + else Unknown 29 + 30 + type reference = { ref_type : string; url : string } 31 + 32 + type affected_range = { 33 + range_type : string; 34 + introduced : string option; 35 + fixed : string option; 36 + } 37 + 38 + type vulnerability = { 39 + id : string; 40 + aliases : string list; 41 + summary : string; 42 + details : string; 43 + severity : severity; 44 + cvss_score : float option; 45 + published : string; 46 + modified : string; 47 + affected_ranges : affected_range list; 48 + references : reference list; 49 + database_specific : string; 50 + } 51 + 52 + (* ── JSON parsing helpers ──────────────────────────────────────────────── *) 53 + 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. *) 56 + 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 -> "") 76 + 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 -> "") 97 + 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 105 + 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 120 + 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 129 + let severity = 130 + match cvss_score with Some s -> severity_of_cvss s | None -> Unknown 131 + in 132 + let aliases = extract_aliases json in 133 + { 134 + id; 135 + aliases; 136 + summary; 137 + details; 138 + severity; 139 + cvss_score; 140 + published; 141 + modified; 142 + affected_ranges = []; 143 + references = []; 144 + database_specific = "{}"; 145 + } 146 + 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 168 + 169 + (* ── HTTP helpers ──────────────────────────────────────────────────────── *) 170 + 171 + let post_json ~sw ~net ~clock url body_str = 172 + Log.debug (fun f -> f "POST %s" url); 173 + let headers = Requests.Headers.(empty |> content_type Requests.Mime.json) in 174 + let body = Requests.Body.text body_str in 175 + let resp = 176 + Requests.One.post ~sw ~clock ~net ~headers ~body ~verify_tls:true url 177 + in 178 + let status = Requests.Response.status_code resp in 179 + if status >= 200 && status < 300 then 180 + let text = Requests.Response.text resp in 181 + Ok text 182 + else 183 + let text = Requests.Response.text resp in 184 + Error (Format.asprintf "OSV API error %d: %s" status text) 185 + 186 + (* ── Queries ───────────────────────────────────────────────────────────── *) 187 + 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 + let query_purl ~sw ~net ~clock purl = 196 + let body = Format.asprintf {|{"package":{"purl":"%s"}}|} purl in 197 + match post_json ~sw ~net ~clock (api_base ^ "/query") body with 198 + | Error _ as e -> e 199 + | Ok json -> Ok (parse_response json) 200 + 201 + let query_package ~sw ~net ~clock ~ecosystem ~name ~version = 202 + let body = 203 + Format.asprintf 204 + {|{"package":{"name":"%s","ecosystem":"%s"},"version":"%s"}|} name 205 + ecosystem version 206 + in 207 + match post_json ~sw ~net ~clock (api_base ^ "/query") body with 208 + | Error _ as e -> e 209 + | Ok json -> Ok (parse_response json) 210 + 211 + let query_commit ~sw ~net ~clock hash = 212 + let body = Format.asprintf {|{"commit":"%s"}|} hash in 213 + match post_json ~sw ~net ~clock (api_base ^ "/query") body with 214 + | Error _ as e -> e 215 + | Ok json -> Ok (parse_response json) 216 + 217 + let query_batch ~sw ~net ~clock ~purls = 218 + (* OSV batch API: POST /v1/querybatch with {"queries": [...]} *) 219 + let queries = 220 + List.map 221 + (fun purl -> Format.asprintf {|{"package":{"purl":"%s"}}|} purl) 222 + purls 223 + in 224 + let body = Format.asprintf {|{"queries":[%s]}|} (String.concat "," queries) in 225 + match post_json ~sw ~net ~clock (api_base ^ "/querybatch") body with 226 + | Error msg -> 227 + Log.err (fun f -> f "batch query failed: %s" msg); 228 + 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 251 + 252 + (* ── Filtering ─────────────────────────────────────────────────────────── *) 253 + 254 + let severity_rank = function 255 + | Critical -> 4 256 + | High -> 3 257 + | Medium -> 2 258 + | Low -> 1 259 + | Unknown -> 0 260 + 261 + let filter_severity ~min vulns = 262 + let min_rank = severity_rank min in 263 + List.filter (fun v -> severity_rank v.severity >= min_rank) vulns 264 + 265 + let has_fix v = List.exists (fun r -> Option.is_some r.fixed) v.affected_ranges 266 + 267 + let cve_ids v = 268 + List.filter (fun s -> Astring.String.is_prefix ~affix:"CVE-" s) v.aliases
+141
lib/osv.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** OSV.dev vulnerability database client. 7 + 8 + Query the {{:https://osv.dev} OSV} (Open Source Vulnerabilities) database 9 + for known vulnerabilities by package URL, ecosystem, or commit hash. 10 + 11 + OSV aggregates data from NVD, GitHub Advisories, RustSec, PyPI, and dozens 12 + of other sources into a unified schema (OSV Schema v1.6). 13 + 14 + {2 Quick Start} 15 + 16 + {[ 17 + Eio_main.run @@ fun env -> 18 + Eio.Switch.run @@ fun sw -> 19 + let vulns = 20 + Osv.query_purl ~sw ~net:env#net ~clock:env#clock 21 + "pkg:npm/lodash@4.17.20" 22 + in 23 + List.iter 24 + (fun v -> 25 + Printf.printf "%s [%s]: %s\n" v.id 26 + (Osv.severity_to_string v.severity) 27 + v.summary) 28 + vulns 29 + ]} 30 + 31 + {2 API Reference} 32 + 33 + The OSV API ({{:https://osv.dev/docs/} docs}) provides: 34 + - Query by package (ecosystem + name + version) 35 + - Query by purl (Package URL, 36 + {{:https://github.com/package-url/purl-spec} spec}) 37 + - Query by commit hash 38 + - Batch queries for multiple packages *) 39 + 40 + (** {1 Types} *) 41 + 42 + type severity = 43 + | Critical (** CVSS >= 9.0 *) 44 + | High (** CVSS 7.0–8.9 *) 45 + | Medium (** CVSS 4.0–6.9 *) 46 + | Low (** CVSS 0.1–3.9 *) 47 + | Unknown (** No CVSS score available *) 48 + 49 + val severity_to_string : severity -> string 50 + (** [severity_to_string s] is ["CRITICAL"], ["HIGH"], etc. *) 51 + 52 + val severity_of_cvss : float -> severity 53 + (** [severity_of_cvss score] maps a CVSS v3 score to a severity level. *) 54 + 55 + type reference = { 56 + ref_type : string; (** ["WEB"], ["ADVISORY"], ["FIX"], ["PACKAGE"], etc. *) 57 + url : string; 58 + } 59 + (** A vulnerability reference link. *) 60 + 61 + type affected_range = { 62 + range_type : string; (** ["SEMVER"], ["ECOSYSTEM"], ["GIT"] *) 63 + introduced : string option; 64 + (** Version where the vulnerability was introduced. *) 65 + fixed : string option; (** Version where the vulnerability was fixed. *) 66 + } 67 + (** An affected version range. *) 68 + 69 + type vulnerability = { 70 + id : string; (** OSV identifier, e.g. ["GHSA-xxxx-yyyy-zzzz"]. *) 71 + aliases : string list; (** CVE IDs and other aliases. *) 72 + summary : string; (** One-line summary. *) 73 + details : string; (** Full description (may be empty). *) 74 + severity : severity; (** Derived from CVSS score when available. *) 75 + cvss_score : float option; (** CVSS v3 base score (0.0–10.0). *) 76 + published : string; (** RFC 3339 timestamp. *) 77 + modified : string; (** RFC 3339 timestamp. *) 78 + affected_ranges : affected_range list; 79 + references : reference list; 80 + database_specific : string; 81 + (** Raw JSON of database-specific fields (may be ["{}"]). *) 82 + } 83 + (** A vulnerability record from OSV. *) 84 + 85 + (** {1 Queries} *) 86 + 87 + val query_purl : 88 + sw:Eio.Switch.t -> 89 + net:_ Eio.Net.t -> 90 + clock:_ Eio.Time.clock -> 91 + string -> 92 + (vulnerability list, string) result 93 + (** [query_purl ~sw ~net ~clock purl] queries OSV by Package URL. 94 + 95 + Example purls: 96 + - ["pkg:npm/lodash@4.17.20"] 97 + - ["pkg:pypi/django@3.2.0"] 98 + - ["pkg:opam/irmin@3.0.0"] *) 99 + 100 + val query_package : 101 + sw:Eio.Switch.t -> 102 + net:_ Eio.Net.t -> 103 + clock:_ Eio.Time.clock -> 104 + ecosystem:string -> 105 + name:string -> 106 + version:string -> 107 + (vulnerability list, string) result 108 + (** [query_package ~sw ~net ~clock ~ecosystem ~name ~version] queries by 109 + ecosystem coordinates. 110 + 111 + Known ecosystems: ["npm"], ["PyPI"], ["Maven"], ["Go"], ["crates.io"], 112 + ["NuGet"], ["Packagist"], ["RubyGems"], ["Hex"], ["Pub"], ["opam"]. *) 113 + 114 + val query_commit : 115 + sw:Eio.Switch.t -> 116 + net:_ Eio.Net.t -> 117 + clock:_ Eio.Time.clock -> 118 + string -> 119 + (vulnerability list, string) result 120 + (** [query_commit ~sw ~net ~clock hash] queries by git commit hash. *) 121 + 122 + val query_batch : 123 + sw:Eio.Switch.t -> 124 + net:_ Eio.Net.t -> 125 + clock:_ Eio.Time.clock -> 126 + purls:string list -> 127 + (string * vulnerability list) list 128 + (** [query_batch ~sw ~net ~clock ~purls] queries multiple purls in one API call. 129 + Returns [(purl, vulns)] pairs. Errors are silently mapped to empty lists — 130 + check logs for details. *) 131 + 132 + (** {1 Filtering} *) 133 + 134 + val filter_severity : min:severity -> vulnerability list -> vulnerability list 135 + (** [filter_severity ~min vulns] keeps only vulns at or above [min]. *) 136 + 137 + val has_fix : vulnerability -> bool 138 + (** [has_fix v] is [true] if any affected range has a [fixed] version. *) 139 + 140 + val cve_ids : vulnerability -> string list 141 + (** [cve_ids v] extracts CVE-YYYY-NNNNN identifiers from [v.aliases]. *)
+37
osv.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "OSV.dev vulnerability database client" 4 + description: 5 + "Query the OSV (Open Source Vulnerabilities) database for known vulnerabilities by Package URL, ecosystem, or commit hash. Aggregates data from NVD, GitHub Advisories, RustSec, PyPI, and dozens of other sources." 6 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 7 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 8 + license: "ISC" 9 + homepage: "https://tangled.org/gazagnaire.org/ocaml-osv" 10 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-osv/issues" 11 + depends: [ 12 + "dune" {>= "3.21"} 13 + "ocaml" {>= "5.1"} 14 + "requests" {>= "0.1"} 15 + "eio" {>= "1.0"} 16 + "fmt" {>= "0.9"} 17 + "logs" {>= "0.7"} 18 + "astring" {>= "0.8"} 19 + "alcotest" {with-test} 20 + "odoc" {with-doc} 21 + ] 22 + build: [ 23 + ["dune" "subst"] {dev} 24 + [ 25 + "dune" 26 + "build" 27 + "-p" 28 + name 29 + "-j" 30 + jobs 31 + "@install" 32 + "@runtest" {with-test} 33 + "@doc" {with-doc} 34 + ] 35 + ] 36 + dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-osv" 37 + x-maintenance-intent: ["(latest)"]