AUR-550 — SBOM submitted to Dependency-Track, gated by project metrics
Status
Implemented in internal/dtrack (the Dependency-Track v5 REST client),
internal/config/dtrack.go (quality_gates.ssor_dtrack config, central-policy
precedence) and cmd/aurumcode/aur550.go (the gate hook, wired into
runReview/runPRReview right after evaluateGate). Behavior proof:
internal/dtrack/client_test.go and cmd/aurumcode/aur550_test.go::TestAUR550*.
Acceptance: tests/acceptance/AUR-550.sh.
Problem
AurumCode had no way to enforce a corporate "single source of record" inventory policy: upload the already-generated SBOM (AUR-549's own output) to a central OWASP Dependency-Track v5 server, and fail the review when that project's own open vulnerabilities/policy violations cross an operator's threshold. Without it, a repository's SBOM could exist on disk and never reach the inventory system at all, or reach it without any gate ever reading the result back.
Design
internal/dtrack is a thin REST client with three calls, mirroring
Dependency-Track v5's own API exactly:
Upload—POST {host}/api/v1/bom, multipart fieldsproject(the Dependency-Track project UUID) andbom(the SBOM bytes), headerX-Api-Key. Returns the processing token from the JSON response.PollUntilProcessed—GET {host}/api/v1/bom/token/{token}on an injectable interval, until the response'sprocessingfield (Dependency- Track's ownBomProcessingResponse) isfalse, or an injectable clock decides the configured timeout elapsed first (ErrTimeout).ProjectMetrics—GET {host}/api/v1/metrics/project/{project}/current, decoded into Dependency-Track v5's ownProjectMetricsfieldscritical,highandpolicyViolationsTotal(every open, unsuppressed finding/ violation in that scope — seeinternal/dtrack/client.go's own package doc for the exact semantics of each field).
Every one of those three metrics fields decodes as *int, never int: a
response Dependency-Track did not fully populate (a metrics snapshot not yet
computed right after processing finishes, or a differently-versioned server)
must read as unknown, never as the silent zero that would make an
unscanned project look clean. EvaluateMetrics/dtrack.Run refuse to grade
an incomplete response at all — the caller's gate turns that refusal into
inconclusive, never an approval. The same fail-closed rule applies to the
processing field during polling (missing/null is read as "still
processing", so a server that never finishes still times out instead of
silently reading as done).
dtrack.Run composes the three calls end to end and returns a single
Outcome{Active, Breach, Inconclusive, InconclusiveReason, Reasons, ...} —
Reasons and InconclusiveReason are always this package's own stable,
non-server-authored tokens (dtrack_timeout, dtrack_http_error,
dtrack_unreachable, dtrack_metrics_incomplete) plus the bare integers read
from the metrics response, never a response body or a raw Go error string —
so a caller can publish Outcome verbatim to a review body, an audit record
or SARIF without a second redaction pass over free text it does not control.
HTTPS-only, no credential-forwarding redirect
dtrack.ValidateHost (called once by NewClient, and again at config
Validate() time so a misconfigured host is a loud parse-time error) accepts
an absolute https:// URL, or http:// only when the host is a loopback IP
literal (127.0.0.0/8 or ::1 — never the name localhost, which depends
on a resolver/hosts file this rule refuses to trust). That one exception
exists only so this package's own tests, and the card's own acceptance
script, can point it at an httptest.Server; it is never a production
escape hatch. Userinfo and a query string in server_api_host are refused
outright. Client's own http.Client never follows a redirect
(CheckRedirect returns http.ErrUseLastResponse): Go's HTTP client strips
Authorization/Cookie on a cross-host redirect but not a custom header
such as X-Api-Key, so the only safe rule is to never follow one at all — a
3xx response surfaces as its own HTTP status instead.
Config and central-policy precedence
quality_gates.ssor_dtrack (internal/config/dtrack.go, SsorDtrackConfig)
is a pointer field on the shared QualityGatesConfig struct (alongside
Sast/SupplyChain, AUR-548/AUR-551's own sections — coordinated so the
three cards' concurrent edits to internal/config converge on one struct).
SsorDtrack is nil exactly when the section is absent from the yaml; every
method on it has a pointer receiver and is nil-safe, so a caller never has to
special-case "section absent" before calling Declared(). enabled: true is
the single opt-in switch — a config that sets server_api_host/secrets ahead
of flipping enabled changes nothing yet (a staged rollout stays inert).
ApplyCentralPolicy (internal/config/central.go) governs QualityGates
exactly like Gate/Rules/Ignore/Exceptions already do: under a central
policy, effective.QualityGates = central.QualityGates unconditionally, and
a repository-declared ssor_dtrack is dropped wholesale with its own named
warning. This is what makes "a repository cannot disable a policy-enabled
ssor_dtrack" true: it is the general "only the policy decides" rule applied
to one more field, not a special case.
sbom_generator.output_file (SsorDtrackConfig.SbomGenerator, AUR-549's own
sub-section, nested here per AUR-549's own documented example) names the
path to the already-generated CycloneDX document this card uploads as-is.
Generating that file is explicitly AUR-549's job and this card's own
Non-goal; SBOMOutputFile() is the one nil-safe accessor the gate hook uses,
so an ssor_dtrack section enabled ahead of sbom_generator being declared
reads as a (nil-safe) misconfiguration, not a panic.
Gate wiring
cmd/aurumcode/aur550.go's applyDTrackGate runs once, immediately after
evaluateGate in both runReview (main.go) and runPRReview (pr.go),
and mergeDTrackGate folds its gateDecision-shaped result into the SAME
gateResult/gateInconclusiveReason those two functions already publish
through gateResult.Lines, writeComplianceArtifacts and
publishPolicyGateStatus (policygate.go, AUR-519/521/538, all untouched).
This card adds no second gate and no second writer:
- Breach (a metric over its threshold):
Breach/Failare set unconditionally — exactly likeevaluateGate's own severity threshold loop — with the metric numbers inLines(e.g.ssor_dtrack: critical 3 > max_critical 0) and a syntheticrender.AuditFinding{RuleID: "ssor_dtrack", ...}inBlockingFindings, so the audit record and SARIF carry the same breach the review body does. - Inconclusive (unreachable server, HTTP error, polling timeout, an
incomplete metrics response, a missing secret, or an unreadable SBOM): sets
Inconclusivewith a stable reason token;Failis set only when the SAMEgate.inconclusivemodeevaluateGatealready reads isblock— a run withwarnor nogate:key at all only gets a visible, non-blocking alert, mirroring AUR-519's ownwarn/""semantics exactly. A breach is never downgraded by this mode. - No-op:
cfg.Declared() == false(nossor_dtracksection, orenabledleft at its defaultfalse) makesapplyDTrackGatereturn a zerogateDecisionand the SAME filter pointer — zero HTTP calls, nothing published, byte-identical to this card never existing.
gateInconclusiveReason (the single string writeComplianceArtifacts reads)
is combined by joining with a comma, never overwritten — AUR-537's own single
-reason assertions (e.g. "provider_failure") stay intact on a run that never
declares ssor_dtrack, and a run where both a model-side and a dtrack-side
reason fire publishes both.
The API key never leaks (AC-004)
redaction.Filter is an immutable value built once by NewFilter/FromEnv
(no mutator exists, and internal/security/redaction is read-only to this
card). applyDTrackGate resolves the key from the environment variable named
by api_key_secret and, the instant it is non-empty, builds a new filter
(redaction.NewFilter(os.Getenv(redaction.CanaryEnv), apiKey)) carrying it as
an exact-value secret, before constructing the HTTP client or doing anything
else that could write a byte. The caller (main.go/pr.go) then wraps its
own stdout/stderr with a second redaction.Writer layer built from that
new filter (wrapWriterWithFilter) — a second layer on top of whatever
writer was already there, flushed via defer before the function returns —
so every subsequent write on those two sinks is redacted by the new filter as
well, and filter itself (the value writeComplianceArtifacts uses for the
audit record and SARIF) is reassigned to the new one. The key is also never
put into an error string this package returns: StatusError carries only the
operation name and the HTTP status code, the response body is read and
discarded (io.Copy(io.Discard, io.LimitReader(...))) on every non-2xx
response, and dtrack.Outcome's own Reasons/InconclusiveReason are
stable tokens plus integers only.
cmd/aurumcode/aur550_test.go::TestAUR550APIKeyNeverLeaks is the canary: the
fake server deliberately echoes the key back in its own 401 body (as a real
Dependency-Track error page could), confirms the server genuinely received it
in X-Api-Key (so the test cannot pass vacuously), and then asserts its
absence from stdout, stderr, the audit record and the SARIF document alike.
Minimum Dependency-Track server version
AUR-549's pinned Trivy emits CycloneDX 1.7 (it has no option to emit an
older spec version). Dependency-Track only accepts a 1.7 BOM upload starting
at server version 5.1.0 (or 4.14.4 on the 4.x line) —
dependencytrack.org/news/dependency-track-5-1,
releases/tag/5.1.0,
releases/tag/4.14.4.
An older server answers the upload with a 4xx (an unsupported-specVersion
body). That rejection is handled by the exact same path as any other
non-2xx response on Upload (StatusError/ErrHTTPStatus ->
ReasonHTTPError): the gate reads dtrack_http_error and goes
inconclusive per policy, never approved, and PollUntilProcessed/
ProjectMetrics are never even called (there is no token to poll and no
project to grade from a BOM the server never accepted). See
cmd/aurumcode/aur550_test.go::TestAUR550UploadRejectedBySpecVersionIsInconclusive.
This card does not itself validate the server's own version ahead of time
-- it is a precondition for the operator to meet, documented here and in
docs/configuration.md, not something this client can discover safely
without an extra, undocumented API call.
Non-goals
Generating the SBOM (AUR-549's own job — this card only reads
sbom_generator.output_file); administering Dependency-Track projects on the
server (creating, renaming, deleting one is a human operation).
Testing
internal/dtrack/client_test.goexercisesValidateHost, andRunend-to-end against anhttptestfake Dependency-Track server: approval within thresholds, a breach with the right numbers, a polling timeout, an HTTP error, an unreachable (closed) server, and an incomplete metrics response — all with an injected fake clock/sleeper, so a 180s configured timeout costs a test nothing.cmd/aurumcode/aur550_test.godrives the same scenarios through the realrunReviewcommand (AC-001/AC-002/AC-003), the API-key canary (AC-004), a custom secret-variable-name run and a disabled-by-default no-op run (AC-005).tests/acceptance/AUR-550.sh(selectorsAC-001..AC-005,all,AC-003-MUT-001) runs the same Go tests inside a sealed, offline copy of the tree, and verifies theAC-003-MUT-001mutation (treating a polling timeout as approved — the exact defect this card refuses to allow) turnsTestAUR550PollingTimeoutIsInconclusivered.
Known gaps
.github/workflows/review.ymlandaction.ymlare untouched: AUR-548 and AUR-549 are editing both concurrently in parallel worktrees, and wiring a real Dependency-Track upload step into CI belongs with whichever card lands the actual SBOM-generation step it depends on.quality_gates.ssor_dtrackworks today through.aurumcode/config.ymlalone; exposing it as reusable workflow inputs is a follow-up, not implemented here.- Dependency-Track's own
GET .../metrics/project/{project}/currentcan return a stale snapshot immediately after processing finishes (the server's own metrics recomputation is asynchronous to BOM processing). This card polls processing to completion but does not add a second wait for a fresh metrics snapshot; documented here as a known limitation, not implemented.