Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
0509af9
feat(dfd): data flow diagram model, analysis, renderer and CLI
Portll Nov 11, 2026
7731ea3
test(dfd): point the CLI tests at the fixture's new home
Portll Nov 11, 2026
1fcdbc6
feat(flow): trace network connects and listens, and record open access
Portll Nov 11, 2026
c6e7da1
feat(flow): reconcile endpoints, give touches a direction, resolve ba…
Portll Nov 11, 2026
cd019e5
test(flow): network observation, self-witness, and open access in C2
Portll Nov 11, 2026
35ef0f3
test(flow): reconcile endpoints, directions, observed paths and readR…
Portll Nov 11, 2026
85a52eb
feat(dfd): closed subtypes, flow roles, verdict and direction; role-a…
Portll Nov 11, 2026
2df882b
refactor(dfd): move fromFlowGraph to flow/dfd-adapter.mjs so lib/ imp…
Portll Nov 11, 2026
367e720
Place dfd labels clear of elements, transpose crossings, add TB orien…
Portll Nov 11, 2026
afe7efa
feat(dfd): map endpoints, databases, schedulers, scripts, logs, the r…
Portll Nov 11, 2026
a42c015
fix(flow): keep each wrapped builtin's name and length under the trac…
Portll Nov 11, 2026
9028f8c
Add --direction, --collapse, --collapse-all-groups-over, --orientatio…
Portll Nov 11, 2026
2efddfb
Serve the data flow diagram in the panel at /dfd/ and /api/dfd, and s…
Portll Nov 11, 2026
34d74db
docs(dfd): threat-model diagram and commitwork layer map, with tests …
Portll Nov 11, 2026
0c5f44d
Assert the dfd route's import closure is in the npm tarball
Portll Nov 11, 2026
82e6fa2
docs(flow): runtime witness for network, observed paths and direction
Portll Nov 11, 2026
ed76b81
docs(flow): dfd sources and mapping: the dfd@1 additions and both ada…
Portll Nov 11, 2026
afdb513
Describe the visualiser as C6 in the dataflow oversight spec
Portll Nov 11, 2026
f1dfb72
feat(flow): endpoint, database and scheduler kinds; bracket structure…
Portll Nov 11, 2026
26e400c
feat(flow): extract launchd, workflow, cron, timer and watch triggers
Portll Nov 11, 2026
b1fcc31
feat(flow): extract endpoints, databases, node scripts and append/tai…
Portll Nov 11, 2026
b1bd5ef
fix(flow): keep the unknown-directory database reason out of the bind…
Portll Nov 11, 2026
6ba15ed
docs(flow): static extraction of endpoints, databases and triggers
Portll Nov 11, 2026
ef65009
Merge branch 'dfd-runtime' into dfd-merge
Portll Nov 11, 2026
09a6627
Merge branch 'dfd-model' into dfd-merge
Portll Nov 11, 2026
34d80d3
Merge branch 'dfd-render' into dfd-merge
Portll Nov 11, 2026
e60490e
Pack TB lanes by element width and share the cross axis between layer…
Portll Nov 11, 2026
8022380
Re-derive dfd test pins from the merged fixture and document the visu…
Portll Nov 11, 2026
12db42b
fix(dfd): tab icon on the route's error page, isolate the spawned pan…
Portll Nov 11, 2026
a3d94f0
Merge branch 'fix/red-ci-20261112' into dfd-merge
Portll Nov 11, 2026
9804333
docs(seams): declare flow/triggers.mjs as a launchd reader with its p…
Portll Nov 11, 2026
56956a2
docs(seams): set the summary counts to what bin/lib/platform-seams.mj…
Portll Nov 11, 2026
6926f2b
docs(seams): restamp the measurement date
Portll Nov 11, 2026
1a432df
Merge branch 'fix/red-ci-20261112' into feat/dfd-20261112
Portll Nov 11, 2026
d335416
Merge branch 'fix/red-ci-20261112' into feat/dfd-20261112
Portll Nov 12, 2026
15a0843
Merge branch 'fix/red-ci-20261112' into feat/dfd-20261112
Portll Nov 12, 2026
32124ca
Merge branch 'fix/red-ci-20261112' into feat/dfd-20261112
Portll Nov 12, 2026
922a60b
Merge branch 'fix/red-ci-20261112' into feat/dfd-20261112
Portll Nov 12, 2026
af17423
Merge branch 'fix/red-ci-20261112' into feat/dfd-20261112
Portll Nov 12, 2026
b789952
flow/static: read another repository's JS/TS sources, never its code
Portll Nov 12, 2026
e52d77e
dfd: --repo and --codeql draw a scanned repository; unknown is never …
Portll Nov 12, 2026
c7436da
commitwork dfd: the visualiser as a subcommand
Portll Nov 12, 2026
e53ccca
panel dfd: repo=<name> draws a registered repository
Portll Nov 12, 2026
7fe9652
npm-pack test: the import walk skips comment lines
Portll Nov 12, 2026
ae47889
commitwork dfd: help and exit codes through the CLI, pinned
Portll Nov 12, 2026
2e1eaee
flow/static: a file no goal parses names each goal's refusal
Portll Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions admin/routes/dfd.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// admin/routes/dfd.mjs — the data flow diagram behind the panel login.
//
// GET /dfd/ the page (renderHtml), sent under the page's own hash-pinned CSP
// GET /api/dfd the filtered dfd@1 document as JSON
//
// Source, read at call time: the dfd@1 file CW_DFD_FILE names, else the flow store's graph
// (<CW_HARNESS_STORE>/flow.json) through the adapter. The query mirrors the command's flags —
// prefix, focus, hops, direction, collapse, collapse-all-groups-over, orientation, max, tests,
// imports — and goes through the command's own parser and loader (flow/dfd-cli.mjs), so the page
// and `node flow/dfd-cli.mjs` read, filter and refuse alike. collapse and orientation shape the
// drawing only; /api/dfd returns the document before either.
//
// `repo=<name>` draws a scanned repository instead: the static pass over the checkout the panel's
// own fleet resolver (admin/lib/core.mjs resolvedRepos, the sweep's resolver) names `name`. Only a
// name is taken — never a path from the query: a value that is not a registry name (letters,
// digits, `.`, `_`, `-`, not `.` or `..`) is refused 400 before any lookup, and a name the
// registry does not know is refused 400 with the reason. The pass runs in a child (`node
// --experimental-vm-modules flow/dfd-cli.mjs --repo <registered path> --emit model`): the panel
// has no vm modules, and the child reads the target's sources without running them. The model it
// returns is filtered and collapsed here, through the same prepare() as every other source.
//
// Dispatched below the login gate (admin/serve.mjs MODULAR_ROUTES), so every route here already
// has a session.

import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { parseArgs, prepare, EXIT, DEFAULT_MAX } from '../../flow/dfd-cli.mjs';
import { renderHtml } from '../../lib/dfd-render.mjs';
import { MARK_ICON } from '../../lib/brand-tokens.mjs';
import { esc } from '../../lib/html-escape.mjs';
import { resolvedRepos } from '../lib/core.mjs';

const CLI = fileURLToPath(new URL('../../flow/dfd-cli.mjs', import.meta.url));
export const REPO_NAME_RE = /^[A-Za-z0-9._-]{1,128}$/;
export const PASS_TIMEOUT_MS = 180_000;

const FILTER_PARAMS = ['prefix', 'focus', 'hops', 'direction'];
const DRAWING_PARAMS = ['collapse', 'collapse-all-groups-over', 'orientation', 'max'];
const BOOL_PARAMS = ['tests', 'imports'];
const STATUS = { [EXIT.USAGE]: 400, [EXIT.INVALID]: 422, [EXIT.INPUT]: 404, [EXIT.SIZE]: 422 };

/**
* `repo=<name>` -> { path } of the registered checkout, or { error } (always a 400). The name is a
* key into the registry and nothing else: it is never joined to a directory.
*/
export function registeredRepo(name, repos = resolvedRepos) {
if (!REPO_NAME_RE.test(name) || name === '.' || name === '..') {
return { error: `repo must name a repository the panel knows (letters, digits, . _ -), not a path: ${JSON.stringify(name.slice(0, 80))}` };
}
const list = repos() || [];
const hit = list.find((r) => r && r.name === name && typeof r.path === 'string' && r.path);
if (!hit) return { error: `unknown repository ${name}: the panel's registry names ${list.length} repositor${list.length === 1 ? 'y' : 'ies'}, and not this one` };
return { path: hit.path };
}

/** Run the static pass on a registered checkout in a child -> { model } or { status, error }. */
export function passInChild(path, { env = process.env, timeoutMs = PASS_TIMEOUT_MS, tests = false, imports = false, exec = execFile } = {}) {
const argv = ['--experimental-vm-modules', '--no-warnings', CLI, '--repo', path, '--emit', 'model', ...(tests ? ['--tests'] : []), ...(imports ? ['--imports'] : [])];
const childEnv = { ...env };
delete childEnv.NODE_TEST_CONTEXT;
return new Promise((resolveP) => {
exec(process.execPath, argv, { env: childEnv, timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8' }, (err, stdout, stderr) => {
if (err) {
const code = typeof err.code === 'number' ? err.code : null;
const why = String(stderr || err.message).trim().replace(/^dfd: /, '').split('\n')[0];
return resolveP({ status: code === EXIT.INPUT ? 404 : 500, error: `the static pass over the repository failed${code === null ? ` (${err.signal || err.code})` : ''}: ${why}` });
}
try { resolveP({ model: JSON.parse(stdout) }); } catch (e) { resolveP({ status: 500, error: `the static pass returned no readable model: ${e.message}` }); }
});
});
}

/** The request's query -> the command's argv, its source fixed by the environment. */
export function argvFor(url, env = process.env, { drawing = true } = {}) {
const q = new URL(url, 'http://panel.invalid').searchParams;
const argv = env.CW_DFD_FILE ? ['--from', env.CW_DFD_FILE] : ['--graph'];
for (const k of [...FILTER_PARAMS, ...(drawing ? DRAWING_PARAMS : [])]) if (q.has(k)) argv.push(`--${k}`, q.get(k));
for (const k of BOOL_PARAMS) if (q.get(k) === '1' || q.get(k) === 'true') argv.push(`--${k}`);
return argv;
}

/**
* -> { status, opts, model, collapse, drawn } or { status, error }. `drawing: false` ignores the
* drawing keys. With `repo=` in the query the answer is a Promise (the pass runs in a child);
* without it, as before, synchronous. `repos` and `pass` are the registry and the child, for tests.
*/
export function load(url, env = process.env, { drawing = true, repos = resolvedRepos, pass = passInChild } = {}) {
// a value flag always takes the next argument as its value, so a query value cannot become a flag
const { opts, error } = parseArgs(argvFor(url, env, { drawing }));
if (error) return { status: 400, error };
const q = new URL(url, 'http://panel.invalid').searchParams;
if (q.has('repo')) {
const reg = registeredRepo(q.get('repo'), repos);
if (reg.error) return { status: 400, error: reg.error };
return pass(reg.path, { env, tests: !!opts.tests, imports: !!opts.imports }).then((built) => {
if (built.error) return { status: built.status, error: built.error };
const got = prepare(opts, { env, source: built.model });
if (got.error) return { status: STATUS[got.code], error: got.error };
return { status: 200, opts, ...got };
});
}
const got = prepare(opts, { env });
if (got.error) return { status: STATUS[got.code], error: got.error };
return { status: 200, opts, ...got };
}

const errorPage = (status, msg) => `<!doctype html><html lang="en"><head><meta charset="utf-8">${MARK_ICON}<title>data flow diagram unavailable</title></head>`
+ `<body><h1>data flow diagram unavailable</h1><p>${esc(msg)}</p><p>Status ${status}.</p></body></html>`;

/** Answer now, or when the pass does: `then` only when `load` returned a Promise. */
const settle = (got, answer) => (typeof got?.then === 'function' ? got.then(answer) : answer(got));

function page(ctx) {
return settle(load(ctx.req.url), (got) => answerPage(ctx, got));
}

function answerPage(ctx, got) {
const max = got.opts?.max ?? DEFAULT_MAX;
if (got.status === 200 && got.drawn > max) {
got.status = 422;
got.error = `${got.drawn} elements is more than max ${max}: narrow it with prefix or focus, collapse groups, or raise max`;
}
if (got.status !== 200) return ctx.send(got.status, errorPage(got.status, got.error), 'text/html; charset=utf-8', { 'cache-control': 'no-store' });
const { html, csp } = renderHtml(got.model, { fonts: 'served', collapse: got.collapse, orientation: got.opts.orientation || 'auto' });
return ctx.send(200, html, 'text/html; charset=utf-8', {
'content-security-policy': csp,
'content-security-policy-report-only': csp,
'cache-control': 'no-store',
'x-robots-tag': 'noindex, nofollow',
});
}

function api(ctx) {
return settle(load(ctx.req.url, process.env, { drawing: false }), (got) => answerApi(ctx, got));
}

function answerApi(ctx, got) {
if (got.status !== 200) return ctx.send(got.status, { ok: false, error: got.error });
return ctx.send(200, got.model, 'application/json', { 'cache-control': 'no-store' });
}

export const routes = [
{ method: 'GET', path: '/dfd/', handle: page },
{ method: 'GET', path: '/dfd', handle: page },
{ method: 'GET', path: '/api/dfd', handle: api },
];
3 changes: 2 additions & 1 deletion admin/serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import { routeFlagOff, offBody } from '../lib/feature-flags.mjs';
import { routes as overwatchLayerRoutes } from './routes/overwatch-layer.mjs';
import { routes as docsiteRoutes, docsiteHandle, docsiteOrigins, docsiteHosts } from './routes/docsite.mjs';
import { routes as launchlistRoutes, launchlistHosts } from './routes/launchlist.mjs';
import { routes as dfdRoutes } from './routes/dfd.mjs';
import { routes as offboxRoutes, offboxHosts, handleIngest as offboxIngest } from './routes/offbox.mjs';
import { routes as jobRoutes, initJobRoutes } from './routes/jobs.mjs';
import { routes as panelProcessRoutes, initPanelProcessRoutes } from './routes/panel-process.mjs';
Expand Down Expand Up @@ -853,7 +854,7 @@ function errorPage(code, title, detail, { back = '/' } = {}) {
// throw there took the process down for everyone rather than refusing one request. Three unguarded
// decodeURIComponent calls have the same shape. Fail closed, per request, not per process.
// Every modular route group, in dispatch order. Adding a group is one import and one entry here.
const MODULAR_ROUTES = [...secretsRoutes, ...leaksCheckRoutes, ...leaksVerifyRoutes, ...postureRoutes, ...reportRoutes, ...remediationRoutes, ...a11yRoutes, ...profileRoutes, ...configEditRoutes, ...scanConfigRoutes, ...ingestRoutes, ...issueDetailRoutes, ...craRoutes, ...determinationRoutes, ...annotationRoutes, ...oversightRoutes, ...correlationRoutes, ...remediationPolicyRoutes, ...llmRuntimeRoutes, ...hostRoutes, ...verdictRoutes, ...packageRoutes, ...updateRoutes, ...codeqlRemediationRoutes, ...cobolworkRemediationRoutes, ...projectsViewRoutes, ...fleetOverviewRoutes, ...settingsRoutes, ...agentSurfaceRoutes, ...perfRoutes, ...scannerRoutes, ...scanPathRoutes, ...turnsRoutes, ...commentRoutes, ...learningRoutes, ...featuresRoutes, ...overwatchLayerRoutes, ...docsiteRoutes, ...launchlistRoutes, ...offboxRoutes, ...rollupsRoutes, ...jobRoutes, ...panelProcessRoutes, ...panelStateRoutes, ...issuesRoutes, ...dailyRoutes, ...renovateRoutes, ...paletteRoutes, ...feedRoutes, ...journeyRoutes, ...graphRoutes, ...pullRequestRoutes, ...inboxRoutes, ...runnerRoutes];
const MODULAR_ROUTES = [...secretsRoutes, ...leaksCheckRoutes, ...leaksVerifyRoutes, ...postureRoutes, ...reportRoutes, ...remediationRoutes, ...a11yRoutes, ...profileRoutes, ...configEditRoutes, ...scanConfigRoutes, ...ingestRoutes, ...issueDetailRoutes, ...craRoutes, ...determinationRoutes, ...annotationRoutes, ...oversightRoutes, ...correlationRoutes, ...remediationPolicyRoutes, ...llmRuntimeRoutes, ...hostRoutes, ...verdictRoutes, ...packageRoutes, ...updateRoutes, ...codeqlRemediationRoutes, ...cobolworkRemediationRoutes, ...projectsViewRoutes, ...fleetOverviewRoutes, ...settingsRoutes, ...agentSurfaceRoutes, ...perfRoutes, ...scannerRoutes, ...scanPathRoutes, ...turnsRoutes, ...commentRoutes, ...learningRoutes, ...featuresRoutes, ...overwatchLayerRoutes, ...docsiteRoutes, ...launchlistRoutes, ...dfdRoutes, ...offboxRoutes, ...rollupsRoutes, ...jobRoutes, ...panelProcessRoutes, ...panelStateRoutes, ...issuesRoutes, ...dailyRoutes, ...renovateRoutes, ...paletteRoutes, ...feedRoutes, ...journeyRoutes, ...graphRoutes, ...pullRequestRoutes, ...inboxRoutes, ...runnerRoutes];

// Server copy of the client's VALID_VIEWS — panel-view-paths.test.mjs pins the two sets equal.
// Includes RETIRED view names still served as aliases ('leaks' -> the secrets view). The server's
Expand Down
Loading
Loading