From b3014db03d270f74ee5d64851c13493a45bbd1a5 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:50:19 +0800 Subject: [PATCH 01/47] fix: upgrade express security dependency chain --- .github/workflows/ci.yml | 19 +- Dockerfile | 20 +- apps/api/package.json | 4 +- apps/api/src/index.ts | 10 +- apps/api/test/security.test.ts | 4 +- package-lock.json | 850 +++++++++++++++++---------------- 6 files changed, 482 insertions(+), 425 deletions(-) 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/index.ts b/apps/api/src/index.ts index b0ad6574..91777d33 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -680,7 +680,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 +707,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/test/security.test.ts b/apps/api/test/security.test.ts index 0152efd3..a188bcfa 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); @@ -2110,7 +2110,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/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", From 4c12108392d038b094e7ca65c66d5c734b5e83c2 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:40:12 +0800 Subject: [PATCH 02/47] feat: add Docker runtime edge evidence refs --- crates/dockermap-core/src/lib.rs | 61 ++++++++++ crates/dockermap-core/src/models.rs | 65 +++++++++++ crates/dockermap-core/src/snapshot_runtime.rs | 110 ++++++++++++++---- crates/dockermap-daemon/src/main.rs | 60 +++++++++- .../dockermap-daemon/src/provider_contract.rs | 1 + .../src/providers/network_infrastructure.rs | 1 + crates/dockermap-daemon/src/providers/npm.rs | 2 + .../dockermap-daemon/src/providers/systemd.rs | 1 + crates/dockermap-daemon/src/publication.rs | 35 +++++- 9 files changed, 310 insertions(+), 26 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index 85cdfb66..f951e752 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -645,6 +645,67 @@ mod tests { .any(|edge| edge.relationship == RuntimeRelationshipKind::ConnectedTo)); } + #[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()); + 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, RuntimeProviderKind::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!(!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 collision_resistant_topology_ids_preserve_distinct_raw_identities() { // Every raw identity below used to collide after lowercasing and diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index 26b49444..dd6622e1 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -815,12 +815,77 @@ pub struct RuntimeMapNode { pub package: Option, } +/// Whether a runtime claim was directly observed, deterministically derived +/// from bounded observations, or inferred by a future heuristic. This is a +/// closed vocabulary: callers must not translate provider error text into a +/// confidence-like assertion label. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeEvidenceAssertionKind { + Observed, + Derived, + Inferred, +} + +/// 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, +} + +/// 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, Deserialize, JsonSchema, PartialEq, Eq)] +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 = 1))] + pub version: u8, + pub id: String, + pub provider: RuntimeProviderKind, + pub kind: RuntimeEvidenceKind, + #[serde(rename = "assertionKind")] + pub assertion_kind: RuntimeEvidenceAssertionKind, + /// A bounded, curated explanation; it is never copied from a raw source. + pub summary: String, + /// The already-public runtime entity whose Docker fact was observed. + #[serde(rename = "subjectRef")] + pub subject_ref: String, + #[serde(rename = "collectedAt")] + #[schemars(range(max = 9_007_199_254_740_991u64))] + pub collected_at: u64, + /// Opaque Docker observation revision, not a timestamp or source dump. + #[serde(rename = "providerRevision")] + pub provider_revision: String, + /// The Docker snapshot is observed as a single current publication. Host + /// provider freshness remains represented by `providerStates` (#66). + pub freshness: RuntimeEvidenceFreshness, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeEvidenceFreshness { + Fresh, +} + #[derive(Debug, Clone, Serialize, Deserialize, 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, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 6d71a2d6..26572bcd 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -8,7 +8,8 @@ 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, + RelationshipKind, RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, RuntimeEvidenceKind, + RuntimeEvidenceRef, RuntimeMap, RuntimeMapDiagnostic, RuntimeMapEdge, RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, RuntimeProviderKind, RuntimeRelationshipKind, RuntimeServiceEntity, RuntimeServiceStatus, }; @@ -307,6 +308,50 @@ 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, +) -> RuntimeEvidenceRef { + let kind_id = match kind { + RuntimeEvidenceKind::DockerNetworkMembership => "network-membership", + RuntimeEvidenceKind::DockerVolumeMount => "volume-mount", + RuntimeEvidenceKind::DockerPortPublication => "port-publication", + }; + let summary = match kind { + RuntimeEvidenceKind::DockerNetworkMembership => { + "Docker reported container network membership" + } + RuntimeEvidenceKind::DockerVolumeMount => "Docker reported volume attachment", + RuntimeEvidenceKind::DockerPortPublication => "Docker reported container port publication", + }; + let provider_revision = if snapshot.model_revision.is_empty() { + format!("docker-observation-{}", snapshot.last_updated) + } else { + snapshot.model_revision.clone() + }; + RuntimeEvidenceRef { + version: 1, + id: format!( + "docker_evidence_{}_{}", + kind_id, + collision_resistant_id_component(&format!("{source}\u{1f}{target}")) + ), + provider: RuntimeProviderKind::Docker, + kind, + assertion_kind: RuntimeEvidenceAssertionKind::Observed, + summary: summary.into(), + subject_ref: source.into(), + collected_at: snapshot.last_updated, + provider_revision, + freshness: RuntimeEvidenceFreshness::Fresh, + } +} + pub fn derive_runtime_map( snapshot: &DockerSnapshot, mut nodes: Vec, @@ -344,15 +389,23 @@ pub fn derive_runtime_map( }); for network_id in &container.networks { + let source = format!( + "docker_container_{}", + collision_resistant_id_component(&container.id) + ); + 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, + )], + source, + target, relationship: RuntimeRelationshipKind::ConnectedTo, metadata: BTreeMap::new(), }); @@ -381,11 +434,18 @@ pub fn derive_runtime_map( service: None, package: None, }); + let source = format!( + "docker_container_{}", + collision_resistant_id_component(&container.id) + ); edges.push(RuntimeMapEdge { - source: format!( - "docker_container_{}", - collision_resistant_id_component(&container.id) - ), + evidence_refs: vec![docker_runtime_evidence( + snapshot, + &source, + &listener_id, + RuntimeEvidenceKind::DockerPortPublication, + )], + source, target: listener_id, relationship: RuntimeRelationshipKind::Exposes, metadata: BTreeMap::new(), @@ -440,15 +500,23 @@ pub fn derive_runtime_map( .iter() .find(|container| container.name == *attached) { + let source = format!( + "docker_container_{}", + collision_resistant_id_component(&container.id) + ); + 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, + )], + source, + target, relationship: RuntimeRelationshipKind::Mounts, metadata: BTreeMap::new(), }); diff --git a/crates/dockermap-daemon/src/main.rs b/crates/dockermap-daemon/src/main.rs index cd33418d..bd2e3d53 100644 --- a/crates/dockermap-daemon/src/main.rs +++ b/crates/dockermap-daemon/src/main.rs @@ -42,10 +42,11 @@ 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, 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 +1526,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 +1562,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 +1582,54 @@ 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: RuntimeProviderKind::Docker, + kind: RuntimeEvidenceKind::DockerNetworkMembership, + assertion_kind: RuntimeEvidenceAssertionKind::Observed, + summary, + subject_ref: "container\u{202e}id".into(), + collected_at: 1, + provider_revision: oversized.clone(), + freshness: RuntimeEvidenceFreshness::Fresh, + }; + let mut edges = vec![RuntimeMapEdge { + source: "container\u{202e}id".into(), + target: "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 pages_log_entries_to_strictly_older_pages() { let entries = (0..5) @@ -2492,6 +2543,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], diff --git a/crates/dockermap-daemon/src/provider_contract.rs b/crates/dockermap-daemon/src/provider_contract.rs index b60b35c9..47605dde 100644 --- a/crates/dockermap-daemon/src/provider_contract.rs +++ b/crates/dockermap-daemon/src/provider_contract.rs @@ -183,6 +183,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/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..f24bdbbe 100644 --- a/crates/dockermap-daemon/src/providers/npm.rs +++ b/crates/dockermap-daemon/src/providers/npm.rs @@ -137,6 +137,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() { @@ -182,6 +183,7 @@ pub(crate) fn collect_npm_projects( target: package_id, relationship: RuntimeRelationshipKind::DependsOn, metadata: dependency_metadata, + evidence_refs: Vec::new(), }); } } diff --git a/crates/dockermap-daemon/src/providers/systemd.rs b/crates/dockermap-daemon/src/providers/systemd.rs index 030e07e7..16954750 100644 --- a/crates/dockermap-daemon/src/providers/systemd.rs +++ b/crates/dockermap-daemon/src/providers/systemd.rs @@ -182,6 +182,7 @@ pub(crate) fn collect_systemd_services( target, relationship: RuntimeRelationshipKind::DependsOn, metadata, + evidence_refs: Vec::new(), }); } } diff --git a/crates/dockermap-daemon/src/publication.rs b/crates/dockermap-daemon/src/publication.rs index ce4013f2..fd5af1a7 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,7 +195,36 @@ 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); + } +} + +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]) { From 08617a169bc246d5e1bfb5f5707eef360484a166 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:44:08 +0800 Subject: [PATCH 03/47] fix: attest Docker evidence with observation token --- crates/dockermap-core/src/lib.rs | 26 +++++++++++++++++++ crates/dockermap-core/src/models.rs | 6 ++++- crates/dockermap-core/src/snapshot_runtime.rs | 10 +++---- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index f951e752..398ad510 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -694,6 +694,10 @@ mod tests { 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, + snapshot.last_updated.to_string() + ); assert!(!evidence.summary.contains(&snapshot.containers[0].name)); } @@ -706,6 +710,28 @@ mod tests { ); } + #[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()); + let evidence = runtime_map + .edges + .iter() + .flat_map(|edge| &edge.evidence_refs) + .next() + .expect("mock snapshot emits Docker evidence"); + + assert_eq!(evidence.collected_at, 42); + assert_eq!(evidence.provider_revision, "42"); + assert_ne!(evidence.provider_revision, snapshot.model_revision); + } + #[test] fn collision_resistant_topology_ids_preserve_distinct_raw_identities() { // Every raw identity below used to collide after lowercasing and diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index dd6622e1..a09ff643 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -847,12 +847,14 @@ pub struct RuntimeEvidenceRef { /// version. It lets future additions remain explicit and reviewable. #[schemars(range(min = 1, max = 1))] pub version: u8, + #[schemars(length(min = 1, max = 259))] pub id: String, pub provider: RuntimeProviderKind, 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 whose Docker fact was observed. #[serde(rename = "subjectRef")] @@ -860,8 +862,10 @@ pub struct RuntimeEvidenceRef { #[serde(rename = "collectedAt")] #[schemars(range(max = 9_007_199_254_740_991u64))] pub collected_at: u64, - /// Opaque Docker observation revision, not a timestamp or source dump. + /// Opaque Docker observation token, not a cache-publication revision or + /// source dump. #[serde(rename = "providerRevision")] + #[schemars(length(min = 1, max = 259))] pub provider_revision: String, /// The Docker snapshot is observed as a single current publication. Host /// provider freshness remains represented by `providerStates` (#66). diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 26572bcd..fd5508cc 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -329,11 +329,11 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::DockerVolumeMount => "Docker reported volume attachment", RuntimeEvidenceKind::DockerPortPublication => "Docker reported container port publication", }; - let provider_revision = if snapshot.model_revision.is_empty() { - format!("docker-observation-{}", snapshot.last_updated) - } else { - snapshot.model_revision.clone() - }; + // `modelRevision` is assigned only after runtime-map derivation and means + // cache publication identity. Evidence must instead attest the Docker + // observation it was derived from. The fixed decimal form is nonempty, + // bounded, deterministic, and contains no Docker source text. + let provider_revision = snapshot.last_updated.to_string(); RuntimeEvidenceRef { version: 1, id: format!( From d6a72cdc5ee52490cec9c5f5a1f165d9a0737518 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:55:42 +0800 Subject: [PATCH 04/47] feat: expose runtime edge evidence inspector --- apps/api/src/daemonResponseValidation.ts | 2 +- apps/api/test/security.test.ts | 44 ++- apps/web/src/lib/demoData.ts | 12 +- apps/web/src/screens/Runtime.tsx | 90 +++++- .../runtime-evidence-inspector.test.tsx | 73 +++++ apps/web/src/styles.css | 50 +++ docs/architecture/ARCHITECTURE.md | 34 ++ docs/security/THREAT_MODEL.md | 7 + .../generated/rust/runtime-map.schema.json | 101 +++++- packages/contracts/src/index.ts | 3 + packages/contracts/src/rustModels.ts | 89 ++++++ packages/contracts/src/rustSchemas.ts | 202 +++++++++++- .../contracts/src/schema-fixtures.test.ts | 8 +- tests/e2e/a11y.spec.ts | 6 + tests/e2e/dockermap.spec.ts | 10 + .../contracts/runtime-map-daemon-emitted.json | 295 ++++++++++++++++-- .../contracts/runtime-map-expanded.json | 36 ++- 17 files changed, 1009 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/screens/runtime-evidence-inspector.test.tsx diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index e74435a6..38f1a3d1 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -32,7 +32,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)]), diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index a188bcfa..597a6f65 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -902,7 +902,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)); @@ -1112,6 +1112,27 @@ 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)); +}); + 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 +1144,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,7 +1995,24 @@ 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: hostile, + target: hostile, + relationship: "depends_on", + metadata: { [hostile]: hostile }, + evidenceRefs: [{ + version: 1, + id: hostile, + provider: "docker", + kind: "docker_network_membership", + assertionKind: "observed", + summary: hostile, + subjectRef: hostile, + collectedAt: 1, + providerRevision: hostile, + freshness: "fresh" + }] + }], diagnostics: [{ provider: "other", severity: "warning", message: hostile }], lastUpdated: 1, modelRevision: "revision-1", diff --git a/apps/web/src/lib/demoData.ts b/apps/web/src/lib/demoData.ts index 16959210..515e1ec5 100644 --- a/apps/web/src/lib/demoData.ts +++ b/apps/web/src/lib/demoData.ts @@ -393,12 +393,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: [ { diff --git a/apps/web/src/screens/Runtime.tsx b/apps/web/src/screens/Runtime.tsx index c2e8b129..fce6fd77 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"; @@ -83,6 +83,25 @@ const PROVIDER_REASON_LABEL: Record = { disabled: "Collection disabled" }; +const ASSERTION_KIND_LABEL: Record = { + observed: "Observed fact", + derived: "Derived relationship", + inferred: "Inferred relationship" +}; + +const FRESHNESS_LABEL: Record = { + fresh: "Current at collection" +}; + +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 +114,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 +184,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)) { @@ -409,8 +430,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 +505,9 @@ function RelationList({ model, edges, direction, - onSelect + onSelect, + onInspectEdge, + selectedEdgeKey }: { title: string; selected: RuntimeNodeRecord; @@ -490,6 +515,8 @@ function RelationList({ edges: RuntimeNodeRecord["incoming"]; direction: "incoming" | "outgoing"; onSelect: (id: string) => void; + onInspectEdge: (edge: RuntimeMapEdge) => void; + selectedEdgeKey: string | null; }) { return (
@@ -499,6 +526,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 +536,9 @@ function RelationList({ {edge.relationship.replaceAll("_", " ")} {identityText(targetId, UNAVAILABLE_RUNTIME_ID)} {collided && {COLLISION_TAG}} + ); } @@ -519,6 +550,9 @@ function RelationList({ {identityText(node.label, UNAVAILABLE_RUNTIME_NODE)} {edge.relationship.replaceAll("_", " ")} + ); })} @@ -528,6 +562,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/runtime-evidence-inspector.test.tsx b/apps/web/src/screens/runtime-evidence-inspector.test.tsx new file mode 100644 index 00000000..e9d9c314 --- /dev/null +++ b/apps/web/src/screens/runtime-evidence-inspector.test.tsx @@ -0,0 +1,73 @@ +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: {} } + ], + 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: [] + } + ], + 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"); + }); +}); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index fe9e0816..37738852 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -983,6 +983,7 @@ kbd { .runtime-edge-row { display: flex; align-items: center; + flex-wrap: wrap; gap: var(--s2); } @@ -1001,6 +1002,55 @@ kbd { text-overflow: ellipsis; } +.runtime-edge-evidence { + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--surface-2); + color: var(--ink-soft); + font-size: 12px; + padding: 6px 8px; + white-space: nowrap; +} + +.runtime-edge-evidence:hover, +.runtime-edge-evidence.is-active { + border-color: var(--border-strong); + background: var(--accent-soft); + color: var(--ink); +} + +.runtime-edge-evidence-heading { + color: var(--ink); + font-weight: 600; + margin: 0 0 var(--s2); + overflow-wrap: anywhere; +} + +.runtime-evidence-reference { + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--surface-2); + padding: var(--s3); +} + +.runtime-evidence-reference-head { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--s2); +} + +.runtime-evidence-reference p { + color: var(--ink-soft); + margin: var(--s2) 0; + overflow-wrap: anywhere; +} + +.runtime-evidence-reference-details { + display: grid; + gap: var(--s2); +} + .runtime-evidence-list li { display: flex; align-items: center; diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index ae2f56c6..7db98522 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -50,6 +50,40 @@ 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 +slice emits 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 +``` + +The first facts are Docker network membership, volume attachment, and port +publication. They are `observed`, carry the Docker collection timestamp and +opaque publication revision, and declare `fresh` only for that Docker +observation. Provider-slot freshness continues to describe optional host +collection separately. 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. + +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 published port | observed | emitted | +| systemd, npm, tmux, proxy, DNS, process and cross-provider edges | bounded provider-specific collector facts | varies | explicit empty migration array; no invented provenance | + 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/security/THREAT_MODEL.md b/docs/security/THREAT_MODEL.md index 55cd0635..f293b51f 100644 --- a/docs/security/THREAT_MODEL.md +++ b/docs/security/THREAT_MODEL.md @@ -41,6 +41,11 @@ 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. ## Main Risks And Protections @@ -144,6 +149,8 @@ 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. - 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/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index bab494df..c4ffb5ca 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -159,6 +159,96 @@ ], "type": "object" }, + "RuntimeEvidenceAssertionKind": { + "description": "Whether a runtime claim was directly observed, deterministically derived\nfrom bounded observations, or inferred by a future heuristic. This is a\nclosed vocabulary: callers must not translate provider error text into a\nconfidence-like assertion label.", + "enum": [ + "observed", + "derived", + "inferred" + ], + "type": "string" + }, + "RuntimeEvidenceFreshness": { + "enum": [ + "fresh" + ], + "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.", + "enum": [ + "docker_network_membership", + "docker_volume_mount", + "docker_port_publication" + ], + "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", + "description": "The Docker snapshot is observed as a single current publication. Host\nprovider freshness remains represented by `providerStates` (#66)." + }, + "id": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "kind": { + "$ref": "#/$defs/RuntimeEvidenceKind" + }, + "provider": { + "$ref": "#/$defs/RuntimeProviderKind" + }, + "providerRevision": { + "description": "Opaque Docker observation token, not a cache-publication revision or\nsource dump.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "subjectRef": { + "description": "The already-public runtime entity whose Docker fact was observed.", + "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": 1, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "id", + "provider", + "kind", + "assertionKind", + "summary", + "subjectRef", + "collectedAt", + "providerRevision", + "freshness" + ], + "type": "object" + }, "RuntimeHealth": { "additionalProperties": false, "properties": { @@ -293,6 +383,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 +411,8 @@ "source", "target", "relationship", - "metadata" + "metadata", + "evidenceRefs" ], "type": "object" }, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 572c6dea..64db988a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -35,6 +35,9 @@ export type { NodeKind, RelationshipKind, RuntimeAdvisorySeverity, + RuntimeEvidenceAssertionKind, + RuntimeEvidenceKind, + RuntimeEvidenceRef, RuntimeEventRef, RuntimeHealth, RuntimeHealthState, diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index 3cd039ff..55ea2c0d 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -42,6 +42,19 @@ export type RuntimeProviderKind = | 'kubernetes' | 'other'; export type DiagnosticSeverity = 'info' | 'warning' | 'error' | 'blocked'; +/** + * Whether a runtime claim was directly observed, deterministically derived + * from bounded observations, or inferred by a future heuristic. This is a + * closed vocabulary: callers must not translate provider error text into a + * confidence-like assertion label. + */ +export type RuntimeEvidenceAssertionKind = 'observed' | 'derived' | 'inferred'; +/** + * 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'; export type RuntimeRelationshipKind = | 'connected_to' | 'depends_on' @@ -212,6 +225,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 +273,41 @@ 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; + /** + * The Docker snapshot is observed as a single current publication. Host + * provider freshness remains represented by `providerStates` (#66). + */ + freshness: 'fresh'; + id: string; + kind: RuntimeEvidenceKind; + provider: RuntimeProviderKind; + /** + * Opaque Docker observation token, not a cache-publication revision or + * source dump. + */ + providerRevision: string; + /** + * The already-public runtime entity whose Docker fact was observed. + */ + 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; diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index 093f899b..1598713c 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -487,6 +487,96 @@ export const RUST_RESPONSE_SCHEMAS = { ], "type": "object" }, + "RuntimeEvidenceAssertionKind": { + "description": "Whether a runtime claim was directly observed, deterministically derived\nfrom bounded observations, or inferred by a future heuristic. This is a\nclosed vocabulary: callers must not translate provider error text into a\nconfidence-like assertion label.", + "enum": [ + "observed", + "derived", + "inferred" + ], + "type": "string" + }, + "RuntimeEvidenceFreshness": { + "enum": [ + "fresh" + ], + "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.", + "enum": [ + "docker_network_membership", + "docker_volume_mount", + "docker_port_publication" + ], + "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", + "description": "The Docker snapshot is observed as a single current publication. Host\nprovider freshness remains represented by `providerStates` (#66)." + }, + "id": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "kind": { + "$ref": "#/$defs/RuntimeEvidenceKind" + }, + "provider": { + "$ref": "#/$defs/RuntimeProviderKind" + }, + "providerRevision": { + "description": "Opaque Docker observation token, not a cache-publication revision or\nsource dump.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "subjectRef": { + "description": "The already-public runtime entity whose Docker fact was observed.", + "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": 1, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "id", + "provider", + "kind", + "assertionKind", + "summary", + "subjectRef", + "collectedAt", + "providerRevision", + "freshness" + ], + "type": "object" + }, "RuntimeHealth": { "additionalProperties": false, "properties": { @@ -621,6 +711,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 +739,8 @@ export const RUST_RESPONSE_SCHEMAS = { "source", "target", "relationship", - "metadata" + "metadata", + "evidenceRefs" ], "type": "object" }, @@ -2590,6 +2689,96 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { ], "type": "object" }, + "RuntimeEvidenceAssertionKind": { + "description": "Whether a runtime claim was directly observed, deterministically derived\nfrom bounded observations, or inferred by a future heuristic. This is a\nclosed vocabulary: callers must not translate provider error text into a\nconfidence-like assertion label.", + "enum": [ + "observed", + "derived", + "inferred" + ], + "type": "string" + }, + "RuntimeEvidenceFreshness": { + "enum": [ + "fresh" + ], + "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.", + "enum": [ + "docker_network_membership", + "docker_volume_mount", + "docker_port_publication" + ], + "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", + "description": "The Docker snapshot is observed as a single current publication. Host\nprovider freshness remains represented by `providerStates` (#66)." + }, + "id": { + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceKind" + }, + "provider": { + "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeProviderKind" + }, + "providerRevision": { + "description": "Opaque Docker observation token, not a cache-publication revision or\nsource dump.", + "maxLength": 259, + "minLength": 1, + "type": "string" + }, + "subjectRef": { + "description": "The already-public runtime entity whose Docker fact was observed.", + "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": 1, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "version", + "id", + "provider", + "kind", + "assertionKind", + "summary", + "subjectRef", + "collectedAt", + "providerRevision", + "freshness" + ], + "type": "object" + }, "RuntimeHealth": { "additionalProperties": false, "properties": { @@ -2724,6 +2913,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 +2941,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "source", "target", "relationship", - "metadata" + "metadata", + "evidenceRefs" ], "type": "object" }, diff --git a/packages/contracts/src/schema-fixtures.test.ts b/packages/contracts/src/schema-fixtures.test.ts index 13852ac4..c985ebe3 100644 --- a/packages/contracts/src/schema-fixtures.test.ts +++ b/packages/contracts/src/schema-fixtures.test.ts @@ -29,7 +29,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 +39,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 +52,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 +65,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/tests/e2e/a11y.spec.ts b/tests/e2e/a11y.spec.ts index 6b6d73e1..aa6d01da 100644 --- a/tests/e2e/a11y.spec.ts +++ b/tests/e2e/a11y.spec.ts @@ -263,6 +263,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..b4f26fc6 100644 --- a/tests/e2e/dockermap.spec.ts +++ b/tests/e2e/dockermap.spec.ts @@ -193,6 +193,16 @@ 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.getByRole("button", { name: "Inspect evidence" }).first(); + 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/runtime-map-daemon-emitted.json b/tests/fixtures/contracts/runtime-map-daemon-emitted.json index 77e3c961..8bda275f 100644 --- a/tests/fixtures/contracts/runtime-map-daemon-emitted.json +++ b/tests/fixtures/contracts/runtime-map-daemon-emitted.json @@ -217,101 +217,356 @@ "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" + } + ] } ], "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": "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..4cb29066 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": [], From 7202d087a27c9c530ab733510e9c2bd0a4331ea1 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:57:51 +0800 Subject: [PATCH 05/47] docs: describe runtime evidence observation token accurately --- docs/architecture/ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 7db98522..837971af 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -62,7 +62,7 @@ collector -> bounded RuntimeEvidenceRef -> RuntimeMapEdge -> daemon publication/ The first facts are Docker network membership, volume attachment, and port publication. They are `observed`, carry the Docker collection timestamp and -opaque publication revision, and declare `fresh` only for that Docker +opaque Docker observation token, and declare `fresh` only for that Docker observation. Provider-slot freshness continues to describe optional host collection separately. An empty array is explicit migration state for a relationship family that has not yet gained provenance; it must not be From 62e34eb34d01fbb0477cbd45c1cf591b6a81c2ba Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:07:28 +0800 Subject: [PATCH 06/47] fix: harden version one runtime evidence --- crates/dockermap-core/src/lib.rs | 42 ++++- crates/dockermap-core/src/models.rs | 20 +- crates/dockermap-core/src/snapshot_runtime.rs | 40 +++- crates/dockermap-daemon/src/cache_refresh.rs | 173 ++++++++++++++++-- crates/dockermap-daemon/src/main.rs | 11 +- .../src/runtime_collection.rs | 15 +- 6 files changed, 257 insertions(+), 44 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index 398ad510..3ae5afdb 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -17,7 +17,9 @@ pub use logs::{ MAX_LOG_PAGE_SIZE, }; pub use models::*; -pub use snapshot_runtime::{derive_graph, derive_images, derive_runtime_map}; +pub use snapshot_runtime::{ + derive_graph, derive_images, derive_runtime_map, derive_runtime_map_with_evidence_revision, +}; pub fn service_entity_kind_name(kind: &ServiceEntityKind) -> &'static str { match kind { @@ -686,7 +688,7 @@ mod tests { assert_eq!(edge.evidence_refs.len(), 1); let evidence = &edge.evidence_refs[0]; assert_eq!(evidence.version, 1); - assert_eq!(evidence.provider, RuntimeProviderKind::Docker); + assert_eq!(evidence.provider, RuntimeEvidenceProvider::Docker); assert_eq!( evidence.assertion_kind, RuntimeEvidenceAssertionKind::Observed @@ -719,7 +721,13 @@ mod tests { // into provider evidence. snapshot.model_revision = "daemon-publication-999".into(); - let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new()); + let runtime_map = derive_runtime_map_with_evidence_revision( + &snapshot, + Vec::new(), + Vec::new(), + Vec::new(), + "opaque-docker-observation-17", + ); let evidence = runtime_map .edges .iter() @@ -728,10 +736,36 @@ mod tests { .expect("mock snapshot emits Docker evidence"); assert_eq!(evidence.collected_at, 42); - assert_eq!(evidence.provider_revision, "42"); + assert_eq!(evidence.provider_revision, "opaque-docker-observation-17"); assert_ne!(evidence.provider_revision, snapshot.model_revision); } + #[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()) + .edges + .into_iter() + .flat_map(|edge| edge.evidence_refs) + .next() + .expect("mock 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 collision_resistant_topology_ids_preserve_distinct_raw_identities() { // Every raw identity below used to collide after lowercasing and diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index a09ff643..eef3164a 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -815,16 +815,22 @@ pub struct RuntimeMapNode { pub package: Option, } -/// Whether a runtime claim was directly observed, deterministically derived -/// from bounded observations, or inferred by a future heuristic. This is a -/// closed vocabulary: callers must not translate provider error text into a -/// confidence-like assertion label. +/// Evidence provider for the version-one Docker-only evidence shape. New +/// providers require a new versioned evidence representation; they cannot be +/// passed off as v1 through the broad runtime-provider enum. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeEvidenceProvider { + Docker, +} + +/// Version-one evidence is a direct Docker observation. Derived and inferred +/// claims need a later, deliberately versioned evidence contract rather than +/// a permissive enum value in this first slice. #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RuntimeEvidenceAssertionKind { Observed, - Derived, - Inferred, } /// Safe, provider-specific fact families supported by the first provenance @@ -849,7 +855,7 @@ pub struct RuntimeEvidenceRef { pub version: u8, #[schemars(length(min = 1, max = 259))] pub id: String, - pub provider: RuntimeProviderKind, + pub provider: RuntimeEvidenceProvider, pub kind: RuntimeEvidenceKind, #[serde(rename = "assertionKind")] pub assertion_kind: RuntimeEvidenceAssertionKind, diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index fd5508cc..6beb9322 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -9,9 +9,9 @@ use crate::{ collision_resistant_id_component, service_entity_kind_name, ContainerRecord, DiagnosticSeverity, DockerSnapshot, GraphEdge, GraphNode, GraphResponse, ImageRecord, NodeKind, RelationshipKind, RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, RuntimeEvidenceKind, - RuntimeEvidenceRef, RuntimeMap, RuntimeMapDiagnostic, RuntimeMapEdge, RuntimeMapNode, - RuntimeNodeKind, RuntimeNodeLayer, RuntimeProviderKind, RuntimeRelationshipKind, - RuntimeServiceEntity, RuntimeServiceStatus, + RuntimeEvidenceProvider, RuntimeEvidenceRef, RuntimeMap, RuntimeMapDiagnostic, RuntimeMapEdge, + RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, RuntimeProviderKind, + RuntimeRelationshipKind, RuntimeServiceEntity, RuntimeServiceStatus, }; pub fn derive_images(snapshot: &DockerSnapshot) -> Vec { @@ -316,6 +316,7 @@ fn docker_runtime_evidence( source: &str, target: &str, kind: RuntimeEvidenceKind, + provider_revision: &str, ) -> RuntimeEvidenceRef { let kind_id = match kind { RuntimeEvidenceKind::DockerNetworkMembership => "network-membership", @@ -329,11 +330,6 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::DockerVolumeMount => "Docker reported volume attachment", RuntimeEvidenceKind::DockerPortPublication => "Docker reported container port publication", }; - // `modelRevision` is assigned only after runtime-map derivation and means - // cache publication identity. Evidence must instead attest the Docker - // observation it was derived from. The fixed decimal form is nonempty, - // bounded, deterministic, and contains no Docker source text. - let provider_revision = snapshot.last_updated.to_string(); RuntimeEvidenceRef { version: 1, id: format!( @@ -341,22 +337,43 @@ fn docker_runtime_evidence( kind_id, collision_resistant_id_component(&format!("{source}\u{1f}{target}")) ), - provider: RuntimeProviderKind::Docker, + provider: RuntimeEvidenceProvider::Docker, kind, assertion_kind: RuntimeEvidenceAssertionKind::Observed, summary: summary.into(), subject_ref: source.into(), collected_at: snapshot.last_updated, - provider_revision, + provider_revision: provider_revision.into(), freshness: RuntimeEvidenceFreshness::Fresh, } } pub fn derive_runtime_map( + snapshot: &DockerSnapshot, + nodes: Vec, + edges: Vec, + diagnostics: Vec, +) -> RuntimeMap { + // Direct core callers have no daemon observation-token lifecycle. The + // daemon uses the explicit variant below; this compatibility projection is + // still nonempty and contains no raw Docker source text. + derive_runtime_map_with_evidence_revision( + snapshot, + nodes, + edges, + diagnostics, + &snapshot.last_updated.to_string(), + ) +} + +/// Derive runtime topology with the daemon-owned opaque Docker observation +/// token that attests the bounded snapshot used for this map. +pub fn derive_runtime_map_with_evidence_revision( snapshot: &DockerSnapshot, mut nodes: Vec, mut edges: Vec, mut diagnostics: Vec, + evidence_provider_revision: &str, ) -> RuntimeMap { for container in &snapshot.containers { let mut metadata = BTreeMap::new(); @@ -403,6 +420,7 @@ pub fn derive_runtime_map( &source, &target, RuntimeEvidenceKind::DockerNetworkMembership, + evidence_provider_revision, )], source, target, @@ -444,6 +462,7 @@ pub fn derive_runtime_map( &source, &listener_id, RuntimeEvidenceKind::DockerPortPublication, + evidence_provider_revision, )], source, target: listener_id, @@ -514,6 +533,7 @@ pub fn derive_runtime_map( &source, &target, RuntimeEvidenceKind::DockerVolumeMount, + evidence_provider_revision, )], source, target, diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index fbf22bae..be19e7bf 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -63,6 +63,10 @@ pub(crate) struct DaemonCache { /// 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 +133,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 +198,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 { @@ -314,8 +375,10 @@ impl DaemonCache { }, 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 } @@ -326,6 +389,25 @@ impl DaemonCache { self.revision .assign(&mut self.snapshot, &mut self.health, &mut self.runtime_map); } + + 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.runtime_providers, + &docker_observation_token, + ); + } } pub(crate) async fn refresh_loop(state: AppState) { @@ -407,7 +489,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; @@ -467,6 +550,7 @@ async fn collect_snapshot(state: &AppState) -> DaemonCache { runtime_map: empty_runtime_map(0), runtime_providers: unavailable_provider_slots(), source_generation: 0, + docker_observation_revision: DockerObservationRevision::new(), revision: PublicationRevision::new(), } } @@ -599,7 +683,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, + 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() { @@ -778,7 +866,8 @@ fn runtime_map_for_snapshot(snapshot: &DockerSnapshot, slots: &RuntimeProviderSl }); } } - let mut runtime_map = runtime_map_from_collection(snapshot, &combined); + let mut runtime_map = + runtime_map_from_collection(snapshot, &combined, docker_observation_revision); runtime_map.provider_states = provider_states_for(slots); runtime_map.diagnostics.extend(extra_diagnostics); runtime_map @@ -958,7 +1047,7 @@ mod scheduler_tests { fn docker_cache(snapshot: DockerSnapshot) -> DaemonCache { let last_updated = snapshot.last_updated; - DaemonCache { + let mut cache = DaemonCache { snapshot, health: HealthResponse { status: HealthState::Ok, @@ -972,8 +1061,23 @@ mod scheduler_tests { runtime_map: empty_runtime_map(last_updated), 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 +1107,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(); } @@ -1441,8 +1545,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)), @@ -1531,7 +1634,7 @@ 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, &slots, "test-observation"); assert!(map .provider_states .iter() @@ -1657,7 +1760,7 @@ 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, &slots, "test-observation"); assert!(map .nodes .iter() @@ -1739,7 +1842,7 @@ mod scheduler_tests { entry.freshness.status_reason = Some(ProviderStatusReason::Refreshing); retained }; - let refreshing = runtime_map_for_snapshot(&snapshot, &slots) + let refreshing = runtime_map_for_snapshot(&snapshot, &slots, "test-observation") .provider_states .into_iter() .find(|state| state.slot == slot) @@ -1757,7 +1860,7 @@ 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) + let timed_out = runtime_map_for_snapshot(&snapshot, &slots, "test-observation") .provider_states .into_iter() .find(|state| state.slot == slot) @@ -1938,4 +2041,46 @@ 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_ne!(first_docker_evidence_revision(&mock_cache), changed_token); + } } diff --git a/crates/dockermap-daemon/src/main.rs b/crates/dockermap-daemon/src/main.rs index bd2e3d53..73c7dcec 100644 --- a/crates/dockermap-daemon/src/main.rs +++ b/crates/dockermap-daemon/src/main.rs @@ -43,10 +43,11 @@ use dockermap_core::mock_log_entries; use dockermap_core::{ derive_runtime_map, page_log_entries, ComposeFileOrigin, ComposeMountKind, ContainerMount, DiagnosticSeverity, LogCursor, LogEntry, NetworkRecord, RuntimeEvidenceAssertionKind, - RuntimeEvidenceFreshness, RuntimeEvidenceKind, RuntimeEvidenceRef, RuntimeMapDiagnostic, - RuntimeMapEdge, RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, RuntimeOwnership, - RuntimePackageEntity, RuntimeProviderKind, RuntimeRelationshipKind, RuntimeServiceEntity, - RuntimeServiceStatus, VolumeRecord, DEFAULT_LOG_PAGE_SIZE, MAX_LOG_PAGE_SIZE, + 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}; @@ -1589,7 +1590,7 @@ mod tests { let evidence = |summary: String| RuntimeEvidenceRef { version: 1, id: format!("evidence-{oversized}"), - provider: RuntimeProviderKind::Docker, + provider: RuntimeEvidenceProvider::Docker, kind: RuntimeEvidenceKind::DockerNetworkMembership, assertion_kind: RuntimeEvidenceAssertionKind::Observed, summary, diff --git a/crates/dockermap-daemon/src/runtime_collection.rs b/crates/dockermap-daemon/src/runtime_collection.rs index a7f7eca2..07872d56 100644 --- a/crates/dockermap-daemon/src/runtime_collection.rs +++ b/crates/dockermap-daemon/src/runtime_collection.rs @@ -21,9 +21,9 @@ use crate::{ publication::redact_runtime_map, }; use dockermap_core::{ - derive_runtime_map, service_entity_kind_name, DiagnosticSeverity, DockerSnapshot, ProviderSlot, - ProviderStateKind, RuntimeMap, RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, - RuntimeProviderKind, ServiceEntityKind, + derive_runtime_map_with_evidence_revision, service_entity_kind_name, DiagnosticSeverity, + DockerSnapshot, ProviderSlot, ProviderStateKind, RuntimeMap, RuntimeMapNode, RuntimeNodeKind, + RuntimeNodeLayer, RuntimeProviderKind, ServiceEntityKind, }; use std::{ collections::BTreeMap, @@ -113,9 +113,16 @@ pub(crate) async fn collect_provider_slot_bounded( pub(crate) fn runtime_map_from_collection( snapshot: &DockerSnapshot, collection: &ProviderCollection, + docker_observation_revision: &str, ) -> 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_with_evidence_revision( + snapshot, + nodes, + edges, + diagnostics, + docker_observation_revision, + ); redact_runtime_map(&mut runtime_map); runtime_map } From 41d53d580ae0478af465746b1faf882d84d9bafe Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:13:28 +0800 Subject: [PATCH 07/47] fix: require explicit runtime evidence tokens --- crates/dockermap-core/src/lib.rs | 50 ++++++++----- crates/dockermap-core/src/models.rs | 75 ++++++++++++++++++- crates/dockermap-core/src/snapshot_runtime.rs | 24 +----- crates/dockermap-daemon/src/main.rs | 37 ++++++++- crates/dockermap-daemon/src/publication.rs | 6 ++ .../src/runtime_collection.rs | 8 +- 6 files changed, 154 insertions(+), 46 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index 3ae5afdb..b6c402a3 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -17,9 +17,7 @@ pub use logs::{ MAX_LOG_PAGE_SIZE, }; pub use models::*; -pub use snapshot_runtime::{ - derive_graph, derive_images, derive_runtime_map, derive_runtime_map_with_evidence_revision, -}; +pub use snapshot_runtime::{derive_graph, derive_images, derive_runtime_map}; pub fn service_entity_kind_name(kind: &ServiceEntityKind) -> &'static str { match kind { @@ -629,7 +627,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 @@ -650,7 +648,7 @@ mod tests { #[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()); + let runtime_map = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test"); let network = runtime_map .edges .iter() @@ -696,10 +694,7 @@ mod tests { 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, - snapshot.last_updated.to_string() - ); + assert_eq!(evidence.provider_revision, "test"); assert!(!evidence.summary.contains(&snapshot.containers[0].name)); } @@ -721,7 +716,7 @@ mod tests { // into provider evidence. snapshot.model_revision = "daemon-publication-999".into(); - let runtime_map = derive_runtime_map_with_evidence_revision( + let runtime_map = derive_runtime_map( &snapshot, Vec::new(), Vec::new(), @@ -743,7 +738,7 @@ mod tests { #[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()) + let evidence = derive_runtime_map(&snapshot, Vec::new(), Vec::new(), Vec::new(), "test") .edges .into_iter() .flat_map(|edge| edge.evidence_refs) @@ -766,6 +761,26 @@ mod tests { } } + #[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 @@ -793,7 +808,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() @@ -886,7 +901,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() @@ -922,8 +937,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); @@ -946,7 +962,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() @@ -971,7 +987,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 eef3164a..18c876e3 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -884,7 +884,7 @@ pub enum RuntimeEvidenceFreshness { Fresh, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] pub struct RuntimeMapEdge { pub source: String, pub target: String, @@ -898,6 +898,79 @@ pub struct RuntimeMapEdge { pub evidence_refs: Vec, } +impl RuntimeMapEdge { + /// Version-one evidence is attached only to the semantic edge it directly + /// observes. Keep this at the canonical model boundary so a structurally + /// valid Docker 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.version != 1 + || 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; + } + + match evidence.kind { + RuntimeEvidenceKind::DockerNetworkMembership => { + self.relationship == RuntimeRelationshipKind::ConnectedTo + && self.source.starts_with("docker_container_") + && self.target.starts_with("docker_network_") + } + RuntimeEvidenceKind::DockerVolumeMount => { + self.relationship == RuntimeRelationshipKind::Mounts + && self.source.starts_with("docker_container_") + && self.target.starts_with("docker_volume_") + } + RuntimeEvidenceKind::DockerPortPublication => { + self.relationship == RuntimeRelationshipKind::Exposes + && self.source.starts_with("docker_container_") + && self.target.starts_with("network_listener_") + } + } + } +} + +#[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)] pub struct RuntimeMapDiagnostic { pub provider: RuntimeProviderKind, diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 6beb9322..785c3e02 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -348,27 +348,11 @@ fn docker_runtime_evidence( } } -pub fn derive_runtime_map( - snapshot: &DockerSnapshot, - nodes: Vec, - edges: Vec, - diagnostics: Vec, -) -> RuntimeMap { - // Direct core callers have no daemon observation-token lifecycle. The - // daemon uses the explicit variant below; this compatibility projection is - // still nonempty and contains no raw Docker source text. - derive_runtime_map_with_evidence_revision( - snapshot, - nodes, - edges, - diagnostics, - &snapshot.last_updated.to_string(), - ) -} - /// Derive runtime topology with the daemon-owned opaque Docker observation -/// token that attests the bounded snapshot used for this map. -pub fn derive_runtime_map_with_evidence_revision( +/// 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, diff --git a/crates/dockermap-daemon/src/main.rs b/crates/dockermap-daemon/src/main.rs index 73c7dcec..54ecc35b 100644 --- a/crates/dockermap-daemon/src/main.rs +++ b/crates/dockermap-daemon/src/main.rs @@ -1594,14 +1594,14 @@ mod tests { kind: RuntimeEvidenceKind::DockerNetworkMembership, assertion_kind: RuntimeEvidenceAssertionKind::Observed, summary, - subject_ref: "container\u{202e}id".into(), + subject_ref: "docker_container_\u{202e}id".into(), collected_at: 1, provider_revision: oversized.clone(), freshness: RuntimeEvidenceFreshness::Fresh, }; let mut edges = vec![RuntimeMapEdge { - source: "container\u{202e}id".into(), - target: "network".into(), + source: "docker_container_\u{202e}id".into(), + target: "docker_network_network".into(), relationship: RuntimeRelationshipKind::ConnectedTo, metadata: BTreeMap::new(), evidence_refs: vec![ @@ -1631,6 +1631,35 @@ mod tests { 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" + ); + } + #[test] fn pages_log_entries_to_strictly_older_pages() { let entries = (0..5) @@ -2039,7 +2068,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 diff --git a/crates/dockermap-daemon/src/publication.rs b/crates/dockermap-daemon/src/publication.rs index fd5af1a7..c3a3f626 100644 --- a/crates/dockermap-daemon/src/publication.rs +++ b/crates/dockermap-daemon/src/publication.rs @@ -196,6 +196,12 @@ pub(crate) fn redact_runtime_edges(edges: &mut [RuntimeMapEdge]) { *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(); + } } } diff --git a/crates/dockermap-daemon/src/runtime_collection.rs b/crates/dockermap-daemon/src/runtime_collection.rs index 07872d56..b5fd7da6 100644 --- a/crates/dockermap-daemon/src/runtime_collection.rs +++ b/crates/dockermap-daemon/src/runtime_collection.rs @@ -21,9 +21,9 @@ use crate::{ publication::redact_runtime_map, }; use dockermap_core::{ - derive_runtime_map_with_evidence_revision, service_entity_kind_name, DiagnosticSeverity, - DockerSnapshot, ProviderSlot, ProviderStateKind, RuntimeMap, RuntimeMapNode, RuntimeNodeKind, - RuntimeNodeLayer, RuntimeProviderKind, ServiceEntityKind, + derive_runtime_map, service_entity_kind_name, DiagnosticSeverity, DockerSnapshot, ProviderSlot, + ProviderStateKind, RuntimeMap, RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, + RuntimeProviderKind, ServiceEntityKind, }; use std::{ collections::BTreeMap, @@ -116,7 +116,7 @@ pub(crate) fn runtime_map_from_collection( docker_observation_revision: &str, ) -> RuntimeMap { let (nodes, edges, diagnostics) = collection.clone().into_parts(); - let mut runtime_map = derive_runtime_map_with_evidence_revision( + let mut runtime_map = derive_runtime_map( snapshot, nodes, edges, From 8ad070ce405c7d3a56f123644bc77becae0f5ca3 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:17:08 +0800 Subject: [PATCH 08/47] fix: reject empty runtime evidence tokens --- crates/dockermap-core/src/lib.rs | 12 ++++++++++++ crates/dockermap-core/src/snapshot_runtime.rs | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index b6c402a3..73ecf628 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -735,6 +735,18 @@ mod tests { 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(); diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 785c3e02..067beb8d 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -359,6 +359,10 @@ pub fn derive_runtime_map( mut diagnostics: Vec, evidence_provider_revision: &str, ) -> RuntimeMap { + assert!( + !evidence_provider_revision.is_empty(), + "runtime evidence requires a nonempty opaque Docker observation token" + ); for container in &snapshot.containers { let mut metadata = BTreeMap::new(); metadata.insert("image".into(), container.image.clone()); From 2758383834efc1b2b7615d49113821d53e9b9283 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:13:45 +0800 Subject: [PATCH 09/47] fix: validate version one evidence semantics --- apps/api/src/daemonResponseValidation.ts | 35 +++++++++- apps/api/test/security.test.ts | 64 ++++++++++++++++++- apps/web/src/screens/Runtime.tsx | 4 +- docs/architecture/ARCHITECTURE.md | 3 +- .../generated/rust/runtime-map.schema.json | 15 +++-- packages/contracts/src/index.ts | 1 + packages/contracts/src/rustModels.ts | 17 +++-- packages/contracts/src/rustSchemas.ts | 30 ++++++--- 8 files changed, 142 insertions(+), 27 deletions(-) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 38f1a3d1..aab3afce 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -52,6 +52,16 @@ const PROVIDER_STATE_SLOT_SET = { const PROVIDER_STATE_SLOTS = Object.keys(PROVIDER_STATE_SLOT_SET) as ProviderSlot[]; const U32_MAX = 4_294_967_295; +// 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_RELATIONSHIP = { + docker_network_membership: "connected_to", + docker_volume_mount: "mounts", + docker_port_publication: "exposes", +} as const; + function hasCompleteProviderStateVector(payload: unknown): boolean { if (!payload || typeof payload !== "object") return false; const providerStates = (payload as { providerStates?: unknown }).providerStates; @@ -119,6 +129,29 @@ 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 { 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; + }; + if (value.version !== 1 || value.provider !== "docker" || value.assertionKind !== "observed" || value.freshness !== "fresh") return false; + if (typeof value.kind !== "string" || V1_EVIDENCE_RELATIONSHIP[value.kind as keyof typeof V1_EVIDENCE_RELATIONSHIP] !== candidate.relationship) 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); + }); + }); +} + export function daemonResponseSchemaId(path: string): RustResponseSchemaId | undefined { const pathname = path.split("?", 1)[0]; if (pathname === "/daemon/containers") return "ContainersResponse"; @@ -150,7 +183,7 @@ 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)))) { throw new DaemonResponseValidationError(); } return payload; diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 597a6f65..6887ebca 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1131,6 +1131,68 @@ test("runtime evidence is required and fails closed before browser publication", 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)); + + for (const [kind, relationship] of [ + ["docker_network_membership", "mounts"], + ["docker_volume_mount", "exposes"], + ["docker_port_publication", "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}` + ); + } +}); + +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].evidenceRefs[0].provider = `token=${sentinel}`; + 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("actual canonical and v1 SSE snapshot/error frames use their declared payload schemas", async () => { @@ -1998,7 +2060,7 @@ test("API publishes redacted and normalized daemon data on every response route" edges: [{ source: hostile, target: hostile, - relationship: "depends_on", + relationship: "connected_to", metadata: { [hostile]: hostile }, evidenceRefs: [{ version: 1, diff --git a/apps/web/src/screens/Runtime.tsx b/apps/web/src/screens/Runtime.tsx index fce6fd77..6dbc0584 100644 --- a/apps/web/src/screens/Runtime.tsx +++ b/apps/web/src/screens/Runtime.tsx @@ -84,9 +84,7 @@ const PROVIDER_REASON_LABEL: Record = { }; const ASSERTION_KIND_LABEL: Record = { - observed: "Observed fact", - derived: "Derived relationship", - inferred: "Inferred relationship" + observed: "Observed fact" }; const FRESHNESS_LABEL: Record = { diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 837971af..33fe54ca 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -62,7 +62,8 @@ collector -> bounded RuntimeEvidenceRef -> RuntimeMapEdge -> daemon publication/ The first facts are Docker network membership, volume attachment, and port publication. They are `observed`, carry the Docker collection timestamp and -opaque Docker observation token, and declare `fresh` only for that Docker +an opaque Docker observation revision token (deliberately neither a timestamp +nor the cache model revision), and declare `fresh` only for that Docker observation. Provider-slot freshness continues to describe optional host collection separately. An empty array is explicit migration state for a relationship family that has not yet gained provenance; it must not be diff --git a/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index c4ffb5ca..7128fa68 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -160,11 +160,9 @@ "type": "object" }, "RuntimeEvidenceAssertionKind": { - "description": "Whether a runtime claim was directly observed, deterministically derived\nfrom bounded observations, or inferred by a future heuristic. This is a\nclosed vocabulary: callers must not translate provider error text into a\nconfidence-like assertion label.", + "description": "Version-one evidence is a direct Docker observation. Derived and inferred\nclaims need a later, deliberately versioned evidence contract rather than\na permissive enum value in this first slice.", "enum": [ - "observed", - "derived", - "inferred" + "observed" ], "type": "string" }, @@ -183,6 +181,13 @@ ], "type": "string" }, + "RuntimeEvidenceProvider": { + "description": "Evidence provider for the version-one Docker-only evidence shape. New\nproviders require a new versioned evidence representation; they cannot be\npassed off as v1 through the broad runtime-provider enum.", + "enum": [ + "docker" + ], + "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.", @@ -209,7 +214,7 @@ "$ref": "#/$defs/RuntimeEvidenceKind" }, "provider": { - "$ref": "#/$defs/RuntimeProviderKind" + "$ref": "#/$defs/RuntimeEvidenceProvider" }, "providerRevision": { "description": "Opaque Docker observation token, not a cache-publication revision or\nsource dump.", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 64db988a..ba1921b3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -37,6 +37,7 @@ export type { RuntimeAdvisorySeverity, RuntimeEvidenceAssertionKind, RuntimeEvidenceKind, + RuntimeEvidenceProvider, RuntimeEvidenceRef, RuntimeEventRef, RuntimeHealth, diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index 55ea2c0d..17547ac5 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -43,18 +43,23 @@ export type RuntimeProviderKind = | 'other'; export type DiagnosticSeverity = 'info' | 'warning' | 'error' | 'blocked'; /** - * Whether a runtime claim was directly observed, deterministically derived - * from bounded observations, or inferred by a future heuristic. This is a - * closed vocabulary: callers must not translate provider error text into a - * confidence-like assertion label. + * Version-one evidence is a direct Docker observation. Derived and inferred + * claims need a later, deliberately versioned evidence contract rather than + * a permissive enum value in this first slice. */ -export type RuntimeEvidenceAssertionKind = 'observed' | 'derived' | 'inferred'; +export type RuntimeEvidenceAssertionKind = 'observed'; /** * 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'; +/** + * Evidence provider for the version-one Docker-only evidence shape. New + * providers require a new versioned evidence representation; they cannot be + * passed off as v1 through the broad runtime-provider enum. + */ +export type RuntimeEvidenceProvider = 'docker'; export type RuntimeRelationshipKind = | 'connected_to' | 'depends_on' @@ -288,7 +293,7 @@ export interface RuntimeEvidenceRef { freshness: 'fresh'; id: string; kind: RuntimeEvidenceKind; - provider: RuntimeProviderKind; + provider: RuntimeEvidenceProvider; /** * Opaque Docker observation token, not a cache-publication revision or * source dump. diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index 1598713c..6a9d4eb1 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -488,11 +488,9 @@ export const RUST_RESPONSE_SCHEMAS = { "type": "object" }, "RuntimeEvidenceAssertionKind": { - "description": "Whether a runtime claim was directly observed, deterministically derived\nfrom bounded observations, or inferred by a future heuristic. This is a\nclosed vocabulary: callers must not translate provider error text into a\nconfidence-like assertion label.", + "description": "Version-one evidence is a direct Docker observation. Derived and inferred\nclaims need a later, deliberately versioned evidence contract rather than\na permissive enum value in this first slice.", "enum": [ - "observed", - "derived", - "inferred" + "observed" ], "type": "string" }, @@ -511,6 +509,13 @@ export const RUST_RESPONSE_SCHEMAS = { ], "type": "string" }, + "RuntimeEvidenceProvider": { + "description": "Evidence provider for the version-one Docker-only evidence shape. New\nproviders require a new versioned evidence representation; they cannot be\npassed off as v1 through the broad runtime-provider enum.", + "enum": [ + "docker" + ], + "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.", @@ -537,7 +542,7 @@ export const RUST_RESPONSE_SCHEMAS = { "$ref": "#/$defs/RuntimeEvidenceKind" }, "provider": { - "$ref": "#/$defs/RuntimeProviderKind" + "$ref": "#/$defs/RuntimeEvidenceProvider" }, "providerRevision": { "description": "Opaque Docker observation token, not a cache-publication revision or\nsource dump.", @@ -2690,11 +2695,9 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "type": "object" }, "RuntimeEvidenceAssertionKind": { - "description": "Whether a runtime claim was directly observed, deterministically derived\nfrom bounded observations, or inferred by a future heuristic. This is a\nclosed vocabulary: callers must not translate provider error text into a\nconfidence-like assertion label.", + "description": "Version-one evidence is a direct Docker observation. Derived and inferred\nclaims need a later, deliberately versioned evidence contract rather than\na permissive enum value in this first slice.", "enum": [ - "observed", - "derived", - "inferred" + "observed" ], "type": "string" }, @@ -2713,6 +2716,13 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { ], "type": "string" }, + "RuntimeEvidenceProvider": { + "description": "Evidence provider for the version-one Docker-only evidence shape. New\nproviders require a new versioned evidence representation; they cannot be\npassed off as v1 through the broad runtime-provider enum.", + "enum": [ + "docker" + ], + "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.", @@ -2739,7 +2749,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceKind" }, "provider": { - "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeProviderKind" + "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceProvider" }, "providerRevision": { "description": "Opaque Docker observation token, not a cache-publication revision or\nsource dump.", From b65cd6af7244c3aa70a048179d0a3739f4666ebf Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:22:04 +0800 Subject: [PATCH 10/47] fix: bind runtime evidence to edge endpoints --- apps/api/src/daemonResponseValidation.ts | 16 +++++++++------- apps/api/test/security.test.ts | 18 ++++++++++++++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index aab3afce..08470d8e 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -56,10 +56,10 @@ const U32_MAX = 4_294_967_295; // 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_RELATIONSHIP = { - docker_network_membership: "connected_to", - docker_volume_mount: "mounts", - docker_port_publication: "exposes", +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_" }, } as const; function hasCompleteProviderStateVector(payload: unknown): boolean { @@ -135,16 +135,18 @@ function hasCoherentRuntimeEvidence(payload: unknown): boolean { if (!Array.isArray(edges)) return false; return edges.every((edge) => { if (!edge || typeof edge !== "object") return false; - const candidate = edge as { relationship?: unknown; evidenceRefs?: unknown }; + 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; + freshness?: unknown; providerRevision?: unknown; collectedAt?: unknown; subjectRef?: unknown; }; if (value.version !== 1 || value.provider !== "docker" || value.assertionKind !== "observed" || value.freshness !== "fresh") return false; - if (typeof value.kind !== "string" || V1_EVIDENCE_RELATIONSHIP[value.kind as keyof typeof V1_EVIDENCE_RELATIONSHIP] !== candidate.relationship) return false; + const expected = typeof value.kind === "string" ? V1_EVIDENCE_EDGE[value.kind as keyof typeof V1_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; // 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); diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 6887ebca..fe7c140a 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1156,6 +1156,15 @@ test("runtime evidence is required and fails closed before browser publication", 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)); + } for (const [kind, relationship] of [ ["docker_network_membership", "mounts"], @@ -1179,7 +1188,8 @@ test("fabricated runtime evidence is rejected over the authenticated API boundar )); const sentinel = "DOCKERMAP_TEST_FAKE_RUNTIME_EVIDENCE_SECRET"; const hostile = structuredClone(fixture); - hostile.edges[0].evidenceRefs[0].provider = `token=${sentinel}`; + 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" }); @@ -2058,8 +2068,8 @@ test("API publishes redacted and normalized daemon data on every response route" sendJson(res, 200, { nodes: [{ id: hostile, provider: "other", type: "service", label: hostile, status: hostile, metadata: { [hostile]: hostile } }], edges: [{ - source: hostile, - target: hostile, + source: `docker_container_${hostile}`, + target: `docker_network_${hostile}`, relationship: "connected_to", metadata: { [hostile]: hostile }, evidenceRefs: [{ @@ -2069,7 +2079,7 @@ test("API publishes redacted and normalized daemon data on every response route" kind: "docker_network_membership", assertionKind: "observed", summary: hostile, - subjectRef: hostile, + subjectRef: `docker_container_${hostile}`, collectedAt: 1, providerRevision: hostile, freshness: "fresh" From 3c033243e5f3729c258a2d1a092477f5616f2d58 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:59:51 +0800 Subject: [PATCH 11/47] feat: evidence Docker Compose dependency declarations --- crates/dockermap-core/src/lib.rs | 58 ++++++++++++ crates/dockermap-core/src/models.rs | 9 ++ crates/dockermap-core/src/snapshot_runtime.rs | 94 +++++++++++++++---- crates/dockermap-daemon/src/main.rs | 10 ++ 4 files changed, 155 insertions(+), 16 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index 73ecf628..e1da2d5d 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -222,6 +222,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 +249,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] @@ -707,6 +720,51 @@ mod tests { ); } + #[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(); diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index 18c876e3..bc0d4c27 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -842,6 +842,9 @@ 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 compact, versioned reference to the bounded fact supporting a runtime @@ -938,6 +941,12 @@ impl RuntimeMapEdge { && self.source.starts_with("docker_container_") && self.target.starts_with("network_listener_") } + RuntimeEvidenceKind::DockerComposeDependsOn => { + self.relationship == RuntimeRelationshipKind::DependsOn + && self.source.starts_with("docker_container_") + && self.target.starts_with("docker_container_") + && self.source != self.target + } } } } diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 067beb8d..39a6ca7c 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -300,6 +300,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") } @@ -322,6 +354,7 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::DockerNetworkMembership => "network-membership", RuntimeEvidenceKind::DockerVolumeMount => "volume-mount", RuntimeEvidenceKind::DockerPortPublication => "port-publication", + RuntimeEvidenceKind::DockerComposeDependsOn => "compose-depends-on", }; let summary = match kind { RuntimeEvidenceKind::DockerNetworkMembership => { @@ -329,6 +362,9 @@ fn docker_runtime_evidence( } RuntimeEvidenceKind::DockerVolumeMount => "Docker reported volume attachment", RuntimeEvidenceKind::DockerPortPublication => "Docker reported container port publication", + RuntimeEvidenceKind::DockerComposeDependsOn => { + "Docker recorded Compose dependency declaration" + } }; RuntimeEvidenceRef { version: 1, @@ -363,6 +399,9 @@ pub fn derive_runtime_map( !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()); @@ -376,10 +415,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(), @@ -394,10 +430,7 @@ pub fn derive_runtime_map( }); for network_id in &container.networks { - let source = format!( - "docker_container_{}", - collision_resistant_id_component(&container.id) - ); + let source = runtime_container_id(container); let target = format!( "docker_network_{}", collision_resistant_id_component(network_id) @@ -440,10 +473,7 @@ pub fn derive_runtime_map( service: None, package: None, }); - let source = format!( - "docker_container_{}", - collision_resistant_id_component(&container.id) - ); + let source = runtime_container_id(container); edges.push(RuntimeMapEdge { evidence_refs: vec![docker_runtime_evidence( snapshot, @@ -458,6 +488,41 @@ pub fn derive_runtime_map( 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 { @@ -507,10 +572,7 @@ pub fn derive_runtime_map( .iter() .find(|container| container.name == *attached) { - let source = format!( - "docker_container_{}", - collision_resistant_id_component(&container.id) - ); + let source = runtime_container_id(container); let target = format!( "docker_volume_{}", collision_resistant_id_component(&volume.id) diff --git a/crates/dockermap-daemon/src/main.rs b/crates/dockermap-daemon/src/main.rs index 54ecc35b..3aec6e36 100644 --- a/crates/dockermap-daemon/src/main.rs +++ b/crates/dockermap-daemon/src/main.rs @@ -1658,6 +1658,16 @@ mod tests { 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] From 8a33379ce6b4c0295a27dd0a850211ed91e02167 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:02:35 +0800 Subject: [PATCH 12/47] feat: expose Compose dependency evidence --- apps/api/src/daemonResponseValidation.ts | 2 + apps/api/test/security.test.ts | 3 +- docs/architecture/ARCHITECTURE.md | 5 ++- .../generated/rust/runtime-map.schema.json | 21 +++++++--- packages/contracts/src/rustModels.ts | 3 +- packages/contracts/src/rustSchemas.ts | 42 +++++++++++++------ 6 files changed, 54 insertions(+), 22 deletions(-) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 08470d8e..05dd5fd6 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -60,6 +60,7 @@ 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_" }, } as const; function hasCompleteProviderStateVector(payload: unknown): boolean { @@ -147,6 +148,7 @@ function hasCoherentRuntimeEvidence(payload: unknown): boolean { const expected = typeof value.kind === "string" ? V1_EVIDENCE_EDGE[value.kind as keyof typeof V1_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 (value.kind === "docker_compose_depends_on" && 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); diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index fe7c140a..6f0cc47b 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1169,7 +1169,8 @@ test("runtime evidence is required and fails closed before browser publication", for (const [kind, relationship] of [ ["docker_network_membership", "mounts"], ["docker_volume_mount", "exposes"], - ["docker_port_publication", "connected_to"] + ["docker_port_publication", "connected_to"], + ["docker_compose_depends_on", "connected_to"] ] as const) { const mismatched = structuredClone(fixture); mismatched.edges[0].evidenceRefs[0].kind = kind; diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 33fe54ca..e63c9c7b 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -60,8 +60,8 @@ they are not reconstructed from labels in React: collector -> bounded RuntimeEvidenceRef -> RuntimeMapEdge -> daemon publication/redaction -> API contract validation -> Runtime inspector ``` -The first facts are Docker network membership, volume attachment, and port -publication. They are `observed`, carry the Docker collection timestamp and +The first facts are Docker network membership, volume attachment, port +publication, and Docker-recorded Compose start-order declarations. 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. Provider-slot freshness continues to describe optional host @@ -83,6 +83,7 @@ Current relationship-source matrix: | Docker container -> network | Docker inventory membership | observed | emitted | | Docker container -> volume | Docker volume attachment | observed | emitted | | Docker container -> listener | Docker published port | observed | emitted | +| 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 | | systemd, npm, tmux, proxy, DNS, process and cross-provider edges | bounded provider-specific collector facts | varies | explicit empty migration array; no invented provenance | The map is organized around a unified service concept. Docker containers, systemd diff --git a/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index 7128fa68..1f0c2986 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -174,12 +174,21 @@ }, "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.", - "enum": [ - "docker_network_membership", - "docker_volume_mount", - "docker_port_publication" - ], - "type": "string" + "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" + } + ] }, "RuntimeEvidenceProvider": { "description": "Evidence provider for the version-one Docker-only evidence shape. New\nproviders require a new versioned evidence representation; they cannot be\npassed off as v1 through the broad runtime-provider enum.", diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index 17547ac5..258b0c15 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -53,7 +53,8 @@ export type RuntimeEvidenceAssertionKind = 'observed'; * 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'; +export type RuntimeEvidenceKind = + ('docker_network_membership' | 'docker_volume_mount' | 'docker_port_publication') | 'docker_compose_depends_on'; /** * Evidence provider for the version-one Docker-only evidence shape. New * providers require a new versioned evidence representation; they cannot be diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index 6a9d4eb1..d75eee70 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -502,12 +502,21 @@ export const RUST_RESPONSE_SCHEMAS = { }, "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.", - "enum": [ - "docker_network_membership", - "docker_volume_mount", - "docker_port_publication" - ], - "type": "string" + "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" + } + ] }, "RuntimeEvidenceProvider": { "description": "Evidence provider for the version-one Docker-only evidence shape. New\nproviders require a new versioned evidence representation; they cannot be\npassed off as v1 through the broad runtime-provider enum.", @@ -2709,12 +2718,21 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { }, "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.", - "enum": [ - "docker_network_membership", - "docker_volume_mount", - "docker_port_publication" - ], - "type": "string" + "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" + } + ] }, "RuntimeEvidenceProvider": { "description": "Evidence provider for the version-one Docker-only evidence shape. New\nproviders require a new versioned evidence representation; they cannot be\npassed off as v1 through the broad runtime-provider enum.", From 576882000d98ac63a13f0d140dae4494a744b430 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:04:32 +0800 Subject: [PATCH 13/47] test: cover Compose dependency evidence fixture --- .../contracts/runtime-map-daemon-emitted.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/fixtures/contracts/runtime-map-daemon-emitted.json b/tests/fixtures/contracts/runtime-map-daemon-emitted.json index 8bda275f..a6523336 100644 --- a/tests/fixtures/contracts/runtime-map-daemon-emitted.json +++ b/tests/fixtures/contracts/runtime-map-daemon-emitted.json @@ -213,6 +213,26 @@ } ], "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", From 81215b565228400705070e987a4318bbc05b35ab Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:08:16 +0800 Subject: [PATCH 14/47] test: reject self-attested Compose evidence --- apps/api/test/security.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 6f0cc47b..4e2b921b 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1165,6 +1165,12 @@ test("runtime evidence is required and fails closed before browser publication", 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"], From 318512e755b597e35d12e05254b1824e6da34796 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:14:57 +0800 Subject: [PATCH 15/47] feat: schedule systemd observations independently --- crates/dockermap-core/src/models.rs | 3 + crates/dockermap-daemon/src/cache_refresh.rs | 30 ++++++--- .../src/runtime_collection.rs | 66 ++++++++++++++++--- 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index bc0d4c27..f776b766 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -153,6 +153,9 @@ pub enum RuntimeMode { pub enum ProviderSlot { NetworkInfrastructure, HostScoped, + /// systemd has an independent collector lifecycle. It must not inherit + /// freshness from the broader host-scoped observation slot. + Systemd, PythonProcesses, NativeProcesses, ProjectNpm, diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index be19e7bf..90a3eaa8 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -300,6 +300,7 @@ impl SlotDataRevision { pub(crate) struct ProviderSlotFlights { network: Arc, host: Arc, + systemd: Arc, python: Arc, native: Arc, npm: Arc, @@ -310,6 +311,7 @@ impl Default for ProviderSlotFlights { Self { network: Arc::new(AtomicBool::new(false)), host: 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)), @@ -325,6 +327,7 @@ impl ProviderSlotFlights { match slot { ProviderSlot::NetworkInfrastructure => self.network.clone(), ProviderSlot::HostScoped => self.host.clone(), + ProviderSlot::Systemd => self.systemd.clone(), ProviderSlot::PythonProcesses => self.python.clone(), ProviderSlot::NativeProcesses => self.native.clone(), ProviderSlot::ProjectNpm => self.npm.clone(), @@ -335,6 +338,7 @@ impl ProviderSlotFlights { [ &self.network, &self.host, + &self.systemd, &self.python, &self.native, &self.npm, @@ -1152,6 +1156,10 @@ mod scheduler_tests { slot_interval(ProviderSlot::HostScoped), Duration::from_secs(15) ); + assert_eq!( + slot_interval(ProviderSlot::Systemd), + Duration::from_secs(15) + ); assert_eq!( slot_interval(ProviderSlot::PythonProcesses), Duration::from_secs(10) @@ -1196,6 +1204,7 @@ 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::Systemd), 5); assert_eq!(invocations(ProviderSlot::PythonProcesses), 7); assert_eq!(invocations(ProviderSlot::NativeProcesses), 7); assert_eq!(invocations(ProviderSlot::ProjectNpm), 2); @@ -1261,14 +1270,15 @@ mod scheduler_tests { assert_eq!(publications, 31); assert_eq!(starts[&ProviderSlot::NetworkInfrastructure], 7); assert_eq!(starts[&ProviderSlot::HostScoped], 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::(), 33); 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); + assert_eq!(legacy_slot_passes, 186); } /// The scheduler's timing trace above deliberately counts claims rather @@ -1293,15 +1303,16 @@ mod scheduler_tests { .block_on(run_real_collector_churn_trace(&profile)); match profile.as_str() { "full-host" => { - assert_eq!(starts.values().sum::(), 28); + assert_eq!(starts.values().sum::(), 33); 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, 186); assert_eq!(legacy_starts * 8 / STATIC_PROVIDER_SLOTS.len() as u64, 248); } "restricted" => { - assert_eq!(starts.values().sum::(), 12); + assert_eq!(starts.values().sum::(), 13); assert_eq!(starts[&ProviderSlot::HostScoped], 1); + assert_eq!(starts[&ProviderSlot::Systemd], 1); assert_eq!(starts[&ProviderSlot::PythonProcesses], 1); assert_eq!(starts[&ProviderSlot::NativeProcesses], 1); } @@ -1521,6 +1532,7 @@ mod scheduler_tests { } else { assert_eq!(starts[&ProviderSlot::NetworkInfrastructure], 7); assert_eq!(starts[&ProviderSlot::HostScoped], 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); @@ -1616,7 +1628,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(); @@ -1807,7 +1819,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(); @@ -1879,7 +1891,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() @@ -1908,7 +1920,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); diff --git a/crates/dockermap-daemon/src/runtime_collection.rs b/crates/dockermap-daemon/src/runtime_collection.rs index b5fd7da6..b580e142 100644 --- a/crates/dockermap-daemon/src/runtime_collection.rs +++ b/crates/dockermap-daemon/src/runtime_collection.rs @@ -48,6 +48,7 @@ pub(crate) type StaticProviderSlot = ProviderSlot; pub(crate) const STATIC_PROVIDER_SLOTS: &[StaticProviderSlot] = &[ StaticProviderSlot::NetworkInfrastructure, StaticProviderSlot::HostScoped, + StaticProviderSlot::Systemd, StaticProviderSlot::PythonProcesses, StaticProviderSlot::NativeProcesses, StaticProviderSlot::ProjectNpm, @@ -59,6 +60,7 @@ pub(crate) fn slot_interval(slot: StaticProviderSlot) -> Duration { match slot { StaticProviderSlot::NetworkInfrastructure => Duration::from_secs(10), StaticProviderSlot::HostScoped => 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), @@ -169,6 +171,17 @@ fn collect_provider_slot( }, ); } + 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); @@ -235,7 +248,7 @@ fn collect_host_node(project_root: Option<&StdPath>, nodes: &mut Vec String { std::env::var("HOSTNAME") .ok() @@ -326,7 +354,6 @@ mod tests { assert!(edges.is_empty()); for provider in [ RuntimeProviderKind::Network, - RuntimeProviderKind::Systemd, RuntimeProviderKind::ScheduledJob, RuntimeProviderKind::Pm2, RuntimeProviderKind::Tmux, @@ -337,6 +364,28 @@ mod tests { } } + #[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)); @@ -360,6 +409,7 @@ mod tests { [ StaticProviderSlot::NetworkInfrastructure, StaticProviderSlot::HostScoped, + StaticProviderSlot::Systemd, StaticProviderSlot::PythonProcesses, StaticProviderSlot::NativeProcesses, StaticProviderSlot::ProjectNpm, From 82956fd55d9c46f7087099be31310b9983e9ec0f Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:28:00 +0800 Subject: [PATCH 16/47] fix: align systemd provider state contract --- apps/api/src/daemonResponseValidation.ts | 1 + apps/api/src/index.ts | 2 +- apps/web/src/lib/demoData.ts | 1 + apps/web/src/lib/model.test.ts | 5 +- apps/web/src/lib/testProviderStates.ts | 3 +- apps/web/src/screens/Runtime.tsx | 1 + .../screens/runtime-provider-states.test.tsx | 8 ++- crates/dockermap-core/src/models.rs | 2 +- crates/dockermap-core/src/schema_baseline.rs | 4 +- crates/dockermap-daemon/src/cache_refresh.rs | 9 ++- ...PROVIDER_SCHEDULING_AND_MODEL_REVISIONS.md | 42 ++++++++------ .../generated/rust/runtime-map.schema.json | 29 ++++++---- packages/contracts/src/rustModels.ts | 8 +-- packages/contracts/src/rustSchemas.ts | 58 ++++++++++++------- tests/e2e/dockermap.spec.ts | 4 +- .../contracts/runtime-map-daemon-emitted.json | 10 ++++ .../contracts/runtime-map-expanded.json | 1 + tests/fixtures/contracts/runtime-map.json | 1 + 18 files changed, 122 insertions(+), 67 deletions(-) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 05dd5fd6..828a569c 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -45,6 +45,7 @@ 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, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 91777d33..3272c052 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -422,7 +422,7 @@ 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") ], diff --git a/apps/web/src/lib/demoData.ts b/apps/web/src/lib/demoData.ts index 515e1ec5..f75c5683 100644 --- a/apps/web/src/lib/demoData.ts +++ b/apps/web/src/lib/demoData.ts @@ -235,6 +235,7 @@ const demoRuntimeMap: RuntimeMap = { { slot: "host_scoped", 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: "systemd", 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" } ], nodes: [ diff --git a/apps/web/src/lib/model.test.ts b/apps/web/src/lib/model.test.ts index c7d9c41f..ac10a8c6 100644 --- a/apps/web/src/lib/model.test.ts +++ b/apps/web/src/lib/model.test.ts @@ -65,9 +65,10 @@ 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[2], state: "collecting", lastAttemptMs: 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[4], state: "disabled", statusReason: "disabled" }, + { ...testProviderStates[5], state: "disabled", statusReason: "disabled" } ]; const model = buildModel(snapshot([]), { ...emptyRuntime, providerStates }); diff --git a/apps/web/src/lib/testProviderStates.ts b/apps/web/src/lib/testProviderStates.ts index c3673262..b909a5b3 100644 --- a/apps/web/src/lib/testProviderStates.ts +++ b/apps/web/src/lib/testProviderStates.ts @@ -8,8 +8,9 @@ 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] = [ unavailableProviderState("network_infrastructure"), unavailableProviderState("host_scoped"), + unavailableProviderState("systemd"), unavailableProviderState("python_processes"), unavailableProviderState("native_processes"), unavailableProviderState("project_npm") ]; diff --git a/apps/web/src/screens/Runtime.tsx b/apps/web/src/screens/Runtime.tsx index 6dbc0584..70650319 100644 --- a/apps/web/src/screens/Runtime.tsx +++ b/apps/web/src/screens/Runtime.tsx @@ -49,6 +49,7 @@ const LAYER_LABEL: Record = { const PROVIDER_SLOT_LABEL: Record = { network_infrastructure: "Network infrastructure", host_scoped: "Host-scoped services", + systemd: "systemd services", python_processes: "Python processes", native_processes: "Native processes", project_npm: "Project npm" diff --git a/apps/web/src/screens/runtime-provider-states.test.tsx b/apps/web/src/screens/runtime-provider-states.test.tsx index e0896f46..96580803 100644 --- a/apps/web/src/screens/runtime-provider-states.test.tsx +++ b/apps/web/src/screens/runtime-provider-states.test.tsx @@ -21,8 +21,9 @@ 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("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" }) ] }; @@ -45,9 +46,10 @@ describe("Runtime collection evidence", () => { 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.match(/class="provider-state-row /g)).toHaveLength(6); expect(html).toContain("Network infrastructure"); expect(html).toContain("Host-scoped services"); + expect(html).toContain("systemd services"); expect(html).toContain("Python processes"); expect(html).toContain("Native processes"); expect(html).toContain("Project npm"); diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index f776b766..778696ae 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -1001,7 +1001,7 @@ pub struct RuntimeMap { #[schemars(length(min = 1))] pub model_revision: String, #[serde(rename = "providerStates")] - #[schemars(length(min = 5, max = 5))] + #[schemars(length(min = 6, max = 6))] 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..d5417ff5 100644 --- a/crates/dockermap-core/src/schema_baseline.rs +++ b/crates/dockermap-core/src/schema_baseline.rs @@ -160,11 +160,11 @@ mod tests { .expect("provider state property exists"); assert_eq!( states.get("minItems").and_then(|value| value.as_u64()), - Some(5) + Some(6) ); assert_eq!( states.get("maxItems").and_then(|value| value.as_u64()), - Some(5) + Some(6) ); } diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index 90a3eaa8..12482d39 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -1276,9 +1276,12 @@ mod scheduler_tests { assert_eq!(starts[&ProviderSlot::ProjectNpm], 2); assert_eq!(starts.values().sum::(), 33); 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, 186); + // 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()) * 5; + assert_eq!(legacy_aggregate_passes, 155); } /// The scheduler's timing trace above deliberately counts claims rather 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/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index 1f0c2986..ff22a987 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -11,14 +11,23 @@ }, "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": "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, @@ -915,8 +924,8 @@ "items": { "$ref": "#/$defs/ProviderState" }, - "maxItems": 5, - "minItems": 5, + "maxItems": 6, + "minItems": 6, "type": "array" }, "source": { diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index 258b0c15..7619a56b 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -123,7 +123,7 @@ export type RuntimeNodeKind = * interface: the daemon owns the complete finite list. */ export type ProviderSlot = - 'network_infrastructure' | 'host_scoped' | 'python_processes' | 'native_processes' | 'project_npm'; + ('network_infrastructure' | 'host_scoped' | 'python_processes' | 'native_processes' | 'project_npm') | 'systemd'; export type ProviderStateKind = 'fresh' | 'stale' | 'collecting' | 'unavailable' | 'timed_out' | 'disabled'; /** * A deliberately small, non-diagnostic explanation for a provider slot that @@ -215,10 +215,10 @@ export interface RuntimeMap { modelRevision: string; nodes: RuntimeMapNode[]; /** - * @minItems 5 - * @maxItems 5 + * @minItems 6 + * @maxItems 6 */ - providerStates: [ProviderState, ProviderState, ProviderState, ProviderState, ProviderState]; + providerStates: [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. diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index d75eee70..b22f2914 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -339,14 +339,23 @@ 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": "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, @@ -1243,8 +1252,8 @@ export const RUST_RESPONSE_SCHEMAS = { "items": { "$ref": "#/$defs/ProviderState" }, - "maxItems": 5, - "minItems": 5, + "maxItems": 6, + "minItems": 6, "type": "array" }, "source": { @@ -2555,14 +2564,23 @@ 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": "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, @@ -3459,8 +3477,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "items": { "$ref": "#/components/schemas/RuntimeMap/$defs/ProviderState" }, - "maxItems": 5, - "minItems": 5, + "maxItems": 6, + "minItems": 6, "type": "array" }, "source": { diff --git a/tests/e2e/dockermap.spec.ts b/tests/e2e/dockermap.spec.ts index b4f26fc6..c93c398d 100644 --- a/tests/e2e/dockermap.spec.ts +++ b/tests/e2e/dockermap.spec.ts @@ -196,7 +196,9 @@ test.describe("DockerMap GUI", () => { // 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.getByRole("button", { name: "Inspect evidence" }).first(); + 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(); diff --git a/tests/fixtures/contracts/runtime-map-daemon-emitted.json b/tests/fixtures/contracts/runtime-map-daemon-emitted.json index a6523336..3e4a33f7 100644 --- a/tests/fixtures/contracts/runtime-map-daemon-emitted.json +++ b/tests/fixtures/contracts/runtime-map-daemon-emitted.json @@ -558,6 +558,16 @@ "dataRevision": "fixture-provider-2", "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", diff --git a/tests/fixtures/contracts/runtime-map-expanded.json b/tests/fixtures/contracts/runtime-map-expanded.json index 4cb29066..b145caf2 100644 --- a/tests/fixtures/contracts/runtime-map-expanded.json +++ b/tests/fixtures/contracts/runtime-map-expanded.json @@ -604,6 +604,7 @@ "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": "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..3087fa9a 100644 --- a/tests/fixtures/contracts/runtime-map.json +++ b/tests/fixtures/contracts/runtime-map.json @@ -28,6 +28,7 @@ "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": "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 } From b8c9effb9865021ad66870e925a06c74b57d6816 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:30:19 +0800 Subject: [PATCH 17/47] test: preserve scheduler legacy baseline --- crates/dockermap-daemon/src/cache_refresh.rs | 21 +++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index 12482d39..c2526ece 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -994,6 +994,10 @@ mod scheduler_tests { 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 = @@ -1280,7 +1284,8 @@ mod scheduler_tests { // 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()) * 5; + let legacy_aggregate_passes = + (1 + 60 / STATIC_REFRESH_INTERVAL.as_secs()) * LEGACY_AGGREGATE_SLOT_COUNT; assert_eq!(legacy_aggregate_passes, 155); } @@ -1307,10 +1312,16 @@ mod scheduler_tests { match profile.as_str() { "full-host" => { assert_eq!(starts.values().sum::(), 33); - let legacy_starts = (1 + 60 / STATIC_REFRESH_INTERVAL.as_secs()) - * STATIC_PROVIDER_SLOTS.len() as u64; - assert_eq!(legacy_starts, 186); - assert_eq!(legacy_starts * 8 / STATIC_PROVIDER_SLOTS.len() as u64, 248); + // 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::(), 13); From 2f3c1937678a91fc02003032f747c0e35e28f442 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:32:58 +0800 Subject: [PATCH 18/47] test: cover systemd provider state in hostile fixture --- apps/api/test/security.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 4e2b921b..1c0858d4 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -2098,6 +2098,7 @@ test("API publishes redacted and normalized daemon data on every response route" providerStates: [ { slot: "network_infrastructure", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, { slot: "host_scoped", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, + { slot: "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" } From 368795f15c99ffbd69eb6b2c2d51c986859f5ec0 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:37:32 +0800 Subject: [PATCH 19/47] feat: attest systemd dependency declarations --- crates/dockermap-core/src/lib.rs | 34 +++ crates/dockermap-core/src/models.rs | 197 ++++++++++++-- crates/dockermap-core/src/snapshot_runtime.rs | 11 + crates/dockermap-daemon/src/cache_refresh.rs | 249 +++++++++++++++++- crates/dockermap-daemon/src/main.rs | 1 + .../dockermap-daemon/src/providers/systemd.rs | 53 ++-- 6 files changed, 498 insertions(+), 47 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index e1da2d5d..b1029ed8 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -831,6 +831,40 @@ mod tests { } } + #[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 mut missing_binding = valid; + missing_binding + .as_object_mut() + .unwrap() + .remove("providerSlot"); + assert!(serde_json::from_value::(missing_binding).is_err()); + } + #[test] fn version_one_evidence_cannot_attest_a_different_runtime_edge() { let snapshot = mock_snapshot(); diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index 778696ae..57010228 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -818,22 +818,23 @@ pub struct RuntimeMapNode { pub package: Option, } -/// Evidence provider for the version-one Docker-only evidence shape. New -/// providers require a new versioned evidence representation; they cannot be -/// passed off as v1 through the broad runtime-provider enum. +/// 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, } -/// Version-one evidence is a direct Docker observation. Derived and inferred -/// claims need a later, deliberately versioned evidence contract rather than -/// a permissive enum value in this first slice. +/// 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 @@ -848,16 +849,25 @@ pub enum RuntimeEvidenceKind { /// Docker's recorded Compose dependency declaration. This is deliberately /// not a health, readiness, or traffic-causality claim. DockerComposeDependsOn, + /// 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 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, Deserialize, JsonSchema, PartialEq, Eq)] +#[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 = 1))] + #[schemars(range(min = 1, max = 2))] pub version: u8, #[schemars(length(min = 1, max = 259))] pub id: String, @@ -879,8 +889,11 @@ pub struct RuntimeEvidenceRef { #[serde(rename = "providerRevision")] #[schemars(length(min = 1, max = 259))] pub provider_revision: String, - /// The Docker snapshot is observed as a single current publication. Host - /// provider freshness remains represented by `providerStates` (#66). + /// Version-two 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, } @@ -888,6 +901,94 @@ pub struct RuntimeEvidenceRef { #[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, + 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), + ) + ) + } +} + +#[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)] @@ -905,9 +1006,9 @@ pub struct RuntimeMapEdge { } impl RuntimeMapEdge { - /// Version-one evidence is attached only to the semantic edge it directly - /// observes. Keep this at the canonical model boundary so a structurally - /// valid Docker fact cannot be re-used to attest a different relationship. + /// 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() @@ -916,8 +1017,7 @@ impl RuntimeMapEdge { fn evidence_ref_matches_edge(&self, evidence: &RuntimeEvidenceRef) -> bool { const MAX_EVIDENCE_TEXT_CHARS: usize = 259; - if evidence.version != 1 - || evidence.id.is_empty() + if evidence.id.is_empty() || evidence.summary.is_empty() || evidence.provider_revision.is_empty() || evidence.id.chars().count() > MAX_EVIDENCE_TEXT_CHARS @@ -928,28 +1028,85 @@ impl RuntimeMapEdge { return false; } - match evidence.kind { - RuntimeEvidenceKind::DockerNetworkMembership => { + 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_") } - RuntimeEvidenceKind::DockerVolumeMount => { + ( + 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_") } - RuntimeEvidenceKind::DockerPortPublication => { + ( + 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_") } - RuntimeEvidenceKind::DockerComposeDependsOn => { + ( + 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 + | RuntimeEvidenceKind::SystemdWants + | RuntimeEvidenceKind::SystemdPartOf, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Systemd), + ) => { + self.relationship == RuntimeRelationshipKind::DependsOn + && self.source.starts_with("systemd_service_") + && self.target.starts_with("systemd_service_") + && self.source != self.target + } + _ => false, } } } diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 39a6ca7c..59666f0b 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -355,6 +355,11 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::DockerVolumeMount => "volume-mount", RuntimeEvidenceKind::DockerPortPublication => "port-publication", RuntimeEvidenceKind::DockerComposeDependsOn => "compose-depends-on", + RuntimeEvidenceKind::SystemdRequires + | RuntimeEvidenceKind::SystemdWants + | RuntimeEvidenceKind::SystemdPartOf => { + unreachable!("Docker evidence helper only accepts Docker evidence kinds") + } }; let summary = match kind { RuntimeEvidenceKind::DockerNetworkMembership => { @@ -365,6 +370,11 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::DockerComposeDependsOn => { "Docker recorded Compose dependency declaration" } + RuntimeEvidenceKind::SystemdRequires + | RuntimeEvidenceKind::SystemdWants + | RuntimeEvidenceKind::SystemdPartOf => { + unreachable!("Docker evidence helper only accepts Docker evidence kinds") + } }; RuntimeEvidenceRef { version: 1, @@ -380,6 +390,7 @@ fn docker_runtime_evidence( subject_ref: source.into(), collected_at: snapshot.last_updated, provider_revision: provider_revision.into(), + provider_slot: None, freshness: RuntimeEvidenceFreshness::Fresh, } } diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index c2526ece..86815aa4 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -8,6 +8,10 @@ use crate::{ docker_collector::DockerCollector, provider_contract::ProviderCollection, + 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 +19,11 @@ 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_images, mock_snapshot, DiagnosticSeverity, + DockerSnapshot, HealthResponse, HealthState, ProviderSlot, ProviderState, ProviderStateKind, + ProviderStatusReason, RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, + RuntimeEvidenceKind, RuntimeEvidenceProvider, RuntimeEvidenceRef, RuntimeMap, + RuntimeMapDiagnostic, RuntimeMapEdge, RuntimeMode, RuntimeProviderKind, }; use std::{ collections::BTreeMap, @@ -854,19 +860,23 @@ fn runtime_map_for_snapshot( 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); + } 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(), }); } } @@ -877,6 +887,118 @@ fn runtime_map_for_snapshot( runtime_map } +/// 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; + } + }; + if edge.relationship != dockermap_core::RuntimeRelationshipKind::DependsOn + || !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)", @@ -989,7 +1111,10 @@ 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, HealthState, RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, + RuntimeProviderKind, + }; use std::{ collections::BTreeMap as TestBTreeMap, fs, os::unix::fs::PermissionsExt, process::Command, }; @@ -1053,6 +1178,112 @@ 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: None, + 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::DependsOn, + metadata: BTreeMap::from([( + SYSTEMD_EVIDENCE_KIND_MARKER.into(), + SYSTEMD_EVIDENCE_REQUIRES.into(), + )]), + evidence_refs: Vec::new(), + }); + collection + } + + #[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(), &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!( + 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 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(), &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(), &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; let mut cache = DaemonCache { diff --git a/crates/dockermap-daemon/src/main.rs b/crates/dockermap-daemon/src/main.rs index 3aec6e36..f09d276a 100644 --- a/crates/dockermap-daemon/src/main.rs +++ b/crates/dockermap-daemon/src/main.rs @@ -1597,6 +1597,7 @@ mod tests { 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 { diff --git a/crates/dockermap-daemon/src/providers/systemd.rs b/crates/dockermap-daemon/src/providers/systemd.rs index 16954750..24a8bb93 100644 --- a/crates/dockermap-daemon/src/providers/systemd.rs +++ b/crates/dockermap-daemon/src/providers/systemd.rs @@ -15,6 +15,29 @@ 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, + } + } +} #[derive(Debug, Clone, PartialEq, Eq)] struct SystemdUnitSummary { @@ -153,30 +176,24 @@ 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, @@ -265,16 +282,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 } @@ -444,9 +461,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!( From 0a50366d9e69dc6cd67f93b35a1fe3d68e6d5539 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:39:30 +0800 Subject: [PATCH 20/47] feat: validate systemd evidence boundary --- apps/api/src/daemonResponseValidation.ts | 30 ++++++- apps/web/src/screens/Runtime.tsx | 7 +- .../generated/rust/runtime-map.schema.json | 45 ++++++++-- packages/contracts/src/rustModels.ts | 46 +++++----- packages/contracts/src/rustSchemas.ts | 90 +++++++++++++++---- 5 files changed, 169 insertions(+), 49 deletions(-) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 828a569c..bc28e370 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -64,6 +64,15 @@ const V1_EVIDENCE_EDGE = { docker_compose_depends_on: { relationship: "depends_on", sourcePrefix: "docker_container_", targetPrefix: "docker_container_" }, } 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; + function hasCompleteProviderStateVector(payload: unknown): boolean { if (!payload || typeof payload !== "object") return false; const providerStates = (payload as { providerStates?: unknown }).providerStates; @@ -144,12 +153,27 @@ function hasCoherentRuntimeEvidence(payload: unknown): boolean { const value = evidence as { version?: unknown; provider?: unknown; kind?: unknown; assertionKind?: unknown; freshness?: unknown; providerRevision?: unknown; collectedAt?: unknown; subjectRef?: unknown; + providerSlot?: unknown; }; - if (value.version !== 1 || value.provider !== "docker" || value.assertionKind !== "observed" || value.freshness !== "fresh") return false; - const expected = typeof value.kind === "string" ? V1_EVIDENCE_EDGE[value.kind as keyof typeof V1_EVIDENCE_EDGE] : undefined; + 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"); + if (!isV1 && !isV2) return false; + const expected = typeof value.kind === "string" + ? (isV1 + ? V1_EVIDENCE_EDGE[value.kind as keyof typeof V1_EVIDENCE_EDGE] + : V2_EVIDENCE_EDGE[value.kind as keyof typeof V2_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 (value.kind === "docker_compose_depends_on" && candidate.source === candidate.target) 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); diff --git a/apps/web/src/screens/Runtime.tsx b/apps/web/src/screens/Runtime.tsx index 70650319..842a435d 100644 --- a/apps/web/src/screens/Runtime.tsx +++ b/apps/web/src/screens/Runtime.tsx @@ -85,11 +85,14 @@ const PROVIDER_REASON_LABEL: Record = { }; const ASSERTION_KIND_LABEL: Record = { - observed: "Observed fact" + observed: "Observed fact", + declared: "Declared relationship" }; const FRESHNESS_LABEL: Record = { - fresh: "Current at collection" + fresh: "Current at collection", + stale: "Retained observation", + timed_out: "Collection timed out" }; type SelectedRuntimeEdge = { diff --git a/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index ff22a987..25409039 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -169,15 +169,18 @@ "type": "object" }, "RuntimeEvidenceAssertionKind": { - "description": "Version-one evidence is a direct Docker observation. Derived and inferred\nclaims need a later, deliberately versioned evidence contract rather than\na permissive enum value in this first slice.", + "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" + "observed", + "declared" ], "type": "string" }, "RuntimeEvidenceFreshness": { "enum": [ - "fresh" + "fresh", + "stale", + "timed_out" ], "type": "string" }, @@ -196,13 +199,29 @@ "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": "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" } ] }, "RuntimeEvidenceProvider": { - "description": "Evidence provider for the version-one Docker-only evidence shape. New\nproviders require a new versioned evidence representation; they cannot be\npassed off as v1 through the broad runtime-provider enum.", + "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" + "docker", + "systemd" ], "type": "string" }, @@ -220,8 +239,7 @@ "type": "integer" }, "freshness": { - "$ref": "#/$defs/RuntimeEvidenceFreshness", - "description": "The Docker snapshot is observed as a single current publication. Host\nprovider freshness remains represented by `providerStates` (#66)." + "$ref": "#/$defs/RuntimeEvidenceFreshness" }, "id": { "maxLength": 259, @@ -240,6 +258,17 @@ "minLength": 1, "type": "string" }, + "providerSlot": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderSlot" + }, + { + "type": "null" + } + ], + "description": "Version-two provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + }, "subjectRef": { "description": "The already-public runtime entity whose Docker fact was observed.", "type": "string" @@ -253,7 +282,7 @@ "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 1, + "maximum": 2, "minimum": 1, "type": "integer" } diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index 7619a56b..4f4f7e00 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -43,24 +43,34 @@ export type RuntimeProviderKind = | 'other'; export type DiagnosticSeverity = 'info' | 'warning' | 'error' | 'blocked'; /** - * Version-one evidence is a direct Docker observation. Derived and inferred - * claims need a later, deliberately versioned evidence contract rather than - * a permissive enum value in this first slice. + * 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'; +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_network_membership' | 'docker_volume_mount' | 'docker_port_publication') + | 'docker_compose_depends_on' + | 'systemd_requires' + | 'systemd_wants' + | 'systemd_part_of'; /** - * Evidence provider for the version-one Docker-only evidence shape. New - * providers require a new versioned evidence representation; they cannot be - * passed off as v1 through the broad runtime-provider enum. + * 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'; +export type RuntimeEvidenceProvider = 'docker' | 'systemd'; +/** + * 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') | 'systemd'; export type RuntimeRelationshipKind = | 'connected_to' | 'depends_on' @@ -118,12 +128,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') | 'systemd'; export type ProviderStateKind = 'fresh' | 'stale' | 'collecting' | 'unavailable' | 'timed_out' | 'disabled'; /** * A deliberately small, non-diagnostic explanation for a provider slot that @@ -287,11 +291,7 @@ export interface RuntimeMapEdge { export interface RuntimeEvidenceRef { assertionKind: RuntimeEvidenceAssertionKind; collectedAt: number; - /** - * The Docker snapshot is observed as a single current publication. Host - * provider freshness remains represented by `providerStates` (#66). - */ - freshness: 'fresh'; + freshness: RuntimeEvidenceFreshness; id: string; kind: RuntimeEvidenceKind; provider: RuntimeEvidenceProvider; @@ -300,6 +300,12 @@ export interface RuntimeEvidenceRef { * source dump. */ providerRevision: string; + /** + * Version-two 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 whose Docker fact was observed. */ diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index b22f2914..bc1108a9 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -497,15 +497,18 @@ export const RUST_RESPONSE_SCHEMAS = { "type": "object" }, "RuntimeEvidenceAssertionKind": { - "description": "Version-one evidence is a direct Docker observation. Derived and inferred\nclaims need a later, deliberately versioned evidence contract rather than\na permissive enum value in this first slice.", + "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" + "observed", + "declared" ], "type": "string" }, "RuntimeEvidenceFreshness": { "enum": [ - "fresh" + "fresh", + "stale", + "timed_out" ], "type": "string" }, @@ -524,13 +527,29 @@ export const RUST_RESPONSE_SCHEMAS = { "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": "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" } ] }, "RuntimeEvidenceProvider": { - "description": "Evidence provider for the version-one Docker-only evidence shape. New\nproviders require a new versioned evidence representation; they cannot be\npassed off as v1 through the broad runtime-provider enum.", + "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" + "docker", + "systemd" ], "type": "string" }, @@ -548,8 +567,7 @@ export const RUST_RESPONSE_SCHEMAS = { "type": "integer" }, "freshness": { - "$ref": "#/$defs/RuntimeEvidenceFreshness", - "description": "The Docker snapshot is observed as a single current publication. Host\nprovider freshness remains represented by `providerStates` (#66)." + "$ref": "#/$defs/RuntimeEvidenceFreshness" }, "id": { "maxLength": 259, @@ -568,6 +586,17 @@ export const RUST_RESPONSE_SCHEMAS = { "minLength": 1, "type": "string" }, + "providerSlot": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderSlot" + }, + { + "type": "null" + } + ], + "description": "Version-two provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + }, "subjectRef": { "description": "The already-public runtime entity whose Docker fact was observed.", "type": "string" @@ -581,7 +610,7 @@ export const RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 1, + "maximum": 2, "minimum": 1, "type": "integer" } @@ -2722,15 +2751,18 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "type": "object" }, "RuntimeEvidenceAssertionKind": { - "description": "Version-one evidence is a direct Docker observation. Derived and inferred\nclaims need a later, deliberately versioned evidence contract rather than\na permissive enum value in this first slice.", + "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" + "observed", + "declared" ], "type": "string" }, "RuntimeEvidenceFreshness": { "enum": [ - "fresh" + "fresh", + "stale", + "timed_out" ], "type": "string" }, @@ -2749,13 +2781,29 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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": "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" } ] }, "RuntimeEvidenceProvider": { - "description": "Evidence provider for the version-one Docker-only evidence shape. New\nproviders require a new versioned evidence representation; they cannot be\npassed off as v1 through the broad runtime-provider enum.", + "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" + "docker", + "systemd" ], "type": "string" }, @@ -2773,8 +2821,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "type": "integer" }, "freshness": { - "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceFreshness", - "description": "The Docker snapshot is observed as a single current publication. Host\nprovider freshness remains represented by `providerStates` (#66)." + "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceFreshness" }, "id": { "maxLength": 259, @@ -2793,6 +2840,17 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "minLength": 1, "type": "string" }, + "providerSlot": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeMap/$defs/ProviderSlot" + }, + { + "type": "null" + } + ], + "description": "Version-two provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + }, "subjectRef": { "description": "The already-public runtime entity whose Docker fact was observed.", "type": "string" @@ -2806,7 +2864,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 1, + "maximum": 2, "minimum": 1, "type": "integer" } From 19492db16108131495e3cab02f1753307e8d9e40 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:42:13 +0800 Subject: [PATCH 21/47] fix: preserve systemd dependency semantics --- crates/dockermap-core/src/lib.rs | 11 +++++ crates/dockermap-core/src/models.rs | 40 ++++++++++++++++--- crates/dockermap-daemon/src/cache_refresh.rs | 16 +++++++- .../dockermap-daemon/src/providers/systemd.rs | 10 ++++- 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index b1029ed8..c172495a 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -857,6 +857,17 @@ mod tests { 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() diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index 57010228..96e6c0a2 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -884,8 +884,8 @@ pub struct RuntimeEvidenceRef { #[serde(rename = "collectedAt")] #[schemars(range(max = 9_007_199_254_740_991u64))] pub collected_at: u64, - /// Opaque Docker observation token, not a cache-publication revision or - /// source dump. + /// 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, @@ -1092,16 +1092,44 @@ impl RuntimeMapEdge { ( 2, RuntimeEvidenceProvider::Systemd, - RuntimeEvidenceKind::SystemdRequires - | RuntimeEvidenceKind::SystemdWants - | RuntimeEvidenceKind::SystemdPartOf, + RuntimeEvidenceKind::SystemdRequires, RuntimeEvidenceAssertionKind::Declared, RuntimeEvidenceFreshness::Fresh | RuntimeEvidenceFreshness::Stale | RuntimeEvidenceFreshness::TimedOut, Some(ProviderSlot::Systemd), ) => { - self.relationship == RuntimeRelationshipKind::DependsOn + self.relationship == RuntimeRelationshipKind::Requires + && self.source.starts_with("systemd_service_") + && self.target.starts_with("systemd_service_") + && 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 diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index 86815aa4..eaf59da8 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -960,7 +960,15 @@ fn bind_systemd_evidence(edges: &mut [RuntimeMapEdge], state: &SlotRuntimeState) continue; } }; - if edge.relationship != dockermap_core::RuntimeRelationshipKind::DependsOn + 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 @@ -1200,7 +1208,7 @@ mod scheduler_tests { collection.parts_mut().1.push(RuntimeMapEdge { source: "systemd_service_application".into(), target: "systemd_service_database".into(), - relationship: dockermap_core::RuntimeRelationshipKind::DependsOn, + relationship: dockermap_core::RuntimeRelationshipKind::Requires, metadata: BTreeMap::from([( SYSTEMD_EVIDENCE_KIND_MARKER.into(), SYSTEMD_EVIDENCE_REQUIRES.into(), @@ -1243,6 +1251,10 @@ mod scheduler_tests { 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 diff --git a/crates/dockermap-daemon/src/providers/systemd.rs b/crates/dockermap-daemon/src/providers/systemd.rs index 24a8bb93..4dd72e60 100644 --- a/crates/dockermap-daemon/src/providers/systemd.rs +++ b/crates/dockermap-daemon/src/providers/systemd.rs @@ -37,6 +37,14 @@ impl SystemdDependencyKind { 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)] @@ -197,7 +205,7 @@ pub(crate) fn collect_systemd_services( edges.push(RuntimeMapEdge { source, target, - relationship: RuntimeRelationshipKind::DependsOn, + relationship: kind.relationship(), metadata, evidence_refs: Vec::new(), }); From 01673f45102187f3fbc023d74dfa3661a6a18ac8 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:44:48 +0800 Subject: [PATCH 22/47] test: enforce systemd evidence semantics --- apps/api/test/security.test.ts | 35 +++++++++++++++++++ .../generated/rust/runtime-map.schema.json | 2 +- packages/contracts/src/rustModels.ts | 4 +-- packages/contracts/src/rustSchemas.ts | 4 +-- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 1c0858d4..29044a8f 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1186,6 +1186,41 @@ test("runtime evidence is required and fails closed before browser publication", `${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)); }); test("fabricated runtime evidence is rejected over the authenticated API boundary", async () => { diff --git a/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index 25409039..6a3581ac 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -253,7 +253,7 @@ "$ref": "#/$defs/RuntimeEvidenceProvider" }, "providerRevision": { - "description": "Opaque Docker observation token, not a cache-publication revision or\nsource dump.", + "description": "Opaque provider observation token, not a cache-publication revision,\ncommand output, or source dump.", "maxLength": 259, "minLength": 1, "type": "string" diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index 4f4f7e00..717e0e2c 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -296,8 +296,8 @@ export interface RuntimeEvidenceRef { kind: RuntimeEvidenceKind; provider: RuntimeEvidenceProvider; /** - * Opaque Docker observation token, not a cache-publication revision or - * source dump. + * Opaque provider observation token, not a cache-publication revision, + * command output, or source dump. */ providerRevision: string; /** diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index bc1108a9..2af83486 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -581,7 +581,7 @@ export const RUST_RESPONSE_SCHEMAS = { "$ref": "#/$defs/RuntimeEvidenceProvider" }, "providerRevision": { - "description": "Opaque Docker observation token, not a cache-publication revision or\nsource dump.", + "description": "Opaque provider observation token, not a cache-publication revision,\ncommand output, or source dump.", "maxLength": 259, "minLength": 1, "type": "string" @@ -2835,7 +2835,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "$ref": "#/components/schemas/RuntimeMap/$defs/RuntimeEvidenceProvider" }, "providerRevision": { - "description": "Opaque Docker observation token, not a cache-publication revision or\nsource dump.", + "description": "Opaque provider observation token, not a cache-publication revision,\ncommand output, or source dump.", "maxLength": 259, "minLength": 1, "type": "string" From 3028ff523f7a89ed54a62ede5a98a809fe6c75fa Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:46:01 +0800 Subject: [PATCH 23/47] docs: explain systemd relationship evidence --- .../runtime-evidence-inspector.test.tsx | 34 ++++++++++++++++++- docs/architecture/ARCHITECTURE.md | 31 ++++++++++------- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/apps/web/src/screens/runtime-evidence-inspector.test.tsx b/apps/web/src/screens/runtime-evidence-inspector.test.tsx index e9d9c314..5c410136 100644 --- a/apps/web/src/screens/runtime-evidence-inspector.test.tsx +++ b/apps/web/src/screens/runtime-evidence-inspector.test.tsx @@ -12,7 +12,9 @@ const snapshot: DockerSnapshot = { 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: "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: [ { @@ -39,6 +41,25 @@ const runtime: RuntimeMap = { 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: [], @@ -70,4 +91,15 @@ describe("Runtime relationship evidence inspector", () => { 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/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index e63c9c7b..8d9e8254 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -52,22 +52,28 @@ acceptance work are recorded in [`CONTRACT_AUTHORITY.md`](CONTRACT_AUTHORITY.md) ### Relationship evidence lifecycle -Each runtime edge has a required `evidenceRefs` array. The current Docker -slice emits bounded, versioned records alongside the edge during derivation; -they are not reconstructed from labels in React: +Each runtime edge has a required `evidenceRefs` array. The current Docker and +Systemd 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 ``` -The first facts are Docker network membership, volume attachment, port -publication, and Docker-recorded Compose start-order declarations. 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. Provider-slot freshness continues to describe optional host -collection separately. 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 one facts are Docker network membership, volume attachment, port +publication, and Docker-recorded Compose start-order declarations. 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. + +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. The evidence representation is closed: provider, kind, assertion kind and freshness are enums, and there is no free-form metadata/config/command-line @@ -84,7 +90,8 @@ Current relationship-source matrix: | Docker container -> volume | Docker volume attachment | observed | emitted | | Docker container -> listener | Docker published port | observed | emitted | | 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 | -| systemd, npm, tmux, proxy, DNS, process and cross-provider edges | bounded provider-specific collector facts | varies | explicit empty migration array; no invented provenance | +| 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, tmux, proxy, DNS, process and cross-provider edges | bounded provider-specific collector facts | varies | explicit empty migration array; no invented provenance | The map is organized around a unified service concept. Docker containers, systemd services, tmux sessions, npm applications, Python applications, and native processes From ce7e54c9d698e655ccbcad627c97a25404d30f72 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:00:52 +0800 Subject: [PATCH 24/47] feat: add bounded systemd findings endpoint --- crates/dockermap-core/src/findings.rs | 209 +++++++++++++++++++ crates/dockermap-core/src/lib.rs | 2 + crates/dockermap-core/src/models.rs | 49 +++++ crates/dockermap-core/src/schema_baseline.rs | 12 +- crates/dockermap-daemon/src/cache_refresh.rs | 60 +++++- crates/dockermap-daemon/src/daemon_api.rs | 12 +- 6 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 crates/dockermap-core/src/findings.rs diff --git a/crates/dockermap-core/src/findings.rs b/crates/dockermap-core/src/findings.rs new file mode 100644 index 00000000..50aae044 --- /dev/null +++ b/crates/dockermap-core/src/findings.rs @@ -0,0 +1,209 @@ +use crate::{ + collision_resistant_id_component, Finding, FindingRule, FindingSeverity, + RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, RuntimeEvidenceKind, + RuntimeEvidenceProvider, RuntimeMap, RuntimeNodeKind, RuntimeProviderKind, + RuntimeRelationshipKind, +}; +use std::collections::{BTreeMap, BTreeSet}; + +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."; + +/// 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 findings = BTreeSet::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 (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.insert(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(), + }); + } + findings.into_iter().collect() +} + +fn is_candidate_requires(edge: &crate::RuntimeMapEdge) -> bool { + 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!(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 serialized_finding_does_not_copy_raw_evidence_or_node_metadata() { + let encoded = serde_json::to_string(&derive_findings(&map(edge( + RuntimeEvidenceFreshness::Fresh, + )))) + .unwrap(); + for forbidden in [ + "/secret/path", + "opaque-safe-revision", + "systemd_evidence_requires_safe", + "fragmentPath", + ] { + assert!(!encoded.contains(forbidden), "finding leaked {forbidden}"); + } + } +} diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index c172495a..78c5f54f 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::{ diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index 96e6c0a2..722bc2c2 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -1175,6 +1175,55 @@ 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord)] +#[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, +} + +#[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, diff --git a/crates/dockermap-core/src/schema_baseline.rs b/crates/dockermap-core/src/schema_baseline.rs index d5417ff5..1dc23715 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); diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index eaf59da8..54427ce8 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -19,11 +19,12 @@ use crate::{ }, }; use dockermap_core::{ - collision_resistant_id_component, derive_images, mock_snapshot, DiagnosticSeverity, - DockerSnapshot, HealthResponse, HealthState, ProviderSlot, ProviderState, ProviderStateKind, - ProviderStatusReason, RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, - RuntimeEvidenceKind, RuntimeEvidenceProvider, RuntimeEvidenceRef, RuntimeMap, - RuntimeMapDiagnostic, RuntimeMapEdge, 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, @@ -64,6 +65,7 @@ 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 @@ -383,6 +385,7 @@ 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(), @@ -398,6 +401,12 @@ 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) { @@ -558,6 +567,7 @@ 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(), @@ -1198,7 +1208,14 @@ mod scheduler_tests { provider: RuntimeProviderKind::Systemd, kind: RuntimeNodeKind::SystemdService, label: label.into(), - status: None, + status: Some( + if id == "systemd_service_application" { + "active" + } else { + "failed" + } + .into(), + ), layer: Some(RuntimeNodeLayer::Service), metadata: BTreeMap::new(), service: None, @@ -1266,6 +1283,36 @@ mod scheduler_tests { } } + #[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 + ); + assert_eq!(cache.findings.findings.len(), 1); + let finding = &cache.findings.findings[0]; + assert_eq!( + finding.rule_id, + dockermap_core::FindingRule::SystemdRequiresTargetNotActive + ); + let serialized = serde_json::to_string(finding).unwrap(); + assert!(!serialized.contains("systemd_evidence_")); + assert!(!serialized.contains("providerRevision")); + } + #[test] fn revisionless_or_disabled_systemd_collection_cannot_publish_evidence() { let mut slots = slots(); @@ -1310,6 +1357,7 @@ 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(), 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); From e0d1c30f492d37c7529008728acccd53c1622fd8 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:11:02 +0800 Subject: [PATCH 25/47] feat: expose bounded runtime findings --- apps/api/src/daemonResponseValidation.ts | 33 +++- apps/api/src/index.ts | 8 + apps/api/src/openapi.ts | 1 + apps/api/src/readHandlers.ts | 2 + apps/api/src/routes.ts | 1 + apps/api/src/rustResponseContracts.ts | 1 + apps/api/test/security.test.ts | 12 +- apps/web/src/App.tsx | 2 + apps/web/src/components/AppShell.tsx | 11 +- apps/web/src/context.tsx | 4 +- apps/web/src/lib/demoData.ts | 2 + apps/web/src/lib/model.ts | 5 +- apps/web/src/screens/Findings.tsx | 47 +++++ apps/web/src/screens/Home.tsx | 13 +- apps/web/src/screens/findings.test.tsx | 44 +++++ docs/architecture/ARCHITECTURE.md | 14 ++ .../rust/findings-response.schema.json | 81 +++++++++ packages/contracts/src/index.ts | 4 + packages/contracts/src/rustModels.ts | 26 ++- packages/contracts/src/rustSchemas.ts | 162 ++++++++++++++++++ .../contracts/src/schema-fixtures.test.ts | 3 +- scripts/generate-rust-contract-types.mjs | 2 +- .../fixtures/contracts/findings-response.json | 14 ++ 23 files changed, 481 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/screens/Findings.tsx create mode 100644 apps/web/src/screens/findings.test.tsx create mode 100644 packages/contracts/generated/rust/findings-response.schema.json create mode 100644 tests/fixtures/contracts/findings-response.json diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index bc28e370..da831221 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 }, @@ -52,6 +53,9 @@ const PROVIDER_STATE_SLOT_SET = { } 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."; // Version-one evidence is intentionally a discriminated Docker observation, // not a generic provenance bag. JSON Schema owns each field's closed enum; @@ -181,6 +185,31 @@ function hasCoherentRuntimeEvidence(payload: unknown): boolean { }); } +// 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; + return finding.ruleId === SYSTEMD_REQUIRES_FINDING_RULE + && 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; + }); +} + export function daemonResponseSchemaId(path: string): RustResponseSchemaId | undefined { const pathname = path.split("?", 1)[0]; if (pathname === "/daemon/containers") return "ContainersResponse"; @@ -212,7 +241,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) || !hasCoherentRuntimeEvidence(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 3272c052..e2b33e87 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -431,6 +431,13 @@ function getMockResponse(path: string): T { 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); 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 29044a8f..3d4293a6 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -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 }); @@ -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,9 @@ 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; })()] ] as const; for (const [daemonPath, body] of invalidResponses) { const daemon = await startStubDaemon((req, res) => { 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 f75c5683..e148b5a3 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, @@ -443,6 +444,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.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/screens/Findings.tsx b/apps/web/src/screens/Findings.tsx new file mode 100644 index 00000000..c31e902e --- /dev/null +++ b/apps/web/src/screens/Findings.tsx @@ -0,0 +1,47 @@ +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 ; + + return ( +
    +
    +
    +
    Evidence-backed review
    +

    Findings

    +

    A small set of explicit, declared dependency conditions. These are not health, readiness, traffic, or security conclusions.

    +
    + Open Runtime +
    + + {!findings ? ( + + + + ) : findings.findings.length === 0 ? ( + + + + ) : ( +
    + {findings.findings.map((finding) => ( + +
    WarningSystemd Requires
    +

    {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/findings.test.tsx b/apps/web/src/screens/findings.test.tsx new file mode 100644 index 00000000..f79f3004 --- /dev/null +++ b/apps/web/src/screens/findings.test.tsx @@ -0,0 +1,44 @@ +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" + }] +}; + +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, 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"); + }); +}); diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 8d9e8254..8ef977c0 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -93,6 +93,20 @@ Current relationship-source matrix: | 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, 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 initial +closed rule, `systemd.requires_target_not_active`, 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. The API validates the fixed vocabulary and static display text before +publication, and the browser displays findings only when their nonempty model +revision matches the current live model. + 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/packages/contracts/generated/rust/findings-response.schema.json b/packages/contracts/generated/rust/findings-response.schema.json new file mode 100644 index 00000000..8cee9cf1 --- /dev/null +++ b/packages/contracts/generated/rust/findings-response.schema.json @@ -0,0 +1,81 @@ +{ + "$defs": { + "Finding": { + "additionalProperties": false, + "properties": { + "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" + ], + "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" + ], + "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" + } + }, + "$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/src/index.ts b/packages/contracts/src/index.ts index ba1921b3..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, diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index 717e0e2c..494fe189 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'; @@ -142,6 +143,16 @@ 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'; +/** + * 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[]; @@ -561,6 +572,19 @@ export interface NetworksResponse { export interface VolumesResponse { volumes: VolumeRecord[]; } +export interface FindingsResponse { + findings: Finding[]; + modelRevision: string; +} +export interface Finding { + 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 2af83486..78dd03bb 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -1307,6 +1307,87 @@ export const RUST_RESPONSE_SCHEMAS = { ], "title": "RuntimeMap", "type": "object" +}, + FindingsResponse: { + "$defs": { + "Finding": { + "additionalProperties": false, + "properties": { + "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" + ], + "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" + ], + "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" + } + }, + "$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": { @@ -3561,6 +3642,87 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { ], "title": "RuntimeMap", "type": "object" +}, + FindingsResponse: { + "$defs": { + "Finding": { + "additionalProperties": false, + "properties": { + "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" + ], + "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" + ], + "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" + } + }, + "$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 c985ebe3..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 { 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/fixtures/contracts/findings-response.json b/tests/fixtures/contracts/findings-response.json new file mode 100644 index 00000000..671f13c5 --- /dev/null +++ b/tests/fixtures/contracts/findings-response.json @@ -0,0 +1,14 @@ +{ + "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" + } + ] +} From 7f6ec16c911f59a325785b8977e7695fcead7472 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:20:41 +0800 Subject: [PATCH 26/47] fix: attach canonical evidence to findings --- crates/dockermap-core/src/findings.rs | 49 +++++++++++++------- crates/dockermap-core/src/models.rs | 8 +++- crates/dockermap-daemon/src/cache_refresh.rs | 6 ++- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/crates/dockermap-core/src/findings.rs b/crates/dockermap-core/src/findings.rs index 50aae044..d73339ee 100644 --- a/crates/dockermap-core/src/findings.rs +++ b/crates/dockermap-core/src/findings.rs @@ -4,7 +4,7 @@ use crate::{ RuntimeEvidenceProvider, RuntimeMap, RuntimeNodeKind, RuntimeProviderKind, RuntimeRelationshipKind, }; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; const SUMMARY: &str = "An active systemd service requires a target that is inactive or failed"; const RECOMMENDATION: &str = @@ -38,12 +38,13 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec { } } - let mut findings = BTreeSet::new(); + 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; }; @@ -56,7 +57,7 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec { { continue; } - findings.insert(Finding { + findings.push(Finding { id: format!( "finding_systemd_requires_target_not_active_{}", collision_resistant_id_component(&format!("{}\u{1f}{}", edge.source, edge.target)) @@ -67,13 +68,21 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec { recommendation: RECOMMENDATION.into(), subject_ref: edge.source.clone(), target_ref: edge.target.clone(), + evidence_refs: vec![evidence], }); } - findings.into_iter().collect() + 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_candidate_requires(edge: &crate::RuntimeMapEdge) -> bool { - edge.relationship == RuntimeRelationshipKind::Requires + edge.has_valid_evidence_refs() + && edge.relationship == RuntimeRelationshipKind::Requires && edge.source != edge.target && edge.evidence_refs.len() == 1 && matches!( @@ -153,6 +162,11 @@ mod tests { ); 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_")); @@ -192,18 +206,21 @@ mod tests { } #[test] - fn serialized_finding_does_not_copy_raw_evidence_or_node_metadata() { - let encoded = serde_json::to_string(&derive_findings(&map(edge( - RuntimeEvidenceFreshness::Fresh, - )))) - .unwrap(); - for forbidden in [ - "/secret/path", - "opaque-safe-revision", - "systemd_evidence_requires_safe", - "fragmentPath", - ] { + 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()); + } } diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index 722bc2c2..e5f5e44d 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -1197,7 +1197,7 @@ pub enum FindingRule { SystemdRequiresTargetNotActive, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Finding { #[schemars(length(min = 1, max = 259))] @@ -1213,6 +1213,12 @@ pub struct Finding { pub subject_ref: String, #[serde(rename = "targetRef")] pub target_ref: String, + /// Canonical, already-sanitized runtime evidence that directly triggered + /// this finding. The rule admits exactly one fact, keeping the response + /// bounded and preventing a generic metadata channel. + #[serde(rename = "evidenceRefs")] + #[schemars(required, length(min = 1, max = 1))] + pub evidence_refs: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index 54427ce8..04e9b88a 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -1308,9 +1308,11 @@ mod scheduler_tests { finding.rule_id, dockermap_core::FindingRule::SystemdRequiresTargetNotActive ); + 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("systemd_evidence_")); - assert!(!serialized.contains("providerRevision")); + assert!(serialized.contains("evidenceRefs")); + assert!(serialized.contains("systemd_requires")); } #[test] From 0bdf1bc343bb41c25b2c0c359046bffef410c5bf Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:23:03 +0800 Subject: [PATCH 27/47] fix: bind findings to triggering evidence --- apps/api/src/daemonResponseValidation.ts | 16 +- apps/api/test/security.test.ts | 4 +- apps/web/src/screens/findings.test.tsx | 8 +- .../rust/findings-response.schema.json | 165 ++++++++- packages/contracts/src/rustModels.ts | 9 + packages/contracts/src/rustSchemas.ts | 330 +++++++++++++++++- .../fixtures/contracts/findings-response.json | 17 +- 7 files changed, 542 insertions(+), 7 deletions(-) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index da831221..d998c0da 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -206,7 +206,21 @@ function hasCoherentFindings(payload: unknown): boolean { && finding.subjectRef.startsWith("systemd_service_") && typeof finding.targetRef === "string" && finding.targetRef.startsWith("systemd_service_") - && finding.subjectRef !== finding.targetRef; + && 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; + })(); }); } diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 3d4293a6..b2aee1dc 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1035,7 +1035,9 @@ test("daemon model responses require non-empty revision and complete provider st 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); 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; })()] ] as const; for (const [daemonPath, body] of invalidResponses) { const daemon = await startStubDaemon((req, res) => { diff --git a/apps/web/src/screens/findings.test.tsx b/apps/web/src/screens/findings.test.tsx index f79f3004..4b09726e 100644 --- a/apps/web/src/screens/findings.test.tsx +++ b/apps/web/src/screens/findings.test.tsx @@ -14,7 +14,13 @@ const findings: FindingsResponse = { 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" + 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" + }] }] }; diff --git a/packages/contracts/generated/rust/findings-response.schema.json b/packages/contracts/generated/rust/findings-response.schema.json index 8cee9cf1..67d5381c 100644 --- a/packages/contracts/generated/rust/findings-response.schema.json +++ b/packages/contracts/generated/rust/findings-response.schema.json @@ -3,6 +3,15 @@ "Finding": { "additionalProperties": false, "properties": { + "evidenceRefs": { + "description": "Canonical, already-sanitized runtime evidence that directly triggered\nthis finding. The rule admits exactly one fact, keeping the response\nbounded and preventing a generic metadata channel.", + "items": { + "$ref": "#/$defs/RuntimeEvidenceRef" + }, + "maxItems": 1, + "minItems": 1, + "type": "array" + }, "id": { "maxLength": 259, "minLength": 1, @@ -38,7 +47,8 @@ "summary", "recommendation", "subjectRef", - "targetRef" + "targetRef", + "evidenceRefs" ], "type": "object" }, @@ -56,6 +66,159 @@ "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": "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": "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" + } + ] + }, + "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" + ], + "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 provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + }, + "subjectRef": { + "description": "The already-public runtime entity whose Docker fact was observed.", + "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": 2, + "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", diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index 494fe189..fbc2a2bf 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -577,6 +577,15 @@ export interface FindingsResponse { modelRevision: string; } export interface Finding { + /** + * Canonical, already-sanitized runtime evidence that directly triggered + * this finding. The rule admits exactly one fact, keeping the response + * bounded and preventing a generic metadata channel. + * + * @minItems 1 + * @maxItems 1 + */ + evidenceRefs: [RuntimeEvidenceRef]; id: string; recommendation: string; ruleId: FindingRule; diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index 78dd03bb..b7a4eb9b 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -1313,6 +1313,15 @@ export const RUST_RESPONSE_SCHEMAS = { "Finding": { "additionalProperties": false, "properties": { + "evidenceRefs": { + "description": "Canonical, already-sanitized runtime evidence that directly triggered\nthis finding. The rule admits exactly one fact, keeping the response\nbounded and preventing a generic metadata channel.", + "items": { + "$ref": "#/$defs/RuntimeEvidenceRef" + }, + "maxItems": 1, + "minItems": 1, + "type": "array" + }, "id": { "maxLength": 259, "minLength": 1, @@ -1348,7 +1357,8 @@ export const RUST_RESPONSE_SCHEMAS = { "summary", "recommendation", "subjectRef", - "targetRef" + "targetRef", + "evidenceRefs" ], "type": "object" }, @@ -1366,6 +1376,159 @@ export const RUST_RESPONSE_SCHEMAS = { "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": "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": "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" + } + ] + }, + "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" + ], + "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 provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + }, + "subjectRef": { + "description": "The already-public runtime entity whose Docker fact was observed.", + "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": 2, + "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", @@ -3648,6 +3811,15 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "Finding": { "additionalProperties": false, "properties": { + "evidenceRefs": { + "description": "Canonical, already-sanitized runtime evidence that directly triggered\nthis finding. The rule admits exactly one fact, keeping the response\nbounded and preventing a generic metadata channel.", + "items": { + "$ref": "#/components/schemas/FindingsResponse/$defs/RuntimeEvidenceRef" + }, + "maxItems": 1, + "minItems": 1, + "type": "array" + }, "id": { "maxLength": 259, "minLength": 1, @@ -3683,7 +3855,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "summary", "recommendation", "subjectRef", - "targetRef" + "targetRef", + "evidenceRefs" ], "type": "object" }, @@ -3701,6 +3874,159 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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": "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": "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" + } + ] + }, + "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" + ], + "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 provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + }, + "subjectRef": { + "description": "The already-public runtime entity whose Docker fact was observed.", + "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": 2, + "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", diff --git a/tests/fixtures/contracts/findings-response.json b/tests/fixtures/contracts/findings-response.json index 671f13c5..c356a8b9 100644 --- a/tests/fixtures/contracts/findings-response.json +++ b/tests/fixtures/contracts/findings-response.json @@ -8,7 +8,22 @@ "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" + "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" + } + ] } ] } From f832f76f2b314242b05d94850564a11ee77ee9f2 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:26:48 +0800 Subject: [PATCH 28/47] test: cover findings accessibility --- tests/e2e/a11y.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/a11y.spec.ts b/tests/e2e/a11y.spec.ts index aa6d01da..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"], From 5d4b1879b13c3e41b5d0bd1c550564ffabd9f814 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:30:51 +0800 Subject: [PATCH 29/47] test: cover 44-service map density --- .../src/screens/service-map-density.test.tsx | 12 ++--- tests/e2e/dockermap.spec.ts | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) 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(' { 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(); From 1311c3d38d3daf6a10aa4c80afff6dd18565c228 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:38:38 +0800 Subject: [PATCH 30/47] feat: flag internal network port publications --- crates/dockermap-core/src/findings.rs | 259 +++++++++++++++++++ crates/dockermap-core/src/models.rs | 8 +- crates/dockermap-daemon/src/cache_refresh.rs | 33 ++- 3 files changed, 291 insertions(+), 9 deletions(-) diff --git a/crates/dockermap-core/src/findings.rs b/crates/dockermap-core/src/findings.rs index d73339ee..db1fdb5f 100644 --- a/crates/dockermap-core/src/findings.rs +++ b/crates/dockermap-core/src/findings.rs @@ -9,6 +9,10 @@ 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."; /// Derive bounded, deterministic advisory findings from the already-public /// runtime topology. The rule intentionally fails closed: it acts only on one @@ -38,6 +42,19 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec { } } + 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()); @@ -71,6 +88,35 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec { evidence_refs: vec![evidence], }); } + 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) @@ -80,6 +126,67 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec { findings } +fn is_docker_container(node: &crate::RuntimeMapNode) -> bool { + node.provider == RuntimeProviderKind::Docker && node.kind == RuntimeNodeKind::Container +} + +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 +} + +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 @@ -223,4 +330,156 @@ mod tests { 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 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::new(), + ), + ], + 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()); + } } diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index e5f5e44d..52d4ec79 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -1195,6 +1195,8 @@ pub enum FindingSeverity { pub enum FindingRule { #[serde(rename = "systemd.requires_target_not_active")] SystemdRequiresTargetNotActive, + #[serde(rename = "docker.internal_network_member_publishes_port")] + DockerInternalNetworkMemberPublishesPort, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -1214,10 +1216,10 @@ pub struct Finding { #[serde(rename = "targetRef")] pub target_ref: String, /// Canonical, already-sanitized runtime evidence that directly triggered - /// this finding. The rule admits exactly one fact, keeping the response - /// bounded and preventing a generic metadata channel. + /// 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 = 1))] + #[schemars(required, length(min = 1, max = 2))] pub evidence_refs: Vec, } diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index 04e9b88a..27a812be 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -1302,17 +1302,38 @@ mod scheduler_tests { cache.findings.model_revision, cache.runtime_map.model_revision ); - assert_eq!(cache.findings.findings.len(), 1); - let finding = &cache.findings.findings[0]; - assert_eq!( - finding.rule_id, - dockermap_core::FindingRule::SystemdRequiresTargetNotActive - ); + 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("mock Docker 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] From 3c59f8b86c6a4669fca250e3e5184b28f2d09375 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:42:04 +0800 Subject: [PATCH 31/47] feat: add internal network port findings --- apps/api/src/daemonResponseValidation.ts | 42 ++++++++++++++++++- apps/api/test/security.test.ts | 4 +- apps/web/src/screens/Findings.tsx | 6 +-- apps/web/src/screens/findings.test.tsx | 24 ++++++++++- .../rust/findings-response.schema.json | 7 ++-- packages/contracts/src/rustModels.ts | 10 ++--- packages/contracts/src/rustSchemas.ts | 14 ++++--- .../fixtures/contracts/findings-response.json | 37 ++++++++++++++++ 8 files changed, 123 insertions(+), 21 deletions(-) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index d998c0da..6551dabb 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -56,6 +56,9 @@ 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."; // Version-one evidence is intentionally a discriminated Docker observation, // not a generic provenance bag. JSON Schema owns each field's closed enum; @@ -196,8 +199,7 @@ function hasCoherentFindings(payload: unknown): boolean { return findings.every((candidate) => { if (!candidate || typeof candidate !== "object") return false; const finding = candidate as Record; - return finding.ruleId === SYSTEMD_REQUIRES_FINDING_RULE - && finding.severity === "warning" + 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" @@ -221,6 +223,42 @@ function hasCoherentFindings(payload: unknown): boolean { && evidence.freshness === "fresh" && evidence.subjectRef === finding.subjectRef; })(); + 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); + })(); }); } diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index b2aee1dc..27dccce4 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1037,7 +1037,9 @@ test("daemon model responses require non-empty revision and complete provider st ["/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[0].evidenceRefs[0].freshness = "stale"; 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) => { diff --git a/apps/web/src/screens/Findings.tsx b/apps/web/src/screens/Findings.tsx index c31e902e..86c2eeaf 100644 --- a/apps/web/src/screens/Findings.tsx +++ b/apps/web/src/screens/Findings.tsx @@ -14,7 +14,7 @@ export default function Findings() {
Evidence-backed review

Findings

-

A small set of explicit, declared dependency conditions. These are not health, readiness, traffic, or security conclusions.

+

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

Open Runtime @@ -30,8 +30,8 @@ export default function Findings() { ) : (
{findings.findings.map((finding) => ( - -
WarningSystemd Requires
+ +
{finding.severity === "warning" ? "Warning" : "Advisory"}{finding.ruleId === "systemd.requires_target_not_active" ? "Systemd Requires" : "Internal network + host port"}{finding.evidenceRefs.length} supporting fact{finding.evidenceRefs.length === 1 ? "" : "s"}

{finding.summary}

{finding.recommendation}

diff --git a/apps/web/src/screens/findings.test.tsx b/apps/web/src/screens/findings.test.tsx index 4b09726e..c4a8daa4 100644 --- a/apps/web/src/screens/findings.test.tsx +++ b/apps/web/src/screens/findings.test.tsx @@ -39,7 +39,7 @@ describe("Findings screen", () => { 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, or security conclusions"); + expect(html).toContain("not health, readiness, traffic, Internet-reachability, or security conclusions"); }); it("fails closed when a coherent live finding response is unavailable", () => { @@ -47,4 +47,26 @@ describe("Findings screen", () => { 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"); + }); }); diff --git a/packages/contracts/generated/rust/findings-response.schema.json b/packages/contracts/generated/rust/findings-response.schema.json index 67d5381c..5b15cc6e 100644 --- a/packages/contracts/generated/rust/findings-response.schema.json +++ b/packages/contracts/generated/rust/findings-response.schema.json @@ -4,11 +4,11 @@ "additionalProperties": false, "properties": { "evidenceRefs": { - "description": "Canonical, already-sanitized runtime evidence that directly triggered\nthis finding. The rule admits exactly one fact, keeping the response\nbounded and preventing a generic metadata channel.", + "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": 1, + "maxItems": 2, "minItems": 1, "type": "array" }, @@ -55,7 +55,8 @@ "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" + "systemd.requires_target_not_active", + "docker.internal_network_member_publishes_port" ], "type": "string" }, diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index fbc2a2bf..d1ad7664 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -147,7 +147,7 @@ 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'; +export type FindingRule = 'systemd.requires_target_not_active' | 'docker.internal_network_member_publishes_port'; /** * Findings are intentionally a small, closed advisory vocabulary. They do * not expose provider output or prescribe an automated remediation. @@ -579,13 +579,13 @@ export interface FindingsResponse { export interface Finding { /** * Canonical, already-sanitized runtime evidence that directly triggered - * this finding. The rule admits exactly one fact, keeping the response - * bounded and preventing a generic metadata channel. + * this finding. Each closed rule has a fixed, small evidence budget, + * preventing this response from becoming a generic metadata channel. * * @minItems 1 - * @maxItems 1 + * @maxItems 2 */ - evidenceRefs: [RuntimeEvidenceRef]; + evidenceRefs: [RuntimeEvidenceRef] | [RuntimeEvidenceRef, RuntimeEvidenceRef]; id: string; recommendation: string; ruleId: FindingRule; diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index b7a4eb9b..a2ec0d3b 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -1314,11 +1314,11 @@ export const RUST_RESPONSE_SCHEMAS = { "additionalProperties": false, "properties": { "evidenceRefs": { - "description": "Canonical, already-sanitized runtime evidence that directly triggered\nthis finding. The rule admits exactly one fact, keeping the response\nbounded and preventing a generic metadata channel.", + "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": 1, + "maxItems": 2, "minItems": 1, "type": "array" }, @@ -1365,7 +1365,8 @@ export const RUST_RESPONSE_SCHEMAS = { "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" + "systemd.requires_target_not_active", + "docker.internal_network_member_publishes_port" ], "type": "string" }, @@ -3812,11 +3813,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "additionalProperties": false, "properties": { "evidenceRefs": { - "description": "Canonical, already-sanitized runtime evidence that directly triggered\nthis finding. The rule admits exactly one fact, keeping the response\nbounded and preventing a generic metadata channel.", + "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": 1, + "maxItems": 2, "minItems": 1, "type": "array" }, @@ -3863,7 +3864,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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" + "systemd.requires_target_not_active", + "docker.internal_network_member_publishes_port" ], "type": "string" }, diff --git a/tests/fixtures/contracts/findings-response.json b/tests/fixtures/contracts/findings-response.json index c356a8b9..bd61102a 100644 --- a/tests/fixtures/contracts/findings-response.json +++ b/tests/fixtures/contracts/findings-response.json @@ -24,6 +24,43 @@ "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" + } + ] } ] } From 6af0e71349663005e4e706b80e0614d23eab72ff Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:44:59 +0800 Subject: [PATCH 32/47] fix: require host publication for internal port findings --- crates/dockermap-core/src/findings.rs | 64 ++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/dockermap-core/src/findings.rs b/crates/dockermap-core/src/findings.rs index db1fdb5f..3bae36c1 100644 --- a/crates/dockermap-core/src/findings.rs +++ b/crates/dockermap-core/src/findings.rs @@ -137,7 +137,41 @@ fn is_docker_network(node: &crate::RuntimeMapNode) -> bool { } fn is_docker_listener(node: &crate::RuntimeMapNode) -> bool { - node.provider == RuntimeProviderKind::Network && node.kind == RuntimeNodeKind::NetworkListener + node.provider == RuntimeProviderKind::Network + && node.kind == RuntimeNodeKind::NetworkListener + && is_host_published_port(node.metadata.get("port").map(String::as_str)) +} + +/// Docker's bounded collector format is either `private/protocol` for an +/// un-published container port or `host:private/protocol` for a host +/// publication. Accept only the latter strict grammar; the rule never emits +/// the port or a bind address, so this is a boolean discriminant only. +fn is_host_published_port(port: Option<&str>) -> bool { + let Some((host, private_and_protocol)) = port.and_then(|value| value.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") } fn is_docker_membership_shape<'a>( @@ -388,7 +422,7 @@ mod tests { listener, RuntimeProviderKind::Network, RuntimeNodeKind::NetworkListener, - BTreeMap::new(), + BTreeMap::from([("port".into(), "8080:80/tcp".into())]), ), ], edges: vec![ @@ -481,5 +515,31 @@ mod tests { 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_port(Some(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_port(Some(port)), "rejected port {port}"); + } } } From 6dfbb8aff25a0c8399e09767b2b74a43d1195782 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:46:33 +0800 Subject: [PATCH 33/47] docs: define bounded Docker port finding --- docs/architecture/ARCHITECTURE.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 8ef977c0..2f6b1956 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -96,16 +96,27 @@ Current relationship-source matrix: ### Bounded findings `GET /daemon/findings` and its authenticated browser aliases expose only a -cached projection of the same published runtime-map revision. The initial -closed rule, `systemd.requires_target_not_active`, emits one warning only when +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. The API validates the fixed vocabulary and static display text before -publication, and the browser displays findings only when their nonempty model -revision matches the current live model. +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. + +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. The map is organized around a unified service concept. Docker containers, systemd services, tmux sessions, npm applications, Python applications, and native processes From 9b9aa3e57d746244306b6dd98ebee95ecfcb6ff6 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:46:49 +0800 Subject: [PATCH 34/47] docs: state findings severity policy --- docs/architecture/ARCHITECTURE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 2f6b1956..47cb6f2e 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -118,6 +118,17 @@ 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. It does not +mean a service failed to start. `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 From a144be61b73bf2ec65bfeec4df6e1c463c8e0202 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:52:27 +0800 Subject: [PATCH 35/47] feat: derive path-free Docker daemon state evidence --- crates/dockermap-core/src/compose.rs | 19 ++-- crates/dockermap-core/src/lib.rs | 90 +++++++++++++++++++ crates/dockermap-core/src/models.rs | 20 ++++- crates/dockermap-core/src/snapshot_runtime.rs | 63 ++++++++++++- crates/dockermap-daemon/src/main.rs | 28 ++++++ 5 files changed, 209 insertions(+), 11 deletions(-) diff --git a/crates/dockermap-core/src/compose.rs b/crates/dockermap-core/src/compose.rs index f36475a6..b862ade2 100644 --- a/crates/dockermap-core/src/compose.rs +++ b/crates/dockermap-core/src/compose.rs @@ -775,13 +775,7 @@ const CREDENTIAL_DIR_NAMES: &[&str] = &[ pub(crate) fn unsafe_bind_source_diagnostic( resolved: &str, ) -> 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/lib.rs b/crates/dockermap-core/src/lib.rs index 78c5f54f..490b8278 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -660,6 +660,96 @@ 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(); diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index 52d4ec79..deeb9860 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -487,6 +487,7 @@ pub enum RuntimeNodeKind { DockerNetwork, DockerVolume, Host, + HostRisk, Service, SystemdService, ScheduledJob, @@ -526,6 +527,7 @@ pub enum RuntimeRelationshipKind { Mounts, Manages, Exposes, + ExposesDaemonState, RunsOn, Uses, Calls, @@ -849,6 +851,9 @@ pub enum RuntimeEvidenceKind { /// 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, @@ -922,7 +927,8 @@ impl RuntimeEvidenceRef { RuntimeEvidenceKind::DockerNetworkMembership | RuntimeEvidenceKind::DockerVolumeMount | RuntimeEvidenceKind::DockerPortPublication - | RuntimeEvidenceKind::DockerComposeDependsOn, + | RuntimeEvidenceKind::DockerComposeDependsOn + | RuntimeEvidenceKind::DockerDaemonStateBindMount, RuntimeEvidenceAssertionKind::Observed, RuntimeEvidenceFreshness::Fresh, None, @@ -1052,6 +1058,18 @@ impl RuntimeMapEdge { && 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, diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 59666f0b..0c270d1d 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -6,9 +6,10 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::{ - collision_resistant_id_component, service_entity_kind_name, ContainerRecord, - DiagnosticSeverity, DockerSnapshot, GraphEdge, GraphNode, GraphResponse, ImageRecord, NodeKind, - RelationshipKind, RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, RuntimeEvidenceKind, + 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, @@ -355,6 +356,7 @@ fn docker_runtime_evidence( 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 => { @@ -370,6 +372,9 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::DockerComposeDependsOn => { "Docker recorded Compose dependency declaration" } + RuntimeEvidenceKind::DockerDaemonStateBindMount => { + "Docker reported a bind mount exposing Docker daemon state" + } RuntimeEvidenceKind::SystemdRequires | RuntimeEvidenceKind::SystemdWants | RuntimeEvidenceKind::SystemdPartOf => { @@ -605,6 +610,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/main.rs b/crates/dockermap-daemon/src/main.rs index f09d276a..9471e2b2 100644 --- a/crates/dockermap-daemon/src/main.rs +++ b/crates/dockermap-daemon/src/main.rs @@ -2637,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 { From 4e8503802506760839ff5e5f79fcf20c69805eed Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:57:08 +0800 Subject: [PATCH 36/47] feat: validate path-free Docker daemon evidence --- apps/api/src/daemonResponseValidation.ts | 2 ++ apps/api/test/security.test.ts | 24 +++++++++++++++++++ .../rust/findings-response.schema.json | 5 ++++ .../generated/rust/runtime-map.schema.json | 7 ++++++ packages/contracts/src/rustModels.ts | 3 +++ packages/contracts/src/rustSchemas.ts | 24 +++++++++++++++++++ 6 files changed, 65 insertions(+) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 6551dabb..28b11d9e 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -69,6 +69,7 @@ const V1_EVIDENCE_EDGE = { 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. @@ -180,6 +181,7 @@ function hasCoherentRuntimeEvidence(payload: unknown): boolean { : 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 (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. diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 27dccce4..0e5825cf 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1231,6 +1231,30 @@ test("runtime evidence is required and fails closed before browser publication", 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" + ); }); test("fabricated runtime evidence is rejected over the authenticated API boundary", async () => { diff --git a/packages/contracts/generated/rust/findings-response.schema.json b/packages/contracts/generated/rust/findings-response.schema.json index 5b15cc6e..f7f35c90 100644 --- a/packages/contracts/generated/rust/findings-response.schema.json +++ b/packages/contracts/generated/rust/findings-response.schema.json @@ -120,6 +120,11 @@ "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.", diff --git a/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index 6a3581ac..79030be5 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -200,6 +200,11 @@ "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.", @@ -548,6 +553,7 @@ "docker_network", "docker_volume", "host", + "host_risk", "service", "systemd_service", "scheduled_job", @@ -815,6 +821,7 @@ "mounts", "manages", "exposes", + "exposes_daemon_state", "runs_on", "uses", "calls", diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index d1ad7664..caf535f1 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -57,6 +57,7 @@ export type RuntimeEvidenceFreshness = 'fresh' | 'stale' | 'timed_out'; 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'; @@ -86,6 +87,7 @@ export type RuntimeRelationshipKind = | 'mounts' | 'manages' | 'exposes' + | 'exposes_daemon_state' | 'runs_on' | 'uses' | 'calls' @@ -108,6 +110,7 @@ export type RuntimeNodeKind = | 'docker_network' | 'docker_volume' | 'host' + | 'host_risk' | 'service' | 'systemd_service' | 'scheduled_job' diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index a2ec0d3b..2e5df565 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -528,6 +528,11 @@ export const RUST_RESPONSE_SCHEMAS = { "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.", @@ -876,6 +881,7 @@ export const RUST_RESPONSE_SCHEMAS = { "docker_network", "docker_volume", "host", + "host_risk", "service", "systemd_service", "scheduled_job", @@ -1143,6 +1149,7 @@ export const RUST_RESPONSE_SCHEMAS = { "mounts", "manages", "exposes", + "exposes_daemon_state", "runs_on", "uses", "calls", @@ -1430,6 +1437,11 @@ export const RUST_RESPONSE_SCHEMAS = { "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.", @@ -3027,6 +3039,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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.", @@ -3375,6 +3392,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "docker_network", "docker_volume", "host", + "host_risk", "service", "systemd_service", "scheduled_job", @@ -3642,6 +3660,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "mounts", "manages", "exposes", + "exposes_daemon_state", "runs_on", "uses", "calls", @@ -3929,6 +3948,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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.", From d9ff04052d6408a1f489634cab8536a40a29067e Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:02:02 +0800 Subject: [PATCH 37/47] feat: flag Docker daemon state bind mounts --- crates/dockermap-core/src/findings.rs | 145 +++++++++++++++++++ crates/dockermap-core/src/models.rs | 2 + crates/dockermap-daemon/src/cache_refresh.rs | 44 +++++- 3 files changed, 189 insertions(+), 2 deletions(-) diff --git a/crates/dockermap-core/src/findings.rs b/crates/dockermap-core/src/findings.rs index 3bae36c1..ab51e595 100644 --- a/crates/dockermap-core/src/findings.rs +++ b/crates/dockermap-core/src/findings.rs @@ -13,6 +13,13 @@ 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 @@ -42,6 +49,15 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec { } } + 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 { @@ -88,6 +104,27 @@ pub fn derive_findings(runtime_map: &RuntimeMap) -> Vec { 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) @@ -130,6 +167,35 @@ 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 @@ -400,6 +466,85 @@ mod tests { } } + 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"; diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index deeb9860..ee2ce7ff 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -1215,6 +1215,8 @@ pub enum FindingRule { 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)] diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index 27a812be..a24e025e 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -1130,8 +1130,8 @@ mod scheduler_tests { use super::*; use crate::provider_contract::ProviderDiagnostic; use dockermap_core::{ - mock_snapshot, HealthState, RuntimeMapNode, RuntimeNodeKind, RuntimeNodeLayer, - RuntimeProviderKind, + mock_snapshot, ComposeMountKind, ContainerMount, HealthState, RuntimeMapNode, + RuntimeNodeKind, RuntimeNodeLayer, RuntimeProviderKind, }; use std::{ collections::BTreeMap as TestBTreeMap, fs, os::unix::fs::PermissionsExt, process::Command, @@ -1336,6 +1336,46 @@ mod scheduler_tests { ); } + #[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(); From 2fdff82d61b31a32b280ad4054b11f7ae195901c Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:05:43 +0800 Subject: [PATCH 38/47] feat: surface Docker daemon state findings --- apps/api/src/daemonResponseValidation.ts | 28 +++++++++++++++++++ apps/api/test/security.test.ts | 1 + apps/web/src/screens/Findings.tsx | 18 ++++++++---- apps/web/src/screens/findings.test.tsx | 19 +++++++++++++ docs/architecture/ARCHITECTURE.md | 25 ++++++++++++----- .../rust/findings-response.schema.json | 3 +- packages/contracts/src/rustModels.ts | 5 +++- packages/contracts/src/rustSchemas.ts | 6 ++-- .../fixtures/contracts/findings-response.json | 24 ++++++++++++++++ 9 files changed, 113 insertions(+), 16 deletions(-) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 28b11d9e..30c8f18e 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -59,6 +59,9 @@ const SYSTEMD_REQUIRES_FINDING_RECOMMENDATION = "Inspect the target service stat 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; @@ -225,6 +228,31 @@ function hasCoherentFindings(payload: unknown): boolean { && 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 diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 0e5825cf..74edae24 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1038,6 +1038,7 @@ test("daemon model responses require non-empty revision and complete provider st ["/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; diff --git a/apps/web/src/screens/Findings.tsx b/apps/web/src/screens/Findings.tsx index 86c2eeaf..3d9a4cb5 100644 --- a/apps/web/src/screens/Findings.tsx +++ b/apps/web/src/screens/Findings.tsx @@ -8,6 +8,12 @@ export default function Findings() { 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 (
@@ -29,17 +35,19 @@ export default function Findings() { ) : (
- {findings.findings.map((finding) => ( - -
{finding.severity === "warning" ? "Warning" : "Advisory"}{finding.ruleId === "systemd.requires_target_not_active" ? "Systemd Requires" : "Internal network + host port"}{finding.evidenceRefs.length} supporting fact{finding.evidenceRefs.length === 1 ? "" : "s"}
+ {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/findings.test.tsx b/apps/web/src/screens/findings.test.tsx index c4a8daa4..4c6262ae 100644 --- a/apps/web/src/screens/findings.test.tsx +++ b/apps/web/src/screens/findings.test.tsx @@ -69,4 +69,23 @@ describe("Findings screen", () => { 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/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 47cb6f2e..6370b352 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -113,6 +113,15 @@ 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 @@ -121,13 +130,15 @@ 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. It does not -mean a service failed to start. `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. +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 diff --git a/packages/contracts/generated/rust/findings-response.schema.json b/packages/contracts/generated/rust/findings-response.schema.json index f7f35c90..ba0b4222 100644 --- a/packages/contracts/generated/rust/findings-response.schema.json +++ b/packages/contracts/generated/rust/findings-response.schema.json @@ -56,7 +56,8 @@ "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.internal_network_member_publishes_port", + "docker.daemon_state_bind_mount" ], "type": "string" }, diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index caf535f1..84b1581f 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -150,7 +150,10 @@ 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'; +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. diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index 2e5df565..ec705d72 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -1373,7 +1373,8 @@ export const RUST_RESPONSE_SCHEMAS = { "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.internal_network_member_publishes_port", + "docker.daemon_state_bind_mount" ], "type": "string" }, @@ -3884,7 +3885,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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.internal_network_member_publishes_port", + "docker.daemon_state_bind_mount" ], "type": "string" }, diff --git a/tests/fixtures/contracts/findings-response.json b/tests/fixtures/contracts/findings-response.json index bd61102a..e76b82d5 100644 --- a/tests/fixtures/contracts/findings-response.json +++ b/tests/fixtures/contracts/findings-response.json @@ -61,6 +61,30 @@ "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" + } + ] } ] } From 1e8ff684964f153e73e7ea646a932540607c84de Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:36:19 +0800 Subject: [PATCH 39/47] fix: suppress runtime evidence in mock mode --- crates/dockermap-core/src/lib.rs | 4 +- crates/dockermap-daemon/src/cache_refresh.rs | 103 +++++++++++++++--- .../src/runtime_collection.rs | 11 +- tests/e2e/dockermap.spec.ts | 47 ++++++++ 4 files changed, 144 insertions(+), 21 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index 490b8278..3627c8ad 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -878,7 +878,7 @@ mod tests { .iter() .flat_map(|edge| &edge.evidence_refs) .next() - .expect("mock snapshot emits Docker evidence"); + .expect("representative Docker snapshot emits Docker evidence"); assert_eq!(evidence.collected_at, 42); assert_eq!(evidence.provider_revision, "opaque-docker-observation-17"); @@ -905,7 +905,7 @@ mod tests { .into_iter() .flat_map(|edge| edge.evidence_refs) .next() - .expect("mock snapshot emits version-one evidence"); + .expect("representative Docker snapshot emits version-one evidence"); let valid = serde_json::to_value(evidence).expect("evidence serializes"); for (field, invalid) in [ diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index a24e025e..1a10ee48 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -423,6 +423,7 @@ impl DaemonCache { 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, ); @@ -864,6 +865,7 @@ fn same_collection_evidence(left: &DockerSnapshot, right: &DockerSnapshot) -> bo fn runtime_map_for_snapshot( snapshot: &DockerSnapshot, + mode: &RuntimeMode, slots: &RuntimeProviderSlots, docker_observation_revision: &str, ) -> RuntimeMap { @@ -891,7 +893,7 @@ fn runtime_map_for_snapshot( } } let mut runtime_map = - runtime_map_from_collection(snapshot, &combined, docker_observation_revision); + 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 @@ -1256,7 +1258,12 @@ mod scheduler_tests { 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(), &slots, "docker-observation"); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &slots, + "docker-observation", + ); let edge = map .edges .iter() @@ -1324,7 +1331,7 @@ mod scheduler_tests { finding.rule_id == dockermap_core::FindingRule::DockerInternalNetworkMemberPublishesPort }) - .expect("mock Docker topology produces the bounded internal-network advisory"); + .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, @@ -1381,7 +1388,12 @@ mod scheduler_tests { let mut slots = slots(); slots.get_mut(&ProviderSlot::Systemd).unwrap().observation = RuntimeProviderState::Fresh(marked_systemd_dependency()); - let map = runtime_map_for_snapshot(&mock_snapshot(), &slots, "docker-observation"); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &slots, + "docker-observation", + ); let edge = map .edges .iter() @@ -1396,7 +1408,12 @@ mod scheduler_tests { 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(), &slots, "docker-observation"); + let map = runtime_map_for_snapshot( + &mock_snapshot(), + &RuntimeMode::Docker, + &slots, + "docker-observation", + ); assert!(map .edges .iter() @@ -2014,7 +2031,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, "test-observation"); + let map = + runtime_map_for_snapshot(&snapshot, &RuntimeMode::Docker, &slots, "test-observation"); assert!(map .provider_states .iter() @@ -2140,7 +2158,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, "test-observation"); + let map = + runtime_map_for_snapshot(&snapshot, &RuntimeMode::Docker, &slots, "test-observation"); assert!(map .nodes .iter() @@ -2222,11 +2241,12 @@ mod scheduler_tests { entry.freshness.status_reason = Some(ProviderStatusReason::Refreshing); retained }; - let refreshing = runtime_map_for_snapshot(&snapshot, &slots, "test-observation") - .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)); @@ -2240,11 +2260,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, "test-observation") - .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)); @@ -2351,6 +2372,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] @@ -2461,6 +2523,11 @@ mod scheduler_tests { // 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_ne!(first_docker_evidence_revision(&mock_cache), changed_token); + 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/runtime_collection.rs b/crates/dockermap-daemon/src/runtime_collection.rs index b580e142..c7e3c13e 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::{ @@ -116,6 +116,7 @@ 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( @@ -125,6 +126,14 @@ pub(crate) fn runtime_map_from_collection( 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 } diff --git a/tests/e2e/dockermap.spec.ts b/tests/e2e/dockermap.spec.ts index d7947cde..9a4ecf55 100644 --- a/tests/e2e/dockermap.spec.ts +++ b/tests/e2e/dockermap.spec.ts @@ -221,6 +221,53 @@ test.describe("DockerMap GUI", () => { 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"); From 7fa1c6ebe32c1789f637f3be8a2389bfccc5e9f6 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:46:06 +0800 Subject: [PATCH 40/47] feat: bind npm manifest provenance evidence --- crates/dockermap-core/src/lib.rs | 39 +++ crates/dockermap-core/src/models.rs | 36 ++- crates/dockermap-core/src/snapshot_runtime.rs | 6 +- crates/dockermap-daemon/src/cache_refresh.rs | 229 ++++++++++++++++++ crates/dockermap-daemon/src/providers/npm.rs | 25 ++ .../rust/findings-response.schema.json | 10 +- .../generated/rust/runtime-map.schema.json | 10 +- packages/contracts/src/rustModels.ts | 5 +- packages/contracts/src/rustSchemas.ts | 40 ++- 9 files changed, 380 insertions(+), 20 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index 3627c8ad..326f157b 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -968,6 +968,45 @@ mod tests { 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_one_evidence_cannot_attest_a_different_runtime_edge() { let snapshot = mock_snapshot(); diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index ee2ce7ff..4fc5917f 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -828,6 +828,7 @@ pub struct RuntimeMapNode { pub enum RuntimeEvidenceProvider { Docker, Systemd, + Npm, } /// Evidence assertion semantics are deliberately closed. A declaration says @@ -862,6 +863,9 @@ pub enum RuntimeEvidenceKind { 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 compact, versioned reference to the bounded fact supporting a runtime @@ -872,7 +876,7 @@ pub enum RuntimeEvidenceKind { 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 = 2))] + #[schemars(range(min = 1, max = 3))] pub version: u8, #[schemars(length(min = 1, max = 259))] pub id: String, @@ -894,9 +898,9 @@ pub struct RuntimeEvidenceRef { #[serde(rename = "providerRevision")] #[schemars(length(min = 1, max = 259))] pub provider_revision: String, - /// Version-two 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. + /// 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, @@ -943,6 +947,15 @@ impl RuntimeEvidenceRef { | RuntimeEvidenceFreshness::Stale | RuntimeEvidenceFreshness::TimedOut, Some(ProviderSlot::Systemd), + ) | ( + 3, + RuntimeEvidenceProvider::Npm, + RuntimeEvidenceKind::NpmPackageManifestDependency, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::ProjectNpm), ) ) } @@ -1122,6 +1135,21 @@ impl RuntimeMapEdge { && 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 + } ( 2, RuntimeEvidenceProvider::Systemd, diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index 0c270d1d..ed8c882a 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -359,7 +359,8 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::DockerDaemonStateBindMount => "daemon-state-bind-mount", RuntimeEvidenceKind::SystemdRequires | RuntimeEvidenceKind::SystemdWants - | RuntimeEvidenceKind::SystemdPartOf => { + | RuntimeEvidenceKind::SystemdPartOf + | RuntimeEvidenceKind::NpmPackageManifestDependency => { unreachable!("Docker evidence helper only accepts Docker evidence kinds") } }; @@ -377,7 +378,8 @@ fn docker_runtime_evidence( } RuntimeEvidenceKind::SystemdRequires | RuntimeEvidenceKind::SystemdWants - | RuntimeEvidenceKind::SystemdPartOf => { + | RuntimeEvidenceKind::SystemdPartOf + | RuntimeEvidenceKind::NpmPackageManifestDependency => { unreachable!("Docker evidence helper only accepts Docker evidence kinds") } }; diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index 1a10ee48..d49dc70b 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -8,6 +8,7 @@ use crate::{ docker_collector::DockerCollector, provider_contract::ProviderCollection, + providers::npm::NPM_EVIDENCE_DEPENDENCY_MARKER, providers::systemd::{ SYSTEMD_EVIDENCE_KIND_MARKER, SYSTEMD_EVIDENCE_PART_OF, SYSTEMD_EVIDENCE_REQUIRES, SYSTEMD_EVIDENCE_WANTS, @@ -878,6 +879,8 @@ fn runtime_map_for_snapshot( 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); } let (target_nodes, target_edges, target_diagnostics) = combined.parts_mut(); target_nodes.extend(nodes); @@ -899,6 +902,89 @@ fn runtime_map_for_snapshot( runtime_map } +/// 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 @@ -1237,6 +1323,43 @@ mod scheduler_tests { 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 + } + #[test] fn systemd_evidence_is_slot_bound_and_truthfully_retained() { for (observation, expected) in [ @@ -1290,6 +1413,112 @@ mod scheduler_tests { } } + #[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()); diff --git a/crates/dockermap-daemon/src/providers/npm.rs b/crates/dockermap-daemon/src/providers/npm.rs index f24bdbbe..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, @@ -178,6 +182,7 @@ 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, @@ -690,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/packages/contracts/generated/rust/findings-response.schema.json b/packages/contracts/generated/rust/findings-response.schema.json index ba0b4222..876be50a 100644 --- a/packages/contracts/generated/rust/findings-response.schema.json +++ b/packages/contracts/generated/rust/findings-response.schema.json @@ -140,6 +140,11 @@ "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" } ] }, @@ -147,7 +152,8 @@ "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" + "systemd", + "npm" ], "type": "string" }, @@ -208,7 +214,7 @@ "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 2, + "maximum": 3, "minimum": 1, "type": "integer" } diff --git a/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index 79030be5..06cccaa4 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -219,6 +219,11 @@ "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" } ] }, @@ -226,7 +231,8 @@ "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" + "systemd", + "npm" ], "type": "string" }, @@ -287,7 +293,7 @@ "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 2, + "maximum": 3, "minimum": 1, "type": "integer" } diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index 84b1581f..b73edbfb 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -60,13 +60,14 @@ export type RuntimeEvidenceKind = | 'docker_daemon_state_bind_mount' | 'systemd_requires' | 'systemd_wants' - | 'systemd_part_of'; + | 'systemd_part_of' + | 'npm_package_manifest_dependency'; /** * 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'; +export type RuntimeEvidenceProvider = 'docker' | 'systemd' | 'npm'; /** * Fixed, schema-backed host-provider slots. This is not a plugin or policy * interface: the daemon owns the complete finite list. diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index ec705d72..fdbd4b11 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -547,6 +547,11 @@ export const RUST_RESPONSE_SCHEMAS = { "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" } ] }, @@ -554,7 +559,8 @@ export const RUST_RESPONSE_SCHEMAS = { "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" + "systemd", + "npm" ], "type": "string" }, @@ -615,7 +621,7 @@ export const RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 2, + "maximum": 3, "minimum": 1, "type": "integer" } @@ -1457,6 +1463,11 @@ export const RUST_RESPONSE_SCHEMAS = { "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" } ] }, @@ -1464,7 +1475,8 @@ export const RUST_RESPONSE_SCHEMAS = { "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" + "systemd", + "npm" ], "type": "string" }, @@ -1525,7 +1537,7 @@ export const RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 2, + "maximum": 3, "minimum": 1, "type": "integer" } @@ -3059,6 +3071,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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" } ] }, @@ -3066,7 +3083,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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" + "systemd", + "npm" ], "type": "string" }, @@ -3127,7 +3145,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 2, + "maximum": 3, "minimum": 1, "type": "integer" } @@ -3969,6 +3987,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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" } ] }, @@ -3976,7 +3999,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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" + "systemd", + "npm" ], "type": "string" }, @@ -4037,7 +4061,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 2, + "maximum": 3, "minimum": 1, "type": "integer" } From ceec4574c0ba63f2d74fd954130003c24533f4c0 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:50:41 +0800 Subject: [PATCH 41/47] chore: refresh npm provenance contracts --- crates/dockermap-core/src/models.rs | 3 ++- .../generated/rust/findings-response.schema.json | 4 ++-- .../generated/rust/runtime-map.schema.json | 4 ++-- packages/contracts/src/rustModels.ts | 9 +++++---- packages/contracts/src/rustSchemas.ts | 16 ++++++++-------- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index 4fc5917f..d53a4ac8 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -887,7 +887,8 @@ pub struct RuntimeEvidenceRef { /// 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 whose Docker fact was observed. + /// The already-public runtime entity directly attested by this bounded + /// provider fact. #[serde(rename = "subjectRef")] pub subject_ref: String, #[serde(rename = "collectedAt")] diff --git a/packages/contracts/generated/rust/findings-response.schema.json b/packages/contracts/generated/rust/findings-response.schema.json index 876be50a..59fae660 100644 --- a/packages/contracts/generated/rust/findings-response.schema.json +++ b/packages/contracts/generated/rust/findings-response.schema.json @@ -199,10 +199,10 @@ "type": "null" } ], - "description": "Version-two provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + "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 whose Docker fact was observed.", + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", "type": "string" }, "summary": { diff --git a/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index 06cccaa4..bc318e9f 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -278,10 +278,10 @@ "type": "null" } ], - "description": "Version-two provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + "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 whose Docker fact was observed.", + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", "type": "string" }, "summary": { diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index b73edbfb..a4fee780 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -319,13 +319,14 @@ export interface RuntimeEvidenceRef { */ providerRevision: string; /** - * Version-two 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. + * 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 whose Docker fact was observed. + * The already-public runtime entity directly attested by this bounded + * provider fact. */ subjectRef: string; /** diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index fdbd4b11..f1255857 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -606,10 +606,10 @@ export const RUST_RESPONSE_SCHEMAS = { "type": "null" } ], - "description": "Version-two provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + "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 whose Docker fact was observed.", + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", "type": "string" }, "summary": { @@ -1522,10 +1522,10 @@ export const RUST_RESPONSE_SCHEMAS = { "type": "null" } ], - "description": "Version-two provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + "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 whose Docker fact was observed.", + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", "type": "string" }, "summary": { @@ -3130,10 +3130,10 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "type": "null" } ], - "description": "Version-two provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + "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 whose Docker fact was observed.", + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", "type": "string" }, "summary": { @@ -4046,10 +4046,10 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "type": "null" } ], - "description": "Version-two provider evidence is explicitly tied to the finite\nscheduler slot that supplied its revision and freshness. Version one\nDocker evidence intentionally has no host-provider slot." + "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 whose Docker fact was observed.", + "description": "The already-public runtime entity directly attested by this bounded\nprovider fact.", "type": "string" }, "summary": { From ebfe0a87511a0fb90e426a4b7c163c283e1981f7 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:05:31 +0800 Subject: [PATCH 42/47] fix: accept closed npm provenance evidence --- apps/api/src/daemonResponseValidation.ts | 18 +++++- apps/api/test/security.test.ts | 62 +++++++++++++++++++ .../contracts/runtime-map-daemon-emitted.json | 57 +++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 30c8f18e..7e01ea50 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -84,6 +84,13 @@ const V2_EVIDENCE_EDGE = { 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; + function hasCompleteProviderStateVector(payload: unknown): boolean { if (!payload || typeof payload !== "object") return false; const providerStates = (payload as { providerStates?: unknown }).providerStates; @@ -176,11 +183,18 @@ function hasCoherentRuntimeEvidence(payload: unknown): boolean { && value.assertionKind === "declared" && value.providerSlot === "systemd" && (value.freshness === "fresh" || value.freshness === "stale" || value.freshness === "timed_out"); - if (!isV1 && !isV2) return false; + 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"); + if (!isV1 && !isV2 && !isV3) return false; const expected = typeof value.kind === "string" ? (isV1 ? V1_EVIDENCE_EDGE[value.kind as keyof typeof V1_EVIDENCE_EDGE] - : V2_EVIDENCE_EDGE[value.kind as keyof typeof V2_EVIDENCE_EDGE]) + : isV2 + ? V2_EVIDENCE_EDGE[value.kind as keyof typeof V2_EVIDENCE_EDGE] + : V3_EVIDENCE_EDGE[value.kind as keyof typeof V3_EVIDENCE_EDGE]) : 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; diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 74edae24..61572a78 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1256,6 +1256,42 @@ test("runtime evidence is required and fails closed before browser publication", () => 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)); }); test("fabricated runtime evidence is rejected over the authenticated API boundary", async () => { @@ -1282,6 +1318,32 @@ test("fabricated runtime evidence is rejected over the authenticated API boundar 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("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) => { diff --git a/tests/fixtures/contracts/runtime-map-daemon-emitted.json b/tests/fixtures/contracts/runtime-map-daemon-emitted.json index 3e4a33f7..2777f93e 100644 --- a/tests/fixtures/contracts/runtime-map-daemon-emitted.json +++ b/tests/fixtures/contracts/runtime-map-daemon-emitted.json @@ -210,6 +210,39 @@ "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": [] + } } ], "edges": [ @@ -532,6 +565,30 @@ "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" + } + ] } ], "diagnostics": [], From a1ef1b9ac575993b68fff4246206fcf3d95511c5 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:59:27 +0800 Subject: [PATCH 43/47] fix: attest only bound Docker host ports --- crates/dockermap-core/src/findings.rs | 42 ++-------- crates/dockermap-core/src/lib.rs | 83 +++++++++++++++++++ crates/dockermap-core/src/snapshot_runtime.rs | 56 +++++++++++-- 3 files changed, 139 insertions(+), 42 deletions(-) diff --git a/crates/dockermap-core/src/findings.rs b/crates/dockermap-core/src/findings.rs index ab51e595..edeef0f7 100644 --- a/crates/dockermap-core/src/findings.rs +++ b/crates/dockermap-core/src/findings.rs @@ -1,3 +1,4 @@ +use crate::snapshot_runtime::is_host_published_docker_port; use crate::{ collision_resistant_id_component, Finding, FindingRule, FindingSeverity, RuntimeEvidenceAssertionKind, RuntimeEvidenceFreshness, RuntimeEvidenceKind, @@ -205,39 +206,10 @@ fn is_docker_network(node: &crate::RuntimeMapNode) -> bool { fn is_docker_listener(node: &crate::RuntimeMapNode) -> bool { node.provider == RuntimeProviderKind::Network && node.kind == RuntimeNodeKind::NetworkListener - && is_host_published_port(node.metadata.get("port").map(String::as_str)) -} - -/// Docker's bounded collector format is either `private/protocol` for an -/// un-published container port or `host:private/protocol` for a host -/// publication. Accept only the latter strict grammar; the rule never emits -/// the port or a bind address, so this is a boolean discriminant only. -fn is_host_published_port(port: Option<&str>) -> bool { - let Some((host, private_and_protocol)) = port.and_then(|value| value.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") + && node + .metadata + .get("port") + .is_some_and(|port| is_host_published_docker_port(port)) } fn is_docker_membership_shape<'a>( @@ -672,7 +644,7 @@ mod tests { 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_port(Some(port)), + is_host_published_docker_port(port), "expected host port {port}" ); } @@ -684,7 +656,7 @@ mod tests { "8080:80/tcp:extra", "not-a-port", ] { - assert!(!is_host_published_port(Some(port)), "rejected port {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 326f157b..b8c60017 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -1175,6 +1175,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(); diff --git a/crates/dockermap-core/src/snapshot_runtime.rs b/crates/dockermap-core/src/snapshot_runtime.rs index ed8c882a..9322ac20 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -42,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(); @@ -493,13 +526,22 @@ pub fn derive_runtime_map( }); let source = runtime_container_id(container); edges.push(RuntimeMapEdge { - evidence_refs: vec![docker_runtime_evidence( - snapshot, - &source, - &listener_id, - RuntimeEvidenceKind::DockerPortPublication, - evidence_provider_revision, - )], + // 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, From 9b0e9288c2632800ed4909d2e57b625336f34129 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:08:55 +0800 Subject: [PATCH 44/47] docs: clarify runtime provenance boundaries --- docs/architecture/ARCHITECTURE.md | 34 +++++++++++++++++++++++++------ docs/security/THREAT_MODEL.md | 14 +++++++++++++ docs/testing/TESTING_PLAN.md | 11 ++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 6370b352..864ca38c 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -52,19 +52,25 @@ acceptance work are recorded in [`CONTRACT_AUTHORITY.md`](CONTRACT_AUTHORITY.md) ### Relationship evidence lifecycle -Each runtime edge has a required `evidenceRefs` array. The current Docker and -Systemd slices emit bounded, versioned records alongside the edge during +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, port -publication, and Docker-recorded Compose start-order declarations. They are +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` @@ -75,6 +81,20 @@ 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 @@ -88,10 +108,12 @@ Current relationship-source matrix: | --- | --- | --- | --- | | Docker container -> network | Docker inventory membership | observed | emitted | | Docker container -> volume | Docker volume attachment | observed | emitted | -| Docker container -> listener | Docker published port | 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, tmux, proxy, DNS, process and cross-provider edges | bounded provider-specific collector facts | varies | explicit empty migration array; no invented provenance | +| 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 diff --git a/docs/security/THREAT_MODEL.md b/docs/security/THREAT_MODEL.md index f293b51f..f3c9cb44 100644 --- a/docs/security/THREAT_MODEL.md +++ b/docs/security/THREAT_MODEL.md @@ -46,6 +46,16 @@ changes them: 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 @@ -151,6 +161,10 @@ Automated tests currently cover: 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 From c4da2fa9fb6d4ae02a8c3d64be0371f5681f64a1 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:24:02 +0800 Subject: [PATCH 45/47] feat: bind cron schedule provenance to its own slot --- crates/dockermap-core/src/lib.rs | 36 +++ crates/dockermap-core/src/models.rs | 34 ++- crates/dockermap-core/src/schema_baseline.rs | 27 +- crates/dockermap-core/src/snapshot_runtime.rs | 6 +- crates/dockermap-daemon/src/cache_refresh.rs | 263 +++++++++++++++++- .../dockermap-daemon/src/provider_contract.rs | 4 + crates/dockermap-daemon/src/providers/cron.rs | 27 +- .../src/runtime_collection.rs | 55 +++- .../rust/findings-response.schema.json | 15 +- .../generated/rust/runtime-map.schema.json | 19 +- packages/contracts/src/rustModels.ts | 23 +- packages/contracts/src/rustSchemas.ts | 68 ++++- .../contracts/runtime-map-daemon-emitted.json | 10 + .../contracts/runtime-map-expanded.json | 1 + tests/fixtures/contracts/runtime-map.json | 1 + 15 files changed, 543 insertions(+), 46 deletions(-) diff --git a/crates/dockermap-core/src/lib.rs b/crates/dockermap-core/src/lib.rs index b8c60017..66dcaaf1 100644 --- a/crates/dockermap-core/src/lib.rs +++ b/crates/dockermap-core/src/lib.rs @@ -1007,6 +1007,42 @@ mod tests { 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(); diff --git a/crates/dockermap-core/src/models.rs b/crates/dockermap-core/src/models.rs index d53a4ac8..401ef79c 100644 --- a/crates/dockermap-core/src/models.rs +++ b/crates/dockermap-core/src/models.rs @@ -153,6 +153,9 @@ 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, @@ -829,6 +832,7 @@ pub enum RuntimeEvidenceProvider { Docker, Systemd, Npm, + Cron, } /// Evidence assertion semantics are deliberately closed. A declaration says @@ -866,6 +870,8 @@ pub enum RuntimeEvidenceKind { /// 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 @@ -876,7 +882,7 @@ pub enum RuntimeEvidenceKind { 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 = 3))] + #[schemars(range(min = 1, max = 4))] pub version: u8, #[schemars(length(min = 1, max = 259))] pub id: String, @@ -957,6 +963,15 @@ impl RuntimeEvidenceRef { | RuntimeEvidenceFreshness::Stale | RuntimeEvidenceFreshness::TimedOut, Some(ProviderSlot::ProjectNpm), + ) | ( + 4, + RuntimeEvidenceProvider::Cron, + RuntimeEvidenceKind::CronScheduleDeclaration, + RuntimeEvidenceAssertionKind::Declared, + RuntimeEvidenceFreshness::Fresh + | RuntimeEvidenceFreshness::Stale + | RuntimeEvidenceFreshness::TimedOut, + Some(ProviderSlot::Cron), ) ) } @@ -1151,6 +1166,21 @@ impl RuntimeMapEdge { && 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, @@ -1292,7 +1322,7 @@ pub struct RuntimeMap { #[schemars(length(min = 1))] pub model_revision: String, #[serde(rename = "providerStates")] - #[schemars(length(min = 6, max = 6))] + #[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 1dc23715..be5ea895 100644 --- a/crates/dockermap-core/src/schema_baseline.rs +++ b/crates/dockermap-core/src/schema_baseline.rs @@ -162,11 +162,34 @@ mod tests { .expect("provider state property exists"); assert_eq!( states.get("minItems").and_then(|value| value.as_u64()), - Some(6) + Some(7) ); assert_eq!( states.get("maxItems").and_then(|value| value.as_u64()), - Some(6) + 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 9322ac20..8ae43169 100644 --- a/crates/dockermap-core/src/snapshot_runtime.rs +++ b/crates/dockermap-core/src/snapshot_runtime.rs @@ -393,7 +393,8 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::SystemdRequires | RuntimeEvidenceKind::SystemdWants | RuntimeEvidenceKind::SystemdPartOf - | RuntimeEvidenceKind::NpmPackageManifestDependency => { + | RuntimeEvidenceKind::NpmPackageManifestDependency + | RuntimeEvidenceKind::CronScheduleDeclaration => { unreachable!("Docker evidence helper only accepts Docker evidence kinds") } }; @@ -412,7 +413,8 @@ fn docker_runtime_evidence( RuntimeEvidenceKind::SystemdRequires | RuntimeEvidenceKind::SystemdWants | RuntimeEvidenceKind::SystemdPartOf - | RuntimeEvidenceKind::NpmPackageManifestDependency => { + | RuntimeEvidenceKind::NpmPackageManifestDependency + | RuntimeEvidenceKind::CronScheduleDeclaration => { unreachable!("Docker evidence helper only accepts Docker evidence kinds") } }; diff --git a/crates/dockermap-daemon/src/cache_refresh.rs b/crates/dockermap-daemon/src/cache_refresh.rs index d49dc70b..f7c52038 100644 --- a/crates/dockermap-daemon/src/cache_refresh.rs +++ b/crates/dockermap-daemon/src/cache_refresh.rs @@ -8,6 +8,7 @@ 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, @@ -309,6 +310,7 @@ impl SlotDataRevision { pub(crate) struct ProviderSlotFlights { network: Arc, host: Arc, + cron: Arc, systemd: Arc, python: Arc, native: Arc, @@ -320,6 +322,7 @@ 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)), @@ -336,6 +339,7 @@ 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(), @@ -347,6 +351,7 @@ impl ProviderSlotFlights { [ &self.network, &self.host, + &self.cron, &self.systemd, &self.python, &self.native, @@ -881,6 +886,8 @@ fn runtime_map_for_snapshot( 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); @@ -895,6 +902,22 @@ fn runtime_map_for_snapshot( }); } } + // 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); @@ -902,6 +925,84 @@ fn runtime_map_for_snapshot( 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 @@ -1360,6 +1461,156 @@ mod scheduler_tests { 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 [ @@ -1760,6 +2011,7 @@ 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) @@ -1808,6 +2060,7 @@ 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); @@ -1874,11 +2127,12 @@ 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::(), 33); + assert_eq!(starts.values().sum::(), 38); assert!(maximum_live_workers <= MAX_CONCURRENT_PROVIDER_SLOTS); // Before Systemd became independently schedulable, one aggregate // host-scoped pass covered it alongside the four other fixed bundles. @@ -1911,7 +2165,7 @@ mod scheduler_tests { .block_on(run_real_collector_churn_trace(&profile)); match profile.as_str() { "full-host" => { - assert_eq!(starts.values().sum::(), 33); + 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. @@ -1924,8 +2178,9 @@ mod scheduler_tests { ); } "restricted" => { - assert_eq!(starts.values().sum::(), 13); + 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); @@ -2134,6 +2389,7 @@ mod scheduler_tests { if profile == "restricted" { for slot in [ ProviderSlot::HostScoped, + ProviderSlot::Cron, ProviderSlot::PythonProcesses, ProviderSlot::NativeProcesses, ] { @@ -2146,6 +2402,7 @@ 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); diff --git a/crates/dockermap-daemon/src/provider_contract.rs b/crates/dockermap-daemon/src/provider_contract.rs index 47605dde..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 } 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/runtime_collection.rs b/crates/dockermap-daemon/src/runtime_collection.rs index c7e3c13e..534e99e0 100644 --- a/crates/dockermap-daemon/src/runtime_collection.rs +++ b/crates/dockermap-daemon/src/runtime_collection.rs @@ -48,6 +48,7 @@ pub(crate) type StaticProviderSlot = ProviderSlot; pub(crate) const STATIC_PROVIDER_SLOTS: &[StaticProviderSlot] = &[ StaticProviderSlot::NetworkInfrastructure, StaticProviderSlot::HostScoped, + StaticProviderSlot::Cron, StaticProviderSlot::Systemd, StaticProviderSlot::PythonProcesses, StaticProviderSlot::NativeProcesses, @@ -60,6 +61,7 @@ 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), @@ -180,6 +182,17 @@ 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( @@ -270,10 +283,6 @@ pub(crate) fn collect_host_scoped_runtime_providers( RuntimeProviderKind::Network, "Network listener discovery omitted because the daemon runs in a restricted PID namespace", ), - ( - RuntimeProviderKind::ScheduledJob, - "Scheduled job discovery omitted because the daemon runs in a restricted PID namespace", - ), ( RuntimeProviderKind::Pm2, "PM2 discovery omitted because the daemon runs in a restricted PID namespace", @@ -294,11 +303,29 @@ pub(crate) fn collect_host_scoped_runtime_providers( let (nodes, _, diagnostics) = collection.parts_mut(); collect_network_listeners(nodes, diagnostics); - collect_scheduled_jobs(nodes, diagnostics); collect_pm2_apps(nodes, diagnostics); collect_tmux_sessions(nodes, diagnostics); } +/// Cron is independently scheduled so declaration evidence has its own +/// revision, freshness, timeout and single-flight guard. It reuses the +/// existing fixed read-only command and bounded fixed filesystem roots. +fn collect_cron_runtime_provider( + pid_namespace: PidNamespaceScope, + collection: &mut ProviderCollection, +) { + if pid_namespace.is_restricted() { + collection.push_diagnostic(ProviderDiagnostic::new( + RuntimeProviderKind::ScheduledJob, + DiagnosticSeverity::Info, + "Scheduled job discovery omitted because the daemon runs in a restricted PID namespace", + )); + return; + } + let (nodes, edges, diagnostics) = collection.parts_mut(); + collect_scheduled_jobs(nodes, edges, diagnostics); +} + /// systemd's unit graph is independently scheduled so its relationship facts /// have their own state and revision. This does not add a command: it keeps /// the existing fixed, read-only `systemctl` collector and its diagnostics. @@ -363,7 +390,6 @@ mod tests { assert!(edges.is_empty()); for provider in [ RuntimeProviderKind::Network, - RuntimeProviderKind::ScheduledJob, RuntimeProviderKind::Pm2, RuntimeProviderKind::Tmux, ] { @@ -373,6 +399,22 @@ 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(); @@ -418,6 +460,7 @@ mod tests { [ StaticProviderSlot::NetworkInfrastructure, StaticProviderSlot::HostScoped, + StaticProviderSlot::Cron, StaticProviderSlot::Systemd, StaticProviderSlot::PythonProcesses, StaticProviderSlot::NativeProcesses, diff --git a/packages/contracts/generated/rust/findings-response.schema.json b/packages/contracts/generated/rust/findings-response.schema.json index 59fae660..6c594816 100644 --- a/packages/contracts/generated/rust/findings-response.schema.json +++ b/packages/contracts/generated/rust/findings-response.schema.json @@ -82,6 +82,11 @@ ], "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.", @@ -145,6 +150,11 @@ "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" } ] }, @@ -153,7 +163,8 @@ "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -214,7 +225,7 @@ "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } diff --git a/packages/contracts/generated/rust/runtime-map.schema.json b/packages/contracts/generated/rust/runtime-map.schema.json index bc318e9f..d1f06993 100644 --- a/packages/contracts/generated/rust/runtime-map.schema.json +++ b/packages/contracts/generated/rust/runtime-map.schema.json @@ -22,6 +22,11 @@ ], "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.", @@ -224,6 +229,11 @@ "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" } ] }, @@ -232,7 +242,8 @@ "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -293,7 +304,7 @@ "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } @@ -966,8 +977,8 @@ "items": { "$ref": "#/$defs/ProviderState" }, - "maxItems": 6, - "minItems": 6, + "maxItems": 7, + "minItems": 7, "type": "array" }, "source": { diff --git a/packages/contracts/src/rustModels.ts b/packages/contracts/src/rustModels.ts index a4fee780..4daf1506 100644 --- a/packages/contracts/src/rustModels.ts +++ b/packages/contracts/src/rustModels.ts @@ -61,19 +61,22 @@ export type RuntimeEvidenceKind = | 'systemd_requires' | 'systemd_wants' | 'systemd_part_of' - | 'npm_package_manifest_dependency'; + | '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'; +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') | 'systemd'; + | ('network_infrastructure' | 'host_scoped' | 'python_processes' | 'native_processes' | 'project_npm') + | 'cron' + | 'systemd'; export type RuntimeRelationshipKind = | 'connected_to' | 'depends_on' @@ -237,10 +240,18 @@ export interface RuntimeMap { modelRevision: string; nodes: RuntimeMapNode[]; /** - * @minItems 6 - * @maxItems 6 + * @minItems 7 + * @maxItems 7 */ - providerStates: [ProviderState, 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. diff --git a/packages/contracts/src/rustSchemas.ts b/packages/contracts/src/rustSchemas.ts index f1255857..0eac7075 100644 --- a/packages/contracts/src/rustSchemas.ts +++ b/packages/contracts/src/rustSchemas.ts @@ -350,6 +350,11 @@ export const RUST_RESPONSE_SCHEMAS = { ], "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.", @@ -552,6 +557,11 @@ export const RUST_RESPONSE_SCHEMAS = { "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" } ] }, @@ -560,7 +570,8 @@ export const RUST_RESPONSE_SCHEMAS = { "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -621,7 +632,7 @@ export const RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } @@ -1294,8 +1305,8 @@ export const RUST_RESPONSE_SCHEMAS = { "items": { "$ref": "#/$defs/ProviderState" }, - "maxItems": 6, - "minItems": 6, + "maxItems": 7, + "minItems": 7, "type": "array" }, "source": { @@ -1405,6 +1416,11 @@ export const RUST_RESPONSE_SCHEMAS = { ], "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.", @@ -1468,6 +1484,11 @@ export const RUST_RESPONSE_SCHEMAS = { "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" } ] }, @@ -1476,7 +1497,8 @@ export const RUST_RESPONSE_SCHEMAS = { "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -1537,7 +1559,7 @@ export const RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } @@ -2874,6 +2896,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { ], "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.", @@ -3076,6 +3103,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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" } ] }, @@ -3084,7 +3116,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -3145,7 +3178,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } @@ -3818,8 +3851,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "items": { "$ref": "#/components/schemas/RuntimeMap/$defs/ProviderState" }, - "maxItems": 6, - "minItems": 6, + "maxItems": 7, + "minItems": 7, "type": "array" }, "source": { @@ -3929,6 +3962,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { ], "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.", @@ -3992,6 +4030,11 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "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" } ] }, @@ -4000,7 +4043,8 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "enum": [ "docker", "systemd", - "npm" + "npm", + "cron" ], "type": "string" }, @@ -4061,7 +4105,7 @@ export const OPENAPI_RUST_RESPONSE_SCHEMAS = { "version": { "description": "Version of this closed evidence representation, not a provider API\nversion. It lets future additions remain explicit and reviewable.", "format": "uint8", - "maximum": 3, + "maximum": 4, "minimum": 1, "type": "integer" } diff --git a/tests/fixtures/contracts/runtime-map-daemon-emitted.json b/tests/fixtures/contracts/runtime-map-daemon-emitted.json index 2777f93e..71f411bd 100644 --- a/tests/fixtures/contracts/runtime-map-daemon-emitted.json +++ b/tests/fixtures/contracts/runtime-map-daemon-emitted.json @@ -615,6 +615,16 @@ "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", diff --git a/tests/fixtures/contracts/runtime-map-expanded.json b/tests/fixtures/contracts/runtime-map-expanded.json index b145caf2..f5ba4a2a 100644 --- a/tests/fixtures/contracts/runtime-map-expanded.json +++ b/tests/fixtures/contracts/runtime-map-expanded.json @@ -604,6 +604,7 @@ "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 }, diff --git a/tests/fixtures/contracts/runtime-map.json b/tests/fixtures/contracts/runtime-map.json index 3087fa9a..259d6fee 100644 --- a/tests/fixtures/contracts/runtime-map.json +++ b/tests/fixtures/contracts/runtime-map.json @@ -28,6 +28,7 @@ "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 }, From 2240fea1c7c779e84c82f266d5016375511391e7 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:30:32 +0800 Subject: [PATCH 46/47] feat: validate cron schedule provenance at API boundary --- apps/api/src/daemonResponseValidation.ts | 19 +++- apps/api/src/index.ts | 2 +- apps/api/test/security.test.ts | 91 +++++++++++++++++++ .../contracts/runtime-map-daemon-emitted.json | 43 +++++++++ 4 files changed, 152 insertions(+), 3 deletions(-) diff --git a/apps/api/src/daemonResponseValidation.ts b/apps/api/src/daemonResponseValidation.ts index 7e01ea50..59d25993 100644 --- a/apps/api/src/daemonResponseValidation.ts +++ b/apps/api/src/daemonResponseValidation.ts @@ -50,6 +50,7 @@ const PROVIDER_STATE_SLOT_SET = { python_processes: true, native_processes: true, project_npm: true, + cron: true, } as const satisfies Record; const PROVIDER_STATE_SLOTS = Object.keys(PROVIDER_STATE_SLOT_SET) as ProviderSlot[]; const U32_MAX = 4_294_967_295; @@ -91,6 +92,12 @@ const V3_EVIDENCE_EDGE = { npm_package_manifest_dependency: { relationship: "depends_on", sourcePrefix: "npm_project_", targetPrefix: "npm_package_" }, } as const; +// Version four is a parsed cron declaration from Cron's own scheduler slot. +// It makes no execution, successful-run, or host-health claim. +const V4_EVIDENCE_EDGE = { + cron_schedule_declaration: { relationship: "runs_on", sourcePrefix: "scheduled_job_", targetPrefix: "host_", target: "host_local" }, +} as const; + function hasCompleteProviderStateVector(payload: unknown): boolean { if (!payload || typeof payload !== "object") return false; const providerStates = (payload as { providerStates?: unknown }).providerStates; @@ -188,16 +195,24 @@ function hasCoherentRuntimeEvidence(payload: unknown): boolean { && value.assertionKind === "declared" && value.providerSlot === "project_npm" && (value.freshness === "fresh" || value.freshness === "stale" || value.freshness === "timed_out"); - if (!isV1 && !isV2 && !isV3) return false; + const isV4 = value.version === 4 + && value.provider === "cron" + && value.assertionKind === "declared" + && value.providerSlot === "cron" + && (value.freshness === "fresh" || value.freshness === "stale" || value.freshness === "timed_out"); + if (!isV1 && !isV2 && !isV3 && !isV4) return false; const expected = typeof value.kind === "string" ? (isV1 ? V1_EVIDENCE_EDGE[value.kind as keyof typeof V1_EVIDENCE_EDGE] : isV2 ? V2_EVIDENCE_EDGE[value.kind as keyof typeof V2_EVIDENCE_EDGE] - : V3_EVIDENCE_EDGE[value.kind as keyof typeof V3_EVIDENCE_EDGE]) + : isV3 + ? V3_EVIDENCE_EDGE[value.kind as keyof typeof V3_EVIDENCE_EDGE] + : V4_EVIDENCE_EDGE[value.kind as keyof typeof V4_EVIDENCE_EDGE]) : undefined; if (!expected || candidate.relationship !== expected.relationship || typeof candidate.source !== "string" || typeof candidate.target !== "string") return false; if (value.subjectRef !== candidate.source || !candidate.source.startsWith(expected.sourcePrefix) || !candidate.target.startsWith(expected.targetPrefix)) return false; + if (isV4 && candidate.target !== "host_local") return false; if (value.kind === "docker_daemon_state_bind_mount" && candidate.target !== "host_risk_docker_daemon_state") return false; if (candidate.source === candidate.target) return false; // An opaque observation token must never be the collection timestamp diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e2b33e87..4d834e3b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -424,7 +424,7 @@ function getMockResponse(path: string): T { providerStates: [ unavailableProviderState("network_infrastructure"), unavailableProviderState("host_scoped"), unavailableProviderState("systemd"), unavailableProviderState("python_processes"), unavailableProviderState("native_processes"), - unavailableProviderState("project_npm") + unavailableProviderState("project_npm"), unavailableProviderState("cron") ], source: "mock" }; diff --git a/apps/api/test/security.test.ts b/apps/api/test/security.test.ts index 61572a78..295916c4 100644 --- a/apps/api/test/security.test.ts +++ b/apps/api/test/security.test.ts @@ -1292,6 +1292,70 @@ test("runtime evidence is required and fails closed before browser publication", assert.ok(malformedNpmEdge); malformedNpmEdge.target = "docker_container_not_a_package"; assert.throws(() => validateDaemonResponse("/daemon/runtime/map", wrongNpmEndpoint)); + + const cronEdge = fixture.edges.find((edge: { source?: unknown }) => edge.source === "scheduled_job_fixture_daily_backup"); + assert.ok(cronEdge, "canonical daemon fixture carries a V4 Cron schedule declaration"); + assert.doesNotThrow(() => validateDaemonResponse("/daemon/runtime/map", fixture)); + for (const freshness of ["stale", "timed_out"] as const) { + const retainedCron = structuredClone(fixture); + const edge = retainedCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(edge); + edge.evidenceRefs[0].freshness = freshness; + assert.doesNotThrow( + () => validateDaemonResponse("/daemon/runtime/map", retainedCron), + `v4 cron evidence may retain ${freshness} data from its own scheduler slot` + ); + } + for (const [field, value] of [ + ["provider", "systemd"], + ["kind", "systemd_requires"], + ["assertionKind", "observed"], + ["providerSlot", "host_scoped"], + ["freshness", "unavailable"], + ["version", 3] + ] as const) { + const malformedCron = structuredClone(fixture); + const edge = malformedCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(edge); + edge.evidenceRefs[0][field] = value; + assert.throws( + () => validateDaemonResponse("/daemon/runtime/map", malformedCron), + `v4 cron evidence must reject fabricated ${field}` + ); + } + const timestampAliasedCron = structuredClone(fixture); + const timestampAliasedCronEdge = timestampAliasedCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(timestampAliasedCronEdge); + timestampAliasedCronEdge.evidenceRefs[0].providerRevision = String(timestampAliasedCronEdge.evidenceRefs[0].collectedAt); + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", timestampAliasedCron)); + const unboundedCron = structuredClone(fixture); + const unboundedCronEdge = unboundedCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(unboundedCronEdge); + unboundedCronEdge.evidenceRefs[0].providerRevision = "x".repeat(260); + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", unboundedCron)); + const unsafeTimestampCron = structuredClone(fixture); + const unsafeTimestampCronEdge = unsafeTimestampCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(unsafeTimestampCronEdge); + unsafeTimestampCronEdge.evidenceRefs[0].collectedAt = Number.MAX_SAFE_INTEGER + 1; + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", unsafeTimestampCron)); + const extraCronField = structuredClone(fixture); + const extraCronFieldEdge = extraCronField.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(extraCronFieldEdge); + extraCronFieldEdge.evidenceRefs[0].rawSchedule = "* * * * * secret"; + assert.throws(() => validateDaemonResponse("/daemon/runtime/map", extraCronField)); + for (const [field, value] of [ + ["target", "host_other"], + ["relationship", "depends_on"] + ] as const) { + const malformedCron = structuredClone(fixture); + const edge = malformedCron.edges.find((candidate: { source?: unknown }) => candidate.source === "scheduled_job_fixture_daily_backup"); + assert.ok(edge); + edge[field] = value; + assert.throws( + () => validateDaemonResponse("/daemon/runtime/map", malformedCron), + `v4 cron evidence must reject a noncanonical ${field}` + ); + } }); test("fabricated runtime evidence is rejected over the authenticated API boundary", async () => { @@ -1344,6 +1408,32 @@ test("fabricated V3 NPM evidence is rejected neutrally over the authenticated AP assert.doesNotMatch(JSON.stringify(body), new RegExp(sentinel)); }); +test("fabricated V4 Cron evidence is rejected neutrally over the authenticated API boundary", async () => { + const fixture = JSON.parse(await readFile( + new URL("../../../tests/fixtures/contracts/runtime-map-daemon-emitted.json", import.meta.url), + "utf8" + )); + const sentinel = "DOCKERMAP_TEST_FAKE_CRON_EVIDENCE_SECRET"; + const cronEdge = fixture.edges.find((edge: { source?: unknown }) => edge.source === "scheduled_job_fixture_daily_backup"); + assert.ok(cronEdge, "canonical fixture must exercise the V4 browser boundary"); + cronEdge.target = `host_${sentinel}`; + cronEdge.evidenceRefs[0].subjectRef = cronEdge.source; + cronEdge.evidenceRefs[0].providerSlot = "host_scoped"; + const daemon = await startStubDaemon((req, res) => { + if (req.url === "/daemon/runtime/map") return sendJson(res, 200, fixture); + return sendJson(res, 404, { code: "not_found", message: "missing" }); + }); + const api = await startApi({ DOCKERMAP_DAEMON_URL: `http://127.0.0.1:${daemon.port}`, DOCKERMAP_API_TOKEN: "test-token" }); + const response = await request(api, "/api/v1/runtime/map", { headers: { Authorization: "Bearer test-token" } }); + assert.equal(response.status, 502); + const body = await response.json(); + assert.deepEqual(body, { + code: "daemon_invalid_response", + message: "Daemon response did not match its declared contract" + }); + assert.doesNotMatch(JSON.stringify(body), new RegExp(sentinel)); +}); + test("actual canonical and v1 SSE snapshot/error frames use their declared payload schemas", async () => { const health = JSON.parse(await readFile(new URL("../../../tests/fixtures/contracts/health-response.json", import.meta.url), "utf8")); const healthyDaemon = await startStubDaemon((req, res) => { @@ -2230,6 +2320,7 @@ test("API publishes redacted and normalized daemon data on every response route" providerStates: [ { slot: "network_infrastructure", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, { slot: "host_scoped", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, + { slot: "cron", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, { slot: "systemd", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, { slot: "python_processes", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, { slot: "native_processes", state: "unavailable", lastAttemptMs: null, lastSuccessMs: null, lastDurationMs: null, consecutiveFailureCount: 0, dataRevision: null, statusReason: "initial" }, diff --git a/tests/fixtures/contracts/runtime-map-daemon-emitted.json b/tests/fixtures/contracts/runtime-map-daemon-emitted.json index 71f411bd..f6532aba 100644 --- a/tests/fixtures/contracts/runtime-map-daemon-emitted.json +++ b/tests/fixtures/contracts/runtime-map-daemon-emitted.json @@ -243,6 +243,28 @@ "dependencies": [], "dependents": [] } + }, + { + "id": "host_local", + "provider": "host", + "type": "host", + "label": "host", + "status": "online", + "layer": "host", + "metadata": {} + }, + { + "id": "scheduled_job_fixture_daily_backup", + "provider": "scheduled_job", + "type": "scheduled_job", + "label": "daily backup", + "status": "scheduled", + "layer": "process", + "metadata": { + "source": "fixture crontab", + "line": "1", + "command": "daily backup" + } } ], "edges": [ @@ -589,6 +611,27 @@ "freshness": "fresh" } ] + }, + { + "source": "scheduled_job_fixture_daily_backup", + "target": "host_local", + "relationship": "runs_on", + "metadata": {}, + "evidenceRefs": [ + { + "version": 4, + "id": "fixture-cron-schedule-declaration-daily-backup", + "provider": "cron", + "kind": "cron_schedule_declaration", + "assertionKind": "declared", + "summary": "cron declared a scheduled job", + "subjectRef": "scheduled_job_fixture_daily_backup", + "collectedAt": 1787196125766, + "providerRevision": "fixture-cron-observation-1", + "providerSlot": "cron", + "freshness": "fresh" + } + ] } ], "diagnostics": [], From c5e3063bd6c4fe20d96604f467b7d405fe4bd712 Mon Sep 17 00:00:00 2001 From: Jonathan <64296013+Joncallim@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:34:28 +0800 Subject: [PATCH 47/47] feat: surface cron collection state --- apps/web/src/lib/demoData.ts | 3 ++- apps/web/src/lib/model.test.ts | 9 +++++---- apps/web/src/lib/testProviderStates.ts | 3 ++- apps/web/src/screens/Runtime.tsx | 3 ++- apps/web/src/screens/runtime-provider-states.test.tsx | 6 ++++-- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/demoData.ts b/apps/web/src/lib/demoData.ts index e148b5a3..365b57ad 100644 --- a/apps/web/src/lib/demoData.ts +++ b/apps/web/src/lib/demoData.ts @@ -234,9 +234,10 @@ 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: "systemd", 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" } ], nodes: [ diff --git a/apps/web/src/lib/model.test.ts b/apps/web/src/lib/model.test.ts index ac10a8c6..c6e29a41 100644 --- a/apps/web/src/lib/model.test.ts +++ b/apps/web/src/lib/model.test.ts @@ -65,10 +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, 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[5], 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/testProviderStates.ts b/apps/web/src/lib/testProviderStates.ts index b909a5b3..cc295239 100644 --- a/apps/web/src/lib/testProviderStates.ts +++ b/apps/web/src/lib/testProviderStates.ts @@ -8,8 +8,9 @@ function unavailableProviderState(slot: ProviderSlot): ProviderState { } /** Complete fixed-slot state used by browser-only fixtures. */ -export const testProviderStates: [ProviderState, 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/Runtime.tsx b/apps/web/src/screens/Runtime.tsx index 842a435d..b68099d4 100644 --- a/apps/web/src/screens/Runtime.tsx +++ b/apps/web/src/screens/Runtime.tsx @@ -49,6 +49,7 @@ 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", @@ -298,7 +299,7 @@ export default function RuntimeScreen() {
    diff --git a/apps/web/src/screens/runtime-provider-states.test.tsx b/apps/web/src/screens/runtime-provider-states.test.tsx index 96580803..3acbe114 100644 --- a/apps/web/src/screens/runtime-provider-states.test.tsx +++ b/apps/web/src/screens/runtime-provider-states.test.tsx @@ -21,6 +21,7 @@ const runtime: RuntimeMap = { providerStates: [ collectionState("network_infrastructure", "fresh"), collectionState("host_scoped", "stale", { consecutiveFailureCount: 1, statusReason: "collection_failed" }), + 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" }), @@ -45,10 +46,11 @@ 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(6); + 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");