From 73db4d99563d875ef6d1a8e8c91cff598eb61f20 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 19 Aug 2026 16:52:24 +0200 Subject: [PATCH] [ENG-3630] Say request-path shielding is applicable, and generate the demo maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, one subject: what the surface note claims, and where the demo examples come from. The note said "Request-path protection applies to this app". Recognizing code that serves requests establishes which KIND of protection is relevant to those paths; it establishes nothing about whether a guard is installed, fetching rules or enforcing them, none of which a build-time map can see. It now says shielding is APPLICABLE to these paths and states that it is not a claim about a guard being installed. This note is not internal prose — it is copied into `coverage.notes`, and consumers render those verbatim, so the sentence was sitting on a dashboard as a protection status. Nothing pinned any of the three notes, which is how it drifted: the suite now asserts the applicability wording on the server state, the "not deployment attestation" caveat on the static one, the "must not be read as no server side" caveat on unknown, and scans all three for posture claims. `examples-emit` generates the demo maps a platform loads to show what each state looks like, the same way `ladder-emit` generates the ladder fixtures. Four apps: recognised Express routes, a named static generator, a server framework whose routes are registered from a table, and a project declaring both a platform config and a static generator. Hand-written demo data shows a shape the extractor does not emit and goes stale in silence; generated data can be regenerated and diffed. The two states the extractor never emits are deliberately not here. A build with no `serverSurface` and one reporting a state a reader does not know are properties of the consumer's timeline, not of any source tree, so the loader derives them and the index says so. Each app also asserts its state in the ordinary suite, not only when someone is regenerating — an extractor change that moves one turns the demo into a map of something else. Output is byte-identical across runs, which is what makes inspecting the diff a real review step. 1220 tests, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/map/surface.ts | 7 +- tests/map/examples-emit.test.ts | 249 +++++++++++++++++++++++++++++++ tests/map/server-surface.test.ts | 41 +++++ 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 tests/map/examples-emit.test.ts diff --git a/src/map/surface.ts b/src/map/surface.ts index 7b6a651..bfe7314 100644 --- a/src/map/surface.ts +++ b/src/map/surface.ts @@ -313,7 +313,12 @@ export function classifyServerSurface( /** The sentence that goes with each state, so a consumer states the same limits the analysis does. */ export function surfaceNote(surface: ServerSurface): string { if (surface.state === 'server-runtime-detected') { - return 'serverSurface: a server runtime was recognized in the analysed source (see its evidence). Request-path protection applies to this app.'; + // "Applicable", not "applies". Recognizing code that serves requests establishes which KIND of + // protection is relevant to these paths; it establishes nothing about whether a guard is installed, + // fetching rules or enforcing them — none of which a build-time map can see. The previous wording + // ("Request-path protection applies to this app") read as a protection status, and since this note is + // rendered verbatim by consumers, it put that claim on a dashboard. + return 'serverSurface: a server runtime was recognized in the analysed source (see its evidence). Request-path shielding is APPLICABLE to these paths — this is not a statement that a guard is installed, fetching rules, or enforcing them.'; } if (surface.state === 'static-build-detected') { diff --git a/tests/map/examples-emit.test.ts b/tests/map/examples-emit.test.ts new file mode 100644 index 0000000..cbadb01 --- /dev/null +++ b/tests/map/examples-emit.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { buildInputMap } from '../../src/map/index.js'; + +// Regeneration entry point for the DEMO maps a platform loads to show what each server-surface state looks +// like on a dashboard. +// +// Same reasoning as `ladder-emit`, for a different consumer. These documents became product examples the +// moment someone looked at a dashboard through them, and a hand-written example is worse than no example: it +// shows a shape the extractor does not actually emit, and it goes stale silently — a map older than this +// extractor still parses and still renders, it just answers for an app the extractor now reads differently. +// Generating them from real little apps keeps the demo honest and the provenance reproducible. +// +// The states the extractor never emits — a build with no `serverSurface` (older than the check) and one +// reporting a state the reader does not know (newer than it) — are deliberately NOT emitted here. They are +// derived by the loader from `server-runtime.json`, because they are properties of the consumer's timeline +// rather than of any app: there is no source tree that produces them. +// +// Run: +// PS_EXAMPLE_EMIT_DIR=/path/to/examples npx vitest run tests/map/examples-emit +// +// Then inspect the diff before loading anything, exactly as with the ladder fixtures. +// +// Skipped otherwise, so a normal suite run neither writes files nor needs a directory. + +const OUT = process.env.PS_EXAMPLE_EMIT_DIR; + +/** Per-machine measurements. They carry no contract, and leaving them in makes every regeneration a diff on noise. */ +const VOLATILE = ['analysisMs', 'rssBytes', 'peakRssBytes'] as const; + +interface ExampleApp { + /** Output filename, and the state the app is here to produce. */ + id: string; + expect: 'server-runtime-detected' | 'static-build-detected' | 'unknown'; + /** Why this app produces that state — the thing a reader of the demo needs to know. */ + why: string; + packageJson: Record; + files: Record; +} + +/** + * Four apps, one per state worth showing, and the two `unknown` variants are separate on purpose: their + * evidence differs, so a consumer that derives a next step from the evidence has something to derive from. + * + * Dependencies are pinned to versions that well-known public advisories affect, so a platform loading these + * gets a populated reachability view rather than a map with nothing to join against. + */ +const APPS: ExampleApp[] = [ + { + id: 'server-runtime', + expect: 'server-runtime-detected', + why: 'Express routes the extractor recognises, with request input reaching several dependency sinks.', + packageJson: { + name: 'orders-api', + dependencies: { + express: '4.18.2', + axios: '0.21.0', + sequelize: '4.44.0', + lodash: '4.17.11', + 'node-serialize': '0.0.4', + systeminformation: '5.3.0', + }, + }, + files: { + 'src/server.js': `const express = require('express'); +const axios = require('axios'); +const app = express(); + +app.get('/api/preview', async (req, res) => { + const upstream = await axios.get(req.query.url); + res.json(upstream.data); +}); + +app.post('/api/orders', async (req, res) => { + const { sequelize } = require('./db'); + const rows = await sequelize.query('SELECT * FROM orders WHERE ref = ' + req.body.ref); + res.json(rows); +}); + +module.exports = app; +`, + 'src/db.js': `const Sequelize = require('sequelize'); +const sequelize = new Sequelize(process.env.DATABASE_URL); +module.exports = { sequelize }; +`, + 'src/settings.js': `const express = require('express'); +const merge = require('lodash/merge'); +const serialize = require('node-serialize'); +const si = require('systeminformation'); +const router = express.Router(); + +router.post('/api/settings', (req, res) => { + const settings = merge({ theme: 'light' }, req.body.settings); + res.json(settings); +}); + +router.post('/api/session', (req, res) => { + const restored = serialize.unserialize(req.cookies.profile); + res.json(restored); +}); + +router.get('/api/diagnostics', async (req, res) => { + const out = await si.inetLatency(req.query.host); + res.json({ out }); +}); + +module.exports = router; +`, + }, + }, + { + id: 'static-build', + expect: 'static-build-detected', + why: 'A static generator named in the manifest, nothing that serves, and no deployment artifact to veto the reading.', + packageJson: { + name: 'marketing-site', + devDependencies: { astro: '4.5.0' }, + dependencies: { lodash: '4.17.11' }, + }, + files: { + 'astro.config.mjs': `import { defineConfig } from 'astro/config'; +export default defineConfig({ output: 'static' }); +`, + 'src/pages/index.astro': `--- +import groupBy from 'lodash/groupBy'; +const posts = groupBy([], 'year'); +--- +

Posts

+`, + }, + }, + { + id: 'unknown-unparsed-stack', + expect: 'unknown', + why: 'A server framework in the manifest with routes registered from a table — no call site the extractor reads. The case that must never render as "no server side".', + packageJson: { + name: 'internal-tool', + dependencies: { fastify: '4.26.0', axios: '0.21.0', lodash: '4.17.11' }, + }, + files: { + 'src/index.js': `const fastify = require('fastify'); +const routes = require('./routes'); + +const app = fastify(); +for (const route of routes) app.route(route); + +app.listen({ port: 3000 }); +`, + 'src/routes.js': `const axios = require('axios'); +const pick = require('lodash/pick'); + +module.exports = [ + { method: 'GET', url: '/health', handler: async () => ({ ok: true }) }, + { method: 'POST', url: '/fetch', handler: async (req) => pick(await axios.get(req.body.url), ['status']) }, +]; +`, + }, + }, + { + id: 'unknown-deployment-declared', + expect: 'unknown', + why: 'A platform config AND a named static generator: the config rules out a confident static reading without proving anything serves, so both signals appear and the state stays open.', + packageJson: { + name: 'docs-portal', + devDependencies: { vite: '5.2.0' }, + dependencies: { axios: '0.21.0' }, + }, + files: { + 'netlify.toml': `[build] + command = "vite build" + publish = "dist" + +[[redirects]] + from = "/api/*" + to = "/.netlify/functions/:splat" + status = 200 +`, + 'vite.config.js': `import { defineConfig } from 'vite'; +export default defineConfig({ build: { outDir: 'dist' } }); +`, + 'src/main.js': `import axios from 'axios'; +export const load = (path) => axios.get(path).then((r) => r.data); +`, + }, + }, +]; + +/** The consumer's on-disk form: two-space JSON with a trailing newline. Byte-identical or it diffs. */ +const serialize = (document: unknown): string => JSON.stringify(document, null, 2) + '\n'; + +async function mapFor(app: ExampleApp): Promise> { + const dir = mkdtempSync(join(tmpdir(), 'ps-example-')); + try { + for (const [rel, body] of Object.entries(app.files)) { + const path = join(dir, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, body); + } + writeFileSync(join(dir, 'package.json'), JSON.stringify(app.packageJson, null, 2)); + + const { map, error } = await buildInputMap(dir); + expect(error, `${app.id} must produce a map`).toBeUndefined(); + const document = map as Record; + for (const field of VOLATILE) delete document.coverage[field]; + + return document; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** An index beside the maps, so a loader does not restate which file demonstrates what. */ +function manifest(): Record { + return { + note: 'Generated from the demo example apps. Do not edit; regenerate.', + derivedByConsumer: [ + 'not-reported — server-runtime.json with `serverSurface` removed: a build older than the check', + 'unreadable — server-runtime.json with an unrecognised `serverSurface.state`: a build newer than the reader', + ], + examples: APPS.map((app) => ({ id: app.id, expect: app.expect, why: app.why })), + }; +} + +describe.skipIf(!OUT)('demo example maps', () => { + it.each(APPS.map((app) => [app.id, app] as const))('emits %s', async (id, app) => { + const document = await mapFor(app); + + // The app is only useful as an example if it still produces the state it was written for. An extractor + // change that moves it turns the demo into a map of something else, which is the drift this catches. + expect(document.serverSurface?.state, `${id} must still produce ${app.expect}`).toBe(app.expect); + + writeFileSync(join(OUT!, `${id}.json`), serialize(document)); + }); + + it('emits the index', () => { + writeFileSync(join(OUT!, 'examples.json'), serialize(manifest())); + }); +}); + +// Runs in the ordinary suite: the apps must keep producing the states they are here to demonstrate, whether +// or not anyone is regenerating. Without this the emitter is only checked on the days someone runs it. +describe('the demo example apps still demonstrate their states', () => { + it.each(APPS.map((app) => [app.id, app] as const))('%s', async (id, app) => { + const document = await mapFor(app); + expect(document.serverSurface?.state, `${id}: ${app.why}`).toBe(app.expect); + }); +}); diff --git a/tests/map/server-surface.test.ts b/tests/map/server-surface.test.ts index f83afa4..3a52d2a 100644 --- a/tests/map/server-surface.test.ts +++ b/tests/map/server-surface.test.ts @@ -328,3 +328,44 @@ describe('the field stays safe to consume', () => { } }); }); + +// ── the sentence a consumer renders verbatim ─────────────────────────────────────────────────────── +// `surfaceNote` is copied into `coverage.notes`, and every consumer of this map shows those notes as written. +// So the note is not internal prose: it is product copy, and until this suite pinned it, nothing did. +describe('the note each state carries', () => { + /** Words that turn an analysis result into a claim about the app's security posture. */ + const POSTURE_CLAIMS = ['protection applies', 'is protected', 'unprotected', 'is safe', 'is secure', 'not vulnerable']; + + const noteFor = async (files: Record): Promise => { + const map = await mapOf(files); + const note = (map.coverage.notes ?? []).find((n) => n.startsWith('serverSurface')); + expect(note, 'every map must carry a serverSurface note').toBeDefined(); + return note!.toLowerCase(); + }; + + it('says request-path shielding is APPLICABLE, never that it is in place', async () => { + // The distinction: this analysis sees source, so it can say which kind of protection is relevant to these + // paths. Whether a guard is installed, fetching rules or enforcing them is invisible to it, and the + // earlier wording asserted exactly that — on a dashboard, verbatim. + const note = await noteFor({ + 'package.json': JSON.stringify({ dependencies: { express: '4.18.2' } }), + 'server.js': "const express = require('express');\nconst app = express();\napp.get('/api/items', (req, res) => res.json([]));\n", + }); + + expect(note).toContain('applicable'); + expect(note).toContain('not a statement that a guard is installed'); + for (const claim of POSTURE_CLAIMS) expect(note, `note must not claim ${claim}`).not.toContain(claim); + }); + + it('keeps the static note a description of the source, not of the deployment', async () => { + const note = await noteFor(VITE_APP); + expect(note).toContain('not deployment attestation'); + for (const claim of POSTURE_CLAIMS) expect(note, `note must not claim ${claim}`).not.toContain(claim); + }); + + it('keeps the unknown note from reading as "no server side"', async () => { + const note = await noteFor({ 'package.json': JSON.stringify({ dependencies: { express: '4' } }) }); + expect(note).toContain('must not be read as "no server side"'); + for (const claim of POSTURE_CLAIMS) expect(note, `note must not claim ${claim}`).not.toContain(claim); + }); +});