# Stella Ops Triage API Contract v1 Base path: `/api/triage/v1` This contract is served by `scanner.webservice` (or a dedicated triage facade that reads scanner-owned tables). All risk/lattice outputs originate from `scanner.webservice`. Key requirements: - Deterministic outputs (policyId + policyVersion + inputsHash). - Proof-linking (chips reference evidenceIds). - `concelier` and `excititor` preserve prune source: API surfaces source chains via `sourceRefs`. ## 0. Conventions ### 0.1 Identifiers - `caseId` == `findingId` (UUID). A case is a finding scoped to an asset/environment. - Hashes are hex strings. ### 0.2 Caching - GET endpoints SHOULD return `ETag`. - Clients SHOULD send `If-None-Match`. ### 0.3 Errors Standard error envelope: ```json { "error": { "code": "string", "message": "string", "details": { "any": "json" }, "traceId": "string" } } ``` Common codes: * `not_found` * `validation_error` * `conflict` * `unauthorized` * `forbidden` * `rate_limited` ## 1. Findings Table ### 1.1 List findings `GET /findings` Query params: * `showMuted` (bool, default false) * `showHidden` (bool, default false) — include gated findings in results * `gatingReason` (optional, enum) — filter by specific gating reason * `lane` (optional, enum) * `search` (optional string; searches asset, purl, cveId) * `page` (int, default 1) * `pageSize` (int, default 50; max 200) * `sort` (optional: `updatedAt`, `score`, `lane`) * `order` (optional: `asc|desc`) Response 200: ```json { "page": 1, "pageSize": 50, "total": 12345, "actionableCount": 847, "mutedCounts": { "reach": 1904, "vex": 513, "compensated": 18 }, "gatedBuckets": { "unreachableCount": 1904, "policyDismissedCount": 234, "backportedCount": 456, "vexNotAffectedCount": 513, "supersededCount": 12, "userMutedCount": 18, "totalHiddenCount": 3137 }, "rows": [ { "id": "uuid", "lane": "BLOCKED", "verdict": "BLOCK", "score": 87, "reachable": "YES", "vex": "affected", "exploit": "YES", "asset": "prod/api-gateway:1.2.3", "updatedAt": "2025-12-16T01:02:03Z", "gatingReason": null, "isHiddenByDefault": false } ] } ``` ## 2. Case Narrative ### 2.1 Get case header `GET /cases/{caseId}` Response 200: ```json { "id": "uuid", "verdict": "BLOCK", "lane": "BLOCKED", "score": 87, "policyId": "prod-strict", "policyVersion": "2025.12.14", "inputsHash": "hex", "why": "Reachable path observed; exploit signal present; prod-strict blocks.", "chips": [ { "key": "reachability", "label": "Reachability", "value": "Reachable (92%)", "evidenceIds": ["uuid"] }, { "key": "vex", "label": "VEX", "value": "affected", "evidenceIds": ["uuid"] }, { "key": "gate", "label": "Gate", "value": "BLOCKED by prod-strict", "evidenceIds": ["uuid"] } ], "sourceRefs": [ { "domain": "concelier", "kind": "cve_record", "ref": "concelier:osv:...", "pruned": false }, { "domain": "excititor", "kind": "effective_vex", "ref": "excititor:openvex:...", "pruned": false } ], "updatedAt": "2025-12-16T01:02:03Z" } ``` Notes: * `sourceRefs` provides preserved provenance chains (including pruned markers when applicable). ## 3. Evidence ### 3.1 List evidence for case `GET /cases/{caseId}/evidence` Response 200: ```json { "caseId": "uuid", "items": [ { "id": "uuid", "type": "VEX_DOC", "title": "Vendor OpenVEX assertion", "issuer": "vendor.example", "signed": true, "signedBy": "CN=Vendor VEX Signer", "contentHash": "hex", "createdAt": "2025-12-15T22:10:00Z", "previewUrl": "/api/triage/v1/evidence/uuid/preview", "rawUrl": "/api/triage/v1/evidence/uuid/raw" } ] } ``` ### 3.2 Get raw evidence object `GET /evidence/{evidenceId}/raw` Returns: * `application/json` for JSON evidence * `application/octet-stream` for binary * MUST include `Content-SHA256` header (hex) when possible. ### 3.3 Preview evidence object `GET /evidence/{evidenceId}/preview` Returns a compact representation safe for UI preview. ## 4. Decisions ### 4.1 Create decision `POST /decisions` Request body: ```json { "caseId": "uuid", "kind": "MUTE_REACH", "reasonCode": "NON_REACHABLE", "note": "No entry path in this env; reviewed runtime traces.", "ttl": "2026-01-16T00:00:00Z" } ``` Response 201: ```json { "decision": { "id": "uuid", "kind": "MUTE_REACH", "reasonCode": "NON_REACHABLE", "note": "No entry path in this env; reviewed runtime traces.", "ttl": "2026-01-16T00:00:00Z", "actor": { "subject": "user:abc", "display": "Vlad" }, "createdAt": "2025-12-16T01:10:00Z", "signatureRef": "dsse:rekor:uuid" } } ``` Rules: * Server signs decisions (DSSE) and persists signature reference. * Creating a decision MUST create a `Snapshot` with trigger `DECISION`. ### 4.2 Revoke decision `POST /decisions/{decisionId}/revoke` Body (optional): ```json { "reason": "Mistake; reachability now observed." } ``` Response 200: ```json { "revokedAt": "2025-12-16T02:00:00Z", "signatureRef": "dsse:rekor:uuid" } ``` ## 5. Snapshots & Smart-Diff ### 5.1 List snapshots `GET /cases/{caseId}/snapshots` Response 200: ```json { "caseId": "uuid", "items": [ { "id": "uuid", "trigger": "POLICY_UPDATE", "changedAt": "2025-12-16T00:00:00Z", "fromInputsHash": "hex", "toInputsHash": "hex", "summary": "Policy version changed; gate threshold crossed." } ] } ``` ### 5.2 Smart-Diff between two snapshots `GET /cases/{caseId}/smart-diff?from={inputsHashA}&to={inputsHashB}` Response 200: ```json { "fromInputsHash": "hex", "toInputsHash": "hex", "inputsChanged": [ { "key": "policyVersion", "before": "2025.12.14", "after": "2025.12.16", "evidenceIds": ["uuid"] } ], "outputsChanged": [ { "key": "verdict", "before": "SHIP", "after": "BLOCK", "evidenceIds": ["uuid"] } ] } ``` ## 6. Export Evidence Bundle ### 6.1 Start export `POST /cases/{caseId}/export` Response 202: ```json { "exportId": "uuid", "status": "QUEUED" } ``` ### 6.2 Poll export `GET /exports/{exportId}` Response 200: ```json { "exportId": "uuid", "status": "READY", "downloadUrl": "/api/triage/v1/exports/uuid/download" } ``` ### 6.3 Download bundle `GET /exports/{exportId}/download` Returns: * `application/zip` * DSSE envelope embedded (or alongside in zip) * bundle contains replay manifest, artifacts, risk result, snapshots ## 7. Events (Notify.WebService integration) These are emitted by `notify.webservice` when scanner outputs change. * `first_signal` * fired on first actionable detection for an asset/environment * `risk_changed` * fired when verdict/lane changes or thresholds crossed * `gate_blocked` * fired when CI gate blocks Event payload includes: * caseId * old/new verdict/lane/score (for changed events) * inputsHash * links to `/cases/{caseId}` ## 8. Gating Explainability ### 8.1 GatingReason enum Reason why a finding is hidden by default in quiet-by-design triage: | Value | Description | |-------|-------------| | `none` | Not gated - visible in default view | | `unreachable` | Not reachable from any application entrypoint | | `policy_dismissed` | Waived or tolerated by policy rules | | `backported` | Patched via distro backport | | `vex_not_affected` | VEX statement declares not affected with sufficient trust | | `superseded` | Superseded by newer advisory | | `user_muted` | Explicitly muted by user | ### 8.2 GatedBucketsSummary ```json { "unreachableCount": 1904, "policyDismissedCount": 234, "backportedCount": 456, "vexNotAffectedCount": 513, "supersededCount": 12, "userMutedCount": 18, "totalHiddenCount": 3137 } ``` ### 8.3 VEX Trust Scoring VEX statements include trust scoring for policy-driven acceptance: ```json { "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path", "issuedBy": "vendor.example", "issuedAt": "2025-12-15T10:00:00Z", "trustScore": 0.85, "policyTrustThreshold": 0.80, "meetsPolicyThreshold": true, "trustBreakdown": { "authority": 0.90, "accuracy": 0.85, "timeliness": 0.80, "verification": 0.85 } } ``` Trust score components: * **Authority** (0-1): Issuer reputation and category * **Accuracy** (0-1): Historical correctness * **Timeliness** (0-1): Response speed * **Verification** (0-1): Signature validity ### 8.4 Finding gating fields Extended fields on finding response: | Field | Type | Description | |-------|------|-------------| | `gatingReason` | string | Why this finding is hidden (null if visible) | | `isHiddenByDefault` | boolean | True if gated by default view | | `subgraphId` | string | Link to reachability subgraph | | `deltasId` | string | Link to delta comparison | | `gatingExplanation` | string | Human-readable explanation | ## 9. Unified Evidence ### 9.1 Get unified evidence `GET /findings/{findingId}/evidence` Returns all evidence tabs in one call. Response 200: ```json { "findingId": "uuid", "cveId": "CVE-2024-1234", "componentPurl": "pkg:npm/lodash@4.17.20", "sbom": { ... }, "reachability": { ... }, "vexClaims": [ ... ], "attestations": [ ... ], "deltas": { ... }, "policy": { ... }, "manifests": { "artifactDigest": "sha256:...", "feedDigest": "sha256:...", "policyDigest": "sha256:...", "manifestDigest": "sha256:..." }, "verification": { "status": "verified", "hashesMatch": true, "signaturesValid": true, "isFresh": true }, "replayCommand": "stella scan replay --artifact sha256:abc...", "generatedAt": "2025-12-16T01:02:03Z" } ``` ### 9.2 Export evidence bundle `GET /findings/{findingId}/evidence/export?format=zip` Downloads complete evidence package as archive (ZIP or TAR.GZ). Response headers: * `Content-Type`: application/zip or application/gzip * `Content-Disposition`: attachment; filename="evidence-{findingId}.zip" * `X-Archive-Digest`: sha256:{digest} ### 9.3 Get replay command `GET /findings/{findingId}/replay-command` Returns copy-ready CLI command for deterministic reproduction. Response 200: ```json { "fullCommand": "stella scan replay --artifact sha256:abc... --manifest sha256:def...", "shortCommand": "stella replay snapshot --verdict V-12345", "bundleDownloadUrl": "/v1/triage/findings/uuid/evidence/export", "inputHashes": { "artifactDigest": "sha256:...", "manifestHash": "sha256:...", "feedSnapshotHash": "sha256:...", "policyHash": "sha256:..." } } ``` --- **Document Version**: 1.1 **Updated**: 2025-12-24 (Sprint 9200 - Gating Explainability) **Target Platform**: .NET 10, PostgreSQL >= 16