diff --git a/docs/implplan/SPRINT_1103_0001_0001_replay_token_library.md b/docs/implplan/SPRINT_1103_0001_0001_replay_token_library.md index 1e516f773..db8c6872a 100644 --- a/docs/implplan/SPRINT_1103_0001_0001_replay_token_library.md +++ b/docs/implplan/SPRINT_1103_0001_0001_replay_token_library.md @@ -1,6 +1,6 @@ # SPRINT_1103_0001_0001 - Replay Token Library -**Status:** TODO +**Status:** DONE **Priority:** P0 - CRITICAL **Module:** Core Libraries, Attestor **Working Directory:** `src/__Libraries/StellaOps.Audit.ReplayToken/` @@ -451,17 +451,17 @@ public static class ServiceCollectionExtensions | # | Task | Status | Assignee | Notes | |---|------|--------|----------|-------| -| 1 | Create project `StellaOps.Audit.ReplayToken` | TODO | | New library | -| 2 | Implement `IReplayTokenGenerator` interface | TODO | | Per §3.1 | -| 3 | Implement `ReplayTokenRequest` model | TODO | | Per §3.2 | -| 4 | Implement `ReplayToken` model | TODO | | Per §3.3 | -| 5 | Implement `Sha256ReplayTokenGenerator` | TODO | | Per §3.4 | -| 6 | Implement decision token extensions | TODO | | Per §3.5 | -| 7 | Implement CLI snippet generator | TODO | | Per §3.6 | -| 8 | Add service registration | TODO | | Per §3.7 | -| 9 | Write unit tests for determinism | TODO | | Verify same inputs → same output | -| 10 | Write unit tests for verification | TODO | | | -| 11 | Document API in README | TODO | | | +| 1 | Create project `StellaOps.Audit.ReplayToken` | DONE | | New library | +| 2 | Implement `IReplayTokenGenerator` interface | DONE | | Per §3.1 | +| 3 | Implement `ReplayTokenRequest` model | DONE | | Per §3.2 | +| 4 | Implement `ReplayToken` model | DONE | | Per §3.3 | +| 5 | Implement `Sha256ReplayTokenGenerator` | DONE | | Per §3.4 | +| 6 | Implement decision token extensions | DONE | | Per §3.5 | +| 7 | Implement CLI snippet generator | DONE | | Per §3.6 | +| 8 | Add service registration | DONE | | Per §3.7 | +| 9 | Write unit tests for determinism | DONE | | Verify same inputs → same output | +| 10 | Write unit tests for verification | DONE | | | +| 11 | Document API in README | DONE | | | --- @@ -469,22 +469,22 @@ public static class ServiceCollectionExtensions ### 5.1 Determinism Requirements -- [ ] Same inputs always produce same token -- [ ] Array ordering doesn't affect output (sorted internally) -- [ ] Null handling is consistent -- [ ] Token format is stable across versions +- [x] Same inputs always produce same token +- [x] Array ordering doesn't affect output (sorted internally) +- [x] Null handling is consistent +- [x] Token format is stable across versions ### 5.2 Verification Requirements -- [ ] `Verify()` returns true for matching inputs -- [ ] `Verify()` returns false for different inputs -- [ ] Token parsing handles valid and invalid formats +- [x] `Verify()` returns true for matching inputs +- [x] `Verify()` returns false for different inputs +- [x] Token parsing handles valid and invalid formats ### 5.3 CLI Requirements -- [ ] Generated CLI snippet is valid bash -- [ ] Snippet includes all necessary parameters -- [ ] Snippet uses proper escaping +- [x] Generated CLI snippet is valid bash +- [x] Snippet includes all necessary parameters +- [x] Snippet uses proper escaping --- diff --git a/docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md b/docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md index fd297a275..b0c9c6925 100644 --- a/docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md +++ b/docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md @@ -1,6 +1,6 @@ # SPRINT_3101_0001_0001 - Scanner API Standardization -**Status:** TODO +**Status:** DOING **Priority:** P0 - CRITICAL **Module:** Scanner.WebService **Working Directory:** `src/Scanner/StellaOps.Scanner.WebService/` diff --git a/docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md b/docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md index ea704bf87..4b540bdcf 100644 --- a/docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md +++ b/docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md @@ -1,6 +1,6 @@ # SPRINT_3102_0001_0001 - Postgres Call Graph Tables -**Status:** TODO +**Status:** DOING **Priority:** P2 - MEDIUM **Module:** Signals, Scanner **Working Directory:** `src/Signals/StellaOps.Signals.Storage.Postgres/` diff --git a/src/Api/StellaOps.Api.OpenApi/scanner/openapi.yaml b/src/Api/StellaOps.Api.OpenApi/scanner/openapi.yaml index 0f0fdbf3a..da761358c 100644 --- a/src/Api/StellaOps.Api.OpenApi/scanner/openapi.yaml +++ b/src/Api/StellaOps.Api.OpenApi/scanner/openapi.yaml @@ -8,8 +8,8 @@ info: idempotent submissions and async computation. servers: - - url: /api - description: Scanner service endpoint + - url: https://scanner.stellaops.local/api/v1 + description: Example Scanner endpoint tags: - name: Scans @@ -83,7 +83,7 @@ paths: description: SHA-256 digest for idempotency (RFC 9530) schema: type: string - example: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: + example: "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:" requestBody: required: true content: diff --git a/src/Api/StellaOps.Api.OpenApi/stella.yaml b/src/Api/StellaOps.Api.OpenApi/stella.yaml index 1f704e3bf..a6729f2e4 100644 --- a/src/Api/StellaOps.Api.OpenApi/stella.yaml +++ b/src/Api/StellaOps.Api.OpenApi/stella.yaml @@ -23,6 +23,9 @@ servers: - url: https://policy.stellaops.local description: Example Policy Engine endpoint x-service: policy + - url: https://scanner.stellaops.local/api/v1 + description: Example Scanner endpoint + x-service: scanner - url: https://scheduler.stellaops.local description: Example Scheduler endpoint x-service: scheduler @@ -47,6 +50,18 @@ tags: description: Policy management APIs - name: Queues description: Queue metrics APIs + - name: Scans + description: Scan lifecycle management + - name: CallGraphs + description: Call graph ingestion + - name: RuntimeEvidence + description: Runtime evidence collection + - name: Reachability + description: Reachability analysis and queries + - name: Exports + description: Report exports + - name: ProofSpines + description: Verifiable audit trails paths: /authority/introspect: post: @@ -1129,6 +1144,367 @@ paths: $ref: "#/components/responses/ErrorResponse" x-service: policy x-original-path: /policies + /scanner/scans: + post: + tags: + - Scans + operationId: createScan + summary: Create a new scan + description: | + Initiates a new scan context. Returns a scanId for subsequent + call graph and evidence submissions. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.CreateScanRequest" + responses: + "201": + description: Scan created + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.CreateScanResponse" + "400": + $ref: "#/components/responses/BadRequest" + x-service: scanner + x-original-path: /scans + /scanner/scans/{scanId}: + get: + tags: + - Scans + operationId: getScan + summary: Get scan status + parameters: + - $ref: "#/components/parameters/ScanIdPath" + responses: + "200": + description: Scan details + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.ScanDetails" + "404": + $ref: "#/components/responses/NotFound" + x-service: scanner + x-original-path: /scans/{scanId} + /scanner/scans/{scanId}/callgraphs: + post: + tags: + - CallGraphs + operationId: submitCallGraph + summary: Submit a call graph + description: | + Submits a language-specific call graph for reachability analysis. + Idempotent: duplicate submissions with same Content-Digest are ignored. + parameters: + - $ref: "#/components/parameters/ScanIdPath" + - name: Content-Digest + in: header + required: true + description: SHA-256 digest for idempotency (RFC 9530) + schema: + type: string + example: "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.CallGraphV1" + application/x-ndjson: + schema: + type: string + description: Streaming NDJSON for large graphs + responses: + "202": + description: Call graph accepted + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.CallGraphAcceptedResponse" + "400": + $ref: "#/components/responses/BadRequest" + "409": + description: Duplicate submission (idempotent success) + "413": + description: Call graph too large + x-service: scanner + x-original-path: /scans/{scanId}/callgraphs + /scanner/scans/{scanId}/compute-reachability: + post: + tags: + - Reachability + operationId: computeReachability + summary: Trigger reachability computation + description: | + Triggers reachability analysis for the scan. Idempotent. + Computation is asynchronous; poll scan status for completion. + parameters: + - $ref: "#/components/parameters/ScanIdPath" + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.ComputeReachabilityRequest" + responses: + "202": + description: Computation started + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.ComputeReachabilityResponse" + "400": + $ref: "#/components/responses/BadRequest" + "409": + description: Computation already in progress + x-service: scanner + x-original-path: /scans/{scanId}/compute-reachability + /scanner/scans/{scanId}/exports/cdxr: + get: + tags: + - Exports + operationId: exportCycloneDxReachability + summary: Export as CycloneDX with reachability extension + parameters: + - $ref: "#/components/parameters/ScanIdPath" + responses: + "200": + description: CycloneDX with reachability + content: + application/vnd.cyclonedx+json: + schema: + type: object + x-service: scanner + x-original-path: /scans/{scanId}/exports/cdxr + /scanner/scans/{scanId}/exports/openvex: + get: + tags: + - Exports + operationId: exportOpenVex + summary: Export as OpenVEX + parameters: + - $ref: "#/components/parameters/ScanIdPath" + responses: + "200": + description: OpenVEX document + content: + application/json: + schema: + type: object + x-service: scanner + x-original-path: /scans/{scanId}/exports/openvex + /scanner/scans/{scanId}/exports/sarif: + get: + tags: + - Exports + operationId: exportSarif + summary: Export findings as SARIF + parameters: + - $ref: "#/components/parameters/ScanIdPath" + responses: + "200": + description: SARIF report + content: + application/sarif+json: + schema: + type: object + x-service: scanner + x-original-path: /scans/{scanId}/exports/sarif + /scanner/scans/{scanId}/reachability/components: + get: + tags: + - Reachability + operationId: getReachabilityByComponent + summary: Get reachability status by component + parameters: + - $ref: "#/components/parameters/ScanIdPath" + - name: purl + in: query + description: Filter by Package URL + schema: + type: string + - name: status + in: query + description: Filter by reachability status + schema: + type: string + enum: + - reachable + - unreachable + - possibly_reachable + - unknown + responses: + "200": + description: Component reachability results + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.ComponentReachabilityList" + x-service: scanner + x-original-path: /scans/{scanId}/reachability/components + /scanner/scans/{scanId}/reachability/explain: + get: + tags: + - Reachability + operationId: explainReachability + summary: Explain reachability for CVE/component + description: | + Returns detailed explanation of why a CVE affects a component, + including path witness, evidence chain, and contributing factors. + parameters: + - $ref: "#/components/parameters/ScanIdPath" + - name: cve + in: query + required: true + schema: + type: string + - name: purl + in: query + required: true + schema: + type: string + responses: + "200": + description: Reachability explanation + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.ReachabilityExplanation" + "404": + description: CVE/component combination not found + x-service: scanner + x-original-path: /scans/{scanId}/reachability/explain + /scanner/scans/{scanId}/reachability/findings: + get: + tags: + - Reachability + operationId: getReachabilityFindings + summary: Get vulnerability findings with reachability + parameters: + - $ref: "#/components/parameters/ScanIdPath" + - name: cve + in: query + description: Filter by CVE ID + schema: + type: string + - name: status + in: query + description: Filter by reachability status + schema: + type: string + enum: + - reachable + - unreachable + - possibly_reachable + - unknown + responses: + "200": + description: Vulnerability findings with reachability + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.ReachabilityFindingList" + x-service: scanner + x-original-path: /scans/{scanId}/reachability/findings + /scanner/scans/{scanId}/runtimeevidence: + post: + tags: + - RuntimeEvidence + operationId: submitRuntimeEvidence + summary: Submit runtime evidence + description: | + Submits runtime execution evidence (stack traces, loaded modules). + Merges with existing evidence for the scan. + parameters: + - $ref: "#/components/parameters/ScanIdPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.RuntimeEvidenceV1" + responses: + "202": + description: Evidence accepted + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.RuntimeEvidenceAcceptedResponse" + "400": + $ref: "#/components/responses/BadRequest" + x-service: scanner + x-original-path: /scans/{scanId}/runtimeevidence + /scanner/scans/{scanId}/sbom: + post: + tags: + - Scans + operationId: submitSbom + summary: Submit SBOM for scan + description: | + Associates an SBOM (CycloneDX or SPDX) with the scan. + Required before reachability computation. + parameters: + - $ref: "#/components/parameters/ScanIdPath" + requestBody: + required: true + content: + application/vnd.cyclonedx+json: + schema: + type: object + application/spdx+json: + schema: + type: object + responses: + "202": + description: SBOM accepted + "400": + $ref: "#/components/responses/BadRequest" + x-service: scanner + x-original-path: /scans/{scanId}/sbom + /scanner/scans/{scanId}/spines: + get: + tags: + - ProofSpines + operationId: getSpinesByScan + summary: List proof spines for a scan + parameters: + - $ref: "#/components/parameters/ScanIdPath" + responses: + "200": + description: Proof spines for scan + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.ProofSpineList" + x-service: scanner + x-original-path: /scans/{scanId}/spines + /scanner/spines/{spineId}: + get: + tags: + - ProofSpines + operationId: getSpine + summary: Get a proof spine + description: Returns full spine with all segments and verification status. + parameters: + - name: spineId + in: path + required: true + schema: + type: string + responses: + "200": + description: Proof spine details + content: + application/json: + schema: + $ref: "#/components/schemas/scanner.ProofSpine" + "404": + $ref: "#/components/responses/NotFound" + x-service: scanner + x-original-path: /spines/{spineId} /scheduler/health: get: tags: @@ -1250,6 +1626,212 @@ paths: x-original-path: /queues/{name} components: schemas: + BreakingChange: + type: object + description: Description of a breaking change between deprecated and successor + endpoints. + required: + - type + - description + properties: + type: + type: string + enum: + - parameter-removed + - parameter-renamed + - parameter-type-changed + - response-schema-changed + - header-removed + - header-renamed + - status-code-changed + - content-type-changed + - authentication-changed + description: Category of the breaking change. + path: + type: string + description: JSON path to the affected element. + example: $.parameters[0].name + description: + type: string + description: Human-readable description of the change. + example: Parameter 'page' renamed to 'cursor' + migrationAction: + type: string + description: Recommended action for consumers. + example: Replace 'page' parameter with 'cursor' using the nextCursor value from + previous response. + DeprecationMetadata: + type: object + description: > + Deprecation metadata for API endpoints. Applied as x-deprecation + extension + + on operation objects. Used by Spectral rules, changelog generation, and + + notification templates. + required: + - deprecatedAt + - sunsetAt + - successorPath + - reason + properties: + deprecatedAt: + type: string + format: date-time + description: ISO 8601 timestamp when the endpoint was marked deprecated. + example: 2025-01-15T00:00:00Z + sunsetAt: + type: string + format: date-time + description: ISO 8601 timestamp when the endpoint will be removed. + example: 2025-07-15T00:00:00Z + successorPath: + type: string + description: Path to the replacement endpoint (if available). + example: /v2/resources + successorOperationId: + type: string + description: Operation ID of the replacement endpoint. + example: getResourcesV2 + reason: + type: string + description: Human-readable explanation for the deprecation. + example: Replaced by paginated v2 endpoint with cursor-based pagination. + migrationGuide: + type: string + format: uri + description: URL to migration documentation. + example: https://docs.stella-ops.org/migration/resources-v2 + notificationChannels: + type: array + description: Notification channels for deprecation announcements. + items: + type: string + enum: + - slack + - teams + - email + - webhook + default: + - email + affectedConsumerHints: + type: array + description: Hints about affected consumers (e.g., SDK names, client IDs). + items: + type: string + breakingChanges: + type: array + description: List of breaking changes in the successor endpoint. + items: + $ref: "#/schemas/BreakingChange" + DeprecationNotificationEvent: + type: object + description: Event payload for deprecation notifications sent to Notify service. + required: + - eventId + - eventType + - timestamp + - tenantId + - deprecation + properties: + eventId: + type: string + format: uuid + description: Unique identifier for this notification event. + eventType: + type: string + const: api.deprecation.announced + description: Event type for routing in Notify service. + timestamp: + type: string + format: date-time + description: ISO 8601 timestamp when the event was generated. + tenantId: + type: string + description: Tenant scope for the notification. + deprecation: + $ref: "#/schemas/DeprecationSummary" + DeprecationReport: + type: object + description: Aggregated report of all deprecations for changelog/SDK publishing. + required: + - generatedAt + - schemaVersion + - deprecations + properties: + generatedAt: + type: string + format: date-time + description: When this report was generated. + schemaVersion: + type: string + const: api.deprecation.report@1 + totalCount: + type: integer + description: Total number of deprecated endpoints. + upcomingSunsets: + type: integer + description: Number of endpoints with sunset within 90 days. + deprecations: + type: array + items: + $ref: "#/schemas/DeprecationSummary" + DeprecationSummary: + type: object + description: Summary of a deprecated endpoint for notification purposes. + required: + - service + - path + - method + - deprecatedAt + - sunsetAt + properties: + service: + type: string + description: Service name owning the deprecated endpoint. + example: authority + path: + type: string + description: API path of the deprecated endpoint. + example: /v1/tokens + method: + type: string + enum: + - GET + - POST + - PUT + - PATCH + - DELETE + - HEAD + - OPTIONS + description: HTTP method of the deprecated endpoint. + operationId: + type: string + description: OpenAPI operation ID. + example: createToken + deprecatedAt: + type: string + format: date-time + sunsetAt: + type: string + format: date-time + daysUntilSunset: + type: integer + description: Computed days remaining until sunset. + example: 180 + successorPath: + type: string + description: Path to the replacement endpoint. + reason: + type: string + description: Deprecation reason. + migrationGuide: + type: string + format: uri + changelogUrl: + type: string + format: uri + description: URL to the API changelog entry for this deprecation. ErrorEnvelope: type: object required: @@ -1809,6 +2391,552 @@ components: type: integer nextPageToken: type: string + scanner.CallGraphAcceptedResponse: + type: object + properties: + callgraphId: + type: string + nodeCount: + type: integer + edgeCount: + type: integer + digest: + type: string + scanner.CallGraphArtifact: + type: object + properties: + artifactKey: + type: string + kind: + type: string + enum: + - assembly + - jar + - module + - binary + sha256: + type: string + purl: + type: string + scanner.CallGraphEdge: + type: object + required: + - from + - to + properties: + from: + type: string + description: Source node ID + to: + type: string + description: Target node ID + kind: + type: string + enum: + - static + - heuristic + default: static + reason: + type: string + enum: + - direct_call + - virtual_call + - reflection_string + - di_binding + - dynamic_import + - unknown + weight: + type: number + default: 1 + scanner.CallGraphEntrypoint: + type: object + required: + - nodeId + - kind + properties: + nodeId: + type: string + kind: + type: string + enum: + - http + - grpc + - cli + - job + - event + - unknown + route: + type: string + description: HTTP route pattern (e.g., /api/orders/{id}) + framework: + type: string + enum: + - aspnetcore + - minimalapi + - spring + - express + - fastapi + - unknown + scanner.CallGraphNode: + type: object + required: + - nodeId + - symbolKey + properties: + nodeId: + type: string + artifactKey: + type: string + symbolKey: + type: string + description: Canonical symbol key (Namespace.Type::Method(signature)) + visibility: + type: string + enum: + - public + - internal + - private + - unknown + isEntrypointCandidate: + type: boolean + default: false + scanner.CallGraphV1: + type: object + required: + - schema + - scanKey + - language + - nodes + - edges + properties: + schema: + type: string + const: stella.callgraph.v1 + scanKey: + type: string + format: uuid + language: + type: string + enum: + - dotnet + - java + - node + - python + - go + - rust + - binary + - ruby + - php + artifacts: + type: array + items: + $ref: "#/components/schemas/scanner.CallGraphArtifact" + nodes: + type: array + items: + $ref: "#/components/schemas/scanner.CallGraphNode" + edges: + type: array + items: + $ref: "#/components/schemas/scanner.CallGraphEdge" + entrypoints: + type: array + items: + $ref: "#/components/schemas/scanner.CallGraphEntrypoint" + scanner.ComponentReachability: + type: object + properties: + purl: + type: string + status: + type: string + enum: + - reachable + - unreachable + - possibly_reachable + - unknown + confidence: + type: number + latticeState: + type: string + why: + type: array + items: + type: string + scanner.ComponentReachabilityList: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/scanner.ComponentReachability" + total: + type: integer + scanner.ComputeReachabilityRequest: + type: object + properties: + forceRecompute: + type: boolean + default: false + entrypoints: + type: array + items: + type: string + description: Override auto-detected entrypoints + targets: + type: array + items: + type: string + description: Specific symbols to analyze + scanner.ComputeReachabilityResponse: + type: object + properties: + jobId: + type: string + status: + type: string + enum: + - queued + - processing + estimatedDuration: + type: string + description: ISO-8601 duration estimate + scanner.CreateScanRequest: + type: object + required: + - artifactDigest + properties: + artifactDigest: + type: string + description: Image or artifact digest (sha256:...) + repoUri: + type: string + commitSha: + type: string + policyProfileId: + type: string + metadata: + type: object + additionalProperties: true + scanner.CreateScanResponse: + type: object + properties: + scanId: + type: string + format: uuid + status: + type: string + enum: + - created + - pending + - processing + - completed + - failed + createdAt: + type: string + format: date-time + scanner.ErrorResponse: + type: object + properties: + error: + type: string + message: + type: string + details: + type: object + scanner.EvidenceChain: + type: object + properties: + staticAnalysis: + type: object + properties: + callgraphDigest: + type: string + pathLength: + type: integer + edgeTypes: + type: array + items: + type: string + runtimeEvidence: + type: object + properties: + observed: + type: boolean + hitCount: + type: integer + lastObserved: + type: string + format: date-time + policyEvaluation: + type: object + properties: + policyDigest: + type: string + verdict: + type: string + verdictReason: + type: string + scanner.ExplanationReason: + type: object + properties: + code: + type: string + description: + type: string + impact: + type: number + scanner.LoadedArtifact: + type: object + properties: + artifactKey: + type: string + evidence: + type: string + enum: + - loaded_module + - mapped_file + - jar_loaded + scanner.ProofSegment: + type: object + properties: + segmentId: + type: string + segmentType: + type: string + enum: + - SBOM_SLICE + - MATCH + - REACHABILITY + - GUARD_ANALYSIS + - RUNTIME_OBSERVATION + - POLICY_EVAL + index: + type: integer + inputHash: + type: string + resultHash: + type: string + prevSegmentHash: + type: string + toolId: + type: string + toolVersion: + type: string + status: + type: string + enum: + - pending + - verified + - partial + - invalid + - untrusted + createdAt: + type: string + format: date-time + scanner.ProofSpine: + type: object + properties: + spineId: + type: string + artifactId: + type: string + vulnerabilityId: + type: string + policyProfileId: + type: string + verdict: + type: string + verdictReason: + type: string + rootHash: + type: string + scanRunId: + type: string + segments: + type: array + items: + $ref: "#/components/schemas/scanner.ProofSegment" + createdAt: + type: string + format: date-time + supersededBySpineId: + type: string + scanner.ProofSpineList: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/scanner.ProofSpineSummary" + total: + type: integer + scanner.ProofSpineSummary: + type: object + properties: + spineId: + type: string + artifactId: + type: string + vulnerabilityId: + type: string + verdict: + type: string + segmentCount: + type: integer + createdAt: + type: string + format: date-time + scanner.ReachabilityExplanation: + type: object + properties: + cveId: + type: string + purl: + type: string + status: + type: string + confidence: + type: number + latticeState: + type: string + pathWitness: + type: array + items: + type: string + description: Symbol path from entrypoint to vulnerable code + why: + type: array + items: + $ref: "#/components/schemas/scanner.ExplanationReason" + evidence: + $ref: "#/components/schemas/scanner.EvidenceChain" + spineId: + type: string + description: Reference to ProofSpine for full audit trail + scanner.ReachabilityFinding: + type: object + properties: + cveId: + type: string + purl: + type: string + status: + type: string + confidence: + type: number + latticeState: + type: string + severity: + type: string + affectedVersions: + type: string + scanner.ReachabilityFindingList: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/scanner.ReachabilityFinding" + total: + type: integer + scanner.RuntimeEnvironment: + type: object + properties: + os: + type: string + k8s: + type: object + properties: + namespace: + type: string + pod: + type: string + container: + type: string + imageDigest: + type: string + buildId: + type: string + scanner.RuntimeEvidenceAcceptedResponse: + type: object + properties: + evidenceId: + type: string + sampleCount: + type: integer + loadedArtifactCount: + type: integer + scanner.RuntimeEvidenceV1: + type: object + required: + - schema + - scanKey + - collectedAt + properties: + schema: + type: string + const: stella.runtimeevidence.v1 + scanKey: + type: string + format: uuid + collectedAt: + type: string + format: date-time + environment: + $ref: "#/components/schemas/scanner.RuntimeEnvironment" + samples: + type: array + items: + $ref: "#/components/schemas/scanner.RuntimeSample" + loadedArtifacts: + type: array + items: + $ref: "#/components/schemas/scanner.LoadedArtifact" + scanner.RuntimeSample: + type: object + properties: + timestamp: + type: string + format: date-time + pid: + type: integer + threadId: + type: integer + frames: + type: array + items: + type: string + description: Array of node IDs representing call stack + sampleWeight: + type: number + default: 1 + scanner.ScanDetails: + type: object + properties: + scanId: + type: string + status: + type: string + artifactDigest: + type: string + callGraphCount: + type: integer + runtimeEvidenceCount: + type: integer + reachabilityStatus: + type: string + enum: + - pending + - computing + - completed + - failed + createdAt: + type: string + format: date-time + completedAt: + type: string + format: date-time scheduler.ErrorEnvelope: type: object properties: diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj index 1dc2531e4..6073f43d1 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj @@ -21,6 +21,7 @@ + - \ No newline at end of file + diff --git a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md new file mode 100644 index 000000000..7a31e7ad5 --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md @@ -0,0 +1,6 @@ +# Scanner WebService Local Tasks + +| Task ID | Sprint | Status | Notes | +| --- | --- | --- | --- | +| `SCAN-API-3101-001` | `docs/implplan/SPRINT_3101_0001_0001_scanner_api_standardization.md` | DOING | Align Scanner OpenAPI spec with current endpoints and include ProofSpine routes; compose into `src/Api/StellaOps.Api.OpenApi/stella.yaml`. | +| `PROOFSPINE-3100-API` | `docs/implplan/SPRINT_3100_0001_0001_proof_spine_system.md` | DOING | Implement and test `/api/v1/spines/*` endpoints and wire verification output. | diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/HmacDsseSigningService.cs b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/HmacDsseSigningService.cs index bc2407655..a00d227f7 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/HmacDsseSigningService.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/HmacDsseSigningService.cs @@ -77,7 +77,7 @@ public sealed class HmacDsseSigningService : IDsseSigningService if (CryptographicOperations.FixedTimeEquals(expected.SignatureBytes, provided)) { - return Task.FromResult(new DsseVerificationOutcome(true, expected.IsTrusted, failureReason: null)); + return Task.FromResult(new DsseVerificationOutcome(true, expected.IsTrusted, FailureReason: null)); } return Task.FromResult(new DsseVerificationOutcome(false, expected.IsTrusted, "dsse_sig_mismatch")); @@ -141,4 +141,3 @@ public sealed class HmacDsseSigningService : IDsseSigningService } } } - diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/ProofSpineModels.cs b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/ProofSpineModels.cs new file mode 100644 index 000000000..bc00a18b7 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/ProofSpineModels.cs @@ -0,0 +1,66 @@ +using StellaOps.Replay.Core; + +namespace StellaOps.Scanner.ProofSpine; + +/// +/// Represents a complete verifiable decision chain from SBOM to VEX verdict. +/// +public sealed record ProofSpine( + string SpineId, + string ArtifactId, + string VulnerabilityId, + string PolicyProfileId, + IReadOnlyList Segments, + string Verdict, + string VerdictReason, + string RootHash, + string ScanRunId, + DateTimeOffset CreatedAt, + string? SupersededBySpineId); + +/// +/// A single evidence segment in the proof chain. +/// +public sealed record ProofSegment( + string SegmentId, + ProofSegmentType SegmentType, + int Index, + string InputHash, + string ResultHash, + string? PrevSegmentHash, + DsseEnvelope Envelope, + string ToolId, + string ToolVersion, + ProofSegmentStatus Status, + DateTimeOffset CreatedAt); + +public sealed record GuardCondition( + string Name, + string Type, + string Value, + bool Passed); + +/// +/// Segment types in execution order. +/// +public enum ProofSegmentType +{ + SbomSlice = 1, + Match = 2, + Reachability = 3, + GuardAnalysis = 4, + RuntimeObservation = 5, + PolicyEval = 6 +} + +/// +/// Verification status of a segment. +/// +public enum ProofSegmentStatus +{ + Pending = 0, + Verified = 1, + Partial = 2, + Invalid = 3, + Untrusted = 4 +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj new file mode 100644 index 000000000..00ee305fd --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + preview + enable + enable + false + + + + + + + + + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/TASKS.md b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/TASKS.md new file mode 100644 index 000000000..3b03546c3 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/TASKS.md @@ -0,0 +1,5 @@ +# Scanner Storage Local Tasks + +| Task ID | Sprint | Status | Notes | +| --- | --- | --- | --- | +| `PROOFSPINE-3100-DB` | `docs/implplan/SPRINT_3100_0001_0001_proof_spine_system.md` | DOING | Add Postgres migrations and repository for ProofSpine persistence (`proof_spines`, `proof_segments`, `proof_spine_history`). | diff --git a/src/Signals/StellaOps.Signals.Storage.Postgres/AGENTS.md b/src/Signals/StellaOps.Signals.Storage.Postgres/AGENTS.md new file mode 100644 index 000000000..cefb6485a --- /dev/null +++ b/src/Signals/StellaOps.Signals.Storage.Postgres/AGENTS.md @@ -0,0 +1,24 @@ +# Signals Storage Postgres Guild Charter + +## Mission +Provide deterministic, offline-first PostgreSQL persistence for Signals, including call graph storage/projection, unknowns registry/scoring, and reachability facts needed by Scanner and Policy. + +## Scope +- PostgreSQL schema owned by Signals (default schema: `signals`). +- Embedded SQL migrations under `Migrations/*.sql`, executed via `AddStartupMigrations`. +- Repository implementations under `Repositories/` (query + ingestion/sync). + +## Required Reading +- `docs/modules/platform/architecture-overview.md` +- `docs/signals/reachability.md` +- `docs/signals/callgraph-formats.md` +- `docs/signals/runtime-facts.md` +- `docs/signals/unknowns-registry.md` +- Current sprint file: `docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md` + +## Working Agreement +1. Update task state to `DOING`/`DONE` in both `/docs/implplan/SPRINT_*.md` and local `TASKS.md`. +2. Keep outputs deterministic: stable ordering, canonical JSON where applicable, UTC timestamps only. +3. Prefer additive, non-breaking startup migrations; avoid long-running data rewrites at startup. +4. Maintain offline posture: no network I/O, no external schema downloads. +5. Changes must be covered by tests (integration preferred for migrations + repositories). diff --git a/src/Signals/StellaOps.Signals.Storage.Postgres/TASKS.md b/src/Signals/StellaOps.Signals.Storage.Postgres/TASKS.md new file mode 100644 index 000000000..085d3b0d4 --- /dev/null +++ b/src/Signals/StellaOps.Signals.Storage.Postgres/TASKS.md @@ -0,0 +1,5 @@ +# Signals Storage Postgres Local Tasks + +| Task ID | Sprint | Status | Notes | +| --- | --- | --- | --- | +| `SIG-PG-3102-001` | `docs/implplan/SPRINT_3102_0001_0001_postgres_callgraph_tables.md` | DOING | Add relational call graph tables + migrations wiring; register query repository and add integration coverage. | diff --git a/src/Signals/StellaOps.Signals/Options/UnknownsDecayOptions.cs b/src/Signals/StellaOps.Signals/Options/UnknownsDecayOptions.cs new file mode 100644 index 000000000..8f1590a9e --- /dev/null +++ b/src/Signals/StellaOps.Signals/Options/UnknownsDecayOptions.cs @@ -0,0 +1,19 @@ +namespace StellaOps.Signals.Options; + +/// +/// Configuration for unknowns decay batch processing. +/// +public sealed class UnknownsDecayOptions +{ + public const string SectionName = "Signals:UnknownsDecay"; + + /// + /// Time of day (UTC hour) for nightly decay batch. Default: 2 (2 AM UTC). + /// + public int NightlyBatchHourUtc { get; set; } = 2; + + /// + /// Maximum subjects per batch run. Default: 10000. + /// + public int MaxSubjectsPerBatch { get; set; } = 10_000; +} diff --git a/src/Signals/StellaOps.Signals/Options/UnknownsScoringOptions.cs b/src/Signals/StellaOps.Signals/Options/UnknownsScoringOptions.cs index 877324a35..ca27c03ea 100644 --- a/src/Signals/StellaOps.Signals/Options/UnknownsScoringOptions.cs +++ b/src/Signals/StellaOps.Signals/Options/UnknownsScoringOptions.cs @@ -66,6 +66,11 @@ public sealed class UnknownsScoringOptions /// public int StalenessMaxDays { get; set; } = 14; + /// + /// Staleness time constant (tau) in days for exponential decay. Default: 14 + /// + public double StalenessTauDays { get; set; } = 14.0; + // ===== BAND THRESHOLDS ===== /// @@ -80,6 +85,11 @@ public sealed class UnknownsScoringOptions // ===== RESCAN SCHEDULING ===== + /// + /// Minutes until HOT items are rescanned. Default: 15 + /// + public int HotRescanMinutes { get; set; } = 15; + /// /// Hours until WARM items are rescanned. Default: 24 /// diff --git a/src/Signals/StellaOps.Signals/Persistence/InMemoryUnknownsRepository.cs b/src/Signals/StellaOps.Signals/Persistence/InMemoryUnknownsRepository.cs index 3ae6c9abc..c61bb272c 100644 --- a/src/Signals/StellaOps.Signals/Persistence/InMemoryUnknownsRepository.cs +++ b/src/Signals/StellaOps.Signals/Persistence/InMemoryUnknownsRepository.cs @@ -8,6 +8,12 @@ namespace StellaOps.Signals.Persistence; public sealed class InMemoryUnknownsRepository : IUnknownsRepository { private readonly ConcurrentDictionary> _store = new(StringComparer.OrdinalIgnoreCase); + private readonly TimeProvider _timeProvider; + + public InMemoryUnknownsRepository(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } public Task UpsertAsync(string subjectKey, IEnumerable items, CancellationToken cancellationToken) { @@ -59,12 +65,23 @@ public sealed class InMemoryUnknownsRepository : IUnknownsRepository return Task.CompletedTask; } + public Task> GetAllSubjectKeysAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var keys = _store.Keys + .OrderBy(static key => key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return Task.FromResult>(keys); + } + public Task> GetDueForRescanAsync( UnknownsBand band, int limit, CancellationToken cancellationToken) { - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var results = _store.Values .SelectMany(x => x) diff --git a/src/Signals/StellaOps.Signals/Program.cs b/src/Signals/StellaOps.Signals/Program.cs index 30026176d..44fe05f0c 100644 --- a/src/Signals/StellaOps.Signals/Program.cs +++ b/src/Signals/StellaOps.Signals/Program.cs @@ -132,6 +132,16 @@ builder.Services.AddSingleton(sp => return new ReachabilityFactCacheDecorator(inner, cache); }); builder.Services.AddSingleton(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(UnknownsScoringOptions.SectionName)); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(UnknownsDecayOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddHttpClient((sp, client) => { diff --git a/src/Signals/StellaOps.Signals/Services/ISignalRefreshService.cs b/src/Signals/StellaOps.Signals/Services/ISignalRefreshService.cs new file mode 100644 index 000000000..58697c55d --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/ISignalRefreshService.cs @@ -0,0 +1,52 @@ +namespace StellaOps.Signals.Services; + +/// +/// Handles signal refresh events that reset decay. +/// +public interface ISignalRefreshService +{ + /// + /// Records a signal refresh event. + /// + Task RefreshSignalAsync(SignalRefreshEvent refreshEvent, CancellationToken cancellationToken = default); +} + +/// +/// Signal refresh event types per advisory. +/// +public sealed class SignalRefreshEvent +{ + /// + /// Subject key for the unknown. + /// + public required string SubjectKey { get; init; } + + /// + /// Unknown ID being refreshed. + /// + public required string UnknownId { get; init; } + + /// + /// Type of signal refresh. + /// + public required SignalRefreshType RefreshType { get; init; } + + /// + /// Weight of this signal type. + /// + public double Weight { get; init; } + + /// + /// Additional context. + /// + public IReadOnlyDictionary? Context { get; init; } +} + +public enum SignalRefreshType +{ + UnknownsIngested, + ReachabilityRecomputed, + RuntimeFactsIngested, + ProvenanceAnchored, + VexUpdated +} diff --git a/src/Signals/StellaOps.Signals/Services/IUnknownsDecayService.cs b/src/Signals/StellaOps.Signals/Services/IUnknownsDecayService.cs new file mode 100644 index 000000000..f03ef6ef5 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/IUnknownsDecayService.cs @@ -0,0 +1,45 @@ +using StellaOps.Signals.Models; + +namespace StellaOps.Signals.Services; + +/// +/// Service for computing confidence decay on unknowns. +/// +public interface IUnknownsDecayService +{ + /// + /// Applies decay to all unknowns in a subject and recomputes bands. + /// + Task ApplyDecayAsync( + string subjectKey, + CancellationToken cancellationToken = default); + + /// + /// Applies decay to a single unknown. + /// + Task ApplyDecayToUnknownAsync( + UnknownSymbolDocument unknown, + CancellationToken cancellationToken = default); + + /// + /// Recomputes all scores and bands for nightly batch. + /// + Task RunNightlyDecayBatchAsync( + CancellationToken cancellationToken = default); +} + +public sealed record DecayResult( + string SubjectKey, + int ProcessedCount, + int HotCount, + int WarmCount, + int ColdCount, + int BandChanges, + DateTimeOffset ComputedAt); + +public sealed record BatchDecayResult( + int TotalSubjects, + int TotalUnknowns, + int TotalBandChanges, + TimeSpan Duration, + DateTimeOffset CompletedAt); diff --git a/src/Signals/StellaOps.Signals/Services/NightlyDecayWorker.cs b/src/Signals/StellaOps.Signals/Services/NightlyDecayWorker.cs new file mode 100644 index 000000000..290e700fd --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/NightlyDecayWorker.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Signals.Options; + +namespace StellaOps.Signals.Services; + +public sealed class NightlyDecayWorker : BackgroundService +{ + private readonly IUnknownsDecayService _decayService; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public NightlyDecayWorker( + IUnknownsDecayService decayService, + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _decayService = decayService ?? throw new ArgumentNullException(nameof(decayService)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var opts = _options.Value; + var nextRun = GetNextRunUtc(_timeProvider.GetUtcNow(), opts.NightlyBatchHourUtc); + var delay = nextRun - _timeProvider.GetUtcNow(); + + if (delay > TimeSpan.Zero) + { + _logger.LogInformation("Next unknowns decay batch scheduled for {NextRun}", nextRun); + await Task.Delay(delay, _timeProvider, stoppingToken).ConfigureAwait(false); + } + + try + { + await _decayService.RunNightlyDecayBatchAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Shutdown requested. + } + catch (Exception ex) + { + _logger.LogError(ex, "Nightly unknowns decay batch failed."); + } + } + } + + private static DateTimeOffset GetNextRunUtc(DateTimeOffset nowUtc, int hourUtc) + { + var clampedHour = Math.Clamp(hourUtc, 0, 23); + var today = new DateTimeOffset(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, TimeSpan.Zero); + var candidate = today.AddHours(clampedHour); + return candidate <= nowUtc ? candidate.AddDays(1) : candidate; + } +} diff --git a/src/Signals/StellaOps.Signals/Services/SignalRefreshService.cs b/src/Signals/StellaOps.Signals/Services/SignalRefreshService.cs new file mode 100644 index 000000000..a241c4386 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/SignalRefreshService.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Signals.Options; +using StellaOps.Signals.Persistence; + +namespace StellaOps.Signals.Services; + +public sealed class SignalRefreshService : ISignalRefreshService +{ + private readonly IUnknownsRepository _repository; + private readonly IUnknownsScoringService _scoringService; + private readonly IOptions _scoringOptions; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public SignalRefreshService( + IUnknownsRepository repository, + IUnknownsScoringService scoringService, + IOptions scoringOptions, + TimeProvider timeProvider, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService)); + _scoringOptions = scoringOptions ?? throw new ArgumentNullException(nameof(scoringOptions)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task RefreshSignalAsync(SignalRefreshEvent refreshEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(refreshEvent); + ArgumentException.ThrowIfNullOrWhiteSpace(refreshEvent.SubjectKey); + ArgumentException.ThrowIfNullOrWhiteSpace(refreshEvent.UnknownId); + + var now = _timeProvider.GetUtcNow(); + var unknowns = await _repository.GetBySubjectAsync(refreshEvent.SubjectKey, cancellationToken).ConfigureAwait(false); + + var target = unknowns.FirstOrDefault(u => string.Equals(u.Id, refreshEvent.UnknownId, StringComparison.Ordinal)); + if (target is null) + { + _logger.LogWarning( + "Signal refresh ignored: unknown {UnknownId} not found for subject {SubjectKey}", + refreshEvent.UnknownId, + refreshEvent.SubjectKey); + return; + } + + target.LastAnalyzedAt = now; + target.UpdatedAt = now; + + await _scoringService.ScoreUnknownAsync(target, _scoringOptions.Value, cancellationToken).ConfigureAwait(false); + await _repository.BulkUpdateAsync(new[] { target }, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Signal refresh applied: subject={SubjectKey}, unknownId={UnknownId}, type={Type}", + refreshEvent.SubjectKey, + refreshEvent.UnknownId, + refreshEvent.RefreshType); + } +} diff --git a/src/Signals/StellaOps.Signals/Services/UnknownsDecayMetrics.cs b/src/Signals/StellaOps.Signals/Services/UnknownsDecayMetrics.cs new file mode 100644 index 000000000..25b4291a2 --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/UnknownsDecayMetrics.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.Metrics; + +namespace StellaOps.Signals.Services; + +internal static class UnknownsDecayMetrics +{ + private static readonly Meter Meter = new("StellaOps.Signals.Decay", "1.0.0"); + + public static readonly Counter SubjectsProcessed = Meter.CreateCounter( + "stellaops_unknowns_decay_subjects_processed_total", + description: "Total subjects processed by unknowns decay batches"); + + public static readonly Counter UnknownsProcessed = Meter.CreateCounter( + "stellaops_unknowns_decay_unknowns_processed_total", + description: "Total unknowns processed by unknowns decay batches"); + + public static readonly Counter BandChanges = Meter.CreateCounter( + "stellaops_unknowns_decay_band_changes_total", + description: "Total band changes caused by decay rescoring"); + + public static readonly Histogram BatchDurationSeconds = Meter.CreateHistogram( + "stellaops_unknowns_decay_batch_duration_seconds", + unit: "s", + description: "Duration of unknowns decay batch runs"); +} diff --git a/src/Signals/StellaOps.Signals/Services/UnknownsDecayService.cs b/src/Signals/StellaOps.Signals/Services/UnknownsDecayService.cs new file mode 100644 index 000000000..763a743be --- /dev/null +++ b/src/Signals/StellaOps.Signals/Services/UnknownsDecayService.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Signals.Models; +using StellaOps.Signals.Options; +using StellaOps.Signals.Persistence; + +namespace StellaOps.Signals.Services; + +/// +/// Implements time-based confidence decay for unknowns by periodically recomputing staleness and band assignment. +/// +public sealed class UnknownsDecayService : IUnknownsDecayService +{ + private readonly IUnknownsRepository _repository; + private readonly IUnknownsScoringService _scoringService; + private readonly IOptions _scoringOptions; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public UnknownsDecayService( + IUnknownsRepository repository, + IUnknownsScoringService scoringService, + IOptions scoringOptions, + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService)); + _scoringOptions = scoringOptions ?? throw new ArgumentNullException(nameof(scoringOptions)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ApplyDecayAsync(string subjectKey, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey); + + var now = _timeProvider.GetUtcNow(); + var unknowns = await _repository.GetBySubjectAsync(subjectKey, cancellationToken).ConfigureAwait(false); + if (unknowns.Count == 0) + { + return new DecayResult(subjectKey, 0, 0, 0, 0, 0, now); + } + + var updated = new List(unknowns.Count); + var bandChanges = 0; + + foreach (var unknown in unknowns) + { + var oldBand = unknown.Band; + var decayed = await ApplyDecayToUnknownAsync(unknown, cancellationToken).ConfigureAwait(false); + updated.Add(decayed); + + if (oldBand != decayed.Band) + { + bandChanges++; + } + } + + await _repository.BulkUpdateAsync(updated, cancellationToken).ConfigureAwait(false); + + var result = new DecayResult( + SubjectKey: subjectKey, + ProcessedCount: updated.Count, + HotCount: updated.Count(u => u.Band == UnknownsBand.Hot), + WarmCount: updated.Count(u => u.Band == UnknownsBand.Warm), + ColdCount: updated.Count(u => u.Band == UnknownsBand.Cold), + BandChanges: bandChanges, + ComputedAt: now); + + UnknownsDecayMetrics.SubjectsProcessed.Add(1); + UnknownsDecayMetrics.UnknownsProcessed.Add(result.ProcessedCount); + UnknownsDecayMetrics.BandChanges.Add(result.BandChanges); + + _logger.LogInformation( + "Applied unknowns decay for {SubjectKey}: processed={ProcessedCount}, hot={HotCount}, warm={WarmCount}, cold={ColdCount}, bandChanges={BandChanges}", + result.SubjectKey, + result.ProcessedCount, + result.HotCount, + result.WarmCount, + result.ColdCount, + result.BandChanges); + + return result; + } + + public async Task ApplyDecayToUnknownAsync(UnknownSymbolDocument unknown, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(unknown); + var opts = _scoringOptions.Value; + return await _scoringService.ScoreUnknownAsync(unknown, opts, cancellationToken).ConfigureAwait(false); + } + + public async Task RunNightlyDecayBatchAsync(CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + + var subjects = await _repository.GetAllSubjectKeysAsync(cancellationToken).ConfigureAwait(false); + var maxSubjects = Math.Max(0, _options.Value.MaxSubjectsPerBatch); + if (maxSubjects > 0 && subjects.Count > maxSubjects) + { + subjects = subjects.Take(maxSubjects).ToArray(); + } + + _logger.LogInformation("Starting nightly unknowns decay batch for {Count} subjects", subjects.Count); + + var totalUnknowns = 0; + var totalBandChanges = 0; + + foreach (var subjectKey in subjects) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await ApplyDecayAsync(subjectKey, cancellationToken).ConfigureAwait(false); + totalUnknowns += result.ProcessedCount; + totalBandChanges += result.BandChanges; + } + + var endTime = _timeProvider.GetUtcNow(); + var duration = endTime - startTime; + + UnknownsDecayMetrics.BatchDurationSeconds.Record(duration.TotalSeconds); + + var batchResult = new BatchDecayResult( + TotalSubjects: subjects.Count, + TotalUnknowns: totalUnknowns, + TotalBandChanges: totalBandChanges, + Duration: duration, + CompletedAt: endTime); + + _logger.LogInformation( + "Completed nightly unknowns decay batch: subjects={TotalSubjects}, unknowns={TotalUnknowns}, bandChanges={TotalBandChanges}, duration={Duration}", + batchResult.TotalSubjects, + batchResult.TotalUnknowns, + batchResult.TotalBandChanges, + batchResult.Duration); + + return batchResult; + } +} diff --git a/src/Signals/StellaOps.Signals/TASKS.md b/src/Signals/StellaOps.Signals/TASKS.md index fa35dfa0e..109b4c935 100644 --- a/src/Signals/StellaOps.Signals/TASKS.md +++ b/src/Signals/StellaOps.Signals/TASKS.md @@ -7,3 +7,4 @@ This file mirrors sprint work for the Signals module. | `SIG-STORE-401-016` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Added reachability store repository APIs and models; callgraph ingestion now populates the store; Mongo index script at `ops/mongo/indices/reachability_store_indices.js`. | | `UNCERTAINTY-SCHEMA-401-024` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Implemented uncertainty tiers and scoring integration; see `src/Signals/StellaOps.Signals/Lattice/UncertaintyTier.cs` and `src/Signals/StellaOps.Signals/Lattice/ReachabilityLattice.cs`. | | `UNCERTAINTY-SCORER-401-025` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Reachability risk score now uses configurable entropy weights and is aligned with `UncertaintyDocument.RiskScore`; tests cover tier/entropy scoring. | +| `UNKNOWNS-DECAY-3601-001` | `docs/implplan/SPRINT_3601_0001_0001_unknowns_decay_algorithm.md` | DOING (2025-12-15) | Implement decay worker/service, signal refresh hook, and deterministic unit/integration tests. | diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/DecisionReplayTokenExtensions.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/DecisionReplayTokenExtensions.cs new file mode 100644 index 000000000..f0b47afee --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.ReplayToken/DecisionReplayTokenExtensions.cs @@ -0,0 +1,71 @@ +namespace StellaOps.Audit.ReplayToken; + +/// +/// Extension for decision- and scoring-specific replay tokens. +/// +public static class DecisionReplayTokenExtensions +{ + /// + /// Generates a replay token for a triage decision. + /// + public static ReplayToken GenerateForDecision( + this IReplayTokenGenerator generator, + string alertId, + string actorId, + string decisionStatus, + IEnumerable evidenceHashes, + string? policyContext, + string? rulesVersion) + { + ArgumentNullException.ThrowIfNull(generator); + ArgumentException.ThrowIfNullOrWhiteSpace(alertId); + ArgumentException.ThrowIfNullOrWhiteSpace(actorId); + ArgumentException.ThrowIfNullOrWhiteSpace(decisionStatus); + ArgumentNullException.ThrowIfNull(evidenceHashes); + + var request = new ReplayTokenRequest + { + InputHashes = new[] { alertId }, + EvidenceHashes = evidenceHashes.ToList(), + RulesVersion = rulesVersion, + AdditionalContext = new Dictionary + { + ["actor_id"] = actorId, + ["decision_status"] = decisionStatus, + ["policy_context"] = policyContext ?? string.Empty + } + }; + + return generator.Generate(request); + } + + /// + /// Generates a replay token for unknowns scoring. + /// + public static ReplayToken GenerateForScoring( + this IReplayTokenGenerator generator, + string subjectKey, + IEnumerable feedManifests, + string scoringConfigVersion, + IEnumerable inputHashes) + { + ArgumentNullException.ThrowIfNull(generator); + ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey); + ArgumentNullException.ThrowIfNull(feedManifests); + ArgumentException.ThrowIfNullOrWhiteSpace(scoringConfigVersion); + ArgumentNullException.ThrowIfNull(inputHashes); + + var request = new ReplayTokenRequest + { + FeedManifests = feedManifests.ToList(), + ScoringConfigVersion = scoringConfigVersion, + InputHashes = inputHashes.ToList(), + AdditionalContext = new Dictionary + { + ["subject_key"] = subjectKey + } + }; + + return generator.Generate(request); + } +} diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/IReplayTokenGenerator.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/IReplayTokenGenerator.cs new file mode 100644 index 000000000..3cda9b0ba --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.ReplayToken/IReplayTokenGenerator.cs @@ -0,0 +1,19 @@ +namespace StellaOps.Audit.ReplayToken; + +/// +/// Generates deterministic replay tokens for audit and reproducibility. +/// +public interface IReplayTokenGenerator +{ + /// + /// Generates a replay token from the given inputs. + /// + /// The inputs to hash. + /// A deterministic replay token. + ReplayToken Generate(ReplayTokenRequest request); + + /// + /// Verifies that inputs match a previously generated token. + /// + bool Verify(ReplayToken token, ReplayTokenRequest request); +} diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/README.md b/src/__Libraries/StellaOps.Audit.ReplayToken/README.md new file mode 100644 index 000000000..89b0d5920 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.ReplayToken/README.md @@ -0,0 +1,18 @@ +# StellaOps.Audit.ReplayToken + +Deterministic replay token generation used to make triage decisions and scoring reproducible and audit-ready. + +## Token format + +`replay:v::` + +Example: + +`replay:v1.0:SHA-256:0123abcd...` + +## Usage + +- Create a `ReplayTokenRequest` with feed/rules/policy/input digests. +- Call `IReplayTokenGenerator.Generate(request)` to get a stable token value. +- Store the token’s `Canonical` string alongside immutable decision events. + diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayCliSnippetGenerator.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayCliSnippetGenerator.cs new file mode 100644 index 000000000..d842a1edb --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayCliSnippetGenerator.cs @@ -0,0 +1,69 @@ +namespace StellaOps.Audit.ReplayToken; + +/// +/// Generates CLI snippets for one-click reproduce functionality. +/// +public sealed class ReplayCliSnippetGenerator +{ + /// + /// Generates a CLI command to reproduce a decision. + /// + public string GenerateDecisionReplay( + ReplayToken token, + string alertId, + string? feedManifestUri = null, + string? policyVersion = null) + { + ArgumentNullException.ThrowIfNull(token); + ArgumentException.ThrowIfNullOrWhiteSpace(alertId); + + var parts = new List + { + "stellaops", + "replay", + "decision", + $"--token {token.Value}", + $"--alert-id {alertId}" + }; + + if (!string.IsNullOrWhiteSpace(feedManifestUri)) + { + parts.Add($"--feed-manifest {feedManifestUri.Trim()}"); + } + + if (!string.IsNullOrWhiteSpace(policyVersion)) + { + parts.Add($"--policy-version {policyVersion.Trim()}"); + } + + return string.Join(" \\\n+ ", parts); + } + + /// + /// Generates a CLI command to reproduce unknowns scoring. + /// + public string GenerateScoringReplay( + ReplayToken token, + string subjectKey, + string? configVersion = null) + { + ArgumentNullException.ThrowIfNull(token); + ArgumentException.ThrowIfNullOrWhiteSpace(subjectKey); + + var parts = new List + { + "stellaops", + "replay", + "scoring", + $"--token {token.Value}", + $"--subject {subjectKey}" + }; + + if (!string.IsNullOrWhiteSpace(configVersion)) + { + parts.Add($"--config-version {configVersion.Trim()}"); + } + + return string.Join(" \\\n+ ", parts); + } +} diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayToken.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayToken.cs new file mode 100644 index 000000000..c0b3bdb5a --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayToken.cs @@ -0,0 +1,94 @@ +namespace StellaOps.Audit.ReplayToken; + +/// +/// A deterministic, content-addressable replay token. +/// +public sealed class ReplayToken : IEquatable +{ + public const string Scheme = "replay"; + public const string DefaultAlgorithm = "SHA-256"; + public const string DefaultVersion = "1.0"; + + /// + /// The token value (SHA-256 hash in hex). + /// + public string Value { get; } + + /// + /// Algorithm used for hashing. + /// + public string Algorithm { get; } + + /// + /// Version of the token generation algorithm. + /// + public string Version { get; } + + /// + /// Timestamp when token was generated. + /// + public DateTimeOffset GeneratedAt { get; } + + /// + /// Canonical representation for storage. + /// + public string Canonical => $"{Scheme}:v{Version}:{Algorithm}:{Value}"; + + public ReplayToken(string value, DateTimeOffset generatedAt, string? algorithm = null, string? version = null) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Token value cannot be empty.", nameof(value)); + } + + Value = value.Trim(); + GeneratedAt = generatedAt; + Algorithm = string.IsNullOrWhiteSpace(algorithm) ? DefaultAlgorithm : algorithm.Trim(); + Version = string.IsNullOrWhiteSpace(version) ? DefaultVersion : version.Trim(); + } + + /// + /// Parse a canonical token string. + /// + public static ReplayToken Parse(string canonical) + { + if (string.IsNullOrWhiteSpace(canonical)) + { + throw new ArgumentException("Token cannot be empty.", nameof(canonical)); + } + + var parts = canonical.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 4 || !string.Equals(parts[0], Scheme, StringComparison.Ordinal)) + { + throw new FormatException($"Invalid replay token format: {canonical}"); + } + + var versionPart = parts[1]; + if (!versionPart.StartsWith("v", StringComparison.Ordinal) || versionPart.Length <= 1) + { + throw new FormatException($"Invalid replay token version: {canonical}"); + } + + var version = versionPart[1..]; + var algorithm = parts[2]; + var value = parts[3]; + + return new ReplayToken(value, DateTimeOffset.UnixEpoch, algorithm, version); + } + + public override string ToString() => Canonical; + + public bool Equals(ReplayToken? other) + { + if (other is null) + { + return false; + } + + return string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object? obj) => obj is ReplayToken other && Equals(other); + + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); +} diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayTokenRequest.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayTokenRequest.cs new file mode 100644 index 000000000..977547606 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.ReplayToken/ReplayTokenRequest.cs @@ -0,0 +1,52 @@ +namespace StellaOps.Audit.ReplayToken; + +/// +/// Inputs for replay token generation. +/// +public sealed class ReplayTokenRequest +{ + /// + /// Feed manifest hashes (advisory sources). + /// + public IReadOnlyList FeedManifests { get; init; } = Array.Empty(); + + /// + /// Rule set version identifier. + /// + public string? RulesVersion { get; init; } + + /// + /// Rule set content hash. + /// + public string? RulesHash { get; init; } + + /// + /// Lattice policy version identifier. + /// + public string? LatticePolicyVersion { get; init; } + + /// + /// Lattice policy content hash. + /// + public string? LatticePolicyHash { get; init; } + + /// + /// Input artifact hashes (SBOMs, images, etc.). + /// + public IReadOnlyList InputHashes { get; init; } = Array.Empty(); + + /// + /// Scoring configuration version. + /// + public string? ScoringConfigVersion { get; init; } + + /// + /// Evidence artifact hashes. + /// + public IReadOnlyList EvidenceHashes { get; init; } = Array.Empty(); + + /// + /// Additional context for extensibility. + /// + public IReadOnlyDictionary AdditionalContext { get; init; } = new Dictionary(); +} diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/ServiceCollectionExtensions.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..09231ee8d --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.ReplayToken/ServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.Audit.ReplayToken; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddReplayTokenServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(TimeProvider.System); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddReplayTokenServices(this IServiceCollection services, TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(timeProvider); + services.AddSingleton(timeProvider); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } +} diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/Sha256ReplayTokenGenerator.cs b/src/__Libraries/StellaOps.Audit.ReplayToken/Sha256ReplayTokenGenerator.cs new file mode 100644 index 000000000..b2171bdab --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.ReplayToken/Sha256ReplayTokenGenerator.cs @@ -0,0 +1,130 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using StellaOps.Cryptography; + +namespace StellaOps.Audit.ReplayToken; + +/// +/// Generates replay tokens using SHA-256 hashing with deterministic canonicalization. +/// +public sealed class Sha256ReplayTokenGenerator : IReplayTokenGenerator +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly ICryptoHash _cryptoHash; + private readonly TimeProvider _timeProvider; + + public Sha256ReplayTokenGenerator(ICryptoHash cryptoHash, TimeProvider timeProvider) + { + _cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + public ReplayToken Generate(ReplayTokenRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var canonical = Canonicalize(request); + var hashHex = ComputeHash(canonical); + + return new ReplayToken(hashHex, _timeProvider.GetUtcNow()); + } + + public bool Verify(ReplayToken token, ReplayTokenRequest request) + { + ArgumentNullException.ThrowIfNull(token); + ArgumentNullException.ThrowIfNull(request); + + var computed = Generate(request); + return string.Equals(token.Value, computed.Value, StringComparison.OrdinalIgnoreCase); + } + + private string ComputeHash(string input) + { + var bytes = Encoding.UTF8.GetBytes(input); + return _cryptoHash.ComputeHashHex(bytes, HashAlgorithms.Sha256); + } + + private static string? NormalizeValue(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim(); + } + + private static List NormalizeSortedList(IReadOnlyList? values) + { + if (values is null || values.Count == 0) + { + return new List(); + } + + var normalized = values + .Where(static x => !string.IsNullOrWhiteSpace(x)) + .Select(static x => x.Trim()) + .OrderBy(static x => x, StringComparer.Ordinal) + .ToList(); + + return normalized; + } + + private static Dictionary NormalizeSortedDictionary(IReadOnlyDictionary? values) + { + if (values is null || values.Count == 0) + { + return new Dictionary(); + } + + var normalized = values + .Where(static kvp => !string.IsNullOrWhiteSpace(kvp.Key)) + .Select(static kvp => new KeyValuePair(kvp.Key.Trim(), kvp.Value?.Trim() ?? string.Empty)) + .OrderBy(static kvp => kvp.Key, StringComparer.Ordinal) + .ToDictionary(static kvp => kvp.Key, static kvp => kvp.Value, StringComparer.Ordinal); + + return normalized; + } + + /// + /// Produces deterministic canonical representation of inputs. + /// + private static string Canonicalize(ReplayTokenRequest request) + { + var canonical = new CanonicalReplayInput + { + Version = ReplayToken.DefaultVersion, + FeedManifests = NormalizeSortedList(request.FeedManifests), + RulesVersion = NormalizeValue(request.RulesVersion), + RulesHash = NormalizeValue(request.RulesHash), + LatticePolicyVersion = NormalizeValue(request.LatticePolicyVersion), + LatticePolicyHash = NormalizeValue(request.LatticePolicyHash), + InputHashes = NormalizeSortedList(request.InputHashes), + ScoringConfigVersion = NormalizeValue(request.ScoringConfigVersion), + EvidenceHashes = NormalizeSortedList(request.EvidenceHashes), + AdditionalContext = NormalizeSortedDictionary(request.AdditionalContext) + }; + + return JsonSerializer.Serialize(canonical, JsonOptions); + } + + private sealed class CanonicalReplayInput + { + public required string Version { get; init; } + public required List FeedManifests { get; init; } + public string? RulesVersion { get; init; } + public string? RulesHash { get; init; } + public string? LatticePolicyVersion { get; init; } + public string? LatticePolicyHash { get; init; } + public required List InputHashes { get; init; } + public string? ScoringConfigVersion { get; init; } + public required List EvidenceHashes { get; init; } + public required Dictionary AdditionalContext { get; init; } + } +} diff --git a/src/__Libraries/StellaOps.Audit.ReplayToken/StellaOps.Audit.ReplayToken.csproj b/src/__Libraries/StellaOps.Audit.ReplayToken/StellaOps.Audit.ReplayToken.csproj new file mode 100644 index 000000000..3e440c4c8 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.ReplayToken/StellaOps.Audit.ReplayToken.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + preview + false + StellaOps.Audit.ReplayToken + Deterministic replay token generation for audit and reproducibility + + + + + + + + + + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj index 80eafd806..f983c6028 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj @@ -12,5 +12,6 @@ + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj index f2ae13087..1e3450aa6 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj @@ -18,6 +18,7 @@ + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj index 9d64b2a65..2ff1d13ca 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj @@ -13,5 +13,6 @@ + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj index 79829334c..6b7fade0c 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj @@ -19,6 +19,7 @@ + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.PqSoft/StellaOps.Cryptography.Plugin.PqSoft.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.PqSoft/StellaOps.Cryptography.Plugin.PqSoft.csproj index 0c0948a31..b7c552b33 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.PqSoft/StellaOps.Cryptography.Plugin.PqSoft.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.PqSoft/StellaOps.Cryptography.Plugin.PqSoft.csproj @@ -13,5 +13,6 @@ + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.SimRemote/StellaOps.Cryptography.Plugin.SimRemote.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.SimRemote/StellaOps.Cryptography.Plugin.SimRemote.csproj index 3197618c2..065315963 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.SimRemote/StellaOps.Cryptography.Plugin.SimRemote.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.SimRemote/StellaOps.Cryptography.Plugin.SimRemote.csproj @@ -6,5 +6,6 @@ + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote/StellaOps.Cryptography.Plugin.SmRemote.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote/StellaOps.Cryptography.Plugin.SmRemote.csproj index 2d41e5741..09b701ffd 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote/StellaOps.Cryptography.Plugin.SmRemote.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote/StellaOps.Cryptography.Plugin.SmRemote.csproj @@ -6,5 +6,6 @@ + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.SmSoft/StellaOps.Cryptography.Plugin.SmSoft.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.SmSoft/StellaOps.Cryptography.Plugin.SmSoft.csproj index cfee29dbb..2ea87bbf0 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.SmSoft/StellaOps.Cryptography.Plugin.SmSoft.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.SmSoft/StellaOps.Cryptography.Plugin.SmSoft.csproj @@ -14,5 +14,6 @@ + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/StellaOps.Cryptography.Plugin.WineCsp.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/StellaOps.Cryptography.Plugin.WineCsp.csproj index 53b9ad97c..4e88ba218 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/StellaOps.Cryptography.Plugin.WineCsp.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/StellaOps.Cryptography.Plugin.WineCsp.csproj @@ -14,5 +14,6 @@ + diff --git a/src/__Tests/StellaOps.Audit.ReplayToken.Tests/ReplayTokenGeneratorTests.cs b/src/__Tests/StellaOps.Audit.ReplayToken.Tests/ReplayTokenGeneratorTests.cs new file mode 100644 index 000000000..ef97797d6 --- /dev/null +++ b/src/__Tests/StellaOps.Audit.ReplayToken.Tests/ReplayTokenGeneratorTests.cs @@ -0,0 +1,118 @@ +using StellaOps.Cryptography; + +namespace StellaOps.Audit.ReplayToken.Tests; + +public sealed class ReplayTokenGeneratorTests +{ + [Fact] + public void Generate_SameInputs_ReturnsSameValue() + { + var cryptoHash = DefaultCryptoHash.CreateForTests(); + var fixedNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); + var timeProvider = new FixedTimeProvider(fixedNow); + + var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider); + + var request = new ReplayTokenRequest + { + FeedManifests = new[] { "sha256:bbb", "sha256:aaa" }, + RulesVersion = "rules-v1", + RulesHash = "sha256:rules", + LatticePolicyVersion = "lattice-v1", + LatticePolicyHash = "sha256:lattice", + InputHashes = new[] { "sha256:input2", "sha256:input1" }, + ScoringConfigVersion = "score-v1", + EvidenceHashes = new[] { "sha256:e2", "sha256:e1" }, + AdditionalContext = new Dictionary + { + ["b"] = "2", + ["a"] = "1" + } + }; + + var token1 = generator.Generate(request); + var token2 = generator.Generate(request); + + Assert.Equal(token1.Value, token2.Value); + Assert.Equal(token1.Canonical, token2.Canonical); + } + + [Fact] + public void Generate_IgnoresArrayOrdering() + { + var cryptoHash = DefaultCryptoHash.CreateForTests(); + var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider); + + var requestA = new ReplayTokenRequest + { + FeedManifests = new[] { "sha256:aaa", "sha256:bbb" }, + InputHashes = new[] { "sha256:input1", "sha256:input2" } + }; + + var requestB = new ReplayTokenRequest + { + FeedManifests = new[] { "sha256:bbb", "sha256:aaa" }, + InputHashes = new[] { "sha256:input2", "sha256:input1" } + }; + + var tokenA = generator.Generate(requestA); + var tokenB = generator.Generate(requestB); + + Assert.Equal(tokenA.Value, tokenB.Value); + } + + [Fact] + public void Verify_MatchingInputs_ReturnsTrue() + { + var cryptoHash = DefaultCryptoHash.CreateForTests(); + var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider); + var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } }; + + var token = generator.Generate(request); + Assert.True(generator.Verify(token, request)); + } + + [Fact] + public void Verify_DifferentInputs_ReturnsFalse() + { + var cryptoHash = DefaultCryptoHash.CreateForTests(); + var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + var generator = new Sha256ReplayTokenGenerator(cryptoHash, timeProvider); + var request = new ReplayTokenRequest { InputHashes = new[] { "sha256:input" } }; + var different = new ReplayTokenRequest { InputHashes = new[] { "sha256:other" } }; + + var token = generator.Generate(request); + Assert.False(generator.Verify(token, different)); + } + + [Fact] + public void ReplayToken_Parse_RoundTripsCanonical() + { + var token = new ReplayToken("0123456789abcdef", DateTimeOffset.UnixEpoch); + var parsed = ReplayToken.Parse(token.Canonical); + + Assert.Equal(token.Value, parsed.Value); + Assert.Equal(token.Algorithm, parsed.Algorithm); + Assert.Equal(token.Version, parsed.Version); + } + + [Theory] + [InlineData("")] + [InlineData("replay")] + [InlineData("replay:v1.0:SHA-256")] + [InlineData("other:v1.0:SHA-256:abc")] + public void ReplayToken_Parse_Invalid_Throws(string canonical) + { + Assert.ThrowsAny(() => ReplayToken.Parse(canonical)); + } + + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } +} diff --git a/src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj b/src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj new file mode 100644 index 000000000..979c804d5 --- /dev/null +++ b/src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + preview + false + true + + + + +