diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2594142e..50e789d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,12 +147,25 @@ jobs: - name: Checkout uses: actions/checkout@v6 - # Issue #42: the runtime stage must mirror the lockfile's nested - # workspace layout (apps/api/node_modules) or the API cannot resolve - # express. Build the image on every PR so regressions ship nowhere. + # The runtime stage copies the pruned workspace dependency closure from + # the resolver's root. Build on every PR so hoisting or closure drift + # cannot ship unnoticed. - name: Build image run: docker build -t dockermap:ci . + - name: Assert runtime dependency boundary + run: | + set -euo pipefail + docker run --rm --entrypoint sh dockermap:ci -ec ' + ! command -v npm + ! command -v npx + test ! -e /opt/dockermap/node_modules/.bin/tsx + test ! -e /opt/dockermap/node_modules/.bin/vite + test ! -d /opt/dockermap/node_modules/typescript + test ! -e /opt/dockermap/node_modules/@playwright/test/package.json + node -e "import(\"express\").then(() => import(\"@dockermap/contracts\"))" + ' + - name: Smoke-test runtime image run: | set -euo pipefail diff --git a/Dockerfile b/Dockerfile index 330b2e5c..395ddb3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,6 +44,13 @@ RUN npm run check:version && npm run check:contracts && npm run build # Build and assert the entire package artifact, rather than relying on a # source-tree module that happened to be copied into the image. RUN test -f packages/contracts/dist/index.js && test -f packages/contracts/dist/nodeSchemas.js +# The runtime image needs the API's production dependency closure only. Prune +# after all builders have finished, so compiler/test tooling never crosses the +# runtime boundary. npm's workspace resolver may hoist that closure to the +# repository root, which is the only node_modules tree copied below. +RUN npm prune --omit=dev \ + && test ! -e node_modules/.bin/tsx && test ! -e node_modules/.bin/vite \ + && test ! -d node_modules/typescript && test ! -e node_modules/@playwright/test/package.json # ---- Runtime image ---------------------------------------------------------- FROM node:22-bookworm-slim AS runtime @@ -62,10 +69,6 @@ RUN groupadd --gid 10003 dockermap && \ WORKDIR /opt/dockermap COPY --from=js-builder /src/node_modules ./node_modules -# npm nests workspace deps in the lockfile layout (apps/api/node_modules/express -# etc.); the runtime image must mirror that layout or the API cannot resolve -# its deps. -COPY --from=js-builder /src/apps/api/node_modules ./apps/api/node_modules COPY --from=js-builder /src/package.json ./package.json COPY --from=js-builder /src/apps/api/dist ./apps/api/dist COPY --from=js-builder /src/apps/api/package.json ./apps/api/package.json @@ -81,6 +84,15 @@ COPY deploy/docker/entrypoint.sh /entrypoint.sh COPY deploy/docker/frontend-entrypoint.sh /frontend-entrypoint.sh COPY deploy/docker/healthcheck.sh /usr/local/bin/dockermap-healthcheck RUN chmod +x /entrypoint.sh /frontend-entrypoint.sh /usr/local/bin/dockermap-healthcheck +# The Node base image includes package-manager CLIs that DockerMap never uses +# at runtime. Remove them after staging the already-pruned closure; `node` +# remains available for the compiled API, while npm/npx cannot become an +# in-container mutation surface. +RUN rm -rf /usr/local/lib/node_modules/npm \ + && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \ + && ! command -v npm && ! command -v npx \ + && test ! -e node_modules/.bin/tsx && test ! -e node_modules/.bin/vite \ + && test ! -d node_modules/typescript && test ! -e node_modules/@playwright/test/package.json ENV NODE_ENV=production \ PORT=4000 \ diff --git a/apps/api/package.json b/apps/api/package.json index 3528a8d4..b3229c81 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,13 +14,13 @@ "@dockermap/contracts": "0.1.0", "ajv": "^8.20.0", "cors": "^2.8.5", - "express": "^4.21.2", + "express": "^5.2.1", "helmet": "^8.2.0" }, "devDependencies": { "@seriousme/openapi-schema-validator": "^2.9.1", "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", + "@types/express": "^5.0.6", "tsx": "^4.20.5", "typescript": "^5.9.2" } diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index e74435a6..59d25993 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -21,6 +21,7 @@ export const DAEMON_RESPONSE_SCHEMA_PATHS = [ { path: "/daemon/snapshot", routeId: "snapshot", schema: RUST_ROUTE_RESPONSE_SCHEMAS.snapshot }, { path: "/daemon/graph", routeId: "graph", schema: RUST_ROUTE_RESPONSE_SCHEMAS.graph }, { path: "/daemon/runtime/map", routeId: "runtime-map", schema: RUST_ROUTE_RESPONSE_SCHEMAS["runtime-map"] }, + { path: "/daemon/findings", routeId: "findings", schema: RUST_ROUTE_RESPONSE_SCHEMAS.findings }, { path: "/daemon/containers", routeId: "containers", schema: RUST_ROUTE_RESPONSE_SCHEMAS.containers }, { path: "/daemon/containers/:name", routeId: "container", schema: RUST_ROUTE_RESPONSE_SCHEMAS.container }, { path: "/daemon/images", routeId: "images", schema: RUST_ROUTE_RESPONSE_SCHEMAS.images }, @@ -32,7 +33,7 @@ export const DAEMON_RESPONSE_SCHEMA_PATHS = [ { path: "/daemon/compose/edit-plan", routeId: "compose-edit-plan", schema: RUST_ROUTE_RESPONSE_SCHEMAS["compose-edit-plan"] }, ] as const satisfies readonly { path: string; schema: RustResponseSchemaId; routeId?: keyof typeof RUST_ROUTE_RESPONSE_SCHEMAS }[]; -const ajv = new Ajv2020({ allErrors: true, strict: true, formats: { uint32: true, uint64: true } }); +const ajv = new Ajv2020({ allErrors: true, strict: true, formats: { uint8: true, uint32: true, uint64: true } }); const validators = new Map( (Object.entries(RUST_RESPONSE_SCHEMAS) as [RustResponseSchemaId, (typeof RUST_RESPONSE_SCHEMAS)[RustResponseSchemaId]][]) .map(([schema, definition]) => [schema, ajv.compile(definition)]), @@ -45,12 +46,57 @@ const validators = new Map( const PROVIDER_STATE_SLOT_SET = { network_infrastructure: true, host_scoped: true, + systemd: true, 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; +const SYSTEMD_REQUIRES_FINDING_RULE = "systemd.requires_target_not_active"; +const SYSTEMD_REQUIRES_FINDING_SUMMARY = "An active systemd service requires a target that is inactive or failed"; +const SYSTEMD_REQUIRES_FINDING_RECOMMENDATION = "Inspect the target service state and its declared dependency configuration."; +const INTERNAL_NETWORK_PORT_FINDING_RULE = "docker.internal_network_member_publishes_port"; +const INTERNAL_NETWORK_PORT_FINDING_SUMMARY = "A container on an internal Docker network also has a published host port."; +const INTERNAL_NETWORK_PORT_FINDING_RECOMMENDATION = "Review whether the host-port publication is intended for this internal-network service."; +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."; + +// Version-one evidence is intentionally a discriminated Docker observation, +// not a generic provenance bag. JSON Schema owns each field's closed enum; +// this small cross-field table binds an emitted fact to the relationship it +// can actually support. A later evidence version must add an explicit row. +const V1_EVIDENCE_EDGE = { + docker_network_membership: { relationship: "connected_to", sourcePrefix: "docker_container_", targetPrefix: "docker_network_" }, + docker_volume_mount: { relationship: "mounts", sourcePrefix: "docker_container_", targetPrefix: "docker_volume_" }, + docker_port_publication: { relationship: "exposes", sourcePrefix: "docker_container_", targetPrefix: "network_listener_" }, + docker_compose_depends_on: { relationship: "depends_on", sourcePrefix: "docker_container_", targetPrefix: "docker_container_" }, + docker_daemon_state_bind_mount: { relationship: "exposes_daemon_state", sourcePrefix: "docker_container_", targetPrefix: "host_risk_docker_daemon_state" }, +} as const; + +// Version two is the intentionally narrow systemd declaration vocabulary. +// It is tied to Systemd's independently scheduled slot, rather than to the +// broader host collection, so retained freshness stays attributable. +const V2_EVIDENCE_EDGE = { + systemd_requires: { relationship: "requires", sourcePrefix: "systemd_service_", targetPrefix: "systemd_service_" }, + systemd_wants: { relationship: "wants", sourcePrefix: "systemd_service_", targetPrefix: "systemd_service_" }, + systemd_part_of: { relationship: "part_of", sourcePrefix: "systemd_service_", targetPrefix: "systemd_service_" }, +} as const; + +// Version three is equally narrow: a package manifest declaration from the +// separately scheduled ProjectNpm slot. It says nothing about installation, +// resolution, execution, or package safety. +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; @@ -119,6 +165,162 @@ function hasCoherentProviderFreshness(payload: unknown): boolean { }); } +function hasCoherentRuntimeEvidence(payload: unknown): boolean { + if (!payload || typeof payload !== "object") return false; + const edges = (payload as { edges?: unknown }).edges; + if (!Array.isArray(edges)) return false; + return edges.every((edge) => { + if (!edge || typeof edge !== "object") return false; + const candidate = edge as { source?: unknown; target?: unknown; relationship?: unknown; evidenceRefs?: unknown }; + if (!Array.isArray(candidate.evidenceRefs)) return false; + return candidate.evidenceRefs.every((evidence) => { + if (!evidence || typeof evidence !== "object") return false; + const value = evidence as { + version?: unknown; provider?: unknown; kind?: unknown; assertionKind?: unknown; + freshness?: unknown; providerRevision?: unknown; collectedAt?: unknown; subjectRef?: unknown; + providerSlot?: unknown; + }; + const isV1 = value.version === 1 + && value.provider === "docker" + && value.assertionKind === "observed" + && value.freshness === "fresh" + && (value.providerSlot === null || value.providerSlot === undefined); + const isV2 = value.version === 2 + && value.provider === "systemd" + && value.assertionKind === "declared" + && value.providerSlot === "systemd" + && (value.freshness === "fresh" || value.freshness === "stale" || value.freshness === "timed_out"); + const isV3 = value.version === 3 + && value.provider === "npm" + && value.assertionKind === "declared" + && value.providerSlot === "project_npm" + && (value.freshness === "fresh" || value.freshness === "stale" || value.freshness === "timed_out"); + 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] + : 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 + // re-labelled as a revision. The daemon produces it independently. + return typeof value.providerRevision === "string" && value.providerRevision !== String(value.collectedAt); + }); + }); +} + +// Findings are a deliberately tiny conclusion vocabulary, not a daemon-supplied +// diagnostics channel. The generated schema owns field shape; this exact rule +// table prevents a compromised daemon from inventing mutable claims or copying +// arbitrary strings through the new endpoint. +function hasCoherentFindings(payload: unknown): boolean { + if (!payload || typeof payload !== "object") return false; + const findings = (payload as { findings?: unknown }).findings; + if (!Array.isArray(findings)) return false; + return findings.every((candidate) => { + if (!candidate || typeof candidate !== "object") return false; + const finding = candidate as Record; + if (finding.ruleId === SYSTEMD_REQUIRES_FINDING_RULE) return finding.severity === "warning" + && finding.summary === SYSTEMD_REQUIRES_FINDING_SUMMARY + && finding.recommendation === SYSTEMD_REQUIRES_FINDING_RECOMMENDATION + && typeof finding.id === "string" + && finding.id.startsWith("finding_systemd_requires_target_not_active_") + && typeof finding.subjectRef === "string" + && finding.subjectRef.startsWith("systemd_service_") + && typeof finding.targetRef === "string" + && finding.targetRef.startsWith("systemd_service_") + && 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 === 2 + && evidence.provider === "systemd" + && evidence.kind === "systemd_requires" + && evidence.assertionKind === "declared" + && evidence.providerSlot === "systemd" + && evidence.freshness === "fresh" + && evidence.subjectRef === finding.subjectRef; + })(); + if (finding.ruleId === DOCKER_DAEMON_STATE_FINDING_RULE) return finding.severity === "warning" + && finding.summary === DOCKER_DAEMON_STATE_FINDING_SUMMARY + && finding.recommendation === DOCKER_DAEMON_STATE_FINDING_RECOMMENDATION + && typeof finding.id === "string" + && finding.id.startsWith("finding_docker_daemon_state_bind_mount_") + && typeof finding.subjectRef === "string" + && finding.subjectRef.startsWith("docker_container_") + && finding.targetRef === "host_risk_docker_daemon_state" + && 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_daemon_state_bind_mount" + && evidence.assertionKind === "observed" + && evidence.summary === "Docker reported a bind mount exposing Docker daemon state" + && evidence.subjectRef === finding.subjectRef + && 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 + && finding.recommendation === INTERNAL_NETWORK_PORT_FINDING_RECOMMENDATION + && typeof finding.id === "string" + && finding.id.startsWith("finding_docker_internal_network_member_publishes_port_") + && typeof finding.subjectRef === "string" + && finding.subjectRef.startsWith("docker_container_") + && typeof finding.targetRef === "string" + && finding.targetRef.startsWith("docker_network_") + && Array.isArray(finding.evidenceRefs) + && finding.evidenceRefs.length === 2 + && (() => { + const [membership, port] = finding.evidenceRefs; + if (!membership || typeof membership !== "object" || !port || typeof port !== "object") return false; + const networkEvidence = membership as Record; + const portEvidence = port as Record; + return networkEvidence.version === 1 + && networkEvidence.provider === "docker" + && networkEvidence.kind === "docker_network_membership" + && networkEvidence.assertionKind === "observed" + && networkEvidence.freshness === "fresh" + && networkEvidence.providerSlot === null + && networkEvidence.subjectRef === finding.subjectRef + && typeof networkEvidence.providerRevision === "string" + && networkEvidence.providerRevision !== String(networkEvidence.collectedAt) + && portEvidence.version === 1 + && portEvidence.provider === "docker" + && portEvidence.kind === "docker_port_publication" + && portEvidence.assertionKind === "observed" + && portEvidence.freshness === "fresh" + && portEvidence.providerSlot === null + && portEvidence.subjectRef === finding.subjectRef + && typeof portEvidence.providerRevision === "string" + && portEvidence.providerRevision !== String(portEvidence.collectedAt); + })(); + }); +} + export function daemonResponseSchemaId(path: string): RustResponseSchemaId | undefined { const pathname = path.split("?", 1)[0]; if (pathname === "/daemon/containers") return "ContainersResponse"; @@ -150,7 +352,9 @@ export class DaemonResponseValidationError extends Error { export function validateDaemonResponse(path: string, payload: unknown) { const schema = daemonResponseSchemaId(path); const validator = schema && validators.get(schema); - if (!validator || !validator(payload) || (schema === "RuntimeMap" && (!hasCompleteProviderStateVector(payload) || !hasCoherentProviderFreshness(payload)))) { + if (!validator || !validator(payload) + || (schema === "RuntimeMap" && (!hasCompleteProviderStateVector(payload) || !hasCoherentProviderFreshness(payload) || !hasCoherentRuntimeEvidence(payload))) + || (schema === "FindingsResponse" && !hasCoherentFindings(payload))) { throw new DaemonResponseValidationError(); } return payload; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b0ad6574..4d834e3b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -422,15 +422,22 @@ function getMockResponse(path: string): T { lastUpdated: mockSnapshot.lastUpdated ?? Date.now(), modelRevision: mockSnapshot.modelRevision ?? "node-mock-v1", providerStates: [ - unavailableProviderState("network_infrastructure"), unavailableProviderState("host_scoped"), + unavailableProviderState("network_infrastructure"), unavailableProviderState("host_scoped"), unavailableProviderState("systemd"), unavailableProviderState("python_processes"), unavailableProviderState("native_processes"), - unavailableProviderState("project_npm") + unavailableProviderState("project_npm"), unavailableProviderState("cron") ], source: "mock" }; return runtimeMap as T; } + if (path === "/daemon/findings") { + return { + findings: [], + modelRevision: mockSnapshot.modelRevision ?? "node-mock-v1" + } as T; + } + if (path === "/daemon/containers") { return { containers: mockContainers } as T; } @@ -577,6 +584,7 @@ registerRoute("auth-whoami", (req, res) => { registerRoute("snapshot", readHandlers.snapshot); registerRoute("graph", readHandlers.graph); registerRoute("runtime-map", readHandlers.runtimeMap); +registerRoute("findings", readHandlers.findings); registerRoute("diagnostics", readHandlers.diagnostics); registerRoute("containers", readHandlers.containers); registerRoute("container", readHandlers.container); @@ -680,7 +688,13 @@ type ExpressLayer = { }; export function registeredRoutes(appInstance: express.Express): RegisteredRoute[] { - const router = appInstance as express.Express & { _router?: { stack?: ExpressLayer[] } }; + // Express 5 exposes the live router at `router`; Express 4 used the + // underscored `_router` property. This inspection is test/startup-policy + // evidence only: runtime request routing remains wholly Express-owned. + const router = appInstance as express.Express & { + router?: { stack?: ExpressLayer[] }; + _router?: { stack?: ExpressLayer[] }; + }; const routes: RegisteredRoute[] = []; const unknownLayers: string[] = []; const walk = (stack: readonly ExpressLayer[]) => { @@ -701,7 +715,7 @@ export function registeredRoutes(appInstance: express.Express): RegisteredRoute[ } } }; - walk(router._router?.stack ?? []); + walk(router.router?.stack ?? router._router?.stack ?? []); if (unknownLayers.length) throw new Error(`Unknown Express layer(s): ${unknownLayers.join(", ")}`); return routes; } diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 62fbdd8b..f80b1feb 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -153,6 +153,7 @@ export const ROUTE_OPERATION_METADATA = { "snapshot": { summary: "Full Docker inventory snapshot", tags: ["docker"], responses: withApiErrors(rustJsonResponseFor("snapshot")) }, "graph": { summary: "Topology graph", tags: ["topology"], responses: withApiErrors(rustJsonResponseFor("graph")) }, "runtime-map": { summary: "Runtime map across all providers", tags: ["runtime"], responses: withApiErrors(rustJsonResponseFor("runtime-map")) }, + "findings": { summary: "Evidence-backed advisory findings", tags: ["runtime"], responses: withApiErrors(rustJsonResponseFor("findings")) }, "diagnostics": { summary: "Aggregated compose and runtime diagnostics", tags: ["system"], responses: withApiErrors(nodeJsonResponse("Diagnostics")) }, "containers": { summary: "List containers", tags: ["docker"], responses: withApiErrors(rustJsonResponseFor("containers")) }, "container": { summary: "Container detail", tags: ["docker"], responses: withApiErrors(rustJsonResponseFor("container")) }, diff --git a/apps/api/src/readHandlers.ts b/apps/api/src/readHandlers.ts index b57b9f8e..b6b49619 100644 --- a/apps/api/src/readHandlers.ts +++ b/apps/api/src/readHandlers.ts @@ -8,6 +8,7 @@ import type { DiagnosticsEntry, DiagnosticsReport, DockerSnapshot, + FindingsResponse, GraphResponse, HealthResponse, ImageRecord, @@ -171,6 +172,7 @@ export function createReadHandlers({ fetchDaemon, sendError, port }: ReadHandler snapshot: respond("/daemon/snapshot"), graph: respond("/daemon/graph"), runtimeMap: respond("/daemon/runtime/map"), + findings: respond("/daemon/findings"), diagnostics: async (_req, res) => { try { const entries: DiagnosticsEntry[] = []; diff --git a/apps/api/src/routes.ts b/apps/api/src/routes.ts index 32170daa..c17a81ba 100644 --- a/apps/api/src/routes.ts +++ b/apps/api/src/routes.ts @@ -36,6 +36,7 @@ export const ROUTE_MANIFEST = [ { id: "snapshot", method: "GET", paths: apiPaths("/api/snapshot"), auth: "authenticated", rateLimit: null }, { id: "graph", method: "GET", paths: apiPaths("/api/graph"), auth: "authenticated", rateLimit: null }, { id: "runtime-map", method: "GET", paths: apiPaths("/api/runtime/map"), auth: "authenticated", rateLimit: null }, + { id: "findings", method: "GET", paths: apiPaths("/api/findings"), auth: "authenticated", rateLimit: null }, { id: "diagnostics", method: "GET", paths: apiPaths("/api/diagnostics"), auth: "authenticated", rateLimit: null }, { id: "containers", method: "GET", paths: apiPaths("/api/containers"), auth: "authenticated", rateLimit: null }, { id: "container", method: "GET", paths: apiPaths("/api/containers/:name"), auth: "authenticated", rateLimit: null }, diff --git a/apps/api/src/rustResponseContracts.ts b/apps/api/src/rustResponseContracts.ts index f8f0a927..6aa120eb 100644 --- a/apps/api/src/rustResponseContracts.ts +++ b/apps/api/src/rustResponseContracts.ts @@ -9,6 +9,7 @@ export const RUST_ROUTE_RESPONSE_SCHEMAS = { snapshot: "DockerSnapshot", graph: "GraphResponse", "runtime-map": "RuntimeMap", + findings: "FindingsResponse", containers: "ContainersResponse", container: "ContainerDetailResponse", images: "ImagesResponse", diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 0152efd3..295916c4 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -441,7 +441,7 @@ test("route manifest completeness rejects every untracked response-capable layer 'app.use("/api/outside-path", (_req, res) => res.status(204).end());', 'app.use((_req, res, next) => process.env.DOCKERMAP_TEST_CONDITION === "respond" ? res.status(204).end() : next());', 'const router = express.Router(); router.get("/outside-mounted", (_req, res) => res.status(204).end()); app.use("/api", router);', - 'app.use("/api/outside-preauth", (_req, res) => res.status(204).end()); const planted = app._router.stack.pop(); app._router.stack.splice(app._router.stack.findIndex((layer) => layer.handle?.name === "limitSessionAttempts"), 0, planted);' + 'app.use("/api/outside-preauth", (_req, res) => res.status(204).end()); const stack = (app.router ?? app._router).stack; const planted = stack.pop(); stack.splice(stack.findIndex((layer) => layer.handle?.name === "limitSessionAttempts"), 0, planted);' ]; for (const mutation of mutations) { const result = await inspectLiveRoutes(mutation); @@ -869,7 +869,7 @@ test("authenticated browser API pass-through responses preserve Rust schemas acr const fixture = async (name: string) => JSON.parse( await readFile(new URL(`../../../tests/fixtures/contracts/${name}`, import.meta.url), "utf8") ) as Record; - const [snapshot, graph, runtimeMap, logs, composeScan, composeGraph, composeEditPlan, health] = await Promise.all([ + const [snapshot, graph, runtimeMap, logs, composeScan, composeGraph, composeEditPlan, health, findings] = await Promise.all([ fixture("mock-snapshot.json"), fixture("graph-response.json"), fixture("runtime-map-daemon-emitted.json"), @@ -877,7 +877,8 @@ test("authenticated browser API pass-through responses preserve Rust schemas acr fixture("compose-scan.json"), fixture("compose-graph.json"), fixture("compose-edit-plan.json"), - fixture("health-response.json") + fixture("health-response.json"), + fixture("findings-response.json") ]); const containers = snapshot.containers as unknown[]; const container = containers.find((entry) => (entry as { name?: unknown }).name === "api"); @@ -887,6 +888,7 @@ test("authenticated browser API pass-through responses preserve Rust schemas acr if (req.url === "/daemon/snapshot") return sendJson(res, 200, snapshot); if (req.url === "/daemon/graph") return sendJson(res, 200, graph); if (req.url === "/daemon/runtime/map") return sendJson(res, 200, runtimeMap); + if (req.url === "/daemon/findings") return sendJson(res, 200, findings); if (req.url === "/daemon/containers") return sendJson(res, 200, { containers }); if (req.url === "/daemon/containers/api") return sendJson(res, 200, container); if (req.url === "/daemon/images") return sendJson(res, 200, { images: snapshot.images }); @@ -902,7 +904,7 @@ test("authenticated browser API pass-through responses preserve Rust schemas acr DOCKERMAP_DAEMON_URL: `http://127.0.0.1:${daemon.port}`, DOCKERMAP_API_TOKEN: "test-token" }); - const ajv = new Ajv2020({ allErrors: true, strict: true, formats: { uint32: true, uint64: true } }); + const ajv = new Ajv2020({ allErrors: true, strict: true, formats: { uint8: true, uint32: true, uint64: true } }); const validators = new Map>(); for (const [schemaName, schema] of Object.entries(RUST_RESPONSE_SCHEMAS) as [RustResponseSchemaId, (typeof RUST_RESPONSE_SCHEMAS)[RustResponseSchemaId]][]) { validators.set(schemaName, ajv.compile(schema)); @@ -912,6 +914,7 @@ test("authenticated browser API pass-through responses preserve Rust schemas acr ["/api/snapshot", "DockerSnapshot"], ["/api/graph", "GraphResponse"], ["/api/runtime/map", "RuntimeMap"], + ["/api/findings", "FindingsResponse"], ["/api/containers", "ContainersResponse"], ["/api/containers/api", "ContainerDetailResponse"], ["/api/images", "ImagesResponse"], @@ -984,6 +987,7 @@ test("daemon model responses require non-empty revision and complete provider st ) as Record; const snapshot = await fixture("mock-snapshot.json"); const runtime = await fixture("runtime-map.json"); + const findings = await fixture("findings-response.json"); const invalidResponses = [ ["/daemon/snapshot", { ...snapshot, modelRevision: "" }], ["/daemon/snapshot", (() => { const value = structuredClone(snapshot); delete value.modelRevision; return value; })()], @@ -1029,7 +1033,14 @@ test("daemon model responses require non-empty revision and complete provider st lastSuccessMs: null, lastDurationMs: null, dataRevision: null, consecutiveFailureCount: 0 }); return value; - })()] + })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[0].summary = "DOCKERMAP_TEST_FORGED_FINDING"; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); value.findings[0].subjectRef = value.findings[0].targetRef; return value; })()], + ["/daemon/findings", (() => { const value = structuredClone(findings); delete value.findings[0].evidenceRefs; return value; })()], + ["/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; })()] ] as const; for (const [daemonPath, body] of invalidResponses) { const daemon = await startStubDaemon((req, res) => { @@ -1112,6 +1123,317 @@ test("daemon response validator maps every generated Rust response root and reje assert.throws(() => validateDaemonResponse("/daemon/not-documented", {})); }); +test("runtime evidence is required and fails closed before browser publication", async () => { + const { validateDaemonResponse } = await import("../src/daemonResponseValidation.js"); + const fixture = JSON.parse(await readFile( + new URL("../../../tests/fixtures/contracts/runtime-map-daemon-emitted.json", import.meta.url), + "utf8" + )); + assert.doesNotThrow(() => validateDaemonResponse("/daemon/runtime/map", fixture)); + + const missing = structuredClone(fixture); + delete missing.edges[0].evidenceRefs; + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", missing)); + + const malformed = structuredClone(fixture); + malformed.edges[0].evidenceRefs[0].summary = { unexpected: "object" }; + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", malformed)); + + const extra = structuredClone(fixture); + extra.edges[0].evidenceRefs[0].rawConfig = "must never become a public field"; + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", extra)); + + for (const [field, value] of [ + ["provider", "systemd"], + ["kind", "systemd_requires"], + ["assertionKind", "inferred"], + ["freshness", "stale"], + ["version", 2] + ] as const) { + const fabricated = structuredClone(fixture); + fabricated.edges[0].evidenceRefs[0][field] = value; + assert.throws( + () => validateDaemonResponse("/daemon/runtime/map", fabricated), + `v1 evidence must reject fabricated ${field}` + ); + } + + const evidence = fixture.edges[0].evidenceRefs[0]; + assert.notEqual( + evidence.providerRevision, + String(evidence.collectedAt), + "providerRevision is an opaque observation token, not a timestamp alias" + ); + const timestampAlias = structuredClone(fixture); + timestampAlias.edges[0].evidenceRefs[0].providerRevision = String(timestampAlias.edges[0].evidenceRefs[0].collectedAt); + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", timestampAlias)); + const wrongSubject = structuredClone(fixture); + wrongSubject.edges[0].evidenceRefs[0].subjectRef = "docker_container_not_the_edge_source"; + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", wrongSubject)); + for (const field of ["source", "target"] as const) { + const wrongEndpoint = structuredClone(fixture); + wrongEndpoint.edges[0][field] = `runtime_${field}_not_docker`; + if (field === "source") wrongEndpoint.edges[0].evidenceRefs[0].subjectRef = wrongEndpoint.edges[0].source; + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", wrongEndpoint)); + } + const selfDeclaredDependency = structuredClone(fixture); + selfDeclaredDependency.edges[0].target = selfDeclaredDependency.edges[0].source; + assert.throws( + () => validateDaemonResponse("/daemon/runtime/map", selfDeclaredDependency), + "a Compose declaration cannot attest a self dependency" + ); + + for (const [kind, relationship] of [ + ["docker_network_membership", "mounts"], + ["docker_volume_mount", "exposes"], + ["docker_port_publication", "connected_to"], + ["docker_compose_depends_on", "connected_to"] + ] as const) { + const mismatched = structuredClone(fixture); + mismatched.edges[0].evidenceRefs[0].kind = kind; + mismatched.edges[0].relationship = relationship; + assert.throws( + () => validateDaemonResponse("/daemon/runtime/map", mismatched), + `${kind} must not support ${relationship}` + ); + } + + const systemd = structuredClone(fixture); + Object.assign(systemd.edges[0], { + source: "systemd_service_application", + target: "systemd_service_database", + relationship: "requires" + }); + Object.assign(systemd.edges[0].evidenceRefs[0], { + version: 2, + provider: "systemd", + kind: "systemd_requires", + assertionKind: "declared", + summary: "systemd declared a Requires dependency", + subjectRef: "systemd_service_application", + providerRevision: "opaque-systemd-observation", + providerSlot: "systemd", + freshness: "stale" + }); + assert.doesNotThrow(() => validateDaemonResponse("/daemon/runtime/map", systemd)); + for (const [kind, relationship] of [ + ["systemd_requires", "wants"], + ["systemd_wants", "part_of"], + ["systemd_part_of", "requires"] + ] as const) { + const mismatched = structuredClone(systemd); + mismatched.edges[0].evidenceRefs[0].kind = kind; + mismatched.edges[0].relationship = relationship; + assert.throws( + () => validateDaemonResponse("/daemon/runtime/map", mismatched), + `${kind} must not support ${relationship}` + ); + } + const wrongSlot = structuredClone(systemd); + wrongSlot.edges[0].evidenceRefs[0].providerSlot = "host_scoped"; + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", wrongSlot)); + + const daemonState = structuredClone(fixture); + Object.assign(daemonState.edges[0], { + source: "docker_container_api", + target: "host_risk_docker_daemon_state", + relationship: "exposes_daemon_state" + }); + Object.assign(daemonState.edges[0].evidenceRefs[0], { + version: 1, + provider: "docker", + kind: "docker_daemon_state_bind_mount", + assertionKind: "observed", + summary: "Docker reported a bind mount exposing Docker daemon state", + subjectRef: "docker_container_api", + providerSlot: null, + freshness: "fresh" + }); + assert.doesNotThrow(() => validateDaemonResponse("/daemon/runtime/map", daemonState)); + const daemonStateWrongTarget = structuredClone(daemonState); + daemonStateWrongTarget.edges[0].target = "host_risk_docker_daemon_state_untrusted"; + assert.throws( + () => validateDaemonResponse("/daemon/runtime/map", daemonStateWrongTarget), + "Docker daemon-state evidence has one canonical synthetic target" + ); + + const npmEdge = fixture.edges.find((edge: { source?: unknown }) => edge.source === "npm_project_dockermap"); + assert.ok(npmEdge, "canonical daemon fixture carries a V3 NPM manifest declaration"); + assert.doesNotThrow(() => validateDaemonResponse("/daemon/runtime/map", fixture)); + for (const freshness of ["stale", "timed_out"] as const) { + const retainedNpm = structuredClone(fixture); + const edge = retainedNpm.edges.find((candidate: { source?: unknown }) => candidate.source === "npm_project_dockermap"); + assert.ok(edge); + edge.evidenceRefs[0].freshness = freshness; + assert.doesNotThrow( + () => validateDaemonResponse("/daemon/runtime/map", retainedNpm), + `v3 npm evidence may retain ${freshness} data from its own scheduler slot` + ); + } + for (const [field, value] of [ + ["provider", "docker"], + ["kind", "docker_compose_depends_on"], + ["assertionKind", "observed"], + ["providerSlot", "systemd"], + ["freshness", "unavailable"], + ["version", 2] + ] as const) { + const malformedNpm = structuredClone(fixture); + const edge = malformedNpm.edges.find((candidate: { source?: unknown }) => candidate.source === "npm_project_dockermap"); + assert.ok(edge); + edge.evidenceRefs[0][field] = value; + assert.throws( + () => validateDaemonResponse("/daemon/runtime/map", malformedNpm), + `v3 npm evidence must reject fabricated ${field}` + ); + } + const wrongNpmEndpoint = structuredClone(fixture); + const malformedNpmEdge = wrongNpmEndpoint.edges.find((edge: { source?: unknown }) => edge.source === "npm_project_dockermap"); + 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 () => { + 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_RUNTIME_EVIDENCE_SECRET"; + const hostile = structuredClone(fixture); + hostile.edges[0].source = `token=${sentinel}`; + hostile.edges[0].evidenceRefs[0].subjectRef = hostile.edges[0].source; + const daemon = await startStubDaemon((req, res) => { + if (req.url === "/daemon/runtime/map") return sendJson(res, 200, hostile); + 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/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("fabricated V3 NPM 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_NPM_EVIDENCE_SECRET"; + const npmEdge = fixture.edges.find((edge: { source?: unknown }) => edge.source === "npm_project_dockermap"); + assert.ok(npmEdge, "canonical fixture must exercise the V3 browser boundary"); + npmEdge.target = `npm_package_token_${sentinel}`; + npmEdge.evidenceRefs[0].subjectRef = npmEdge.source; + npmEdge.evidenceRefs[0].providerSlot = "systemd"; + 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("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) => { @@ -1123,7 +1445,7 @@ test("actual canonical and v1 SSE snapshot/error frames use their declared paylo DOCKERMAP_API_TOKEN: "test-token" }); const auth = { Authorization: "Bearer test-token" }; - const rustValidator = new Ajv2020({ allErrors: true, strict: true, formats: { uint32: true, uint64: true } }) + const rustValidator = new Ajv2020({ allErrors: true, strict: true, formats: { uint8: true, uint32: true, uint64: true } }) .compile(RUST_RESPONSE_SCHEMAS.HealthResponse); for (const path of ["/api/events/stream", "/api/v1/events/stream"]) { @@ -1974,13 +2296,32 @@ test("API publishes redacted and normalized daemon data on every response route" if (req.url === "/daemon/runtime/map") { sendJson(res, 200, { nodes: [{ id: hostile, provider: "other", type: "service", label: hostile, status: hostile, metadata: { [hostile]: hostile } }], - edges: [{ source: hostile, target: hostile, relationship: "depends_on", metadata: { [hostile]: hostile } }], + edges: [{ + source: `docker_container_${hostile}`, + target: `docker_network_${hostile}`, + relationship: "connected_to", + metadata: { [hostile]: hostile }, + evidenceRefs: [{ + version: 1, + id: hostile, + provider: "docker", + kind: "docker_network_membership", + assertionKind: "observed", + summary: hostile, + subjectRef: `docker_container_${hostile}`, + collectedAt: 1, + providerRevision: hostile, + freshness: "fresh" + }] + }], diagnostics: [{ provider: "other", severity: "warning", message: hostile }], lastUpdated: 1, modelRevision: "revision-1", 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" }, { slot: "project_npm", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" } @@ -2110,7 +2451,7 @@ test("SSE error payloads and invalid log service names cannot reflect hostile in async function inspectLiveRoutes(mutation = "") { const port = await freePort(); const script = ` - import express from "./apps/api/node_modules/express/lib/express.js"; + import express from "express"; import { app, registeredRoutes } from "./apps/api/src/index.ts"; import { assertRouteManifestComplete } from "./apps/api/src/routes.ts"; ${mutation} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 09c09a3e..f05cc8b3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -16,6 +16,7 @@ import Logs from "./screens/Logs"; import Compose from "./screens/Compose"; import Diagnostics from "./screens/Diagnostics"; import Settings from "./screens/Settings"; +import Findings from "./screens/Findings"; import NotFound from "./screens/NotFound"; import { useSettings } from "./hooks/useSettings"; import { useEffect, useState } from "react"; @@ -43,6 +44,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index c3a3365e..5b05010e 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; -import type { AuthWhoamiResponse } from "@dockermap/contracts"; +import type { AuthWhoamiResponse, FindingsResponse } from "@dockermap/contracts"; import { useDaemonHeartbeat } from "../hooks/useDaemonHeartbeat"; import { useSystemModel } from "../hooks/useSystemModel"; import { useSettings } from "../hooks/useSettings"; @@ -44,6 +44,7 @@ const SPACES: { heading: string; items: NavItem[] }[] = [ { to: "/", label: "Home", icon: "home", end: true }, { to: "/map", label: "Service Map", icon: "map" }, { to: "/runtime", label: "Runtime", icon: "layers" }, + { to: "/findings", label: "Findings", icon: "alert" }, { to: "/changes", label: "Changes", icon: "history" }, { to: "/copilot", label: "Copilot", icon: "spark" } ] @@ -140,6 +141,13 @@ export default function AppShell({ onBearerSignOut }: { onBearerSignOut: () => v healthMode: health?.mode ?? null }); const { model, modelProvenance, loading, error } = useSystemModel(tick, evidenceMode); + const findingsResource = useApiResource("/api/findings", tick); + const findings = useMemo(() => { + if (modelProvenance !== "live" || !model || !findingsResource.data) return null; + return findingsResource.data.modelRevision === model.modelRevision && findingsResource.data.modelRevision.length > 0 + ? findingsResource.data + : null; + }, [findingsResource.data, model, modelProvenance]); const [commandOpen, setCommandOpen] = useState(false); const [clock, setClock] = useState(() => Date.now()); @@ -189,6 +197,7 @@ export default function AppShell({ onBearerSignOut }: { onBearerSignOut: () => v loading, error, health, + findings, tick, evidenceMode, openCommand: () => setCommandOpen(true) diff --git a/apps/web/src/context.tsx b/apps/web/src/context.tsx index 951f9687..672d4ea1 100644 --- a/apps/web/src/context.tsx +++ b/apps/web/src/context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import type { HealthResponse } from "@dockermap/contracts"; +import type { FindingsResponse, HealthResponse } from "@dockermap/contracts"; import type { SystemModel } from "./lib/model"; import type { EvidenceMode, ModelProvenance } from "./lib/evidence"; @@ -10,6 +10,8 @@ export interface AppContextValue { loading: boolean; error: string | null; health: HealthResponse | null; + /** Findings are published only when they attest the current live model revision. */ + findings?: FindingsResponse | null; tick: number; evidenceMode: EvidenceMode | null; openCommand: () => void; diff --git a/apps/web/src/lib/demoData.ts b/apps/web/src/lib/demoData.ts index 16959210..365b57ad 100644 --- a/apps/web/src/lib/demoData.ts +++ b/apps/web/src/lib/demoData.ts @@ -3,6 +3,7 @@ import type { ContainerRecord, DiagnosticsReport, DockerSnapshot, + FindingsResponse, GraphResponse, HealthResponse, ImageRecord, @@ -233,6 +234,8 @@ const demoRuntimeMap: RuntimeMap = { 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" }, { slot: "project_npm", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" } @@ -393,12 +396,12 @@ const demoRuntimeMap: RuntimeMap = { } ], edges: [ - { source: "runtime_gateway", target: "runtime_api", relationship: "proxies_to", metadata: { port: 80 } }, - { source: "runtime_api", target: "runtime_postgres", relationship: "depends_on", metadata: { source: "compose" } }, - { source: "runtime_worker", target: "runtime_api", relationship: "calls", metadata: { queue: "jobs" } }, - { source: "runtime_worker", target: "runtime_postgres", relationship: "depends_on", metadata: { source: "runtime" } }, - { source: "runtime_postgres", target: "runtime_postgres_data", relationship: "mounts", metadata: { path: "/var/lib/postgresql/data" } }, - { source: "runtime_systemd_api", target: "runtime_gateway", relationship: "exposes", metadata: { unit: "dockermap-api.service" } } + { source: "runtime_gateway", target: "runtime_api", relationship: "proxies_to", metadata: { port: 80 }, evidenceRefs: [] }, + { source: "runtime_api", target: "runtime_postgres", relationship: "depends_on", metadata: { source: "compose" }, evidenceRefs: [] }, + { source: "runtime_worker", target: "runtime_api", relationship: "calls", metadata: { queue: "jobs" }, evidenceRefs: [] }, + { source: "runtime_worker", target: "runtime_postgres", relationship: "depends_on", metadata: { source: "runtime" }, evidenceRefs: [] }, + { source: "runtime_postgres", target: "runtime_postgres_data", relationship: "mounts", metadata: { path: "/var/lib/postgresql/data" }, evidenceRefs: [] }, + { source: "runtime_systemd_api", target: "runtime_gateway", relationship: "exposes", metadata: { unit: "dockermap-api.service" }, evidenceRefs: [] } ], diagnostics: [ { @@ -442,6 +445,7 @@ export function getDemoResponse(path: string): T { if (pathname === "/api/snapshot") return demoSnapshot as T; if (pathname === "/api/graph") return demoGraph as T; if (pathname === "/api/runtime/map") return demoRuntimeMap as T; + if (pathname === "/api/findings") return { findings: [], modelRevision: demoSnapshot.modelRevision } as FindingsResponse as T; if (pathname === "/api/health") { return { node: { status: "ok", port: 4000 }, diff --git a/apps/web/src/lib/model.test.ts b/apps/web/src/lib/model.test.ts index c7d9c41f..c6e29a41 100644 --- a/apps/web/src/lib/model.test.ts +++ b/apps/web/src/lib/model.test.ts @@ -65,9 +65,11 @@ describe("runtime collection evidence", () => { const providerStates: RuntimeMap["providerStates"] = [ { ...testProviderStates[0], state: "fresh", lastAttemptMs: 1, lastSuccessMs: 2, lastDurationMs: 1, dataRevision: "test-provider-1", statusReason: null }, { ...testProviderStates[1], state: "stale", lastAttemptMs: 1, lastSuccessMs: 2, lastDurationMs: 1, consecutiveFailureCount: 1, dataRevision: "test-provider-2", statusReason: "collection_failed" }, - { ...testProviderStates[2], state: "collecting", lastAttemptMs: 3, lastSuccessMs: 2, lastDurationMs: 1, dataRevision: "test-provider-3", statusReason: "refreshing" }, - { ...testProviderStates[3], state: "timed_out", lastAttemptMs: 3, lastSuccessMs: 2, lastDurationMs: 1, consecutiveFailureCount: 1, dataRevision: "test-provider-4", statusReason: "collection_timed_out" }, - { ...testProviderStates[4], state: "disabled", statusReason: "disabled" } + { ...testProviderStates[2], state: "fresh", lastAttemptMs: 1, lastSuccessMs: 2, lastDurationMs: 1, dataRevision: "test-provider-3", statusReason: null }, + { ...testProviderStates[3], state: "collecting", lastAttemptMs: 3, statusReason: "refreshing" }, + { ...testProviderStates[4], state: "timed_out", lastAttemptMs: 3, lastSuccessMs: 2, lastDurationMs: 1, consecutiveFailureCount: 1, dataRevision: "test-provider-5", statusReason: "collection_timed_out" }, + { ...testProviderStates[5], state: "disabled", statusReason: "disabled" }, + { ...testProviderStates[6], state: "disabled", statusReason: "disabled" } ]; const model = buildModel(snapshot([]), { ...emptyRuntime, providerStates }); diff --git a/apps/web/src/lib/model.ts b/apps/web/src/lib/model.ts index c841a7ad..ae4967da 100644 --- a/apps/web/src/lib/model.ts +++ b/apps/web/src/lib/model.ts @@ -120,6 +120,8 @@ export interface SystemModel { volumeNameCollisions: Set; imageRefCollisions: Set; lastUpdated: number; + /** Opaque daemon publication identity shared by the source model envelopes. */ + modelRevision: string; } export type RuntimeLayerId = NonNullable | "unassigned"; @@ -451,7 +453,8 @@ export function buildModel(snapshot: DockerSnapshot, runtimeMap: RuntimeMap): Sy networkNameCollisions, volumeNameCollisions, imageRefCollisions, - lastUpdated: Math.max(snapshot.lastUpdated, runtime.lastUpdated) + lastUpdated: Math.max(snapshot.lastUpdated, runtime.lastUpdated), + modelRevision: snapshot.modelRevision }; } diff --git a/apps/web/src/lib/testProviderStates.ts b/apps/web/src/lib/testProviderStates.ts index c3673262..cc295239 100644 --- a/apps/web/src/lib/testProviderStates.ts +++ b/apps/web/src/lib/testProviderStates.ts @@ -8,8 +8,10 @@ function unavailableProviderState(slot: ProviderSlot): ProviderState { } /** Complete fixed-slot state used by browser-only fixtures. */ -export const testProviderStates: [ProviderState, ProviderState, ProviderState, ProviderState, ProviderState] = [ +export const testProviderStates: [ProviderState, ProviderState, ProviderState, ProviderState, ProviderState, ProviderState, ProviderState] = [ unavailableProviderState("network_infrastructure"), unavailableProviderState("host_scoped"), + unavailableProviderState("cron"), + unavailableProviderState("systemd"), unavailableProviderState("python_processes"), unavailableProviderState("native_processes"), unavailableProviderState("project_npm") ]; diff --git a/apps/web/src/screens/Findings.tsx b/apps/web/src/screens/Findings.tsx new file mode 100644 index 00000000..3d9a4cb5 --- /dev/null +++ b/apps/web/src/screens/Findings.tsx @@ -0,0 +1,55 @@ +import { Link } from "react-router-dom"; +import { useApp } from "../context"; +import Icon from "../components/Icon"; +import { EmptyState, Loading, Panel, Tag } from "../components/primitives"; + +export default function Findings() { + const { findings, loading } = useApp(); + + if (loading && !findings) return ; + + const presentationFor = (ruleId: string) => { + if (ruleId === "systemd.requires_target_not_active") return ["Declared dependency needs review", "Observed declaration"] as const; + if (ruleId === "docker.daemon_state_bind_mount") return ["Docker daemon-state access needs review", "Observed Docker fact"] as const; + return ["Internal-network port publication needs review", "Observed Docker facts"] as const; + }; + + return ( +
+
+
+
Evidence-backed review
+

Findings

+

A small set of explicit evidence conditions. These are not health, readiness, traffic, Internet-reachability, or security conclusions.

+
+ Open Runtime +
+ + {!findings ? ( + + + + ) : findings.findings.length === 0 ? ( + + + + ) : ( +
+ {findings.findings.map((finding) => { + const [title, hint] = presentationFor(finding.ruleId); + const category = finding.ruleId === "systemd.requires_target_not_active" ? "Systemd Requires" : finding.ruleId === "docker.daemon_state_bind_mount" ? "Docker daemon state" : "Internal network + host port"; + return +
{finding.severity === "warning" ? "Warning" : "Advisory"}{category}{finding.evidenceRefs.length} supporting fact{finding.evidenceRefs.length === 1 ? "" : "s"}
+

{finding.summary}

+

{finding.recommendation}

+
+
Declaring service
{finding.subjectRef}
+
Target service
{finding.targetRef}
+
+
; + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index 704c603a..ec3f5e21 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -19,7 +19,7 @@ import { resourceFor } from "../lib/stubs"; import { UPDATE_STATUS_CLAIM, UPDATE_STATUS_LABEL } from "../lib/updates"; export default function Home() { - const { model, modelProvenance, loading, error, evidenceMode } = useApp(); + const { model, modelProvenance, loading, error, evidenceMode, findings } = useApp(); const history = useMemo( () => (model ? changeFeed(model, evidenceMode, modelProvenance) : CHANGE_HISTORY_CLAIM), [model, evidenceMode, modelProvenance] @@ -54,6 +54,7 @@ export default function Home() { {summary.healthy}} /> {summary.attention}} /> {summary.offline}} /> + @@ -117,6 +118,16 @@ export default function Home() { + Review}> + {findings ? ( + findings.findings.length === 0 + ? + :

{findings.findings.length} bounded finding{findings.findings.length === 1 ? "" : "s"} available for review.

+ ) : ( + + )} +
+ {history.kind === "unavailable" ? ( diff --git a/apps/web/src/screens/Runtime.tsx b/apps/web/src/screens/Runtime.tsx index c2e8b129..b68099d4 100644 --- a/apps/web/src/screens/Runtime.tsx +++ b/apps/web/src/screens/Runtime.tsx @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; -import type { ProviderSlot, ProviderState, ProviderStatusReason, RuntimeLocation, RuntimeProviderKind } from "@dockermap/contracts"; +import type { ProviderSlot, ProviderState, ProviderStatusReason, RuntimeEvidenceAssertionKind, RuntimeEvidenceRef, RuntimeLocation, RuntimeMapEdge, RuntimeProviderKind } from "@dockermap/contracts"; import { useApp } from "../context"; import { needsAttention, type RuntimeLayerId, type RuntimeNodeRecord } from "../lib/model"; import { formatRelative } from "../lib/format"; @@ -49,6 +49,8 @@ const LAYER_LABEL: Record = { const PROVIDER_SLOT_LABEL: Record = { network_infrastructure: "Network infrastructure", host_scoped: "Host-scoped services", + cron: "Cron schedule declarations", + systemd: "systemd services", python_processes: "Python processes", native_processes: "Native processes", project_npm: "Project npm" @@ -83,6 +85,26 @@ const PROVIDER_REASON_LABEL: Record = { disabled: "Collection disabled" }; +const ASSERTION_KIND_LABEL: Record = { + observed: "Observed fact", + declared: "Declared relationship" +}; + +const FRESHNESS_LABEL: Record = { + fresh: "Current at collection", + stale: "Retained observation", + timed_out: "Collection timed out" +}; + +type SelectedRuntimeEdge = { + edge: RuntimeMapEdge; + key: string; +}; + +function runtimeEdgeKey(edge: RuntimeMapEdge) { + return [edge.source, edge.target, edge.relationship, ...edge.evidenceRefs.map((evidence) => evidence.id)].join("\u0000"); +} + function providerFreshnessText(providerState: ProviderState): string { if (providerState.lastSuccessMs !== null) return `Last collected ${formatRelative(providerState.lastSuccessMs)}`; if (providerState.lastAttemptMs !== null) return `Last attempted ${formatRelative(providerState.lastAttemptMs)}`; @@ -95,6 +117,7 @@ export default function RuntimeScreen() { const [layerFilter, setLayerFilter] = useState("all"); const [attentionOnly, setAttentionOnly] = useState(false); const [selectedId, setSelectedId] = useState(null); + const [selectedEdge, setSelectedEdge] = useState(null); const nodeRefs = useRef(new Map()); /** * KEYED focus request: set by selectNode, consumed by the layout effect @@ -164,6 +187,7 @@ export default function RuntimeScreen() { */ const selectNode = (id: string) => { setSelectedId(id); + setSelectedEdge(null); const node = runtime?.byId.get(id); if (!node) return; if (!filteredNodes.some((n) => n.id === id)) { @@ -275,7 +299,7 @@ export default function RuntimeScreen() {
    @@ -409,8 +433,10 @@ export default function RuntimeScreen() { )} - - + setSelectedEdge({ edge, key: runtimeEdgeKey(edge) })} selectedEdgeKey={selectedEdge?.key ?? null} /> + setSelectedEdge({ edge, key: runtimeEdgeKey(edge) })} selectedEdgeKey={selectedEdge?.key ?? null} /> + + {selectedEdge ? : null} {selected.service?.logs.length ? (
    @@ -482,7 +508,9 @@ function RelationList({ model, edges, direction, - onSelect + onSelect, + onInspectEdge, + selectedEdgeKey }: { title: string; selected: RuntimeNodeRecord; @@ -490,6 +518,8 @@ function RelationList({ edges: RuntimeNodeRecord["incoming"]; direction: "incoming" | "outgoing"; onSelect: (id: string) => void; + onInspectEdge: (edge: RuntimeMapEdge) => void; + selectedEdgeKey: string | null; }) { return (
    @@ -499,6 +529,7 @@ function RelationList({ ) : (
      {edges.map((edge, index) => { + const edgeKey = runtimeEdgeKey(edge); const targetId = direction === "outgoing" ? edge.target : edge.source; const node = model.runtime.byId.get(targetId); if (!node) { @@ -508,6 +539,9 @@ function RelationList({ {edge.relationship.replaceAll("_", " ")} {identityText(targetId, UNAVAILABLE_RUNTIME_ID)} {collided && {COLLISION_TAG}} + ); } @@ -519,6 +553,9 @@ function RelationList({ {identityText(node.label, UNAVAILABLE_RUNTIME_NODE)} {edge.relationship.replaceAll("_", " ")} + ); })} @@ -528,6 +565,54 @@ function RelationList({ ); } +export function RuntimeEvidenceInspector({ + edge, + model +}: { + edge: RuntimeMapEdge; + model: NonNullable["model"]>; +}) { + const source = model.runtime.byId.get(edge.source); + const target = model.runtime.byId.get(edge.target); + const relationship = edge.relationship.replaceAll("_", " "); + + return ( +
      +

      Relationship evidence

      +

      + {identityText(source?.label ?? edge.source, UNAVAILABLE_RUNTIME_ID)} {identityText(target?.label ?? edge.target, UNAVAILABLE_RUNTIME_ID)} +

      + + {edge.evidenceRefs.length === 0 ? ( +

      No evidence references yet — this relationship family is still migrating.

      + ) : ( +
        + {edge.evidenceRefs.map((evidence) => )} +
      + )} +
      + ); +} + +function RuntimeEvidenceReference({ evidence }: { evidence: RuntimeEvidenceRef }) { + return ( +
    • +
      + {ASSERTION_KIND_LABEL[evidence.assertionKind]} + {evidence.kind.replaceAll("_", " ")} + {formatRelative(evidence.collectedAt)} +
      +

      {identityText(evidence.summary, "Evidence summary unavailable")}

      +
      + + + + +
      +
    • + ); +} + function locationLabel(location: RuntimeLocation | null): string { if (!location) return "—"; return `${identityText(location.kind, UNAVAILABLE_LOCATION_KIND)}: ${identityText(location.value, UNAVAILABLE_LOCATION_VALUE)}`; diff --git a/apps/web/src/screens/findings.test.tsx b/apps/web/src/screens/findings.test.tsx new file mode 100644 index 00000000..4c6262ae --- /dev/null +++ b/apps/web/src/screens/findings.test.tsx @@ -0,0 +1,91 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import type { FindingsResponse } from "@dockermap/contracts"; +import { AppContext, type AppContextValue } from "../context"; +import Findings from "./Findings"; + +const findings: FindingsResponse = { + modelRevision: "findings-revision", + findings: [{ + id: "finding_systemd_requires_target_not_active_test", + ruleId: "systemd.requires_target_not_active", + severity: "warning", + summary: "An active systemd service requires a target that is inactive or failed", + recommendation: "Inspect the target service state and its declared dependency configuration.", + subjectRef: "systemd_service_application", + targetRef: "systemd_service_database", + evidenceRefs: [{ + version: 2, id: "systemd_requires:systemd_service_application:systemd_service_database", + provider: "systemd", kind: "systemd_requires", assertionKind: "declared", + summary: "systemd declared a Requires dependency", subjectRef: "systemd_service_application", + collectedAt: 1, providerRevision: "test-systemd-observation", providerSlot: "systemd", freshness: "fresh" + }] + }] +}; + +function render(value: Partial): string { + const context: AppContextValue = { + model: null, modelProvenance: null, loading: false, error: null, health: null, + findings: null, tick: 0, evidenceMode: null, openCommand: () => {}, ...value + }; + return renderToStaticMarkup(); +} + +describe("Findings screen", () => { + it("renders only the bounded declaration conclusion and its static recommendation", () => { + const html = render({ findings }); + expect(html).toContain("Declared dependency needs review"); + expect(html).toContain(findings.findings[0].summary); + expect(html).toContain(findings.findings[0].recommendation); + expect(html).toContain("Systemd Requires"); + expect(html).toContain("not health, readiness, traffic, Internet-reachability, or security conclusions"); + }); + + it("fails closed when a coherent live finding response is unavailable", () => { + const html = render({ findings: null }); + expect(html).toContain("Live evidence is not established"); + expect(html).toContain("model revision matches the current live Docker model"); + }); + + it("describes the Docker internal-network condition without claiming Internet exposure", () => { + const internalPort = structuredClone(findings); + internalPort.findings[0] = { + id: "finding_docker_internal_network_member_publishes_port_test", + ruleId: "docker.internal_network_member_publishes_port", + severity: "advisory", + summary: "A container on an internal Docker network also has a published host port.", + recommendation: "Review whether the host-port publication is intended for this internal-network service.", + subjectRef: "docker_container_api", + targetRef: "docker_network_internal", + evidenceRefs: [ + { version: 1, id: "network", provider: "docker", kind: "docker_network_membership", assertionKind: "observed", summary: "Docker reported container network membership", subjectRef: "docker_container_api", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" }, + { version: 1, id: "port", provider: "docker", kind: "docker_port_publication", assertionKind: "observed", summary: "Docker reported a published container port", subjectRef: "docker_container_api", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" } + ] + }; + const html = render({ findings: internalPort }); + expect(html).toContain("Internal-network port publication needs review"); + expect(html).toContain("Observed Docker facts"); + expect(html).toContain("2 supporting facts"); + expect(html).not.toContain("Internet exposure"); + }); + + it("labels daemon-state access as a bounded authority review without mount details", () => { + const daemonState = structuredClone(findings); + daemonState.findings[0] = { + id: "finding_docker_daemon_state_bind_mount_test", + ruleId: "docker.daemon_state_bind_mount", + severity: "warning", + summary: "A container has Docker daemon state access that may provide Docker daemon API authority.", + recommendation: "Review whether this container requires Docker daemon API authority.", + subjectRef: "docker_container_api", + targetRef: "host_risk_docker_daemon_state", + evidenceRefs: [{ version: 1, id: "daemon-state", provider: "docker", kind: "docker_daemon_state_bind_mount", assertionKind: "observed", summary: "Docker reported a bind mount exposing Docker daemon state", subjectRef: "docker_container_api", collectedAt: 1, providerRevision: "opaque", providerSlot: null, freshness: "fresh" }] + }; + const html = render({ findings: daemonState }); + expect(html).toContain("Docker daemon-state access needs review"); + expect(html).toContain("Docker daemon state"); + expect(html).toContain("may provide Docker daemon API authority"); + expect(html).not.toContain("/var/run/docker.sock"); + }); +}); diff --git a/apps/web/src/screens/runtime-evidence-inspector.test.tsx b/apps/web/src/screens/runtime-evidence-inspector.test.tsx new file mode 100644 index 00000000..5c410136 --- /dev/null +++ b/apps/web/src/screens/runtime-evidence-inspector.test.tsx @@ -0,0 +1,105 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { DockerSnapshot, RuntimeMap } from "@dockermap/contracts"; +import { buildModel } from "../lib/model"; +import { testProviderStates } from "../lib/testProviderStates"; +import { RuntimeEvidenceInspector } from "./Runtime"; + +const snapshot: DockerSnapshot = { + containers: [], images: [], networks: [], volumes: [], lastUpdated: 1, modelRevision: "test-revision" +}; + +const runtime: RuntimeMap = { + nodes: [ + { id: "container-api", provider: "docker", type: "container", label: "api", status: "running", metadata: {} }, + { id: "network-app", provider: "docker", type: "docker_network", label: "app-net", status: null, metadata: {} }, + { id: "systemd_service_api", provider: "systemd", type: "systemd_service", label: "api.service", status: "running", metadata: {} }, + { id: "systemd_service_database", provider: "systemd", type: "systemd_service", label: "database.service", status: "running", metadata: {} } + ], + edges: [ + { + source: "container-api", + target: "network-app", + relationship: "connected_to", + metadata: {}, + evidenceRefs: [{ + version: 1, + id: "docker-network-membership-api-app", + provider: "docker", + kind: "docker_network_membership", + assertionKind: "observed", + summary: "Docker reported container network membership", + subjectRef: "container-api", + collectedAt: 1, + providerRevision: "docker-observation-1", + freshness: "fresh" + }] + }, + { + source: "container-api", + target: "network-app", + relationship: "related_to", + metadata: {}, + evidenceRefs: [] + }, + { + source: "systemd_service_api", + target: "systemd_service_database", + relationship: "requires", + metadata: {}, + evidenceRefs: [{ + version: 2, + id: "systemd-requires-api-database", + provider: "systemd", + kind: "systemd_requires", + assertionKind: "declared", + summary: "systemd declared a Requires dependency", + subjectRef: "systemd_service_api", + collectedAt: 1, + providerRevision: "systemd-observation-1", + providerSlot: "systemd", + freshness: "stale" + }] + } + ], + diagnostics: [], + lastUpdated: 1, + modelRevision: "test-revision", + providerStates: testProviderStates +}; + +const model = buildModel(snapshot, runtime); + +describe("Runtime relationship evidence inspector", () => { + it("renders canonical observed evidence rather than inventing confidence", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Relationship evidence"); + expect(html).toContain("api"); + expect(html).toContain("app-net"); + expect(html).toContain("Observed fact"); + expect(html).toContain("docker network membership"); + expect(html).toContain("Docker reported container network membership"); + expect(html).toContain("Current at collection"); + expect(html).toContain("docker-observation-1"); + expect(html).not.toContain("Confidence"); + }); + + it("makes a relationship with no migrated evidence explicit", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("No evidence references yet — this relationship family is still migrating."); + expect(html).not.toContain("Observed fact"); + }); + + it("renders retained Systemd declarations as collection evidence, not service health", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Declared relationship"); + expect(html).toContain("systemd requires"); + expect(html).toContain("systemd declared a Requires dependency"); + expect(html).toContain("Retained observation"); + expect(html).toContain("systemd-observation-1"); + expect(html).not.toContain("healthy"); + }); +}); diff --git a/apps/web/src/screens/runtime-provider-states.test.tsx b/apps/web/src/screens/runtime-provider-states.test.tsx index e0896f46..3acbe114 100644 --- a/apps/web/src/screens/runtime-provider-states.test.tsx +++ b/apps/web/src/screens/runtime-provider-states.test.tsx @@ -21,8 +21,10 @@ const runtime: RuntimeMap = { providerStates: [ collectionState("network_infrastructure", "fresh"), collectionState("host_scoped", "stale", { consecutiveFailureCount: 1, statusReason: "collection_failed" }), - collectionState("python_processes", "collecting", { statusReason: "refreshing" }), - collectionState("native_processes", "timed_out", { consecutiveFailureCount: 1, statusReason: "collection_timed_out" }), + collectionState("cron", "fresh"), + collectionState("systemd", "collecting", { lastSuccessMs: null, lastDurationMs: null, dataRevision: null, statusReason: "refreshing" }), + collectionState("python_processes", "timed_out", { consecutiveFailureCount: 1, statusReason: "collection_timed_out" }), + collectionState("native_processes", "disabled", { lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, dataRevision: null, statusReason: "disabled" }), collectionState("project_npm", "disabled", { lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, dataRevision: null, statusReason: "disabled" }) ] }; @@ -44,10 +46,12 @@ describe("Runtime collection evidence", () => { const html = render(); expect(html).toContain("Collection evidence"); - expect(html).toContain("Collection state only — it does not describe service health"); - expect(html.match(/class="provider-state-row /g)).toHaveLength(5); + expect(html).toContain("Collection state only — it does not describe service health or cron execution"); + expect(html.match(/class="provider-state-row /g)).toHaveLength(7); expect(html).toContain("Network infrastructure"); expect(html).toContain("Host-scoped services"); + expect(html).toContain("Cron schedule declarations"); + expect(html).toContain("systemd services"); expect(html).toContain("Python processes"); expect(html).toContain("Native processes"); expect(html).toContain("Project npm"); diff --git a/apps/web/src/screens/service-map-density.test.tsx b/apps/web/src/screens/service-map-density.test.tsx index dbd89059..5600cfbe 100644 --- a/apps/web/src/screens/service-map-density.test.tsx +++ b/apps/web/src/screens/service-map-density.test.tsx @@ -9,7 +9,7 @@ import { buildModel } from "../lib/model"; import MapScreen from "./Map"; const runtime: RuntimeMap = { nodes: [], edges: [], diagnostics: [], modelRevision: "test-revision", providerStates: testProviderStates, lastUpdated: 0 }; -const containers = Array.from({ length: 32 }, (_, index) => ({ +const containers = Array.from({ length: 44 }, (_, index) => ({ id: `service-${String(index).padStart(2, "0")}`, name: `service-${String(index).padStart(2, "0")}`, image: "busybox:1", @@ -36,13 +36,13 @@ function renderMap(source: DockerSnapshot) { describe("high-density Service Map", () => { it("keeps the default graph to recorded topology while retaining every observed service in the directory", () => { const html = renderMap(snapshot()); - expect(html).toContain("Services in this snapshot32"); + expect(html).toContain("Services in this snapshot44"); expect(html).toContain("Resolved start-order links2"); - expect(html).toContain("No recorded declaration29"); - // The graph has only the three evidence-connected services, not 32 labels. + expect(html).toContain("No recorded declaration41"); + // The graph has only the three evidence-connected services, not 44 labels. expect(html.split(' Option<(DiagnosticSeverity, String)> { - let path = Path::new(resolved); - - let is_docker_socket = path - .components() - .any(|component| component.as_os_str() == "docker.sock"); - let is_docker_data = resolved == "/var/lib/docker" || resolved.starts_with("/var/lib/docker/"); - if is_docker_socket || is_docker_data { + if is_docker_daemon_state_bind_source(resolved) { return Some(( DiagnosticSeverity::Blocked, format!( @@ -790,6 +784,7 @@ pub(crate) fn unsafe_bind_source_diagnostic( )); } + let path = Path::new(resolved); if path.components().any(|component| { let name = component.as_os_str().to_string_lossy(); CREDENTIAL_DIR_NAMES.iter().any(|needle| name == *needle) @@ -813,6 +808,16 @@ pub(crate) fn unsafe_bind_source_diagnostic( None } +/// Closed, path-boundary predicate shared by Compose diagnostics and runtime +/// derivation. Callers must never publish the matching source path. +pub(crate) fn is_docker_daemon_state_bind_source(resolved: &str) -> bool { + let path = Path::new(resolved); + path.components() + .any(|component| component.as_os_str() == "docker.sock") + || resolved == "/var/lib/docker" + || resolved.starts_with("/var/lib/docker/") +} + fn mounts_match(compose_mount: &ComposeMount, runtime_mount: &ContainerMount) -> bool { compose_mount.kind == runtime_mount.kind && compose_mount.target == runtime_mount.target diff --git a/crates/dockermap-core/src/findings.rs b/crates/dockermap-core/src/findings.rs new file mode 100644 index 00000000..edeef0f7 --- /dev/null +++ b/crates/dockermap-core/src/findings.rs @@ -0,0 +1,662 @@ +use crate::snapshot_runtime::is_host_published_docker_port; +use crate::{ + collision_resistant_id_component, Finding, FindingRule, FindingSeverity, + RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, RuntimeEvidenceKind, + RuntimeEvidenceProvider, RuntimeMap, RuntimeNodeKind, RuntimeProviderKind, + RuntimeRelationshipKind, +}; +use std::collections::BTreeMap; + +const SUMMARY: &str = "An active systemd service requires a target that is inactive or failed"; +const RECOMMENDATION: &str = + "Inspect the target service state and its declared dependency configuration."; +const INTERNAL_NETWORK_PORT_SUMMARY: &str = + "A container on an internal Docker network also has a published host port."; +const INTERNAL_NETWORK_PORT_RECOMMENDATION: &str = + "Review whether the host-port publication is intended for this internal-network service."; +const DOCKER_DAEMON_STATE_SUMMARY: &str = + "A container has Docker daemon state access that may provide Docker daemon API authority."; +const DOCKER_DAEMON_STATE_RECOMMENDATION: &str = + "Review whether this container requires Docker daemon API authority."; +const DOCKER_DAEMON_STATE_RISK_ID: &str = "host_risk_docker_daemon_state"; +const DOCKER_DAEMON_STATE_EVIDENCE_SUMMARY: &str = + "Docker reported a bind mount exposing Docker daemon state"; + +/// Derive bounded, deterministic advisory findings from the already-public +/// runtime topology. The rule intentionally fails closed: it acts only on one +/// fresh V2 systemd `Requires=` declaration between uniquely identified +/// systemd services. Raw provider material is never copied into a finding. +pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec { + let node_counts = runtime_map + .nodes + .iter() + .fold(BTreeMap::new(), |mut counts, node| { + *counts.entry(node.id.as_str()).or_insert(0usize) += 1; + counts + }); + let nodes = runtime_map + .nodes + .iter() + .filter(|node| node_counts.get(node.id.as_str()) == Some(&1)) + .map(|node| (node.id.as_str(), node)) + .collect::>(); + + let mut candidate_counts = BTreeMap::<(&str, &str), usize>::new(); + for edge in &runtime_map.edges { + if is_candidate_requires(edge) { + *candidate_counts + .entry((edge.source.as_str(), edge.target.as_str())) + .or_default() += 1; + } + } + + let mut daemon_state_counts = BTreeMap::<(&str, &str), usize>::new(); + for edge in &runtime_map.edges { + if is_docker_daemon_state_shape(edge, &nodes) { + *daemon_state_counts + .entry((edge.source.as_str(), edge.target.as_str())) + .or_default() += 1; + } + } + + let mut membership_counts = BTreeMap::<(&str, &str), usize>::new(); + let mut port_counts = BTreeMap::<&str, usize>::new(); + for edge in &runtime_map.edges { + if is_docker_membership_shape(edge, &nodes) { + *membership_counts + .entry((edge.source.as_str(), edge.target.as_str())) + .or_default() += 1; + } + if is_docker_port_shape(edge, &nodes) { + *port_counts.entry(edge.source.as_str()).or_default() += 1; + } + } + + let mut findings = Vec::new(); + for edge in &runtime_map.edges { + let pair = (edge.source.as_str(), edge.target.as_str()); + if candidate_counts.get(&pair) != Some(&1) || !is_candidate_requires(edge) { + continue; + } + let evidence = edge.evidence_refs[0].clone(); + let (Some(source), Some(target)) = (nodes.get(pair.0), nodes.get(pair.1)) else { + continue; + }; + if source.provider != RuntimeProviderKind::Systemd + || target.provider != RuntimeProviderKind::Systemd + || source.kind != RuntimeNodeKind::SystemdService + || target.kind != RuntimeNodeKind::SystemdService + || source.status.as_deref() != Some("active") + || !matches!(target.status.as_deref(), Some("inactive" | "failed")) + { + continue; + } + findings.push(Finding { + id: format!( + "finding_systemd_requires_target_not_active_{}", + collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target)) + ), + rule_id: FindingRule::SystemdRequiresTargetNotActive, + severity: FindingSeverity::Warning, + summary: SUMMARY.into(), + recommendation: RECOMMENDATION.into(), + subject_ref: edge.source.clone(), + target_ref: edge.target.clone(), + evidence_refs: vec![evidence], + }); + } + for edge in &runtime_map.edges { + let pair = (edge.source.as_str(), edge.target.as_str()); + if daemon_state_counts.get(&pair) != Some(&1) + || !is_candidate_docker_daemon_state_bind_mount(edge, &nodes) + { + continue; + } + findings.push(Finding { + id: format!( + "finding_docker_daemon_state_bind_mount_{}", + collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target)) + ), + rule_id: FindingRule::DockerDaemonStateBindMount, + severity: FindingSeverity::Warning, + summary: DOCKER_DAEMON_STATE_SUMMARY.into(), + recommendation: DOCKER_DAEMON_STATE_RECOMMENDATION.into(), + subject_ref: edge.source.clone(), + target_ref: edge.target.clone(), + evidence_refs: vec![edge.evidence_refs[0].clone()], + }); + } + for edge in &runtime_map.edges { + let pair = (edge.source.as_str(), edge.target.as_str()); + if membership_counts.get(&pair) != Some(&1) + || port_counts.get(edge.source.as_str()) != Some(&1) + || !is_candidate_internal_network_membership(edge, &nodes) + { + continue; + } + let Some(port_edge) = runtime_map.edges.iter().find(|candidate| { + candidate.source == edge.source && is_candidate_port_publication(candidate, &nodes) + }) else { + continue; + }; + let network_evidence = edge.evidence_refs[0].clone(); + let port_evidence = port_edge.evidence_refs[0].clone(); + findings.push(Finding { + id: format!( + "finding_docker_internal_network_member_publishes_port_{}", + collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target)) + ), + rule_id: FindingRule::DockerInternalNetworkMemberPublishesPort, + severity: FindingSeverity::Advisory, + summary: INTERNAL_NETWORK_PORT_SUMMARY.into(), + recommendation: INTERNAL_NETWORK_PORT_RECOMMENDATION.into(), + subject_ref: edge.source.clone(), + target_ref: edge.target.clone(), + evidence_refs: vec![network_evidence, port_evidence], + }); + } + findings.sort_by(|left, right| { + left.id + .cmp(&right.id) + .then_with(|| left.subject_ref.cmp(&right.subject_ref)) + .then_with(|| left.target_ref.cmp(&right.target_ref)) + }); + findings +} + +fn is_docker_container(node: &crate::RuntimeMapNode) -> bool { + node.provider == RuntimeProviderKind::Docker && node.kind == RuntimeNodeKind::Container +} + +fn is_docker_daemon_state_risk(node: &crate::RuntimeMapNode) -> bool { + node.provider == RuntimeProviderKind::Docker + && node.kind == RuntimeNodeKind::HostRisk + && node.id == DOCKER_DAEMON_STATE_RISK_ID + && node.metadata.is_empty() +} + +fn is_docker_daemon_state_shape<'a>( + edge: &crate::RuntimeMapEdge, + nodes: &BTreeMap<&'a str, &'a crate::RuntimeMapNode>, +) -> bool { + edge.metadata.is_empty() + && edge.relationship == RuntimeRelationshipKind::ExposesDaemonState + && matches!( + (nodes.get(edge.source.as_str()), nodes.get(edge.target.as_str())), + (Some(source), Some(target)) + if is_docker_container(source) && is_docker_daemon_state_risk(target) + ) +} + +fn is_candidate_docker_daemon_state_bind_mount<'a>( + edge: &crate::RuntimeMapEdge, + nodes: &BTreeMap<&'a str, &'a crate::RuntimeMapNode>, +) -> bool { + is_docker_daemon_state_shape(edge, nodes) + && is_fresh_docker_evidence(edge, RuntimeEvidenceKind::DockerDaemonStateBindMount) + && matches!(edge.evidence_refs.first(), Some(evidence) if evidence.summary == DOCKER_DAEMON_STATE_EVIDENCE_SUMMARY) +} + +fn is_docker_network(node: &crate::RuntimeMapNode) -> bool { + node.provider == RuntimeProviderKind::Docker + && node.kind == RuntimeNodeKind::DockerNetwork + && node.metadata.get("internal").map(String::as_str) == Some("true") +} + +fn is_docker_listener(node: &crate::RuntimeMapNode) -> bool { + node.provider == RuntimeProviderKind::Network + && node.kind == RuntimeNodeKind::NetworkListener + && node + .metadata + .get("port") + .is_some_and(|port| is_host_published_docker_port(port)) +} + +fn is_docker_membership_shape<'a>( + edge: &crate::RuntimeMapEdge, + nodes: &BTreeMap<&'a str, &'a crate::RuntimeMapNode>, +) -> bool { + edge.relationship == RuntimeRelationshipKind::ConnectedTo + && matches!((nodes.get(edge.source.as_str()), nodes.get(edge.target.as_str())), + (Some(source), Some(target)) if is_docker_container(source) && is_docker_network(target)) +} + +fn is_docker_port_shape<'a>( + edge: &crate::RuntimeMapEdge, + nodes: &BTreeMap<&'a str, &'a crate::RuntimeMapNode>, +) -> bool { + edge.relationship == RuntimeRelationshipKind::Exposes + && matches!((nodes.get(edge.source.as_str()), nodes.get(edge.target.as_str())), + (Some(source), Some(target)) if is_docker_container(source) && is_docker_listener(target)) +} + +fn is_candidate_internal_network_membership<'a>( + edge: &crate::RuntimeMapEdge, + nodes: &BTreeMap<&'a str, &'a crate::RuntimeMapNode>, +) -> bool { + is_docker_membership_shape(edge, nodes) + && is_fresh_docker_evidence(edge, RuntimeEvidenceKind::DockerNetworkMembership) +} + +fn is_candidate_port_publication<'a>( + edge: &crate::RuntimeMapEdge, + nodes: &BTreeMap<&'a str, &'a crate::RuntimeMapNode>, +) -> bool { + is_docker_port_shape(edge, nodes) + && is_fresh_docker_evidence(edge, RuntimeEvidenceKind::DockerPortPublication) +} + +fn is_fresh_docker_evidence(edge: &crate::RuntimeMapEdge, kind: RuntimeEvidenceKind) -> bool { + edge.has_valid_evidence_refs() + && edge.evidence_refs.len() == 1 + && matches!(edge.evidence_refs.first(), Some(evidence) + if evidence.version == 1 + && evidence.provider == RuntimeEvidenceProvider::Docker + && evidence.kind == kind + && evidence.assertion_kind == RuntimeEvidenceAssertionKind::Observed + && evidence.freshness == RuntimeEvidenceFreshness::Fresh + && evidence.subject_ref == edge.source + && evidence.provider_slot.is_none()) +} + +fn is_candidate_requires(edge: &crate::RuntimeMapEdge) -> bool { + edge.has_valid_evidence_refs() + && edge.relationship == RuntimeRelationshipKind::Requires + && edge.source != edge.target + && edge.evidence_refs.len() == 1 + && matches!( + edge.evidence_refs.first(), + Some(evidence) + if evidence.version == 2 + && evidence.provider == RuntimeEvidenceProvider::Systemd + && evidence.kind == RuntimeEvidenceKind::SystemdRequires + && evidence.assertion_kind == RuntimeEvidenceAssertionKind::Declared + && evidence.freshness == RuntimeEvidenceFreshness::Fresh + && evidence.subject_ref == edge.source + && evidence.provider_slot == Some(crate::ProviderSlot::Systemd) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + ProviderSlot, RuntimeEvidenceRef, RuntimeMapEdge, RuntimeMapNode, RuntimeNodeLayer, + }; + use std::collections::BTreeMap; + + fn node(id: &str, status: &str) -> RuntimeMapNode { + RuntimeMapNode { + id: id.into(), + provider: RuntimeProviderKind::Systemd, + kind: RuntimeNodeKind::SystemdService, + label: "safe label".into(), + status: Some(status.into()), + layer: Some(RuntimeNodeLayer::Service), + metadata: BTreeMap::from([("fragmentPath".into(), "/secret/path".into())]), + service: None, + package: None, + } + } + fn edge(freshness: RuntimeEvidenceFreshness) -> RuntimeMapEdge { + let source = "systemd_service_source".to_string(); + RuntimeMapEdge { + source: source.clone(), + target: "systemd_service_target".into(), + relationship: RuntimeRelationshipKind::Requires, + metadata: BTreeMap::new(), + evidence_refs: vec![RuntimeEvidenceRef { + version: 2, + id: "systemd_evidence_requires_safe".into(), + provider: RuntimeEvidenceProvider::Systemd, + kind: RuntimeEvidenceKind::SystemdRequires, + assertion_kind: RuntimeEvidenceAssertionKind::Declared, + summary: "systemd declared a Requires dependency".into(), + subject_ref: source, + collected_at: 1, + provider_revision: "opaque-safe-revision".into(), + provider_slot: Some(ProviderSlot::Systemd), + freshness, + }], + } + } + fn map(edge: RuntimeMapEdge) -> RuntimeMap { + RuntimeMap { + nodes: vec![ + node("systemd_service_source", "active"), + node("systemd_service_target", "failed"), + ], + edges: vec![edge], + ..Default::default() + } + } + + #[test] + fn emits_a_stable_warning_for_fresh_requires_to_failed_target() { + let findings = derive_findings(&map(edge(RuntimeEvidenceFreshness::Fresh))); + assert_eq!(findings.len(), 1); + assert_eq!( + findings[0].rule_id, + FindingRule::SystemdRequiresTargetNotActive + ); + assert_eq!(findings[0].severity, FindingSeverity::Warning); + assert_eq!(findings[0].recommendation, RECOMMENDATION); + assert_eq!(findings[0].evidence_refs.len(), 1); + assert_eq!( + findings[0].evidence_refs[0].kind, + RuntimeEvidenceKind::SystemdRequires + ); + assert!(findings[0] + .id + .starts_with("finding_systemd_requires_target_not_active_")); + } + + #[test] + fn fails_closed_for_stale_ambiguous_or_non_matching_inputs() { + for freshness in [ + RuntimeEvidenceFreshness::Stale, + RuntimeEvidenceFreshness::TimedOut, + ] { + assert!(derive_findings(&map(edge(freshness))).is_empty()); + } + let mut ambiguous = map(edge(RuntimeEvidenceFreshness::Fresh)); + ambiguous.edges.push(edge(RuntimeEvidenceFreshness::Fresh)); + assert!(derive_findings(&ambiguous).is_empty()); + let mut collision = map(edge(RuntimeEvidenceFreshness::Fresh)); + collision + .nodes + .push(node("systemd_service_target", "failed")); + assert!(derive_findings(&collision).is_empty()); + let mut wants = map(edge(RuntimeEvidenceFreshness::Fresh)); + wants.edges[0].relationship = RuntimeRelationshipKind::Wants; + assert!(derive_findings(&wants).is_empty()); + let mut wrong_kind = map(edge(RuntimeEvidenceFreshness::Fresh)); + wrong_kind.edges[0].evidence_refs[0].kind = RuntimeEvidenceKind::SystemdWants; + assert!(derive_findings(&wrong_kind).is_empty()); + let mut inactive_source = map(edge(RuntimeEvidenceFreshness::Fresh)); + inactive_source.nodes[0].status = Some("inactive".into()); + assert!(derive_findings(&inactive_source).is_empty()); + let mut active_target = map(edge(RuntimeEvidenceFreshness::Fresh)); + active_target.nodes[1].status = Some("active".into()); + assert!(derive_findings(&active_target).is_empty()); + let mut non_systemd = map(edge(RuntimeEvidenceFreshness::Fresh)); + non_systemd.nodes[1].provider = RuntimeProviderKind::Docker; + assert!(derive_findings(&non_systemd).is_empty()); + } + + #[test] + fn finding_carries_only_the_canonical_evidence_ref_without_node_metadata() { + let input = map(edge(RuntimeEvidenceFreshness::Fresh)); + let expected_evidence = input.edges[0].evidence_refs[0].clone(); + let findings = derive_findings(&input); + assert_eq!(findings[0].evidence_refs, vec![expected_evidence]); + let encoded = serde_json::to_string(&findings).unwrap(); + for forbidden in ["/secret/path", "fragmentPath"] { + assert!(!encoded.contains(forbidden), "finding leaked {forbidden}"); + } + } + + #[test] + fn finding_rejects_an_unvalidated_evidence_ref() { + let mut input = map(edge(RuntimeEvidenceFreshness::Fresh)); + input.edges[0].evidence_refs[0].provider_revision.clear(); + assert!(derive_findings(&input).is_empty()); + } + + fn docker_node( + id: &str, + provider: RuntimeProviderKind, + kind: RuntimeNodeKind, + metadata: BTreeMap, + ) -> RuntimeMapNode { + RuntimeMapNode { + id: id.into(), + provider, + kind, + label: "safe Docker entity".into(), + status: None, + layer: None, + metadata, + service: None, + package: None, + } + } + + fn docker_evidence(kind: RuntimeEvidenceKind, source: &str) -> RuntimeEvidenceRef { + RuntimeEvidenceRef { + version: 1, + id: format!("docker_evidence_{source}"), + provider: RuntimeEvidenceProvider::Docker, + kind, + assertion_kind: RuntimeEvidenceAssertionKind::Observed, + summary: "Docker reported a bounded runtime fact".into(), + subject_ref: source.into(), + collected_at: 1, + provider_revision: "opaque-docker-observation".into(), + provider_slot: None, + freshness: RuntimeEvidenceFreshness::Fresh, + } + } + + fn daemon_state_map() -> RuntimeMap { + let container = "docker_container_daemon_state"; + let risk = DOCKER_DAEMON_STATE_RISK_ID; + RuntimeMap { + nodes: vec![ + docker_node( + container, + RuntimeProviderKind::Docker, + RuntimeNodeKind::Container, + BTreeMap::new(), + ), + docker_node( + risk, + RuntimeProviderKind::Docker, + RuntimeNodeKind::HostRisk, + BTreeMap::new(), + ), + ], + edges: vec![RuntimeMapEdge { + source: container.into(), + target: risk.into(), + relationship: RuntimeRelationshipKind::ExposesDaemonState, + metadata: BTreeMap::new(), + evidence_refs: vec![RuntimeEvidenceRef { + summary: DOCKER_DAEMON_STATE_EVIDENCE_SUMMARY.into(), + ..docker_evidence(RuntimeEvidenceKind::DockerDaemonStateBindMount, container) + }], + }], + ..Default::default() + } + } + + #[test] + fn daemon_state_bind_mount_warning_carries_only_canonical_evidence() { + let input = daemon_state_map(); + let findings = derive_findings(&input); + assert_eq!(findings.len(), 1); + let finding = &findings[0]; + assert_eq!(finding.rule_id, FindingRule::DockerDaemonStateBindMount); + assert_eq!(finding.severity, FindingSeverity::Warning); + assert_eq!(finding.summary, DOCKER_DAEMON_STATE_SUMMARY); + assert_eq!(finding.recommendation, DOCKER_DAEMON_STATE_RECOMMENDATION); + assert_eq!(finding.target_ref, DOCKER_DAEMON_STATE_RISK_ID); + assert_eq!(finding.evidence_refs, input.edges[0].evidence_refs); + let encoded = serde_json::to_string(finding).unwrap(); + for forbidden in ["/var/run/docker.sock", "readOnly", "mount-id"] { + assert!(!encoded.contains(forbidden), "finding leaked {forbidden}"); + } + } + + #[test] + fn daemon_state_bind_mount_warning_fails_closed() { + let mut stale = daemon_state_map(); + stale.edges[0].evidence_refs[0].freshness = RuntimeEvidenceFreshness::Stale; + assert!(derive_findings(&stale).is_empty()); + + let mut duplicate = daemon_state_map(); + duplicate.edges.push(duplicate.edges[0].clone()); + assert!(derive_findings(&duplicate).is_empty()); + + let mut wrong_kind = daemon_state_map(); + wrong_kind.edges[0].evidence_refs[0].kind = RuntimeEvidenceKind::DockerVolumeMount; + assert!(derive_findings(&wrong_kind).is_empty()); + + let mut missing = daemon_state_map(); + missing.edges[0].evidence_refs.clear(); + assert!(derive_findings(&missing).is_empty()); + + let mut collision = daemon_state_map(); + collision.nodes.push(collision.nodes[1].clone()); + assert!(derive_findings(&collision).is_empty()); + + let mut raw_metadata = daemon_state_map(); + raw_metadata.edges[0] + .metadata + .insert("mountSource".into(), "/var/run/docker.sock".into()); + assert!(derive_findings(&raw_metadata).is_empty()); + } + + fn internal_network_port_map() -> RuntimeMap { + let container = "docker_container_safe"; + let network = "docker_network_internal"; + let listener = "network_listener_safe"; + RuntimeMap { + nodes: vec![ + docker_node( + container, + RuntimeProviderKind::Docker, + RuntimeNodeKind::Container, + BTreeMap::new(), + ), + docker_node( + network, + RuntimeProviderKind::Docker, + RuntimeNodeKind::DockerNetwork, + BTreeMap::from([("internal".into(), "true".into())]), + ), + docker_node( + listener, + RuntimeProviderKind::Network, + RuntimeNodeKind::NetworkListener, + BTreeMap::from([("port".into(), "8080:80/tcp".into())]), + ), + ], + edges: vec![ + RuntimeMapEdge { + source: container.into(), + target: network.into(), + relationship: RuntimeRelationshipKind::ConnectedTo, + metadata: BTreeMap::new(), + evidence_refs: vec![docker_evidence( + RuntimeEvidenceKind::DockerNetworkMembership, + container, + )], + }, + RuntimeMapEdge { + source: container.into(), + target: listener.into(), + relationship: RuntimeRelationshipKind::Exposes, + metadata: BTreeMap::new(), + evidence_refs: vec![docker_evidence( + RuntimeEvidenceKind::DockerPortPublication, + container, + )], + }, + ], + ..Default::default() + } + } + + #[test] + fn emits_a_deterministic_advisory_with_exact_docker_evidence_pair() { + let input = internal_network_port_map(); + let findings = derive_findings(&input); + assert_eq!(findings.len(), 1); + let finding = &findings[0]; + assert_eq!( + finding.rule_id, + FindingRule::DockerInternalNetworkMemberPublishesPort + ); + assert_eq!(finding.severity, FindingSeverity::Advisory); + assert_eq!(finding.summary, INTERNAL_NETWORK_PORT_SUMMARY); + assert_eq!(finding.recommendation, INTERNAL_NETWORK_PORT_RECOMMENDATION); + assert_eq!(finding.subject_ref, "docker_container_safe"); + assert_eq!(finding.target_ref, "docker_network_internal"); + assert_eq!( + finding.evidence_refs, + vec![ + input.edges[0].evidence_refs[0].clone(), + input.edges[1].evidence_refs[0].clone(), + ] + ); + assert!(finding + .id + .starts_with("finding_docker_internal_network_member_publishes_port_")); + } + + #[test] + fn docker_internal_network_port_rule_fails_closed() { + let mut stale_membership = internal_network_port_map(); + stale_membership.edges[0].evidence_refs[0].freshness = RuntimeEvidenceFreshness::Stale; + assert!(derive_findings(&stale_membership).is_empty()); + + let mut stale_port = internal_network_port_map(); + stale_port.edges[1].evidence_refs[0].freshness = RuntimeEvidenceFreshness::Stale; + assert!(derive_findings(&stale_port).is_empty()); + + let mut duplicate_port = internal_network_port_map(); + duplicate_port.edges.push(duplicate_port.edges[1].clone()); + assert!(derive_findings(&duplicate_port).is_empty()); + + let mut duplicate_membership = internal_network_port_map(); + duplicate_membership + .edges + .push(duplicate_membership.edges[0].clone()); + assert!(derive_findings(&duplicate_membership).is_empty()); + + let mut wrong_kind = internal_network_port_map(); + wrong_kind.edges[1].evidence_refs[0].kind = RuntimeEvidenceKind::DockerNetworkMembership; + assert!(derive_findings(&wrong_kind).is_empty()); + + let mut not_exactly_internal = internal_network_port_map(); + not_exactly_internal.nodes[1] + .metadata + .insert("internal".into(), "True".into()); + assert!(derive_findings(¬_exactly_internal).is_empty()); + + let mut collision = internal_network_port_map(); + collision.nodes.push(collision.nodes[1].clone()); + assert!(derive_findings(&collision).is_empty()); + + let mut wrong_listener = internal_network_port_map(); + wrong_listener.nodes[2].provider = RuntimeProviderKind::Docker; + assert!(derive_findings(&wrong_listener).is_empty()); + + let mut private_only_port = internal_network_port_map(); + private_only_port.nodes[2] + .metadata + .insert("port".into(), "80/tcp".into()); + assert!(derive_findings(&private_only_port).is_empty()); + } + + #[test] + fn host_publication_discriminant_accepts_only_bounded_collector_port_syntax() { + for port in ["8080:80/tcp", "53:53/udp", "443:443/sctp"] { + assert!( + is_host_published_docker_port(port), + "expected host port {port}" + ); + } + for port in [ + "80/tcp", + "0:80/tcp", + "8080:80/icmp", + "127.0.0.1:8080:80/tcp", + "8080:80/tcp:extra", + "not-a-port", + ] { + assert!(!is_host_published_docker_port(port), "rejected port {port}"); + } + } +} diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index 85cdfb66..66dcaaf1 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; #[cfg(test)] use std::path::{Path, PathBuf}; +mod findings; mod fixtures; mod identity; mod logs; @@ -10,6 +11,7 @@ mod models; pub mod schema_baseline; mod snapshot_runtime; +pub use findings::derive_findings; pub use fixtures::{mock_log_entries, mock_logs, mock_snapshot, unix_timestamp_millis}; pub use identity::collision_resistant_id_component; pub use logs::{ @@ -222,6 +224,13 @@ mod tests { let graph = derive_graph(&snapshot); assert_eq!(graph.nodes.len(), snapshot.containers.len()); assert!(graph.edges.is_empty()); + + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); + assert!(runtime_map.edges.iter().all(|edge| { + edge.evidence_refs + .iter() + .all(|evidence| evidence.kind != RuntimeEvidenceKind::DockerComposeDependsOn) + })); } #[test] @@ -242,6 +251,12 @@ mod tests { }; assert!(derive_graph(&snapshot).edges.is_empty()); + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); + assert!(runtime_map.edges.iter().all(|edge| { + edge.evidence_refs + .iter() + .all(|evidence| evidence.kind != RuntimeEvidenceKind::DockerComposeDependsOn) + })); } #[test] @@ -627,7 +642,7 @@ mod tests { #[test] fn derives_runtime_map_from_docker_snapshot() { let snapshot = mock_snapshot(); - let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new()); + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); assert!(runtime_map .nodes @@ -645,6 +660,409 @@ mod tests { .any(|edge| edge.relationship == RuntimeRelationshipKind::ConnectedTo)); } + #[test] + fn daemon_state_bind_mount_evidence_is_path_free_and_unique_per_container() { + let mut snapshot = mock_snapshot(); + snapshot.containers[0].mounts = vec![ + ContainerMount { + id: "private-one".into(), + kind: ComposeMountKind::Bind, + source: Some("/private/DOCKERMAP_TEST_DAEMON_STATE/docker.sock".into()), + target: "/inside/socket".into(), + read_only: true, + }, + ContainerMount { + id: "private-two".into(), + kind: ComposeMountKind::Bind, + source: Some("/var/lib/docker/DOCKERMAP_TEST_DAEMON_STATE".into()), + target: "/inside/data".into(), + read_only: false, + }, + ]; + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); + let risk = runtime_map + .nodes + .iter() + .find(|node| node.id == "host_risk_docker_daemon_state") + .expect("matching bind mount derives the synthetic risk target"); + assert_eq!(risk.kind, RuntimeNodeKind::HostRisk); + assert!(risk.metadata.is_empty()); + let edges = runtime_map + .edges + .iter() + .filter(|edge| edge.target == "host_risk_docker_daemon_state") + .collect::>(); + assert_eq!(edges.len(), 1, "two qualifying mounts retain one safe edge"); + assert_eq!( + edges[0].relationship, + RuntimeRelationshipKind::ExposesDaemonState + ); + assert_eq!(edges[0].evidence_refs.len(), 1); + assert_eq!( + edges[0].evidence_refs[0].kind, + RuntimeEvidenceKind::DockerDaemonStateBindMount + ); + let serialized = serde_json::to_string(&runtime_map).unwrap(); + for forbidden in [ + "DOCKERMAP_TEST_DAEMON_STATE", + "/inside/socket", + "/inside/data", + "readOnly", + ] { + assert!( + !serialized.contains(forbidden), + "runtime evidence leaked {forbidden}" + ); + } + } + + #[test] + fn irrelevant_or_collided_daemon_state_mounts_fail_closed() { + let mut irrelevant = mock_snapshot(); + irrelevant.containers[0].mounts = vec![ContainerMount { + id: "not-bind".into(), + kind: ComposeMountKind::NamedVolume, + source: Some("/var/lib/docker".into()), + target: "/inside".into(), + read_only: false, + }]; + assert!( + derive_runtime_map(&irrelevant, Vec::new(), Vec::new(), Vec::new(), "test") + .edges + .iter() + .all(|edge| edge.target != "host_risk_docker_daemon_state") + ); + + let mut collided = mock_snapshot(); + collided.containers[0].mounts = vec![ContainerMount { + id: "daemon-bind".into(), + kind: ComposeMountKind::Bind, + source: Some("/var/run/docker.sock".into()), + target: "/inside".into(), + read_only: false, + }]; + collided.containers.push(collided.containers[0].clone()); + assert!( + derive_runtime_map(&collided, Vec::new(), Vec::new(), Vec::new(), "test") + .edges + .iter() + .all(|edge| edge.target != "host_risk_docker_daemon_state") + ); + } + + #[test] + fn docker_runtime_edges_carry_bounded_observed_evidence_without_confidence() { + let snapshot = mock_snapshot(); + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); + let network = runtime_map + .edges + .iter() + .find(|edge| { + edge.relationship == RuntimeRelationshipKind::ConnectedTo + && edge.evidence_refs.iter().any(|evidence| { + evidence.kind == RuntimeEvidenceKind::DockerNetworkMembership + }) + }) + .expect("mock snapshot has Docker network membership"); + let port = runtime_map + .edges + .iter() + .find(|edge| { + edge.relationship == RuntimeRelationshipKind::Exposes + && edge + .evidence_refs + .iter() + .any(|evidence| evidence.kind == RuntimeEvidenceKind::DockerPortPublication) + }) + .expect("mock snapshot has Docker port publication"); + let mount = runtime_map + .edges + .iter() + .find(|edge| { + edge.relationship == RuntimeRelationshipKind::Mounts + && edge + .evidence_refs + .iter() + .any(|evidence| evidence.kind == RuntimeEvidenceKind::DockerVolumeMount) + }) + .expect("mock snapshot has Docker volume attachment"); + + for edge in [network, port, mount] { + assert_eq!(edge.evidence_refs.len(), 1); + let evidence = &edge.evidence_refs[0]; + assert_eq!(evidence.version, 1); + assert_eq!(evidence.provider, RuntimeEvidenceProvider::Docker); + assert_eq!( + evidence.assertion_kind, + RuntimeEvidenceAssertionKind::Observed + ); + assert_eq!(evidence.freshness, RuntimeEvidenceFreshness::Fresh); + assert_eq!(evidence.subject_ref, edge.source); + assert_eq!(evidence.collected_at, snapshot.last_updated); + assert_eq!(evidence.provider_revision, "test"); + assert!(!evidence.summary.contains(&snapshot.containers[0].name)); + } + + let serialized = serde_json::to_string(&runtime_map).expect("runtime map serializes"); + assert!(serialized.contains("evidenceRefs")); + assert!(serialized.contains("assertionKind")); + assert!( + !serialized.contains("confidence"), + "observed Docker facts must not imply numerical confidence" + ); + } + + #[test] + fn docker_runtime_compose_dependencies_are_bounded_observed_declarations() { + let snapshot = mock_snapshot(); + let runtime_map = derive_runtime_map( + &snapshot, + Vec::new(), + Vec::new(), + Vec::new(), + "opaque-docker-observation", + ); + let dependencies = runtime_map + .edges + .iter() + .filter(|edge| { + edge.evidence_refs + .iter() + .any(|evidence| evidence.kind == RuntimeEvidenceKind::DockerComposeDependsOn) + }) + .collect::>(); + + assert_eq!(dependencies.len(), 5); + for edge in dependencies { + assert_eq!(edge.relationship, RuntimeRelationshipKind::DependsOn); + assert!(edge.source.starts_with("docker_container_")); + assert!(edge.target.starts_with("docker_container_")); + assert_ne!(edge.source, edge.target); + assert_eq!(edge.evidence_refs.len(), 1); + let evidence = &edge.evidence_refs[0]; + assert_eq!(evidence.version, 1); + assert_eq!(evidence.provider, RuntimeEvidenceProvider::Docker); + assert_eq!( + evidence.assertion_kind, + RuntimeEvidenceAssertionKind::Observed + ); + assert_eq!(evidence.freshness, RuntimeEvidenceFreshness::Fresh); + assert_eq!(evidence.subject_ref, edge.source); + assert_eq!(evidence.collected_at, snapshot.last_updated); + assert_eq!(evidence.provider_revision, "opaque-docker-observation"); + assert_eq!( + evidence.summary, + "Docker recorded Compose dependency declaration" + ); + } + } + + #[test] + fn docker_evidence_provider_revision_attests_observation_not_cache_publication() { + let mut snapshot = mock_snapshot(); + snapshot.last_updated = 42; + // The daemon assigns this after runtime derivation. Supplying a + // plausible publication value here proves it cannot leak backward + // into provider evidence. + snapshot.model_revision = "daemon-publication-999".into(); + + let runtime_map = derive_runtime_map( + &snapshot, + Vec::new(), + Vec::new(), + Vec::new(), + "opaque-docker-observation-17", + ); + let evidence = runtime_map + .edges + .iter() + .flat_map(|edge| &edge.evidence_refs) + .next() + .expect("representative Docker snapshot emits Docker evidence"); + + assert_eq!(evidence.collected_at, 42); + assert_eq!(evidence.provider_revision, "opaque-docker-observation-17"); + assert_ne!(evidence.provider_revision, snapshot.model_revision); + } + + #[test] + fn runtime_evidence_derivation_rejects_an_empty_observation_token() { + let snapshot = mock_snapshot(); + let result = std::panic::catch_unwind(|| { + derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "") + }); + assert!( + result.is_err(), + "an empty providerRevision must never produce a runtime map" + ); + } + + #[test] + fn version_one_evidence_rejects_non_docker_or_non_observed_claims() { + let snapshot = mock_snapshot(); + let evidence = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test") + .edges + .into_iter() + .flat_map(|edge| edge.evidence_refs) + .next() + .expect("representative Docker snapshot emits version-one evidence"); + let valid = serde_json::to_value(evidence).expect("evidence serializes"); + + for (field, invalid) in [ + ("provider", serde_json::json!("systemd")), + ("assertionKind", serde_json::json!("inferred")), + ("freshness", serde_json::json!("stale")), + ("kind", serde_json::json!("systemd_requires")), + ] { + let mut malformed = valid.clone(); + malformed[field] = invalid; + assert!( + serde_json::from_value::(malformed).is_err(), + "v1 must reject fabricated {field} evidence" + ); + } + } + + #[test] + fn version_two_systemd_evidence_requires_its_closed_slot_binding() { + let valid = serde_json::json!({ + "version": 2, + "id": "systemd_evidence_requires_opaque", + "provider": "systemd", + "kind": "systemd_requires", + "assertionKind": "declared", + "summary": "systemd declared a Requires dependency", + "subjectRef": "systemd_service_app", + "collectedAt": 42, + "providerRevision": "opaque-systemd-revision", + "providerSlot": "systemd", + "freshness": "stale" + }); + assert!(serde_json::from_value::(valid.clone()).is_ok()); + for (field, invalid) in [ + ("providerSlot", serde_json::json!("host_scoped")), + ("assertionKind", serde_json::json!("observed")), + ("freshness", serde_json::json!("unavailable")), + ("kind", serde_json::json!("docker_network_membership")), + ] { + let mut malformed = valid.clone(); + malformed[field] = invalid; + assert!(serde_json::from_value::(malformed).is_err()); + } + let edge = serde_json::json!({ + "source": "systemd_service_app", + "target": "systemd_service_database", + "relationship": "requires", + "metadata": {}, + "evidenceRefs": [valid.clone()] + }); + assert!(serde_json::from_value::(edge.clone()).is_ok()); + let mut wrong_relationship = edge; + wrong_relationship["relationship"] = serde_json::json!("wants"); + assert!(serde_json::from_value::(wrong_relationship).is_err()); + let mut missing_binding = valid; + missing_binding + .as_object_mut() + .unwrap() + .remove("providerSlot"); + assert!(serde_json::from_value::(missing_binding).is_err()); + } + + #[test] + fn version_three_npm_manifest_evidence_requires_its_closed_slot_binding() { + let valid = serde_json::json!({ + "version": 3, + "id": "npm_evidence_manifest_dependency_opaque", + "provider": "npm", + "kind": "npm_package_manifest_dependency", + "assertionKind": "declared", + "summary": "package manifest declared a dependency", + "subjectRef": "npm_project_app", + "collectedAt": 42, + "providerRevision": "opaque-npm-revision", + "providerSlot": "project_npm", + "freshness": "timed_out" + }); + assert!(serde_json::from_value::(valid.clone()).is_ok()); + for (field, invalid) in [ + ("providerSlot", serde_json::json!("systemd")), + ("provider", serde_json::json!("systemd")), + ("assertionKind", serde_json::json!("observed")), + ("kind", serde_json::json!("systemd_requires")), + ] { + let mut malformed = valid.clone(); + malformed[field] = invalid; + assert!(serde_json::from_value::(malformed).is_err()); + } + let edge = serde_json::json!({ + "source": "npm_project_app", + "target": "npm_package_dependency", + "relationship": "depends_on", + "metadata": {}, + "evidenceRefs": [valid] + }); + assert!(serde_json::from_value::(edge.clone()).is_ok()); + let mut wrong_target = edge; + wrong_target["target"] = serde_json::json!("systemd_service_database"); + assert!(serde_json::from_value::(wrong_target).is_err()); + } + + #[test] + fn version_four_cron_evidence_requires_its_closed_slot_and_canonical_edge() { + let valid = serde_json::json!({ + "version": 4, + "id": "cron_evidence_schedule_opaque", + "provider": "cron", + "kind": "cron_schedule_declaration", + "assertionKind": "declared", + "summary": "cron declared a scheduled job", + "subjectRef": "scheduled_job_opaque", + "collectedAt": 42, + "providerRevision": "opaque-cron-revision", + "providerSlot": "cron", + "freshness": "stale" + }); + assert!(serde_json::from_value::(valid.clone()).is_ok()); + for (field, invalid) in [ + ("providerSlot", serde_json::json!("host_scoped")), + ("provider", serde_json::json!("systemd")), + ("assertionKind", serde_json::json!("observed")), + ("kind", serde_json::json!("systemd_requires")), + ] { + let mut malformed = valid.clone(); + malformed[field] = invalid; + assert!(serde_json::from_value::(malformed).is_err()); + } + let edge = serde_json::json!({ + "source": "scheduled_job_opaque", "target": "host_local", "relationship": "runs_on", + "metadata": {}, "evidenceRefs": [valid] + }); + assert!(serde_json::from_value::(edge.clone()).is_ok()); + let mut wrong_target = edge; + wrong_target["target"] = serde_json::json!("host_other"); + assert!(serde_json::from_value::(wrong_target).is_err()); + } + + #[test] + fn version_one_evidence_cannot_attest_a_different_runtime_edge() { + let snapshot = mock_snapshot(); + let edge = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test") + .edges + .into_iter() + .find(|edge| { + edge.relationship == RuntimeRelationshipKind::ConnectedTo + && !edge.evidence_refs.is_empty() + }) + .expect("mock snapshot emits a Docker network edge"); + let mut malformed = serde_json::to_value(edge).expect("edge serializes"); + malformed["relationship"] = serde_json::json!("exposes"); + + assert!( + serde_json::from_value::(malformed).is_err(), + "network membership evidence must not attest a port-publication edge" + ); + } + #[test] fn collision_resistant_topology_ids_preserve_distinct_raw_identities() { // Every raw identity below used to collide after lowercasing and @@ -672,7 +1090,7 @@ mod tests { attached_to: Vec::new(), }) .collect(); - let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new()); + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); let volume_ids = runtime_map .nodes .iter() @@ -765,7 +1183,7 @@ mod tests { snapshot.networks.clear(); snapshot.volumes.clear(); - let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new()); + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); let listeners = runtime_map .nodes .iter() @@ -793,6 +1211,89 @@ mod tests { })); } + #[test] + fn private_container_ports_remain_listeners_without_host_publication_evidence() { + let mut snapshot = mock_snapshot(); + snapshot.containers = vec![ContainerRecord { + id: "private-port-container".into(), + name: "private-port".into(), + image: "example:latest".into(), + status: "running".into(), + role: "service".into(), + networks: Vec::new(), + ports: vec!["80/tcp".into()], + mounts: Vec::new(), + depends_on: Vec::new(), + }]; + snapshot.networks.clear(); + snapshot.volumes.clear(); + + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); + let listener = runtime_map + .nodes + .iter() + .find(|node| node.kind == RuntimeNodeKind::NetworkListener) + .expect("private container port remains visible as a listener"); + assert_eq!( + listener.metadata.get("port").map(String::as_str), + Some("80/tcp") + ); + let edge = runtime_map + .edges + .iter() + .find(|edge| edge.target == listener.id) + .expect("private listener remains connected to its container"); + assert!( + edge.evidence_refs.is_empty(), + "private-only listener has no host-publication attestation" + ); + + let serialized = serde_json::to_string(&runtime_map).expect("runtime map serializes"); + assert!(!serialized.contains("Docker reported container port publication")); + assert!(!serialized.contains("docker_port_publication")); + } + + #[test] + fn nonzero_host_bindings_receive_bounded_publication_evidence() { + let mut snapshot = mock_snapshot(); + snapshot.containers = vec![ContainerRecord { + id: "published-port-container".into(), + name: "published-port".into(), + image: "example:latest".into(), + status: "running".into(), + role: "service".into(), + networks: Vec::new(), + ports: vec!["8443:443/tcp".into(), "0:53/udp".into(), "53/udp".into()], + mounts: Vec::new(), + depends_on: Vec::new(), + }]; + snapshot.networks.clear(); + snapshot.volumes.clear(); + + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); + let publication_edges = runtime_map + .edges + .iter() + .filter(|edge| { + edge.evidence_refs + .iter() + .any(|evidence| evidence.kind == RuntimeEvidenceKind::DockerPortPublication) + }) + .collect::>(); + assert_eq!(publication_edges.len(), 1); + assert_eq!( + publication_edges[0].evidence_refs[0].summary, + "Docker reported container port publication" + ); + assert!(publication_edges[0].has_valid_evidence_refs()); + let serialized = + serde_json::to_string(publication_edges[0]).expect("publication edge serializes"); + assert!( + !serialized.contains("8443:443/tcp"), + "evidence itself never copies port data" + ); + } + #[test] fn equivalent_reordered_snapshots_produce_the_same_runtime_topology() { let first = mock_snapshot(); @@ -801,8 +1302,9 @@ mod tests { reordered.networks.reverse(); reordered.volumes.reverse(); - let first_map = derive_runtime_map(&first, Vec::new(), Vec::new(), Vec::new()); - let reordered_map = derive_runtime_map(&reordered, Vec::new(), Vec::new(), Vec::new()); + let first_map = derive_runtime_map(&first, Vec::new(), Vec::new(), Vec::new(), "test"); + let reordered_map = + derive_runtime_map(&reordered, Vec::new(), Vec::new(), Vec::new(), "test"); assert_eq!(reordered_map.nodes, first_map.nodes); assert_eq!(reordered_map.edges, first_map.edges); @@ -825,7 +1327,7 @@ mod tests { }, ]; - let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new()); + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); let duplicated = runtime_map .nodes .iter() @@ -850,7 +1352,7 @@ mod tests { // JSON → Rust) instead of a hand-written fixture, so the contract test // validates output collectors actually produce. let snapshot = mock_snapshot(); - let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new()); + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); let serialized = serde_json::to_string(&runtime_map).expect("map should serialize"); let deserialized: RuntimeMap = diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index 26b49444..401ef79c 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -153,6 +153,12 @@ pub enum RuntimeMode { pub enum ProviderSlot { NetworkInfrastructure, HostScoped, + /// Cron has an independent collector lifecycle. It must not inherit + /// host-node, listener, PM2, or tmux freshness. + Cron, + /// systemd has an independent collector lifecycle. It must not inherit + /// freshness from the broader host-scoped observation slot. + Systemd, PythonProcesses, NativeProcesses, ProjectNpm, @@ -484,6 +490,7 @@ pub enum RuntimeNodeKind { DockerNetwork, DockerVolume, Host, + HostRisk, Service, SystemdService, ScheduledJob, @@ -523,6 +530,7 @@ pub enum RuntimeRelationshipKind { Mounts, Manages, Exposes, + ExposesDaemonState, RunsOn, Uses, Calls, @@ -815,12 +823,426 @@ pub struct RuntimeMapNode { pub package: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +/// Evidence providers are deliberately closed. Version two adds systemd only +/// after it received its own scheduler slot; it cannot inherit a broader host +/// collection's freshness or revision. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeEvidenceProvider { + Docker, + Systemd, + Npm, + Cron, +} + +/// Evidence assertion semantics are deliberately closed. A declaration says +/// what a source configured, never that its target is healthy or was invoked. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeEvidenceAssertionKind { + Observed, + Declared, +} + +/// Safe, provider-specific fact families supported by the first provenance +/// slice. New sources require an explicit enum addition rather than an +/// arbitrary source string or metadata map. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeEvidenceKind { + DockerNetworkMembership, + DockerVolumeMount, + DockerPortPublication, + /// Docker's recorded Compose dependency declaration. This is deliberately + /// not a health, readiness, or traffic-causality claim. + DockerComposeDependsOn, + /// A bind mount that matches the closed Docker socket/data-root predicate. + /// The public evidence and target intentionally omit the mount path. + DockerDaemonStateBindMount, + /// A systemd `Requires=` declaration. It is not a successful-start or + /// health assertion. + SystemdRequires, + /// A systemd `Wants=` declaration. It is not a successful-start or + /// health assertion. + SystemdWants, + /// A systemd `PartOf=` declaration. It is not an ordering assertion. + SystemdPartOf, + /// A package.json dependency declaration. This is not proof that the + /// package was installed, resolved, executed, or is safe. + NpmPackageManifestDependency, + /// A parsed cron declaration. This does not claim the command ran. + CronScheduleDeclaration, +} + +/// A compact, versioned reference to the bounded fact supporting a runtime +/// relationship. It intentionally contains no raw command output, config +/// fragment, path, process arguments, or generic metadata bag. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeEvidenceRef { + /// Version of this closed evidence representation, not a provider API + /// version. It lets future additions remain explicit and reviewable. + #[schemars(range(min = 1, max = 4))] + pub version: u8, + #[schemars(length(min = 1, max = 259))] + pub id: String, + pub provider: RuntimeEvidenceProvider, + pub kind: RuntimeEvidenceKind, + #[serde(rename = "assertionKind")] + pub assertion_kind: RuntimeEvidenceAssertionKind, + /// A bounded, curated explanation; it is never copied from a raw source. + #[schemars(length(min = 1, max = 259))] + pub summary: String, + /// The already-public runtime entity directly attested by this bounded + /// provider fact. + #[serde(rename = "subjectRef")] + pub subject_ref: String, + #[serde(rename = "collectedAt")] + #[schemars(range(max = 9_007_199_254_740_991u64))] + pub collected_at: u64, + /// Opaque provider observation token, not a cache-publication revision, + /// command output, or source dump. + #[serde(rename = "providerRevision")] + #[schemars(length(min = 1, max = 259))] + pub provider_revision: String, + /// Version-two-and-later provider evidence is explicitly tied to the + /// finite scheduler slot that supplied its revision and freshness. + /// Version-one Docker evidence intentionally has no host-provider slot. + #[serde(rename = "providerSlot", skip_serializing_if = "Option::is_none")] + pub provider_slot: Option, + pub freshness: RuntimeEvidenceFreshness, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeEvidenceFreshness { + Fresh, + Stale, + TimedOut, +} + +impl RuntimeEvidenceRef { + fn has_valid_versioned_shape(&self) -> bool { + matches!( + ( + self.version, + self.provider, + self.kind, + self.assertion_kind, + self.freshness, + self.provider_slot, + ), + ( + 1, + RuntimeEvidenceProvider::Docker, + RuntimeEvidenceKind::DockerNetworkMembership + | RuntimeEvidenceKind::DockerVolumeMount + | RuntimeEvidenceKind::DockerPortPublication + | RuntimeEvidenceKind::DockerComposeDependsOn + | RuntimeEvidenceKind::DockerDaemonStateBindMount, + RuntimeEvidenceAssertionKind::Observed, + RuntimeEvidenceFreshness::Fresh, + None, + ) | ( + 2, + RuntimeEvidenceProvider::Systemd, + RuntimeEvidenceKind::SystemdRequires + | RuntimeEvidenceKind::SystemdWants + | RuntimeEvidenceKind::SystemdPartOf, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Systemd), + ) | ( + 3, + RuntimeEvidenceProvider::Npm, + RuntimeEvidenceKind::NpmPackageManifestDependency, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::ProjectNpm), + ) | ( + 4, + RuntimeEvidenceProvider::Cron, + RuntimeEvidenceKind::CronScheduleDeclaration, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Cron), + ) + ) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeEvidenceRefWire { + version: u8, + id: String, + provider: RuntimeEvidenceProvider, + kind: RuntimeEvidenceKind, + #[serde(rename = "assertionKind")] + assertion_kind: RuntimeEvidenceAssertionKind, + summary: String, + #[serde(rename = "subjectRef")] + subject_ref: String, + #[serde(rename = "collectedAt")] + collected_at: u64, + #[serde(rename = "providerRevision")] + provider_revision: String, + #[serde(rename = "providerSlot")] + provider_slot: Option, + freshness: RuntimeEvidenceFreshness, +} + +impl<'de> Deserialize<'de> for RuntimeEvidenceRef { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = RuntimeEvidenceRefWire::deserialize(deserializer)?; + let evidence = Self { + version: wire.version, + id: wire.id, + provider: wire.provider, + kind: wire.kind, + assertion_kind: wire.assertion_kind, + summary: wire.summary, + subject_ref: wire.subject_ref, + collected_at: wire.collected_at, + provider_revision: wire.provider_revision, + provider_slot: wire.provider_slot, + freshness: wire.freshness, + }; + evidence + .has_valid_versioned_shape() + .then_some(evidence) + .ok_or_else(|| { + serde::de::Error::custom("runtime evidence has an invalid versioned shape") + }) + } +} + +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] pub struct RuntimeMapEdge { pub source: String, pub target: String, pub relationship: RuntimeRelationshipKind, pub metadata: BTreeMap, + /// Empty for relationship families that have not yet been migrated to the + /// evidence model. It remains present on the wire so API/UI consumers have + /// one stable, bounded relationship shape while the migration continues. + #[serde(rename = "evidenceRefs")] + #[schemars(required, length(max = 8))] + pub evidence_refs: Vec, +} + +impl RuntimeMapEdge { + /// Evidence is attached only to the semantic edge it directly supports. + /// Keep this at the canonical model boundary so a structurally valid fact + /// cannot be re-used to attest a different relationship. + pub fn has_valid_evidence_refs(&self) -> bool { + self.evidence_refs + .iter() + .all(|evidence| self.evidence_ref_matches_edge(evidence)) + } + + fn evidence_ref_matches_edge(&self, evidence: &RuntimeEvidenceRef) -> bool { + const MAX_EVIDENCE_TEXT_CHARS: usize = 259; + if evidence.id.is_empty() + || evidence.summary.is_empty() + || evidence.provider_revision.is_empty() + || evidence.id.chars().count() > MAX_EVIDENCE_TEXT_CHARS + || evidence.summary.chars().count() > MAX_EVIDENCE_TEXT_CHARS + || evidence.provider_revision.chars().count() > MAX_EVIDENCE_TEXT_CHARS + || evidence.subject_ref != self.source + { + return false; + } + + if !evidence.has_valid_versioned_shape() { + return false; + } + + match ( + evidence.version, + evidence.provider, + evidence.kind, + evidence.assertion_kind, + evidence.freshness, + evidence.provider_slot, + ) { + ( + 1, + RuntimeEvidenceProvider::Docker, + RuntimeEvidenceKind::DockerNetworkMembership, + RuntimeEvidenceAssertionKind::Observed, + RuntimeEvidenceFreshness::Fresh, + None, + ) => { + self.relationship == RuntimeRelationshipKind::ConnectedTo + && self.source.starts_with("docker_container_") + && self.target.starts_with("docker_network_") + } + ( + 1, + RuntimeEvidenceProvider::Docker, + RuntimeEvidenceKind::DockerDaemonStateBindMount, + RuntimeEvidenceAssertionKind::Observed, + RuntimeEvidenceFreshness::Fresh, + None, + ) => { + self.relationship == RuntimeRelationshipKind::ExposesDaemonState + && self.source.starts_with("docker_container_") + && self.target == "host_risk_docker_daemon_state" + } + ( + 1, + RuntimeEvidenceProvider::Docker, + RuntimeEvidenceKind::DockerVolumeMount, + RuntimeEvidenceAssertionKind::Observed, + RuntimeEvidenceFreshness::Fresh, + None, + ) => { + self.relationship == RuntimeRelationshipKind::Mounts + && self.source.starts_with("docker_container_") + && self.target.starts_with("docker_volume_") + } + ( + 1, + RuntimeEvidenceProvider::Docker, + RuntimeEvidenceKind::DockerPortPublication, + RuntimeEvidenceAssertionKind::Observed, + RuntimeEvidenceFreshness::Fresh, + None, + ) => { + self.relationship == RuntimeRelationshipKind::Exposes + && self.source.starts_with("docker_container_") + && self.target.starts_with("network_listener_") + } + ( + 1, + RuntimeEvidenceProvider::Docker, + RuntimeEvidenceKind::DockerComposeDependsOn, + RuntimeEvidenceAssertionKind::Observed, + RuntimeEvidenceFreshness::Fresh, + None, + ) => { + self.relationship == RuntimeRelationshipKind::DependsOn + && self.source.starts_with("docker_container_") + && self.target.starts_with("docker_container_") + && self.source != self.target + } + ( + 2, + RuntimeEvidenceProvider::Systemd, + RuntimeEvidenceKind::SystemdRequires, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Systemd), + ) => { + self.relationship == RuntimeRelationshipKind::Requires + && self.source.starts_with("systemd_service_") + && self.target.starts_with("systemd_service_") + && self.source != self.target + } + ( + 3, + RuntimeEvidenceProvider::Npm, + RuntimeEvidenceKind::NpmPackageManifestDependency, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::ProjectNpm), + ) => { + self.relationship == RuntimeRelationshipKind::DependsOn + && self.source.starts_with("npm_project_") + && self.target.starts_with("npm_package_") + && self.source != self.target + } + ( + 4, + RuntimeEvidenceProvider::Cron, + RuntimeEvidenceKind::CronScheduleDeclaration, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Cron), + ) => { + self.relationship == RuntimeRelationshipKind::RunsOn + && self.source.starts_with("scheduled_job_") + && self.target == "host_local" + && self.source != self.target + } + ( + 2, + RuntimeEvidenceProvider::Systemd, + RuntimeEvidenceKind::SystemdWants, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Systemd), + ) => { + self.relationship == RuntimeRelationshipKind::Wants + && self.source.starts_with("systemd_service_") + && self.target.starts_with("systemd_service_") + && self.source != self.target + } + ( + 2, + RuntimeEvidenceProvider::Systemd, + RuntimeEvidenceKind::SystemdPartOf, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Systemd), + ) => { + self.relationship == RuntimeRelationshipKind::PartOf + && self.source.starts_with("systemd_service_") + && self.target.starts_with("systemd_service_") + && self.source != self.target + } + _ => false, + } + } +} + +#[derive(Deserialize)] +struct RuntimeMapEdgeWire { + source: String, + target: String, + relationship: RuntimeRelationshipKind, + metadata: BTreeMap, + #[serde(rename = "evidenceRefs")] + evidence_refs: Vec, +} + +impl<'de> Deserialize<'de> for RuntimeMapEdge { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = RuntimeMapEdgeWire::deserialize(deserializer)?; + let edge = Self { + source: wire.source, + target: wire.target, + relationship: wire.relationship, + metadata: wire.metadata, + evidence_refs: wire.evidence_refs, + }; + edge.has_valid_evidence_refs() + .then_some(edge) + .ok_or_else(|| serde::de::Error::custom("runtime evidence does not attest this edge")) + } } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -830,6 +1252,65 @@ pub struct RuntimeMapDiagnostic { pub message: String, } +/// Findings are intentionally a small, closed advisory vocabulary. They do +/// not expose provider output or prescribe an automated remediation. +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, +)] +#[serde(rename_all = "snake_case")] +pub enum FindingSeverity { + Warning, + Advisory, +} + +/// Closed rule identifiers keep clients from treating findings as arbitrary +/// provider messages. New rules require an explicit contract addition. +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord, +)] +#[serde(rename_all = "snake_case")] +pub enum FindingRule { + #[serde(rename = "systemd.requires_target_not_active")] + SystemdRequiresTargetNotActive, + #[serde(rename = "docker.internal_network_member_publishes_port")] + DockerInternalNetworkMemberPublishesPort, + #[serde(rename = "docker.daemon_state_bind_mount")] + DockerDaemonStateBindMount, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Finding { + #[schemars(length(min = 1, max = 259))] + pub id: String, + #[serde(rename = "ruleId")] + pub rule_id: FindingRule, + pub severity: FindingSeverity, + #[schemars(length(min = 1, max = 259))] + pub summary: String, + #[schemars(length(min = 1, max = 259))] + pub recommendation: String, + #[serde(rename = "subjectRef")] + pub subject_ref: String, + #[serde(rename = "targetRef")] + pub target_ref: String, + /// Canonical, already-sanitized runtime evidence that directly triggered + /// this finding. Each closed rule has a fixed, small evidence budget, + /// preventing this response from becoming a generic metadata channel. + #[serde(rename = "evidenceRefs")] + #[schemars(required, length(min = 1, max = 2))] + pub evidence_refs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] +#[serde(deny_unknown_fields)] +pub struct FindingsResponse { + pub findings: Vec, + #[serde(rename = "modelRevision")] + #[schemars(length(min = 1))] + pub model_revision: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] pub struct RuntimeMap { pub nodes: Vec, @@ -841,7 +1322,7 @@ pub struct RuntimeMap { #[schemars(length(min = 1))] pub model_revision: String, #[serde(rename = "providerStates")] - #[schemars(length(min = 5, max = 5))] + #[schemars(length(min = 7, max = 7))] pub provider_states: Vec, /// ACTUAL source of these bytes: "docker" or "mock" (#85 A3). Stamped by /// the daemon route layer from the cache's runtime mode. diff --git a/crates/dockermap-core/src/schema_baseline.rs b/crates/dockermap-core/src/schema_baseline.rs index ce857933..be5ea895 100644 --- a/crates/dockermap-core/src/schema_baseline.rs +++ b/crates/dockermap-core/src/schema_baseline.rs @@ -5,16 +5,17 @@ use crate::{ ComposeEditPlan, ComposeGraph, ComposeScan, ContainerDetailResponse, ContainersResponse, - DockerSnapshot, GraphResponse, HealthResponse, ImagesResponse, LogsResponse, NetworksResponse, - RuntimeMap, VolumesResponse, + DockerSnapshot, FindingsResponse, GraphResponse, HealthResponse, ImagesResponse, LogsResponse, + NetworksResponse, RuntimeMap, VolumesResponse, }; use schemars::{schema_for, Schema}; use serde_json::Value; -pub const DAEMON_SCHEMA_NAMES: [&str; 13] = [ +pub const DAEMON_SCHEMA_NAMES: [&str; 14] = [ "DockerSnapshot", "GraphResponse", "RuntimeMap", + "FindingsResponse", "ComposeScan", "ComposeGraph", "ComposeEditPlan", @@ -33,11 +34,12 @@ pub const DAEMON_SCHEMA_NAMES: [&str; 13] = [ /// that standard `JSON.parse` cannot preserve. pub const JSON_SAFE_INTEGER_MAX: u64 = 9_007_199_254_740_991; -pub fn daemon_schemas() -> [Schema; 13] { +pub fn daemon_schemas() -> [Schema; 14] { [ schema_for!(DockerSnapshot), schema_for!(GraphResponse), schema_for!(RuntimeMap), + schema_for!(FindingsResponse), schema_for!(ComposeScan), schema_for!(ComposeGraph), schema_for!(ComposeEditPlan), @@ -55,7 +57,7 @@ pub fn daemon_schemas() -> [Schema; 13] { /// forward-compatible when deserializing, while fixtures reject typoed or /// unreviewed response fields rather than silently redefining the contract. /// This changes schema validation only, never daemon serialization behavior. -pub fn daemon_schema_documents() -> [Value; 13] { +pub fn daemon_schema_documents() -> [Value; 14] { daemon_schemas().map(|schema| { let mut document = serde_json::to_value(schema).expect("schemars schema serializes"); deny_unknown_object_properties(&mut document); @@ -160,11 +162,34 @@ mod tests { .expect("provider state property exists"); assert_eq!( states.get("minItems").and_then(|value| value.as_u64()), - Some(5) + Some(7) ); assert_eq!( states.get("maxItems").and_then(|value| value.as_u64()), - Some(5) + Some(7) + ); + } + + #[test] + fn runtime_evidence_schema_admits_the_closed_version_four_cron_shape() { + let schema = DAEMON_SCHEMA_NAMES + .iter() + .zip(daemon_schema_documents()) + .find_map(|(name, schema)| (*name == "RuntimeMap").then_some(schema)) + .expect("runtime map schema exists"); + let evidence = schema + .pointer("/$defs/RuntimeEvidenceRef") + .expect("runtime evidence definition exists"); + assert_eq!( + evidence + .pointer("/properties/version/maximum") + .and_then(|value| value.as_u64()), + Some(4), + "generated schema must not reject the newest closed evidence version" + ); + assert!( + evidence.pointer("/properties/provider/$ref").is_some(), + "provider stays a closed generated enum" ); } diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 6d71a2d6..8ae43169 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -6,11 +6,13 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::{ - collision_resistant_id_component, service_entity_kind_name, ContainerRecord, - DiagnosticSeverity, DockerSnapshot, GraphEdge, GraphNode, GraphResponse, ImageRecord, NodeKind, - RelationshipKind, RuntimeMap, RuntimeMapDiagnostic, RuntimeMapEdge, RuntimeMapNode, - RuntimeNodeKind, RuntimeNodeLayer, RuntimeProviderKind, RuntimeRelationshipKind, - RuntimeServiceEntity, RuntimeServiceStatus, + collision_resistant_id_component, compose::is_docker_daemon_state_bind_source, + service_entity_kind_name, ContainerRecord, DiagnosticSeverity, DockerSnapshot, GraphEdge, + GraphNode, GraphResponse, ImageRecord, NodeKind, RelationshipKind, + RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, RuntimeEvidenceKind, + RuntimeEvidenceProvider, RuntimeEvidenceRef, RuntimeMap, RuntimeMapDiagnostic, RuntimeMapEdge, + RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, RuntimeProviderKind, + RuntimeRelationshipKind, RuntimeServiceEntity, RuntimeServiceStatus, }; pub fn derive_images(snapshot: &DockerSnapshot) -> Vec { @@ -40,6 +42,39 @@ pub fn derive_images(snapshot: &DockerSnapshot) -> Vec { .collect() } +/// Return whether a bounded Docker collector port string proves that Docker +/// bound a nonzero host port. The collector publishes `private/protocol` for +/// a container-only listener and `host:private/protocol` for a host binding. +/// Keep this discriminant at the derivation boundary so publication evidence +/// never attests the former. +pub(crate) fn is_host_published_docker_port(port: &str) -> bool { + let Some((host, private_and_protocol)) = port.split_once(':') else { + return false; + }; + if host.is_empty() + || !host.bytes().all(|byte| byte.is_ascii_digit()) + || host + .parse::() + .ok() + .filter(|value| *value > 0) + .is_none() + || private_and_protocol.contains(':') + { + return false; + } + let Some((private, protocol)) = private_and_protocol.split_once('/') else { + return false; + }; + !private.is_empty() + && private.bytes().all(|byte| byte.is_ascii_digit()) + && private + .parse::() + .ok() + .filter(|value| *value > 0) + .is_some() + && matches!(protocol, "tcp" | "udp" | "sctp") +} + pub fn derive_graph(snapshot: &DockerSnapshot) -> GraphResponse { let mut nodes = Vec::new(); let mut edges = Vec::new(); @@ -299,6 +334,38 @@ fn duplicate_runtime_node_ids(nodes: &[RuntimeMapNode]) -> BTreeSet { .collect() } +/// Runtime dependency edges may only use a container identity when that +/// generated public identity names exactly one non-empty Docker record. This +/// is intentionally narrower than a best-effort Compose join: duplicate, +/// empty, and self references remain visible in inventory but cannot create a +/// misleading topology relationship. +struct RuntimeContainerIdentityIndex { + counts: BTreeMap, +} + +impl RuntimeContainerIdentityIndex { + fn from_containers(containers: &[ContainerRecord]) -> Self { + let mut counts = BTreeMap::new(); + for container in containers { + if !container.id.is_empty() { + *counts.entry(runtime_container_id(container)).or_default() += 1; + } + } + Self { counts } + } + + fn has_unique_id(&self, container: &ContainerRecord) -> bool { + !container.id.is_empty() && self.counts.get(&runtime_container_id(container)) == Some(&1) + } +} + +fn runtime_container_id(container: &ContainerRecord) -> String { + format!( + "docker_container_{}", + collision_resistant_id_component(&container.id) + ) +} + fn runtime_node_sort_key(node: &RuntimeMapNode) -> String { serde_json::to_string(node).expect("runtime nodes must serialize") } @@ -307,12 +374,87 @@ fn runtime_edge_sort_key(edge: &RuntimeMapEdge) -> String { serde_json::to_string(edge).expect("runtime edges must serialize") } +/// Construct evidence only from already-derived runtime identities and a +/// closed fact family. No raw Docker value is copied into the evidence record: +/// labels and detail stay on their existing, independently redacted entities. +fn docker_runtime_evidence( + snapshot: &DockerSnapshot, + source: &str, + target: &str, + kind: RuntimeEvidenceKind, + provider_revision: &str, +) -> RuntimeEvidenceRef { + let kind_id = match kind { + RuntimeEvidenceKind::DockerNetworkMembership => "network-membership", + RuntimeEvidenceKind::DockerVolumeMount => "volume-mount", + RuntimeEvidenceKind::DockerPortPublication => "port-publication", + RuntimeEvidenceKind::DockerComposeDependsOn => "compose-depends-on", + RuntimeEvidenceKind::DockerDaemonStateBindMount => "daemon-state-bind-mount", + RuntimeEvidenceKind::SystemdRequires + | RuntimeEvidenceKind::SystemdWants + | RuntimeEvidenceKind::SystemdPartOf + | RuntimeEvidenceKind::NpmPackageManifestDependency + | RuntimeEvidenceKind::CronScheduleDeclaration => { + unreachable!("Docker evidence helper only accepts Docker evidence kinds") + } + }; + let summary = match kind { + RuntimeEvidenceKind::DockerNetworkMembership => { + "Docker reported container network membership" + } + RuntimeEvidenceKind::DockerVolumeMount => "Docker reported volume attachment", + RuntimeEvidenceKind::DockerPortPublication => "Docker reported container port publication", + RuntimeEvidenceKind::DockerComposeDependsOn => { + "Docker recorded Compose dependency declaration" + } + RuntimeEvidenceKind::DockerDaemonStateBindMount => { + "Docker reported a bind mount exposing Docker daemon state" + } + RuntimeEvidenceKind::SystemdRequires + | RuntimeEvidenceKind::SystemdWants + | RuntimeEvidenceKind::SystemdPartOf + | RuntimeEvidenceKind::NpmPackageManifestDependency + | RuntimeEvidenceKind::CronScheduleDeclaration => { + unreachable!("Docker evidence helper only accepts Docker evidence kinds") + } + }; + RuntimeEvidenceRef { + version: 1, + id: format!( + "docker_evidence_{}_{}", + kind_id, + collision_resistant_id_component(&format!("{source}\u{1f}{target}")) + ), + provider: RuntimeEvidenceProvider::Docker, + kind, + assertion_kind: RuntimeEvidenceAssertionKind::Observed, + summary: summary.into(), + subject_ref: source.into(), + collected_at: snapshot.last_updated, + provider_revision: provider_revision.into(), + provider_slot: None, + freshness: RuntimeEvidenceFreshness::Fresh, + } +} + +/// Derive runtime topology with the daemon-owned opaque Docker observation +/// token that attests the bounded snapshot used for this map. Callers must +/// supply a nonempty opaque token; a timestamp fallback would falsely claim a +/// provider revision and is intentionally not exposed. pub fn derive_runtime_map( snapshot: &DockerSnapshot, mut nodes: Vec, mut edges: Vec, mut diagnostics: Vec, + evidence_provider_revision: &str, ) -> RuntimeMap { + assert!( + !evidence_provider_revision.is_empty(), + "runtime evidence requires a nonempty opaque Docker observation token" + ); + let container_aliases = ContainerAliases::from_containers(&snapshot.containers); + let runtime_container_ids = + RuntimeContainerIdentityIndex::from_containers(&snapshot.containers); for container in &snapshot.containers { let mut metadata = BTreeMap::new(); metadata.insert("image".into(), container.image.clone()); @@ -326,10 +468,7 @@ pub fn derive_runtime_map( } nodes.push(RuntimeMapNode { - id: format!( - "docker_container_{}", - collision_resistant_id_component(&container.id) - ), + id: runtime_container_id(container), provider: RuntimeProviderKind::Docker, kind: RuntimeNodeKind::Container, label: container.name.clone(), @@ -344,15 +483,21 @@ pub fn derive_runtime_map( }); for network_id in &container.networks { + let source = runtime_container_id(container); + let target = format!( + "docker_network_{}", + collision_resistant_id_component(network_id) + ); edges.push(RuntimeMapEdge { - source: format!( - "docker_container_{}", - collision_resistant_id_component(&container.id) - ), - target: format!( - "docker_network_{}", - collision_resistant_id_component(network_id) - ), + evidence_refs: vec![docker_runtime_evidence( + snapshot, + &source, + &target, + RuntimeEvidenceKind::DockerNetworkMembership, + evidence_provider_revision, + )], + source, + target, relationship: RuntimeRelationshipKind::ConnectedTo, metadata: BTreeMap::new(), }); @@ -381,16 +526,65 @@ pub fn derive_runtime_map( service: None, package: None, }); + let source = runtime_container_id(container); edges.push(RuntimeMapEdge { - source: format!( - "docker_container_{}", - collision_resistant_id_component(&container.id) - ), + // A container-only port remains useful topology, but it is + // not a host publication. Findings rely on this attestation, + // so emit it only when the bounded record proves a nonzero + // host binding. + evidence_refs: is_host_published_docker_port(port) + .then(|| { + docker_runtime_evidence( + snapshot, + &source, + &listener_id, + RuntimeEvidenceKind::DockerPortPublication, + evidence_provider_revision, + ) + }) + .into_iter() + .collect(), + source, target: listener_id, relationship: RuntimeRelationshipKind::Exposes, metadata: BTreeMap::new(), }); } + + for dependency in &container.depends_on { + // A Docker Compose label is a direct declaration, but it is not a + // sufficient basis for a relationship unless both container + // endpoints resolve uniquely. In particular, never select an + // arbitrary duplicate role/name or turn a self/empty reference + // into topology. + let Some(target) = container_aliases.resolve_dependency(dependency) else { + continue; + }; + if !runtime_container_ids.has_unique_id(container) + || !runtime_container_ids.has_unique_id(target) + || std::ptr::eq(container, target) + { + continue; + } + let source = runtime_container_id(container); + let target = runtime_container_id(target); + if source == target { + continue; + } + edges.push(RuntimeMapEdge { + evidence_refs: vec![docker_runtime_evidence( + snapshot, + &source, + &target, + RuntimeEvidenceKind::DockerComposeDependsOn, + evidence_provider_revision, + )], + source, + target, + relationship: RuntimeRelationshipKind::DependsOn, + metadata: BTreeMap::new(), + }); + } } for network in &snapshot.networks { @@ -440,15 +634,21 @@ pub fn derive_runtime_map( .iter() .find(|container| container.name == *attached) { + let source = runtime_container_id(container); + let target = format!( + "docker_volume_{}", + collision_resistant_id_component(&volume.id) + ); edges.push(RuntimeMapEdge { - source: format!( - "docker_container_{}", - collision_resistant_id_component(&container.id) - ), - target: format!( - "docker_volume_{}", - collision_resistant_id_component(&volume.id) - ), + evidence_refs: vec![docker_runtime_evidence( + snapshot, + &source, + &target, + RuntimeEvidenceKind::DockerVolumeMount, + evidence_provider_revision, + )], + source, + target, relationship: RuntimeRelationshipKind::Mounts, metadata: BTreeMap::new(), }); @@ -456,6 +656,58 @@ pub fn derive_runtime_map( } } + const DOCKER_DAEMON_STATE_RISK_ID: &str = "host_risk_docker_daemon_state"; + let daemon_state_sources = snapshot + .containers + .iter() + .filter(|container| { + runtime_container_ids.has_unique_id(container) + && container.mounts.iter().any(|mount| { + mount.kind == crate::ComposeMountKind::Bind + && mount + .source + .as_deref() + .is_some_and(is_docker_daemon_state_bind_source) + }) + }) + .map(runtime_container_id) + .collect::>(); + if !daemon_state_sources.is_empty() + && !nodes + .iter() + .any(|node| node.id == DOCKER_DAEMON_STATE_RISK_ID) + { + nodes.push(RuntimeMapNode { + id: DOCKER_DAEMON_STATE_RISK_ID.into(), + provider: RuntimeProviderKind::Docker, + kind: RuntimeNodeKind::HostRisk, + label: "Docker daemon state exposure".into(), + status: None, + layer: Some(RuntimeNodeLayer::Host), + metadata: BTreeMap::new(), + service: None, + package: None, + }); + for source in daemon_state_sources { + if nodes.iter().filter(|node| node.id == source).count() != 1 { + continue; + } + edges.push(RuntimeMapEdge { + evidence_refs: vec![docker_runtime_evidence( + snapshot, + &source, + DOCKER_DAEMON_STATE_RISK_ID, + RuntimeEvidenceKind::DockerDaemonStateBindMount, + evidence_provider_revision, + )], + source, + target: DOCKER_DAEMON_STATE_RISK_ID.into(), + relationship: RuntimeRelationshipKind::ExposesDaemonState, + metadata: BTreeMap::new(), + }); + } + } + let duplicate_node_ids = duplicate_runtime_node_ids(&nodes); nodes.sort_by_key(runtime_node_sort_key); for _ in duplicate_node_ids { diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index fbf22bae..f7c52038 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -8,6 +8,12 @@ use crate::{ docker_collector::DockerCollector, provider_contract::ProviderCollection, + providers::cron::CRON_EVIDENCE_SCHEDULE_MARKER, + providers::npm::NPM_EVIDENCE_DEPENDENCY_MARKER, + providers::systemd::{ + SYSTEMD_EVIDENCE_KIND_MARKER, SYSTEMD_EVIDENCE_PART_OF, SYSTEMD_EVIDENCE_REQUIRES, + SYSTEMD_EVIDENCE_WANTS, + }, publication::{publish_docker_snapshot, redact_health_response, redact_runtime_map}, runtime_collection::{ collect_provider_slot_bounded, runtime_map_from_collection, slot_interval, @@ -15,9 +21,12 @@ use crate::{ }, }; use dockermap_core::{ - derive_images, mock_snapshot, DiagnosticSeverity, DockerSnapshot, HealthResponse, HealthState, - ProviderSlot, ProviderState, ProviderStateKind, ProviderStatusReason, RuntimeMap, - RuntimeMapDiagnostic, RuntimeMode, RuntimeProviderKind, + collision_resistant_id_component, derive_findings, derive_images, mock_snapshot, + DiagnosticSeverity, DockerSnapshot, FindingsResponse, HealthResponse, HealthState, + ProviderSlot, ProviderState, ProviderStateKind, ProviderStatusReason, + RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, RuntimeEvidenceKind, + RuntimeEvidenceProvider, RuntimeEvidenceRef, RuntimeMap, RuntimeMapDiagnostic, RuntimeMapEdge, + RuntimeMode, RuntimeProviderKind, }; use std::{ collections::BTreeMap, @@ -58,11 +67,16 @@ pub(crate) struct DaemonCache { pub(crate) snapshot: DockerSnapshot, pub(crate) health: HealthResponse, pub(crate) runtime_map: RuntimeMap, + pub(crate) findings: FindingsResponse, runtime_providers: RuntimeProviderSlots, /// Increments on every Docker/mock source transition. A late worker must /// match this generation as well as evidence, so Docker→mock→Docker can /// never accept a completion from the earlier live generation. source_generation: u64, + /// Opaque source-observation token attached to Docker-native evidence. + /// This is intentionally distinct from the broader publication revision: + /// provider slot state may change without changing Docker facts. + docker_observation_revision: DockerObservationRevision, revision: PublicationRevision, } @@ -129,6 +143,54 @@ impl PublicationRevision { } } +/// Per-process opaque identity for the current sanitized Docker observation. +/// It advances only when bounded Docker semantics (or source mode) change, +/// never for the two-second observation timestamp tick. +#[derive(Clone)] +struct DockerObservationRevision { + boot: String, + sequence: u64, + last_observable: Option, +} + +impl DockerObservationRevision { + fn new() -> Self { + Self { + boot: opaque_revision_boot_component(), + sequence: 0, + last_observable: None, + } + } + + fn current(&self) -> String { + format!("{}-{}", self.boot, self.sequence) + } + + fn assign(&mut self, snapshot: &DockerSnapshot, mode: &RuntimeMode) { + let mut published = publish_docker_snapshot(snapshot); + // Observation time and the publication revision do not describe a + // Docker fact. Clearing them prevents a healthy refresh ticker from + // fabricating a new evidence revision every two seconds. + published.last_updated = 0; + published.model_revision.clear(); + let observable = serde_json::to_string(&(mode, published)) + .expect("public Docker observation is serializable"); + if self.last_observable.as_deref() != Some(observable.as_str()) { + self.sequence = self + .sequence + .checked_add(1) + .expect("Docker observation revision sequence overflow"); + self.last_observable = Some(observable); + } + } +} + +fn opaque_revision_boot_component() -> String { + let mut bytes = [0_u8; 16]; + getrandom::fill(&mut bytes).expect("OS CSPRNG for opaque revision boot component"); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + /// Remove only fields which record when Docker was observed from the cloned, /// already-public model used to decide whether a semantic publication changed. /// The cache and HTTP responses retain the original values. In particular, @@ -146,6 +208,15 @@ fn clear_volatile_observation_markers( health.last_updated = 0; health.snapshot_version.clear(); runtime_map.last_updated = 0; + // Docker evidence retains its real collection time for clients, but it is + // not semantic topology. The stable opaque provider token remains in the + // comparison so a genuine sanitized Docker observation still advances the + // model revision exactly once. + for edge in &mut runtime_map.edges { + for evidence in &mut edge.evidence_refs { + evidence.collected_at = 0; + } + } } fn boot_instance_component() -> String { @@ -239,6 +310,8 @@ impl SlotDataRevision { pub(crate) struct ProviderSlotFlights { network: Arc, host: Arc, + cron: Arc, + systemd: Arc, python: Arc, native: Arc, npm: Arc, @@ -249,6 +322,8 @@ impl Default for ProviderSlotFlights { Self { network: Arc::new(AtomicBool::new(false)), host: Arc::new(AtomicBool::new(false)), + cron: Arc::new(AtomicBool::new(false)), + systemd: Arc::new(AtomicBool::new(false)), python: Arc::new(AtomicBool::new(false)), native: Arc::new(AtomicBool::new(false)), npm: Arc::new(AtomicBool::new(false)), @@ -264,6 +339,8 @@ impl ProviderSlotFlights { match slot { ProviderSlot::NetworkInfrastructure => self.network.clone(), ProviderSlot::HostScoped => self.host.clone(), + ProviderSlot::Cron => self.cron.clone(), + ProviderSlot::Systemd => self.systemd.clone(), ProviderSlot::PythonProcesses => self.python.clone(), ProviderSlot::NativeProcesses => self.native.clone(), ProviderSlot::ProjectNpm => self.npm.clone(), @@ -274,6 +351,8 @@ impl ProviderSlotFlights { [ &self.network, &self.host, + &self.cron, + &self.systemd, &self.python, &self.native, &self.npm, @@ -312,10 +391,13 @@ impl DaemonCache { provider_states: unavailable_provider_states(), ..Default::default() }, + findings: FindingsResponse::default(), runtime_providers: unavailable_provider_slots(), source_generation: 0, + docker_observation_revision: DockerObservationRevision::new(), revision: PublicationRevision::new(), }; + cache.assign_docker_observation_revision(); cache.assign_revision(); cache } @@ -325,6 +407,32 @@ impl DaemonCache { // publication. Provider state is runtime-topology evidence only. self.revision .assign(&mut self.snapshot, &mut self.health, &mut self.runtime_map); + // Findings are a pure projection of the sanitized runtime map, so + // calculate and cache them only after the publication revision exists. + self.findings = FindingsResponse { + findings: derive_findings(&self.runtime_map), + model_revision: self.runtime_map.model_revision.clone(), + }; + } + + fn assign_docker_observation_revision(&mut self) { + self.docker_observation_revision + .assign(&self.snapshot, &self.health.mode); + } + + fn docker_observation_token(&self) -> String { + self.docker_observation_revision.current() + } + + fn rebuild_runtime_map(&mut self) { + self.assign_docker_observation_revision(); + let docker_observation_token = self.docker_observation_token(); + self.runtime_map = runtime_map_for_snapshot( + &self.snapshot, + &self.health.mode, + &self.runtime_providers, + &docker_observation_token, + ); } } @@ -407,7 +515,8 @@ async fn publish_docker_snapshot_cache( if same_source && !same_collection_evidence(&cache.snapshot, &updated.snapshot) { mark_network_observation_stale(&mut updated.runtime_providers); } - updated.runtime_map = runtime_map_for_snapshot(&updated.snapshot, &updated.runtime_providers); + updated.docker_observation_revision = cache.docker_observation_revision.clone(); + updated.rebuild_runtime_map(); updated.revision = cache.revision.clone(); updated.assign_revision(); *cache = updated; @@ -465,8 +574,10 @@ async fn collect_snapshot(state: &AppState) -> DaemonCache { snapshot, health, runtime_map: empty_runtime_map(0), + findings: FindingsResponse::default(), runtime_providers: unavailable_provider_slots(), source_generation: 0, + docker_observation_revision: DockerObservationRevision::new(), revision: PublicationRevision::new(), } } @@ -599,7 +710,7 @@ async fn claim_due_provider_slots(state: &AppState, now: Duration) -> Vec bo left == right } -fn runtime_map_for_snapshot(snapshot: &DockerSnapshot, slots: &RuntimeProviderSlots) -> RuntimeMap { +fn runtime_map_for_snapshot( + snapshot: &DockerSnapshot, + mode: &RuntimeMode, + slots: &RuntimeProviderSlots, + docker_observation_revision: &str, +) -> RuntimeMap { let mut combined = ProviderCollection::default(); let mut extra_diagnostics = Vec::new(); for slot in STATIC_PROVIDER_SLOTS.iter().copied() { - let state = &slots[&slot].observation; - if let Some(collection) = retained_collection(state) { - let (nodes, edges, diagnostics) = collection.into_parts(); + let slot_state = &slots[&slot]; + let observation = &slot_state.observation; + if let Some(collection) = retained_collection(observation) { + let (nodes, mut edges, diagnostics) = collection.into_parts(); + if slot == ProviderSlot::Systemd { + bind_systemd_evidence(&mut edges, slot_state); + } else if slot == ProviderSlot::ProjectNpm { + bind_npm_evidence(&mut edges, slot_state); + } else if slot == ProviderSlot::Cron { + bind_cron_evidence(&mut edges, slot_state); + } let (target_nodes, target_edges, target_diagnostics) = combined.parts_mut(); target_nodes.extend(nodes); target_edges.extend(edges); target_diagnostics.extend(diagnostics); } - if !matches!(state, RuntimeProviderState::Fresh(_)) { + if !matches!(observation, RuntimeProviderState::Fresh(_)) { extra_diagnostics.push(RuntimeMapDiagnostic { provider: RuntimeProviderKind::Other, severity: DiagnosticSeverity::Warning, - message: slot_diagnostic(slot, state).into(), + message: slot_diagnostic(slot, observation).into(), }); } } - let mut runtime_map = runtime_map_from_collection(snapshot, &combined); + // Cron's declaration target is canonical only when the independently + // retained HostScoped observation supplied `host_local`. Startup and host + // refresh ordering can otherwise leave a dangling relationship; omit it + // rather than publishing an unverifiable target or borrowing host state. + let has_canonical_host = combined.nodes().iter().any(|node| { + node.id == "host_local" + && node.provider == RuntimeProviderKind::Host + && node.kind == dockermap_core::RuntimeNodeKind::Host + }); + if !has_canonical_host { + combined.parts_mut().1.retain(|edge| { + !(edge.source.starts_with("scheduled_job_") + && edge.target == "host_local" + && edge.relationship == dockermap_core::RuntimeRelationshipKind::RunsOn) + }); + } + let mut runtime_map = + runtime_map_from_collection(snapshot, &combined, docker_observation_revision, mode); runtime_map.provider_states = provider_states_for(slots); runtime_map.diagnostics.extend(extra_diagnostics); runtime_map } +/// Bind a parsed cron declaration only to the independently scheduled Cron +/// slot. The private marker is removed in every path. A cron relationship is +/// fail-closed until the canonical retained host node exists. +fn bind_cron_evidence(edges: &mut [RuntimeMapEdge], state: &SlotRuntimeState) { + let disabled = retained_collection(&state.observation) + .as_ref() + .is_some_and(|collection| { + collection.states().iter().any(|candidate| { + candidate.slot == ProviderSlot::Cron + && candidate.state == ProviderStateKind::Disabled + }) + }); + let freshness = match &state.observation { + RuntimeProviderState::Fresh(_) => RuntimeEvidenceFreshness::Fresh, + RuntimeProviderState::Collecting(Some(_)) | RuntimeProviderState::Degraded(Some(_)) => { + RuntimeEvidenceFreshness::Stale + } + RuntimeProviderState::TimedOut(Some(_)) => RuntimeEvidenceFreshness::TimedOut, + RuntimeProviderState::Unavailable + | RuntimeProviderState::Collecting(None) + | RuntimeProviderState::Degraded(None) + | RuntimeProviderState::TimedOut(None) => { + clear_cron_evidence(edges); + return; + } + }; + let (Some(revision), Some(collected_at)) = ( + state + .freshness + .data_revision + .as_ref() + .map(SlotDataRevision::public), + state.freshness.last_success_ms, + ) else { + clear_cron_evidence(edges); + return; + }; + if disabled { + clear_cron_evidence(edges); + return; + } + for edge in edges.iter_mut() { + let marker = edge.metadata.remove(CRON_EVIDENCE_SCHEDULE_MARKER); + if marker.as_deref() != Some("declared") + || edge.relationship != dockermap_core::RuntimeRelationshipKind::RunsOn + || !edge.source.starts_with("scheduled_job_") + || edge.target != "host_local" + || edge.source == edge.target + { + edge.evidence_refs.clear(); + continue; + } + edge.evidence_refs = vec![RuntimeEvidenceRef { + version: 4, + id: format!( + "cron_evidence_schedule_{}", + collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target)) + ), + provider: RuntimeEvidenceProvider::Cron, + kind: RuntimeEvidenceKind::CronScheduleDeclaration, + assertion_kind: RuntimeEvidenceAssertionKind::Declared, + summary: "cron declared a scheduled job".into(), + subject_ref: edge.source.clone(), + collected_at, + provider_revision: revision.clone(), + provider_slot: Some(ProviderSlot::Cron), + freshness, + }]; + } +} + +fn clear_cron_evidence(edges: &mut [RuntimeMapEdge]) { + for edge in edges.iter_mut() { + edge.metadata.remove(CRON_EVIDENCE_SCHEDULE_MARKER); + edge.evidence_refs.clear(); + } +} + +/// Convert the private NPM manifest marker into public evidence only after +/// this exact ProjectNpm slot has a sanitized opaque revision and successful +/// collection timestamp. Retention is explicit: stale/timed-out observations +/// remain labelled as such, while disabled, unavailable, revision-less, and +/// source-reset observations publish no NPM evidence. +fn bind_npm_evidence(edges: &mut [RuntimeMapEdge], state: &SlotRuntimeState) { + let disabled = retained_collection(&state.observation) + .as_ref() + .is_some_and(|collection| { + collection.states().iter().any(|candidate| { + candidate.slot == ProviderSlot::ProjectNpm + && candidate.state == ProviderStateKind::Disabled + }) + }); + let freshness = match &state.observation { + RuntimeProviderState::Fresh(_) => RuntimeEvidenceFreshness::Fresh, + RuntimeProviderState::Collecting(Some(_)) | RuntimeProviderState::Degraded(Some(_)) => { + RuntimeEvidenceFreshness::Stale + } + RuntimeProviderState::TimedOut(Some(_)) => RuntimeEvidenceFreshness::TimedOut, + RuntimeProviderState::Unavailable + | RuntimeProviderState::Collecting(None) + | RuntimeProviderState::Degraded(None) + | RuntimeProviderState::TimedOut(None) => { + clear_npm_evidence(edges); + return; + } + }; + if disabled { + clear_npm_evidence(edges); + return; + } + let Some(revision) = state + .freshness + .data_revision + .as_ref() + .map(SlotDataRevision::public) + else { + clear_npm_evidence(edges); + return; + }; + let Some(collected_at) = state.freshness.last_success_ms else { + clear_npm_evidence(edges); + return; + }; + + for edge in edges { + let marker = edge.metadata.remove(NPM_EVIDENCE_DEPENDENCY_MARKER); + if marker.as_deref() != Some("declared") + || edge.relationship != dockermap_core::RuntimeRelationshipKind::DependsOn + || !edge.source.starts_with("npm_project_") + || !edge.target.starts_with("npm_package_") + || edge.source == edge.target + { + edge.evidence_refs.clear(); + continue; + } + edge.evidence_refs = vec![RuntimeEvidenceRef { + version: 3, + id: format!( + "npm_evidence_manifest_dependency_{}", + collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target)) + ), + provider: RuntimeEvidenceProvider::Npm, + kind: RuntimeEvidenceKind::NpmPackageManifestDependency, + assertion_kind: RuntimeEvidenceAssertionKind::Declared, + summary: "package manifest declared a dependency".into(), + subject_ref: edge.source.clone(), + collected_at, + provider_revision: revision.clone(), + provider_slot: Some(ProviderSlot::ProjectNpm), + freshness, + }]; + } +} + +fn clear_npm_evidence(edges: &mut [RuntimeMapEdge]) { + for edge in edges { + edge.metadata.remove(NPM_EVIDENCE_DEPENDENCY_MARKER); + edge.evidence_refs.clear(); + } +} + +/// Convert the private, closed systemd dependency marker into public evidence +/// only after this exact slot completed and owns a sanitized opaque revision. +/// Retained observations deliberately become stale/timed-out evidence instead +/// of being relabelled as fresh; a disabled or revision-less observation emits +/// no evidence at all. +fn bind_systemd_evidence(edges: &mut [RuntimeMapEdge], state: &SlotRuntimeState) { + let is_disabled = retained_collection(&state.observation) + .as_ref() + .map(|collection| { + collection.states().iter().any(|candidate| { + candidate.slot == ProviderSlot::Systemd + && candidate.state == ProviderStateKind::Disabled + }) + }) + .unwrap_or(false); + if is_disabled { + for edge in edges { + edge.metadata.remove(SYSTEMD_EVIDENCE_KIND_MARKER); + edge.evidence_refs.clear(); + } + return; + } + let freshness = match &state.observation { + RuntimeProviderState::Fresh(_) => RuntimeEvidenceFreshness::Fresh, + RuntimeProviderState::Collecting(Some(_)) | RuntimeProviderState::Degraded(Some(_)) => { + RuntimeEvidenceFreshness::Stale + } + RuntimeProviderState::TimedOut(Some(_)) => RuntimeEvidenceFreshness::TimedOut, + RuntimeProviderState::Unavailable + | RuntimeProviderState::Collecting(None) + | RuntimeProviderState::Degraded(None) + | RuntimeProviderState::TimedOut(None) => { + for edge in edges { + edge.metadata.remove(SYSTEMD_EVIDENCE_KIND_MARKER); + edge.evidence_refs.clear(); + } + return; + } + }; + let Some(revision) = state + .freshness + .data_revision + .as_ref() + .map(SlotDataRevision::public) + else { + for edge in edges { + edge.metadata.remove(SYSTEMD_EVIDENCE_KIND_MARKER); + edge.evidence_refs.clear(); + } + return; + }; + let Some(collected_at) = state.freshness.last_success_ms else { + for edge in edges { + edge.metadata.remove(SYSTEMD_EVIDENCE_KIND_MARKER); + edge.evidence_refs.clear(); + } + return; + }; + + for edge in edges { + let kind = match edge + .metadata + .remove(SYSTEMD_EVIDENCE_KIND_MARKER) + .as_deref() + { + Some(SYSTEMD_EVIDENCE_REQUIRES) => RuntimeEvidenceKind::SystemdRequires, + Some(SYSTEMD_EVIDENCE_WANTS) => RuntimeEvidenceKind::SystemdWants, + Some(SYSTEMD_EVIDENCE_PART_OF) => RuntimeEvidenceKind::SystemdPartOf, + _ => { + edge.evidence_refs.clear(); + continue; + } + }; + let expected_relationship = match kind { + RuntimeEvidenceKind::SystemdRequires => { + dockermap_core::RuntimeRelationshipKind::Requires + } + RuntimeEvidenceKind::SystemdWants => dockermap_core::RuntimeRelationshipKind::Wants, + RuntimeEvidenceKind::SystemdPartOf => dockermap_core::RuntimeRelationshipKind::PartOf, + _ => unreachable!("closed systemd marker maps only to systemd evidence"), + }; + if edge.relationship != expected_relationship + || !edge.source.starts_with("systemd_service_") + || !edge.target.starts_with("systemd_service_") + || edge.source == edge.target + { + edge.evidence_refs.clear(); + continue; + } + let kind_id = match kind { + RuntimeEvidenceKind::SystemdRequires => "requires", + RuntimeEvidenceKind::SystemdWants => "wants", + RuntimeEvidenceKind::SystemdPartOf => "part-of", + _ => unreachable!("closed systemd marker maps only to systemd evidence"), + }; + edge.evidence_refs = vec![RuntimeEvidenceRef { + version: 2, + id: format!( + "systemd_evidence_{kind_id}_{}", + collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target)) + ), + provider: RuntimeEvidenceProvider::Systemd, + kind, + assertion_kind: RuntimeEvidenceAssertionKind::Declared, + summary: match kind { + RuntimeEvidenceKind::SystemdRequires => "systemd declared a Requires dependency", + RuntimeEvidenceKind::SystemdWants => "systemd declared a Wants dependency", + RuntimeEvidenceKind::SystemdPartOf => "systemd declared a PartOf dependency", + _ => unreachable!("closed systemd marker maps only to systemd evidence"), + } + .into(), + subject_ref: edge.source.clone(), + collected_at, + provider_revision: revision.clone(), + provider_slot: Some(ProviderSlot::Systemd), + freshness, + }]; + } +} + fn slot_diagnostic(_slot: ProviderSlot, state: &RuntimeProviderState) -> &'static str { match state { RuntimeProviderState::Collecting(Some(_)) => "Runtime provider slot refresh is in progress; serving retained observations (stale)", @@ -896,11 +1318,18 @@ fn empty_runtime_map(last_updated: u64) -> RuntimeMap { mod scheduler_tests { use super::*; use crate::provider_contract::ProviderDiagnostic; - use dockermap_core::{mock_snapshot, HealthState, RuntimeProviderKind}; + use dockermap_core::{ + mock_snapshot, ComposeMountKind, ContainerMount, HealthState, RuntimeMapNode, + RuntimeNodeKind, RuntimeNodeLayer, RuntimeProviderKind, + }; use std::{ collections::BTreeMap as TestBTreeMap, fs, os::unix::fs::PermissionsExt, process::Command, }; + // Before Systemd was extracted into its own independently scheduled slot, + // every two-second pass ran these five aggregate collection bundles. + const LEGACY_AGGREGATE_SLOT_COUNT: u64 = 5; + const SCHEDULER_CHURN_CHILD_ENV: &str = "DOCKERMAP_SCHEDULER_CHURN_CHILD"; const SCHEDULER_CHURN_ATTESTATION_PATH_ENV: &str = "DOCKERMAP_SCHEDULER_CHURN_ATTESTATION_PATH"; const SCHEDULER_CHURN_ATTESTATION_TOKEN_ENV: &str = @@ -956,9 +1385,527 @@ mod scheduler_tests { unavailable_provider_slots() } + fn marked_systemd_dependency() -> ProviderCollection { + let mut collection = ProviderCollection::default(); + collection.set_state(ProviderSlot::Systemd, ProviderStateKind::Fresh); + for (id, label) in [ + ("systemd_service_application", "application"), + ("systemd_service_database", "database"), + ] { + collection.nodes_mut().push(RuntimeMapNode { + id: id.into(), + provider: RuntimeProviderKind::Systemd, + kind: RuntimeNodeKind::SystemdService, + label: label.into(), + status: Some( + if id == "systemd_service_application" { + "active" + } else { + "failed" + } + .into(), + ), + layer: Some(RuntimeNodeLayer::Service), + metadata: BTreeMap::new(), + service: None, + package: None, + }); + } + collection.parts_mut().1.push(RuntimeMapEdge { + source: "systemd_service_application".into(), + target: "systemd_service_database".into(), + relationship: dockermap_core::RuntimeRelationshipKind::Requires, + metadata: BTreeMap::from([( + SYSTEMD_EVIDENCE_KIND_MARKER.into(), + SYSTEMD_EVIDENCE_REQUIRES.into(), + )]), + evidence_refs: Vec::new(), + }); + collection + } + + fn marked_npm_dependency() -> ProviderCollection { + let mut collection = ProviderCollection::default(); + collection.set_state(ProviderSlot::ProjectNpm, ProviderStateKind::Fresh); + for (id, label, kind) in [ + ( + "npm_project_application", + "application", + RuntimeNodeKind::Package, + ), + ( + "npm_package_dependency", + "dependency", + RuntimeNodeKind::PackageDependency, + ), + ] { + collection.nodes_mut().push(RuntimeMapNode { + id: id.into(), + provider: RuntimeProviderKind::Npm, + kind, + label: label.into(), + status: None, + layer: Some(RuntimeNodeLayer::Package), + metadata: BTreeMap::new(), + service: None, + package: None, + }); + } + collection.parts_mut().1.push(RuntimeMapEdge { + source: "npm_project_application".into(), + target: "npm_package_dependency".into(), + relationship: dockermap_core::RuntimeRelationshipKind::DependsOn, + metadata: BTreeMap::from([(NPM_EVIDENCE_DEPENDENCY_MARKER.into(), "declared".into())]), + evidence_refs: Vec::new(), + }); + collection + } + + fn marked_cron_declaration() -> ProviderCollection { + let mut collection = ProviderCollection::default(); + collection.set_state(ProviderSlot::Cron, ProviderStateKind::Fresh); + collection.nodes_mut().push(RuntimeMapNode { + id: "scheduled_job_declared".into(), + provider: RuntimeProviderKind::ScheduledJob, + kind: RuntimeNodeKind::ScheduledJob, + label: "scheduled job".into(), + status: Some("scheduled".into()), + layer: Some(RuntimeNodeLayer::Process), + metadata: BTreeMap::new(), + service: None, + package: None, + }); + collection.parts_mut().1.push(RuntimeMapEdge { + source: "scheduled_job_declared".into(), + target: "host_local".into(), + relationship: dockermap_core::RuntimeRelationshipKind::RunsOn, + metadata: BTreeMap::from([(CRON_EVIDENCE_SCHEDULE_MARKER.into(), "declared".into())]), + evidence_refs: Vec::new(), + }); + collection + } + + fn host_collection() -> ProviderCollection { + let mut collection = ProviderCollection::default(); + collection.set_state(ProviderSlot::HostScoped, ProviderStateKind::Fresh); + collection.nodes_mut().push(RuntimeMapNode { + id: "host_local".into(), + provider: RuntimeProviderKind::Host, + kind: RuntimeNodeKind::Host, + label: "host".into(), + status: Some("online".into()), + layer: Some(RuntimeNodeLayer::Host), + metadata: BTreeMap::new(), + service: None, + package: None, + }); + collection + } + + #[test] + fn cron_declaration_evidence_is_slot_bound_and_target_gated() { + for (observation, expected) in [ + ( + RuntimeProviderState::Fresh(marked_cron_declaration()), + RuntimeEvidenceFreshness::Fresh, + ), + ( + RuntimeProviderState::Degraded(Some(marked_cron_declaration())), + RuntimeEvidenceFreshness::Stale, + ), + ( + RuntimeProviderState::TimedOut(Some(marked_cron_declaration())), + RuntimeEvidenceFreshness::TimedOut, + ), + ] { + let mut provider_slots = slots(); + let cron = provider_slots.get_mut(&ProviderSlot::Cron).unwrap(); + cron.observation = observation; + cron.freshness.data_revision = Some(SlotDataRevision::first()); + cron.freshness.last_success_ms = Some(42); + // A completed Cron pass alone must not publish a dangling edge. + let no_host = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &provider_slots, + "docker-observation", + ); + assert!(no_host + .edges + .iter() + .all(|edge| edge.source != "scheduled_job_declared")); + + let host = provider_slots.get_mut(&ProviderSlot::HostScoped).unwrap(); + host.observation = RuntimeProviderState::Fresh(host_collection()); + host.freshness.data_revision = Some(SlotDataRevision::first()); + host.freshness.last_success_ms = Some(42); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &provider_slots, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "scheduled_job_declared") + .expect("canonical host admits cron edge"); + assert!(edge.metadata.is_empty()); + assert_eq!(edge.evidence_refs.len(), 1); + let evidence = &edge.evidence_refs[0]; + assert_eq!(evidence.version, 4); + assert_eq!(evidence.provider, RuntimeEvidenceProvider::Cron); + assert_eq!(evidence.kind, RuntimeEvidenceKind::CronScheduleDeclaration); + assert_eq!( + evidence.assertion_kind, + RuntimeEvidenceAssertionKind::Declared + ); + assert_eq!(evidence.provider_slot, Some(ProviderSlot::Cron)); + assert_eq!(evidence.collected_at, 42); + assert_eq!(evidence.freshness, expected); + } + } + + #[test] + fn cron_marker_never_publishes_without_lifecycle_or_after_reset() { + let mut provider_slots = slots(); + let cron = provider_slots.get_mut(&ProviderSlot::Cron).unwrap(); + cron.observation = RuntimeProviderState::Fresh(marked_cron_declaration()); + let host = provider_slots.get_mut(&ProviderSlot::HostScoped).unwrap(); + host.observation = RuntimeProviderState::Fresh(host_collection()); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &provider_slots, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "scheduled_job_declared") + .unwrap(); + assert!(edge.evidence_refs.is_empty() && edge.metadata.is_empty()); + + let mut disabled = slots(); + let cron = disabled.get_mut(&ProviderSlot::Cron).unwrap(); + let mut collection = marked_cron_declaration(); + collection.set_state(ProviderSlot::Cron, ProviderStateKind::Disabled); + cron.observation = RuntimeProviderState::Fresh(collection); + cron.freshness.data_revision = Some(SlotDataRevision::first()); + cron.freshness.last_success_ms = Some(42); + disabled + .get_mut(&ProviderSlot::HostScoped) + .unwrap() + .observation = RuntimeProviderState::Fresh(host_collection()); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &disabled, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "scheduled_job_declared") + .unwrap(); + assert!(edge.evidence_refs.is_empty() && edge.metadata.is_empty()); + } + + #[test] + fn systemd_evidence_is_slot_bound_and_truthfully_retained() { + for (observation, expected) in [ + ( + RuntimeProviderState::Fresh(marked_systemd_dependency()), + RuntimeEvidenceFreshness::Fresh, + ), + ( + RuntimeProviderState::Degraded(Some(marked_systemd_dependency())), + RuntimeEvidenceFreshness::Stale, + ), + ( + RuntimeProviderState::TimedOut(Some(marked_systemd_dependency())), + RuntimeEvidenceFreshness::TimedOut, + ), + ] { + let mut slots = slots(); + let state = slots.get_mut(&ProviderSlot::Systemd).unwrap(); + state.observation = observation; + state.freshness.data_revision = Some(SlotDataRevision::first()); + state.freshness.last_success_ms = Some(42); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &slots, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "systemd_service_application") + .expect("systemd relationship is retained"); + assert!(edge.metadata.is_empty(), "private marker never publishes"); + assert_eq!(edge.evidence_refs.len(), 1); + let evidence = &edge.evidence_refs[0]; + assert_eq!(evidence.version, 2); + assert_eq!(evidence.provider, RuntimeEvidenceProvider::Systemd); + assert_eq!(evidence.kind, RuntimeEvidenceKind::SystemdRequires); + assert_eq!( + edge.relationship, + dockermap_core::RuntimeRelationshipKind::Requires + ); + assert_eq!( + evidence.assertion_kind, + RuntimeEvidenceAssertionKind::Declared + ); + assert_eq!(evidence.provider_slot, Some(ProviderSlot::Systemd)); + assert_eq!(evidence.collected_at, 42); + assert_eq!(evidence.freshness, expected); + assert!(!evidence.provider_revision.is_empty()); + } + } + + #[test] + fn npm_manifest_evidence_is_slot_bound_redacted_and_truthfully_retained() { + for (observation, expected) in [ + ( + RuntimeProviderState::Fresh(marked_npm_dependency()), + RuntimeEvidenceFreshness::Fresh, + ), + ( + RuntimeProviderState::Degraded(Some(marked_npm_dependency())), + RuntimeEvidenceFreshness::Stale, + ), + ( + RuntimeProviderState::TimedOut(Some(marked_npm_dependency())), + RuntimeEvidenceFreshness::TimedOut, + ), + ] { + let mut slots = slots(); + let state = slots.get_mut(&ProviderSlot::ProjectNpm).unwrap(); + state.observation = observation; + state.freshness.data_revision = Some(SlotDataRevision::first()); + state.freshness.last_success_ms = Some(42); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &slots, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "npm_project_application") + .expect("npm dependency remains visible"); + assert!(edge.metadata.is_empty(), "private marker never publishes"); + assert_eq!(edge.evidence_refs.len(), 1); + let evidence = &edge.evidence_refs[0]; + assert_eq!(evidence.version, 3); + assert_eq!(evidence.provider, RuntimeEvidenceProvider::Npm); + assert_eq!( + evidence.kind, + RuntimeEvidenceKind::NpmPackageManifestDependency + ); + assert_eq!( + evidence.assertion_kind, + RuntimeEvidenceAssertionKind::Declared + ); + assert_eq!(evidence.provider_slot, Some(ProviderSlot::ProjectNpm)); + assert_eq!(evidence.freshness, expected); + assert_eq!(evidence.summary, "package manifest declared a dependency"); + assert!(!evidence.summary.contains("package.json")); + } + } + + #[test] + fn npm_manifest_marker_cannot_publish_without_success_revision_or_after_source_reset() { + let mut provider_slots = slots(); + let state = provider_slots.get_mut(&ProviderSlot::ProjectNpm).unwrap(); + state.observation = RuntimeProviderState::Fresh(marked_npm_dependency()); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &provider_slots, + "docker-observation", + ); + let npm_edges = map + .edges + .iter() + .filter(|edge| edge.source.starts_with("npm_project_")) + .collect::>(); + assert!(npm_edges.iter().all(|edge| edge.evidence_refs.is_empty())); + assert!(npm_edges.iter().all(|edge| edge.metadata.is_empty())); + + let mut disabled = slots(); + let state = disabled.get_mut(&ProviderSlot::ProjectNpm).unwrap(); + let mut collection = marked_npm_dependency(); + collection.set_state(ProviderSlot::ProjectNpm, ProviderStateKind::Disabled); + state.observation = RuntimeProviderState::Fresh(collection); + state.freshness.data_revision = Some(SlotDataRevision::first()); + state.freshness.last_success_ms = Some(42); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &disabled, + "docker-observation", + ); + assert!(map + .edges + .iter() + .filter(|edge| edge.source.starts_with("npm_project_")) + .all(|edge| edge.evidence_refs.is_empty() && edge.metadata.is_empty())); + + let mut reset = source_reset_provider_slots(); + let state = reset.get_mut(&ProviderSlot::ProjectNpm).unwrap(); + state.observation = RuntimeProviderState::Unavailable; + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &reset, + "docker-observation", + ); + assert!(map + .edges + .iter() + .filter(|edge| edge.source.starts_with("npm_project_")) + .all(|edge| edge.evidence_refs.is_empty())); + } + + #[test] + fn findings_are_cached_only_after_the_runtime_map_revision_is_published() { + let mut cache = docker_cache(mock_snapshot()); + let mut provider_slots = slots(); + let state = provider_slots.get_mut(&ProviderSlot::Systemd).unwrap(); + state.observation = RuntimeProviderState::Fresh(marked_systemd_dependency()); + state.freshness.data_revision = Some(SlotDataRevision::first()); + state.freshness.last_success_ms = Some(42); + cache.runtime_providers = provider_slots; + cache.rebuild_runtime_map(); + assert!(cache.runtime_map.model_revision.is_empty()); + assert!(cache.findings.model_revision.is_empty()); + + cache.assign_revision(); + + assert_eq!( + cache.findings.model_revision, + cache.runtime_map.model_revision + ); + let finding = cache + .findings + .findings + .iter() + .find(|finding| { + finding.rule_id == dockermap_core::FindingRule::SystemdRequiresTargetNotActive + }) + .expect("fresh systemd evidence produces its warning alongside other cached findings"); + assert_eq!(finding.evidence_refs.len(), 1); + assert_eq!(finding.evidence_refs[0].version, 2); + let serialized = serde_json::to_string(finding).unwrap(); + assert!(serialized.contains("evidenceRefs")); + assert!(serialized.contains("systemd_requires")); + + let docker_finding = cache + .findings + .findings + .iter() + .find(|finding| { + finding.rule_id + == dockermap_core::FindingRule::DockerInternalNetworkMemberPublishesPort + }) + .expect("a Docker-mode representative topology produces the bounded internal-network advisory"); + assert_eq!(docker_finding.evidence_refs.len(), 2); + assert_eq!( + docker_finding.evidence_refs[0].kind, + RuntimeEvidenceKind::DockerNetworkMembership + ); + assert_eq!( + docker_finding.evidence_refs[1].kind, + RuntimeEvidenceKind::DockerPortPublication + ); + } + + #[test] + fn daemon_state_bind_mount_finding_is_cached_after_publication() { + let mut snapshot = mock_snapshot(); + snapshot.containers[0].mounts = vec![ContainerMount { + id: "private-mount-id".into(), + kind: ComposeMountKind::Bind, + source: Some("/private/DOCKERMAP_TEST_DAEMON_STATE/docker.sock".into()), + target: "/private/target".into(), + read_only: true, + }]; + let mut cache = docker_cache(snapshot); + cache.rebuild_runtime_map(); + cache.assign_revision(); + let finding = cache + .findings + .findings + .iter() + .find(|finding| { + finding.rule_id == dockermap_core::FindingRule::DockerDaemonStateBindMount + }) + .expect("cached runtime map produces the daemon-state warning"); + assert_eq!(finding.evidence_refs.len(), 1); + assert_eq!( + finding.evidence_refs[0].kind, + RuntimeEvidenceKind::DockerDaemonStateBindMount + ); + let serialized = serde_json::to_string(finding).unwrap(); + for forbidden in [ + "DOCKERMAP_TEST_DAEMON_STATE", + "/private/target", + "private-mount-id", + "readOnly", + ] { + assert!( + !serialized.contains(forbidden), + "cached finding leaked {forbidden}" + ); + } + } + + #[test] + fn revisionless_or_disabled_systemd_collection_cannot_publish_evidence() { + let mut slots = slots(); + slots.get_mut(&ProviderSlot::Systemd).unwrap().observation = + RuntimeProviderState::Fresh(marked_systemd_dependency()); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &slots, + "docker-observation", + ); + let edge = map + .edges + .iter() + .find(|edge| edge.source == "systemd_service_application") + .expect("systemd relationship remains visible without evidence"); + assert!(edge.evidence_refs.is_empty()); + assert!(edge.metadata.is_empty()); + + let mut disabled = marked_systemd_dependency(); + disabled.set_state(ProviderSlot::Systemd, ProviderStateKind::Disabled); + let state = slots.get_mut(&ProviderSlot::Systemd).unwrap(); + state.observation = RuntimeProviderState::Fresh(disabled); + state.freshness.data_revision = Some(SlotDataRevision::first()); + state.freshness.last_success_ms = Some(42); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &slots, + "docker-observation", + ); + assert!(map + .edges + .iter() + .find(|edge| edge.source == "systemd_service_application") + .expect("disabled systemd relationship remains visible without evidence") + .evidence_refs + .is_empty()); + } + fn docker_cache(snapshot: DockerSnapshot) -> DaemonCache { let last_updated = snapshot.last_updated; - DaemonCache { + let mut cache = DaemonCache { snapshot, health: HealthResponse { status: HealthState::Ok, @@ -970,10 +1917,26 @@ mod scheduler_tests { message: Some("controlled Docker cache".into()), }, runtime_map: empty_runtime_map(last_updated), + findings: FindingsResponse::default(), runtime_providers: unavailable_provider_slots(), source_generation: 0, + docker_observation_revision: DockerObservationRevision::new(), revision: PublicationRevision::new(), - } + }; + cache.assign_docker_observation_revision(); + cache + } + + fn first_docker_evidence_revision(cache: &DaemonCache) -> String { + cache + .runtime_map + .edges + .iter() + .flat_map(|edge| &edge.evidence_refs) + .next() + .expect("Docker runtime map carries evidence") + .provider_revision + .clone() } /// Complete a claimed fixed slot without running a host collector. This is @@ -1003,7 +1966,7 @@ mod scheduler_tests { for slot in claimed { complete_synthetic_slot(&mut cache.runtime_providers, *slot, completed_at); } - cache.runtime_map = runtime_map_for_snapshot(&cache.snapshot, &cache.runtime_providers); + cache.rebuild_runtime_map(); cache.assign_revision(); } @@ -1048,6 +2011,11 @@ mod scheduler_tests { slot_interval(ProviderSlot::HostScoped), Duration::from_secs(15) ); + assert_eq!(slot_interval(ProviderSlot::Cron), Duration::from_secs(15)); + assert_eq!( + slot_interval(ProviderSlot::Systemd), + Duration::from_secs(15) + ); assert_eq!( slot_interval(ProviderSlot::PythonProcesses), Duration::from_secs(10) @@ -1092,6 +2060,8 @@ mod scheduler_tests { let invocations = |slot| 1 + window.as_secs() / slot_interval(slot).as_secs(); assert_eq!(invocations(ProviderSlot::NetworkInfrastructure), 7); assert_eq!(invocations(ProviderSlot::HostScoped), 5); + assert_eq!(invocations(ProviderSlot::Cron), 5); + assert_eq!(invocations(ProviderSlot::Systemd), 5); assert_eq!(invocations(ProviderSlot::PythonProcesses), 7); assert_eq!(invocations(ProviderSlot::NativeProcesses), 7); assert_eq!(invocations(ProviderSlot::ProjectNpm), 2); @@ -1157,14 +2127,20 @@ mod scheduler_tests { assert_eq!(publications, 31); assert_eq!(starts[&ProviderSlot::NetworkInfrastructure], 7); assert_eq!(starts[&ProviderSlot::HostScoped], 5); + assert_eq!(starts[&ProviderSlot::Cron], 5); + assert_eq!(starts[&ProviderSlot::Systemd], 5); assert_eq!(starts[&ProviderSlot::PythonProcesses], 7); assert_eq!(starts[&ProviderSlot::NativeProcesses], 7); assert_eq!(starts[&ProviderSlot::ProjectNpm], 2); - assert_eq!(starts.values().sum::(), 28); + assert_eq!(starts.values().sum::(), 38); assert!(maximum_live_workers <= MAX_CONCURRENT_PROVIDER_SLOTS); - let legacy_slot_passes = - (1 + 60 / STATIC_REFRESH_INTERVAL.as_secs()) * STATIC_PROVIDER_SLOTS.len() as u64; - assert_eq!(legacy_slot_passes, 155); + // Before Systemd became independently schedulable, one aggregate + // host-scoped pass covered it alongside the four other fixed bundles. + // Preserve that actual historical five-bundle baseline rather than + // retroactively multiplying the old cadence by today's six slots. + let legacy_aggregate_passes = + (1 + 60 / STATIC_REFRESH_INTERVAL.as_secs()) * LEGACY_AGGREGATE_SLOT_COUNT; + assert_eq!(legacy_aggregate_passes, 155); } /// The scheduler's timing trace above deliberately counts claims rather @@ -1189,15 +2165,23 @@ mod scheduler_tests { .block_on(run_real_collector_churn_trace(&profile)); match profile.as_str() { "full-host" => { - assert_eq!(starts.values().sum::(), 28); - let legacy_starts = (1 + 60 / STATIC_REFRESH_INTERVAL.as_secs()) - * STATIC_PROVIDER_SLOTS.len() as u64; - assert_eq!(legacy_starts, 155); - assert_eq!(legacy_starts * 8 / STATIC_PROVIDER_SLOTS.len() as u64, 248); + assert_eq!(starts.values().sum::(), 38); + // The old whole-runtime pass had five aggregate bundles; + // systemd was part of host-scoped collection, not a sixth + // independently scheduled unit. + let legacy_aggregate_starts = + (1 + 60 / STATIC_REFRESH_INTERVAL.as_secs()) * LEGACY_AGGREGATE_SLOT_COUNT; + assert_eq!(legacy_aggregate_starts, 155); + assert_eq!( + legacy_aggregate_starts * 8 / LEGACY_AGGREGATE_SLOT_COUNT, + 248 + ); } "restricted" => { - assert_eq!(starts.values().sum::(), 12); + assert_eq!(starts.values().sum::(), 14); assert_eq!(starts[&ProviderSlot::HostScoped], 1); + assert_eq!(starts[&ProviderSlot::Cron], 1); + assert_eq!(starts[&ProviderSlot::Systemd], 1); assert_eq!(starts[&ProviderSlot::PythonProcesses], 1); assert_eq!(starts[&ProviderSlot::NativeProcesses], 1); } @@ -1405,6 +2389,7 @@ mod scheduler_tests { if profile == "restricted" { for slot in [ ProviderSlot::HostScoped, + ProviderSlot::Cron, ProviderSlot::PythonProcesses, ProviderSlot::NativeProcesses, ] { @@ -1417,6 +2402,8 @@ mod scheduler_tests { } else { assert_eq!(starts[&ProviderSlot::NetworkInfrastructure], 7); assert_eq!(starts[&ProviderSlot::HostScoped], 5); + assert_eq!(starts[&ProviderSlot::Cron], 5); + assert_eq!(starts[&ProviderSlot::Systemd], 5); assert_eq!(starts[&ProviderSlot::PythonProcesses], 7); assert_eq!(starts[&ProviderSlot::NativeProcesses], 7); assert_eq!(starts[&ProviderSlot::ProjectNpm], 2); @@ -1441,8 +2428,7 @@ mod scheduler_tests { .expect("fixed python slot exists"); python.observation = RuntimeProviderState::Collecting(retained_collection(&python.observation)); - initial.runtime_map = - runtime_map_for_snapshot(&initial.snapshot, &initial.runtime_providers); + initial.rebuild_runtime_map(); initial.assign_revision(); let state = AppState { cache: Arc::new(RwLock::new(initial)), @@ -1513,7 +2499,7 @@ mod scheduler_tests { #[test] fn disabled_slots_are_never_queued_after_profile_fact_is_observed() { let mut slots = slots(); - let slot = ProviderSlot::HostScoped; + let slot = ProviderSlot::Systemd; let mut collection = ProviderCollection::default(); collection.set_state(slot, ProviderStateKind::Disabled); let entry = slots.get_mut(&slot).unwrap(); @@ -1531,7 +2517,8 @@ mod scheduler_tests { collection.set_state(slot, ProviderStateKind::Fresh); slots.get_mut(&slot).unwrap().observation = RuntimeProviderState::TimedOut(Some(collection)); - let map = runtime_map_for_snapshot(&snapshot, &slots); + let map = + runtime_map_for_snapshot(&snapshot, &RuntimeMode::Docker, &slots, "test-observation"); assert!(map .provider_states .iter() @@ -1657,7 +2644,8 @@ mod scheduler_tests { collection.set_state(slot, ProviderStateKind::Fresh); slots.get_mut(&slot).unwrap().observation = RuntimeProviderState::Degraded(Some(collection)); - let map = runtime_map_for_snapshot(&snapshot, &slots); + let map = + runtime_map_for_snapshot(&snapshot, &RuntimeMode::Docker, &slots, "test-observation"); assert!(map .nodes .iter() @@ -1704,7 +2692,7 @@ mod scheduler_tests { #[test] fn provider_freshness_projection_is_safe_and_retains_good_evidence_on_failure() { let snapshot = mock_snapshot(); - let slot = ProviderSlot::PythonProcesses; + let slot = ProviderSlot::Systemd; let mut slots = slots(); let entry = slots.get_mut(&slot).unwrap(); let mut collection = ProviderCollection::default(); @@ -1739,11 +2727,12 @@ mod scheduler_tests { entry.freshness.status_reason = Some(ProviderStatusReason::Refreshing); retained }; - let refreshing = runtime_map_for_snapshot(&snapshot, &slots) - .provider_states - .into_iter() - .find(|state| state.slot == slot) - .unwrap(); + let refreshing = + runtime_map_for_snapshot(&snapshot, &RuntimeMode::Docker, &slots, "test-observation") + .provider_states + .into_iter() + .find(|state| state.slot == slot) + .unwrap(); assert_eq!(refreshing.state, ProviderStateKind::Stale); assert_eq!(refreshing.last_attempt_ms, Some(120)); assert_eq!(refreshing.last_success_ms, Some(110)); @@ -1757,11 +2746,12 @@ mod scheduler_tests { entry.observation = RuntimeProviderState::TimedOut(retained); entry.freshness.consecutive_failure_count = 1; entry.freshness.status_reason = Some(ProviderStatusReason::CollectionTimedOut); - let timed_out = runtime_map_for_snapshot(&snapshot, &slots) - .provider_states - .into_iter() - .find(|state| state.slot == slot) - .unwrap(); + let timed_out = + runtime_map_for_snapshot(&snapshot, &RuntimeMode::Docker, &slots, "test-observation") + .provider_states + .into_iter() + .find(|state| state.slot == slot) + .unwrap(); assert_eq!(timed_out.state, ProviderStateKind::TimedOut); assert_eq!(timed_out.last_attempt_ms, Some(120)); assert_eq!(timed_out.last_success_ms, Some(110)); @@ -1776,7 +2766,7 @@ mod scheduler_tests { #[test] fn source_reset_clears_provider_freshness_without_exposing_private_state() { - let slot = ProviderSlot::NetworkInfrastructure; + let slot = ProviderSlot::Systemd; let mut slots = source_reset_provider_slots(); let state = provider_states_for(&slots) .into_iter() @@ -1805,7 +2795,7 @@ mod scheduler_tests { #[test] fn opaque_data_revision_changes_only_for_sanitized_observable_data() { - let slot = ProviderSlot::NativeProcesses; + let slot = ProviderSlot::Systemd; let mut freshness = SlotFreshness::default(); let mut first = ProviderCollection::default(); first.set_state(slot, ProviderStateKind::Fresh); @@ -1868,6 +2858,47 @@ mod scheduler_tests { .any(|diagnostic| diagnostic .message .contains("controlled live slot observation"))); + assert!(cache.runtime_map.nodes.iter().any(|node| { + node.provider == RuntimeProviderKind::Docker && node.kind == RuntimeNodeKind::Container + })); + assert!( + !cache.runtime_map.edges.is_empty(), + "mock topology remains useful" + ); + assert!(cache + .runtime_map + .edges + .iter() + .all(|edge| edge.evidence_refs.is_empty())); + assert!(cache.findings.findings.is_empty()); + } + + #[tokio::test] + async fn forced_mock_mode_preserves_sample_topology_without_runtime_evidence() { + // The collector checks this flag before it can connect to the Docker + // gateway. This regression therefore proves the explicit forced-mock + // path, rather than merely constructing a sample cache by hand. + std::env::set_var("DOCKERMAP_FORCE_MOCK", "true"); + let collected = collect_snapshot(&AppState::new()).await; + std::env::remove_var("DOCKERMAP_FORCE_MOCK"); + assert_eq!(collected.health.mode, RuntimeMode::Mock); + + let state = AppState::new(); + publish_docker_snapshot_cache(&state, collected).await; + let cache = state.cache.read().await; + assert!(cache.runtime_map.nodes.iter().any(|node| { + node.provider == RuntimeProviderKind::Docker && node.kind == RuntimeNodeKind::Container + })); + assert!( + !cache.runtime_map.edges.is_empty(), + "sample edges remain visible" + ); + assert!(cache + .runtime_map + .edges + .iter() + .all(|edge| edge.evidence_refs.is_empty())); + assert!(cache.findings.findings.is_empty()); } #[tokio::test] @@ -1938,4 +2969,51 @@ mod scheduler_tests { RuntimeProviderState::Fresh(_) )); } + + #[tokio::test] + async fn docker_evidence_token_tracks_sanitized_source_semantics_not_refresh_ticks() { + let mut first = mock_snapshot(); + first.last_updated = 10; + let state = AppState { + cache: Arc::new(RwLock::new(docker_cache(first.clone()))), + docker: Arc::new(RwLock::new(None)), + provider_slot_in_flight: Arc::new(ProviderSlotFlights::default()), + }; + + publish_docker_snapshot_cache(&state, docker_cache(first.clone())).await; + let first_cache = state.cache.read().await; + let first_token = first_docker_evidence_revision(&first_cache); + let first_model_revision = first_cache.snapshot.model_revision.clone(); + assert_ne!(first_token, first.last_updated.to_string()); + drop(first_cache); + + let mut ticker_only = first.clone(); + ticker_only.last_updated = 12; + publish_docker_snapshot_cache(&state, docker_cache(ticker_only.clone())).await; + let ticker_cache = state.cache.read().await; + assert_eq!(first_docker_evidence_revision(&ticker_cache), first_token); + assert_eq!(ticker_cache.snapshot.model_revision, first_model_revision); + drop(ticker_cache); + + let mut changed = ticker_only.clone(); + changed.containers[0].name = "semantic-container-change".into(); + changed.last_updated = 14; + publish_docker_snapshot_cache(&state, docker_cache(changed.clone())).await; + let changed_cache = state.cache.read().await; + let changed_token = first_docker_evidence_revision(&changed_cache); + assert_ne!(changed_token, first_token); + assert_ne!(changed_token, changed.last_updated.to_string()); + drop(changed_cache); + + // A Docker/mock source transition is semantic evidence even when the + // bounded inventory happens to have the same visible entities. + publish_docker_snapshot_cache(&state, DaemonCache::mock()).await; + let mock_cache = state.cache.read().await; + assert!(mock_cache + .runtime_map + .edges + .iter() + .all(|edge| edge.evidence_refs.is_empty())); + assert!(mock_cache.findings.findings.is_empty()); + } } diff --git a/crates/dockermap-daemon/src/daemon_api.rs b/crates/dockermap-daemon/src/daemon_api.rs index 6953b53f..ceed4136 100644 --- a/crates/dockermap-daemon/src/daemon_api.rs +++ b/crates/dockermap-daemon/src/daemon_api.rs @@ -24,8 +24,8 @@ use axum::{ }; use dockermap_core::{ derive_graph, mock_log_entries, ContainerDetailResponse, ContainersResponse, DockerSnapshot, - GraphResponse, HealthResponse, ImagesResponse, LogCursor, LogsResponse, NetworksResponse, - RuntimeMap, VolumesResponse, DEFAULT_LOG_PAGE_SIZE, MAX_LOG_PAGE_SIZE, + FindingsResponse, GraphResponse, HealthResponse, ImagesResponse, LogCursor, LogsResponse, + NetworksResponse, RuntimeMap, VolumesResponse, DEFAULT_LOG_PAGE_SIZE, MAX_LOG_PAGE_SIZE, }; pub(crate) const MAX_LOG_QUERY_CHARS: usize = 256; @@ -61,6 +61,7 @@ pub(crate) fn daemon_router(state: AppState, daemon_token: DaemonAuthToken) -> R .route("/daemon/snapshot", get(get_snapshot)) .route("/daemon/graph", get(get_graph)) .route("/daemon/runtime/map", get(get_runtime_map)) + .route("/daemon/findings", get(get_findings)) .route("/daemon/containers", get(get_containers)) .route("/daemon/containers/{name}", get(get_container)) .route("/daemon/images", get(get_images)) @@ -110,6 +111,13 @@ async fn get_runtime_map(State(state): State) -> Json { Json(runtime_map) } +async fn get_findings(State(state): State) -> Json { + // Findings are cached during refresh immediately after the runtime map is + // assigned its publication revision; requests never invoke providers. + let cache = state.cache.read().await; + Json(cache.findings.clone()) +} + async fn get_containers(State(state): State) -> Json { let cache = state.cache.read().await; let snapshot = publish_docker_snapshot(&cache.snapshot); diff --git a/crates/dockermap-daemon/src/main.rs b/crates/dockermap-daemon/src/main.rs index cd33418d..9471e2b2 100644 --- a/crates/dockermap-daemon/src/main.rs +++ b/crates/dockermap-daemon/src/main.rs @@ -42,10 +42,12 @@ use dockermap_core::mock_log_entries; #[cfg(test)] use dockermap_core::{ derive_runtime_map, page_log_entries, ComposeFileOrigin, ComposeMountKind, ContainerMount, - DiagnosticSeverity, LogCursor, LogEntry, NetworkRecord, RuntimeMapDiagnostic, RuntimeMapEdge, - RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, RuntimeOwnership, RuntimePackageEntity, - RuntimeProviderKind, RuntimeRelationshipKind, RuntimeServiceEntity, RuntimeServiceStatus, - VolumeRecord, DEFAULT_LOG_PAGE_SIZE, MAX_LOG_PAGE_SIZE, + DiagnosticSeverity, LogCursor, LogEntry, NetworkRecord, RuntimeEvidenceAssertionKind, + RuntimeEvidenceFreshness, RuntimeEvidenceKind, RuntimeEvidenceProvider, RuntimeEvidenceRef, + RuntimeMapDiagnostic, RuntimeMapEdge, RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, + RuntimeOwnership, RuntimePackageEntity, RuntimeProviderKind, RuntimeRelationshipKind, + RuntimeServiceEntity, RuntimeServiceStatus, VolumeRecord, DEFAULT_LOG_PAGE_SIZE, + MAX_LOG_PAGE_SIZE, }; #[cfg(test)] use dockermap_core::{mock_snapshot, HealthResponse, HealthState, RuntimeMap, RuntimeMode}; @@ -1525,6 +1527,7 @@ mod tests { target: "host_local".into(), relationship: RuntimeRelationshipKind::RunsOn, metadata: BTreeMap::from([("argv".into(), command.into())]), + evidence_refs: Vec::new(), }]; let mut diagnostics = vec![RuntimeMapDiagnostic { provider: RuntimeProviderKind::Process, @@ -1560,6 +1563,7 @@ mod tests { "header".into(), "Authorization: Bearer DOCKERMAP_TEST_FAKE_EDGE_TOKEN".into(), )]), + evidence_refs: Vec::new(), }]; let mut diagnostics = vec![RuntimeMapDiagnostic { provider: RuntimeProviderKind::Other, @@ -1579,6 +1583,94 @@ mod tests { ); } + #[test] + fn runtime_evidence_redacts_controls_and_secrets_bounds_text_and_preserves_collisions() { + let secret = "https://user:DOCKERMAP_TEST_FAKE_EVIDENCE_TOKEN@example.test/path"; + let oversized = "x".repeat(800); + let evidence = |summary: String| RuntimeEvidenceRef { + version: 1, + id: format!("evidence-{oversized}"), + provider: RuntimeEvidenceProvider::Docker, + kind: RuntimeEvidenceKind::DockerNetworkMembership, + assertion_kind: RuntimeEvidenceAssertionKind::Observed, + summary, + subject_ref: "docker_container_\u{202e}id".into(), + collected_at: 1, + provider_revision: oversized.clone(), + provider_slot: None, + freshness: RuntimeEvidenceFreshness::Fresh, + }; + let mut edges = vec![RuntimeMapEdge { + source: "docker_container_\u{202e}id".into(), + target: "docker_network_network".into(), + relationship: RuntimeRelationshipKind::ConnectedTo, + metadata: BTreeMap::new(), + evidence_refs: vec![ + evidence(secret.into()), + evidence("safe\u{202e}summary".into()), + ], + }]; + + redact_runtime_edges(&mut edges); + + assert_eq!(edges[0].evidence_refs.len(), 2); + assert!(edges[0] + .evidence_refs + .iter() + .all(|evidence| evidence.summary.chars().count() <= 259)); + assert!(edges[0] + .evidence_refs + .iter() + .all(|evidence| evidence.id.chars().count() <= 259)); + assert!(edges[0].evidence_refs.iter().all(|evidence| { + evidence.provider_revision.chars().count() <= 259 + && evidence.subject_ref == edges[0].source + })); + let serialized = serde_json::to_string(&edges).expect("evidence serializes"); + assert!(!serialized.contains("DOCKERMAP_TEST_FAKE_EVIDENCE_TOKEN")); + assert!(!serialized.contains('\u{202e}')); + assert!(serialized.contains(REDACTED_VALUE)); + } + + #[test] + fn publication_retains_only_evidence_that_attests_its_docker_edge() { + let snapshot = mock_snapshot(); + let mut map = derive_runtime_map( + &snapshot, + Vec::new(), + Vec::new(), + Vec::new(), + "opaque-observation", + ); + let expected = map + .edges + .iter() + .map(|edge| edge.evidence_refs.len()) + .sum::(); + assert!(expected > 0, "mock Docker snapshot emits evidence"); + + redact_runtime_map(&mut map); + + assert_eq!( + map.edges + .iter() + .map(|edge| edge.evidence_refs.len()) + .sum::(), + expected, + "publication must retain correctly bound Docker evidence" + ); + assert!(map.edges.iter().any(|edge| { + edge.evidence_refs.iter().any(|evidence| { + evidence.kind == RuntimeEvidenceKind::DockerComposeDependsOn + && evidence.summary == "Docker recorded Compose dependency declaration" + && evidence.subject_ref == edge.source + && edge.source.starts_with("docker_container_") + && edge.target.starts_with("docker_container_") + && edge.source != edge.target + }) + })); + } + #[test] fn pages_log_entries_to_strictly_older_pages() { let entries = (0..5) @@ -1987,7 +2079,7 @@ mod tests { #[test] fn docker_container_nodes_carry_layer_and_service_entity() { let snapshot = mock_snapshot(); - let map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new()); + let map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); let container = map .nodes @@ -2492,6 +2584,7 @@ mod tests { target: unsafe_package_id.into(), relationship: RuntimeRelationshipKind::DependsOn, metadata: BTreeMap::new(), + evidence_refs: Vec::new(), }; let mut map = RuntimeMap { nodes: vec![node, duplicate_after_normalization, package_node], @@ -2544,6 +2637,34 @@ mod tests { assert_eq!(advisory.id, "advisory�id"); } + #[test] + fn daemon_state_risk_evidence_stays_path_free_through_publication() { + let mut snapshot = mock_snapshot(); + snapshot.containers[0].mounts = vec![ContainerMount { + id: "private-daemon-state-mount".into(), + kind: ComposeMountKind::Bind, + source: Some("/private/DOCKERMAP_TEST_DAEMON_STATE/docker.sock".into()), + target: "/private/target".into(), + read_only: false, + }]; + let mut map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); + redact_runtime_map(&mut map); + let serialized = serde_json::to_string(&map).unwrap(); + assert!(serialized.contains("host_risk_docker_daemon_state")); + assert!(serialized.contains("docker_daemon_state_bind_mount")); + for forbidden in [ + "DOCKERMAP_TEST_DAEMON_STATE", + "/private/target", + "private-daemon-state-mount", + "readOnly", + ] { + assert!( + !serialized.contains(forbidden), + "publication leaked {forbidden}" + ); + } + } + #[test] fn compose_publication_normalizes_diagnostics_and_graph_inputs() { let mut scan = ComposeScan { diff --git a/crates/dockermap-daemon/src/provider_contract.rs b/crates/dockermap-daemon/src/provider_contract.rs index b60b35c9..71bab908 100644 --- a/crates/dockermap-daemon/src/provider_contract.rs +++ b/crates/dockermap-daemon/src/provider_contract.rs @@ -49,6 +49,10 @@ pub(crate) struct ProviderCollection { } impl ProviderCollection { + pub(crate) fn nodes(&self) -> &[RuntimeMapNode] { + &self.nodes + } + pub(crate) fn nodes_mut(&mut self) -> &mut Vec { &mut self.nodes } @@ -183,6 +187,7 @@ mod tests { target: "docker_container_target".into(), relationship: RuntimeRelationshipKind::RelatedTo, metadata: edge_metadata, + evidence_refs: Vec::new(), }); collection.diagnostics.push(RuntimeMapDiagnostic { provider: RuntimeProviderKind::Process, diff --git a/crates/dockermap-daemon/src/providers/cron.rs b/crates/dockermap-daemon/src/providers/cron.rs index 538b2263..8969d004 100644 --- a/crates/dockermap-daemon/src/providers/cron.rs +++ b/crates/dockermap-daemon/src/providers/cron.rs @@ -7,8 +7,8 @@ use crate::process_runner::{run_command_with_timeout, PROVIDER_COMMAND_TIMEOUT}; use crate::{push_provider_diagnostic, redact_sensitive_text, safe_runtime_id_component}; use dockermap_core::{ - DiagnosticSeverity, RuntimeMapDiagnostic, RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, - RuntimeProviderKind, + DiagnosticSeverity, RuntimeMapDiagnostic, RuntimeMapEdge, RuntimeMapNode, RuntimeNodeKind, + RuntimeNodeLayer, RuntimeProviderKind, RuntimeRelationshipKind, }; use std::{ collections::{BTreeMap, BTreeSet}, @@ -22,8 +22,13 @@ use std::{ const MAX_CRON_D_ENTRIES: usize = 64; const MAX_CRON_FILE_BYTES: u64 = 64 * 1024; +/// Private handoff marker. Cache refresh removes it on every path and creates +/// public evidence only after this exact Cron slot owns a successful revision. +pub(crate) const CRON_EVIDENCE_SCHEDULE_MARKER: &str = "__dockermapCronScheduleDeclaration"; + pub(crate) fn collect_scheduled_jobs( nodes: &mut Vec, + edges: &mut Vec, diagnostics: &mut Vec, ) { let mut job_sources = Vec::new(); @@ -60,12 +65,13 @@ pub(crate) fn collect_scheduled_jobs( metadata.insert("source".into(), source.clone()); metadata.insert("line".into(), line.to_string()); metadata.insert("command".into(), safe_command.clone()); + let id = format!( + "scheduled_job_{}_{}", + safe_runtime_id_component(&source, "source"), + safe_runtime_id_component(&format!("{line}_{safe_command}"), "command") + ); nodes.push(RuntimeMapNode { - id: format!( - "scheduled_job_{}_{}", - safe_runtime_id_component(&source, "source"), - safe_runtime_id_component(&format!("{line}_{safe_command}"), "command") - ), + id: id.clone(), provider: RuntimeProviderKind::ScheduledJob, kind: RuntimeNodeKind::ScheduledJob, label: safe_command, @@ -75,6 +81,13 @@ pub(crate) fn collect_scheduled_jobs( service: None, package: None, }); + edges.push(RuntimeMapEdge { + source: id, + target: "host_local".into(), + relationship: RuntimeRelationshipKind::RunsOn, + metadata: BTreeMap::from([(CRON_EVIDENCE_SCHEDULE_MARKER.into(), "declared".into())]), + evidence_refs: Vec::new(), + }); } } diff --git a/crates/dockermap-daemon/src/providers/network_infrastructure.rs b/crates/dockermap-daemon/src/providers/network_infrastructure.rs index 5dfb01cb..0e5cbce2 100644 --- a/crates/dockermap-daemon/src/providers/network_infrastructure.rs +++ b/crates/dockermap-daemon/src/providers/network_infrastructure.rs @@ -241,6 +241,7 @@ fn push_network_container_node( ), relationship: RuntimeRelationshipKind::RelatedTo, metadata: BTreeMap::new(), + evidence_refs: Vec::new(), }); } diff --git a/crates/dockermap-daemon/src/providers/npm.rs b/crates/dockermap-daemon/src/providers/npm.rs index a5676cfc..4316364b 100644 --- a/crates/dockermap-daemon/src/providers/npm.rs +++ b/crates/dockermap-daemon/src/providers/npm.rs @@ -34,6 +34,10 @@ const MAX_PACKAGE_JSON_BYTES: u64 = 262_144; const MAX_NPM_SCRIPTS: usize = 16; const MAX_SCRIPT_CHARS: usize = 200; +/// Private marker consumed only by cache refresh after slot lifecycle binding. +/// It is never a public runtime-map metadata key. +pub(crate) const NPM_EVIDENCE_DEPENDENCY_MARKER: &str = "__dockermapNpmManifestDependency"; + #[derive(Debug, Clone, PartialEq, Eq)] struct PackageDependencyRecord { name: String, @@ -137,6 +141,7 @@ pub(crate) fn collect_npm_projects( target: "host_local".into(), relationship: RuntimeRelationshipKind::RunsOn, metadata: BTreeMap::new(), + evidence_refs: Vec::new(), }); } for (index, dependency) in project.dependencies.into_iter().enumerate() { @@ -177,11 +182,13 @@ pub(crate) fn collect_npm_projects( let mut dependency_metadata = BTreeMap::new(); dependency_metadata.insert("version".into(), safe_version); dependency_metadata.insert("scope".into(), safe_scope); + dependency_metadata.insert(NPM_EVIDENCE_DEPENDENCY_MARKER.into(), "declared".into()); edges.push(RuntimeMapEdge { source: node_id.clone(), target: package_id, relationship: RuntimeRelationshipKind::DependsOn, metadata: dependency_metadata, + evidence_refs: Vec::new(), }); } } @@ -688,6 +695,26 @@ mod tests { ); } + #[test] + fn npm_dependency_edges_carry_only_the_private_evidence_marker_before_binding() { + let (_, edges, _) = collect_fixture_projects(PidNamespaceScope::Host { diagnostic: None }); + let dependencies = edges + .iter() + .filter(|edge| edge.relationship == RuntimeRelationshipKind::DependsOn) + .collect::>(); + assert!(!dependencies.is_empty()); + assert!(dependencies.iter().all(|edge| { + edge.source.starts_with("npm_project_") + && edge.target.starts_with("npm_package_") + && edge + .metadata + .get(NPM_EVIDENCE_DEPENDENCY_MARKER) + .map(String::as_str) + == Some("declared") + && edge.evidence_refs.is_empty() + })); + } + #[test] fn restricted_pid_namespace_omits_host_edges() { let (_, edges, _) = collect_fixture_projects(PidNamespaceScope::Restricted); diff --git a/crates/dockermap-daemon/src/providers/systemd.rs b/crates/dockermap-daemon/src/providers/systemd.rs index 030e07e7..4dd72e60 100644 --- a/crates/dockermap-daemon/src/providers/systemd.rs +++ b/crates/dockermap-daemon/src/providers/systemd.rs @@ -15,6 +15,37 @@ use std::{ }; const MAX_SYSTEMD_UNITS: usize = 128; +/// Private handoff marker consumed by the scheduler once it has a real +/// collection revision. It is never allowed into the published map. +pub(crate) const SYSTEMD_EVIDENCE_KIND_MARKER: &str = "__dockermapSystemdEvidenceKind"; +pub(crate) const SYSTEMD_EVIDENCE_REQUIRES: &str = "requires"; +pub(crate) const SYSTEMD_EVIDENCE_WANTS: &str = "wants"; +pub(crate) const SYSTEMD_EVIDENCE_PART_OF: &str = "part_of"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum SystemdDependencyKind { + Requires, + Wants, + PartOf, +} + +impl SystemdDependencyKind { + fn marker(self) -> &'static str { + match self { + Self::Requires => SYSTEMD_EVIDENCE_REQUIRES, + Self::Wants => SYSTEMD_EVIDENCE_WANTS, + Self::PartOf => SYSTEMD_EVIDENCE_PART_OF, + } + } + + fn relationship(self) -> RuntimeRelationshipKind { + match self { + Self::Requires => RuntimeRelationshipKind::Requires, + Self::Wants => RuntimeRelationshipKind::Wants, + Self::PartOf => RuntimeRelationshipKind::PartOf, + } + } +} #[derive(Debug, Clone, PartialEq, Eq)] struct SystemdUnitSummary { @@ -153,35 +184,30 @@ pub(crate) fn collect_systemd_services( )); } - let mut dependency_reasons = BTreeMap::<(String, String), BTreeSet>::new(); + let mut dependencies = BTreeSet::<(String, String, SystemdDependencyKind)>::new(); for detail in details_by_unit.values() { - for (property, dependency) in systemd_dependency_pairs(detail) { + for (kind, dependency) in systemd_dependency_pairs(detail) { let source = systemd_node_id(&detail.id); let target = systemd_node_id(&dependency); if source == target { continue; } - dependency_reasons - .entry((source, target)) - .or_default() - .insert(property); + dependencies.insert((source, target, kind)); if !summary_by_unit.contains_key(&dependency) { nodes.push(systemd_runtime_node(&dependency, None, None, system_uptime)); } } } - for ((source, target), reasons) in dependency_reasons { + for (source, target, kind) in dependencies { let mut metadata = BTreeMap::new(); - metadata.insert( - "systemdProperties".into(), - reasons.into_iter().collect::>().join(","), - ); + metadata.insert(SYSTEMD_EVIDENCE_KIND_MARKER.into(), kind.marker().into()); edges.push(RuntimeMapEdge { source, target, - relationship: RuntimeRelationshipKind::DependsOn, + relationship: kind.relationship(), metadata, + evidence_refs: Vec::new(), }); } } @@ -264,16 +290,16 @@ fn parse_systemd_unit_list(value: &str) -> Vec { .collect() } -fn systemd_dependency_pairs(detail: &SystemdUnitDetails) -> Vec<(String, String)> { +fn systemd_dependency_pairs(detail: &SystemdUnitDetails) -> Vec<(SystemdDependencyKind, String)> { let mut pairs = Vec::new(); for dependency in &detail.requires { - pairs.push(("requires".into(), dependency.clone())); + pairs.push((SystemdDependencyKind::Requires, dependency.clone())); } for dependency in &detail.wants { - pairs.push(("wants".into(), dependency.clone())); + pairs.push((SystemdDependencyKind::Wants, dependency.clone())); } for dependency in &detail.part_of { - pairs.push(("part_of".into(), dependency.clone())); + pairs.push((SystemdDependencyKind::PartOf, dependency.clone())); } pairs } @@ -443,9 +469,9 @@ mod tests { assert_eq!( systemd_dependency_pairs(&records[0]), vec![ - ("requires".to_string(), "redis.service".to_string()), - ("wants".to_string(), "postgres.service".to_string()), - ("part_of".to_string(), "worker.service".to_string()) + (SystemdDependencyKind::Requires, "redis.service".to_string()), + (SystemdDependencyKind::Wants, "postgres.service".to_string()), + (SystemdDependencyKind::PartOf, "worker.service".to_string()) ] ); assert_eq!( diff --git a/crates/dockermap-daemon/src/publication.rs b/crates/dockermap-daemon/src/publication.rs index ce4013f2..c3a3f626 100644 --- a/crates/dockermap-daemon/src/publication.rs +++ b/crates/dockermap-daemon/src/publication.rs @@ -12,6 +12,10 @@ use dockermap_core::{ use std::collections::{BTreeMap, BTreeSet}; pub(crate) const REDACTED_VALUE: &str = "[redacted]"; +/// Evidence is a compact explanation reference, not an alternate raw-source +/// transport. Keep its human-facing fields independently bounded even if a +/// future provider constructs it outside the Docker derivation path. +const MAX_RUNTIME_EVIDENCE_TEXT_CHARS: usize = 256; /// Character-bounded display truncation shared by Docker logs and bounded /// project metadata. It lives at the publication boundary rather than the @@ -191,9 +195,44 @@ pub(crate) fn redact_runtime_edges(edges: &mut [RuntimeMapEdge]) { for value in edge.metadata.values_mut() { *value = redact_runtime_display_text(value); } + redact_runtime_evidence_refs(&mut edge.evidence_refs); + // Provider collections are internal, but retain this second + // fail-closed check after normalization: no evidence is better than a + // structurally plausible fact attached to the wrong relationship. + if !edge.has_valid_evidence_refs() { + edge.evidence_refs.clear(); + } } } +fn redact_runtime_evidence_refs(evidence_refs: &mut [dockermap_core::RuntimeEvidenceRef]) { + for evidence in evidence_refs.iter_mut() { + // `subjectRef` is a runtime edge endpoint rather than presentation + // detail, so do not truncate it independently of its owning edge. + // This preserves the existing collision/non-routability semantics. + evidence.subject_ref = redact_runtime_display_text(&evidence.subject_ref); + evidence.id = truncate_chars( + &redact_runtime_display_text(&evidence.id), + MAX_RUNTIME_EVIDENCE_TEXT_CHARS, + ); + evidence.summary = truncate_chars( + &redact_runtime_display_text(&evidence.summary), + MAX_RUNTIME_EVIDENCE_TEXT_CHARS, + ); + evidence.provider_revision = truncate_chars( + &redact_runtime_display_text(&evidence.provider_revision), + MAX_RUNTIME_EVIDENCE_TEXT_CHARS, + ); + } + // Keep distinct post-redaction occurrences visible. Evidence IDs are not + // routing keys (each record remains inline with its edge), so deduping a + // normalized collision would silently erase an observation. Sorting gives + // clients deterministic output without selecting one collision winner. + evidence_refs.sort_by_key(|evidence| { + serde_json::to_string(evidence).expect("runtime evidence must serialize") + }); +} + pub(crate) fn redact_runtime_diagnostics(diagnostics: &mut [RuntimeMapDiagnostic]) { for diagnostic in diagnostics { diagnostic.message = redact_runtime_display_text(&diagnostic.message); diff --git a/crates/dockermap-daemon/src/runtime_collection.rs b/crates/dockermap-daemon/src/runtime_collection.rs index a7f7eca2..534e99e0 100644 --- a/crates/dockermap-daemon/src/runtime_collection.rs +++ b/crates/dockermap-daemon/src/runtime_collection.rs @@ -22,7 +22,7 @@ use crate::{ }; use dockermap_core::{ derive_runtime_map, service_entity_kind_name, DiagnosticSeverity, DockerSnapshot, ProviderSlot, - ProviderStateKind, RuntimeMap, RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, + ProviderStateKind, RuntimeMap, RuntimeMapNode, RuntimeMode, RuntimeNodeKind, RuntimeNodeLayer, RuntimeProviderKind, ServiceEntityKind, }; use std::{ @@ -48,6 +48,8 @@ pub(crate) type StaticProviderSlot = ProviderSlot; pub(crate) const STATIC_PROVIDER_SLOTS: &[StaticProviderSlot] = &[ StaticProviderSlot::NetworkInfrastructure, StaticProviderSlot::HostScoped, + StaticProviderSlot::Cron, + StaticProviderSlot::Systemd, StaticProviderSlot::PythonProcesses, StaticProviderSlot::NativeProcesses, StaticProviderSlot::ProjectNpm, @@ -59,6 +61,8 @@ pub(crate) fn slot_interval(slot: StaticProviderSlot) -> Duration { match slot { StaticProviderSlot::NetworkInfrastructure => Duration::from_secs(10), StaticProviderSlot::HostScoped => Duration::from_secs(15), + StaticProviderSlot::Cron => Duration::from_secs(15), + StaticProviderSlot::Systemd => Duration::from_secs(15), StaticProviderSlot::PythonProcesses => Duration::from_secs(10), StaticProviderSlot::NativeProcesses => Duration::from_secs(10), StaticProviderSlot::ProjectNpm => Duration::from_secs(60), @@ -113,9 +117,25 @@ pub(crate) async fn collect_provider_slot_bounded( pub(crate) fn runtime_map_from_collection( snapshot: &DockerSnapshot, collection: &ProviderCollection, + docker_observation_revision: &str, + mode: &RuntimeMode, ) -> RuntimeMap { let (nodes, edges, diagnostics) = collection.clone().into_parts(); - let mut runtime_map = derive_runtime_map(snapshot, nodes, edges, diagnostics); + let mut runtime_map = derive_runtime_map( + snapshot, + nodes, + edges, + diagnostics, + docker_observation_revision, + ); + // `mock_snapshot` intentionally preserves a representative topology, but + // it is not an observation from Docker. Never let derived Docker (or + // retained provider) evidence attest those sample nodes and edges. + if *mode != RuntimeMode::Docker { + for edge in &mut runtime_map.edges { + edge.evidence_refs.clear(); + } + } redact_runtime_map(&mut runtime_map); runtime_map } @@ -162,6 +182,28 @@ fn collect_provider_slot( }, ); } + StaticProviderSlot::Cron => { + collect_cron_runtime_provider(pid_namespace, &mut collection); + collection.set_state( + slot, + if pid_namespace.is_restricted() { + ProviderStateKind::Disabled + } else { + ProviderStateKind::Fresh + }, + ); + } + StaticProviderSlot::Systemd => { + collect_systemd_runtime_provider(pid_namespace, &mut collection); + collection.set_state( + slot, + if pid_namespace.is_restricted() { + ProviderStateKind::Disabled + } else { + ProviderStateKind::Fresh + }, + ); + } StaticProviderSlot::PythonProcesses => { let (nodes, _, diagnostics) = collection.parts_mut(); collect_python_processes(pid_namespace.is_restricted(), nodes, diagnostics); @@ -228,7 +270,7 @@ fn collect_host_node(project_root: Option<&StdPath>, nodes: &mut Vec String { std::env::var("HOSTNAME") .ok() @@ -319,8 +390,6 @@ mod tests { assert!(edges.is_empty()); for provider in [ RuntimeProviderKind::Network, - RuntimeProviderKind::Systemd, - RuntimeProviderKind::ScheduledJob, RuntimeProviderKind::Pm2, RuntimeProviderKind::Tmux, ] { @@ -330,6 +399,44 @@ mod tests { } } + #[test] + fn restricted_namespace_keeps_cron_as_a_distinct_disabled_slot() { + let mut cron = ProviderCollection::default(); + collect_cron_runtime_provider(PidNamespaceScope::Restricted, &mut cron); + cron.set_state(StaticProviderSlot::Cron, ProviderStateKind::Disabled); + assert!(cron.states().iter().any(|state| { + state.slot == StaticProviderSlot::Cron && state.state == ProviderStateKind::Disabled + })); + let (nodes, edges, diagnostics) = cron.into_parts(); + assert!(nodes.is_empty() && edges.is_empty()); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.provider == RuntimeProviderKind::ScheduledJob + && diagnostic.message.contains("restricted PID namespace") + })); + } + + #[test] + fn restricted_namespace_keeps_systemd_as_a_distinct_disabled_slot() { + let mut host = ProviderCollection::default(); + collect_host_scoped_runtime_providers(PidNamespaceScope::Restricted, &mut host); + let (_, _, host_diagnostics) = host.into_parts(); + assert!(host_diagnostics + .iter() + .all(|diagnostic| diagnostic.provider != RuntimeProviderKind::Systemd)); + + let mut systemd = ProviderCollection::default(); + collect_systemd_runtime_provider(PidNamespaceScope::Restricted, &mut systemd); + systemd.set_state(StaticProviderSlot::Systemd, ProviderStateKind::Disabled); + assert!(systemd.states().iter().any(|state| { + state.slot == StaticProviderSlot::Systemd && state.state == ProviderStateKind::Disabled + })); + let (_, _, systemd_diagnostics) = systemd.into_parts(); + assert!(systemd_diagnostics.iter().any(|diagnostic| { + diagnostic.provider == RuntimeProviderKind::Systemd + && diagnostic.message.contains("restricted PID namespace") + })); + } + #[test] fn runtime_collection_guard_prevents_two_rapid_refreshes() { let in_flight = Arc::new(AtomicBool::new(false)); @@ -353,6 +460,8 @@ mod tests { [ StaticProviderSlot::NetworkInfrastructure, StaticProviderSlot::HostScoped, + StaticProviderSlot::Cron, + StaticProviderSlot::Systemd, StaticProviderSlot::PythonProcesses, StaticProviderSlot::NativeProcesses, StaticProviderSlot::ProjectNpm, diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index ae2f56c6..864ca38c 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -50,6 +50,118 @@ acceptance work are recorded in [`CONTRACT_AUTHORITY.md`](CONTRACT_AUTHORITY.md) `GET /daemon/runtime/map` is the backend's provider-neutral JSON graph for visualization. `apps/api` proxies it as `GET /api/runtime/map`. +### Relationship evidence lifecycle + +Each runtime edge has a required `evidenceRefs` array. The current Docker, +Systemd, and npm slices emit bounded, versioned records alongside the edge during +derivation; they are not reconstructed from labels in React: + +```text +collector -> bounded RuntimeEvidenceRef -> RuntimeMapEdge -> daemon publication/redaction -> API contract validation -> Runtime inspector +``` + +Version one facts are Docker network membership, volume attachment, actual +nonzero host-port publication, Docker-recorded Compose start-order declarations, +and a fixed Docker-daemon-state bind-mount predicate. They are +`observed`, carry the Docker collection timestamp and an opaque Docker +observation revision token (deliberately neither a timestamp nor the cache +model revision), and declare `fresh` only for that Docker observation. +Container-only listeners remain useful topology, but do not receive port +publication evidence. A host binding fact is not an Internet-reachability, +health, traffic, or exploitability claim. The Docker-daemon-state fact is +path-free: it says only that a container matched the closed risk predicate, not +which mount path, mount ID, or mount options produced that result. + +Version two adds only Systemd `Requires`, `Wants`, and `PartOf` declarations. +Each fact is `declared`, is tied to the independently scheduled `systemd` +slot's opaque data revision and last successful collection timestamp, and can +be `fresh`, retained `stale`, or `timed_out`. It never claims successful start, +readiness, health, traffic, inverse dependency, or symmetric membership. +Restricted PID mode emits no Systemd edge evidence. An empty array is explicit +migration state for a relationship family that has not yet gained provenance; +it must not be silently presented as an observed fact. + +Version three adds npm `package.json` dependency declarations. Each fact is +`declared`, is tied to the bounded `project_npm` slot's opaque data revision and +last successful collection timestamp, and can be `fresh`, retained `stale`, or +`timed_out`. It attests only that the bounded manifest scan declared the +project-to-package dependency: it is not proof that a package was installed, +resolved, executed, healthy, safe, or used at runtime. The evidence contains a +curated summary rather than raw manifest content. + +Mock mode keeps representative topology available for UI and transport testing, +but it is not a Docker observation. In that mode every runtime edge has an +empty `evidenceRefs` array and no evidence-derived finding is published. A +Docker/mock source transition discards retained provider observations instead +of relabelling live evidence as sample data. + +The evidence representation is closed: provider, kind, assertion kind and +freshness are enums, and there is no free-form metadata/config/command-line +field. The daemon and browser publication boundaries redact display-hostile +or secret-like strings before response bytes reach the UI. Identity collisions +remain visible but non-routable; an edge inspector can still explain the +selected relationship without joining a collided target. + +Current relationship-source matrix: + +| Relationship family | Source | Assertion | Evidence status | +| --- | --- | --- | --- | +| Docker container -> network | Docker inventory membership | observed | emitted | +| Docker container -> volume | Docker volume attachment | observed | emitted | +| Docker container -> listener | Docker inventory port with a validated nonzero host binding | observed host publication, not reachability, health, or traffic evidence | emitted only for that host binding; container-only listeners remain topology without publication evidence | +| Docker container -> Docker container (`depends_on`) | Docker-recorded Compose start-order label | observed declaration, not health or traffic causality | emitted when both identities resolve uniquely | +| Docker container -> Docker daemon state risk target | Docker inventory bind mount matching the closed daemon-state predicate | observed path-free risk condition, not breach, compromise, reachability, or impact evidence | emitted only for a uniquely resolved matching container; no mount path, ID, or options are published | +| systemd service -> systemd service (`requires`, `wants`, `part_of`) | Systemd `Requires=`, `Wants=`, `PartOf=` declaration | declared relationship, not start/health/traffic evidence | emitted only with a valid dedicated Systemd-slot observation; retained facts state freshness explicitly | +| npm project -> npm package dependency | bounded `package.json` manifest discovery under the configured project root | declared dependency, not installation, resolution, execution, health, safety, or runtime-use evidence | emitted only with a valid `project_npm` slot observation; retained facts state `fresh`, `stale`, or `timed_out` explicitly; raw manifest content is not evidence | +| tmux, proxy, DNS, process and cross-provider edges | bounded provider-specific collector facts | varies | explicit empty migration array; no invented provenance | + +### Bounded findings + +`GET /daemon/findings` and its authenticated browser aliases expose only a +cached projection of the same published runtime-map revision. The closed +`systemd.requires_target_not_active` rule emits one warning only when +there is exactly one fresh, declared Systemd `Requires` edge from a uniquely +identified active service to a uniquely identified inactive or failed service. +It is a dependency configuration condition—not proof of a failed start, +readiness, traffic, service health, or security impact. Stale, timed-out, +ambiguous, duplicate, non-Systemd, `Wants`, and `PartOf` evidence produces no +finding. + +`docker.internal_network_member_publishes_port` emits an advisory only when +one uniquely identified Docker container has both one fresh, validated Docker +internal-network membership fact and one fresh Docker listener fact whose +already-sanitized port form proves a nonzero host-to-container publication. +Container-only ports, malformed or bind-address-like forms, stale evidence, +duplicate facts, and identity collisions produce no finding. This is not an +Internet-reachability, vulnerability, or security conclusion. + +`docker.daemon_state_bind_mount` emits a warning only when one uniquely +identified Docker container has exactly one fresh, path-free Docker fact bound +to the fixed Docker-daemon-state risk target. It means the recorded access may +provide Docker daemon API authority and should be reviewed; it does not prove a +breach, compromise, reachability, or impact. Mount paths, mount IDs, read-only +flags, and raw configuration never enter the runtime edge, finding, or browser +response. Missing, stale, duplicate, malformed, or collided facts produce no +finding. + +Each rule carries only its exact triggering evidence references. The API +validates the fixed vocabulary, static display text, and rule-specific evidence +shape before publication, and the browser displays findings only when their +nonempty model revision matches the current live model. + +#### Current finding policy + +`warning` is reserved for a fresh, directly recorded declaration whose current +service-state endpoints satisfy a closed, fail-closed condition, or for the +single path-free Docker-daemon-state fact whose recorded access may provide +Docker daemon API authority. Neither meaning proves a failed start, breach, +reachability, compromise, or impact. `advisory` is reserved for a fresh +combination of directly observed Docker facts that merits a configuration +review but does not establish exposure, reachability, vulnerability, or +impact. There is no critical severity in the current pack. New rules require +an explicit contract, fixed evidence budget, deterministic positive and +benign-negative fixtures, and a review of their exact conclusion language. + The map is organized around a unified service concept. Docker containers, systemd services, tmux sessions, npm applications, Python applications, and native processes should all expose the same operational shape wherever the provider can safely populate diff --git a/docs/architecture/PROVIDER_SCHEDULING_AND_MODEL_REVISIONS.md b/docs/architecture/PROVIDER_SCHEDULING_AND_MODEL_REVISIONS.md index b872ad7a..77b7cf92 100644 --- a/docs/architecture/PROVIDER_SCHEDULING_AND_MODEL_REVISIONS.md +++ b/docs/architecture/PROVIDER_SCHEDULING_AND_MODEL_REVISIONS.md @@ -9,8 +9,8 @@ proposal for a generic job framework. DockerMap keeps the two-second Docker-inventory refresh loop. Each cycle publishes the Docker snapshot immediately, then claims due fixed provider slots in the background. The private completion-relative policy is: network -infrastructure 10 seconds, host-scoped 15 seconds, Python processes 10 -seconds, native processes 10 seconds, and project npm 60 seconds. At most two +infrastructure 10 seconds, host-scoped 15 seconds, systemd 15 seconds, Python +processes 10 seconds, native processes 10 seconds, and project npm 60 seconds. At most two slots run at once. There are no user-configurable timers, persisted telemetry, conditional browser fetch policy, provider plugin, or policy DSL. @@ -19,10 +19,11 @@ The fixed runtime collection stages, in order, are: 1. Docker projection from the snapshot. 2. Network infrastructure (including its fixed opt-in and restricted-PID handling). -3. Host-scoped collectors (listeners, systemd, scheduled jobs, PM2, tmux). -4. Python process projection. -5. Native-process projection. -6. Bounded project-root npm discovery. +3. Host-scoped collectors (listeners, scheduled jobs, PM2, tmux). +4. systemd service declarations. +5. Python process projection. +6. Native-process projection. +7. Bounded project-root npm discovery. `STATIC_PROVIDER_SLOTS`, its fixed cadence table, and `STATIC_REFRESH_INTERVAL` are code-level implementation policy. They are not a scheduling API. Changing @@ -32,15 +33,15 @@ and cache coherence. ## Provider state vocabulary -`RuntimeMap.providerStates` is a schema-backed five-item evidence vector for -the fixed slots: `network_infrastructure`, `host_scoped`, +`RuntimeMap.providerStates` is a schema-backed six-item evidence vector for +the fixed slots: `network_infrastructure`, `host_scoped`, `systemd`, `python_processes`, `native_processes`, and `project_npm`. Each entry contains only its slot and one of `fresh`, `stale`, `collecting`, `unavailable`, `timed_out`, or `disabled`. It contains no provider command, path, raw error, diagnostic, secret, timestamp, or configurable policy. Diagnostics remain the human-readable, publication-sanitized explanation. -The schema enforces item shape and a five-item bound; the Node daemon-response +The schema enforces item shape and a six-item bound; the Node daemon-response boundary additionally rejects a vector unless every fixed slot appears exactly once. This is a closed, typed contract invariant rather than a configurable policy. @@ -144,22 +145,25 @@ per-slot runtime budget and fixed process/filesystem bounds remain unchanged. For a 60-second healthy interval, the old static pass invoked every slot 31 times. The fixed policy has deterministic maximum attempts of 7 network, 5 -host-scoped, 7 Python, 7 native, and 2 npm attempts (initial attempt included). +host-scoped, 5 systemd, 7 Python, 7 native, and 2 npm attempts (initial attempt included). This is a timing/cost comparison only: completed observations still retain their existing source, redaction, profile, and stale-state semantics. ## Deterministic large-host evidence The daemon unit suite drives the fixed policy through a 0–60 second virtual -healthy trace with synthetic immediate completions. It records 7, 5, 7, 7, -and 2 provider execution opportunities respectively (28 total), never more -than two concurrently admitted slots. Its explicit former-policy baseline is -31 two-second passes multiplied by five slots: 155 opportunities. This is an -execution-opportunity comparison, not a claim about CPU consumption, +healthy trace with synthetic immediate completions. It records 7 network, 5 +host-scoped, 5 systemd, 7 Python, 7 native, and 2 provider execution +opportunities (33 actual slot claims), never more than two concurrently +admitted slots. Its explicit former-policy baseline is 31 two-second aggregate +passes multiplied by the five bundles that existed before Systemd became +independent: 155 opportunities. The split makes claim count a different unit +from the former aggregate pass; fixed command churn is the more meaningful +comparison. This is not a claim about CPU consumption, subprocess creation, or wall-clock work on a particular host. The same deterministic suite publishes generated 500-container Docker -snapshots at all 31 two-second positions, verifies the five-slot runtime +snapshots at all 31 two-second positions, verifies the six-slot runtime vector remains coherent and renderable, and proves that this larger inventory does not increase host-provider opportunities. A separate occupied-guard trace retains both timeout and stale evidence while later Docker snapshots @@ -181,7 +185,7 @@ so a Cargo/shell/CI parent cannot forge entry by supplying a token-shaped file and environment values. The child drives the actual fixed slot collectors through the production scheduler's virtual 0–60 second claims, using immediate test completions. -The full-host trace proves 28 scheduler starts and 48 actual child commands +The full-host trace proves 33 scheduler starts and 48 actual child commands (versus the former 155 starts and 248 commands for 31 whole passes). The restricted profile proves zero host command stubs execute and that the host-scoped, Python, and native slots become terminal `disabled` states after @@ -202,12 +206,12 @@ revision monotonicity/stability and sanitized evidence comparison, restricted-PID omission behavior, no-overlap guard, fresh Docker publication with retained stale provider observations, timeout degradation, and source-transition isolation. Generated schema/API tests require non-empty -revisions and a five-item `providerStates` vector; web hook regressions retain +revisions and a six-item `providerStates` vector; web hook regressions retain the current fetch cadence while refusing generation-, provenance-, or revision-mismatched model pairs. The scheduler evidence additionally exercises the full 60-second fixed-policy -opportunity trace, the explicit 155-opportunity legacy baseline, 31 +claim trace, the explicit 155-aggregate-pass legacy baseline, 31 publications of a generated 500-container snapshot, inventory-independent provider starts, and occupied timeout/stale slots while Docker publication continues. diff --git a/docs/security/THREAT_MODEL.md b/docs/security/THREAT_MODEL.md index 55cd0635..f3c9cb44 100644 --- a/docs/security/THREAT_MODEL.md +++ b/docs/security/THREAT_MODEL.md @@ -41,6 +41,21 @@ changes them: documented before release. - Package, service, process, unit, and proxy inspection must not leak secrets from env vars, command lines, service files, credentials, or inline auth URLs. +- Runtime relationship evidence is a separate publication surface. It is a closed, bounded + record with no generic metadata/config/argv field; evidence values pass the same + redaction and control-character publication boundary as all other daemon response text. + A malformed evidence record is rejected at the API schema boundary rather than + partially published. +- Mock fallback may show representative topology, but never attests it as Docker or retained + host-provider evidence: every runtime edge has an empty evidence array and evidence-derived + findings are absent. A live/mock source change drops retained observations rather than + relabelling them as mock data. +- Docker port-publication evidence is emitted only for a validated nonzero host binding. + A container-only listener stays visible as topology without publication evidence, and a + publication fact does not establish reachability, health, traffic, or exploitability. +- Docker daemon-state bind-mount evidence is a single closed, path-free fact. It never exposes + a mount path, mount ID, mount options, or raw Docker configuration. npm dependency evidence + is likewise a bounded declaration with a curated summary, never raw `package.json` content. ## Main Risks And Protections @@ -144,6 +159,12 @@ Automated tests currently cover: - Symlink bind-source detection without following the symlink during validation. - Provider redaction fixtures for systemd, tmux, npm/package metadata, native-process-shaped output, reverse-proxy markers, DNS markers, provider diagnostics, and provider edge metadata. +- Runtime-edge evidence schema rejection and publication redaction, including malformed + provenance fields and secret/control-character-bearing evidence summaries or references. +- Evidence-source boundaries: forced mock and live-to-mock reset publish topology without + runtime evidence or evidence-derived findings; private container ports have no host-publication + evidence; daemon-state facts remain path-free; and Systemd/npm declarations retain only their + closed slot revision, timestamp, and freshness vocabulary. - GUI smoke coverage against daemon fallback mode. - Route and middleware completeness: every Express layer must be wrapped in `trackedMiddleware()` and every route registered through `registerRoute()` with diff --git a/docs/testing/TESTING_PLAN.md b/docs/testing/TESTING_PLAN.md index d9d62dbc..c481565d 100644 --- a/docs/testing/TESTING_PLAN.md +++ b/docs/testing/TESTING_PLAN.md @@ -12,6 +12,11 @@ containers, or services. - Rust formatting and linting. - Rust unit tests for the core Docker and Compose model plus daemon helpers. - Runtime-map contracts for Docker and non-Docker provider signals. +- Runtime-evidence source boundaries: forced mock mode and a live-to-mock reset retain sample + topology while clearing evidence and evidence-derived findings; Docker publication evidence + requires a bounded nonzero host binding rather than a container-only listener; Docker + daemon-state evidence is path-free; and Systemd/npm declaration evidence is checked against + its dedicated scheduler-slot revision and `fresh`/`stale`/`timed_out` lifecycle. - Rust-owned JSON Schema and generated TypeScript declarations, Node-owned envelope/request/SSE schemas, and readable contract fixtures. The contract check fails on stale generated output, invalid fixtures, incomplete @@ -124,6 +129,12 @@ markers, provider diagnostics, and provider edge metadata. These fixtures delibe `DOCKERMAP_TEST_FAKE_*` sentinels and assert the returned runtime/provider JSON omits those raw values. +The runtime-evidence regressions are fixture-first and do not require a Docker +daemon, systemd, npm registry, or a real project manifest. They verify the +closed evidence vocabulary and source/freshness behavior without treating +topology as proof of readiness, health, traffic, causality, package +installation, or network reachability. + Python and native-process collectors are implemented (shipped via #32/#38/#39 + #33) with fixture-first tests for fake `/proc` trees, optional fixed `ps` output, Python manifests, skipped directories, cap diagnostics, and redaction sentinels. These tests prove that env diff --git a/package-lock.json b/package-lock.json index 1332cf00..0fd26ab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,342 +26,17 @@ "@dockermap/contracts": "0.1.0", "ajv": "^8.20.0", "cors": "^2.8.5", - "express": "^4.21.2", + "express": "^5.2.1", "helmet": "^8.2.0" }, "devDependencies": { "@seriousme/openapi-schema-validator": "^2.9.1", "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", + "@types/express": "^5.0.6", "tsx": "^4.20.5", "typescript": "^5.9.2" } }, - "apps/api/node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "apps/api/node_modules/@types/express-serve-static-core": { - "version": "4.19.9", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", - "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "apps/api/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "apps/api/node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "apps/api/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "apps/api/node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "apps/api/node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "apps/api/node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "apps/api/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "apps/api/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "apps/api/node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "apps/api/node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "apps/api/node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "apps/api/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "apps/api/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "apps/api/node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "apps/api/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "apps/api/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "apps/api/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "apps/api/node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "apps/api/node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "apps/api/node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "apps/api/node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "apps/api/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, "apps/web": { "name": "@dockermap/web", "version": "0.1.0", @@ -1985,6 +1660,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -2006,13 +1706,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -2024,9 +1717,9 @@ } }, "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -2057,6 +1750,27 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2191,6 +1905,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -2247,12 +1974,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2296,6 +2017,43 @@ "require-from-string": "^2.0.2" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -2399,6 +2157,19 @@ "node": ">=18" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -2424,6 +2195,15 @@ "node": ">= 0.6" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -2495,7 +2275,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2525,16 +2304,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2620,9 +2389,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2718,6 +2487,49 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2758,6 +2570,27 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2767,6 +2600,15 @@ "node": ">= 0.6" } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2863,9 +2705,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2919,6 +2761,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2964,6 +2822,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3405,25 +3269,54 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", - "bin": { - "mime": "cli.js" + "dependencies": { + "mime-db": "^1.54.0" }, "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/minimist": { @@ -3461,6 +3354,35 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -3518,6 +3440,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -3540,6 +3471,16 @@ "node": ">= 0.8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3683,12 +3624,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -3706,6 +3648,21 @@ "node": ">= 0.6" } }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3787,25 +3744,21 @@ "dev": true, "license": "MIT" }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -3842,6 +3795,51 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -3855,14 +3853,14 @@ "license": "ISC" }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -3874,13 +3872,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -4099,6 +4097,37 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4170,15 +4199,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -4421,6 +4441,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/packages/contracts/generated/rust/findings-response.schema.json b/packages/contracts/generated/rust/findings-response.schema.json new file mode 100644 index 00000000..6c594816 --- /dev/null +++ b/packages/contracts/generated/rust/findings-response.schema.json @@ -0,0 +1,268 @@ +{ + "$defs": { + "Finding": { + "additionalProperties": false, + "properties": { + "evidenceRefs": { + "description": "Canonical, already-sanitized runtime evidence that directly triggered\nthis finding. Each closed rule has a fixed, small evidence budget,\npreventing this response from becoming a generic metadata channel.", + "items": { + "$ref": "#/$defs/RuntimeEvidenceRef" + }, + "maxItems": 2, + "minItems": 1, + "type": "array" + }, + "id": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "recommendation": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "ruleId": { + "$ref": "#/$defs/FindingRule" + }, + "severity": { + "$ref": "#/$defs/FindingSeverity" + }, + "subjectRef": { + "type": "string" + }, + "summary": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "targetRef": { + "type": "string" + } + }, + "required": [ + "id", + "ruleId", + "severity", + "summary", + "recommendation", + "subjectRef", + "targetRef", + "evidenceRefs" + ], + "type": "object" + }, + "FindingRule": { + "description": "Closed rule identifiers keep clients from treating findings as arbitrary\nprovider messages. New rules require an explicit contract addition.", + "enum": [ + "systemd.requires_target_not_active", + "docker.internal_network_member_publishes_port", + "docker.daemon_state_bind_mount" + ], + "type": "string" + }, + "FindingSeverity": { + "description": "Findings are intentionally a small, closed advisory vocabulary. They do\nnot expose provider output or prescribe an automated remediation.", + "enum": [ + "warning", + "advisory" + ], + "type": "string" + }, + "ProviderSlot": { + "description": "Fixed, schema-backed host-provider slots. This is not a plugin or policy\ninterface: the daemon owns the complete finite list.", + "oneOf": [ + { + "enum": [ + "network_infrastructure", + "host_scoped", + "python_processes", + "native_processes", + "project_npm" + ], + "type": "string" + }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, + { + "const": "systemd", + "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", + "type": "string" + } + ] + }, + "RuntimeEvidenceAssertionKind": { + "description": "Evidence assertion semantics are deliberately closed. A declaration says\nwhat a source configured, never that its target is healthy or was invoked.", + "enum": [ + "observed", + "declared" + ], + "type": "string" + }, + "RuntimeEvidenceFreshness": { + "enum": [ + "fresh", + "stale", + "timed_out" + ], + "type": "string" + }, + "RuntimeEvidenceKind": { + "description": "Safe, provider-specific fact families supported by the first provenance\nslice. New sources require an explicit enum addition rather than an\narbitrary source string or metadata map.", + "oneOf": [ + { + "enum": [ + "docker_network_membership", + "docker_volume_mount", + "docker_port_publication" + ], + "type": "string" + }, + { + "const": "docker_compose_depends_on", + "description": "Docker's recorded Compose dependency declaration. This is deliberately\nnot a health, readiness, or traffic-causality claim.", + "type": "string" + }, + { + "const": "docker_daemon_state_bind_mount", + "description": "A bind mount that matches the closed Docker socket/data-root predicate.\nThe public evidence and target intentionally omit the mount path.", + "type": "string" + }, + { + "const": "systemd_requires", + "description": "A systemd `Requires=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" + }, + { + "const": "systemd_wants", + "description": "A systemd `Wants=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" + }, + { + "const": "systemd_part_of", + "description": "A systemd `PartOf=` declaration. It is not an ordering assertion.", + "type": "string" + }, + { + "const": "npm_package_manifest_dependency", + "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", + "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" + } + ] + }, + "RuntimeEvidenceProvider": { + "description": "Evidence providers are deliberately closed. Version two adds systemd only\nafter it received its own scheduler slot; it cannot inherit a broader host\ncollection's freshness or revision.", + "enum": [ + "docker", + "systemd", + "npm", + "cron" + ], + "type": "string" + }, + "RuntimeEvidenceRef": { + "additionalProperties": false, + "description": "A compact, versioned reference to the bounded fact supporting a runtime\nrelationship. It intentionally contains no raw command output, config\nfragment, path, process arguments, or generic metadata bag.", + "properties": { + "assertionKind": { + "$ref": "#/$defs/RuntimeEvidenceAssertionKind" + }, + "collectedAt": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "freshness": { + "$ref": "#/$defs/RuntimeEvidenceFreshness" + }, + "id": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "kind": { + "$ref": "#/$defs/RuntimeEvidenceKind" + }, + "provider": { + "$ref": "#/$defs/RuntimeEvidenceProvider" + }, + "providerRevision": { + "description": "Opaque provider observation token, not a cache-publication revision,\ncommand output, or source dump.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "providerSlot": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderSlot" + }, + { + "type": "null" + } + ], + "description": "Version-two-and-later provider evidence is explicitly tied to the\nfinite scheduler slot that supplied its revision and freshness.\nVersion-one Docker evidence intentionally has no host-provider slot." + }, + "subjectRef": { + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", + "type": "string" + }, + "summary": { + "description": "A bounded, curated explanation; it is never copied from a raw source.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", + "format": "uint8", + "maximum": 4, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "id", + "provider", + "kind", + "assertionKind", + "summary", + "subjectRef", + "collectedAt", + "providerRevision", + "freshness" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "findings": { + "items": { + "$ref": "#/$defs/Finding" + }, + "type": "array" + }, + "modelRevision": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "findings", + "modelRevision" + ], + "title": "FindingsResponse", + "type": "object" +} diff --git a/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index bab494df..d1f06993 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -11,14 +11,28 @@ }, "ProviderSlot": { "description": "Fixed, schema-backed host-provider slots. This is not a plugin or policy\ninterface: the daemon owns the complete finite list.", - "enum": [ - "network_infrastructure", - "host_scoped", - "python_processes", - "native_processes", - "project_npm" - ], - "type": "string" + "oneOf": [ + { + "enum": [ + "network_infrastructure", + "host_scoped", + "python_processes", + "native_processes", + "project_npm" + ], + "type": "string" + }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, + { + "const": "systemd", + "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", + "type": "string" + } + ] }, "ProviderState": { "additionalProperties": false, @@ -159,6 +173,156 @@ ], "type": "object" }, + "RuntimeEvidenceAssertionKind": { + "description": "Evidence assertion semantics are deliberately closed. A declaration says\nwhat a source configured, never that its target is healthy or was invoked.", + "enum": [ + "observed", + "declared" + ], + "type": "string" + }, + "RuntimeEvidenceFreshness": { + "enum": [ + "fresh", + "stale", + "timed_out" + ], + "type": "string" + }, + "RuntimeEvidenceKind": { + "description": "Safe, provider-specific fact families supported by the first provenance\nslice. New sources require an explicit enum addition rather than an\narbitrary source string or metadata map.", + "oneOf": [ + { + "enum": [ + "docker_network_membership", + "docker_volume_mount", + "docker_port_publication" + ], + "type": "string" + }, + { + "const": "docker_compose_depends_on", + "description": "Docker's recorded Compose dependency declaration. This is deliberately\nnot a health, readiness, or traffic-causality claim.", + "type": "string" + }, + { + "const": "docker_daemon_state_bind_mount", + "description": "A bind mount that matches the closed Docker socket/data-root predicate.\nThe public evidence and target intentionally omit the mount path.", + "type": "string" + }, + { + "const": "systemd_requires", + "description": "A systemd `Requires=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" + }, + { + "const": "systemd_wants", + "description": "A systemd `Wants=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" + }, + { + "const": "systemd_part_of", + "description": "A systemd `PartOf=` declaration. It is not an ordering assertion.", + "type": "string" + }, + { + "const": "npm_package_manifest_dependency", + "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", + "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" + } + ] + }, + "RuntimeEvidenceProvider": { + "description": "Evidence providers are deliberately closed. Version two adds systemd only\nafter it received its own scheduler slot; it cannot inherit a broader host\ncollection's freshness or revision.", + "enum": [ + "docker", + "systemd", + "npm", + "cron" + ], + "type": "string" + }, + "RuntimeEvidenceRef": { + "additionalProperties": false, + "description": "A compact, versioned reference to the bounded fact supporting a runtime\nrelationship. It intentionally contains no raw command output, config\nfragment, path, process arguments, or generic metadata bag.", + "properties": { + "assertionKind": { + "$ref": "#/$defs/RuntimeEvidenceAssertionKind" + }, + "collectedAt": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "freshness": { + "$ref": "#/$defs/RuntimeEvidenceFreshness" + }, + "id": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "kind": { + "$ref": "#/$defs/RuntimeEvidenceKind" + }, + "provider": { + "$ref": "#/$defs/RuntimeEvidenceProvider" + }, + "providerRevision": { + "description": "Opaque provider observation token, not a cache-publication revision,\ncommand output, or source dump.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "providerSlot": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderSlot" + }, + { + "type": "null" + } + ], + "description": "Version-two-and-later provider evidence is explicitly tied to the\nfinite scheduler slot that supplied its revision and freshness.\nVersion-one Docker evidence intentionally has no host-provider slot." + }, + "subjectRef": { + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", + "type": "string" + }, + "summary": { + "description": "A bounded, curated explanation; it is never copied from a raw source.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", + "format": "uint8", + "maximum": 4, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "id", + "provider", + "kind", + "assertionKind", + "summary", + "subjectRef", + "collectedAt", + "providerRevision", + "freshness" + ], + "type": "object" + }, "RuntimeHealth": { "additionalProperties": false, "properties": { @@ -293,6 +457,14 @@ "RuntimeMapEdge": { "additionalProperties": false, "properties": { + "evidenceRefs": { + "description": "Empty for relationship families that have not yet been migrated to the\nevidence model. It remains present on the wire so API/UI consumers have\none stable, bounded relationship shape while the migration continues.", + "items": { + "$ref": "#/$defs/RuntimeEvidenceRef" + }, + "maxItems": 8, + "type": "array" + }, "metadata": { "additionalProperties": { "type": "string" @@ -313,7 +485,8 @@ "source", "target", "relationship", - "metadata" + "metadata", + "evidenceRefs" ], "type": "object" }, @@ -397,6 +570,7 @@ "docker_network", "docker_volume", "host", + "host_risk", "service", "systemd_service", "scheduled_job", @@ -664,6 +838,7 @@ "mounts", "manages", "exposes", + "exposes_daemon_state", "runs_on", "uses", "calls", @@ -802,8 +977,8 @@ "items": { "$ref": "#/$defs/ProviderState" }, - "maxItems": 5, - "minItems": 5, + "maxItems": 7, + "minItems": 7, "type": "array" }, "source": { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 572c6dea..0e4593e6 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -18,6 +18,10 @@ export type { ContainerRecord, ContainersResponse, DockerSnapshot, + Finding, + FindingRule, + FindingSeverity, + FindingsResponse, GraphEdge, GraphNode, GraphResponse, @@ -35,6 +39,10 @@ export type { NodeKind, RelationshipKind, RuntimeAdvisorySeverity, + RuntimeEvidenceAssertionKind, + RuntimeEvidenceKind, + RuntimeEvidenceProvider, + RuntimeEvidenceRef, RuntimeEventRef, RuntimeHealth, RuntimeHealthState, diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index 3cd039ff..4daf1506 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -14,7 +14,8 @@ export type RustDaemonModels = | ContainerRecord | ImagesResponse | NetworksResponse - | VolumesResponse; + | VolumesResponse + | FindingsResponse; export type ComposeMountKind = 'bind' | 'named_volume' | 'anonymous_volume' | 'unsupported'; export type RuntimeMode = 'docker' | 'mock'; export type RelationshipKind = 'connected_to' | 'mounts'; @@ -42,6 +43,40 @@ export type RuntimeProviderKind = | 'kubernetes' | 'other'; export type DiagnosticSeverity = 'info' | 'warning' | 'error' | 'blocked'; +/** + * Evidence assertion semantics are deliberately closed. A declaration says + * what a source configured, never that its target is healthy or was invoked. + */ +export type RuntimeEvidenceAssertionKind = 'observed' | 'declared'; +export type RuntimeEvidenceFreshness = 'fresh' | 'stale' | 'timed_out'; +/** + * Safe, provider-specific fact families supported by the first provenance + * slice. New sources require an explicit enum addition rather than an + * arbitrary source string or metadata map. + */ +export type RuntimeEvidenceKind = + | ('docker_network_membership' | 'docker_volume_mount' | 'docker_port_publication') + | 'docker_compose_depends_on' + | 'docker_daemon_state_bind_mount' + | 'systemd_requires' + | 'systemd_wants' + | 'systemd_part_of' + | 'npm_package_manifest_dependency' + | 'cron_schedule_declaration'; +/** + * Evidence providers are deliberately closed. Version two adds systemd only + * after it received its own scheduler slot; it cannot inherit a broader host + * collection's freshness or revision. + */ +export type RuntimeEvidenceProvider = 'docker' | 'systemd' | 'npm' | 'cron'; +/** + * Fixed, schema-backed host-provider slots. This is not a plugin or policy + * interface: the daemon owns the complete finite list. + */ +export type ProviderSlot = + | ('network_infrastructure' | 'host_scoped' | 'python_processes' | 'native_processes' | 'project_npm') + | 'cron' + | 'systemd'; export type RuntimeRelationshipKind = | 'connected_to' | 'depends_on' @@ -56,6 +91,7 @@ export type RuntimeRelationshipKind = | 'mounts' | 'manages' | 'exposes' + | 'exposes_daemon_state' | 'runs_on' | 'uses' | 'calls' @@ -78,6 +114,7 @@ export type RuntimeNodeKind = | 'docker_network' | 'docker_volume' | 'host' + | 'host_risk' | 'service' | 'systemd_service' | 'scheduled_job' @@ -99,12 +136,6 @@ export type RuntimeNodeKind = | 'process' | 'network_listener' | 'orchestrator_workload'; -/** - * Fixed, schema-backed host-provider slots. This is not a plugin or policy - * interface: the daemon owns the complete finite list. - */ -export type ProviderSlot = - 'network_infrastructure' | 'host_scoped' | 'python_processes' | 'native_processes' | 'project_npm'; export type ProviderStateKind = 'fresh' | 'stale' | 'collecting' | 'unavailable' | 'timed_out' | 'disabled'; /** * A deliberately small, non-diagnostic explanation for a provider slot that @@ -119,6 +150,19 @@ export type ComposeRelationshipKind = 'declares_mount' | 'mounted_at'; export type ComposeNodeKind = 'service' | 'host_path' | 'container_path' | 'named_volume' | 'anonymous_volume'; export type LogLevel = 'info' | 'warn' | 'error'; export type HealthState = 'ok' | 'degraded'; +/** + * Closed rule identifiers keep clients from treating findings as arbitrary + * provider messages. New rules require an explicit contract addition. + */ +export type FindingRule = + | 'systemd.requires_target_not_active' + | 'docker.internal_network_member_publishes_port' + | 'docker.daemon_state_bind_mount'; +/** + * Findings are intentionally a small, closed advisory vocabulary. They do + * not expose provider output or prescribe an automated remediation. + */ +export type FindingSeverity = 'warning' | 'advisory'; export interface DockerSnapshot { containers: ContainerRecord[]; @@ -196,10 +240,18 @@ export interface RuntimeMap { modelRevision: string; nodes: RuntimeMapNode[]; /** - * @minItems 5 - * @maxItems 5 + * @minItems 7 + * @maxItems 7 */ - providerStates: [ProviderState, ProviderState, ProviderState, ProviderState, ProviderState]; + providerStates: [ + ProviderState, + ProviderState, + ProviderState, + ProviderState, + ProviderState, + ProviderState, + ProviderState + ]; /** * ACTUAL source of these bytes: "docker" or "mock" (#85 A3). Stamped by * the daemon route layer from the cache's runtime mode. @@ -212,6 +264,47 @@ export interface RuntimeMapDiagnostic { severity: DiagnosticSeverity; } export interface RuntimeMapEdge { + /** + * Empty for relationship families that have not yet been migrated to the + * evidence model. It remains present on the wire so API/UI consumers have + * one stable, bounded relationship shape while the migration continues. + * + * @maxItems 8 + */ + evidenceRefs: + | [] + | [RuntimeEvidenceRef] + | [RuntimeEvidenceRef, RuntimeEvidenceRef] + | [RuntimeEvidenceRef, RuntimeEvidenceRef, RuntimeEvidenceRef] + | [RuntimeEvidenceRef, RuntimeEvidenceRef, RuntimeEvidenceRef, RuntimeEvidenceRef] + | [RuntimeEvidenceRef, RuntimeEvidenceRef, RuntimeEvidenceRef, RuntimeEvidenceRef, RuntimeEvidenceRef] + | [ + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef + ] + | [ + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef + ] + | [ + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef, + RuntimeEvidenceRef + ]; metadata: { [k: string]: string; }; @@ -219,6 +312,44 @@ export interface RuntimeMapEdge { source: string; target: string; } +/** + * A compact, versioned reference to the bounded fact supporting a runtime + * relationship. It intentionally contains no raw command output, config + * fragment, path, process arguments, or generic metadata bag. + */ +export interface RuntimeEvidenceRef { + assertionKind: RuntimeEvidenceAssertionKind; + collectedAt: number; + freshness: RuntimeEvidenceFreshness; + id: string; + kind: RuntimeEvidenceKind; + provider: RuntimeEvidenceProvider; + /** + * Opaque provider observation token, not a cache-publication revision, + * command output, or source dump. + */ + providerRevision: string; + /** + * Version-two-and-later provider evidence is explicitly tied to the + * finite scheduler slot that supplied its revision and freshness. + * Version-one Docker evidence intentionally has no host-provider slot. + */ + providerSlot?: ProviderSlot | null; + /** + * The already-public runtime entity directly attested by this bounded + * provider fact. + */ + subjectRef: string; + /** + * A bounded, curated explanation; it is never copied from a raw source. + */ + summary: string; + /** + * Version of this closed evidence representation, not a provider API + * version. It lets future additions remain explicit and reviewable. + */ + version: number; +} export interface RuntimeMapNode { id: string; label: string; @@ -460,6 +591,28 @@ export interface NetworksResponse { export interface VolumesResponse { volumes: VolumeRecord[]; } +export interface FindingsResponse { + findings: Finding[]; + modelRevision: string; +} +export interface Finding { + /** + * Canonical, already-sanitized runtime evidence that directly triggered + * this finding. Each closed rule has a fixed, small evidence budget, + * preventing this response from becoming a generic metadata channel. + * + * @minItems 1 + * @maxItems 2 + */ + evidenceRefs: [RuntimeEvidenceRef] | [RuntimeEvidenceRef, RuntimeEvidenceRef]; + id: string; + recommendation: string; + ruleId: FindingRule; + severity: FindingSeverity; + subjectRef: string; + summary: string; + targetRef: string; +} // Rust's transparent route wrapper serializes as the record itself. export type ContainerDetailResponse = ContainerRecord; diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index 093f899b..0eac7075 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -339,14 +339,28 @@ export const RUST_RESPONSE_SCHEMAS = { }, "ProviderSlot": { "description": "Fixed, schema-backed host-provider slots. This is not a plugin or policy\ninterface: the daemon owns the complete finite list.", - "enum": [ - "network_infrastructure", - "host_scoped", - "python_processes", - "native_processes", - "project_npm" - ], - "type": "string" + "oneOf": [ + { + "enum": [ + "network_infrastructure", + "host_scoped", + "python_processes", + "native_processes", + "project_npm" + ], + "type": "string" + }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, + { + "const": "systemd", + "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", + "type": "string" + } + ] }, "ProviderState": { "additionalProperties": false, @@ -487,6 +501,156 @@ export const RUST_RESPONSE_SCHEMAS = { ], "type": "object" }, + "RuntimeEvidenceAssertionKind": { + "description": "Evidence assertion semantics are deliberately closed. A declaration says\nwhat a source configured, never that its target is healthy or was invoked.", + "enum": [ + "observed", + "declared" + ], + "type": "string" + }, + "RuntimeEvidenceFreshness": { + "enum": [ + "fresh", + "stale", + "timed_out" + ], + "type": "string" + }, + "RuntimeEvidenceKind": { + "description": "Safe, provider-specific fact families supported by the first provenance\nslice. New sources require an explicit enum addition rather than an\narbitrary source string or metadata map.", + "oneOf": [ + { + "enum": [ + "docker_network_membership", + "docker_volume_mount", + "docker_port_publication" + ], + "type": "string" + }, + { + "const": "docker_compose_depends_on", + "description": "Docker's recorded Compose dependency declaration. This is deliberately\nnot a health, readiness, or traffic-causality claim.", + "type": "string" + }, + { + "const": "docker_daemon_state_bind_mount", + "description": "A bind mount that matches the closed Docker socket/data-root predicate.\nThe public evidence and target intentionally omit the mount path.", + "type": "string" + }, + { + "const": "systemd_requires", + "description": "A systemd `Requires=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" + }, + { + "const": "systemd_wants", + "description": "A systemd `Wants=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" + }, + { + "const": "systemd_part_of", + "description": "A systemd `PartOf=` declaration. It is not an ordering assertion.", + "type": "string" + }, + { + "const": "npm_package_manifest_dependency", + "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", + "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" + } + ] + }, + "RuntimeEvidenceProvider": { + "description": "Evidence providers are deliberately closed. Version two adds systemd only\nafter it received its own scheduler slot; it cannot inherit a broader host\ncollection's freshness or revision.", + "enum": [ + "docker", + "systemd", + "npm", + "cron" + ], + "type": "string" + }, + "RuntimeEvidenceRef": { + "additionalProperties": false, + "description": "A compact, versioned reference to the bounded fact supporting a runtime\nrelationship. It intentionally contains no raw command output, config\nfragment, path, process arguments, or generic metadata bag.", + "properties": { + "assertionKind": { + "$ref": "#/$defs/RuntimeEvidenceAssertionKind" + }, + "collectedAt": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "freshness": { + "$ref": "#/$defs/RuntimeEvidenceFreshness" + }, + "id": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "kind": { + "$ref": "#/$defs/RuntimeEvidenceKind" + }, + "provider": { + "$ref": "#/$defs/RuntimeEvidenceProvider" + }, + "providerRevision": { + "description": "Opaque provider observation token, not a cache-publication revision,\ncommand output, or source dump.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "providerSlot": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderSlot" + }, + { + "type": "null" + } + ], + "description": "Version-two-and-later provider evidence is explicitly tied to the\nfinite scheduler slot that supplied its revision and freshness.\nVersion-one Docker evidence intentionally has no host-provider slot." + }, + "subjectRef": { + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", + "type": "string" + }, + "summary": { + "description": "A bounded, curated explanation; it is never copied from a raw source.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", + "format": "uint8", + "maximum": 4, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "id", + "provider", + "kind", + "assertionKind", + "summary", + "subjectRef", + "collectedAt", + "providerRevision", + "freshness" + ], + "type": "object" + }, "RuntimeHealth": { "additionalProperties": false, "properties": { @@ -621,6 +785,14 @@ export const RUST_RESPONSE_SCHEMAS = { "RuntimeMapEdge": { "additionalProperties": false, "properties": { + "evidenceRefs": { + "description": "Empty for relationship families that have not yet been migrated to the\nevidence model. It remains present on the wire so API/UI consumers have\none stable, bounded relationship shape while the migration continues.", + "items": { + "$ref": "#/$defs/RuntimeEvidenceRef" + }, + "maxItems": 8, + "type": "array" + }, "metadata": { "additionalProperties": { "type": "string" @@ -641,7 +813,8 @@ export const RUST_RESPONSE_SCHEMAS = { "source", "target", "relationship", - "metadata" + "metadata", + "evidenceRefs" ], "type": "object" }, @@ -725,6 +898,7 @@ export const RUST_RESPONSE_SCHEMAS = { "docker_network", "docker_volume", "host", + "host_risk", "service", "systemd_service", "scheduled_job", @@ -992,6 +1166,7 @@ export const RUST_RESPONSE_SCHEMAS = { "mounts", "manages", "exposes", + "exposes_daemon_state", "runs_on", "uses", "calls", @@ -1130,8 +1305,8 @@ export const RUST_RESPONSE_SCHEMAS = { "items": { "$ref": "#/$defs/ProviderState" }, - "maxItems": 5, - "minItems": 5, + "maxItems": 7, + "minItems": 7, "type": "array" }, "source": { @@ -1157,137 +1332,405 @@ export const RUST_RESPONSE_SCHEMAS = { "title": "RuntimeMap", "type": "object" }, - ComposeScan: { + FindingsResponse: { "$defs": { - "ComposeDiagnostic": { + "Finding": { "additionalProperties": false, "properties": { + "evidenceRefs": { + "description": "Canonical, already-sanitized runtime evidence that directly triggered\nthis finding. Each closed rule has a fixed, small evidence budget,\npreventing this response from becoming a generic metadata channel.", + "items": { + "$ref": "#/$defs/RuntimeEvidenceRef" + }, + "maxItems": 2, + "minItems": 1, + "type": "array" + }, "id": { + "maxLength": 259, + "minLength": 1, "type": "string" }, - "message": { + "recommendation": { + "maxLength": 259, + "minLength": 1, "type": "string" }, - "origin": { - "$ref": "#/$defs/ComposeFileOrigin" + "ruleId": { + "$ref": "#/$defs/FindingRule" }, "severity": { - "$ref": "#/$defs/DiagnosticSeverity" + "$ref": "#/$defs/FindingSeverity" + }, + "subjectRef": { + "type": "string" + }, + "summary": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "targetRef": { + "type": "string" } }, "required": [ "id", + "ruleId", "severity", - "message", - "origin" + "summary", + "recommendation", + "subjectRef", + "targetRef", + "evidenceRefs" ], "type": "object" }, - "ComposeFileOrigin": { - "additionalProperties": false, - "properties": { - "field": { + "FindingRule": { + "description": "Closed rule identifiers keep clients from treating findings as arbitrary\nprovider messages. New rules require an explicit contract addition.", + "enum": [ + "systemd.requires_target_not_active", + "docker.internal_network_member_publishes_port", + "docker.daemon_state_bind_mount" + ], + "type": "string" + }, + "FindingSeverity": { + "description": "Findings are intentionally a small, closed advisory vocabulary. They do\nnot expose provider output or prescribe an automated remediation.", + "enum": [ + "warning", + "advisory" + ], + "type": "string" + }, + "ProviderSlot": { + "description": "Fixed, schema-backed host-provider slots. This is not a plugin or policy\ninterface: the daemon owns the complete finite list.", + "oneOf": [ + { + "enum": [ + "network_infrastructure", + "host_scoped", + "python_processes", + "native_processes", + "project_npm" + ], "type": "string" }, - "file": { + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", "type": "string" }, - "service": { - "type": [ - "string", - "null" - ] + { + "const": "systemd", + "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", + "type": "string" } - }, - "required": [ - "file", - "field" + ] + }, + "RuntimeEvidenceAssertionKind": { + "description": "Evidence assertion semantics are deliberately closed. A declaration says\nwhat a source configured, never that its target is healthy or was invoked.", + "enum": [ + "observed", + "declared" ], - "type": "object" + "type": "string" }, - "ComposeMount": { - "additionalProperties": false, - "properties": { - "id": { + "RuntimeEvidenceFreshness": { + "enum": [ + "fresh", + "stale", + "timed_out" + ], + "type": "string" + }, + "RuntimeEvidenceKind": { + "description": "Safe, provider-specific fact families supported by the first provenance\nslice. New sources require an explicit enum addition rather than an\narbitrary source string or metadata map.", + "oneOf": [ + { + "enum": [ + "docker_network_membership", + "docker_volume_mount", + "docker_port_publication" + ], "type": "string" }, - "kind": { - "$ref": "#/$defs/ComposeMountKind" + { + "const": "docker_compose_depends_on", + "description": "Docker's recorded Compose dependency declaration. This is deliberately\nnot a health, readiness, or traffic-causality claim.", + "type": "string" }, - "origin": { - "$ref": "#/$defs/ComposeFileOrigin" + { + "const": "docker_daemon_state_bind_mount", + "description": "A bind mount that matches the closed Docker socket/data-root predicate.\nThe public evidence and target intentionally omit the mount path.", + "type": "string" }, - "readOnly": { - "type": "boolean" + { + "const": "systemd_requires", + "description": "A systemd `Requires=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" }, - "resolvedSource": { - "type": [ - "string", - "null" - ] + { + "const": "systemd_wants", + "description": "A systemd `Wants=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" }, - "service": { + { + "const": "systemd_part_of", + "description": "A systemd `PartOf=` declaration. It is not an ordering assertion.", "type": "string" }, - "source": { - "type": [ - "string", - "null" - ] + { + "const": "npm_package_manifest_dependency", + "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", + "type": "string" }, - "target": { + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", "type": "string" } - }, - "required": [ - "id", - "service", - "kind", - "target", - "readOnly", - "origin" - ], - "type": "object" + ] }, - "ComposeMountKind": { + "RuntimeEvidenceProvider": { + "description": "Evidence providers are deliberately closed. Version two adds systemd only\nafter it received its own scheduler slot; it cannot inherit a broader host\ncollection's freshness or revision.", "enum": [ - "bind", - "named_volume", - "anonymous_volume", - "unsupported" + "docker", + "systemd", + "npm", + "cron" ], "type": "string" }, - "ComposeService": { + "RuntimeEvidenceRef": { "additionalProperties": false, + "description": "A compact, versioned reference to the bounded fact supporting a runtime\nrelationship. It intentionally contains no raw command output, config\nfragment, path, process arguments, or generic metadata bag.", "properties": { - "dependsOn": { - "items": { - "type": "string" - }, - "type": "array" + "assertionKind": { + "$ref": "#/$defs/RuntimeEvidenceAssertionKind" }, - "environment": { - "additionalProperties": { - "type": "string" - }, - "type": "object" + "collectedAt": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" }, - "image": { - "type": [ - "string", - "null" - ] + "freshness": { + "$ref": "#/$defs/RuntimeEvidenceFreshness" }, - "name": { + "id": { + "maxLength": 259, + "minLength": 1, "type": "string" - } - }, - "required": [ - "name", - "environment", - "dependsOn" - ], + }, + "kind": { + "$ref": "#/$defs/RuntimeEvidenceKind" + }, + "provider": { + "$ref": "#/$defs/RuntimeEvidenceProvider" + }, + "providerRevision": { + "description": "Opaque provider observation token, not a cache-publication revision,\ncommand output, or source dump.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "providerSlot": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderSlot" + }, + { + "type": "null" + } + ], + "description": "Version-two-and-later provider evidence is explicitly tied to the\nfinite scheduler slot that supplied its revision and freshness.\nVersion-one Docker evidence intentionally has no host-provider slot." + }, + "subjectRef": { + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", + "type": "string" + }, + "summary": { + "description": "A bounded, curated explanation; it is never copied from a raw source.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", + "format": "uint8", + "maximum": 4, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "id", + "provider", + "kind", + "assertionKind", + "summary", + "subjectRef", + "collectedAt", + "providerRevision", + "freshness" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "findings": { + "items": { + "$ref": "#/$defs/Finding" + }, + "type": "array" + }, + "modelRevision": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "findings", + "modelRevision" + ], + "title": "FindingsResponse", + "type": "object" +}, + ComposeScan: { + "$defs": { + "ComposeDiagnostic": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "origin": { + "$ref": "#/$defs/ComposeFileOrigin" + }, + "severity": { + "$ref": "#/$defs/DiagnosticSeverity" + } + }, + "required": [ + "id", + "severity", + "message", + "origin" + ], + "type": "object" + }, + "ComposeFileOrigin": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "file": { + "type": "string" + }, + "service": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "file", + "field" + ], + "type": "object" + }, + "ComposeMount": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/ComposeMountKind" + }, + "origin": { + "$ref": "#/$defs/ComposeFileOrigin" + }, + "readOnly": { + "type": "boolean" + }, + "resolvedSource": { + "type": [ + "string", + "null" + ] + }, + "service": { + "type": "string" + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "target": { + "type": "string" + } + }, + "required": [ + "id", + "service", + "kind", + "target", + "readOnly", + "origin" + ], + "type": "object" + }, + "ComposeMountKind": { + "enum": [ + "bind", + "named_volume", + "anonymous_volume", + "unsupported" + ], + "type": "string" + }, + "ComposeService": { + "additionalProperties": false, + "properties": { + "dependsOn": { + "items": { + "type": "string" + }, + "type": "array" + }, + "environment": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "image": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "environment", + "dependsOn" + ], "type": "object" }, "DiagnosticSeverity": { @@ -2442,14 +2885,28 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { }, "ProviderSlot": { "description": "Fixed, schema-backed host-provider slots. This is not a plugin or policy\ninterface: the daemon owns the complete finite list.", - "enum": [ - "network_infrastructure", - "host_scoped", - "python_processes", - "native_processes", - "project_npm" - ], - "type": "string" + "oneOf": [ + { + "enum": [ + "network_infrastructure", + "host_scoped", + "python_processes", + "native_processes", + "project_npm" + ], + "type": "string" + }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, + { + "const": "systemd", + "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", + "type": "string" + } + ] }, "ProviderState": { "additionalProperties": false, @@ -2590,6 +3047,156 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { ], "type": "object" }, + "RuntimeEvidenceAssertionKind": { + "description": "Evidence assertion semantics are deliberately closed. A declaration says\nwhat a source configured, never that its target is healthy or was invoked.", + "enum": [ + "observed", + "declared" + ], + "type": "string" + }, + "RuntimeEvidenceFreshness": { + "enum": [ + "fresh", + "stale", + "timed_out" + ], + "type": "string" + }, + "RuntimeEvidenceKind": { + "description": "Safe, provider-specific fact families supported by the first provenance\nslice. New sources require an explicit enum addition rather than an\narbitrary source string or metadata map.", + "oneOf": [ + { + "enum": [ + "docker_network_membership", + "docker_volume_mount", + "docker_port_publication" + ], + "type": "string" + }, + { + "const": "docker_compose_depends_on", + "description": "Docker's recorded Compose dependency declaration. This is deliberately\nnot a health, readiness, or traffic-causality claim.", + "type": "string" + }, + { + "const": "docker_daemon_state_bind_mount", + "description": "A bind mount that matches the closed Docker socket/data-root predicate.\nThe public evidence and target intentionally omit the mount path.", + "type": "string" + }, + { + "const": "systemd_requires", + "description": "A systemd `Requires=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" + }, + { + "const": "systemd_wants", + "description": "A systemd `Wants=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" + }, + { + "const": "systemd_part_of", + "description": "A systemd `PartOf=` declaration. It is not an ordering assertion.", + "type": "string" + }, + { + "const": "npm_package_manifest_dependency", + "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", + "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" + } + ] + }, + "RuntimeEvidenceProvider": { + "description": "Evidence providers are deliberately closed. Version two adds systemd only\nafter it received its own scheduler slot; it cannot inherit a broader host\ncollection's freshness or revision.", + "enum": [ + "docker", + "systemd", + "npm", + "cron" + ], + "type": "string" + }, + "RuntimeEvidenceRef": { + "additionalProperties": false, + "description": "A compact, versioned reference to the bounded fact supporting a runtime\nrelationship. It intentionally contains no raw command output, config\nfragment, path, process arguments, or generic metadata bag.", + "properties": { + "assertionKind": { + "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceAssertionKind" + }, + "collectedAt": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "freshness": { + "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceFreshness" + }, + "id": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceKind" + }, + "provider": { + "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceProvider" + }, + "providerRevision": { + "description": "Opaque provider observation token, not a cache-publication revision,\ncommand output, or source dump.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "providerSlot": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeMap/$defs/ProviderSlot" + }, + { + "type": "null" + } + ], + "description": "Version-two-and-later provider evidence is explicitly tied to the\nfinite scheduler slot that supplied its revision and freshness.\nVersion-one Docker evidence intentionally has no host-provider slot." + }, + "subjectRef": { + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", + "type": "string" + }, + "summary": { + "description": "A bounded, curated explanation; it is never copied from a raw source.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", + "format": "uint8", + "maximum": 4, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "id", + "provider", + "kind", + "assertionKind", + "summary", + "subjectRef", + "collectedAt", + "providerRevision", + "freshness" + ], + "type": "object" + }, "RuntimeHealth": { "additionalProperties": false, "properties": { @@ -2724,6 +3331,14 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "RuntimeMapEdge": { "additionalProperties": false, "properties": { + "evidenceRefs": { + "description": "Empty for relationship families that have not yet been migrated to the\nevidence model. It remains present on the wire so API/UI consumers have\none stable, bounded relationship shape while the migration continues.", + "items": { + "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceRef" + }, + "maxItems": 8, + "type": "array" + }, "metadata": { "additionalProperties": { "type": "string" @@ -2744,7 +3359,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "source", "target", "relationship", - "metadata" + "metadata", + "evidenceRefs" ], "type": "object" }, @@ -2828,6 +3444,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "docker_network", "docker_volume", "host", + "host_risk", "service", "systemd_service", "scheduled_job", @@ -3095,6 +3712,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "mounts", "manages", "exposes", + "exposes_daemon_state", "runs_on", "uses", "calls", @@ -3233,8 +3851,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "items": { "$ref": "#/components/schemas/RuntimeMap/$defs/ProviderState" }, - "maxItems": 5, - "minItems": 5, + "maxItems": 7, + "minItems": 7, "type": "array" }, "source": { @@ -3259,6 +3877,274 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { ], "title": "RuntimeMap", "type": "object" +}, + FindingsResponse: { + "$defs": { + "Finding": { + "additionalProperties": false, + "properties": { + "evidenceRefs": { + "description": "Canonical, already-sanitized runtime evidence that directly triggered\nthis finding. Each closed rule has a fixed, small evidence budget,\npreventing this response from becoming a generic metadata channel.", + "items": { + "$ref": "#/components/schemas/FindingsResponse/$defs/RuntimeEvidenceRef" + }, + "maxItems": 2, + "minItems": 1, + "type": "array" + }, + "id": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "recommendation": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "ruleId": { + "$ref": "#/components/schemas/FindingsResponse/$defs/FindingRule" + }, + "severity": { + "$ref": "#/components/schemas/FindingsResponse/$defs/FindingSeverity" + }, + "subjectRef": { + "type": "string" + }, + "summary": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "targetRef": { + "type": "string" + } + }, + "required": [ + "id", + "ruleId", + "severity", + "summary", + "recommendation", + "subjectRef", + "targetRef", + "evidenceRefs" + ], + "type": "object" + }, + "FindingRule": { + "description": "Closed rule identifiers keep clients from treating findings as arbitrary\nprovider messages. New rules require an explicit contract addition.", + "enum": [ + "systemd.requires_target_not_active", + "docker.internal_network_member_publishes_port", + "docker.daemon_state_bind_mount" + ], + "type": "string" + }, + "FindingSeverity": { + "description": "Findings are intentionally a small, closed advisory vocabulary. They do\nnot expose provider output or prescribe an automated remediation.", + "enum": [ + "warning", + "advisory" + ], + "type": "string" + }, + "ProviderSlot": { + "description": "Fixed, schema-backed host-provider slots. This is not a plugin or policy\ninterface: the daemon owns the complete finite list.", + "oneOf": [ + { + "enum": [ + "network_infrastructure", + "host_scoped", + "python_processes", + "native_processes", + "project_npm" + ], + "type": "string" + }, + { + "const": "cron", + "description": "Cron has an independent collector lifecycle. It must not inherit\nhost-node, listener, PM2, or tmux freshness.", + "type": "string" + }, + { + "const": "systemd", + "description": "systemd has an independent collector lifecycle. It must not inherit\nfreshness from the broader host-scoped observation slot.", + "type": "string" + } + ] + }, + "RuntimeEvidenceAssertionKind": { + "description": "Evidence assertion semantics are deliberately closed. A declaration says\nwhat a source configured, never that its target is healthy or was invoked.", + "enum": [ + "observed", + "declared" + ], + "type": "string" + }, + "RuntimeEvidenceFreshness": { + "enum": [ + "fresh", + "stale", + "timed_out" + ], + "type": "string" + }, + "RuntimeEvidenceKind": { + "description": "Safe, provider-specific fact families supported by the first provenance\nslice. New sources require an explicit enum addition rather than an\narbitrary source string or metadata map.", + "oneOf": [ + { + "enum": [ + "docker_network_membership", + "docker_volume_mount", + "docker_port_publication" + ], + "type": "string" + }, + { + "const": "docker_compose_depends_on", + "description": "Docker's recorded Compose dependency declaration. This is deliberately\nnot a health, readiness, or traffic-causality claim.", + "type": "string" + }, + { + "const": "docker_daemon_state_bind_mount", + "description": "A bind mount that matches the closed Docker socket/data-root predicate.\nThe public evidence and target intentionally omit the mount path.", + "type": "string" + }, + { + "const": "systemd_requires", + "description": "A systemd `Requires=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" + }, + { + "const": "systemd_wants", + "description": "A systemd `Wants=` declaration. It is not a successful-start or\nhealth assertion.", + "type": "string" + }, + { + "const": "systemd_part_of", + "description": "A systemd `PartOf=` declaration. It is not an ordering assertion.", + "type": "string" + }, + { + "const": "npm_package_manifest_dependency", + "description": "A package.json dependency declaration. This is not proof that the\npackage was installed, resolved, executed, or is safe.", + "type": "string" + }, + { + "const": "cron_schedule_declaration", + "description": "A parsed cron declaration. This does not claim the command ran.", + "type": "string" + } + ] + }, + "RuntimeEvidenceProvider": { + "description": "Evidence providers are deliberately closed. Version two adds systemd only\nafter it received its own scheduler slot; it cannot inherit a broader host\ncollection's freshness or revision.", + "enum": [ + "docker", + "systemd", + "npm", + "cron" + ], + "type": "string" + }, + "RuntimeEvidenceRef": { + "additionalProperties": false, + "description": "A compact, versioned reference to the bounded fact supporting a runtime\nrelationship. It intentionally contains no raw command output, config\nfragment, path, process arguments, or generic metadata bag.", + "properties": { + "assertionKind": { + "$ref": "#/components/schemas/FindingsResponse/$defs/RuntimeEvidenceAssertionKind" + }, + "collectedAt": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "freshness": { + "$ref": "#/components/schemas/FindingsResponse/$defs/RuntimeEvidenceFreshness" + }, + "id": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/FindingsResponse/$defs/RuntimeEvidenceKind" + }, + "provider": { + "$ref": "#/components/schemas/FindingsResponse/$defs/RuntimeEvidenceProvider" + }, + "providerRevision": { + "description": "Opaque provider observation token, not a cache-publication revision,\ncommand output, or source dump.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "providerSlot": { + "anyOf": [ + { + "$ref": "#/components/schemas/FindingsResponse/$defs/ProviderSlot" + }, + { + "type": "null" + } + ], + "description": "Version-two-and-later provider evidence is explicitly tied to the\nfinite scheduler slot that supplied its revision and freshness.\nVersion-one Docker evidence intentionally has no host-provider slot." + }, + "subjectRef": { + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", + "type": "string" + }, + "summary": { + "description": "A bounded, curated explanation; it is never copied from a raw source.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", + "format": "uint8", + "maximum": 4, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "id", + "provider", + "kind", + "assertionKind", + "summary", + "subjectRef", + "collectedAt", + "providerRevision", + "freshness" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "findings": { + "items": { + "$ref": "#/components/schemas/FindingsResponse/$defs/Finding" + }, + "type": "array" + }, + "modelRevision": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "findings", + "modelRevision" + ], + "title": "FindingsResponse", + "type": "object" }, ComposeScan: { "$defs": { diff --git a/packages/contracts/src/schema-fixtures.test.ts b/packages/contracts/src/schema-fixtures.test.ts index 13852ac4..6db05d3c 100644 --- a/packages/contracts/src/schema-fixtures.test.ts +++ b/packages/contracts/src/schema-fixtures.test.ts @@ -14,7 +14,8 @@ const fixtures = [ ["compose-graph", ["compose-graph.json"]], ["compose-edit-plan", ["compose-edit-plan.json"]], ["logs-response", ["logs-response.json"]], - ["health-response", ["health-response.json"]] + ["health-response", ["health-response.json"]], + ["findings-response", ["findings-response.json"]] ] as const; async function readJson(path: string): Promise { @@ -29,7 +30,7 @@ describe("Rust-owned daemon schema baseline", () => { it.each(fixtures)("validates %s fixtures against the committed generated schema", async (schemaName, fixtureNames) => { const schemaPath = `${repoRoot}packages/contracts/generated/rust/${schemaName}.schema.json`; const schema = await readSchema(schemaPath); - const validator = new Ajv2020({ allErrors: true, formats: { uint32: true, uint64: true } }).compile(schema); + const validator = new Ajv2020({ allErrors: true, formats: { uint8: true, uint32: true, uint64: true } }).compile(schema); for (const fixtureName of fixtureNames) { const fixture = await readJson(`${repoRoot}tests/fixtures/contracts/${fixtureName}`); @@ -39,7 +40,7 @@ describe("Rust-owned daemon schema baseline", () => { it("rejects a fixture that drifts from the Rust-owned serialization shape", async () => { const schema = await readSchema(`${repoRoot}packages/contracts/generated/rust/docker-snapshot.schema.json`); - const validator = new Ajv2020({ allErrors: true, formats: { uint32: true, uint64: true } }).compile(schema); + const validator = new Ajv2020({ allErrors: true, formats: { uint8: true, uint32: true, uint64: true } }).compile(schema); const fixture = await readJson(`${repoRoot}tests/fixtures/contracts/mock-snapshot.json`) as { lastUpdated: unknown; }; @@ -52,7 +53,7 @@ describe("Rust-owned daemon schema baseline", () => { it("rejects integers above the browser-safe JSON range", async () => { const schema = await readSchema(`${repoRoot}packages/contracts/generated/rust/docker-snapshot.schema.json`); - const validator = new Ajv2020({ allErrors: true, formats: { uint32: true, uint64: true } }).compile(schema); + const validator = new Ajv2020({ allErrors: true, formats: { uint8: true, uint32: true, uint64: true } }).compile(schema); const fixture = await readJson(`${repoRoot}tests/fixtures/contracts/mock-snapshot.json`) as { lastUpdated: unknown; }; @@ -65,7 +66,7 @@ describe("Rust-owned daemon schema baseline", () => { it("rejects an undeclared response field instead of letting fixtures redefine the contract", async () => { const schema = await readSchema(`${repoRoot}packages/contracts/generated/rust/health-response.schema.json`); - const validator = new Ajv2020({ allErrors: true, formats: { uint32: true, uint64: true } }).compile(schema); + const validator = new Ajv2020({ allErrors: true, formats: { uint8: true, uint32: true, uint64: true } }).compile(schema); const fixture = await readJson(`${repoRoot}tests/fixtures/contracts/health-response.json`) as Record; fixture.unreviewed = true; diff --git a/scripts/generate-rust-contract-types.mjs b/scripts/generate-rust-contract-types.mjs index 55ed2576..e941c223 100644 --- a/scripts/generate-rust-contract-types.mjs +++ b/scripts/generate-rust-contract-types.mjs @@ -20,7 +20,7 @@ const roots = [ ["LogsResponse", "logs-response"], ["HealthResponse", "health-response"], ["ContainersResponse", "containers-response"], ["ContainerDetailResponse", "container-detail-response"], ["ImagesResponse", "images-response"], ["NetworksResponse", "networks-response"], - ["VolumesResponse", "volumes-response"] + ["VolumesResponse", "volumes-response"], ["FindingsResponse", "findings-response"] ]; function stable(value) { diff --git a/tests/e2e/a11y.spec.ts b/tests/e2e/a11y.spec.ts index 6b6d73e1..bd640d86 100644 --- a/tests/e2e/a11y.spec.ts +++ b/tests/e2e/a11y.spec.ts @@ -11,6 +11,7 @@ const coreRoutes = [ ["home", "/"], ["map", "/map"], ["runtime", "/runtime"], + ["findings", "/findings"], ["changes", "/changes"], ["copilot", "/copilot"], ["networking", "/networking"], @@ -263,6 +264,12 @@ test.describe("responsive and accessibility matrix", () => { // without the shared selectNode handler focus would fall to BODY. await runtimeNode.click(); await expect(runtimeNode).toHaveAttribute("aria-pressed", "true"); + const edgeEvidence = page.getByRole("button", { name: "Inspect evidence" }).first(); + if (await edgeEvidence.count() > 0) { + await edgeEvidence.click(); + await expect(edgeEvidence).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByText("Relationship evidence", { exact: true })).toBeVisible(); + } const edgeTarget = page.locator(".runtime-edge-target").first(); if (await edgeTarget.count() > 0) { const edgeLabel = (await edgeTarget.locator("span").textContent())?.trim() ?? ""; diff --git a/tests/e2e/dockermap.spec.ts b/tests/e2e/dockermap.spec.ts index ebce7698..9a4ecf55 100644 --- a/tests/e2e/dockermap.spec.ts +++ b/tests/e2e/dockermap.spec.ts @@ -171,9 +171,103 @@ test.describe("DockerMap GUI", () => { await expect(page.getByText("Sample data", { exact: true })).toHaveCount(0); }); + test("high-density map keeps isolates in the directory and focuses them without widening the default graph", async ({ page }) => { + stack = await startMockStack(); + const revision = "e2e-dense-map-v1"; + await page.route("**/api/events/stream*", async (route) => { + await route.fulfill({ + contentType: "text/event-stream", + body: `event: snapshot\ndata: {"status":"ok","mode":"mock","dockerReachable":false,"message":"dense fixture","lastUpdated":0,"snapshotVersion":"${revision}","modelRevision":"${revision}"}\n\n` + }); + }); + await page.route("**/api/snapshot", async (route) => { + const response = await route.fetch(); + const snapshot = (await response.json()) as Record; + const containers = Array.from({ length: 44 }, (_, index) => ({ + id: `dense-${String(index).padStart(2, "0")}`, + name: `dense-${String(index).padStart(2, "0")}`, + image: "busybox:1", + status: "running", + role: "service", + networks: ["dense-network"], + ports: [], + mounts: [], + dependsOn: index === 1 ? ["dense-00"] : index === 2 ? ["dense-01"] : [] + })); + snapshot.containers = containers; + snapshot.images = []; + snapshot.volumes = []; + snapshot.networks = [{ id: "dense-network", name: "dense-network", driver: "bridge", internal: false, members: containers.map((container) => container.name) }]; + snapshot.modelRevision = revision; + await route.fulfill({ response, json: snapshot }); + }); + await page.route("**/api/runtime/map", async (route) => { + const response = await route.fetch(); + const runtimeMap = (await response.json()) as Record; + runtimeMap.modelRevision = revision; + await route.fulfill({ response, json: runtimeMap }); + }); + + await page.goto(`${stack.webUrl}/map`, { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { name: "Service Map" })).toBeVisible(); + await expect(page.locator(".service-directory .runtime-node-btn")).toHaveCount(44); + await expect(page.locator("g.node")).toHaveCount(3); + + await page.locator(".service-directory .runtime-node-btn", { hasText: "dense-43" }).click(); + await expect(page.locator("g.node")).toHaveCount(1); + await expect(page.locator("g.node", { hasText: "dense-43" })).toHaveCount(1); + }); + test("runtime relation navigation widens filters, keeps the destination selected and focused", async ({ page }) => { stack = await startMockStack(); + // This inspector exercise needs a coherent Docker-attested publication. + // Keep the default mock-stack tests (including the explicit no-evidence + // assertions) as the mock safety coverage; this narrowly upgrades only + // the fixture used to prove the live evidence renderer. + const revision = "e2e-runtime-evidence-docker-v1"; + await page.route("**/api/events/stream*", async (route) => { + await route.fulfill({ + contentType: "text/event-stream", + body: `event: snapshot\ndata: {"status":"ok","mode":"docker","dockerReachable":true,"message":"Docker fixture","lastUpdated":1,"snapshotVersion":"${revision}","modelRevision":"${revision}"}\n\n` + }); + }); + await page.route("**/api/snapshot", async (route) => { + const response = await route.fetch(); + const snapshot = (await response.json()) as Record; + snapshot.source = "docker"; + snapshot.modelRevision = revision; + await route.fulfill({ response, json: snapshot }); + }); + await page.route("**/api/runtime/map", async (route) => { + const response = await route.fetch(); + const runtimeMap = (await response.json()) as { + source?: unknown; + modelRevision?: unknown; + nodes?: Array<{ id: string; label: string; provider: string; type: string }>; + edges?: Array<{ source: string; target: string; evidenceRefs?: unknown[] }>; + }; + runtimeMap.source = "docker"; + runtimeMap.modelRevision = revision; + const api = runtimeMap.nodes?.find((node) => node.provider === "docker" && node.type === "container" && node.label === "api"); + const application = runtimeMap.nodes?.find((node) => node.provider === "docker" && node.type === "docker_network" && node.label === "application"); + const edge = runtimeMap.edges?.find((candidate) => candidate.source === api?.id && candidate.target === application?.id); + if (!edge) throw new Error("runtime evidence fixture is missing the api-to-application relation"); + edge.evidenceRefs = [{ + version: 1, + id: "e2e-docker-network-membership-api-application", + provider: "docker", + kind: "docker_network_membership", + assertionKind: "observed", + summary: "Docker reported container network membership", + subjectRef: edge.source, + collectedAt: 1, + providerRevision: "e2e-docker-observation", + freshness: "fresh" + }]; + await route.fulfill({ response, json: runtimeMap }); + }); + await page.goto(stack.webUrl, { waitUntil: "domcontentloaded" }); await openSpace(page, "Runtime", "/runtime"); @@ -193,6 +287,18 @@ test.describe("DockerMap GUI", () => { const applicationNode = page.locator("button.runtime-node-btn", { hasText: "application" }).filter({ hasText: "docker network" }); await expect(applicationNode).toHaveCount(0); + // Edge evidence is selected independently of endpoint navigation. The + // inspector must expose the canonical Docker fact, not reconstruct a + // rationale from the two visible labels. + const inspectEvidence = page + .locator(".runtime-edge-row", { has: page.locator(".runtime-edge-target", { hasText: "application" }) }) + .getByRole("button", { name: "Inspect evidence" }); + await inspectEvidence.click(); + await expect(inspectEvidence).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByText("Relationship evidence", { exact: true })).toBeVisible(); + await expect(page.getByText("Observed fact", { exact: true })).toBeVisible(); + await expect(page.getByText("Docker reported container network membership", { exact: true })).toBeVisible(); + // Follow the relation anyway: the destination must become visible (the // incompatible layer filter is widened), stay SELECTED, and receive FOCUS // on its persistent row button — never BODY. Previously the visibility diff --git a/tests/fixtures/contracts/findings-response.json b/tests/fixtures/contracts/findings-response.json new file mode 100644 index 00000000..e76b82d5 --- /dev/null +++ b/tests/fixtures/contracts/findings-response.json @@ -0,0 +1,90 @@ +{ + "modelRevision": "fixture-findings-revision", + "findings": [ + { + "id": "finding_systemd_requires_target_not_active_fixture", + "ruleId": "systemd.requires_target_not_active", + "severity": "warning", + "summary": "An active systemd service requires a target that is inactive or failed", + "recommendation": "Inspect the target service state and its declared dependency configuration.", + "subjectRef": "systemd_service_application", + "targetRef": "systemd_service_database", + "evidenceRefs": [ + { + "version": 2, + "id": "systemd_requires:systemd_service_application:systemd_service_database", + "provider": "systemd", + "kind": "systemd_requires", + "assertionKind": "declared", + "summary": "systemd declared a Requires dependency", + "subjectRef": "systemd_service_application", + "collectedAt": 1710000000000, + "providerRevision": "fixture-systemd-observation", + "providerSlot": "systemd", + "freshness": "fresh" + } + ] + }, + { + "id": "finding_docker_internal_network_member_publishes_port_fixture", + "ruleId": "docker.internal_network_member_publishes_port", + "severity": "advisory", + "summary": "A container on an internal Docker network also has a published host port.", + "recommendation": "Review whether the host-port publication is intended for this internal-network service.", + "subjectRef": "docker_container_api", + "targetRef": "docker_network_internal", + "evidenceRefs": [ + { + "version": 1, + "id": "docker_network_membership:docker_container_api:docker_network_internal", + "provider": "docker", + "kind": "docker_network_membership", + "assertionKind": "observed", + "summary": "Docker reported container network membership", + "subjectRef": "docker_container_api", + "collectedAt": 1710000000000, + "providerRevision": "fixture-docker-observation", + "providerSlot": null, + "freshness": "fresh" + }, + { + "version": 1, + "id": "docker_port_publication:docker_container_api:network_listener_api_8080", + "provider": "docker", + "kind": "docker_port_publication", + "assertionKind": "observed", + "summary": "Docker reported a published container port", + "subjectRef": "docker_container_api", + "collectedAt": 1710000000000, + "providerRevision": "fixture-docker-observation", + "providerSlot": null, + "freshness": "fresh" + } + ] + }, + { + "id": "finding_docker_daemon_state_bind_mount_fixture", + "ruleId": "docker.daemon_state_bind_mount", + "severity": "warning", + "summary": "A container has Docker daemon state access that may provide Docker daemon API authority.", + "recommendation": "Review whether this container requires Docker daemon API authority.", + "subjectRef": "docker_container_api", + "targetRef": "host_risk_docker_daemon_state", + "evidenceRefs": [ + { + "version": 1, + "id": "docker_daemon_state_bind_mount:docker_container_api:host_risk_docker_daemon_state", + "provider": "docker", + "kind": "docker_daemon_state_bind_mount", + "assertionKind": "observed", + "summary": "Docker reported a bind mount exposing Docker daemon state", + "subjectRef": "docker_container_api", + "collectedAt": 1710000000000, + "providerRevision": "fixture-docker-observation", + "providerSlot": null, + "freshness": "fresh" + } + ] + } + ] +} diff --git a/tests/fixtures/contracts/runtime-map-daemon-emitted.json b/tests/fixtures/contracts/runtime-map-daemon-emitted.json index 77e3c961..f6532aba 100644 --- a/tests/fixtures/contracts/runtime-map-daemon-emitted.json +++ b/tests/fixtures/contracts/runtime-map-daemon-emitted.json @@ -210,108 +210,503 @@ "metadata": { "port": "6379:6379/tcp" } + }, + { + "id": "npm_project_dockermap", + "provider": "npm", + "type": "package", + "label": "DockerMap", + "status": "discovered", + "layer": "package", + "metadata": { + "private": "true", + "serviceEntityKind": "node_application" + } + }, + { + "id": "npm_package_express_5_1_0", + "provider": "npm", + "type": "package_dependency", + "label": "express", + "status": null, + "layer": "package", + "metadata": { + "package": "express", + "scope": "dependencies", + "serviceEntityKind": "package_dependency", + "version": "5.1.0" + }, + "package": { + "name": "express", + "manager": "npm", + "version": "5.1.0", + "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": [ + { + "source": "docker_container_container_api", + "target": "docker_container_container_db", + "relationship": "depends_on", + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_compose_depends_on-docker_container_container_api-docker_container_container_db", + "provider": "docker", + "kind": "docker_compose_depends_on", + "assertionKind": "observed", + "summary": "Docker recorded Compose dependency declaration", + "subjectRef": "docker_container_container_api", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] + }, { "source": "docker_container_container_api", "target": "docker_network_network_app", "relationship": "connected_to", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_network_membership-docker_container_container_api-docker_network_network_app", + "provider": "docker", + "kind": "docker_network_membership", + "assertionKind": "observed", + "summary": "Docker reported container network membership", + "subjectRef": "docker_container_container_api", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_api", "target": "docker_network_network_data", "relationship": "connected_to", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_network_membership-docker_container_container_api-docker_network_network_data", + "provider": "docker", + "kind": "docker_network_membership", + "assertionKind": "observed", + "summary": "Docker reported container network membership", + "subjectRef": "docker_container_container_api", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_api", "target": "docker_volume_volume_app_cache", "relationship": "mounts", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_volume_mount-docker_container_container_api-docker_volume_volume_app_cache", + "provider": "docker", + "kind": "docker_volume_mount", + "assertionKind": "observed", + "summary": "Docker reported volume attachment", + "subjectRef": "docker_container_container_api", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_api", "target": "network_listener_3233_3233_tcp", "relationship": "exposes", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_port_publication-docker_container_container_api-network_listener_3233_3233_tcp", + "provider": "docker", + "kind": "docker_port_publication", + "assertionKind": "observed", + "summary": "Docker reported container port publication", + "subjectRef": "docker_container_container_api", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_cache", "target": "docker_network_network_data", "relationship": "connected_to", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_network_membership-docker_container_container_cache-docker_network_network_data", + "provider": "docker", + "kind": "docker_network_membership", + "assertionKind": "observed", + "summary": "Docker reported container network membership", + "subjectRef": "docker_container_container_cache", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_cache", "target": "network_listener_6379_6379_tcp", "relationship": "exposes", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_port_publication-docker_container_container_cache-network_listener_6379_6379_tcp", + "provider": "docker", + "kind": "docker_port_publication", + "assertionKind": "observed", + "summary": "Docker reported container port publication", + "subjectRef": "docker_container_container_cache", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_db", "target": "docker_network_network_data", "relationship": "connected_to", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_network_membership-docker_container_container_db-docker_network_network_data", + "provider": "docker", + "kind": "docker_network_membership", + "assertionKind": "observed", + "summary": "Docker reported container network membership", + "subjectRef": "docker_container_container_db", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_db", "target": "docker_volume_volume_postgres_data", "relationship": "mounts", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_volume_mount-docker_container_container_db-docker_volume_volume_postgres_data", + "provider": "docker", + "kind": "docker_volume_mount", + "assertionKind": "observed", + "summary": "Docker reported volume attachment", + "subjectRef": "docker_container_container_db", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_db", "target": "network_listener_5432_5432_tcp", "relationship": "exposes", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_port_publication-docker_container_container_db-network_listener_5432_5432_tcp", + "provider": "docker", + "kind": "docker_port_publication", + "assertionKind": "observed", + "summary": "Docker reported container port publication", + "subjectRef": "docker_container_container_db", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_gateway", "target": "docker_network_network_app", "relationship": "connected_to", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_network_membership-docker_container_container_gateway-docker_network_network_app", + "provider": "docker", + "kind": "docker_network_membership", + "assertionKind": "observed", + "summary": "Docker reported container network membership", + "subjectRef": "docker_container_container_gateway", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_gateway", "target": "docker_network_network_edge", "relationship": "connected_to", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_network_membership-docker_container_container_gateway-docker_network_network_edge", + "provider": "docker", + "kind": "docker_network_membership", + "assertionKind": "observed", + "summary": "Docker reported container network membership", + "subjectRef": "docker_container_container_gateway", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_gateway", "target": "network_listener_3233_80_tcp", "relationship": "exposes", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_port_publication-docker_container_container_gateway-network_listener_3233_80_tcp", + "provider": "docker", + "kind": "docker_port_publication", + "assertionKind": "observed", + "summary": "Docker reported container port publication", + "subjectRef": "docker_container_container_gateway", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_worker", "target": "docker_network_network_app", "relationship": "connected_to", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_network_membership-docker_container_container_worker-docker_network_network_app", + "provider": "docker", + "kind": "docker_network_membership", + "assertionKind": "observed", + "summary": "Docker reported container network membership", + "subjectRef": "docker_container_container_worker", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_worker", "target": "docker_network_network_data", "relationship": "connected_to", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_network_membership-docker_container_container_worker-docker_network_network_data", + "provider": "docker", + "kind": "docker_network_membership", + "assertionKind": "observed", + "summary": "Docker reported container network membership", + "subjectRef": "docker_container_container_worker", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] }, { "source": "docker_container_container_worker", "target": "docker_volume_volume_app_cache", "relationship": "mounts", - "metadata": {} + "metadata": {}, + "evidenceRefs": [ + { + "version": 1, + "id": "fixture-docker_volume_mount-docker_container_container_worker-docker_volume_volume_app_cache", + "provider": "docker", + "kind": "docker_volume_mount", + "assertionKind": "observed", + "summary": "Docker reported volume attachment", + "subjectRef": "docker_container_container_worker", + "collectedAt": 1787196125766, + "providerRevision": "fixture-boot-3", + "freshness": "fresh" + } + ] + }, + { + "source": "npm_project_dockermap", + "target": "npm_package_express_5_1_0", + "relationship": "depends_on", + "metadata": { + "scope": "dependencies", + "version": "5.1.0" + }, + "evidenceRefs": [ + { + "version": 3, + "id": "fixture-npm-manifest-dependency-dockermap-express", + "provider": "npm", + "kind": "npm_package_manifest_dependency", + "assertionKind": "declared", + "summary": "package manifest declared a dependency", + "subjectRef": "npm_project_dockermap", + "collectedAt": 1787196125766, + "providerRevision": "fixture-project-npm-observation-1", + "providerSlot": "project_npm", + "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": [], "lastUpdated": 1787196125766, "modelRevision": "fixture-boot-3", "providerStates": [ - { "slot": "network_infrastructure", "state": "fresh", "lastAttemptMs": 1787196125700, "lastSuccessMs": 1787196125710, "lastDurationMs": 10, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-1", "statusReason": null }, - { "slot": "host_scoped", "state": "fresh", "lastAttemptMs": 1787196125700, "lastSuccessMs": 1787196125710, "lastDurationMs": 10, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-2", "statusReason": null }, - { "slot": "python_processes", "state": "fresh", "lastAttemptMs": 1787196125700, "lastSuccessMs": 1787196125710, "lastDurationMs": 10, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-3", "statusReason": null }, - { "slot": "native_processes", "state": "fresh", "lastAttemptMs": 1787196125700, "lastSuccessMs": 1787196125710, "lastDurationMs": 10, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-4", "statusReason": null }, - { "slot": "project_npm", "state": "fresh", "lastAttemptMs": 1787196125700, "lastSuccessMs": 1787196125710, "lastDurationMs": 10, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-5", "statusReason": null } + { + "slot": "network_infrastructure", + "state": "fresh", + "lastAttemptMs": 1787196125700, + "lastSuccessMs": 1787196125710, + "lastDurationMs": 10, + "consecutiveFailureCount": 0, + "dataRevision": "fixture-provider-1", + "statusReason": null + }, + { + "slot": "host_scoped", + "state": "fresh", + "lastAttemptMs": 1787196125700, + "lastSuccessMs": 1787196125710, + "lastDurationMs": 10, + "consecutiveFailureCount": 0, + "dataRevision": "fixture-provider-2", + "statusReason": null + }, + { + "slot": "cron", + "state": "fresh", + "lastAttemptMs": 1787196125700, + "lastSuccessMs": 1787196125710, + "lastDurationMs": 10, + "consecutiveFailureCount": 0, + "dataRevision": "fixture-provider-cron", + "statusReason": null + }, + { + "slot": "systemd", + "state": "fresh", + "lastAttemptMs": 1787196125700, + "lastSuccessMs": 1787196125710, + "lastDurationMs": 10, + "consecutiveFailureCount": 0, + "dataRevision": "fixture-provider-systemd", + "statusReason": null + }, + { + "slot": "python_processes", + "state": "fresh", + "lastAttemptMs": 1787196125700, + "lastSuccessMs": 1787196125710, + "lastDurationMs": 10, + "consecutiveFailureCount": 0, + "dataRevision": "fixture-provider-3", + "statusReason": null + }, + { + "slot": "native_processes", + "state": "fresh", + "lastAttemptMs": 1787196125700, + "lastSuccessMs": 1787196125710, + "lastDurationMs": 10, + "consecutiveFailureCount": 0, + "dataRevision": "fixture-provider-4", + "statusReason": null + }, + { + "slot": "project_npm", + "state": "fresh", + "lastAttemptMs": 1787196125700, + "lastSuccessMs": 1787196125710, + "lastDurationMs": 10, + "consecutiveFailureCount": 0, + "dataRevision": "fixture-provider-5", + "statusReason": null + } ] } diff --git a/tests/fixtures/contracts/runtime-map-expanded.json b/tests/fixtures/contracts/runtime-map-expanded.json index 69f50955..f5ba4a2a 100644 --- a/tests/fixtures/contracts/runtime-map-expanded.json +++ b/tests/fixtures/contracts/runtime-map-expanded.json @@ -495,7 +495,8 @@ "relationship": "related_to", "metadata": { "path": "443/tcp" - } + }, + "evidenceRefs": [] }, { "source": "runtime_caddy_proxy", @@ -503,7 +504,8 @@ "relationship": "manages", "metadata": { "unit": "caddy.service" - } + }, + "evidenceRefs": [] }, { "source": "runtime_systemd_caddy", @@ -511,7 +513,8 @@ "relationship": "exposes", "metadata": { "port": "443" - } + }, + "evidenceRefs": [] }, { "source": "runtime_docker_network_app", @@ -519,7 +522,8 @@ "relationship": "connected_to", "metadata": { "scope": "app-net" - } + }, + "evidenceRefs": [] }, { "source": "runtime_container_api", @@ -527,7 +531,8 @@ "relationship": "connected_to", "metadata": { "role": "primary" - } + }, + "evidenceRefs": [] }, { "source": "runtime_database_db", @@ -535,7 +540,8 @@ "relationship": "mounts", "metadata": { "mountPoint": "/var/lib/postgresql/data" - } + }, + "evidenceRefs": [] }, { "source": "runtime_npm_app", @@ -543,7 +549,8 @@ "relationship": "depends_on", "metadata": { "kind": "deployment" - } + }, + "evidenceRefs": [] }, { "source": "runtime_systemd_worker", @@ -551,7 +558,8 @@ "relationship": "wants", "metadata": { "ordering": "after-login" - } + }, + "evidenceRefs": [] }, { "source": "runtime_tmux_worker", @@ -559,7 +567,8 @@ "relationship": "owns", "metadata": { "command": "node worker.js" - } + }, + "evidenceRefs": [] }, { "source": "runtime_npm_app", @@ -567,7 +576,8 @@ "relationship": "depends_on", "metadata": { "manager": "npm" - } + }, + "evidenceRefs": [] }, { "source": "runtime_npm_app", @@ -575,7 +585,8 @@ "relationship": "depends_on", "metadata": { "manager": "npm" - } + }, + "evidenceRefs": [] }, { "source": "runtime_systemd_caddy", @@ -583,7 +594,8 @@ "relationship": "after", "metadata": { "ordering": "proxy" - } + }, + "evidenceRefs": [] } ], "diagnostics": [], @@ -592,6 +604,8 @@ "providerStates": [ { "slot": "network_infrastructure", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-1", "statusReason": null }, { "slot": "host_scoped", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-2", "statusReason": null }, + { "slot": "cron", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-cron", "statusReason": null }, + { "slot": "systemd", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-systemd", "statusReason": null }, { "slot": "python_processes", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-3", "statusReason": null }, { "slot": "native_processes", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-4", "statusReason": null }, { "slot": "project_npm", "state": "fresh", "lastAttemptMs": 1710000001200, "lastSuccessMs": 1710000001230, "lastDurationMs": 30, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-5", "statusReason": null } diff --git a/tests/fixtures/contracts/runtime-map.json b/tests/fixtures/contracts/runtime-map.json index ff8b25d1..259d6fee 100644 --- a/tests/fixtures/contracts/runtime-map.json +++ b/tests/fixtures/contracts/runtime-map.json @@ -28,6 +28,8 @@ "providerStates": [ { "slot": "network_infrastructure", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-1", "statusReason": null }, { "slot": "host_scoped", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-2", "statusReason": null }, + { "slot": "cron", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-cron", "statusReason": null }, + { "slot": "systemd", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-systemd", "statusReason": null }, { "slot": "python_processes", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-3", "statusReason": null }, { "slot": "native_processes", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-4", "statusReason": null }, { "slot": "project_npm", "state": "fresh", "lastAttemptMs": 1710000000000, "lastSuccessMs": 1710000000001, "lastDurationMs": 1, "consecutiveFailureCount": 0, "dataRevision": "fixture-provider-5", "statusReason": null }