API Reference
The BOMHort API is primarily read-only (GET endpoints). One write endpoint exists — POST /api/v1/sboms/upload for push-model CI/CD ingestion — and it is disabled unless AUTH_ENABLED=true, regardless of the global auth default.
The MCP server wraps a subset of these endpoints as Model Context Protocol tools, so an agent can query a BOMHort instance without a REST integration being written first. It is read-only, and it adds no endpoints — it calls the ones documented here.
Base URL
http://<api-gateway-host>:8080/api/v1
In headless mode (Helm: ui.enabled: false), only the API Gateway is deployed. All endpoints remain the same.
Authentication
Authentication is fully optional and disabled by default. Enable it via the AUTH_ENABLED=true environment variable on the API Gateway. See the Deployment Guide for full setup instructions.
Two modes (combinable)
Service Token — single shared secret, typically used by upstream proxies (oauth2-proxy, Kong, custom auth gateways):
# Either header works:
curl -H "Authorization: Bearer <service-token>" \
https://api.bomhort.example.com/api/v1/stats/dashboard
curl -H "X-Service-Token: <service-token>" \
https://api.bomhort.example.com/api/v1/stats/dashboard
API Keys — multiple pre-shared keys for direct API consumers (CI/CD pipelines, scripts):
curl -H "X-API-Key: <api-key>" \
https://api.bomhort.example.com/api/v1/stats/dashboard
Public endpoints (always accessible)
Even when authentication is enabled, the following endpoints bypass auth so Kubernetes probes and CORS preflight work:
| Endpoint | Reason |
|---|---|
/healthz | K8s health check (legacy, always 200) |
/livez | Liveness probe (always 200 if process alive) |
/readyz | Readiness probe (pings ClickHouse, 503 if unavailable) |
OPTIONS * | CORS preflight |
Responses
| Status | Condition |
|---|---|
200 OK | Auth disabled, or valid token/key presented |
401 Unauthorized (with WWW-Authenticate: Bearer realm="bomhort") | Auth enabled, no credential sent |
401 Unauthorized | Auth enabled, invalid token/key |
All credential comparisons are constant-time (crypto/subtle.ConstantTimeCompare) to prevent timing attacks. Failed auth attempts are logged with sanitized client IPs.
Common Patterns
Pagination
Paginated endpoints accept:
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
page | uint64 | 1 | — | Page number (1-based) |
page_size | uint64 | 50 | 500 | Items per page |
Paginated responses return:
{
"data": [...],
"total": 1234,
"page": 1,
"page_size": 50
}
Dependency scope
Every finding that points at a package carries where that package sits in the SBOM’s dependency graph, so a CVE or a copyleft license in a library you pull in yourself reads differently from one five levels down:
dependency_scope | dependency_depth | Meaning |
|---|---|---|
root | 0 | The product the SBOM describes (SPDX DESCRIBES target, CycloneDX metadata.component) |
direct | 1 | Declared directly by the product |
transitive | ≥ 2 | Pulled in by another dependency; the depth is the shortest path from the root |
unknown | omitted | The SBOM carries no relationship graph (flat package list) or several candidate roots, or the row was ingested before BOMHort recorded depths (migration 025) |
Depth is computed once at parse time from the document’s relationships
(DEPENDS_ON, DEPENDENCY_OF, CONTAINS, CycloneDX dependsOn, …) and stored
per package; nothing is re-derived at query time. Endpoints that list
vulnerabilities accept ?scope=root|direct|transitive|unknown to filter on it —
an invalid value is a 400. Rows ingested before the column existed show up as
unknown until make re-scan.
The scope is only as good as the document: a generator that lists every module
as a DEPENDS_ON of the root (a star-shaped graph, common for Go module SBOMs)
yields direct for all of them, because that is what the SBOM states. BOMHort
reports the declared graph and does not second-guess it.
Vulnerability ids and aliases
For Go, npm, PyPI and most other ecosystems OSV’s primary id is GHSA-…, GO-… or PYSEC-…; the CVE is only one of its aliases (#412). Every finding therefore carries three id fields:
| Field | Description |
|---|---|
vuln_id | The id OSV stores the finding under. Stable; use it to re-query OSV. |
cve_id | The CVE of the same flaw: vuln_id itself when that is a CVE, else the CVE among aliases (the smallest one in the rare case of several). Omitted when the flaw has no CVE. |
aliases | Every other OSV id of the flaw, sorted. Omitted when OSV lists none. |
They appear on /api/v1/vulnerabilities, /api/v1/sboms/{id}/vulnerabilities, /api/v1/projects/{name}/vulnerabilities, the vulnerability facet of /api/v1/search, and on VEX statements (where vuln_id stays the id the VEX author wrote and aliases come from the findings it matches). Lookups accept any of the names: /api/v1/vulnerabilities/CVE-2024-45338/affected-projects finds the findings stored as GO-2024-3333, and search matches aliases. Older API versions returned vuln_id only; clients that ignore the new fields are unaffected.
Error Responses
All errors return:
{
"error": "Human-readable error message"
}
| HTTP Status | Meaning |
|---|---|
| 400 | Bad Request — invalid parameters (malformed UUID, missing required query param) |
| 429 | Too Many Requests — rate limit exceeded (100 req/10s per IP) |
| 500 | Internal Server Error — database or processing failure |
Rate Limiting
- 100 requests per 10 seconds per source IP
- Returns
429 Too Many Requestswhen exceeded - No
Retry-Afterheader (client should implement exponential backoff)
Health
GET /healthz
Health check endpoint for Kubernetes probes.
Response: 200 OK
{"status": "ok"}
GET /livez
Liveness probe. Returns 200 OK as long as the API Gateway process is running. Used by Kubernetes to determine if the pod should be restarted.
Response: 200 OK
{"status": "ok"}
GET /readyz
Readiness probe. Pings ClickHouse to verify database connectivity. Returns 503 Service Unavailable if the database is unreachable. Kubernetes uses this to remove the pod from service endpoints until it recovers.
Response (healthy): 200 OK
{"status": "ok"}
Response (unhealthy): 503 Service Unavailable
{"error": "ClickHouse unavailable"}
Dashboard & Statistics
GET /api/v1/stats/dashboard
Aggregated platform statistics for the dashboard view.
Response: 200 OK
{
"total_sboms": 142,
"total_packages": 18743,
"total_vulnerabilities": 892,
"effective_vulnerabilities": 756,
"suppressed_by_vex": 136,
"critical_vulns": 23,
"high_vulns": 187,
"medium_vulns": 412,
"low_vulns": 270,
"license_breakdown": {
"permissive": 16755,
"copyleft": 312,
"unapproved": 41,
"unknown": 18
},
"exempted_packages": 14,
"total_vex_statements": 42,
"last_cve_refresh": "2026-05-20T03:00:00Z",
"new_vulns_since_refresh": 3,
"archived_repos_count": 7
}
GET /api/v1/stats/dependencies
Top-N most used dependencies across all projects.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | uint64 | 50 | Number of top dependencies to return (max 500) |
Response: 200 OK
{
"total_unique_deps": 4521,
"top_dependencies": [
{
"package_name": "golang.org/x/net",
"purl": "pkg:golang/golang.org/x/net",
"project_count": 87,
"versions": ["v0.23.0", "v0.24.0", "v0.25.0"],
"vuln_count": 2
}
]
}
GET /api/v1/stats/version-skew
Packages with inconsistent versions across different projects (version skew detection).
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | uint64 | 1 | Page number |
page_size | uint64 | 50 | Items per page (max 500) |
search | string | — | Filter by package name (case-insensitive substring match) |
Response: 200 OK
{
"total_skewed_packages": 234,
"items": [
{
"package_name": "golang.org/x/crypto",
"purl": "pkg:golang/golang.org/x/crypto",
"version_count": 4,
"project_count": 12,
"is_direct_in_count": 8,
"versions": [
{
"version": "v0.23.0",
"project_count": 5,
"projects": ["prometheus", "grafana", "etcd"]
},
{
"version": "v0.21.0",
"project_count": 3,
"projects": ["containerd", "runc"]
}
]
}
],
"page": 1,
"page_size": 50
}
SBOMs
POST /api/v1/sboms/upload
Push an SBOM or VEX document directly instead of relying on a filesystem/S3 scan — designed for CI/CD pipelines. The upload is enqueued as a normal ingestion job; the parsing worker processes it exactly like any other job, so no ingestion logic is duplicated here.
Storage backend: the upload is routed to whichever push-storage backend is configured, resolved once at API Gateway startup:
- If one of the configured
S3_BUCKETSentries has"skipScan": true, the upload is written there (s3://<bucket>/<prefix>/pushed/<uuid>-<filename>). That bucket is excluded from the ingestion watcher’s periodic scan so the same object is never rediscovered and re-enqueued under a different dedup hash. - Otherwise, it falls back to the local filesystem under
SBOM_DIR/pushed/— this requires the API Gateway’sSBOM_DIRmount to be writable (see the Deployment Guide for the HelmsbomSource.writableflag). - If neither is available, every request gets
503 Service Unavailablerather than a confusing storage error.
This endpoint refuses every request with 403 Forbidden unless AUTH_ENABLED=true on the API Gateway — independent of whether the request would otherwise pass the global auth middleware. A write endpoint open by default is a materially different risk than a read-only API open by default, so this check is enforced by the handler itself.
Headers:
| Header | Required | Description |
|---|---|---|
X-Filename | ✅ | Original filename. Must end in .spdx.json, .cdx.json, .openvex.json, .vex.json, or a generic .json (format auto-detected downstream, same as local scans). Only the base name is used — any directory components are stripped before the file is stored. |
X-Source-Repo | — | Source repository URL for the product this SBOM describes (#332), e.g. https://github.com/example-org/example-app. Must be a plain http(s) URL without credentials — rejected with 400 otherwise. Overrides whatever the parser would extract from the document. Normalised before storage (.git suffix stripped, inline @ref split off). |
X-Source-Ref | — | Commit SHA, tag or branch the SBOM was generated from. No whitespace, max 256 chars. An explicit value outranks a ref embedded in X-Source-Repo. |
Authorization / X-Service-Token / X-API-Key | ✅ | Same credential options as the rest of the API — see Authentication. |
Body: Raw SBOM or VEX JSON content (not multipart form data). Max size is MAX_UPLOAD_SIZE_MB (Helm: apiGateway.maxUploadSizeMB, default 50 MB); larger bodies are rejected with 413.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
cluster | string | Overrides this instance’s configured CLUSTER_NAME for the resulting ingestion job. |
namespace | string | Overrides the configured NAMESPACE (#138). The deployment namespace the artifact belongs to, e.g. payments. |
project | string | Overrides the configured PROJECT (#57), e.g. payment-service. |
parent | string | Overrides the configured PARENT: the parent (product) this SBOM’s project belongs to. Outranks the mapping file and the automatic grouping (Project groups). |
tags | string | Comma-separated grouping labels (#357), e.g. sandbox-applications,platform. Merged with the instance-wide TAGS rather than overriding them — a CI job adding a grouping does not contradict a configured one. Normalised (trimmed, lowercased, deduplicated, max 32 × 64 chars). |
sbom_id | — | VEX uploads only (#350): scopes every statement in the document to this SBOM. Must be a valid SBOM UUID; rejected with 400 on SBOM uploads or malformed values. Without it, the worker resolves the statement’s OpenVEX product @id against sboms (source_repo, document_namespace, document_name); if nothing matches the statement is stored unscoped and suppresses nothing — until a later ingest of the product’s SBOM lets the VEX rescue pass (migration 020) re-resolve and scope it. |
The ownership parameters are optional and independent. A parameter that is absent or blank inherits the instance default — ?namespace= and omitting it entirely mean the same thing, so a client cannot accidentally blank out a configured value. Values are trimmed.
tags is the one exception to that override model: because groupings are many-to-many, upload tags are added to the configured ones instead of replacing them. project and tags also answer different questions — project names what the artifact is, tags say what kind of thing it is — so tagging an upload never collapses it into the tag. See Fleet Views.
Unlike the bucket/directory ingestion path, the server cannot infer these from an uploaded body, so a pushing CI job states them explicitly. They are stamped onto the ingestion job and copied onto every row the parsing worker writes (sboms, sbom_packages, vulnerabilities, license_compliance, vex_statements, document_store).
Response (accepted): 202 Accepted
{
"status": "pending",
"job_id": "d465b76c-5b91-48a1-b069-97069e9759a1",
"sha256_hash": "3a7bd3e2360a3d4b1f8e...",
"job_type": "sbom",
"cluster": "production",
"namespace": "payments",
"project": "payment-service"
}
Response (already ingested): 200 OK
{
"status": "duplicate",
"sha256_hash": "3a7bd3e2360a3d4b1f8e...",
"message": "Content already ingested, skipping"
}
Errors:
400— MissingX-Filenameheader, unsupported file extension, empty body, malformedX-Source-Repo/X-Source-Refheader, or content that fails validation (SBOM uploads must be a single well-formed JSON object; VEX uploads must be a valid OpenVEX document — see below)403—AUTH_ENABLEDis nottrueon this instance413— Body exceedsMAX_UPLOAD_SIZE_MB500— Storage (S3 or local) or ingestion-queue failure503— No push-storage backend is configured (noskipScanS3 bucket, andSBOM_DIRisn’t writable)
VEX uploads are validated with the same OpenVEX parser the worker uses (a @context or at least one statements entry must be present) before anything is written to disk or enqueued. SBOM uploads must be a single, well-formed JSON object — bare scalars, null, top-level arrays, concatenated documents, and trailing data after the document are all rejected with 400. Beyond that the shape is not constrained: format detection (bomFormat/spdxVersion/in-toto predicateType) and full parsing (package/component extraction) still happen in the parsing worker, so an unrecognized-but-well-formed object is accepted here and resolved downstream.
Example:
curl -X POST http://localhost:8080/api/v1/sboms/upload \
-H "X-API-Key: <api-key>" \
-H "X-Filename: my-service.spdx.json" \
--data-binary @my-service.spdx.json
Tagging the upload with all three ownership dimensions:
curl -X POST "http://localhost:8080/api/v1/sboms/upload?cluster=prod-eu&namespace=payments&project=payment-service" \
-H "X-API-Key: <api-key>" \
-H "X-Filename: my-service.spdx.json" \
--data-binary @my-service.spdx.json
Stating the source attribution from CI, where the pipeline knows the exact repo and commit (#332):
curl -X POST http://localhost:8080/api/v1/sboms/upload \
-H "X-API-Key: <api-key>" \
-H "X-Filename: my-service.spdx.json" \
-H "X-Source-Repo: https://github.com/example-org/example-app" \
-H "X-Source-Ref: ${GIT_COMMIT}" \
--data-binary @my-service.spdx.json
GET /api/v1/sboms
Paginated list of all ingested SBOMs.
Document names: document_name is the name stored at ingestion, also used by
the detail and other SBOM-related endpoints. Meaningful source names are preserved.
Empty or temporary names such as tmp.ABC123xyz fall back to a uniquely described
root package plus version (for example example-org/widget v1.2.3), then a source
label, finally Unnamed SBOM. See the complete parser rules.
Source bytes, hashes, SBOM IDs, and the original download are unchanged; the API
does not introduce a separate original-name field. Older rows retain their stored
names until re-processed. Project-scoped license exceptions match the exact resolved
document_name, including the version; old temporary names are not aliases.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | uint64 | 1 | Page number |
page_size | uint64 | 50 | Items per page (max 500) |
search | string | — | Filter by document name or source file (case-insensitive) |
Response: 200 OK
{
"data": [
{
"sbom_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"source_file": "containerd-v1.7.2.spdx.json",
"spdx_version": "SPDX-2.3",
"document_name": "containerd-v1.7.2",
"document_version": "1.7.2",
"package_count": 245,
"vuln_count": 12,
"ingested_at": "2026-05-20T14:30:00Z",
"source_repo": "https://github.com/containerd/containerd",
"source_ref": "v1.7.2",
"cluster": "prod-eu",
"namespace": "payments",
"project": "payment-service"
}
],
"total": 142,
"page": 1,
"page_size": 50
}
source_repo / source_ref (#332) identify where the product’s source lives — extracted from the document at ingest (SPDX root downloadLocation / vcs ExternalRef, then a source: <kind>:<url> entry in creationInfo.creators, then a repository-shaped documentNamespace; CycloneDX metadata.component.externalReferences[type=vcs] and pedigree.commits[0].uid), overridable via the upload headers or PATCH /api/v1/sboms/{id}. Both are omitted from the JSON when unknown.
document_version is the version of the product the document describes — SPDX root package versionInfo, CycloneDX metadata.component.version — extracted at ingest and omitted when the document states none. Also returned by the detail endpoint.
cluster / namespace / project (#177) are the three ownership dimensions the SBOM was tagged with at ingest (see Ownership Data Model). Each is omitted when unset, so single-instance deployments see the same payload as before. The same fields are returned by GET /api/v1/clusters/{name}/sboms.
GET /api/v1/sboms/{id}/detail
Detailed SBOM information including vulnerability severity breakdown.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | UUID | SBOM identifier |
Response: 200 OK
{
"sbom_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"source_file": "containerd-v1.7.2.spdx.json",
"spdx_version": "SPDX-2.3",
"document_name": "containerd-v1.7.2",
"package_count": 245,
"vuln_count": 12,
"ingested_at": "2026-05-20T14:30:00Z",
"source_repo": "https://github.com/containerd/containerd",
"source_ref": "v1.7.2",
"critical_vulns": 1,
"high_vulns": 3,
"medium_vulns": 6,
"low_vulns": 2
}
Errors:
400— Invalid UUID format
PATCH /api/v1/sboms/{id}
Sets or clears source_repo / source_ref on an existing SBOM (#332) — the manual escape hatch for documents whose automatic extraction yielded nothing (Syft dir: scans, pkg:generic roots, monorepos). Requires AUTH_ENABLED=true, like the upload endpoint: an unauthenticated PATCH would let anyone redirect triage tooling to arbitrary repositories.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | UUID | SBOM identifier |
Body: JSON with one or both fields. This is a true PATCH — an absent field keeps its current value, an explicit "" clears it:
{
"source_repo": "https://github.com/example-org/example-app",
"source_ref": "v1.2.3"
}
source_repo must be a plain http(s) URL without credentials; source_ref a git ref without whitespace (max 256 chars). Values are normalised before storage exactly like extracted ones (.git suffix stripped, inline @ref split off), so the stored form is identical regardless of how it arrived.
Response: 200 OK with the resulting attribution:
{
"sbom_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"source_repo": "https://github.com/example-org/example-app",
"source_ref": "v1.2.3"
}
Errors:
400— Invalid UUID, invalid JSON, empty body object, or a value that fails validation403—AUTH_ENABLEDis nottrueon this instance404— No SBOM with this ID
Example — pin the repo for an SBOM whose extraction failed, keeping whatever ref is already stored:
curl -X PATCH http://localhost:8080/api/v1/sboms/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "X-API-Key: <api-key>" \
-H "Content-Type: application/json" \
-d '{"source_repo": "https://github.com/example-org/example-app"}'
GET /api/v1/sboms/{id}/download
Download the original SBOM JSON file. Streams the file from S3 or local filesystem depending on ingestion source.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | UUID | SBOM identifier |
Response: 200 OK — Binary stream with Content-Disposition: attachment
| Header | Value |
|---|---|
Content-Type | application/json |
Content-Disposition | attachment; filename="<original-filename>" |
Errors:
400— Invalid UUID format404— SBOM not found or source file no longer available in storage503— S3 not configured (source is S3 but API Gateway has no S3 credentials)
Example:
curl -O -J \
http://localhost:8080/api/v1/sboms/a1b2c3d4-e5f6-7890-abcd-ef1234567890/download
GET /api/v1/sboms/{id}/vulnerabilities
All vulnerabilities found in a specific SBOM, including the effective VEX statement.
Row semantics (#335): exactly one row per (vuln_id, purl). When several VEX statements have been ingested for the pair (e.g. an automated under_investigation draft followed by a human not_affected), the statement with the newest vex_timestamp wins — the same OpenVEX conflict rule BOMHort applies for dashboard scoring. Clients never need to dedupe or re-implement OpenVEX semantics. A statement matches a finding by vuln_id or any OSV alias (migration 019 — a statement about the CVE covers the finding stored under its GHSA id), and a statement stored product-wide (product_purl = '*', i.e. the OpenVEX product carried no subcomponents) covers every component of the SBOM.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | UUID | SBOM identifier |
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
scope | string | all | Only root, direct, transitive or unknown findings (see Dependency scope) |
Response: 200 OK
[
{
"vuln_id": "GHSA-xyz-abc-123",
"cve_id": "CVE-2023-44487",
"aliases": ["CVE-2023-44487", "GO-2023-2102"],
"severity": "HIGH",
"purl": "pkg:golang/golang.org/x/net@v0.17.0",
"summary": "HTTP/2 rapid reset vulnerability",
"fixed_version": "v0.23.0",
"source_file": "containerd-v1.7.2.spdx.json",
"discovered_at": "2026-05-18T09:00:00Z",
"dependency_scope": "transitive",
"dependency_depth": 3,
"vex_status": "not_affected",
"vex_justification": "vulnerable_code_not_present",
"vex_timestamp": "2026-09-01T12:00:00Z",
"vex_statement_id": "b3f1c2d4-5678-90ab-cdef-1234567890ab",
"vex_author": "Example Org Security Team",
"vex_tooling": "VEXViper/0.1.0"
}
]
vex_justification, vex_timestamp, vex_statement_id, vex_author and vex_tooling describe the winning statement and are omitted (like vex_status) when no VEX statement covers the pair. vex_author/vex_tooling carry the provenance captured by migration 017 (#334) and are empty for statements ingested before it. vex_timestamp enables re-triage policies such as “re-open under_investigation older than 30 days” without paging through /api/v1/vex/statements.
GET /api/v1/sboms/{id}/vex
VEX statements affecting one SBOM (#350): every statement scoped to it, newest first. Unscoped statements (sbom_id = '', product unresolvable at ingest) are not returned — they apply to no SBOM.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | UUID | SBOM identifier |
Response: 200 OK — array of VEXStatementItem (same shape as /api/v1/vex/statements, including provenance from #334). Every row carries this SBOM’s sbom_id.
Why scoping matters: a VEX statement asserts a vulnerability status for a product. vulnerable_code_not_in_execute_path is a reachability claim about one product — another project with the identical library version may be exploitable. A statement therefore only suppresses findings in its own SBOM; a statement whose product could not be resolved suppresses nothing at all — there is no global/fleet-wide VEX scope. (If the product’s SBOM simply had not been ingested yet, the VEX rescue pass scopes the statement as soon as it arrives.)
GET /api/v1/sboms/{id}/licenses
License breakdown for a specific SBOM, grouped by license ID with package lists.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | UUID | SBOM identifier |
Response: 200 OK
[
{
"license_id": "Apache-2.0",
"category": "permissive",
"package_count": 120,
"packages": ["github.com/containerd/containerd", "..."],
"exempted_packages": [],
"exemption_reason": ""
},
{
"license_id": "GPL-2.0-only",
"category": "copyleft",
"package_count": 2,
"packages": ["github.com/some/gpl-lib"],
"exempted_packages": ["github.com/some/gpl-lib"],
"exemption_reason": "System library, not linked",
"package_scopes": { "github.com/some/gpl-lib": "transitive" }
}
]
package_scopes maps each listed package (from packages and exempted_packages) to its dependency scope. Packages whose scope is unknown are left out of the map, and the map itself is omitted when no package has a recorded depth — a client reads a missing key as unknown.
GET /api/v1/sboms/{id}/dependencies
Dependency tree reconstructed as a flat array with parent→child index references.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | UUID | SBOM identifier |
Response: 200 OK
[
{
"index": 0,
"spdx_id": "SPDXRef-Package-containerd",
"name": "containerd",
"version": "1.7.2",
"purl": "pkg:golang/github.com/containerd/containerd@v1.7.2",
"license": "Apache-2.0",
"license_source": "declared",
"dependency_scope": "root",
"dependency_depth": 0,
"children": [1, 2, 3]
},
{
"index": 1,
"spdx_id": "SPDXRef-Package-runc",
"name": "runc",
"version": "1.1.12",
"purl": "pkg:golang/github.com/opencontainers/runc@v1.1.12",
"license": "Apache-2.0",
"license_source": "github",
"dependency_scope": "direct",
"dependency_depth": 1,
"children": [4, 5]
}
]
The UI reconstructs the tree by following children indices. Root nodes are those not referenced as children by any other node. dependency_scope / dependency_depth are the stored dependency scope of each node — a package reachable through several paths carries the shortest.
license_source says where license came from (declared, github, npm, nuget, depsdev, packagist, pypi, optionally with +latest / +normalized) or, when the license is still unknown, why (first-party, not-published, no-license-upstream, no-purl, unsupported-ecosystem, unresolved). It is omitted for SBOMs ingested before BOMHort recorded it. See License Resolution.
An SBOM that exists but has no package rows yet (for example while it is being ingested) returns 200 with [].
Errors:
400—idis not a UUID404— No SBOM with this ID
Vulnerabilities
GET /api/v1/vulnerabilities
Paginated list of all discovered vulnerabilities across all SBOMs. Each item carries cve_id and aliases next to vuln_id (see Vulnerability ids and aliases).
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | uint64 | 1 | Page number |
page_size | uint64 | 50 | Items per page (max 500) |
scope | string | all | Only root, direct, transitive or unknown findings (see Dependency scope) |
Response: 200 OK — PaginatedResponse<VulnerabilityListItem>
{
"data": [
{
"vuln_id": "GO-2024-3333",
"cve_id": "CVE-2024-45338",
"aliases": ["CVE-2024-45338", "GHSA-w32m-9786-jp63"],
"severity": "CRITICAL",
"purl": "pkg:golang/golang.org/x/net@v0.21.0",
"summary": "Denial of service in net/http",
"fixed_version": "v0.23.0",
"source_file": "etcd-v3.5.12.spdx.json",
"discovered_at": "2026-05-15T00:00:00Z",
"dependency_scope": "direct",
"dependency_depth": 1,
"vex_status": ""
}
],
"total": 892,
"page": 1,
"page_size": 50
}
GET /api/v1/vulnerabilities/{id}/affected-projects
All projects affected by a specific vulnerability, including transitive dependency information. The id may be any name of the flaw (#412): the stored vuln_id or any of its OSV aliases, so a CVE finds findings stored under their GHSA or GO id. An id no finding knows returns [].
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | Vulnerability ID or any alias (CVE, GHSA, GO-, PYSEC-, etc.) |
Response: 200 OK
[
{
"sbom_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"source_file": "etcd-v3.5.12.spdx.json",
"document_name": "etcd-v3.5.12",
"purl": "pkg:golang/golang.org/x/net@v0.21.0",
"package_name": "golang.org/x/net",
"version": "v0.21.0",
"severity": "CRITICAL",
"vex_status": "",
"is_direct": false,
"dependency_scope": "transitive",
"dependency_depth": 4
}
]
is_direct is kept for compatibility and is true exactly when dependency_scope is direct (a root finding — the described product itself is vulnerable — is reported as false); new clients should read dependency_scope, which also distinguishes unknown from transitive.
Errors:
400— Invalid vulnerability ID format
Reports
GET /api/v1/reports/vulnerabilities
Every finding of GET /api/v1/vulnerabilities as a file download for auditors (#266). Same query, same rows, same order — no pagination, every finding with its VEX status (like the JSON listing, there is no “effective only” filter; filter on the vex_status column). The rows are streamed from the database straight into the response, so the export works on instances with hundreds of thousands of findings without the gateway holding them in memory. The API gateway’s query limits apply to it like to any other request.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
format | string | ✅ | csv. The only format today; anything else is a 400. |
scope | string | — | Only root, direct, transitive or unknown findings (see Dependency scope) |
A legacy vex_filter parameter is ignored, as on the JSON listing.
Response: 200 OK, Content-Type: text/csv; charset=utf-8, Content-Disposition: attachment; filename="bomhort-vulnerabilities-<YYYY-MM-DD>.csv" (UTC date).
cve_id,vuln_id,aliases,severity,purl,summary,fixed_version,source_file,discovered_at,dependency_scope,dependency_depth,vex_status
CVE-2024-45338,GO-2024-3333,CVE-2024-45338;GHSA-w32m-9786-jp63,HIGH,pkg:golang/golang.org/x/net@v0.21.0,Non-linear parsing of case-insensitive content,v0.33.0,etcd-v3.5.12.spdx.json,2026-05-15T00:00:00Z,transitive,3,not_affected
cve_idcomes first because that is the id audit questionnaires ask for. It is empty when the flaw has no CVE (see Vulnerability ids and aliases).aliasesare;-joined.dependency_depthis empty when unknown.- Cells starting with
=,+,-,@, tab or carriage return get a leading', so a spreadsheet shows them as text instead of running them as formulas (CSV injection). Summaries come from OSV, and PURLs and file names come from uploaded SBOMs, so the export cannot trust them. - The status line goes out before the first row. If the database fails mid-export, the file is cut short and the gateway logs
vulnerability report truncated. A query that fails up front returns500with a JSON error and no file.
Errors:
400— Missing or unsupportedformat, or invalidscope500— The query failed before any row was sent
Licenses
GET /api/v1/licenses/compliance
Aggregated license compliance overview across all projects, with exemption details.
Response: 200 OK
[
{
"license_id": "GPL-3.0-only",
"category": "copyleft",
"package_count": 5,
"sbom_count": 3,
"non_compliant_packages": ["github.com/some/gpl3-lib"],
"exempted_packages": ["github.com/some/gpl3-lib"],
"exemption_reason": "Build tool only, not distributed",
"affected_sboms": [
{
"sbom_id": "a1b2c3d4-...",
"document_name": "my-project-v1.0"
}
]
}
]
category is one of permissive, copyleft, unapproved (a declared license that is not on the policy allow-list, e.g. LicenseRef-…, BSD, Public Domain) or unknown (no license information, NOASSERTION / NONE). Why a license is unknown is answered by GET /api/v1/licenses/sources below; the rules are on the License Resolution page.
GET /api/v1/licenses/sources
How every package across all SBOMs got its license — or why it has none. One row per distinct license_source value (see License Resolution), most frequent first. Packages from SBOMs ingested before sources were recorded are counted as unrecorded.
Response: 200 OK
[
{
"source": "declared",
"origin": "declared",
"modifiers": [],
"resolved": true,
"package_count": 251204,
"sbom_count": 498,
"examples": ["golang.org/x/sys", "github.com/stretchr/testify"]
},
{
"source": "depsdev+latest",
"origin": "depsdev",
"modifiers": ["latest"],
"resolved": true,
"package_count": 812,
"sbom_count": 37,
"examples": ["org.apache.commons:commons-pool2"]
},
{
"source": "not-published",
"origin": "not-published",
"modifiers": [],
"resolved": false,
"package_count": 242,
"sbom_count": 19,
"examples": ["athenz-zms", "internal-ui"]
}
]
GET /api/v1/projects
Paginated list of projects. A project is its configured project label when set, otherwise derived from the source path or document name. Each project aggregates its SBOMs (versions), total packages, and vulnerability counts.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
page_size | integer | 50 | Items per page (max 500) |
search | string | — | Filter projects by name (ILIKE) |
tag | string | — | Only projects carrying this grouping label |
group_by | string | — | parent: list parents with their member projects instead (see below) |
tag narrows which projects are listed; it never merges them. Filtering by
sandbox-applications returns each sandbox project as its own entry, with its
own version count. Values are normalised (trimmed, lowercased), so tag=Platform
matches data stored as platform.
Each project carries parent and parent_source when it belongs to a parent
(see Project groups); both are omitted otherwise.
Response: 200 OK
{
"data": [
{
"project_name": "k2s",
"sbom_count": 3,
"package_count": 1847,
"vuln_count": 23,
"latest_ingested": "2026-05-28T14:30:00Z",
"latest_sbom_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tags": ["sandbox-applications"]
}
],
"total": 142,
"page": 1,
"page_size": 50
}
GET /api/v1/projects?group_by=parent
The same listing grouped by parent and paged by group: one row per parent with
its member projects, and one row per project that has no parent (a group of
one, project_count: 1, sources: []). search matches the group name or any
member name; tag restricts the members. Any other group_by value is 400.
package_count and vuln_count of a group are de-duplicated across every SBOM
of every member — not the sum of the members’ counts. members lists the
parent project first when the parent is itself a project (is_project: true),
then the others by name. sources are the distinct parent_source values of
the members; owner is the owner the automatic grouping used.
{
"data": [
{
"name": "argo",
"is_project": true,
"project_count": 3,
"sbom_count": 32,
"package_count": 2104,
"vuln_count": 1388,
"latest_ingested": "2026-10-01T11:55:31Z",
"tags": [],
"sources": ["repo"],
"owner": "argoproj",
"members": [
{ "project_name": "argo", "sbom_count": 8, "...": "..." },
{ "project_name": "argo-cd/argo-rollouts", "parent": "argo", "parent_source": "repo", "...": "..." },
{ "project_name": "argo-cd/argo-workflows", "parent": "argo", "parent_source": "repo", "...": "..." }
]
}
],
"total": 18,
"page": 1,
"page_size": 50
}
Project groups
A project’s parent (product) is resolved when the API is queried, over all projects, so a changed mapping file or bucket config takes effect without re-ingesting. First match wins:
| # | Source (parent_source) | How |
|---|---|---|
| 1 | config | The mapping file PROJECT_GROUPS_FILE (Helm projectGroups), see Ownership |
| 2 | explicit | Assigned at ingest: bucket "parent", the parent path-layout token, ?parent= on upload, PARENT |
| 3 | tag | A tag that is the name of another project (layout tag/project) |
| 4 | repo | Owner of source_repo on any forge (GitLab subgroups and Azure DevOps projects kept) |
| 5 | document | Owner in a document name of the form owner/repo version |
| 6 | purl | Namespace of the root package URL: maven groupId, npm scope, Go module owner |
| 7 | supplier | Supplier / manufacturer of the root component |
Sources 4–7 only group when it is unambiguous: projects sharing an owner form a
family; if exactly one of them is a top-level project (no / in its name), it
becomes the parent of the others. Without one, the family is grouped under the
owner’s name from two members on. With several, nothing is grouped — a shared
owner such as kubernetes-sigs would otherwise lump unrelated projects
together; the mapping file decides there. Nothing is grouped by similar names.
GET /api/v1/tags
Every grouping label in use, with how many SBOMs and how many distinct projects carry it. Returns [] on an instance that tags nothing — clients should read that as “this deployment does not group projects” and hide the affordance rather than showing an empty control.
project_count is usually the more meaningful figure: tags group projects, so 312 SBOMs across 41 sandbox applications reads as 41, not 312.
Response: 200 OK
[
{ "tag": "graduated", "sbom_count": 89, "project_count": 12 },
{ "tag": "sandbox-applications", "sbom_count": 312, "project_count": 41 }
]
See Fleet Views for how tags are configured and how they differ from the cluster/namespace/project dimensions.
GET /api/v1/projects/{name}
One project as a unit. All counts are de-duplicated across the project’s
SBOMs (versions): package_count is distinct components, vuln_count is
distinct (vulnerability, package) pairs, bucketed under the highest severity
seen. license_breakdown is summed over SBOMs (per-SBOM category counts have
no component identity) and answers “what does this project ship under” as a
proportion. parents are the entries of tags that are themselves project
names; sub-projects are GET /api/v1/projects?tag={name}.
The UI’s project page renders this as its Overview tab — the dashboard scoped to one project: KPI cards, severity and license donuts, and the VEX split derived from the vulnerability rows below.
A name containing / (the org/project fallback shape) is sent
percent-encoded. 404 when no SBOM resolves to that name.
Response: 200 OK
{
"project_name": "notification-hub",
"tags": ["platform"],
"parents": [],
"related_project_count": 0,
"sbom_count": 1,
"package_count": 12,
"vuln_count": 0,
"critical_vulns": 0, "high_vulns": 0, "medium_vulns": 0, "low_vulns": 0,
"latest_ingested": "2026-09-24T20:03:40Z",
"latest_version": "2.3.0",
"latest_sbom_id": "8ca2efd4-9b83-5b93-a013-d53b7ddc54a9",
"clusters": ["prod-eu"],
"namespaces": ["platform"],
"license_breakdown": { "permissive": 7, "copyleft": 3, "unapproved": 1, "unknown": 1 },
"parent": "platform-suite",
"parent_source": "repo",
"parent_owner": "acme-platform",
"children": []
}
parent / parent_source / parent_owner are the resolved parent (see
Project groups); omitted when the project has none.
children are the projects whose resolved parent is this one. The tag-based
parents above are unchanged and still shown.
GET /api/v1/projects/{name}/sboms
The project’s SBOMs (versions), newest first. Same row shape and page /
page_size parameters as GET /api/v1/sboms.
GET /api/v1/projects/{name}/vulnerabilities
One row per distinct (vuln_id, purl) across every SBOM of the project, with
affected_sboms (in how many versions it occurs) and the effective VEX status:
among all statements scoped to any of the project’s SBOMs that cover the pair,
the newest wins. Same row shape as GET /api/v1/vulnerabilities; not paginated.
dependency_scope is the shallowest depth the package has in any of the
project’s SBOMs. Accepts ?scope= like GET /api/v1/vulnerabilities.
GET /api/v1/projects/{name}/packages
Distinct components across the project’s SBOMs, most exposed first
(vuln_count desc, then sbom_count desc). Paginated; search matches name
or PURL.
Response: 200 OK
{
"data": [
{ "name": "github.com/example/and-with-copyleft", "version": "v3.2.0",
"purl": "pkg:golang/github.com/example/and-with-copyleft@v3.2.0",
"sbom_count": 1, "vuln_count": 0 }
],
"total": 12, "page": 1, "page_size": 50
}
GET /api/v1/projects/license-compliance
Projects with copyleft, unapproved or unknown license packages (filtered by active exceptions). unapproved_count counts packages whose declared license is not on the policy allow-list; unknown_count counts packages without license information.
Response: 200 OK
[
{
"sbom_id": "a1b2c3d4-...",
"source_file": "my-project.spdx.json",
"document_name": "my-project-v2.1",
"copyleft_count": 3,
"unapproved_count": 2,
"unknown_count": 1,
"violating_licenses": ["LGPL-2.1-only", "CC0-1.0", "NOASSERTION"],
"non_compliant_packages": ["github.com/some/lgpl-lib", "github.com/unknown/pkg"],
"package_scopes": { "github.com/some/lgpl-lib": "direct", "github.com/unknown/pkg": "transitive" }
}
]
package_scopes gives each non-compliant package its dependency scope; unknown packages are left out, a missing key reads as unknown.
The full listing carries every non-compliant package name of every SBOM and grows with the instance; on a large fleet it is tens of megabytes. Clients that only need the numbers should ask for the summary.
Query parameters:
| Parameter | Description |
|---|---|
summary | true returns one summary per SBOM instead: the counts and licenses as above, the package lists reduced to non_compliant_package_count (equal to the length of non_compliant_packages in the full listing), direct_count and transitive_count. Root packages and packages without a recorded depth are in neither. The server reads the SBOMs in batches, so memory does not grow with the instance. The UI’s License Compliance page uses this form. |
Response (?summary=true): 200 OK
[
{
"sbom_id": "a1b2c3d4-...",
"source_file": "my-project.spdx.json",
"document_name": "my-project-v2.1",
"copyleft_count": 3,
"unapproved_count": 2,
"unknown_count": 1,
"violating_licenses": ["LGPL-2.1-only", "CC0-1.0", "NOASSERTION"],
"non_compliant_package_count": 2,
"direct_count": 1,
"transitive_count": 1
}
]
GET /api/v1/license-exceptions
Configured license exception document (read-only, loaded from the configured
file, URL or S3 object and reloaded every EXCEPTIONS_REFRESH_INTERVAL).
Includes pending/revoked rules for inspection; only approved and allowlisted
rules affect checks.
By default both arrays are empty. No CNCF approvals are automatically loaded.
Response: 200 OK
{
"version": "1.0.0",
"lastUpdated": "",
"blanketExceptions": [],
"exceptions": [
{
"id": "example-review-required",
"package": "example.org/team/library",
"license": "MPL-2.0",
"project": "my-sbom-document-name",
"status": "pending",
"approvedDate": "",
"comment": "Example only; requires organization approval"
}
]
}
Field names are camelCase, and package is a package name, not a PURL prefix
or glob. A package rule retains its package restriction even when project is
empty or "*". Otherwise project matches the exact SBOM document name.
Configuration errors (also on GET /api/v1/projects/license-compliance):
500 Internal Server Error when the current exceptions document is malformed —
even if an earlier version loaded, so a broken edit is never hidden behind stale
rules. 503 Service Unavailable when no version could be read yet (unreadable
file, unreachable or missing remote source); once one has loaded, a later
outage keeps serving it. Missing optional files return an empty document. An
existing empty primary file takes precedence over any fallback file. Stored
compliance results follow configuration changes automatically: the workers
re-check them in the background (Retroactive application).
GET /api/v1/license-policy
Active license classification policy (permissive vs. copyleft lists) and the
mode used to fold compound SPDX expressions (strict | permissive-wins |
off; see Deployment → License Policy).
Response: 200 OK
{
"permissive": [
"MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unlicense", "0BSD"
],
"copyleft": [
"GPL-2.0-only", "GPL-3.0-only", "LGPL-2.1-only", "AGPL-3.0-only", "MPL-2.0"
],
"expressionMode": "strict"
}
VEX (Vulnerability Exploitability eXchange)
GET /api/v1/vex/statements
Paginated list of all ingested VEX statements with affected SBOM cross-references.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | uint64 | 1 | Page number |
page_size | uint64 | 50 | Items per page (max 500) |
Response: 200 OK — PaginatedResponse<VEXStatementItem>
{
"data": [
{
"vex_id": "https://example.com/vex/2024-001",
"document_id": "https://openvex.dev/docs/example/v1",
"source_file": "golang-common.openvex.json",
"product_purl": "pkg:golang/golang.org/x/net",
"vuln_id": "CVE-2024-45338",
"cve_id": "CVE-2024-45338",
"aliases": ["GHSA-w32m-9786-jp63", "GO-2024-3333"],
"status": "not_affected",
"justification": "vulnerable_code_not_present",
"impact_statement": "The vulnerable HTTP/2 code path is not used",
"action_statement": "",
"vex_timestamp": "2024-12-01T00:00:00Z",
"ingested_at": "2026-05-20T14:30:00Z",
"sbom_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"author": "Example Org Security Team",
"role": "automated vulnerability triage",
"tooling": "VEXViper/0.1.0",
"status_notes": "confidence=0.94; vulnerable symbol not reachable",
"affected_sboms": [
{
"sbom_id": "a1b2c3d4-...",
"document_name": "etcd-v3.5.12"
}
]
}
],
"total": 42,
"page": 1,
"page_size": 50
}
sbom_id (#350) marks the SBOM the statement is scoped to; it is omitted for unscoped statements, whose product could not be resolved at ingest — those suppress findings in no SBOM. Unscoped statements are not permanently inert: the persisted product @id (product_ref, migration 020) lets the parsing worker re-resolve and scope them after each later SBOM ingest (VEX rescue). author, role, tooling and status_notes (#334) carry the statement’s provenance, taken from the OpenVEX document (author/role/tooling) and statement (status_notes) at ingest. All four are omitted when the source document does not set them — including every statement ingested before migration 017. Automated producers (VEXViper, #338) set tooling and write confidence + reasoning into status_notes; a role/tooling-based automated-vs-human badge and a ?vex_source= filter follow in Phase 3.
Packages
GET /api/v1/packages/search
Search packages by name across all ingested SBOMs.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | ✅ | Search query (case-insensitive substring match) |
page | uint64 | — | Page number (default 1) |
page_size | uint64 | — | Items per page (default 50, max 500) |
Response: 200 OK
{
"total_results": 15,
"items": [
{
"package_name": "golang.org/x/crypto",
"purl": "pkg:golang/golang.org/x/crypto",
"project_count": 87,
"versions": ["v0.21.0", "v0.22.0", "v0.23.0"],
"projects": [
{
"project_name": "etcd-v3.5.12",
"version": "v0.21.0",
"sbom_id": "a1b2c3d4-..."
}
]
}
],
"page": 1,
"page_size": 50,
"query": "crypto"
}
Errors:
400— Missing required query parameterq
GET /api/v1/packages/detail
Detailed information about a specific package, listing all projects that use it.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Exact package name |
page | uint64 | — | Page number (default 1) |
page_size | uint64 | — | Items per page (default 50, max 500) |
Response: 200 OK
{
"package_name": "golang.org/x/crypto",
"total_projects": 87,
"projects": [
{
"project_name": "etcd-v3.5.12",
"version": "v0.21.0",
"sbom_id": "a1b2c3d4-..."
}
],
"page": 1,
"page_size": 50
}
Errors:
400— Missing required query parametername
Global Search
GET /api/v1/search
Faceted search across packages, projects, vulnerabilities, and licenses in a single request. Powers the navbar typeahead and the /search results page in the UI.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | ✅ | Search query, minimum 2 characters (case-insensitive substring match) |
limit | uint64 | — | Max results per facet (default 5, max 50) |
Vulnerabilities match on the vulnerability ID, any of its OSV aliases (#412) and the summary text; each result carries cve_id and aliases. Projects match on the derived project key (org/repo for S3 sources, document name otherwise).
Response: 200 OK
{
"query": "grpc",
"packages": [
{
"package_name": "google.golang.org/grpc",
"purl": "pkg:golang/google.golang.org/grpc@v1.60.0",
"project_count": 87
}
],
"total_packages": 3,
"projects": [
{
"project_name": "grpc/grpc-go",
"sbom_count": 4,
"latest_sbom_id": "a1b2c3d4-..."
}
],
"total_projects": 1,
"vulnerabilities": [
{
"vuln_id": "GHSA-abcd-efgh-ijkl",
"cve_id": "CVE-2024-9999",
"aliases": ["CVE-2024-9999"],
"severity": "HIGH",
"summary": "gRPC denial of service",
"affected_sboms": 7
}
],
"total_vulnerabilities": 1,
"licenses": [
{
"license_id": "Apache-2.0",
"category": "permissive",
"sbom_count": 100
}
],
"total_licenses": 1
}
Facet arrays are always present (empty when no matches). Each facet returns at most limit items; the total_* fields report the full match counts.
Errors:
400— Query parameterqmissing or shorter than 2 characters
GET /api/v1/packages/archived
Packages whose upstream GitHub repository has been archived (supply-chain risk
indicator). One row per (SBOM, package) pair, so a dependency shared by five
projects appears five times — group by repo client-side.
Only repositories that something in the corpus still depends on are reported.
archived_repos_count in dashboard stats counts the
distinct repo values of exactly this endpoint, so the banner and the list can
never disagree.
Response: 200 OK
[
{
"sbom_id": "6f2b525a-fbde-5b43-aa92-ac06a3f7220d",
"source_file": "k2s/k2s-0.3.0.spdx.json",
"project_name": "k2s",
"project_version": "0.3.0",
"package_name": "gopkg.in/yaml.v3",
"package_purl": "pkg:golang/gopkg.in/yaml.v3@v3.0.1",
"repo": "go-yaml/yaml",
"last_pushed": "2025-04-01T17:00:11Z",
"stars": 7019
}
]
Returns [] when nothing is affected, never null.
repo is the GitHub repository, which frequently has no textual relationship
to the package: gopkg.in/yaml.v3 lives in go-yaml/yaml, k8s.io/client-go
in kubernetes/client-go, google.golang.org/grpc in grpc/grpc-go. The
mapping is maintained in internal/github and applied on both the ingest and
the query side — do not try to derive one from the other by string matching.
Clusters
GET /api/v1/clusters
List all known clusters with summary statistics. Returns an array of clusters sorted by SBOM count (descending).
Response: 200 OK
[
{
"name": "production",
"sbom_count": 85,
"package_count": 12400,
"vuln_count": 423,
"last_ingested": "2026-06-01T14:30:00Z"
},
{
"name": "staging",
"sbom_count": 42,
"package_count": 6100,
"vuln_count": 198,
"last_ingested": "2026-06-01T12:00:00Z"
}
]
Clusters with name: "" represent SBOMs ingested before multi-cluster support was enabled (migration 012). They appear as an empty string in the listing.
GET /api/v1/clusters/{name}/stats
Per-cluster dashboard statistics including severity breakdown and license distribution.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
name | string | Cluster name (max 200 chars) |
Response: 200 OK
{
"cluster": "production",
"total_sboms": 85,
"total_packages": 12400,
"total_vulnerabilities": 423,
"critical_vulns": 12,
"high_vulns": 89,
"medium_vulns": 210,
"low_vulns": 112,
"license_breakdown": {
"permissive": 9800,
"copyleft": 340,
"unapproved": 410,
"unknown": 1850
},
"last_ingested": "2026-06-01T14:30:00Z"
}
Error Responses:
400 Bad Request— cluster name empty or exceeds 200 characters404 Not Found— no SBOMs exist for this cluster
GET /api/v1/clusters/{name}/sboms
Paginated list of SBOMs for a specific cluster.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
name | string | Cluster name (max 200 chars) |
Query Parameters: Standard pagination (page, page_size)
Response: 200 OK — Standard PaginatedResponse[SBOMListItem]
{
"data": [
{
"sbom_id": "550e8400-e29b-41d4-a716-446655440000",
"source_file": "s3://platform-sboms/containerd/v1.7.spdx.json",
"spdx_version": "SPDX-2.3",
"document_name": "containerd-v1.7",
"package_count": 245,
"vuln_count": 12,
"ingested_at": "2026-06-01T14:30:00Z"
}
],
"total": 85,
"page": 1,
"page_size": 50
}
Namespaces
Namespaces are the second ownership dimension (#138). Unlike a cluster name, a
namespace name is only unique within a cluster — payments commonly exists
in prod-eu, prod-us and staging at the same time. Every namespace endpoint
therefore accepts an optional cluster query parameter:
cluster | Meaning |
|---|---|
| omitted / blank | Aggregate this namespace across the whole fleet |
prod-eu | Only the prod-eu slice of this namespace |
See Fleet Views for the configuration side and a runnable example.
GET /api/v1/namespaces
List all known namespaces with summary statistics, sorted by SBOM count (descending).
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
cluster | string | Optional cluster scope (max 200 chars) |
Response: 200 OK
[
{
"name": "payments",
"cluster_count": 3,
"sbom_count": 4,
"package_count": 14,
"vuln_count": 89,
"last_ingested": "2026-09-18T07:06:19Z"
},
{
"name": "search",
"cluster_count": 1,
"sbom_count": 1,
"package_count": 4,
"vuln_count": 34,
"last_ingested": "2026-09-18T07:06:16Z"
}
]
cluster_count is the number of distinct clusters this namespace appears in —
the fastest way to spot a name that is reused by several teams. When the listing
is filtered, each item additionally echoes the cluster it was scoped to.
Error Responses:
400 Bad Request—clusterexceeds 200 characters
GET /api/v1/namespaces/{name}/stats
Per-namespace statistics including severity breakdown and license distribution.
Mirrors the shape of GET /api/v1/clusters/{name}/stats, plus the list of
clusters the namespace spans.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
name | string | Namespace name (max 200 chars) |
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
cluster | string | Optional cluster scope (max 200 chars) |
Response: 200 OK
{
"namespace": "payments",
"cluster": "staging",
"clusters": ["staging"],
"total_sboms": 1,
"total_packages": 4,
"total_vulnerabilities": 18,
"critical_vulns": 0,
"high_vulns": 1,
"medium_vulns": 5,
"low_vulns": 12,
"license_breakdown": { "permissive": 3 },
"last_ingested": "2026-09-18T07:06:15Z"
}
Error Responses:
400 Bad Request— namespace name empty or either value exceeds 200 characters404 Not Found— no SBOMs exist for this namespace (in this scope)
GET /api/v1/namespaces/{name}/sboms
Paginated list of SBOMs in a namespace.
Query Parameters: cluster plus standard pagination (page, page_size)
Response: 200 OK — Standard PaginatedResponse[SBOMListItem]. Each item
carries its cluster, namespace and project.
Fleet
GET /api/v1/fleet
The complete cluster → namespace → project hierarchy in a single response.
This is what the UI’s Fleet tree renders; fetching it per cluster would turn a
50-cluster fleet into 50 round trips on page load.
Response: 200 OK
[
{
"name": "prod-eu",
"sbom_count": 4,
"vuln_count": 116,
"namespaces": [
{
"name": "payments",
"sbom_count": 2,
"vuln_count": 41,
"projects": [
{ "name": "ledger", "sbom_count": 1, "vuln_count": 15, "last_ingested": "2026-09-18T07:06:18Z" },
{ "name": "payment-api", "sbom_count": 1, "vuln_count": 26, "last_ingested": "2026-09-18T07:06:13Z" }
]
}
]
}
]
Cluster and namespace counts are the sums of their children. An empty name at
any level means that dimension is unassigned for those SBOMs — it is
returned rather than hidden, because a large unassigned bucket is the clearest
signal that ingestion labelling is not configured.
Security Headers
All responses include:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Content-Security-Policy | default-src 'none'; frame-ancestors 'none' |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(), microphone=(), geolocation=() |
CORS
Configured via CORS_ALLOWED_ORIGINS environment variable.
- Default:
*(development) - Production: Set to your frontend domain(s)
- Allowed methods:
GET,OPTIONSon every endpoint;POSTis additionally advertised only on/api/v1/sboms/upload - Allowed headers:
Content-Type,Authorization,X-Service-Token,X-API-Key,X-Filename
Headless Mode
When deployed with ui.enabled: false in Helm values, BOMHort runs as a pure API service:
- All endpoints above remain available
- No UI container is deployed
- No Nginx, no static file serving
- Ideal for CI/CD integrations, custom dashboards, or aggregation by external tools
# values-headless.yaml
ui:
enabled: false
apiGateway:
ingress:
enabled: true
hosts:
- sbom-api.internal.example.com
Usage Examples
curl
# Get dashboard overview
curl -s http://localhost:8080/api/v1/stats/dashboard | jq .
# Search SBOMs
curl -s "http://localhost:8080/api/v1/sboms?search=containerd&page_size=10" | jq .
# Get vulnerabilities (all findings, each with its VEX status attached)
curl -s "http://localhost:8080/api/v1/vulnerabilities?page_size=100" | jq .
# Check which projects are affected by a CVE
curl -s http://localhost:8080/api/v1/vulnerabilities/CVE-2024-45338/affected-projects | jq .
# Search packages
curl -s "http://localhost:8080/api/v1/packages/search?q=crypto" | jq .
CI/CD Integration (GitHub Actions)
- name: Check for critical vulnerabilities
run: |
CRITICAL=$(curl -sf "$BOMHORT_URL/api/v1/stats/dashboard" | jq '.critical_vulns')
if [ "$CRITICAL" -gt 0 ]; then
echo "::error::$CRITICAL critical vulnerabilities detected"
exit 1
fi