A small webhook bridge that ships SBOMs from Harbor into BOMHort — automatically, with zero changes to either product.
image push -> Harbor (Trivy generates the SBOM, stored as an OCI accessory)
-> SCANNING_COMPLETED webhook -> bomhort-bridge
-> pulls the SBOM from the registry API
-> drops <project>__<repo>@<digest>.spdx.json into BOMHort's watched dir
-> BOMHort ingestion-watcher -> parsing-worker -> dashboard
Once connected, every SBOM Harbor generates appears in BOMHort's analytics: license breakdown, OSV-matched vulnerabilities, cross-project views — all derived from the package list alone, with no vulnerability scan in Harbor.
The bridge is a single Go binary (~350 lines, standard library only) and is stateless: its only output is files in a folder.
- Harbor >= v2.13.0, installed with Trivy. Both matter:
- Below v2.13.0 Harbor never fires webhooks for SBOM scans — the scan succeeds but the event is silently dropped (fixed upstream in goharbor/harbor#21794). There is no error anywhere, so this is easy to lose a day on.
- Without Trivy (
./install.sh --with-trivy) Harbor has no scanner and cannot generate SBOMs at all. Check under Administration -> Interrogation Services that Trivy is registered.
- Docker + Docker Compose for the BOMHort stack.
- Go >= 1.24 to run the bridge (or build the binary once and copy it).
git clone https://github.com/seebom-labs/BOMHort.git
cd BOMHort
docker compose up --build -dThis starts ClickHouse, the api-gateway (:8080), the parsing-worker, and the
UI. The compose file mounts ./sboms as the watched drop directory — that
is where the bridge will write.
Port clash: BOMHort's UI maps to host port 8090 by default — the same port a local Harbor often uses. If needed, add a
docker-compose.override.ymlnext to the compose file:services: ui: ports: !override - "8091:80"
Note: the ingestion-watcher service is one-shot — it scans the
directory, enqueues new files, and exits. That is normal; you re-run it
after new SBOMs arrive (step 5).
git clone https://github.com/container-registry/bomhort-bridge.git
cd bomhort-bridge
export HARBOR_URL=http://<harbor-host>:<port>
export HARBOR_USERNAME='robot$bomhort-bridge' # or admin for a quick test
export HARBOR_PASSWORD='<secret>'
export SBOM_DIR=/path/to/BOMHort/sboms # the dir BOMHort watches
export WEBHOOK_AUTH='<shared-secret>' # must match the webhook's auth header
go run .You should see:
bomhort-bridge listening on :9099, dropping SBOMs into ...
- For anything beyond a quick test, create a robot account with pull permission on the project instead of using admin.
LISTEN_ADDRoverrides the port;INSECURE_SKIP_VERIFY=truefor self-signed HTTPS Harbor.
In the Harbor UI: Project -> Webhooks -> New Webhook
- Notify type:
http - Event type: check Scanning finished (fires as
SCANNING_COMPLETED) - Endpoint URL:
http://<bridge-host>:9099/webhook - Auth Header: the same value as
WEBHOOK_AUTH
Or via API:
curl -u <user>:<pass> -X POST -H 'Content-Type: application/json' \
http://<harbor-host>:<port>/api/v2.0/projects/<project>/webhook/policies -d '{
"name": "bomhort-bridge",
"enabled": true,
"event_types": ["SCANNING_COMPLETED"],
"targets": [{"type":"http","address":"http://<bridge-host>:9099/webhook","auth_header":"<shared-secret>"}]
}'The endpoint must be reachable from inside Harbor's containers. If the bridge runs on the Docker host,
localhostwill NOT work — inside a container it points at the container itself. Use the Harbor compose network's gateway IP (find it withdocker network inspect <harbor-network> --format '{{(index .IPAM.Config 0).Gateway}}') or a real host IP / DNS name.Verify from inside Harbor:
docker exec harbor-core curl -s -o /dev/null -w '%{http_code}' http://<bridge-host>:9099/webhook -X POST -H 'Authorization: <shared-secret>' -d '{}'— expect200(and a harmlessignoring event type ""line in the bridge log; that is the bridge correctly discarding the empty test event).
docker pull nginx:alpine
docker tag nginx:alpine <harbor-host>:<port>/<project>/nginx:alpine
docker push <harbor-host>:<port>/<project>/nginx:alpineGenerate the SBOM — in the Harbor UI (open the artifact -> Generate SBOM) or via API. To find the artifact digest:
curl -u <user>:<pass> http://<harbor-host>:<port>/api/v2.0/projects/<project>/repositories/<repo>/artifacts \
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['digest'])"Then trigger the scan:
curl -u <user>:<pass> -X POST -H 'Content-Type: application/json' \
-d '{"scan_type":"sbom"}' \
http://<harbor-host>:<port>/api/v2.0/projects/<project>/repositories/<repo>/artifacts/<digest>/scanTip: enable automatically generate SBOM on push in the project configuration to make the whole pipeline hands-free.
When the scan finishes, the bridge log shows:
dropped <project>__<repo>@<digest>.spdx.json (... bytes)
event SCANNING_COMPLETED: handled 1 SBOM resource(s)
cd BOMHort
docker compose run --rm ingestion-watcherThe watcher enqueues the new file; the always-running parsing-worker parses
it, matches vulnerabilities via OSV, and classifies licenses. Open the
dashboard (default http://localhost:8090, or :8091 with the override) —
the SBOM appears with package count, license breakdown, and vulnerabilities.
For continuous operation, run the watcher on a schedule (cron or a systemd timer).
- One file per artifact digest. Files are named
<project>__<repo>@<digest>.spdx.json, so a re-scan of the same artifact maps to the same file. A new image version has a new digest and correctly becomes a new file. - Normalized output — re-scans never create duplicates. Trivy's raw
output is not byte-stable: every run stamps a fresh
creationInfo.created, a randomdocumentNamespaceUUID, and anannotationDateon every package annotation. That would defeat BOMHort's content-hash dedup and re-ingest every re-scan as a duplicate row. The bridge therefore pins these fields to fixed values before writing (documentNamespacebecomesurn:harbor:sbom:<digest>, the dates become a fixed epoch). Re-scans of an unchanged artifact produce identical bytes, so dedup works downstream with no state kept anywhere. The SBOM remains valid SPDX; only per-run scan metadata is pinned. - Vulnerability-scan events are ignored. The same
SCANNING_COMPLETEDevent type also fires for vulnerability scans (payload withoutsbom_overview); the bridge only acts on SBOM completions. - Half-written files are never ingested. The bridge writes to a
_-prefixed temp file first (BOMHort's watcher ignore-prefix), then renames atomically. - Delivery guarantees. Webhooks are fire-and-forget: Harbor retries a few times, then the event is lost (e.g. if the bridge is down). Fine for a proof of concept; for production add a periodic reconciliation poll, or wait for the spec-based adapter integration.
| Symptom | Likely cause / fix |
|---|---|
| SBOM scan succeeds but no webhook arrives; the policy shows zero executions | Harbor < v2.13.0 — SBOM scans fire no event on older versions, completely silently. Upgrade Harbor. |
| Webhook executions show failures in Harbor | Endpoint not reachable from inside Harbor's containers (see step 3), or the auth header does not match WEBHOOK_AUTH. |
| "Generate SBOM" missing / scan fails immediately | Trivy is not installed or not the default scanner. Re-run the Harbor installer with --with-trivy. |
| Bridge logs an error fetching the accessory | The Harbor credentials lack pull permission on the project, or HARBOR_URL is not reachable from where the bridge runs. |
File appears in sboms/ but nothing shows on the dashboard |
The one-shot ingestion-watcher has not run since the file arrived — run docker compose run --rm ingestion-watcher, then give the parsing-worker a few seconds. |
| Dashboard shows 0 vulnerabilities for an image that should have them | OSV matching happens in the parsing-worker and needs outbound internet access; check its logs. |
go test ./... # unit tests, including the normalization determinism test
go build .