diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 59d25993..532fea23 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js"; import { RUST_RESPONSE_SCHEMAS, @@ -63,6 +64,9 @@ const INTERNAL_NETWORK_PORT_FINDING_RECOMMENDATION = "Review whether the host-po const DOCKER_DAEMON_STATE_FINDING_RULE = "docker.daemon_state_bind_mount"; const DOCKER_DAEMON_STATE_FINDING_SUMMARY = "A container has Docker daemon state access that may provide Docker daemon API authority."; const DOCKER_DAEMON_STATE_FINDING_RECOMMENDATION = "Review whether this container requires Docker daemon API authority."; +const COMPOSE_DECLARED_TARGET_NOT_ACTIVE_FINDING_RULE = "docker.compose_declared_target_not_active"; +const COMPOSE_DECLARED_TARGET_NOT_ACTIVE_FINDING_SUMMARY = "A running Docker Compose service declares a dependency whose container is not active."; +const COMPOSE_DECLARED_TARGET_NOT_ACTIVE_FINDING_RECOMMENDATION = "Review the declared dependency and the target container state."; // Version-one evidence is intentionally a discriminated Docker observation, // not a generic provenance bag. JSON Schema owns each field's closed enum; @@ -98,6 +102,30 @@ const V4_EVIDENCE_EDGE = { cron_schedule_declaration: { relationship: "runs_on", sourcePrefix: "scheduled_job_", targetPrefix: "host_", target: "host_local" }, } as const; +// Keep the finding identity binding byte-for-byte aligned with core's +// `collision_resistant_id_component`: a readable slug plus SHA-256 of the +// untouched subject/target pair. Finding IDs are not daemon-assigned labels. +function collisionResistantIdComponent(value: string): string { + let slug = ""; + let emittedSeparator = false; + for (const character of value) { + if (/^[A-Za-z0-9_.-]$/.test(character)) { + slug += character; + emittedSeparator = false; + } else if (!emittedSeparator) { + slug += "-"; + emittedSeparator = true; + } + } + slug = slug.replace(/^-+|-+$/g, ""); + const readable = (slug || "identity").slice(0, 48); + return `${readable}--${createHash("sha256").update(value).digest("hex")}`; +} + +function composeDeclaredTargetFindingId(subjectRef: string, targetRef: string): string { + return `finding_docker_compose_declared_target_not_active_${collisionResistantIdComponent(`${subjectRef}\u001f${targetRef}`)}`; +} + function hasCompleteProviderStateVector(payload: unknown): boolean { if (!payload || typeof payload !== "object") return false; const providerStates = (payload as { providerStates?: unknown }).providerStates; @@ -282,6 +310,32 @@ function hasCoherentFindings(payload: unknown): boolean { && typeof evidence.providerRevision === "string" && evidence.providerRevision !== String(evidence.collectedAt); })(); + if (finding.ruleId === COMPOSE_DECLARED_TARGET_NOT_ACTIVE_FINDING_RULE) return finding.severity === "advisory" + && finding.summary === COMPOSE_DECLARED_TARGET_NOT_ACTIVE_FINDING_SUMMARY + && finding.recommendation === COMPOSE_DECLARED_TARGET_NOT_ACTIVE_FINDING_RECOMMENDATION + && typeof finding.subjectRef === "string" + && finding.subjectRef.startsWith("docker_container_") + && typeof finding.targetRef === "string" + && finding.targetRef.startsWith("docker_container_") + && finding.subjectRef !== finding.targetRef + && finding.id === composeDeclaredTargetFindingId(finding.subjectRef, finding.targetRef) + && Array.isArray(finding.evidenceRefs) + && finding.evidenceRefs.length === 1 + && (() => { + const candidateEvidence = finding.evidenceRefs[0]; + if (!candidateEvidence || typeof candidateEvidence !== "object") return false; + const evidence = candidateEvidence as Record; + return evidence.version === 1 + && evidence.provider === "docker" + && evidence.kind === "docker_compose_depends_on" + && evidence.assertionKind === "observed" + && evidence.summary === "Docker recorded Compose dependency declaration" + && evidence.subjectRef === finding.subjectRef + && (evidence.providerSlot === undefined || evidence.providerSlot === null) + && evidence.freshness === "fresh" + && typeof evidence.providerRevision === "string" + && evidence.providerRevision !== String(evidence.collectedAt); + })(); if (finding.ruleId !== INTERNAL_NETWORK_PORT_FINDING_RULE) return false; return finding.severity === "advisory" && finding.summary === INTERNAL_NETWORK_PORT_FINDING_SUMMARY diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index ee625409..0e3bacba 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -934,6 +934,14 @@ test("authenticated browser API pass-through responses preserve Rust schemas acr assert.ok(validator, `missing ${schemaName} validator`); const body = await response.json(); assert.equal(validator(body), true, `${path}: ${JSON.stringify(validator.errors)}`); + if (canonicalPath === "/api/findings") { + const findingList = (body as { findings?: unknown }).findings; + assert.ok(Array.isArray(findingList)); + assert.ok(findingList.some((finding) => ( + finding && typeof finding === "object" + && (finding as { ruleId?: unknown }).ruleId === "docker.compose_declared_target_not_active" + )), `${path} must preserve the canonical Compose target finding`); + } } } assert.ok( @@ -1040,7 +1048,13 @@ test("daemon model responses require non-empty revision and complete provider st ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[0].evidenceRefs[0].freshness = "stale"; return value; })()], ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[2].targetRef = "host_risk_untrusted"; return value; })()], ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[1].evidenceRefs[1].kind = "docker_volume_mount"; return value; })()], - ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[1].evidenceRefs[0].providerRevision = String(value.findings[1].evidenceRefs[0].collectedAt); return value; })()] + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[1].evidenceRefs[0].providerRevision = String(value.findings[1].evidenceRefs[0].collectedAt); return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].summary = "DOCKERMAP_TEST_FORGED_COMPOSE_FINDING"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].id = "finding_docker_compose_declared_target_not_active_forged"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].evidenceRefs[0].freshness = "stale"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].evidenceRefs[0].kind = "docker_network_membership"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].evidenceRefs[0].providerSlot = "project_npm"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[3].unsafe = "DOCKERMAP_TEST_EXTRA"; return value; })()] ] as const; for (const [daemonPath, body] of invalidResponses) { const daemon = await startStubDaemon((req, res) => { diff --git a/tests/fixtures/contracts/findings-response.json b/tests/fixtures/contracts/findings-response.json index e76b82d5..db3f7007 100644 --- a/tests/fixtures/contracts/findings-response.json +++ b/tests/fixtures/contracts/findings-response.json @@ -85,6 +85,29 @@ "freshness": "fresh" } ] + }, + { + "id": "finding_docker_compose_declared_target_not_active_docker_container_compose_source-docker_container--bf87ab2e9b1b8b7f49304dbc1fa040e0bf4c561c8585b686be888110fb646715", + "ruleId": "docker.compose_declared_target_not_active", + "severity": "advisory", + "summary": "A running Docker Compose service declares a dependency whose container is not active.", + "recommendation": "Review the declared dependency and the target container state.", + "subjectRef": "docker_container_compose_source", + "targetRef": "docker_container_compose_target", + "evidenceRefs": [ + { + "version": 1, + "id": "docker_compose_depends_on:docker_container_compose_source:docker_container_compose_target", + "provider": "docker", + "kind": "docker_compose_depends_on", + "assertionKind": "observed", + "summary": "Docker recorded Compose dependency declaration", + "subjectRef": "docker_container_compose_source", + "collectedAt": 1710000000000, + "providerRevision": "fixture-docker-observation", + "freshness": "fresh" + } + ] } ] }