Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions apps/api/src/daemonResponseValidation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
import {
RUST_RESPONSE_SCHEMAS,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>;
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)
Comment on lines +330 to +334

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind Compose evidence IDs to the target

When the daemon returns an otherwise valid Compose finding whose evidence ID names another target—or contains arbitrary daemon-controlled text—this branch accepts it because it binds only evidence.subjectRef, never evidence.id. Core's docker_runtime_evidence derives the evidence ID from both source and target (crates/dockermap-core/src/snapshot_runtime.rs:423-426), so this gap can publish a conclusion unsupported by its attached evidence and provides an unnecessary string channel to browser clients; recompute and compare the canonical Compose evidence ID here.

AGENTS.md reference: AGENTS.md:L17-L20

Useful? React with 👍 / 👎.

&& 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
Expand Down
16 changes: 15 additions & 1 deletion apps/api/test/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) => {
Expand Down
23 changes: 23 additions & 0 deletions tests/fixtures/contracts/findings-response.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
]
}
Loading