Quality gates / quality (pull_request) Successful in 2m4s
- 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
120 lines
5.4 KiB
JavaScript
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');
|
|
});
|