Integration with BOMHort

Which API contract VEXViper relies on and why it is a sidecar.

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

CallPurposeClient method
GET /healthreadinessHealthy
GET /api/v1/sboms?page&page_size&searchenumerate 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}/vulnerabilitiesthe 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}/dependenciesdependency tree → package names; direct/transitive fallback for BOMHort < 0.8 or dependency_scope: unknownDependencies
GET /api/v1/sboms/{id}/downloadoriginal 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-Keyingest the generated document, scoped to the SBOM it describes (#350)UploadVEX
GET /api/v1/vex/statementsverify 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-… or GHSA-…, 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) and products[0].subcomponents[0].@id = products[0].subcomponents[0].identifiers.purl = finding.purl (BOMHort reads identifiers.purl first, 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=true with API_KEYS=… (or SERVICE_TOKEN) on the api-gateway;
  • the gateway must be able to write SBOM_DIR/pushed/ (or use the S3 skipScan bucket);
  • 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 3m polls /api/v1/vex/statements until the document’s statements appear.

3. Deployment next to BOMHort

deploy/helm/vexviper renders

  • mode: cronjob (default) — vexviper watch --once every 6 h, state and repo cache on an optional PVC. Idempotent: an SBOM is re-processed only when its vuln_count@ingested_at fingerprint changes or --regenerate is set. Settled verdicts (not_affected, fixed) and verdicts written by a human (vex_tooling not vexviper/…) are never re-sent to the provider unless --force is used.
  • mode: deployment — continuous poller.
  • mode: mcp — mcp-serve --transport http behind 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_affected on 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/fixed without deterministic evidence are downgraded to under_investigation unless explicitly allowed.
  • Upload is opt-in; the default is a reviewable file. author_role and status_notes are 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 heuristic nothing leaves the machine besides the git clone and OSV lookups.

5. Upstream issues (seebom-labs/BOMHort)

Filed as tracking epic BOMHort#338:

IssueFeatureStatus
#332source_repo / source_ref per SBOMlanded (BOMHort ≥ 0.7.0) — used by repo resolution, beats SBOM hints
#333since/cursor listing, vex_status=missing filteropen — watch still lists everything each pass
#335one row per (vuln_id, purl), latest statement wins, vex_timestamplanded (BOMHort ≥ 0.7.0) — re-triage TTL reads the row timestamp
#336idempotent upload + job statusopen — --wait still re-reads the vulnerabilities endpoint
#334statement provenance + automated/human badgelanded (BOMHort ≥ 0.7.0) — author/role/tooling/status_notes surfaced
#337outbound webhooksopen
#350per-SBOM statement scoping (?sbom_id= upload, vex_scope)landed (BOMHort ≥ 0.7.0) — VEXViper always uploads scoped
#412cve_id / aliases on findings, alias-aware VEX matchinglanded (BOMHort ≥ 0.8.0) — alias twins collapse to one finding; all OSV aliases emitted
#444dependency_scope / dependency_depth, ?scope= filterlanded (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_justification in the vulnerabilities API

BOMHort 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:

  1. Add a docs/integrations/vexviper page (I can open the PR).
  2. Return vex_justification, vex_status_notes and vex_updated_at alongside vex_status in /api/v1/sboms/{id}/vulnerabilities so 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).
  3. Make the ingestion result observable: an endpoint (or a field on the upload response) that reports whether a pushed *.openvex.json was applied and to how many findings — today clients must poll /api/v1/vex/statements.
  4. (Optional) accept X-Filename documents with a vexviper tooling 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.sboms pins remain the escape hatch);
  • GET /api/v1/sboms has no since/cursor and /vulnerabilities no vex_filter=missing, so watch must 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

RouteProgrammatic?Status
GitHub Models (provider: github) — https://models.github.ai/inference, OpenAI-compatible, auth Bearer <GitHub token>, header X-GitHub-Api-Versionyes (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 / agenticsupported 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:

KindSourceJustification the heuristic emits
version_fixedinstalled ≥ fixed_versionfixed
govulncheck_not_reachablegovulncheck symbol level (Go)vulnerable_code_not_in_execute_path
dev_dependencylockfile 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_founddirect 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 cratevulnerable_code_not_in_execute_path
symbol_not_referencedCargo only: RustSec affects.functions present, crate used by product code, sole consumer, no function name/path referencedvulnerable_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 --repo or a repo.sboms pin 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 carry not_affected; everything else ends as under_investigation without 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 affected without govulncheck reachability.