Pular para conteúdo

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 fields project (the Dependency-Track project UUID) and bom (the SBOM bytes), header X-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's processing field (Dependency- Track's own BomProcessingResponse) is false, 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 own ProjectMetrics fields critical, high and policyViolationsTotal (every open, unsuppressed finding/ violation in that scope — see internal/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/Fail are set unconditionally — exactly like evaluateGate's own severity threshold loop — with the metric numbers in Lines (e.g. ssor_dtrack: critical 3 > max_critical 0) and a synthetic render.AuditFinding{RuleID: "ssor_dtrack", ...} in BlockingFindings, 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 Inconclusive with a stable reason token; Fail is set only when the SAME gate.inconclusive mode evaluateGate already reads is block — a run with warn or no gate: key at all only gets a visible, non-blocking alert, mirroring AUR-519's own warn/"" semantics exactly. A breach is never downgraded by this mode.
  • No-op: cfg.Declared() == false (no ssor_dtrack section, or enabled left at its default false) makes applyDTrackGate return a zero gateDecision and 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.go exercises ValidateHost, and Run end-to-end against an httptest fake 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.go drives the same scenarios through the real runReview command (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 (selectors AC-001..AC-005, all, AC-003-MUT-001) runs the same Go tests inside a sealed, offline copy of the tree, and verifies the AC-003-MUT-001 mutation (treating a polling timeout as approved — the exact defect this card refuses to allow) turns TestAUR550PollingTimeoutIsInconclusive red.

Known gaps

  • .github/workflows/review.yml and action.yml are 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_dtrack works today through .aurumcode/config.yml alone; exposing it as reusable workflow inputs is a follow-up, not implemented here.
  • Dependency-Track's own GET .../metrics/project/{project}/current can 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.