diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac41c65..5f20bf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,14 @@ jobs: bun Tools/RenderReport.ts Examples/ledgerline test -s Examples/ledgerline/REPORT.html ! grep -qE 'src="https?://|href="https?://[^"]*\.css' Examples/ledgerline/REPORT.html + + - name: Summary renders, self-contained, and carries its qualifiers + run: | + bun Tools/RenderSummary.ts Examples/ledgerline + test -s Examples/ledgerline/SUMMARY.html + ! grep -qE 'src="https?://|href="https?://[^"]*\.css' Examples/ledgerline/SUMMARY.html + ! grep -q ' + + +Ledgerline — multi-tenant invoicing API (fictional; design document only, no code) — STPA Executive Summary +
+
+
STPA threat model — executive summary
+

Ledgerline — multi-tenant invoicing API (fictional; design document only, no code)

+
32 questions asked (8 control actions × 4 ways to be unsafe) · generated 2026-08-19
+
+
NOT INDEPENDENTLY REVIEWED. No second model has adversarially reviewed these findings, so every finding and every band below is a draft, not a verdict. Run stpa verify after the review pass before circulating this page.
+
+
8
findings
+
1
band 1 — fix first
+
100%
analysis complete
+
4
root causes
+
3
in wave 1
+
+

What is at stake

+
  • L-1 — one organisation's invoice data is disclosed to a party outside that organisation.
  • L-2 — Ledgerline staff read customer data without a business reason, and the customer cannot tell.
+
Hazard — an unsafe system state, not an attackFindingsWorst
H-1 the system serves invoice data to a principal whose entitlement to that organisation is not current. (→ L-1)62
H-2 the system grants a staff principal access to an organisation's data with no record the organisation can read. (→ L-2)13
H-3 the system delivers organisation event data to a network endpoint it has not established is external and org-controlled. (→ L-1)11
+

Findings column counted from hazard ids named in each finding.

+
+

What to fix first

+

Wave 1 of the engineering plan — ranked by band, then by leverage.

+
+
1CA-8.providedWebhook dispatcherM effort
+

The webhook dispatcher POSTs a payment event when the configured URL points at an internal address, leading to H-3 (internal endpoints reached from inside the trust boundary, and event contents delivered to an unintended host).

+

Fix — Egress proxy that resolves at connect time and refuses non-public addresses.

+

Where — DESIGN.md — 'the webhook URL is whatever the org typed'

+
+
2CA-3.providedPay-link serviceS effort
+

The pay-link service serves an invoice when the presenter is anyone who has ever seen the link, including after the customer relationship ends, leading to H-1 (invoice contents disclosed to a non-customer).

+

Fix — Expire the token on payment or void; rotate on dispute.

+

Where — DESIGN.md — 'pay-link token is a UUIDv4 ... never rotates'

+
+
2CA-3.durationPay-link serviceS effort
+

The pay-link service keeps honouring a pay-link token indefinitely after the invoice is paid, leading to H-1.

+

Fix — Give the token a TTL independent of invoice state as a backstop.

+

Where — DESIGN.md — pay-link section

+
+
+

The few changes that close the most

+

Root causes ranked by leverage — how many findings each one closes. Fixing these 3 resolves 7 of 8 findings.

+
+
RC-1closes 3M effort
+

Entitlement is decided from a claim, never from current state

+

The gateway mints orgId at login and never re-reads it, and the invoice service trusts the header the gateway attaches. Neither component owns the belief, so neither refreshes it. The design note 'existing sessions are not revoked' is the same defect stated as a fact.

+

Fix — Derive org membership at the point of use, from the membership table, with a short cache. Make the gateway header advisory rather than authoritative.

+
+
RC-2closes 2S effort
+

Possession of a long-lived token is treated as entitlement

+

Pay links are UUIDv4s that never rotate or expire, and they are distributed by email — so every copy that email produces is a permanent credential. No check can distinguish the customer from anyone the customer forwarded the mail to.

+

Fix — Bind pay-link validity to invoice state: expire at payment, rotate on dispute, and require a second factor the customer already holds.

+
+
RC-4closes 2L effort
+

Three of four feedback channels do not exist

+

No pay-link access log, no impersonation notice the org can read, no webhook egress log. Every other finding here is silent, and every fix above can regress unobserved.

+

Fix — Log the pay-link fetch, expose an org-readable impersonation trail, and record webhook egress destinations.

+
+
+
This is the summary. The full analysis — every unsafe control action, the loss scenarios behind each finding, the security constraints with runnable probes, and the complete engineering plan — is in REPORT.html, beside this file.
+
+

STPA (System-Theoretic Process Analysis, Leveson & Thomas) with its security adaptation STPA-Sec. It looks for losses that happen when every component works as designed — broken authorization, tenant leaks, stale permissions, bypass paths. It is not a scanner: no CVEs, no injection, no dependency audit.

+

Generated from ledgerline/. Every figure on this page is computed from the analysis artifacts, not written by hand.

+
+
\ No newline at end of file diff --git a/README.md b/README.md index 97c2b84..ab41a51 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,15 @@ stpa init .stpa/model.json -o .stpa/grid.json # 3. the 4 x N grid # ...resolve every cell in grid.json stpa status .stpa/grid.json # 4. coverage check # ...write .stpa/remediation.json — cost, location, fix, probe -stpa run .stpa # 5. plan + REPORT.html +stpa run .stpa # 5. plan + SUMMARY.html + REPORT.html ``` -Open `.stpa/REPORT.html` in any browser. It is a single self-contained file — no CDN, no scripts, no fonts. Email it, commit it, print it. +Every run writes two self-contained files — no CDN, no scripts, no fonts. Email them, commit them, print them. + +- **`.stpa/SUMMARY.html`** — one page: what is at stake, what to fix first, the few root causes that close the most. This is the one you forward. +- **`.stpa/REPORT.html`** — the full analysis: every unsafe control action, the loss scenarios, the constraints with runnable probes, the engineering plan. + +The summary is generated from the same artifacts as the report and carries the same qualifiers, so the two cannot disagree. Run `stpa summary` on its own to regenerate just the short one. ### What each command does diff --git a/SKILL.md b/SKILL.md index 32435ea..0bc8737 100644 --- a/SKILL.md +++ b/SKILL.md @@ -148,7 +148,7 @@ bun $S/Tools/ReportLink.ts .stpa [--copy-to ~/Desktop] **The report reads as a narrative, top to bottom, and the engineering plan comes LAST.** It opens with a **plain-language STPA primer and glossary** (§0 "Reading this report") that defines every term *before* it is used — loss, hazard, controller, control action, process model, UCA and its four types, loss scenario, constraint, trust zone, blast radius, band, plus the security terms the findings use (IDOR, confused deputy, TOCTOU, prompt injection) — and states plainly that STPA finds the emergent/interaction class STRIDE structurally misses while *complementing* STRIDE, not replacing it. It also names how the report's three groupings relate so they don't read as disconnected: the **four UCA types** are the *mechanism*, the **hazards** are the dangerous *states* they produce, the **root causes** are the shared *fixes*. A threat-model report that uses "UCA", "hazard", or "IDOR" before defining them has failed the reader — the primer is not optional. `RenderReport.ts` slices `01-scope.md` into its parts (so nothing is duplicated) and lays the report out as a story a non-engineer can follow: **1 · What the system is** (the target's own purpose statement) → **2 · How it's built — systems & subsystems** (a **generated, self-contained SVG control-loop diagram** built from the model: actors → the app's subsystems → the shared resources they act on, with control arrows down and dashed feedback up, and cross-account/bus edges flagged as crown-jewel paths; the full control-structure text folds underneath) → **3 · Boundary & seams** (the trust boundary + assumptions) → **4 · What's at stake** (stakeholder losses in business terms, and hazards as unsafe system *states* ranked by how many findings reach each) → **5 · Where to focus** (a prioritization view for when the finding count is overwhelming — the top root causes by leverage, the cumulative share of findings they close, and the band-1 count, so the reader fixes the few things that resolve most findings) → **6 · How it goes wrong (loss scenarios)** and **7 · What must hold (security constraints)** — the reader's payload, placed in the prime middle rather than buried before the plan: the causal stories of how each hazard is actually reached, and the MUST-NOT that *prevents* the hazard (or *bounds* it — blast radius, attribution — where prevention is not possible), each with a runnable check → **8 · Unsafe control actions** (the full UCA grid — the exhaustive enumeration *behind* the scenarios, grouped into per-plane collapsible groups whose summaries carry finding / ruled-out / band-1 / action counts) → **9 · What each part believes** (the process-model & feedback table — kept at the end as reference because it is the densest section) → **10 · Engineering plan** (root causes by leverage, highest-leverage move, waves, six plan-quality metrics). The "what kind of thing goes wrong" bars in the header encode count as **fill length** proportional to the largest category (no full-width rail — a short bar must read as few, not as "same width as the rest"). Every section is collapsible via native `
` — the four narrative anchors (§1 What it is, §2 How it's built, §4 What's at stake, §5 Where to focus) default open and the rest collapsed, but all can be minimized. **No JavaScript, so the page stays self-contained and offline**; print/PDF export forces every section open so nothing is lost. `md()` folds soft-wrapped source lines into single paragraphs (a hard-wrapped scope file must not render as one `

` per line), and each collapsible section drops its own leading heading since the summary already labels it. The per-plane grouping keys off each cell's `plane` field and falls back to one auto-opened group when absent. -**HTML is the default deliverable.** Every analysis ends with `RenderReport.ts` writing `REPORT.html` — self-contained (no CDN, no scripts, no external fonts; opens from `file://` with no network), light/dark aware, print-clean. It reads `grid.json` plus whatever `0*.md` artifacts exist, so partial analyses still render. Never hand-author the HTML: the coverage figure, counts, and concentration qualifier are derived from grid arithmetic, and hand-narrating them is how the report drifts from the grid. +**HTML is the default deliverable, and it ships in two lengths.** `RenderSummary.ts` writes `SUMMARY.html` — one page, no JavaScript, prints to a single sheet: the stat row, what is at stake, wave 1, and the top root causes by leverage. It is the page that gets forwarded, which makes it the page most likely to be read *instead of* the report — so it carries every qualifier the report carries (not peer-reviewed, sections missing, scope not delivered, cells open, findings unbound) **above** the numbers rather than in a footnote. A summary that drops them is not a shorter report, it is a different and false claim. Every figure on it is computed from the artifacts; none is narrated. Alongside it, `RenderReport.ts` writes `REPORT.html` — self-contained (no CDN, no scripts, no external fonts; opens from `file://` with no network), light/dark aware, print-clean. It reads `grid.json` plus whatever `0*.md` artifacts exist, so partial analyses still render. Never hand-author the HTML: the coverage figure, counts, and concentration qualifier are derived from grid arithmetic, and hand-narrating them is how the report drifts from the grid. **Read `README.md` first** — install, the tactical run-one loop, and the strategic case for where this sits in an SDLC. `METHOD.md` is the theory and the honest limitations. @@ -269,4 +269,4 @@ bun $S/Tools/ReportLink.ts .stpa [--copy-to ~/Desktop] ## Execution Log -Analyses write to `/.stpa/` by default: `01-scope.md`, `02-control-structure.md`, `model.json`, `grid.json`, `03-ucas.md`, `04-scenarios.md`, `05-constraints.md`, and **`REPORT.html`** — the default deliverable, rendered by `Tools/RenderReport.ts` as the final step of every run. Re-run `IdentifyUCAs` when the control structure changes — new controller, new entry point, new integration, new operator role. The grid makes deltas cheap: only new control actions add cells. +Analyses write to `/.stpa/` by default: `01-scope.md`, `02-control-structure.md`, `model.json`, `grid.json`, `03-ucas.md`, `04-scenarios.md`, `05-constraints.md`, plus **`SUMMARY.html`** (one page, `Tools/RenderSummary.ts`) and **`REPORT.html`** (the full analysis, `Tools/RenderReport.ts`) — both rendered as the final step of every run. Re-run `IdentifyUCAs` when the control structure changes — new controller, new entry point, new integration, new operator role. The grid makes deltas cheap: only new control actions add cells. diff --git a/Tools/RenderSummary.ts b/Tools/RenderSummary.ts new file mode 100644 index 0000000..c51a205 --- /dev/null +++ b/Tools/RenderSummary.ts @@ -0,0 +1,426 @@ +/** + * RenderSummary.ts — the one-page executive summary that sits beside REPORT.html. + * + * REPORT.html is the analysis: ten sections, the full grid, every scenario and + * constraint. It is written for the engineers who will fix the findings, and it is + * long because the method is exhaustive. Nobody forwards it to a founder. + * + * This is the page that gets forwarded. One screen, no JavaScript, no external + * requests, prints to a single sheet: what the system is, what is at stake, the + * findings that matter most, and where to start on Monday. + * + * THE DESIGN CONSTRAINT THAT MATTERS: a summary is the artifact most likely to be + * read *instead of* the report, which makes it the artifact most likely to launder + * an incomplete analysis into a confident one. That is precisely the failure class + * this toolkit exists to find — a component meeting its spec while the composition + * misleads. So every qualifier the report carries, this page carries too, ABOVE the + * numbers rather than in a footnote: not peer-reviewed, sections missing, scope not + * delivered, cells still open, findings unbound. A summary that drops them is not a + * shorter report, it is a different and false claim. + * + * Every number here is computed from the artifacts. None is typed by hand — the + * evidence gate exists because a stale hand-typed count reached a deliverable once. + * + * Usage: RenderSummary.ts [analysis-dir] [-o out.html] [--title "..."] + * Reads grid.json (required), plus 06-remediation.json, 01-scope.md, model.json and + * review-scorecard.json when present. Writes

/SUMMARY.html. + */ + +import { readFileSync, existsSync, writeFileSync } from "node:fs"; +import { join, resolve, basename } from "node:path"; + +type Cell = { + id: string; + controlAction?: string; + controller?: string; + state: "open" | "uca" | "tombstone"; + statement?: string; + bindsTo?: string[]; + linksTo?: string[]; +}; +type Grid = { + system?: string; + totalCells: number; + declaredElements?: string[]; + cells: Cell[]; + scope?: { requested?: string; candidateControlActions?: number; selectionCriteria?: string }; +}; + +const die = (msg: string, code = 1): never => { + console.error(msg); + process.exit(code); +}; + +const argv = process.argv.slice(2); +if (argv.includes("--help") || argv.includes("-h")) { + die( + [ + "RenderSummary.ts — one-page executive summary for an STPA analysis", + "", + "Usage: RenderSummary.ts [analysis-dir] [-o out.html] [--title \"...\"]", + "", + "Reads grid.json (required) plus 06-remediation.json, 01-scope.md, model.json", + "and review-scorecard.json when present. Writes /SUMMARY.html.", + "", + "This is the page that gets forwarded. It carries every qualifier the full", + "report carries — a summary that drops them is a different, false claim.", + ].join("\n"), + 2, + ); +} + +const flagArgs = new Set(["-o", "--title"]); +const dir = resolve(argv.find((a, i) => !a.startsWith("-") && !flagArgs.has(argv[i - 1] ?? "")) ?? ".stpa"); +const oIdx = argv.indexOf("-o"); +const outPath = oIdx !== -1 ? argv[oIdx + 1]! : join(dir, "SUMMARY.html"); +const tIdx = argv.indexOf("--title"); +const titleOverride = tIdx !== -1 ? argv[tIdx + 1] : undefined; + +const read = (f: string) => (existsSync(join(dir, f)) ? readFileSync(join(dir, f), "utf8") : null); +const readJson = (f: string): T | null => { + const r = read(f); + if (!r) return null; + try { + return JSON.parse(r) as T; + } catch (e) { + console.error(`warning: ${f} is not valid JSON (${(e as Error).message}) — continuing without it`); + return null; + } +}; + +const gridRaw = read("grid.json"); +if (!gridRaw) die(`no grid.json in ${dir} — run the analysis first (Steps 2–3).`); +let grid: Grid; +try { + grid = JSON.parse(gridRaw); +} catch (e) { + die(`grid.json is not valid JSON: ${(e as Error).message}`); +} + +const esc = (s: string) => + String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +// ── the same arithmetic the grid and the report use; never re-derived by hand ── +const declared = new Set(grid.declaredElements ?? []); +const isBound = (c: Cell) => (c.bindsTo ?? []).some((r) => declared.has(r)); +const findings = grid.cells.filter((c) => c.state === "uca"); +const bound = findings.filter(isBound); +const unbound = findings.filter((c) => !isBound(c)); +const tombs = grid.cells.filter((c) => c.state === "tombstone"); +const openCells = grid.cells.filter((c) => c.state === "open"); +const pct = grid.totalCells ? ((bound.length + tombs.length) / grid.totalCells) * 100 : 0; + +const modeledCAs = grid.totalCells / 4; +const candidateCAs = grid.scope?.candidateControlActions; +const surfacePct = candidateCAs && candidateCAs > 0 ? (modeledCAs / candidateCAs) * 100 : null; +const scopeBreach = grid.scope?.requested === "full" && typeof candidateCAs === "number" && modeledCAs < candidateCAs; + +type Plan = { + system?: string; + metrics?: Record; + clusters?: { id: string; name: string; summary?: string; fix?: string; effort?: string; leverage?: number; bestBand?: number; findings?: string[] }[]; + waves?: { wave: number; label?: string; items?: any[] }[]; +}; +const plan = readJson("06-remediation.json"); +const bandOf = new Map(); +const itemOf = new Map(); +for (const w of plan?.waves ?? []) + for (const it of w.items ?? []) { + if (typeof it?.band === "number") bandOf.set(it.id, it.band); + itemOf.set(it.id, it); + } +const band1 = findings.filter((c) => bandOf.get(c.id) === 1); + +// The same section contract the report enforces. A summary of an analysis missing +// its scenarios is a summary of half a method, and must say so. +const SECTIONS = [ + { file: "01-scope.md", label: "Scope, losses and hazards" }, + { file: "02-control-structure.md", label: "Control structure" }, + { file: "04-scenarios.md", label: "Loss scenarios" }, + { file: "07-chains.md", label: "Composition" }, + { file: "05-constraints.md", label: "Security constraints" }, + { file: "06-remediation.json", label: "Engineering plan" }, +]; +const missingSections = SECTIONS.filter((x) => !read(x.file)); + +// ── losses and hazards, parsed out of 01-scope.md exactly as the report does ── +const scopeMd = read("01-scope.md") ?? ""; +const parseLH = (letter: string): { id: string; title: string; text: string }[] => { + const out: { id: string; title: string; text: string }[] = []; + const re = new RegExp(`-\\s*\\*\\*(${letter}-\\d+)[^*]*\\*\\*\\s*([\\s\\S]*?)(?=\\n\\s*-\\s*\\*\\*|\\n\\s*\\n|\\n#|$)`, "g"); + for (const m of scopeMd.matchAll(re)) { + const boldInner = (m[0].match(/\*\*([^*]+)\*\*/)?.[1] ?? m[1]).trim(); + const title = boldInner + .replace(new RegExp(`^${letter}-\\d+\\s*[—:–-]?\\s*`), "") + .replace(/[.:]\s*$/, "") + .trim(); + const text = (m[2] ?? "") + .replace(/\s+/g, " ") + .replace(/`?\[[^\]]*\]`?/g, "") + .replace(/[`*_]/g, "") + // "- **L-1** — the loss" leaves the dash on the text half once the id is + // stripped from the title half; without this the row renders "L-1 — — the loss" + .replace(/^[—–-]\s*/, "") + .trim(); + out.push({ id: m[1]!, title, text }); + } + return out; +}; +const losses = parseLH("L"); +const hazards = parseLH("H"); +/** + * How many findings reach each hazard. `linksTo` is the recorded linkage and is + * always preferred — but plenty of real analyses never fill it in, and a column of + * zeros beside findings whose own statements say "leading to H-3" is not a missing + * number, it is a WRONG one. So when no finding records a link, fall back to the + * hazard ids the statements themselves name. That is a derivation from the artifact, + * not a guess, and the table says which of the two produced the column. + */ +const linkedCount = findings.filter((c) => (c.linksTo ?? []).length).length; +const hazardsIn = (c: Cell): string[] => + linkedCount ? (c.linksTo ?? []) : [...new Set((c.statement ?? "").match(/\bH-\d+\b/g) ?? [])]; +const reachSource = linkedCount ? "recorded links" : "hazard ids named in each finding"; +const hazCount = new Map(); +for (const c of findings) + for (const h of hazardsIn(c)) { + const cur = hazCount.get(h) ?? { n: 0, worst: 9 }; + cur.n++; + const b = bandOf.get(c.id); + if (b && b < cur.worst) cur.worst = b; + hazCount.set(h, cur); + } + +const scorecard = readJson("review-scorecard.json"); +const reviewed = !!(scorecard?.passed && scorecard?.independentReview); + +const model = readJson("model.json"); +const title = titleOverride ?? plan?.system ?? grid.system ?? model?.system ?? "STPA Threat Model"; +const reportHref = existsSync(join(dir, "REPORT.html")) ? "REPORT.html" : null; + +// ── the page ────────────────────────────────────────────────────────────────── +const qualifiers: string[] = []; +if (!reviewed) + qualifiers.push( + `
NOT INDEPENDENTLY REVIEWED. No second model has adversarially reviewed these findings, so every finding and every band below is a draft, not a verdict. Run stpa verify after the review pass before circulating this page.
`, + ); +if (missingSections.length) + qualifiers.push( + `
THE ANALYSIS IS INCOMPLETE — ${missingSections.length} of ${SECTIONS.length} sections are absent: ${missingSections.map((x) => esc(x.label)).join(", ")}. The findings below are real, but nothing here can tell you whether they are the important ones.
`, + ); +if (scopeBreach) + qualifiers.push( + `
SCOPE NOT DELIVERED AS REQUESTED. A full-surface analysis was asked for; ${modeledCAs} of ${candidateCAs} control actions were modeled. The ${candidateCAs! - modeledCAs} unmodeled action(s) are outstanding work, not a declared boundary.
`, + ); +if (surfacePct !== null && surfacePct < 100) + qualifiers.push( + `
GRID COVERAGE IS NOT SYSTEM COVERAGE. ${modeledCAs} of ${candidateCAs} candidate control actions were modeled (${surfacePct.toFixed(0)}% of the surface). The ${pct.toFixed(1)}% below describes the analysis of that subset only.
`, + ); +else if (surfacePct === null) + qualifiers.push( + `
SURFACE COVERAGE NOT DECLARED. The model does not record how many candidate control actions this system has, so the coverage figure below cannot be read as system coverage.
`, + ); +if (openCells.length) + qualifiers.push(`
${openCells.length} cell(s) remain OPEN. An open cell is a hole in the analysis, not a pass.
`); +if (unbound.length) + qualifiers.push( + `
${unbound.length} finding(s) are UNBOUND — they cite no declared process-model variable or feedback channel, so they are not yet grounded in this system's control structure and do not count toward coverage.
`, + ); + +const stat = (v: string, k: string, cls = "") => `
${v}
${k}
`; +const statsHtml = `
+ ${stat(String(findings.length), "findings")} + ${stat(String(band1.length), "band 1 — fix first", band1.length ? "urgent" : "")} + ${stat(`${pct.toFixed(0)}%`, "analysis complete")} + ${stat(String(plan?.metrics?.rootCauses ?? plan?.clusters?.length ?? "—"), "root causes")} + ${stat(String(plan?.metrics?.wave1Size ?? (plan?.waves?.[0]?.items?.length ?? "—")), "in wave 1")} +
`; + +const stakeHtml = + losses.length || hazards.length + ? `

What is at stake

+ ${ + losses.length + ? `
    ${losses + .map((l) => `
  • ${esc(l.id)} ${l.title ? `${esc(l.title)}` : ""}${l.text ? ` — ${esc(l.text)}` : ""}
  • `) + .join("")}
` + : "" + } + ${ + hazards.length + ? `${[...hazards] + .sort((a, b) => (hazCount.get(b.id)?.n ?? 0) - (hazCount.get(a.id)?.n ?? 0)) + .slice(0, 6) + .map((h) => { + const hc = hazCount.get(h.id); + const worst = hc && hc.worst < 9 ? hc.worst : null; + return ``; + }) + .join("")}
Hazard — an unsafe system state, not an attackFindingsWorst
${esc(h.id)} ${esc(h.title || h.text)}${hc?.n ?? 0}${worst ? `${worst}` : "—"}
+

Findings column counted from ${esc(reachSource)}.

` + : "" + } +
` + : ""; + +// The findings a reader must not miss: wave 1 if the plan ran, else band-1/2, else +// the first few findings. Always says which rule produced the list, because "top +// findings" chosen by an undisclosed rule is how a summary quietly editorialises. +const wave1 = plan?.waves?.find((w) => w.wave === 1)?.items ?? []; +const picked = wave1.length + ? { items: wave1, why: "Wave 1 of the engineering plan — ranked by band, then by leverage." } + : { + items: findings + .filter((c) => (bandOf.get(c.id) ?? 9) <= 2) + .map((c) => itemOf.get(c.id) ?? { id: c.id, statement: c.statement, controller: c.controller, band: bandOf.get(c.id) }) + .slice(0, 5), + why: "Band 1 and band 2 findings. No engineering plan was generated — run stpa plan for a ranked order.", + }; + +const findingsHtml = picked.items.length + ? `

What to fix first

+

${picked.why}

+ ${picked.items + .slice(0, 6) + .map( + (it: any) => `
+
${it.band ? `${it.band}` : ""}${esc(it.id ?? "")}${it.controller ? `${esc(it.controller)}` : ""}${it.effort ? `${esc(it.effort)} effort` : ""}
+ ${it.statement ? `

${esc(it.statement)}

` : ""} + ${it.fix ? `

Fix — ${esc(it.fix)}

` : ""} + ${it.location ? `

Where — ${esc(it.location)}

` : ""} +
`, + ) + .join("")} +
` + : ""; + +const clusters = [...(plan?.clusters ?? [])].sort((a, b) => (b.leverage ?? 0) - (a.leverage ?? 0)).slice(0, 3); +const rootHtml = clusters.length + ? `

The few changes that close the most

+

Root causes ranked by leverage — how many findings each one closes. Fixing these ${clusters.length} resolves ${clusters.reduce((n, c) => n + (c.findings?.length ?? 0), 0)} of ${findings.length} findings.

+ ${clusters + .map( + (c) => `
+
${esc(c.id)}closes ${c.findings?.length ?? c.leverage ?? 0}${c.effort ? `${esc(c.effort)} effort` : ""}
+

${esc(c.name)}

+ ${c.summary ? `

${esc(c.summary)}

` : ""} + ${c.fix ? `

Fix — ${esc(c.fix)}

` : ""} +
`, + ) + .join("")} +
` + : ""; + +const html = ` + + +${esc(title)} — STPA Executive Summary +
+
+
STPA threat model — executive summary
+

${esc(title)}

+
${grid.totalCells} questions asked (${modeledCAs} control actions × 4 ways to be unsafe) · generated ${new Date().toISOString().slice(0, 10)}
+
+${qualifiers.join("\n")} +${statsHtml} +${stakeHtml} +${findingsHtml} +${rootHtml} +${ + reportHref + ? `
This is the summary. The full analysis — every unsafe control action, the loss scenarios behind each finding, the security constraints with runnable probes, and the complete engineering plan — is in ${esc(reportHref)}, beside this file.
` + : `
This is the summary. Run stpa report to generate the full analysis beside it.
` +} +
+

STPA (System-Theoretic Process Analysis, Leveson & Thomas) with its security adaptation STPA-Sec. It looks for losses that happen when every component works as designed — broken authorization, tenant leaks, stale permissions, bypass paths. It is not a scanner: no CVEs, no injection, no dependency audit.

+

Generated from ${esc(basename(dir))}/. Every figure on this page is computed from the analysis artifacts, not written by hand.

+
+
`; + +try { + writeFileSync(outPath, html); +} catch (e) { + die(`cannot write ${outPath}: ${(e as Error).message}`); +} +console.error( + `wrote ${outPath} (${findings.length} findings, ${band1.length} band-1, ${pct.toFixed(1)}% coverage` + + `${qualifiers.length ? `, ${qualifiers.length} qualifier(s) carried` : ""})`, +); diff --git a/Workflows/FullAnalysis.md b/Workflows/FullAnalysis.md index cdc2f13..86bfe1d 100644 --- a/Workflows/FullAnalysis.md +++ b/Workflows/FullAnalysis.md @@ -20,6 +20,7 @@ Runs Steps 1–4 plus constraint emission against a target. Use when the target | `05-constraints.md` | controller constraints + spec-ready negative assertions + Test Strategy rows | | `remediation.json` | analyst input: severity, reachability, effort, location, fix, probe per finding | | `06-remediation.md` / `.json` | the engineering plan — root causes by leverage, waves, metrics | +| **`SUMMARY.html`** | **the page you forward** — one sheet: at stake, fix first, top root causes. Carries every qualifier the report carries | | **`REPORT.html`** | **the default deliverable** — plan first, then the analysis. Self-contained, no network | ## Steps diff --git a/stpa b/stpa index 38e1882..d8589fe 100755 --- a/stpa +++ b/stpa @@ -8,6 +8,7 @@ * stpa grid render the grid as markdown * stpa plan [dir] findings -> ranked, sequenced backlog * stpa report [dir] the HTML deliverable (default final step) + * stpa summary [dir] the one-page executive summary beside it * stpa link [dir] clickable file:// link to the report * stpa merge [dir] --expect reconcile parallel plane results (gate) * stpa discover [dir] prove the inventory was multi-modal (gate, BEFORE init) @@ -39,6 +40,7 @@ const USAGE = `stpa — control-theoretic threat modeling (STPA) for codebases a stpa grid [-o md] render the grid as markdown stpa plan [dir] [--check] findings -> ranked, sequenced backlog stpa report [dir] [-o html] the HTML deliverable + stpa summary [dir] [-o html] one-page executive summary (forward THIS one) stpa link [dir] [--copy-to d] print a clickable file:// link to the report stpa merge [dir] --expect reconcile parallel plane results (gate) stpa discover [dir] prove the inventory was multi-modal (gate, BEFORE init) @@ -55,7 +57,7 @@ Typical first run: # ...write .stpa/model.json by hand from the candidates (this is the analysis) stpa init .stpa/model.json -o .stpa/grid.json # ...resolve every cell in grid.json - stpa run .stpa # -> .stpa/REPORT.html + stpa run .stpa # -> .stpa/SUMMARY.html + REPORT.html First time here? What this does, in three lines: @@ -201,6 +203,8 @@ switch (cmd) { process.exit(run("Prioritize.ts", rest)); case "report": process.exit(run("RenderReport.ts", rest)); + case "summary": + process.exit(run("RenderSummary.ts", rest)); case "verify": process.exit(run("VerifyGate.ts", rest)); case "link": @@ -258,6 +262,13 @@ switch (cmd) { console.error(` non-zero. Run the adversarial-review pass with a different model, then re-run.\n`); } const r = run("RenderReport.ts", [dir]); + // The summary renders from the same artifacts and carries the same qualifiers, + // so it cannot drift from the report. It is generated second and never gates: + // a failed summary must not suppress a report that rendered fine. + if (r === 0) { + const sm = run("RenderSummary.ts", [dir]); + if (sm !== 0) console.error(`\n!! summary did not render (exit ${sm}). The full report above is unaffected.\n`); + } // The deliverable's location must be impossible to miss AND clickable. ReportLink // emits a file:// URL (terminals hyperlink those; they do not hyperlink bare paths), // warns that .stpa is hidden from Finder, and — when the run is sandboxed — says so