Integration with BOMHort
Maintained in seebom-labs/VEXViper — edit it there.
This document records how VEXViper couples to BOMHort, which API contract it relies on, and what an upstream integration could look like.
1. Integration model
BOMHort (backend/, Go 1.25, stdlib HTTP, ClickHouse) exposes no plugin mechanism:
no Go plugin loading, no gRPC/webhook hooks, no MCP. Its contribution guidelines require
stdlib-only tests, discourage frameworks and ask contributors to ask first before adding
Go dependencies. VEXViper needs openvex/go-vex, the MCP Go SDK, packageurl-go and
yaml.v3 (plus the stdlib-only bomhort-go client), so the correct integration is an out-of-tree sidecar speaking the public REST
API (docs/api-reference calls this “custom tooling”). Benefits:
- zero changes to BOMHort; survives the announced 1.0 API freeze;
- independent release cadence and security review of LLM code;
- deployable next to BOMHort (CronJob/Deployment) or on a developer laptop (stdio MCP).
2. API contract used
| Call | Purpose | Client method |
|---|---|---|
GET /health | readiness | Healthy |
GET /api/v1/sboms?page&page_size&search | enumerate SBOMs (watch mode, --sbom lookup by id / document_name / source_file); source_repo/source_ref feed repo resolution (#332) | ListSBOMs, AllSBOMs, FindSBOM |
GET /api/v1/sboms/{id}/vulnerabilities | the findings: vuln_id, purl, severity, summary, fixed_version, vex_status (+ vex_timestamp, vex_justification, vex_scope since #335/#350; dependency_scope is the authoritative direct/transitive flag since 0.8.0 #444) | Vulnerabilities |
GET /api/v1/sboms/{id}/dependencies | dependency tree → package names; direct/transitive fallback for BOMHort < 0.8 or dependency_scope: unknown | Dependencies |
GET /api/v1/sboms/{id}/download | original SBOM → repository hints only (VCS external refs, root PURLs, main Go module) | DownloadSBOM |
POST /api/v1/sboms/upload?sbom_id={id} + X-Filename: <name>.openvex.json + X-API-Key | ingest the generated document, scoped to the SBOM it describes (#350) | UploadVEX |
GET /api/v1/vex/statements | verify ingestion (--wait) | AllVEXStatements, ListVEXStatements |
Rate limit (100 req / 10 s) is respected by the client’s paging and there is no polling
tighter than --wait’s 2 s interval.
Matching rules that shape the output
BOMHort applies a VEX statement to a finding when statement.vuln_id == finding.vuln_id
and statement.product_purl == finding.purl — plain string equality. Therefore every
statement VEXViper emits:
- uses
vulnerability.name = finding.vuln_id(exactly as BOMHort returned it, e.g.GO-2025-…orGHSA-…, whatever BOMHort chose as primary id); - uses the OpenVEX spec shape with exactly one product and one subcomponent:
products[0].@id = <BOMHort sbom_id>(the product is the SBOM) andproducts[0].subcomponents[0].@id = products[0].subcomponents[0].identifiers.purl = finding.purl(BOMHort readsidentifiers.purlfirst, then@id; the subcomponent purl is what it matches); - never normalises, re-encodes or re-qualifies the PURL.
The subcomponent matters: BOMHort ≥ 0.7 treats a product without subcomponents as
product-wide (product_purl = "*") once it is scoped, i.e. the verdict would cover every
package in the SBOM with that vulnerability ID and the newest statement would win for all of
them — a fixed for x/net@v0.31.0 would hide the still-vulnerable x/net@v0.30.0.
Earlier VEXViper versions emitted products[0].@id = finding.purl without subcomponents and
are affected by this; re-run generate --force --upload once per affected SBOM so
every finding gets a newer, per-package statement that outranks the old product-wide ones.
The product @id is the SBOM UUID rather than the document name or repository because only
the UUID resolves to exactly one SBOM when a document is uploaded without ?sbom_id=
(e.g. upload_vex with a path, or a document published via GitOps and ingested later).
Since BOMHort #350 a statement is additionally scoped to one SBOM: VEXViper always
uploads with ?sbom_id=<sbom uuid> so its verdicts (reachability claims about one
product) never suppress the same CVE fleet-wide. Gateways ≤ 0.6.1 ignore the parameter and
fall back to global statements.
Only OpenVEX is supported by BOMHort, hence only OpenVEX is emitted.
Since #335, GET /sboms/{id}/vulnerabilities returns exactly one row per (vuln_id, purl) — the statement with the newest vex_timestamp wins, and the row carries
vex_timestamp/vex_justification/vex_statement_id. VEXViper uses the row timestamp
directly for the re-assessment TTL. Older gateways emitted one row per matching statement;
VEXViper still dedupes defensively by (vuln_id, purl) and falls back to paging
/api/v1/vex/statements when rows carry no vex_timestamp.
Upload requirements
AUTH_ENABLED=truewithAPI_KEYS=…(orSERVICE_TOKEN) on the api-gateway;- the gateway must be able to write
SBOM_DIR/pushed/(or use the S3skipScanbucket); - the ingestion-watcher picks the file up and the parsing-worker applies it; typical
latency in the E2E stack is 5–20 s.
vexviper generate --upload --wait 3mpolls/api/v1/vex/statementsuntil the document’s statements appear.
3. Deployment next to BOMHort
deploy/helm/vexviper renders
mode: cronjob(default) —vexviper watch --onceevery 6 h, state and repo cache on an optional PVC. Idempotent: an SBOM is re-processed only when itsvuln_count@ingested_atfingerprint changes or--regenerateis set. Settled verdicts (not_affected,fixed) and verdicts written by a human (vex_toolingnotvexviper/…) are never re-sent to the provider unless--forceis used.mode: deployment— continuous poller.mode: mcp—mcp-serve --transport httpbehind a ClusterIP Service so agents/LLM hosts in the cluster can run interactive triage.
Secrets: one Secret with api-key (BOMHort) and optionally openai-api-key.
Release branches and BOMHort compatibility
VEXViper releases are cut from release/vX.Y branches with RC tags first; see
docs/RELEASING.md. The release workflow also runs E2E on pushes and PRs to
release/**. Keep that branch’s .github/workflows/e2e.yml BOMHORT_PINNED_REF at the
BOMHort commit the minor was validated against; only advance it on the branch when a patch
intentionally depends on newer BOMHort API behaviour. Dependabot targets main only, so
security dependency updates for a supported release branch should be cherry-picked after
they merge on main.
4. Safety posture
- Default provider is heuristic; it can only produce
not_affectedon strong deterministic evidence: govulncheck proves the vulnerable symbol unreachable, the lockfile resolves the package outside the runtime closure, or the lockfile graph proves product code is the package’s only consumer and it never imports it / never references the RustSec vulnerable functions (see “Strong evidence” below). LLM providers are opt-in. - LLM claims of
not_affected/fixedwithout deterministic evidence are downgraded tounder_investigationunless explicitly allowed. - Upload is opt-in; the default is a reviewable file.
author_roleandstatus_notesare transparent about automation and confidence. - Review-first GitOps (
vex.git): documents are committed to a repository and proposed as one pull request per product; humans merge, BOMHort (or a CI job with--upload) ingests the merged files. Review branches are rebuilt from the base branch every run, so a PR always shows the current draft, and identical content produces no commit. - No SBOM content is sent anywhere except to the configured provider; with
heuristicnothing leaves the machine besides the git clone and OSV lookups.
5. Upstream issues (seebom-labs/BOMHort)
Filed as tracking epic BOMHort#338:
| Issue | Feature | Status |
|---|---|---|
| #332 | source_repo / source_ref per SBOM | landed (BOMHort ≥ 0.7.0) — used by repo resolution, beats SBOM hints |
| #333 | since/cursor listing, vex_status=missing filter | open — watch still lists everything each pass |
| #335 | one row per (vuln_id, purl), latest statement wins, vex_timestamp | landed (BOMHort ≥ 0.7.0) — re-triage TTL reads the row timestamp |
| #336 | idempotent upload + job status | open — --wait still re-reads the vulnerabilities endpoint |
| #334 | statement provenance + automated/human badge | landed (BOMHort ≥ 0.7.0) — author/role/tooling/status_notes surfaced |
| #337 | outbound webhooks | open |
| #350 | per-SBOM statement scoping (?sbom_id= upload, vex_scope) | landed (BOMHort ≥ 0.7.0) — VEXViper always uploads scoped |
| #412 | cve_id / aliases on findings, alias-aware VEX matching | landed (BOMHort ≥ 0.8.0) — alias twins collapse to one finding; all OSV aliases emitted |
| #444 | dependency_scope / dependency_depth, ?scope= filter | landed (BOMHort ≥ 0.8.0) — authoritative direct/transitive flag, tree walk is the fallback |
The original proposal text is kept below for context.
Title: Document VEXViper as a VEX-generation companion; expose
vex_justificationin the vulnerabilities APIBOMHort can consume OpenVEX but has no way to produce it, so every finding stays effective until a human writes VEX. VEXViper is an out-of-tree Go sidecar that reads
/api/v1/sboms/{id}/vulnerabilities, resolves the product repository, runs govulncheck/collects evidence, asks a configurable assessment provider (rules / OpenAI-compatible / MCP tool) and uploads a go-vex-validated OpenVEX document via/api/v1/sboms/upload. Verified against BOMHort’s own 0.6.1 SBOM (9/9 statements applied).Proposals:
- Add a
docs/integrations/vexviperpage (I can open the PR).- Return
vex_justification,vex_status_notesandvex_updated_atalongsidevex_statusin/api/v1/sboms/{id}/vulnerabilitiesso overlays (#255) and reviewers can see why and when a statement was applied (and re-triage tools can age them without paging/api/v1/vex/statements).- Make the ingestion result observable: an endpoint (or a field on the upload response) that reports whether a pushed
*.openvex.jsonwas applied and to how many findings — today clients must poll/api/v1/vex/statements.- (Optional) accept
X-Filenamedocuments with avexvipertooling marker in the “companion OpenVEX” export planned in #255 so generated and hand-written statements can be distinguished.
6. Re-running VEX generation over time
BOMHort periodically refreshes its OSV database and recomputes findings, but it has no
scheduler or hook for re-triage — an applied statement stays until a newer one for the
same (vuln_id, purl) is ingested. VEXViper therefore owns the re-run policy
(watch.reassess_after, see README “Re-running over time”): fingerprint changes trigger
assessment of new findings; a TTL re-opens under_investigation/affected verdicts using
the row-level vex_timestamp (#335; paging /api/v1/vex/statements remains as fallback for
gateways ≤ 0.6.1). Because BOMHort resolves conflicts by newest timestamp, re-uploads are
idempotent.
Scale: many SBOMs, few distinct questions
A BOMHort instance with 15 000 SBOMs typically describes a few hundred product builds.
VEXViper’s assessment cache (cache.*, README “Scaling to thousands of SBOMs”) keys verdicts
by provider, product commit, finding and an evidence fingerprint, so each distinct question
is paid for once and reused for every SBOM that shares the build. What limits this today is
BOMHort’s data model, not VEXViper:
- per-SBOM source repository / commit now exists upstream (
source_repo/source_ref, #332) and VEXViper prefers it; for SBOMs ingested without it, repo resolution still falls back to PURLs and SBOM hints (repo.sbomspins remain the escape hatch); GET /api/v1/sbomshas nosince/cursor and/vulnerabilitiesnovex_filter=missing, sowatchmust list everything each pass.
SBOM producers can help: include the VCS URL and exact commit (SPDX ExternalRef
SECURITY/OTHER + packageSourceInfo, CycloneDX externalReferences[type=vcs] +
pedigree.commits), build metadata (Go version, build tags) and a stable product identifier.
Until those land, VEXViper compensates client-side: watch.concurrency runs several SBOMs per
pass in parallel while bomhort.rate_limit (default 90 / 10 s) keeps the client under the
gateway limit, and --wait verifies after upload — by re-reading
/api/v1/sboms/{id}/vulnerabilities — which statements BOMHort actually applied, which were
overridden by a newer (human) statement and which never matched (the poor man’s #336).
Cost visibility
Every run reports provider usage (calls, tokens, Copilot premium requests, model, cache hits)
per finding, per SBOM and cumulatively in the watch state file; see README “Cost tracking”.
llm.budget caps spend per run / pass (deferred findings simply stay open in BOMHort and are
retried next pass), and watch.listen exposes everything as Prometheus metrics plus /healthz.
7. Using a GitHub Copilot subscription as LLM source
| Route | Programmatic? | Status |
|---|---|---|
GitHub Models (provider: github) — https://models.github.ai/inference, OpenAI-compatible, auth Bearer <GitHub token>, header X-GitHub-Api-Version | yes (CI, CronJob) | supported; billed to the GitHub/Copilot plan; models like openai/gpt-4.1, openai/o4-mini, meta/llama-…. Token: fine-grained PAT with models: read or Actions GITHUB_TOKEN with permissions: models: read. |
Copilot CLI, non-interactive (provider: copilot) — copilot -p <prompt> -s --no-ask-user --deny-tool=shell/write/edit; auth = the CLI’s login (/login, gh auth login, GH_TOKEN/COPILOT_GITHUB_TOKEN) | yes (laptop, CI runner with a logged-in CLI) | supported & verified; ~15 s/finding; official scripting mode of the Copilot CLI, covered by the Copilot seat. in_repo: true lets it read the product checkout. |
Copilot as MCP host — VS Code Copilot Chat / Copilot CLI / Copilot coding agent calls vexviper mcp-serve tools (list_findings → get_repo_context → model reasons → draft_vex → upload_vex) | interactive / agentic | supported today; the Copilot model does the assessment inside the host, human in the loop. |
Copilot Chat internal API (api.githubcopilot.com via token exchange) | — | not implemented: undocumented, ToS restricts to Copilot clients, breaks without notice. |
GitHub Actions example:
permissions: { contents: read, models: read }
steps:
- run: vexviper generate --provider github --sbom ${{ github.event.repository.name }}-${{ github.ref_name }} --upload
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BOMHORT_API_KEY: ${{ secrets.BOMHORT_API_KEY }}
VEXVIPER_BOMHORT_URL: https://bomhort.example.com
8. BOMHort Go client: bomhort-go
The REST client and the bomhorttest fake gateway started as internal/bomhort and now live
in their own stdlib-only module, github.com/seebom-labs/bomhort-go,
owned by seebom-labs and tested against real BOMHort releases (pinned) and BOMHort main
(weekly API-drift job). VEXViper imports it as bomhort; the client methods in the table in
§2 are bomhort-go methods. Client fixes and new BOMHort fields/endpoints belong upstream in
bomhort-go — VEXViper only bumps the dependency.
9. Strong evidence
not_affected is only accepted with at least one strong evidence item. Strong means an
exact fact from a tool or the package manager, never a lexical guess:
| Kind | Source | Justification the heuristic emits |
|---|---|---|
version_fixed | installed ≥ fixed_version | fixed |
govulncheck_not_reachable | govulncheck symbol level (Go) | vulnerable_code_not_in_execute_path |
dev_dependency | lockfile resolves the package outside the runtime closure: npm dev: true, pnpm dev, composer packages-dev, poetry category/groups, uv dev-dependencies, Pipfile.lock develop, Gradle test-only configurations, or complete graph + root dev split (yarn, Cargo single-crate, Gemfile groups) | vulnerable_code_not_present |
import_not_found | direct dependency, lockfile graph shows no other dependents, verbatim import syntax (npm, Cargo, Composer PSR-4, Dart), ≥1 non-test source scanned, no import, not in npm scripts / not a renamed crate | vulnerable_code_not_in_execute_path |
symbol_not_referenced | Cargo only: RustSec affects.functions present, crate used by product code, sole consumer, no function name/path referenced | vulnerable_code_not_in_execute_path |
Not strong, by design: manifest-only dev declarations (the lockfile may still pull the
package into the runtime closure), import misses for ecosystems where the import name is
inferred (PyPI dist→module mapping, Java packages from groupId/artifactId, NuGet
namespaces) or where frameworks load packages without an import (Bundler.require,
Django INSTALLED_APPS), dependency_path, package_imported, manifest_not_found.
What VEXViper deliberately does not run: osv-scanner --call-analysis (needs
cargo build, i.e. arbitrary build.rs execution in an untrusted checkout), Maven/Gradle
dependency resolution, npm install. The clone stays read-only apart from govulncheck.
10. Known limitations
- Repository resolution depends on SBOM quality: syft
dir:SBOMs of Go repos resolve (VCS ref / main module);pkg:generic/<name>@<ver>roots without VCS refs need--repoor arepo.sbomspin in the config (matched by SBOM id / document name / source file). - Call-graph reachability (govulncheck) covers Go only. npm, PyPI, Cargo, RubyGems,
Composer, Maven, NuGet and Dart products get version evidence, OSV context, the lockfile
graph (
dependency_path,dev_dependency), manifest depth and a source import scan (package_imported,import_not_found, test paths excluded); Cargo additionally a lexical check for RustSec’s vulnerable functions. Elixir/Hex and Swift/SPM currently contribute manifest/lockfile presence only. Only the exact facts listed under “Strong evidence” can carrynot_affected; everything else ends asunder_investigationwithout an LLM, with a confidence that reflects the evidence. Advisories for npm, PyPI, Maven, NuGet and Composer carry no vulnerable-function data, so function-level reachability is impossible there regardless of tooling. - The heuristic provider never claims
affectedwithout govulncheck reachability.