diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 7e01ea50..59d25993 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -50,6 +50,7 @@ const PROVIDER_STATE_SLOT_SET = { python_processes: true, native_processes: true, project_npm: true, + cron: true, } as const satisfies Record; const PROVIDER_STATE_SLOTS = Object.keys(PROVIDER_STATE_SLOT_SET) as ProviderSlot[]; const U32_MAX = 4_294_967_295; @@ -91,6 +92,12 @@ const V3_EVIDENCE_EDGE = { npm_package_manifest_dependency: { relationship: "depends_on", sourcePrefix: "npm_project_", targetPrefix: "npm_package_" }, } as const; +// Version four is a parsed cron declaration from Cron's own scheduler slot. +// It makes no execution, successful-run, or host-health claim. +const V4_EVIDENCE_EDGE = { + cron_schedule_declaration: { relationship: "runs_on", sourcePrefix: "scheduled_job_", targetPrefix: "host_", target: "host_local" }, +} as const; + function hasCompleteProviderStateVector(payload: unknown): boolean { if (!payload || typeof payload !== "object") return false; const providerStates = (payload as { providerStates?: unknown }).providerStates; @@ -188,16 +195,24 @@ function hasCoherentRuntimeEvidence(payload: unknown): boolean { && value.assertionKind === "declared" && value.providerSlot === "project_npm" && (value.freshness === "fresh" || value.freshness === "stale" || value.freshness === "timed_out"); - if (!isV1 && !isV2 && !isV3) return false; + const isV4 = value.version === 4 + && value.provider === "cron" + && value.assertionKind === "declared" + && value.providerSlot === "cron" + && (value.freshness === "fresh" || value.freshness === "stale" || value.freshness === "timed_out"); + if (!isV1 && !isV2 && !isV3 && !isV4) return false; const expected = typeof value.kind === "string" ? (isV1 ? V1_EVIDENCE_EDGE[value.kind as keyof typeof V1_EVIDENCE_EDGE] : isV2 ? V2_EVIDENCE_EDGE[value.kind as keyof typeof V2_EVIDENCE_EDGE] - : V3_EVIDENCE_EDGE[value.kind as keyof typeof V3_EVIDENCE_EDGE]) + : isV3 + ? V3_EVIDENCE_EDGE[value.kind as keyof typeof V3_EVIDENCE_EDGE] + : V4_EVIDENCE_EDGE[value.kind as keyof typeof V4_EVIDENCE_EDGE]) : undefined; if (!expected || candidate.relationship !== expected.relationship || typeof candidate.source !== "string" || typeof candidate.target !== "string") return false; if (value.subjectRef !== candidate.source || !candidate.source.startsWith(expected.sourcePrefix) || !candidate.target.startsWith(expected.targetPrefix)) return false; + if (isV4 && candidate.target !== "host_local") return false; if (value.kind === "docker_daemon_state_bind_mount" && candidate.target !== "host_risk_docker_daemon_state") return false; if (candidate.source === candidate.target) return false; // An opaque observation token must never be the collection timestamp diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5c250947..2fc9a7b3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -424,7 +424,7 @@ function getMockResponse(path: string): T { providerStates: [ unavailableProviderState("network_infrastructure"), unavailableProviderState("host_scoped"), unavailableProviderState("systemd"), unavailableProviderState("python_processes"), unavailableProviderState("native_processes"), - unavailableProviderState("project_npm") + unavailableProviderState("project_npm"), unavailableProviderState("cron") ], source: "mock" }; diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 84eed820..ee625409 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1292,6 +1292,70 @@ test("runtime evidence is required and fails closed before browser publication", assert.ok(malformedNpmEdge); malformedNpmEdge.target = "docker_container_not_a_package"; assert.throws(() => validateDaemonResponse("/daemon/runtime/map", wrongNpmEndpoint)); + + const cronEdge = fixture.edges.find((edge: { source?: unknown }) => edge.source === "scheduled_job_fixture_daily_backup"); + assert.ok(cronEdge, "canonical daemon fixture carries a V4 Cron schedule declaration"); + assert.doesNotThrow(() => validateDaemonResponse("/daemon/runtime/map", fixture)); + for (const freshness of ["stale", "timed_out"] as const) { + const retainedCron = structuredClone(fixture); + const edge = retainedCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(edge); + edge.evidenceRefs[0].freshness = freshness; + assert.doesNotThrow( + () => validateDaemonResponse("/daemon/runtime/map", retainedCron), + `v4 cron evidence may retain ${freshness} data from its own scheduler slot` + ); + } + for (const [field, value] of [ + ["provider", "systemd"], + ["kind", "systemd_requires"], + ["assertionKind", "observed"], + ["providerSlot", "host_scoped"], + ["freshness", "unavailable"], + ["version", 3] + ] as const) { + const malformedCron = structuredClone(fixture); + const edge = malformedCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(edge); + edge.evidenceRefs[0][field] = value; + assert.throws( + () => validateDaemonResponse("/daemon/runtime/map", malformedCron), + `v4 cron evidence must reject fabricated ${field}` + ); + } + const timestampAliasedCron = structuredClone(fixture); + const timestampAliasedCronEdge = timestampAliasedCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(timestampAliasedCronEdge); + timestampAliasedCronEdge.evidenceRefs[0].providerRevision = String(timestampAliasedCronEdge.evidenceRefs[0].collectedAt); + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", timestampAliasedCron)); + const unboundedCron = structuredClone(fixture); + const unboundedCronEdge = unboundedCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(unboundedCronEdge); + unboundedCronEdge.evidenceRefs[0].providerRevision = "x".repeat(260); + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", unboundedCron)); + const unsafeTimestampCron = structuredClone(fixture); + const unsafeTimestampCronEdge = unsafeTimestampCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(unsafeTimestampCronEdge); + unsafeTimestampCronEdge.evidenceRefs[0].collectedAt = Number.MAX_SAFE_INTEGER + 1; + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", unsafeTimestampCron)); + const extraCronField = structuredClone(fixture); + const extraCronFieldEdge = extraCronField.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(extraCronFieldEdge); + extraCronFieldEdge.evidenceRefs[0].rawSchedule = "* * * * * secret"; + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", extraCronField)); + for (const [field, value] of [ + ["target", "host_other"], + ["relationship", "depends_on"] + ] as const) { + const malformedCron = structuredClone(fixture); + const edge = malformedCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(edge); + edge[field] = value; + assert.throws( + () => validateDaemonResponse("/daemon/runtime/map", malformedCron), + `v4 cron evidence must reject a noncanonical ${field}` + ); + } }); test("fabricated runtime evidence is rejected over the authenticated API boundary", async () => { @@ -1344,6 +1408,32 @@ test("fabricated V3 NPM evidence is rejected neutrally over the authenticated AP assert.doesNotMatch(JSON.stringify(body), new RegExp(sentinel)); }); +test("fabricated V4 Cron evidence is rejected neutrally over the authenticated API boundary", async () => { + const fixture = JSON.parse(await readFile( + new URL("../../../tests/fixtures/contracts/runtime-map-daemon-emitted.json", import.meta.url), + "utf8" + )); + const sentinel = "DOCKERMAP_TEST_FAKE_CRON_EVIDENCE_SECRET"; + const cronEdge = fixture.edges.find((edge: { source?: unknown }) => edge.source === "scheduled_job_fixture_daily_backup"); + assert.ok(cronEdge, "canonical fixture must exercise the V4 browser boundary"); + cronEdge.target = `host_${sentinel}`; + cronEdge.evidenceRefs[0].subjectRef = cronEdge.source; + cronEdge.evidenceRefs[0].providerSlot = "host_scoped"; + const daemon = await startStubDaemon((req, res) => { + if (req.url === "/daemon/runtime/map") return sendJson(res, 200, fixture); + return sendJson(res, 404, { code: "not_found", message: "missing" }); + }); + const api = await startApi({ DOCKERMAP_DAEMON_URL: `http://127.0.0.1:${daemon.port}`, DOCKERMAP_API_TOKEN: "test-token" }); + const response = await request(api, "/api/v1/runtime/map", { headers: { Authorization: "Bearer test-token" } }); + assert.equal(response.status, 502); + const body = await response.json(); + assert.deepEqual(body, { + code: "daemon_invalid_response", + message: "Daemon response did not match its declared contract" + }); + assert.doesNotMatch(JSON.stringify(body), new RegExp(sentinel)); +}); + test("actual canonical and v1 SSE snapshot/error frames use their declared payload schemas", async () => { const health = JSON.parse(await readFile(new URL("../../../tests/fixtures/contracts/health-response.json", import.meta.url), "utf8")); const healthyDaemon = await startStubDaemon((req, res) => { @@ -2230,6 +2320,7 @@ test("API publishes redacted and normalized daemon data on every response route" providerStates: [ { slot: "network_infrastructure", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, { slot: "host_scoped", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, + { slot: "cron", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, { slot: "systemd", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, { slot: "python_processes", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, { slot: "native_processes", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, diff --git a/tests/fixtures/contracts/runtime-map-daemon-emitted.json b/tests/fixtures/contracts/runtime-map-daemon-emitted.json index 71f411bd..f6532aba 100644 --- a/tests/fixtures/contracts/runtime-map-daemon-emitted.json +++ b/tests/fixtures/contracts/runtime-map-daemon-emitted.json @@ -243,6 +243,28 @@ "dependencies": [], "dependents": [] } + }, + { + "id": "host_local", + "provider": "host", + "type": "host", + "label": "host", + "status": "online", + "layer": "host", + "metadata": {} + }, + { + "id": "scheduled_job_fixture_daily_backup", + "provider": "scheduled_job", + "type": "scheduled_job", + "label": "daily backup", + "status": "scheduled", + "layer": "process", + "metadata": { + "source": "fixture crontab", + "line": "1", + "command": "daily backup" + } } ], "edges": [ @@ -589,6 +611,27 @@ "freshness": "fresh" } ] + }, + { + "source": "scheduled_job_fixture_daily_backup", + "target": "host_local", + "relationship": "runs_on", + "metadata": {}, + "evidenceRefs": [ + { + "version": 4, + "id": "fixture-cron-schedule-declaration-daily-backup", + "provider": "cron", + "kind": "cron_schedule_declaration", + "assertionKind": "declared", + "summary": "cron declared a scheduled job", + "subjectRef": "scheduled_job_fixture_daily_backup", + "collectedAt": 1787196125766, + "providerRevision": "fixture-cron-observation-1", + "providerSlot": "cron", + "freshness": "fresh" + } + ] } ], "diagnostics": [],