Overview
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)
- 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; - 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,govulncheckreachability 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; - asks an assessment provider for
status/justification/confidence/reasoningper 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; - applies guardrails and emits an OpenVEX document built with
openvex/go-vexwhose every statement passesStatement.Validate()and reuses BOMHort’s(vuln_id, purl)verbatim so BOMHort’s exact-match join applies it; - optionally uploads it through
POST /api/v1/sboms/uploadand waits until BOMHort shows the newvex_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 BOMHort | Consequence |
|---|---|
| No plugin API (no Go plugins, gRPC hooks, webhooks, MCP) — the REST API is the only extension surface | VEXViper is an out-of-tree Go module talking to /api/v1 |
| Contribution policy: stdlib-only, “ask first” for new dependencies, no frameworks | go-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 export | VEXViper’s output is exactly that overlay, generated instead of hand-written |
Details: docs/INTEGRATION.md.
Runtime model — one binary, three modes
| Mode | Command | When | Deploy as |
|---|---|---|---|
| One-shot | vexviper generate --sbom … [--repo …] [--upload] | CI job after publishing an SBOM, manual triage, make vex | binary / container step |
| Watcher | vexviper watch [--once] [--upload] | keep every SBOM in BOMHort covered; re-runs when an SBOM’s vuln_count@ingested_at fingerprint changes | Kubernetes CronJob (--once, recommended) or Deployment via the Helm chart |
| MCP server | vexviper mcp-serve --transport stdio|http | let a host LLM/agent list findings, fetch evidence, draft, and upload VEX with a human in the loop | stdio 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 —
watchre-runs an SBOM when itsvuln_count@ingested_atfingerprint changes; only findings without avex_statusare assessed; - expiring verdicts —
watch.reassess_after: 168h(or--reassess-after 168h) re-runs each SBOM at least that often and re-assessesunder_investigation/affectedfindings whose newest BOMHort statement is older than the TTL (new evidence: fixed versions, govulncheck DB updates, better model).not_affected/fixedare stable claims and are only revisited with--force; - a new statement for the same
(vuln_id, purl)supersedes the old one in BOMHort (latestvex_timestampwins), 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:
| Key | Env | Default | Meaning |
|---|---|---|---|
bomhort.url | VEXVIPER_BOMHORT_URL | http://localhost:8080 | BOMHort API gateway |
bomhort.api_key_env | BOMHORT_API_KEY | X-API-Key for uploads | |
llm.provider | VEXVIPER_LLM_PROVIDER | heuristic | heuristic | openai | github | copilot | mcptool |
llm.min_confidence | VEXVIPER_LLM_MIN_CONFIDENCE | 0.6 | below → under_investigation |
llm.budget.{max_calls,max_tokens,max_premium_requests} | VEXVIPER_LLM_BUDGET_* | unlimited | spend cap per run / watch pass; beyond it findings are deferred |
bomhort.{rate_limit,rate_window} | VEXVIPER_BOMHORT_RATE_* | 90, 10s | client-side pacing below BOMHort’s gateway limit |
llm.openai.{base_url,model,api_key_env} | VEXVIPER_OPENAI_* | OpenAI / gpt-4o-mini | any OpenAI-compatible endpoint (Azure, GitHub Models, Ollama, vLLM, LiteLLM) |
llm.github.{base_url,model,token_env} | VEXVIPER_GITHUB_* / GITHUB_TOKEN | GitHub Models / openai/gpt-4.1-mini | GitHub Models inference |
llm.copilot.{command,model,args,in_repo,timeout} | VEXVIPER_COPILOT_* | copilot, 180s | Copilot CLI non-interactive mode |
llm.mcp.{transport,command,args,url,tool} | VEXVIPER_MCP_* | stdio / assess_vulnerability | the MCP server + tool VEXViper calls |
repo.{cache_dir,override,clone,govulncheck} | VEXVIPER_REPO_* | .vexviper-cache, clone+govulncheck on | product 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_TOKEN | off, main, vex/, vexviper/, PR on | GitOps review mode: commit documents, open a PR per product |
watch.{interval,state_file,reassess_after} | VEXVIPER_WATCH_* | 15m, TTL off | poller & periodic re-assessment |
watch.{concurrency,listen} | VEXVIPER_WATCH_CONCURRENCY, VEXVIPER_WATCH_LISTEN | 1, off | parallel SBOMs per pass; /metrics + /healthz address |
cache.{enabled,dir,ttl} | VEXVIPER_CACHE_* | on, <repo.cache_dir>/assessments, never | assessment cache shared across SBOMs |
timeout | VEXVIPER_TIMEOUT | 30m | per 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.
fixedwhen 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_pathwhen 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); otherwiseunder_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/completionswith JSON-schema structured output,temperature 0. System prompt ininternal/llm/prompt.goframes 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 withmodels: read, or the Actions token withpermissions: 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 withcopilot→/login, orgh auth login, or exportGH_TOKEN=$(gh auth token). Within_repo: trueCopilot runs inside the cloned product repo and may read code (never write). ~15 s per finding; verified on kubelb 1.4.2 (not_affected0.88 with govulncheck evidence,affected0.95 with call path toidna.ToASCII).mcptool — VEXViper connects as MCP client (stdio
command/argsor streamable HTTPurl) and callstoolwith{"system_prompt": "...", "prompt": "<rendered evidence>", "request": {<finding+evidence>}, "schema": {<assessment JSON schema>}}and accepts either
structuredContentor 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_affectedrequires at least one strong deterministic evidence item unlessallow_unsupported_not_affected: true. Strong items are exact facts, never lexical guesses:govulncheck_not_reachable,version_fixed, a lockfile-resolveddev_dependency, and — only when the lockfile graph proves product code is the package’s sole consumer —import_not_found(npm, Cargo, Composer, Dart) and Rustsymbol_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;fixedrequires a knownfixed_version≤ installed version;- every statement passes go-vex
Statement.Validate();status_notesrecords provider, confidence and reasoning;toolingrecordsvexviper/<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:
| Ecosystem | Lockfile graph (paths, dependents) | Dev-only (strong) | Import scan | Strong import_not_found | Vulnerable symbols |
|---|---|---|---|---|---|
| Go | govulncheck | – | – | – | govulncheck call graph (strong) |
| npm | package-lock v1/v2/v3, yarn v1/berry, pnpm | npm dev: true, pnpm dev, computed from yarn/pnpm graph + package.json | import/require specifier | ✔ | – |
| PyPI | poetry.lock, uv.lock (Pipfile.lock: flags only) | poetry category/groups, uv dev-dependencies, Pipfile.lock develop | import/from (dist→module table) | ✘ inferred names | – |
| Cargo | Cargo.lock | computed from graph + [dev-dependencies] (single-crate) | use/crate:: | ✔ | RustSec affects.functions lexical (strong when sole consumer) |
| RubyGems | Gemfile.lock | computed from graph + Gemfile groups | require | ✘ Bundler.require | – |
| Composer | composer.lock | packages-dev | PSR-4/PSR-0 namespaces from composer.lock | ✔ | – |
| Maven/Gradle | gradle.lockfile (flags only) | Gradle test-only configurations, pom <scope>test</scope> (manifest, weak) | Java package guessed from groupId/artifactId | ✘ inferred | – |
| NuGet | packages.lock.json | – (weak: PrivateAssets="all") | using namespace = package id | ✘ inferred | – |
| Dart/pub | pubspec.lock (depth only) | – (weak: dev_dependencies) | import 'package:…' | ✘ no graph | – |
| Elixir/Hex | mix.lock (presence only) | mix.exs dependency tuple (weak) | – | – | – |
| Swift/SPM | Package.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:
- clones/fetches the repo (
<repo.cache_dir>/gitops/…), resets a review branchvexviper/<product-slug>onto the current base — a stale branch never resurrects old statements; - writes
<path>/<name>.vexviper.openvex.json; identical content is a no-op; - 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:
| Tool | Purpose |
|---|---|
list_sboms | SBOMs in BOMHort with vuln counts |
list_findings | BOMHort’s findings for one SBOM (incl. existing vex_status) |
get_repo_context | resolve+clone repo, govulncheck, evidence + ready-made prompt per finding |
draft_vex | build a validated OpenVEX document from assessments the host provides |
generate_vex | run the full pipeline with the configured provider, optionally upload |
upload_vex | push a document to BOMHort (optional sbom argument scopes it to one SBOM) |
list_vex_statements | verify 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