Files
timmy 29ea045671
Quality gates / quality (pull_request) Successful in 2m4s
docs: add end-to-end photo data-flow threat model (#20)
- docs/PHOTO-DATAFLOW-THREAT-MODEL.md: browser, API ingress, model worker,
  transient buffers, training opt-in store, logs, exports, and deletion,
  with trust boundaries and a numbered threat register
- every named abuse case (replay, oversized input, metadata leakage,
  poisoning, unauthorized training, operator access) carries a mitigation,
  an owner, a tracked follow-up ticket, and the artifact that will prove it
- tests/threat-model.test.js enforces diagram coverage, owner/follow-up
  presence against ROADMAP ticket numbers, and the non-diagnostic boundary
  with synthetic-only fixtures
- link the model from the accepted product decision record
2026-10-10 08:03:31 +00:00

120 lines
5.4 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const threatModelPath = new URL('../docs/PHOTO-DATAFLOW-THREAT-MODEL.md', import.meta.url);
const productDecisionPath = new URL('../docs/PRODUCT-DECISIONS.md', import.meta.url);
const roadmapPath = new URL('../ROADMAP.md', import.meta.url);
// Every hop that can hold, move, or leak a user's photo bytes has to be named
// in the diagram, not only in prose.
const REQUIRED_DIAGRAM_NODES = [
['browser', /\bbrowser\b/i],
['API ingress', /\bapi[- ]ingress\b/i],
['model worker', /\bmodel worker\b/i],
['transient buffers', /\btransient buffer/i],
['training opt-in store', /\btraining opt-in\b/i],
['logs', /\blogs?\b/i],
['exports', /\bexports?\b/i],
['deletion', /\bdeletion\b/i],
];
// The abuse cases the ticket names explicitly, in the order they appear there.
const REQUIRED_THREATS = [
['replay', /\breplay\b/i],
['oversized input', /\boversized input\b/i],
['metadata leakage', /\bmetadata leakage\b/i],
['poisoning', /\bpoisoning\b/i],
['unauthorized training', /\bunauthorized training\b/i],
['operator access', /\boperator access\b/i],
];
function diagramBody(doc) {
const match = doc.match(/```mermaid\r?\n([\s\S]*?)```/);
assert.ok(match, 'the threat model must contain a fenced mermaid data-flow diagram');
return match[1];
}
function threatBlocks(doc) {
const blocks = new Map();
const pattern = /^###\s+(T\d+)\s+—\s+(.+)$/gm;
const headings = [...doc.matchAll(pattern)];
headings.forEach((heading, index) => {
const start = heading.index + heading[0].length;
const end = index + 1 < headings.length ? headings[index + 1].index : doc.length;
blocks.set(heading[1], { name: heading[2].trim(), body: doc.slice(start, end) });
});
return blocks;
}
function knownIssueNumbers(roadmap) {
return new Set([...roadmap.matchAll(/issues\/(\d+)/g)].map((match) => Number(match[1])));
}
test('photo data-flow threat model diagrams every component that can hold or leak image bytes', async () => {
const doc = await readFile(threatModelPath, 'utf8');
const diagram = diagramBody(doc);
for (const [label, pattern] of REQUIRED_DIAGRAM_NODES) {
assert.match(diagram, pattern, `the data-flow diagram must show the ${label} component`);
}
// The diagram has to end somewhere: the model worker's bytes must not flow on.
assert.match(diagram, /no retention|dropped|discarded|never persist/i,
'the diagram must show that worker input is not retained');
});
test('every required abuse case has a mitigation, an owner, and a real follow-up ticket', async () => {
const [doc, roadmap] = await Promise.all([
readFile(threatModelPath, 'utf8'),
readFile(roadmapPath, 'utf8'),
]);
const known = knownIssueNumbers(roadmap);
const blocks = threatBlocks(doc);
assert.ok(blocks.size > 0, 'threats must be numbered headings so their coverage is checkable');
const named = [...blocks.values()].map((block) => block.name).join('\n');
for (const [label, pattern] of REQUIRED_THREATS) {
assert.match(named, pattern, `the ${label} abuse case must be its own numbered threat`);
const block = [...blocks.values()].find((entry) => pattern.test(entry.name));
assert.match(block.body, /^\s*-\s*\*\*Owner:\*\*\s*\S+/m,
`${label} needs a named owner`);
assert.match(block.body, /^\s*-\s*\*\*Testable follow-up:\*\*\s*#\d+/m,
`${label} needs a testable follow-up ticket`);
assert.match(block.body, /^\s*-\s*\*\*Mitigation:\*\*\s*\S+/m,
`${label} needs a concrete mitigation, not a restatement of the risk`);
assert.match(block.body, /^\s*-\s*\*\*Evidence:\*\*\s*\S+/m,
`${label} needs the artifact that will prove the mitigation works`);
const followUp = Number(block.body.match(/^\s*-\s*\*\*Testable follow-up:\*\*\s*#(\d+)/m)[1]);
assert.ok(known.has(followUp),
`${label} points at #${followUp}, which is not a tracked ticket in ROADMAP.md`);
assert.notEqual(followUp, 20, `${label} must delegate to work other than this threat model itself`);
}
});
test('threat model keeps the photo path non-diagnostic and free of real medical images', async () => {
const doc = await readFile(threatModelPath, 'utf8');
assert.match(doc, /non-diagnostic/i);
assert.match(doc, /never diagnos/i);
assert.match(doc, /provisional/i);
assert.match(doc, /synthetic/i, 'fixtures must be synthetic, not real medical images');
assert.match(doc, /no real (?:medical |stool )?images? (?:are |may )?(?:ever )?(?:added|used|shipped|included)/i,
'the model must state plainly that no real medical images enter the repo');
assert.match(doc, /deterministic (?:red-flag )?(?:escalation|urgent-symptom)/i,
'the model must note that escalation is deterministic and never model-gated');
assert.doesNotMatch(doc, /\b(?:bathroom photo|patient photo|clinical image|real stool image)\b/i,
'the model must not promise or reference real medical imagery');
assert.doesNotMatch(doc, /\b(?:clinically validated|clinically proven|medical device|FDA-cleared|CE-marked)\b/i,
'the model must not overstate the clinical status of the vision path');
});
test('the accepted product decision record links the photo data-flow threat model', async () => {
const decision = await readFile(productDecisionPath, 'utf8');
assert.match(decision, /\.\/PHOTO-DATAFLOW-THREAT-MODEL\.md/,
'the governing decision record must point reviewers at the threat model');
});