Overview

Quick start, install, configuration, guardrails and the MCP server.

Maintained in seebom-labs/VEXViper — edit it there.

LLM-assisted OpenVEX generation for BOMHort.

BOMHort ingests SBOMs, matches them against OSV and applies OpenVEX statements — but it cannot produce VEX. Every CVE stays “effective” until somebody hand-writes an OpenVEX document. VEXViper closes that loop:

BOMHort findings ──► resolve product repo ──► clone + govulncheck + evidence
      ▲                                              │
      │                                              ▼
      └──── upload ◄── validated OpenVEX ◄── assessment (heuristic | OpenAI-compatible | MCP tool)
  1. reads the vulnerability findings BOMHort already computed for an SBOM (vuln_id, purl, fixed_version, vex_status, …) — BOMHort is the single source of truth, VEXViper does not rescan anything;
  2. resolves the product’s source repository (from the SBOM’s VCS metadata / PURLs, or --repo owner/name@ref), clones it shallowly and gathers deterministic evidence: installed vs fixed version, direct/transitive depth, govulncheck reachability for Go products, lockfile-graph / manifest / import evidence for npm, PyPI, Cargo, RubyGems, Composer, Maven, NuGet and Dart products (see Evidence per ecosystem), RustSec vulnerable-function references for Rust, Go symbol references, OSV details;
  3. asks an assessment provider for status/justification/confidence/reasoning per finding — a rules-only heuristic (default, offline), any OpenAI-compatible endpoint, GitHub Models or the GitHub Copilot CLI (your Copilot subscription), or a tool on a configurable MCP server;
  4. applies guardrails and emits an OpenVEX document built with openvex/go-vex whose every statement passes Statement.Validate() and reuses BOMHort’s (vuln_id, purl) verbatim so BOMHort’s exact-match join applies it;
  5. optionally uploads it through POST /api/v1/sboms/upload and waits until BOMHort shows the new vex_status.

It is also an MCP server itself, so an LLM host (Claude Desktop, VS Code/Copilot, an agent) can drive or review the triage interactively.

Status

Requires BOMHort ≥ 0.7.0 (per-SBOM VEX scoping, one row per (vuln_id, purl), source_repo). Older gateways accept uploads but ignore the scoping parameter.

examples/bomhort-0.6.1.openvex.json was generated end-to-end against a real BOMHort instance from BOMHort’s own release SBOM: 10 findings → 10 statements → BOMHort applied all 10 (6× not_affected backed by govulncheck, 4× under_investigation for npm components where no reachability evidence exists). Each statement names the SBOM as product and the finding’s package as subcomponent, so verdicts stay per package (GHSA-hh8m-fm6v-7cvg hits both @angular/core and @angular/compiler). See E2E.

Why a sidecar over REST (and not an in-tree plugin)

Fact about BOMHortConsequence
No plugin API (no Go plugins, gRPC hooks, webhooks, MCP) — the REST API is the only extension surfaceVEXViper is an out-of-tree Go module talking to /api/v1
Contribution policy: stdlib-only, “ask first” for new dependencies, no frameworksgo-vex + MCP SDK cannot go into the core module
VEX ↔ vulnerability join is exact string equality on (vuln_id, product_purl)statements copy the API’s purl/vuln_id; product @id = BOMHort SBOM ID, subcomponent = the finding’s PURL
Upload needs AUTH_ENABLED=true, X-API-Key, X-Filename: *.openvex.json, writable SBOM_DIR/pushed/--upload is opt-in; default is a draft file
Issue #255 plans a companion-OpenVEX exportVEXViper’s output is exactly that overlay, generated instead of hand-written

Details: docs/INTEGRATION.md.

Runtime model — one binary, three modes

ModeCommandWhenDeploy as
One-shotvexviper generate --sbom … [--repo …] [--upload]CI job after publishing an SBOM, manual triage, make vexbinary / container step
Watchervexviper watch [--once] [--upload]keep every SBOM in BOMHort covered; re-runs when an SBOM’s vuln_count@ingested_at fingerprint changesKubernetes CronJob (--once, recommended) or Deployment via the Helm chart
MCP servervexviper mcp-serve --transport stdio|httplet a host LLM/agent list findings, fetch evidence, draft, and upload VEX with a human in the loopstdio from an IDE / Claude Desktop, or HTTP Service in the cluster

Recommendation: run the CronJob with the heuristic provider first (no keys, no false not_affected without govulncheck evidence), review drafts, then switch llm.provider to openai or mcptool and enable vex.upload.

Quick start

How-to guides: run as a CronJob · review drafts in pull requests · set up an LLM provider.

make build                                   # → bin/vexviper (needs Go ≥ 1.26 (image uses 1.27), git; govulncheck optional)

# assess one SBOM already known to BOMHort, write ./bomhort-0.6.1.vexviper.openvex.json
bin/vexviper generate --bomhort http://localhost:8080 --sbom bomhort-0.6.1

# repository cannot be derived from the SBOM? tell VEXViper (one-shot) …
bin/vexviper generate --sbom kubelb-1.4.2 --repo kubermatic/kubelb@v1.4.2
# … or pin it permanently in vexviper.yaml so watch/mcp-serve know it too:
#   repo:
#     sboms:
#       - match: "kubelb-*"          # glob on SBOM id | document_name | source_file
#         repo: kubermatic/kubelb    # ref defaults to the version in the SBOM name (v1.4.2)

# push it back, wait until BOMHort ingested it and report which statements were
# applied / overridden by a newer statement / not matched
BOMHORT_API_KEY=… bin/vexviper generate --sbom bomhort-0.6.1 --upload --wait 3m

# watch everything, once (CronJob style)
bin/vexviper watch --config examples/vexviper.yaml --once --upload

# long-running watcher: 4 SBOMs in parallel, Prometheus metrics + health on :9090
bin/vexviper watch --config vexviper.yaml --upload --concurrency 4 --listen :9090

# be an MCP server for your LLM host
bin/vexviper mcp-serve --config vexviper.yaml                      # stdio
bin/vexviper mcp-serve --transport http --addr 127.0.0.1:8765       # streamable HTTP at /mcp

Output filename: <sbom-name>.vexviper.openvex.json. Findings that already carry a vex_status are skipped by default. --regenerate re-assesses them except settled verdicts (not_affected, fixed) — no provider tokens are spent on findings that are already closed, and except human verdicts — a statement whose vex_tooling is not vexviper/… was written by a person or another tool and is never overwritten by a bot. --force is the hard regenerate that revisits everything. --only CVE-…,GHSA-… restricts the run. On BOMHort ≥ 0.8 a statement applies to every alias of a vulnerability, so alias twins of one package (GO-… and its CVE-…) are assessed once and emitted once.

Re-running over time

BOMHort refreshes OSV data but has no notion of “re-triage”; VEXViper owns that:

  • new findings — watch re-runs an SBOM when its vuln_count@ingested_at fingerprint changes; only findings without a vex_status are assessed;
  • expiring verdicts — watch.reassess_after: 168h (or --reassess-after 168h) re-runs each SBOM at least that often and re-assesses under_investigation / affected findings whose newest BOMHort statement is older than the TTL (new evidence: fixed versions, govulncheck DB updates, better model). not_affected / fixed are stable claims and are only revisited with --force;
  • a new statement for the same (vuln_id, purl) supersedes the old one in BOMHort (latest vex_timestamp wins), so re-runs are idempotent.

Scaling to thousands of SBOMs: the assessment cache

The number of SBOMs is not the cost driver — the number of distinct (product commit, vulnerability, package) questions is, and that is orders of magnitude smaller (the same build ships to many clusters; consecutive versions share most findings). VEXViper therefore caches every provider verdict in cache.dir (default <repo.cache_dir>/assessments, one JSON file per entry) keyed by

provider+model · product commit (or repo@ref) · vuln_id · purl · evidence fingerprint · prompt version

The evidence fingerprint covers the evidence kinds and strength (e.g. govulncheck_not_reachable), package/fixed versions and the OSV record’s modified timestamp — so a govulncheck DB update or a new commit invalidates the entry automatically, while a re-clone to another path does not. Cache hits cost nothing and are marked cached=true in logs, Cached in the MCP output and counted in the usage summary. --regenerate, --force and --no-cache bypass cache reads (fresh verdicts are still written). cache.ttl (default: never) forces periodic re-asking; cache.enabled: false turns it off. Mount repo.cache_dir on a PVC so the cache survives CronJob runs.

Cost tracking (TokenOps)

Every assessment carries a Usage record: provider calls, prompt/completion tokens (OpenAI-compatible and GitHub Models), premium requests and output tokens (Copilot CLI, its billing unit — e.g. claude-haiku-4.5 costs 0.33 per call), the answering model, wall time and cache hits. It is logged per finding and per SBOM (msg="provider usage"), printed in the CLI summary (provider usage: 9 calls, 8.1k output tokens, 2.97 premium requests, model=claude-haiku-4.5), returned by the generate_vex MCP tool and accumulated in the watch state file (usage = lifetime total, last_pass_usage = most recent pass), so the cost of automated triage is visible without any external metering.

Budget. llm.budget.{max_calls,max_tokens,max_premium_requests} caps what a run (CLI) or a single watch pass may spend. Once a limit is hit the remaining findings are deferred: they get no statement (so they stay visible as open in BOMHort), the SBOM is not marked processed, and the next pass — with a fresh budget — picks them up. Deferred counts show up in the summary, the MCP result and vexviper_findings_deferred_total. Cache hits are free and never count.

Metrics. vexviper watch --listen :9090 (or watch.listen) serves GET /metrics (Prometheus text format: runs, findings, statements by status, provider calls/tokens/premium requests, cache hits, watch passes, last-pass timestamp) and GET /healthz (503 once no pass finished within 3× the interval). The Helm chart wires both when metrics.enabled is set.

Throughput: concurrency and rate limiting

A watch pass processes SBOMs serially by default. watch.concurrency: 4 (or --concurrency) runs several pipelines in parallel — budget, cache and repo checkouts are shared and safe; clones of the same repository are serialized. BOMHort’s gateway allows ~100 requests / 10 s per client IP, so the client paces itself with bomhort.rate_limit: 90 per bomhort.rate_window: 10s (default; 0 disables) and still backs off on 429.

Install

Every release tag is cut from a release/vX.Y branch. Tags vX.Y.Z and pre-releases such as vX.Y.Z-rc.N publish binaries (linux/darwin, amd64/arm64, SPDX SBOM, checksums) on the releases page, a multi-arch, cosign-signed image and the Helm chart as OCI artifact. Final releases also move the stable X.Y and latest image tags; RCs only publish their exact version. main is available as :main. See docs/RELEASING.md for branch cuts, RCs, backports and patch releases.

# container (ships git + Go toolchain + govulncheck)
docker run --rm -v $PWD/work:/work -e VEXVIPER_BOMHORT_URL=http://host:8080 \
  ghcr.io/seebom-labs/vexviper:latest generate --sbom …
cosign verify ghcr.io/seebom-labs/vexviper:latest \
  --certificate-identity-regexp 'https://github.com/seebom-labs/VEXViper/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

# Helm (CronJob by default; mode=deployment adds /metrics + probes, mode=mcp a Service)
helm install vexviper oci://ghcr.io/seebom-labs/charts/vexviper --version 0.8.0 \
  --set bomhort.url=http://bomhort-api-gateway:8080 --set mode=cronjob

# explicit RC install (RCs never move latest or the stable X.Y image tag)
helm install vexviper oci://ghcr.io/seebom-labs/charts/vexviper --version 0.8.0-rc.1

# from source
go install github.com/seebom-labs/vexviper/cmd/vexviper@latest   # or: make build → bin/vexviper

Configuration

--config vexviper.yaml (see the fully commented examples/vexviper.yaml), every key overridable by VEXVIPER_<SECTION>_<KEY> and secrets via *_env indirections:

KeyEnvDefaultMeaning
bomhort.urlVEXVIPER_BOMHORT_URLhttp://localhost:8080BOMHort API gateway
bomhort.api_key_envBOMHORT_API_KEYX-API-Key for uploads
llm.providerVEXVIPER_LLM_PROVIDERheuristicheuristic | openai | github | copilot | mcptool
llm.min_confidenceVEXVIPER_LLM_MIN_CONFIDENCE0.6below → under_investigation
llm.budget.{max_calls,max_tokens,max_premium_requests}VEXVIPER_LLM_BUDGET_*unlimitedspend cap per run / watch pass; beyond it findings are deferred
bomhort.{rate_limit,rate_window}VEXVIPER_BOMHORT_RATE_*90, 10sclient-side pacing below BOMHort’s gateway limit
llm.openai.{base_url,model,api_key_env}VEXVIPER_OPENAI_*OpenAI / gpt-4o-miniany OpenAI-compatible endpoint (Azure, GitHub Models, Ollama, vLLM, LiteLLM)
llm.github.{base_url,model,token_env}VEXVIPER_GITHUB_* / GITHUB_TOKENGitHub Models / openai/gpt-4.1-miniGitHub Models inference
llm.copilot.{command,model,args,in_repo,timeout}VEXVIPER_COPILOT_*copilot, 180sCopilot CLI non-interactive mode
llm.mcp.{transport,command,args,url,tool}VEXVIPER_MCP_*stdio / assess_vulnerabilitythe MCP server + tool VEXViper calls
repo.{cache_dir,override,clone,govulncheck}VEXVIPER_REPO_*.vexviper-cache, clone+govulncheck onproduct repo handling
repo.sboms[]{match,repo}—per-SBOM repository pins (glob match, {version} placeholder)
vex.{author,author_role,supplier,namespace,out_dir,upload,regenerate}VEXVIPER_VEX_*VEXViper, automated triage (LLM-assisted)document metadata & output
vex.git.{enabled,repo,branch,path,branch_prefix,pr,token_env,sign_off}VEXVIPER_VEX_GIT_* / GITHUB_TOKENoff, main, vex/, vexviper/, PR onGitOps review mode: commit documents, open a PR per product
watch.{interval,state_file,reassess_after}VEXVIPER_WATCH_*15m, TTL offpoller & periodic re-assessment
watch.{concurrency,listen}VEXVIPER_WATCH_CONCURRENCY, VEXVIPER_WATCH_LISTEN1, offparallel SBOMs per pass; /metrics + /healthz address
cache.{enabled,dir,ttl}VEXVIPER_CACHE_*on, <repo.cache_dir>/assessments, neverassessment cache shared across SBOMs
timeoutVEXVIPER_TIMEOUT30mper SBOM

Where does the product repository come from?

Precedence: --repo flag / repo tool argument → repo.override → repo.sboms match → BOMHort’s source_repo/source_ref SBOM attributes (BOMHort ≥ #332) → VCS external refs and main module in the SBOM → root PURLs. Missing refs are filled with the version parsed from the SBOM name. list_sboms (MCP) shows configured_repo so an agent can see which SBOMs still need a pin.

Assessment providers

  • heuristic (default) — no network, no key. fixed when installed ≥ fixed_version; not_affected/vulnerable_code_not_in_execute_path (0.85) when govulncheck finds no reachable symbol; affected (0.9) when it does; not_affected/vulnerable_code_not_present (0.8) when the lockfile resolves the package outside the runtime closure; not_affected/vulnerable_code_not_in_execute_path when product code is provably the package’s only consumer and never references the RustSec vulnerable functions (0.75) or never imports the package at all (0.7); otherwise under_investigation — with a calibrated confidence and cited evidence (dev-only dependency never imported 0.6, imported package 0.45 …) so reviewers can sort drafts.

  • openai — POST {base_url}/chat/completions with JSON-schema structured output, temperature 0. System prompt in internal/llm/prompt.go frames a conservative analyst and forbids inventing evidence.

  • github — GitHub Models (https://models.github.ai/inference, OpenAI dialect) with a GitHub token (GITHUB_TOKEN; fine-grained PAT with models: read, or the Actions token with permissions: models: read). Usage is billed to your GitHub / Copilot plan — this is the supported way to use a Copilot subscription programmatically. See Copilot / GitHub Models.

  • copilot — runs the GitHub Copilot CLI non-interactively (copilot -p <prompt> -s --no-ask-user --deny-tool=shell,write,edit [--model …]) and parses the JSON answer. Uses your Copilot subscription with no API key: log in once with copilot → /login, or gh auth login, or export GH_TOKEN=$(gh auth token). With in_repo: true Copilot runs inside the cloned product repo and may read code (never write). ~15 s per finding; verified on kubelb 1.4.2 (not_affected 0.88 with govulncheck evidence, affected 0.95 with call path to idna.ToASCII).

  • mcptool — VEXViper connects as MCP client (stdio command/args or streamable HTTP url) and calls tool with

    {"system_prompt": "...", "prompt": "<rendered evidence>", "request": {<finding+evidence>}, "schema": {<assessment JSON schema>}}
    

    and accepts either structuredContent or a JSON text result (code fences tolerated). This lets you plug in any model behind a server you control (Bedrock, on-prem, an agent).

Non-heuristic providers fall back to the heuristic on error, so a run never fails because the model is unreachable.

Assessment schema: status, justification, impact_statement, action_statement, confidence (0–1), reasoning, evidence_refs[].

Guardrails (always on)

  • confidence < min_confidence → under_investigation;
  • not_affected requires at least one strong deterministic evidence item unless allow_unsupported_not_affected: true. Strong items are exact facts, never lexical guesses: govulncheck_not_reachable, version_fixed, a lockfile-resolved dev_dependency, and — only when the lockfile graph proves product code is the package’s sole consumer — import_not_found (npm, Cargo, Composer, Dart) and Rust symbol_not_referenced. All other manifest/import evidence (package_imported, dependency_path, manifest_not_found, inferred-import ecosystems) only shows how a package is declared and imported, not whether the vulnerable code executes. Details in Evidence per ecosystem;
  • fixed requires a known fixed_version ≤ installed version;
  • every statement passes go-vex Statement.Validate(); status_notes records provider, confidence and reasoning; tooling records vexviper/<version> provider=<name>.

Evidence per ecosystem

govulncheck is the only build-free call-graph tool VEXViper can run (osv-scanner’s Rust call analysis needs cargo build, which would execute the checkout’s build scripts; no comparable free tool exists for JVM, npm, PyPI, NuGet). Everywhere else the evidence comes from the package manager’s lockfile graph plus a source scan, and only exact facts are marked strong:

EcosystemLockfile graph (paths, dependents)Dev-only (strong)Import scanStrong import_not_foundVulnerable symbols
Gogovulncheck–––govulncheck call graph (strong)
npmpackage-lock v1/v2/v3, yarn v1/berry, pnpmnpm dev: true, pnpm dev, computed from yarn/pnpm graph + package.jsonimport/require specifier✔–
PyPIpoetry.lock, uv.lock (Pipfile.lock: flags only)poetry category/groups, uv dev-dependencies, Pipfile.lock developimport/from (dist→module table)✘ inferred names–
CargoCargo.lockcomputed from graph + [dev-dependencies] (single-crate)use/crate::✔RustSec affects.functions lexical (strong when sole consumer)
RubyGemsGemfile.lockcomputed from graph + Gemfile groupsrequire✘ Bundler.require–
Composercomposer.lockpackages-devPSR-4/PSR-0 namespaces from composer.lock✔–
Maven/Gradlegradle.lockfile (flags only)Gradle test-only configurations, pom <scope>test</scope> (manifest, weak)Java package guessed from groupId/artifactId✘ inferred–
NuGetpackages.lock.json– (weak: PrivateAssets="all")using namespace = package id✘ inferred–
Dart/pubpubspec.lock (depth only)– (weak: dev_dependencies)import 'package:…'✘ no graph–
Elixir/Hexmix.lock (presence only)mix.exs dependency tuple (weak)–––
Swift/SPMPackage.resolved (presence only)Package.swift package URL/name (weak)–––

Strong dev_dependency — the package manager itself resolved the package outside the runtime closure, or the lockfile carries the complete edge set plus the root’s runtime/dev split so the closure is computed exactly. A manifest-only devDependencies entry is not strong: the lockfile may still pull the same package into the runtime closure through another dependency (VEXViper reports that case explicitly).

Strong import_not_found (rule “product code is the only caller and does not load it”) requires all of: the package is a direct dependency (BOMHort’s graph wins over the manifest), the lockfile graph shows no other package depends on it, the ecosystem’s import syntax names the package verbatim, at least one non-test source file was scanned, none imports it, and (npm) it is not invoked from package.json scripts / (Cargo) not renamed via package = "…".

Strong Rust symbol_not_referenced — same sole-consumer conditions, the advisory lists affects.functions, product files that use the crate never mention those function names or paths. Macros and re-exports can hide a call, which is why the heuristic grades this below govulncheck (0.75) and a reviewer should treat it as a lexical result.

Every other item (dependency_path, package_imported, non-strong import_not_found, manifest_not_found, no_reachability_analysis) is context for the provider and the reviewer; the guardrails never let it justify not_affected on its own.

Review-first GitOps mode (vex.git)

Instead of (or before) uploading straight into BOMHort, VEXViper can commit every document to a git repository and open a pull request per product, so humans review LLM drafts in a normal code-review flow and the VEX history is auditable:

vex:
  git:
    enabled: true
    repo: https://github.com/acme/vex-statements.git   # or git@github.com:acme/vex-statements.git
    branch: main            # base branch
    path: vex               # directory inside the repo
    branch_prefix: vexviper/  # one review branch per product ("" = commit straight to `branch`)
    pr: true                # open/update a GitHub PR (needs a token with contents+pull_requests write)
    token_env: GITHUB_TOKEN # also authenticates https pushes; ssh remotes use the ambient agent
    sign_off: true          # DCO trailer

vexviper generate --git / vexviper watch --git (or enabled: true) then:

  1. clones/fetches the repo (<repo.cache_dir>/gitops/…), resets a review branch vexviper/<product-slug> onto the current base — a stale branch never resurrects old statements;
  2. writes <path>/<name>.vexviper.openvex.json; identical content is a no-op;
  3. commits (author from author_name/author_email, optional -s), force-pushes the review branch (bot-owned) and opens or updates the open PR for it with a review checklist.

Tokens never appear on the command line (GIT_CONFIG_* env headers) and are redacted from errors and logs. Merging does not upload anything: point BOMHort’s VEX ingestion or a CI job (vexviper generate --upload) at the merged files, or run with both --git and --upload when you only want the audit trail.

MCP server

vexviper mcp-serve exposes:

ToolPurpose
list_sbomsSBOMs in BOMHort with vuln counts
list_findingsBOMHort’s findings for one SBOM (incl. existing vex_status)
get_repo_contextresolve+clone repo, govulncheck, evidence + ready-made prompt per finding
draft_vexbuild a validated OpenVEX document from assessments the host provides
generate_vexrun the full pipeline with the configured provider, optionally upload
upload_vexpush a document to BOMHort (optional sbom argument scopes it to one SBOM)
list_vex_statementsverify BOMHort ingested it

Host config example: examples/mcp-client-config.json. Typical agent flow: list_findings → get_repo_context → the host model reasons → draft_vex → human review → upload_vex → list_vex_statements.

Testing locally

make lint test                                     # unit tests, fake BOMHort, no services needed

# dry run against a running BOMHort (no upload; writes ./vex/<name>.vexviper.openvex.json)
cat > vexviper.local.yaml <<'Y'
bomhort: { url: http://localhost:8080 }
repo:
  cache_dir: /tmp/vexviper-cache
  sboms:
    - { match: "kubermatic_kubelb_*", repo: kubermatic/kubelb }   # SBOM lacks VCS refs
vex: { out_dir: ./vex }
Y
bin/vexviper generate --config vexviper.local.yaml --sbom kubermatic_kubelb_1.4.2.spdx.json
bin/vexviper generate --config vexviper.local.yaml --sbom kubermatic_kubelb_1.4.2.spdx.json \
  --only GO-2026-5026 --provider github --stdout --log-level debug   # needs GITHUB_TOKEN (models:read)

# full round trip incl. upload → BOMHort applies vex_status (isolated stack on :18080)
BOMHORT_SRC=~/GolandProjects/seebom make e2e
BOMHORT_SRC=~/GolandProjects/seebom ./hack/e2e-bomhort.sh --keep   # keep stack for manual poking

# MCP server for your IDE / Copilot / Claude (see examples/mcp-client-config.json)
bin/vexviper mcp-serve --bomhort http://localhost:8080

--log-level debug shows repository resolution and govulncheck. If every Go finding ends as under_investigation, check the log for govulncheck failed: govulncheck needs a go toolchain — VEXViper looks in PATH, $GOROOT/bin, ~/sdk/go*/bin and /usr/local/go/bin. Private repositories need git credentials (e.g. a credential helper or GIT_CONFIG_GLOBAL).

Development

make lint          # gofmt + go vet (incl. integration tag)
make test          # go test ./...
make test-race
make e2e           # full BOMHort round trip, see below

Tests use only the standard library testing package plus httptest, in-memory MCP transports and the reusable fake BOMHort bomhorttest from bomhort-go. Fixtures under testdata/ include BOMHort’s release SBOM and recorded OSV responses — unit tests need no network.

End-to-end test against BOMHort

hack/e2e-bomhort.sh spins up an isolated BOMHort stack (hack/docker-compose.e2e.yml: ClickHouse, api-gateway, ingestion-watcher, parsing-worker with AUTH_ENABLED=true), drops testdata/bomhort-0.6.1.spdx.json into its SBOM dir, runs vexviper generate --upload --wait, asserts BOMHort applied every statement, runs go test -tags integration ./test/integration/ and copies the result to examples/bomhort-0.6.1.openvex.json.

BOMHORT_SRC=~/GolandProjects/seebom make e2e          # needs docker compose + locally built seebom-* images
BOMHORT_SRC=~/GolandProjects/seebom make e2e-ci       # builds BOMHort from source first, leaves examples/ alone

Env: BOMHORT_IMAGE_PREFIX/BOMHORT_IMAGE_TAG to pick images, BOMHORT_BUILD=1 to build them from $BOMHORT_SRC/backend/Dockerfile, --keep to leave the stack up. The published ghcr.io/seebom-labs/bomhort/*:0.6.1 images predate the upload endpoint; use BOMHORT_IMAGE_PREFIX=ghcr.io/seebom-labs/bomhort/ BOMHORT_IMAGE_TAG=0.7.1 or build from source.

CI: .github/workflows/e2e.yml runs the same script on every PR (including backports to release/**) against a pinned BOMHort commit (BOMHORT_PINNED_REF) and weekly against BOMHort main; the generated document, compose logs and integration-test output are uploaded as artifacts. On a release branch, keep the pin at the BOMHort ref that minor was tested against unless the patch deliberately depends on newer BOMHort API behaviour.

Layout

cmd/vexviper/          CLI (generate | watch | mcp-serve | version)
internal/sbom/         minimal SPDX/CycloneDX reader — only for repository hints
internal/repo/         PURL/VCS → repository resolution, shallow git clone cache
internal/evidence/     version compare, dependency depth, govulncheck (multi-module), symbol grep, non-Go manifest/import scan
internal/osv/          OSV vuln detail client (context for the LLM)
internal/llm/          Provider interface, prompt, usage accounting + budget, heuristic | openai/github | copilot | mcptool | mock
internal/assesscache/  verdict cache keyed by provider · commit · finding · evidence fingerprint
internal/vexgen/       assessments → go-vex document, guardrails, validation
internal/pipeline/     orchestration, upload verification, watch loop (worker pool), Prometheus metrics
internal/gitops/       review-first publishing: git commit/push + GitHub PR per product (git CLI + net/http)
internal/mcpserver/    VEXViper's own MCP server
test/integration/      -tags integration tests against a live BOMHort
hack/                  E2E compose + script
deploy/helm/vexviper/  CronJob | Deployment | MCP Service chart

License

Apache-2.0