diff --git a/src/map/extract.ts b/src/map/extract.ts index 017ecaa..42b24da 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -5,6 +5,7 @@ import type { SiteInputMap, Endpoint, TsModule } from './types.js'; import { guessScriptKind } from './ast.js'; import { buildModuleBindings } from './bindings.js'; import { collectSources, detectDeploymentShapes, detectFramework, hasEntrySignal, type WalkStats } from './sources.js'; +import { classifyServerSurface, surfaceNote } from './surface.js'; import { functionNameFromPath, routeFromFilePath } from './routes.js'; import { collectLocalSinks } from './sinks.js'; import { createModuleGraph } from './module-graph.js'; @@ -239,12 +240,19 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac // everywhere except the case it warns about. notes.push(deploymentShapes.length === 0 ? '`deploymentShapes` is EMPTY: no deployment artifact was recognized. That is not evidence the app has no server-side runtime — a serverless handler this analysis cannot parse produces no endpoint and looks identical to an app that has none, and entry-point recognition has no completeness flag (coverage.importsComplete covers the import inventory only). A definitive answer needs deployment or build attestation, which source analysis cannot supply.' - : `\`deploymentShapes\` records ${deploymentShapes.length} deployment artifact(s) the project declares (${deploymentShapes.map((s) => s.shape).join(', ')}). POSITIVE EVIDENCE ONLY, and findings differ in strength: \`config\` and \`provider-directory\` show a deployment, while \`layout\` (a root \`api/\` or \`functions/\` folder) is an ordinary application folder that may hold no function at all — it must not on its own be read as a server runtime.`); + : `\`deploymentShapes\` records ${deploymentShapes.length} deployment artifact(s) the project declares (${deploymentShapes.map((s) => s.shape).join(', ')}). POSITIVE EVIDENCE ONLY, and findings differ in strength: only \`runtime-entry\` (a worker entry, or a provider function directory with source) shows something that serves. \`deployment-config\` means the project deploys to a platform — a static site on Netlify has a \`netlify.toml\` — and \`layout\` (a root \`api/\` or \`functions/\` folder) is an ordinary application folder. Neither may on its own be read as a server runtime.`); + + // Classified from what was positively found: the endpoints above, the deployment artifacts, and the + // manifest. Emitted with its evidence and its own caveat, in every state — the `unknown` and static + // states are the ones a consumer could over-read, so neither travels without the sentence that bounds it. + const serverSurface = classifyServerSurface(cwd, endpoints.length, deploymentShapes, ts); + notes.push(surfaceNote(serverSurface)); return { version: 3, framework: detectFramework(cwd), deploymentShapes, + serverSurface, endpoints, imports: importList, apiInvocations: invocationList, diff --git a/src/map/sources.ts b/src/map/sources.ts index 471d249..d653e1f 100644 --- a/src/map/sources.ts +++ b/src/map/sources.ts @@ -130,28 +130,50 @@ export function isInside(candidate: string, boundary: string): boolean { // Findings are not equally strong, and the difference is carried in the data rather than left for a // consumer to rediscover: // -// config the project DECLARES a deployment (`vercel.json`, `wrangler.toml`, `_worker.js`) -// provider-directory a provider-specific function directory holding real source -// layout an ordinary application folder that MIGHT be functions (`api/`, `functions/`) +// runtime-entry code that SERVES — a worker entry, or a provider function directory with source +// deployment-config the project deploys to a platform, which says nothing about anything serving +// layout an ordinary application folder that MIGHT be functions (`api/`, `functions/`) +// +// The `deployment-config` line is the one worth being careful about: a static site on Netlify has a +// `netlify.toml`, a static Vercel project has a `vercel.json`, and a Pages project deploying only assets +// has a `wrangler.toml`. Reading any of those as a server runtime would classify a large share of purely +// static apps as having one — so they establish "deploys somewhere", nothing more. // // `layout` exists because `api/client.ts` is a perfectly normal front-end folder and `api/handler.ts` is a -// Vercel function, and from the outside they are the same directory name. Treating that as proof of a -// server runtime would classify a pile of client-only apps as having one. A classifier may use `layout` to -// stay UNDECIDED; it must not use it alone to conclude a runtime exists. +// Vercel function, and from the outside they are the same directory name. +// +// A classifier may use `deployment-config` and `layout` to stay UNDECIDED; neither may conclude a runtime. // // `DeploymentEvidence` and `DeploymentShape` are imported from `types.ts` rather than restated: they are the // document's contract, and two structural copies of a vocabulary is how the two drift apart later. -const DEPLOYMENT_SHAPES: Array<{ shape: string; evidence: DeploymentEvidence; files?: string[]; dirs?: string[] }> = [ +interface ShapeCandidate { + shape: string; + evidence: DeploymentEvidence; + files?: string[]; + dirs?: string[]; + /** + * Require the per-function directory layout (`/index.ts`) rather than any source underneath. + * + * Supabase needs this: the convention puts shared code in `supabase/functions/_shared/*.ts`, which the + * platform does not deploy and which exists in projects with no deployed function at all. Counting it + * would report a runtime for a project that serves nothing — and this signal is the one the product + * messaging leans on, so a false positive here is a wrong statement to a customer. + */ + requiresFunctionEntry?: boolean; +} + +const DEPLOYMENT_SHAPES: ShapeCandidate[] = [ // Config first: these are declarations by the project itself, and they survive a build output being // absent (a fresh clone has no `.vercel`/`.wrangler` directory). - { shape: 'vercel', evidence: 'config', files: ['vercel.json'] }, - { shape: 'netlify', evidence: 'config', files: ['netlify.toml'] }, + { shape: 'vercel', evidence: 'deployment-config', files: ['vercel.json'] }, + { shape: 'netlify', evidence: 'deployment-config', files: ['netlify.toml'] }, // Wrangler names a Workers/Pages deployment. `.jsonc` and `.json` are both current spellings. - { shape: 'cloudflare-workers', evidence: 'config', files: ['wrangler.toml', 'wrangler.jsonc', 'wrangler.json'] }, - // Pages advanced mode: a single worker entry at the project root takes over routing entirely. - { shape: 'cloudflare-pages-advanced', evidence: 'config', files: ['_worker.js', '_worker.ts'] }, - { shape: 'netlify-functions', evidence: 'provider-directory', dirs: ['netlify/functions', 'netlify/edge-functions'] }, - { shape: 'supabase-functions', evidence: 'provider-directory', dirs: ['supabase/functions'] }, + { shape: 'cloudflare-workers', evidence: 'deployment-config', files: ['wrangler.toml', 'wrangler.jsonc', 'wrangler.json'] }, + // Pages advanced mode: a single worker entry at the project root takes over routing entirely. Unlike a + // wrangler config, this file IS the server — it is a runtime entry, not a deployment declaration. + { shape: 'cloudflare-pages-advanced', evidence: 'runtime-entry', files: ['_worker.js', '_worker.ts'] }, + { shape: 'netlify-functions', evidence: 'runtime-entry', dirs: ['netlify/functions', 'netlify/edge-functions'] }, + { shape: 'supabase-functions', evidence: 'runtime-entry', dirs: ['supabase/functions'], requiresFunctionEntry: true }, // Ambiguous by nature and reported as one shape: a root `functions/` directory is Cloudflare Pages // Functions, Firebase functions, or a Deno layout depending on the platform, and nothing inside the // repository always distinguishes them. Naming it honestly is better than guessing a provider. @@ -207,22 +229,53 @@ export function detectDeploymentShapes(cwd: string, opts: DeploymentScanOptions for (const dir of candidate.dirs ?? []) { const full = join(cwd, dir); + + // Containment is settled BEFORE anything reads the directory. `statSync` follows symlinks, so a + // linked `api/` reports as a directory here — and inspecting its contents first would mean this + // analysis had already read a tree outside the project, whatever it then did with the result. The + // guarantee is "we do not look", not "we look and discard". try { - // A directory with no source file in it is scaffolding, not a deployment: an empty `api/` - // would otherwise make every project that once considered serverless look like it ships it. - // `statSync` FOLLOWS symlinks, which is what makes the boundary check here load-bearing: a - // linked `api/` reports as a directory and would otherwise be this project's evidence. - if (statSync(full).isDirectory() && inProject(full) && holdsSourceFile(full)) { - found.push({ shape: candidate.shape, source: dir, evidence: candidate.evidence }); - break; - } - } catch { /* not this one */ } + if (!statSync(full).isDirectory() || !inProject(full)) continue; + } catch { + continue; // missing, or unresolvable — either way not evidence + } + + // A directory with no source file in it is scaffolding, not a deployment: an empty `api/` would + // otherwise make every project that once considered serverless look like it ships it. + if (candidate.requiresFunctionEntry ? holdsFunctionEntry(full) : holdsSourceFile(full)) { + found.push({ shape: candidate.shape, source: dir, evidence: candidate.evidence }); + break; + } } } return found; } +/** + * Whether a directory holds at least one deployable FUNCTION, in the per-function layout: a child directory + * whose name does not start with `_`, containing an `index.*` source file. + * + * Only ever called for a directory the caller has already confirmed is inside the project. + * + * The underscore rule is the platform's own: Supabase treats `supabase/functions/_shared/` as shared code + * and does not deploy it, so a project can have that directory full of TypeScript and serve nothing. + */ +function holdsFunctionEntry(dir: string): boolean { + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('_')) continue; + try { + for (const nested of readdirSync(join(dir, entry.name), { withFileTypes: true })) { + if (nested.isFile() && /^index\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(nested.name)) return true; + } + } catch { /* unreadable candidate */ } + } + } catch { /* unreadable */ } + + return false; +} + /** * Whether a directory holds at least one source file, one level down included. * diff --git a/src/map/surface.ts b/src/map/surface.ts new file mode 100644 index 0000000..7b6a651 --- /dev/null +++ b/src/map/surface.ts @@ -0,0 +1,324 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { DeploymentShape, ServerSurface, SurfaceSignal, TsModule } from './types.js'; + +// Does this app have a server side at all? +// +// The question is worth answering because the answer changes what protection MEANS: an app with no server +// runtime cannot run a request guard, so its advisories are dependency and bundle hygiene rather than +// request-path risk. Told plainly, that is useful. Told wrongly, it is the worst output this analysis can +// produce — "nothing to protect here" on an app we simply failed to read. +// +// So the classification is built from POSITIVE signals on both sides, and the honest answer is usually +// neither: +// +// server-runtime-detected a recognized endpoint, or an artifact that SERVES (a worker entry, a provider +// function directory with source) +// static-build-detected a static generator identified AND nothing suggesting a server surface +// unknown everything else — including "we found nothing and cannot say why" +// +// A deployment CONFIG is not in that first list, and that is the correction that matters most here: a static +// site on Netlify has a `netlify.toml`, a static Vercel project has a `vercel.json`, and a Pages project +// serving only assets has a `wrangler.toml`. Reading those as a runtime would have classified a large share +// of purely static apps as having a server. They stay visible in the evidence and they still rule out a +// confident static claim — a project that deploys somewhere is not one this analysis can call server-free — +// but the answer with nothing else behind it is `unknown`. +// +// Three rules keep `static-build-detected` from becoming the default for an unrecognised stack: +// +// 1. It needs a static generator NAMED. Absence of server signals is not evidence; `endpoints: []` is +// what an unparsed framework looks like, and entry-point recognition has no completeness flag. +// 2. A server-framework dependency blocks it. An app with `express` installed and no endpoint we could +// read is a parsing gap, not a static site. +// 3. Any deployment shape blocks it — config and `layout` included. A root `api/` folder holding source +// may be a front-end helper or a pile of platform functions, and from here they are the same folder. +// That ambiguity belongs in `unknown`, not in a claim either way. +// +// Even `static-build-detected` is not deployment attestation: it says the source describes a static build, +// not that nothing server-side is deployed. Only build or platform metadata can carry that, and this +// analysis never sees it. + +/** Dependencies that build a static bundle and, on their own, no server. */ +const STATIC_GENERATORS: Array<{ dep: string; label: string }> = [ + { dep: 'vite', label: 'vite' }, + { dep: 'react-scripts', label: 'create-react-app' }, + { dep: 'gatsby', label: 'gatsby' }, + { dep: 'parcel', label: 'parcel' }, + { dep: '@sveltejs/adapter-static', label: 'sveltekit-static-adapter' }, +]; + +/** + * Build tools that turn a static bundler into an SSR stack. + * + * Vite is the awkward one: it is the default bundler for client-only apps AND the foundation of several + * server frameworks, so `vite` in a manifest is not by itself a static build. Anything here vetoes the + * static reading and leaves the app `unknown`, which is the conservative direction for a state whose product + * meaning is "no request-path protection needed". + */ +const SSR_COMPANIONS = ['vike', 'vite-plugin-ssr', '@react-router/node', '@react-router/serve', 'vite-plugin-node']; + +/** + * Dependencies that mean a server, so a static claim is off the table. + * + * Deliberately wider than the framework detector: this list only has to answer "might this app serve + * requests", and over-answering yes costs a claim we would rather not make anyway. + */ +const SERVER_DEPENDENCIES = [ + 'express', 'fastify', 'hono', 'koa', '@nestjs/core', '@hapi/hapi', 'h3', 'polka', 'restify', + '@tanstack/react-start', '@tanstack/start', '@tanstack/solid-start', 'nuxt', 'remix', + '@remix-run/node', '@remix-run/server-runtime', +]; + +/** + * Frameworks that ship BOTH modes, where another dependency decides which one this project is. + * + * `@sveltejs/kit` is the case that made this necessary: every static SvelteKit app has it alongside + * `@sveltejs/adapter-static`, so treating kit as an unconditional server dependency made a real static + * SvelteKit project permanently `unknown` — and the test that "proved" the adapter worked installed the + * adapter with no kit, which is not a package set anyone ships. + */ +const CONDITIONAL_SERVER_DEPENDENCIES: Array<{ dep: string; staticWhen: string }> = [ + { dep: '@sveltejs/kit', staticWhen: '@sveltejs/adapter-static' }, +]; + +/** Astro and Next ship both modes, so the adapter or the output setting decides. */ +const SSR_ADAPTERS = ['@astrojs/node', '@astrojs/vercel', '@astrojs/cloudflare', '@astrojs/netlify', '@astrojs/deno']; + +/** How each deployment-evidence level appears in the surface evidence list. */ +const SHAPE_SIGNAL: Record = { + 'runtime-entry': 'runtime-entry', + 'deployment-config': 'deployment-config', + layout: 'ambiguous-layout', +}; + +interface Manifest { + dependencies?: Record; + devDependencies?: Record; + scripts?: Record; +} + +function readManifest(cwd: string): Manifest | null { + try { + const parsed = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); + + return typeof parsed === 'object' && parsed !== null ? parsed as Manifest : null; + } catch { + return null; // no manifest, or unreadable — either way nothing is identified from it + } +} + +/** + * Whether a Next project is configured to emit a static export rather than run a server. + * + * The config is PARSED, not pattern-matched. A regex over the file text accepts + * `// output: 'export'`, the same words inside a string, and an example block nobody exports — and each of + * those would have reclassified an ordinary server-mode Next app as static, which is the direction that + * loses protection. Parsing is also not the same as executing: the AST is read, the config never runs. + * + * Only a value reachable from the module's export counts, so dead code declaring `output: 'export'` is + * ignored too. The wrapper form (`withPlugins(config)`, `withMDX({...})`) is followed one level, since it + * is how most real Next configs are written. + */ +function nextExportsStatically(cwd: string, manifest: Manifest, ts: TsModule | undefined): string | null { + const scripts = Object.values(manifest.scripts ?? {}).join(' '); + // Scripts are JSON strings — no comments to be fooled by. + if (/\bnext\s+export\b/.test(scripts)) return 'next export (build script)'; + + if (ts === undefined) return null; // no compiler available: nothing is claimed from the config + + for (const file of ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs']) { + let text: string; + try { + text = readFileSync(join(cwd, file), 'utf8'); + } catch { + continue; + } + + try { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + if (exportedConfigIsStatic(sf, ts)) return `output: 'export' (${file})`; + } catch { /* unparseable config claims nothing */ } + } + + return null; +} + +/** + * `output: 'export'` on the object this module actually exports. + * + * Alias following is cycle-safe by construction. `const a = b; const b = a; export default a` would + * otherwise recurse until the stack gave out, and while the surrounding catch turns that into a + * conservative `unknown`, a malformed config should be an ordinary answer rather than an exception used as + * control flow — a thrown RangeError also discards any signal found before it. + */ +function exportedConfigIsStatic(sf: any, ts: TsModule): boolean { + const objects: any[] = []; + const followed = new Set(); + + const collectFrom = (expr: any): void => { + if (!expr) return; + if (ts.isObjectLiteralExpression(expr)) { objects.push(expr); return; } + // `export default withPlugins({...})` / `module.exports = withMDX(config)` + if (ts.isCallExpression(expr)) { for (const arg of expr.arguments) collectFrom(arg); return; } + // `const nextConfig = {...}; export default nextConfig` + if (ts.isIdentifier(expr)) { + const name = expr.text; + if (followed.has(name)) return; // already resolved, or part of a cycle + followed.add(name); + const visit = (node: any): void => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) { + collectFrom(node.initializer); + } + ts.forEachChild(node, visit); + }; + visit(sf); + } + }; + + const findExports = (node: any): void => { + // `export default X` + if (ts.isExportAssignment(node)) collectFrom(node.expression); + // `module.exports = X` + if (ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isPropertyAccessExpression(node.left) + && node.left.name.text === 'exports') { + collectFrom(node.right); + } + ts.forEachChild(node, findExports); + }; + findExports(sf); + + for (const object of objects) { + for (const property of object.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const key = property.name; + const keyName = ts.isIdentifier(key) || ts.isStringLiteralLike(key) ? key.text : undefined; + if (keyName !== 'output') continue; + if (ts.isStringLiteralLike(property.initializer) && property.initializer.text === 'export') return true; + } + } + + return false; +} + +/** + * Static generators this project positively identifies, each with the dependency or setting that named it. + * + * Astro and Next are conditional: both ship a server mode, so `astro` alone says nothing and an SSR adapter + * rules the static reading out entirely. + */ +function staticSignals(cwd: string, ts: TsModule | undefined): SurfaceSignal[] { + const manifest = readManifest(cwd); + if (manifest === null) return []; + + const deps = { ...manifest.dependencies, ...manifest.devDependencies }; + const signals: SurfaceSignal[] = []; + + for (const generator of STATIC_GENERATORS) { + if (deps[generator.dep] !== undefined) { + signals.push({ signal: 'static-generator', source: generator.label }); + } + } + + if (deps['astro'] !== undefined && !SSR_ADAPTERS.some((adapter) => deps[adapter] !== undefined)) { + signals.push({ signal: 'static-generator', source: 'astro (no SSR adapter)' }); + } + + if (deps['next'] !== undefined) { + const exported = nextExportsStatically(cwd, manifest, ts); + if (exported !== null) signals.push({ signal: 'static-generator', source: `next: ${exported}` }); + } + + return signals; +} + +/** Server-framework dependencies present, each named — they block a static conclusion. */ +function serverDependencySignals(cwd: string, ts: TsModule | undefined): SurfaceSignal[] { + const manifest = readManifest(cwd); + if (manifest === null) return []; + + const deps = { ...manifest.dependencies, ...manifest.devDependencies }; + const found = SERVER_DEPENDENCIES.filter((dep) => deps[dep] !== undefined); + + // Both-mode frameworks: present, but not a server dependency when the project also installs the + // dependency that makes it static. + for (const conditional of CONDITIONAL_SERVER_DEPENDENCIES) { + if (deps[conditional.dep] !== undefined && deps[conditional.staticWhen] === undefined) { + found.push(conditional.dep); + } + } + + // An SSR companion turns a static bundler into a server stack, so it vetoes the static reading the same + // way a server framework does. `vite` alone is not a static build. + for (const companion of SSR_COMPANIONS) if (deps[companion] !== undefined) found.push(companion); + + // `next` counts as a server dependency UNLESS the project exports statically, which `staticSignals` + // establishes from the same manifest. + if (deps['next'] !== undefined && nextExportsStatically(cwd, manifest, ts) === null) found.push('next'); + if (deps['astro'] !== undefined) { + for (const adapter of SSR_ADAPTERS) if (deps[adapter] !== undefined) found.push(adapter); + } + + return found.map((dep) => ({ signal: 'server-dependency', source: dep })); +} + +/** + * Classify the app's server surface from what was positively found. + * + * `endpointCount` and `deploymentShapes` come from the analysis that already ran; the manifest signals are + * read here. Nothing in this function infers from absence except the final fall-through to `unknown`, + * which is the one honest thing absence supports. + */ +export function classifyServerSurface( + cwd: string, + endpointCount: number, + deploymentShapes: DeploymentShape[], + ts?: TsModule, +): ServerSurface { + const evidence: SurfaceSignal[] = []; + + if (endpointCount > 0) { + evidence.push({ signal: 'endpoint', source: `${endpointCount} recognized entry point(s)` }); + } + for (const shape of deploymentShapes) { + evidence.push({ + signal: SHAPE_SIGNAL[shape.evidence], + source: `${shape.shape} (${shape.source})`, + }); + } + + const servers = serverDependencySignals(cwd, ts); + const statics = staticSignals(cwd, ts); + + // Only code that SERVES settles this: a recognized endpoint, a worker entry, or a provider function + // directory with source in it. A deployment config does not — a static site on Netlify has a + // `netlify.toml` and no server — and a `layout` folder does not either, since `api/client.ts` is an + // ordinary front-end helper. Both remain in the evidence, and both still block a static claim below. + const servesRequests = deploymentShapes.some((shape) => shape.evidence === 'runtime-entry'); + if (endpointCount > 0 || servesRequests) { + return { state: 'server-runtime-detected', evidence }; + } + + evidence.push(...servers, ...statics); + + const blocked = servers.length > 0 || deploymentShapes.length > 0; + if (statics.length > 0 && !blocked) { + return { state: 'static-build-detected', evidence }; + } + + return { state: 'unknown', evidence }; +} + +/** 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.'; + } + + if (surface.state === 'static-build-detected') { + return 'serverSurface: a static build was identified and no server runtime was recognized in the analysed source. This is NOT deployment attestation — it describes the source, not what is deployed, and a serverless function added at the platform level would not appear here. Advisories against this app are dependency and bundle concerns rather than request-path risk.'; + } + + return 'serverSurface is UNKNOWN: neither a server runtime nor a static build could be positively identified. This is the honest answer for an unrecognised stack, and it must not be read as "no server side" — an unparsed framework produces no endpoints, and entry-point recognition has no completeness flag.'; +} diff --git a/src/map/types.ts b/src/map/types.ts index a973ff8..35364f8 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -258,13 +258,20 @@ export interface Flow { } /** - * How strong a deployment finding is. Not all artifacts prove the same thing: + * What a deployment finding actually shows. Three levels, and the top one is narrower than it first looks: * - * `config` the project DECLARES a deployment (`vercel.json`, `wrangler.toml`, `_worker.js`) - * `provider-directory` a provider-specific function directory holding real source + * `runtime-entry` code that SERVES: a worker entry (`_worker.js`), or a provider function + * directory holding real source + * `deployment-config` the project deploys to a platform (`vercel.json`, `netlify.toml`, + * `wrangler.toml`) — which says nothing about whether anything serves requests. + * A static site on Netlify has a `netlify.toml` and no server at all. * `layout` an ordinary application folder that MIGHT hold functions (`api/`, `functions/`) + * + * Only `runtime-entry` supports concluding a server runtime exists. The other two are still reported and + * still rule out a confident "static" reading, because a project that deploys somewhere, or keeps an `api/` + * folder, is not one this analysis can call server-free. */ -export type DeploymentEvidence = 'config' | 'provider-directory' | 'layout'; +export type DeploymentEvidence = 'runtime-entry' | 'deployment-config' | 'layout'; export interface DeploymentShape { /** Which shape was recognized, e.g. `netlify-functions`. */ @@ -272,19 +279,53 @@ export interface DeploymentShape { /** The artifact that proved it, repo-relative, so a consumer can show its evidence. */ source: string; /** - * How strong the finding is. Not all artifacts prove the same thing: - * - * `config` the project DECLARES a deployment (`vercel.json`, `wrangler.toml`, `_worker.js`) - * `provider-directory` a provider-specific function directory holding real source - * `layout` an ordinary application folder that MIGHT hold functions (`api/`, `functions/`) - * - * `layout` is deliberately weaker: `api/client.ts` is a normal front-end folder and `api/handler.ts` is a - * platform function, and the directory name is the same either way. A consumer may use `layout` to stay - * undecided; it must not conclude a server runtime from `layout` alone. + * What the finding shows — see `DeploymentEvidence`. Only `runtime-entry` supports concluding that a + * server runtime exists; `deployment-config` and `layout` are reported, and rule out a confident + * "static" reading, without proving anything serves. */ evidence: DeploymentEvidence; } +/** One reason behind a `serverSurface` state, so a consumer can explain the classification. */ +export interface SurfaceSignal { + /** + * What kind of signal this is: + * `endpoint` a recognized entry point was parsed + * `runtime-entry` an artifact that serves: a worker entry, or a provider function directory + * `deployment-config` a platform config — the project deploys, which is not the same as serving + * `ambiguous-layout` a `layout` shape — present, and not sufficient for a runtime conclusion + * `server-dependency` a server framework, or an SSR companion, in the manifest + * `static-generator` a static build tool positively identified + * + * Only `endpoint` and `runtime-entry` support a server-runtime conclusion. `deployment-config` and + * `ambiguous-layout` rule out a confident static reading without proving anything serves. + */ + signal: 'endpoint' | 'runtime-entry' | 'deployment-config' | 'ambiguous-layout' | 'server-dependency' | 'static-generator'; + /** The thing that produced the signal — a dependency name, a file, a count. */ + source: string; +} + +/** + * Whether this app appears to have a server side, from positive signals on both sides. + * + * Exists because the answer changes what protection means: an app with no server runtime cannot run a + * request guard, so its advisories are dependency and bundle hygiene rather than request-path risk. + * + * `static-build-detected` requires a static generator to be NAMED, and is blocked by any deployment shape + * (a platform config or an ambiguous `layout` folder included) or any server-framework dependency. A + * deployment config never produces `server-runtime-detected` on its own: a static site on Netlify has a + * `netlify.toml` and no server at all. It is still not deployment + * attestation: it describes the source, not what is deployed, and a function added at the platform level + * would not appear here. `unknown` is the honest answer for an unrecognised stack and must never be read as + * "no server side" — an unparsed framework produces no endpoints, and entry-point recognition has no + * completeness flag. + */ +export interface ServerSurface { + state: 'server-runtime-detected' | 'static-build-detected' | 'unknown'; + /** Every signal that produced the state, so the classification can be shown rather than asserted. */ + evidence: SurfaceSignal[]; +} + export interface Coverage { /** Adapter that produced the map. */ adapter: string; @@ -433,6 +474,10 @@ export interface SiteInputMap { * Additive, so still version 3: a v3 reader that ignores it keeps behaving correctly. */ deploymentShapes?: DeploymentShape[]; + /** + * Whether this app appears to have a server side — see `ServerSurface`. Additive, so still version 3. + */ + serverSurface?: ServerSurface; endpoints: Endpoint[]; coverage: Coverage; /** diff --git a/tests/map/deployment-shapes.test.ts b/tests/map/deployment-shapes.test.ts index d895843..895dc40 100644 --- a/tests/map/deployment-shapes.test.ts +++ b/tests/map/deployment-shapes.test.ts @@ -1,5 +1,22 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; + +// Records every directory read, delegating to the real implementation so behaviour is unchanged. A spy on +// the module namespace is not possible under ESM, and the property being asserted — that an escaping link is +// never TRAVERSED, as opposed to traversed and discarded — is not observable from the return value. +const { directoryReads } = vi.hoisted(() => ({ directoryReads: [] as string[] })); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + readdirSync: (path: unknown, options?: unknown) => { + directoryReads.push(String(path)); + + return (actual.readdirSync as (p: unknown, o?: unknown) => unknown)(path, options); + }, + }; +}); import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { detectDeploymentShapes } from '../../src/map/sources.js'; @@ -94,6 +111,37 @@ describe('what it refuses to claim', () => { expect(detectDeploymentShapes(dir)).toEqual([]); }); + it('ignores Supabase shared code that no function deploys', () => { + // `supabase/functions/_shared/` is the platform's own convention for code shared BETWEEN functions, + // and it is not deployed. A project can have it full of TypeScript and serve nothing, so counting it + // would report a runtime — and this is the signal the product messaging leans on. + expect(shapesOf({ + 'supabase/functions/_shared/cors.ts': 'export const cors = {};', + 'supabase/functions/_shared/db.ts': 'export const client = null;', + })).toEqual([]); + + // The layout that makes the underscore rule matter: `_shared/index.ts` is an ordinary barrel file, and + // it satisfies the per-function entry test on name alone. Without the underscore skip, shared code with + // a barrel would report a deployed function. (My first version of this test used `_shared/cors.ts`, + // which the index check already rejected — so it passed without exercising the rule at all.) + expect(shapesOf({ + 'supabase/functions/_shared/index.ts': "export * from './cors.ts';", + })).toEqual([]); + + // A real function alongside it is still found. + expect(shapesOf({ + 'supabase/functions/_shared/cors.ts': 'export const cors = {};', + 'supabase/functions/notify/index.ts': 'Deno.serve(() => new Response("ok"))', + })).toContain('supabase-functions'); + }); + + it('requires the per-function layout for Supabase, not just any source', () => { + // Supabase deploys `/index.ts`. A loose file directly under `functions/` is not a deployable + // function, so it is not evidence of one. + expect(shapesOf({ 'supabase/functions/helpers.ts': 'export const x = 1;' })).toEqual([]); + expect(shapesOf({ 'supabase/functions/notify/handler.ts': 'export default () => {}' })).toEqual([]); + }); + it('ignores a directory holding no source file', () => { expect(shapesOf({ 'api/README.md': '# planned', 'api/notes.txt': 'later' })).toEqual([]); }); @@ -150,8 +198,9 @@ describe('the map carries the evidence, not just the label', () => { const byShape = Object.fromEntries((map!.deploymentShapes ?? []).map((s) => [s.shape, s.evidence])); // `api/client.ts` is an ordinary front-end folder. It is reported — a consumer may want to look — - // but as `layout`, so a classifier cannot read a server runtime out of it alone. - expect(byShape['cloudflare-workers']).toBe('config'); + // but as `layout`, so a classifier cannot read a server runtime out of it alone. And `wrangler.toml` + // is `deployment-config`: the project deploys to a platform, which is not the same as serving. + expect(byShape['cloudflare-workers']).toBe('deployment-config'); expect(byShape['root-api-directory']).toBe('layout'); }); @@ -168,7 +217,7 @@ describe('the map carries the evidence, not just the label', () => { // The whole point of the field, written where a consumer reading the document will see it. expect(note, 'the map must say what the field does and does not mean').toBeDefined(); expect(note).toContain('POSITIVE EVIDENCE ONLY'); - expect(note).toContain('must not on its own be read as a server runtime'); + expect(note).toContain('Neither may on its own be read as a server runtime'); }); it('refuses an artifact that resolves outside the project', () => { @@ -185,6 +234,33 @@ describe('the map carries the evidence, not just the label', () => { .toContain('root-api-directory'); }); + it('never READS an escaping directory, not merely discards what it found there', () => { + // The distinction that matters: refusing to report a finding is not the same guarantee as refusing to + // look. Evaluating the contents check before the boundary check meant this analysis walked a tree + // outside the project on every scan and then threw the result away — invisible in the output, and + // exactly the behaviour the boundary exists to prevent. + const outside = project({ 'handler.ts': 'export default () => new Response("ok")' }); + const dir = project({ 'package.json': '{}' }); + symlinkSync(outside, join(dir, 'api'), 'dir'); + + // Asserted on the LINK path, which is what gets read: the code walks `/api`, and the kernel + // resolves it to the external tree. A first version of this control filtered on the target path, which + // never appears in a read call — so it would have reported success against the unfixed code too. + const linkPath = join(dir, 'api'); + + directoryReads.length = 0; + expect(detectDeploymentShapes(dir)).toEqual([]); + expect(directoryReads.filter((target) => target.startsWith(linkPath)), + 'an escaping directory must not be read at all').toEqual([]); + + // With the opt-out it IS read. This half proves the control can see a read, so the empty list above + // means something — a control that cannot observe the behaviour it forbids is not a control. + directoryReads.length = 0; + expect(detectDeploymentShapes(dir, { followOutside: true }).map((s) => s.shape)) + .toContain('root-api-directory'); + expect(directoryReads.some((target) => target.startsWith(linkPath))).toBe(true); + }); + it('refuses a config file that resolves outside the project', () => { const outside = project({ 'vercel.json': '{}' }); const dir = project({ 'package.json': '{}' }); diff --git a/tests/map/server-surface.test.ts b/tests/map/server-surface.test.ts new file mode 100644 index 0000000..f83afa4 --- /dev/null +++ b/tests/map/server-surface.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect, afterEach } 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'; +import type { InputMap } from '../../src/map/types.js'; + +// Whether the app has a server side at all. +// +// The reason to answer it: an app with no server runtime cannot run a request guard, so its advisories are +// dependency and bundle hygiene rather than request-path risk. Saying that plainly is useful. Saying it +// wrongly is the worst output this analysis can produce — "nothing to protect here" on an app nobody could +// read — so most of this file is about the cases that must come back `unknown`. +const dirs: string[] = []; +afterEach(() => { + while (dirs.length > 0) rmSync(dirs.pop()!, { recursive: true, force: true }); +}); + +const project = (files: Record): string => { + const dir = mkdtempSync(join(tmpdir(), 'ps-surface-')); + dirs.push(dir); + for (const [rel, body] of Object.entries(files)) { + const path = join(dir, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, body); + } + return dir; +}; +const mapOf = async (files: Record): Promise => { + const { map, error } = await buildInputMap(project(files)); + expect(error).toBeUndefined(); + return map!; +}; +const stateOf = async (files: Record) => (await mapOf(files)).serverSurface?.state; + +const VITE_APP = { + 'package.json': JSON.stringify({ dependencies: { react: '18' }, devDependencies: { vite: '5' } }), + 'src/main.tsx': 'export const App = () => null;', + 'index.html': '
', +}; + +describe('a server runtime, when something positively shows one', () => { + it('reports a recognized endpoint as a server runtime', async () => { + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { express: '4' } }), + 'src/server.js': ` + const express = require("express"); + const app = express(); + app.get("/items", (req, res) => res.json([])); + module.exports = app; + `, + })).toBe('server-runtime-detected'); + }); + + it('reports function source with no endpoint parsed as a runtime', async () => { + // The case the evidence layer exists for: a handler this analysis cannot read still serves. The source + // in a provider function directory says so, and that outweighs an empty endpoint list. + expect(await stateOf({ + ...VITE_APP, + 'netlify.toml': '[build]\n publish = "dist"', + 'netlify/functions/submit.ts': 'export default async () => new Response("ok")', + })).toBe('server-runtime-detected'); + }); + + it('reports a worker entry as a runtime, since that file IS the server', async () => { + expect(await stateOf({ + ...VITE_APP, + '_worker.js': 'export default { fetch: () => new Response("ok") }', + })).toBe('server-runtime-detected'); + }); + + it('carries the evidence that produced the state', async () => { + const map = await mapOf({ + ...VITE_APP, + 'supabase/functions/notify/index.ts': 'Deno.serve(() => new Response("ok"))', + }); + + // A consumer has to be able to show WHY, not just display a badge. + expect(map.serverSurface?.state).toBe('server-runtime-detected'); + expect(map.serverSurface?.evidence.map((e) => e.signal)).toContain('runtime-entry'); + expect(map.serverSurface?.evidence.find((e) => e.signal === 'runtime-entry')?.source) + .toContain('supabase/functions'); + }); +}); + +describe('a static build, only when one is named', () => { + it('reports a static build for a client-only project', async () => { + expect(await stateOf(VITE_APP)).toBe('static-build-detected'); + }); + + it('recognizes the other common generators', async () => { + expect(await stateOf({ 'package.json': JSON.stringify({ devDependencies: { 'react-scripts': '5' } }) })) + .toBe('static-build-detected'); + expect(await stateOf({ 'package.json': JSON.stringify({ dependencies: { gatsby: '5' } }) })) + .toBe('static-build-detected'); + }); + + it('reads a real static SvelteKit project, which ships kit AND the adapter', async () => { + // The package set people actually have. Treating `@sveltejs/kit` as an unconditional server dependency + // made every genuine static SvelteKit app permanently `unknown`, and the test that appeared to cover + // this installed the adapter with no kit — a combination nobody ships. + expect(await stateOf({ + 'package.json': JSON.stringify({ + devDependencies: { '@sveltejs/kit': '2', '@sveltejs/adapter-static': '3', vite: '5' }, + }), + })).toBe('static-build-detected'); + + // And kit without the static adapter stays undecided, because then it is a server framework. + expect(await stateOf({ + 'package.json': JSON.stringify({ devDependencies: { '@sveltejs/kit': '2', '@sveltejs/adapter-node': '5', vite: '5' } }), + })).toBe('unknown'); + }); + + it('does not read Vite as static when an SSR companion is installed', async () => { + // Vite builds client-only apps and underpins several server frameworks, so `vite` alone is not a + // static build. A companion that adds SSR vetoes the reading — conservative in the direction that + // matters, since this state means "no request-path protection needed". + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { vike: '0.4' }, devDependencies: { vite: '5' } }), + })).toBe('unknown'); + expect(await stateOf({ + 'package.json': JSON.stringify({ devDependencies: { vite: '5', 'vite-plugin-ssr': '0.4' } }), + })).toBe('unknown'); + }); + + it('reads a Next project as static only when it exports statically', async () => { + // Next ships both modes, so the dependency says nothing on its own. + expect(await stateOf({ 'package.json': JSON.stringify({ dependencies: { next: '14' } }) })) + .toBe('unknown'); + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' }, scripts: { build: 'next build && next export' } }), + })).toBe('static-build-detected'); + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': "module.exports = { output: 'export' };", + })).toBe('static-build-detected'); + }); + + it('ignores output: export when it is only a comment, a string, or dead code', async () => { + // A regex over the config text accepted all three, and each would have reclassified an ordinary + // server-mode Next app as static — the direction that loses protection. The config is parsed instead: + // comments never reach the AST, a string literal is one token, and only a value reachable from the + // module's export counts. + const commented = await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': ` + // output: 'export' <- we tried this and reverted + /* const old = { output: 'export' }; */ + module.exports = { reactStrictMode: true }; + `, + }); + expect(commented, 'a commented-out setting is not configuration').toBe('unknown'); + + const stringified = await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': `module.exports = { env: { NOTE: "output: 'export' is not set here" } };`, + }); + expect(stringified, 'the words inside a string are not a setting').toBe('unknown'); + + const deadCode = await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': ` + const staticExample = { output: 'export' }; + module.exports = { reactStrictMode: true }; + `, + }); + expect(deadCode, 'an object nobody exports is not this project’s config').toBe('unknown'); + + // And the real thing still reads, including through a plugin wrapper and a named variable. + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.mjs': `const nextConfig = { output: 'export' };\nexport default nextConfig;`, + })).toBe('static-build-detected'); + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': `module.exports = withPlugins({ output: 'export' });`, + })).toBe('static-build-detected'); + }); + + it('follows an alias chain to the real config, and survives a cyclic one', async () => { + // Chained aliases are ordinary in real configs, so the traversal has to follow them... + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.mjs': `const base = { output: 'export' };\nconst config = base;\nexport default config;`, + })).toBe('static-build-detected'); + + // ...and a malformed one must come back as an ordinary answer rather than an exception used as control + // flow. Recursing until the stack gives out also discards any signal found before it. + const cyclic = await mapOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': `const a = b;\nconst b = a;\nmodule.exports = a;`, + }); + expect(cyclic.serverSurface?.state).toBe('unknown'); + expect(cyclic.coverage.notes.some((n) => n.includes('serverSurface'))).toBe(true); + }); + + it('does not list a static generator for a Next app that serves', async () => { + // Two independent rules keep plain `next` out of `static-build-detected`: it is not counted as a static + // generator, and it IS counted as a server dependency. Only the second decides the state, so mutating + // the first changed nothing observable — which made the first rule untested rather than redundant. + // + // The evidence list is consumer-visible, and a `static-generator` signal in it invites "static build + // detected" as a displayed reason for a state that says the opposite. + const map = await mapOf({ 'package.json': JSON.stringify({ dependencies: { next: '14' } }) }); + const signals = map.serverSurface?.evidence ?? []; + + expect(map.serverSurface?.state).toBe('unknown'); + expect(signals.filter((e) => e.signal === 'static-generator')).toEqual([]); + expect(signals.map((e) => e.source)).toContain('next'); + + // And when it does export statically, the generator signal appears with the setting that proved it. + const exported = await mapOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.mjs': "export default { output: 'export' };", + }); + expect(exported.serverSurface?.evidence.find((e) => e.signal === 'static-generator')?.source) + .toContain("output: 'export'"); + }); + + it('does not read Astro as static when an SSR adapter is installed', async () => { + expect(await stateOf({ 'package.json': JSON.stringify({ dependencies: { astro: '4' } }) })) + .toBe('static-build-detected'); + expect(await stateOf({ 'package.json': JSON.stringify({ dependencies: { astro: '4', '@astrojs/node': '8' } }) })) + .toBe('unknown'); + }); + + it('says what the state does NOT mean, in the document itself', async () => { + const map = await mapOf(VITE_APP); + const note = map.coverage.notes.find((n) => n.includes('serverSurface')); + + // The claim a consumer would otherwise make for us. A static reading describes the source; a function + // added at the platform level is invisible to it. + expect(note).toContain('NOT deployment attestation'); + expect(note).toContain('not what is deployed'); + }); +}); + +describe('unknown, which is most of the interesting cases', () => { + it('does not read a platform config alone as a server runtime', async () => { + // The common shape this gets wrong: a static site deployed to Netlify has a `netlify.toml` and no + // server whatsoever. Same for a static Vercel project and a Pages project serving only assets. The + // config establishes "deploys somewhere", which is not "serves requests". + for (const config of [ + { 'netlify.toml': '[build]\n publish = "dist"' }, + { 'vercel.json': '{"cleanUrls": true}' }, + { 'wrangler.toml': 'name = "app"\npages_build_output_dir = "dist"' }, + ]) { + expect(await stateOf({ ...VITE_APP, ...config }), JSON.stringify(config)).toBe('unknown'); + } + }); + + it('keeps the config visible as evidence, and still refuses the static claim', async () => { + // Both halves: the config is why this is not `static-build-detected`, and a consumer needs to see it — + // but it is not promoted to a runtime either. `unknown` with a stated reason. + const map = await mapOf({ ...VITE_APP, 'netlify.toml': '[build]\n publish = "dist"' }); + const signals = map.serverSurface?.evidence ?? []; + + expect(map.serverSurface?.state).toBe('unknown'); + expect(signals.map((e) => e.signal)).toContain('deployment-config'); + expect(signals.map((e) => e.signal)).toContain('static-generator'); + }); + + it('refuses a static claim when a server framework is installed but no endpoint parsed', async () => { + // The defect this rule prevents: an unparsed framework produces no endpoints, which looks exactly like + // a static app. `express` in the manifest says otherwise, so the honest answer is that we do not know. + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { express: '4', vite: '5' } }), + 'src/main.tsx': 'export const App = () => null;', + })).toBe('unknown'); + }); + + it('refuses a static claim when an ambiguous api folder holds source', async () => { + // `api/client.ts` is an ordinary front-end helper; `api/handler.ts` is a platform function. From here + // they are the same folder, so neither claim is available. + expect(await stateOf({ + ...VITE_APP, + 'api/client.ts': 'export const get = () => fetch("/x");', + })).toBe('unknown'); + }); + + it('reports unknown for a stack it recognizes nothing in', async () => { + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { 'some-unknown-framework': '1' } }), + 'src/app.ts': 'export const handler = () => "hello";', + })).toBe('unknown'); + }); + + it('reports unknown with no manifest at all', async () => { + expect(await stateOf({ 'src/app.ts': 'export const x = 1;' })).toBe('unknown'); + }); + + it('says plainly that unknown is not "no server side"', async () => { + const map = await mapOf({ 'package.json': JSON.stringify({ dependencies: { 'x-framework': '1' } }) }); + const note = map.coverage.notes.find((n) => n.includes('serverSurface')); + + expect(note).toContain('UNKNOWN'); + expect(note).toContain('must not be read as "no server side"'); + // And it names the reason, rather than asserting the limit. + expect(note).toContain('no completeness flag'); + }); + + it('keeps the ambiguous layout visible in the evidence, so the state can be explained', async () => { + const map = await mapOf({ ...VITE_APP, 'functions/hello.js': 'export const onRequest = () => {}' }); + const signals = map.serverSurface?.evidence.map((e) => e.signal) ?? []; + + // Both halves are reported: the folder that blocked the static claim AND the generator that would + // otherwise have supported it. A state with no visible reason is one a consumer cannot act on. + expect(map.serverSurface?.state).toBe('unknown'); + expect(signals).toContain('ambiguous-layout'); + expect(signals).toContain('static-generator'); + }); +}); + +describe('the field stays safe to consume', () => { + it('is additive, so a v3 reader is unaffected', async () => { + const map = await mapOf(VITE_APP); + + expect(map.version).toBe(3); + expect(map.serverSurface?.state).toBeDefined(); + }); + + it('never claims a state without at least one signal behind it, except unknown', async () => { + for (const files of [VITE_APP, { 'package.json': JSON.stringify({ dependencies: { express: '4' } }) }]) { + const map = await mapOf(files); + if (map.serverSurface?.state !== 'unknown') { + expect(map.serverSurface?.evidence.length, 'a positive state needs evidence').toBeGreaterThan(0); + } + } + }); +});