From 68957902d46c92625392c5a26b107cc440dfee5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:36:15 +0000 Subject: [PATCH 1/2] Link incidents to Strata: targets on steps, the assets hit, reports back An incident can be declared with a Strata IS, the assets hit and parameters (a CVE). Playbook steps get optional `targets` (selectors Strata resolves on its map) and decisions optional `effects` (an answer marks assets); both are validated with the draft, edited in the step inspector and written to the YAML export. PB-001, PB-003, PB-006 and PB-010 ship with them. POST /api/incidents/{id}/strata (analyst) takes what Strata reports back: the state of the assets and log entries, which land in the timeline. The incident page shows a Strata block. Both backends (server and Claude artifact) accept the link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ke7yEW5dhDYFVpV2cdgs87 Signed-off-by: Claude --- CHANGELOG.md | 5 +++ README.md | 10 +++++ apps/api/src/repo.ts | 3 +- apps/api/src/routes.ts | 17 +++++++- apps/web/src/backend/artifact.ts | 14 ++++-- apps/web/src/backend/http.ts | 7 ++- apps/web/src/styles.css | 10 +++++ apps/web/src/views/Editor.tsx | 7 +++ apps/web/src/views/IncidentRun.tsx | 19 ++++++++ apps/web/src/views/Incidents.tsx | 16 ++++++- packages/core/src/graph.ts | 3 ++ packages/core/src/incident.ts | 7 ++- packages/core/src/index.ts | 1 + packages/core/src/playbooks.ts | 36 ++++++++++++---- packages/core/src/strata.ts | 69 ++++++++++++++++++++++++++++++ packages/core/src/types.ts | 16 +++++++ tests/api.test.mjs | 33 ++++++++++++++ 17 files changed, 252 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/strata.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db0f67a..aaa105c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Muster. Versions follow [Semantic Versioning](https://sem ## [Unreleased] +### Added +- **Link to Strata**, the map of the information systems. An incident can be declared with a Strata IS, the assets hit and parameters (a CVE), under **Link to Strata** in the declare form. Strata, signed in with an analyst account, opens it on its map and runs it from there: answers and tasks go both ways. It reports back the state of the assets (compromised, suspected, contained) and what it did, which lands in the timeline as `Strata: …` entries, written by its account with the name of the person on the map. The incident page shows a **Strata** block with the assets and their state, and what the current step points at. +- **Strata targets on playbook steps**: selectors that say what a step acts on (`affected`, `flows:affected`, `vuln:{cve}`, `role:backup`, `type:dc`, `reach:…`, `consumers:…`, `business:…`, `contacts:…`), edited in the step inspector and checked when a draft is saved, and **effects** on decisions (an answer marks assets in Strata). PB-001, PB-003, PB-006 and PB-010 ship with them. The YAML export writes both. +- API: `strata` on `POST /api/incidents` and in the incident list, and `POST /api/incidents/{id}/strata` (analyst) for what Strata reports back. + ### Changed - Built with TypeScript 7 and esbuild 0.28. The app's code is the same; the next image is assembled by the new tools, checked by the browser walk-through and the image test. diff --git a/README.md b/README.md index a6d22fa..5c25097 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,16 @@ gh attestation verify oci://ghcr.io/solidgeeks/muster:1.0.1 --owner SolidGeeks It must print `Verification succeeded!`. If it fails, do not run the image, and report it (see [SECURITY.md](SECURITY.md)). +## Working with Strata + +[Strata](https://github.com/SolidGeeks/strata) maps your information systems. Linked to Muster, a response runs on the map: the assets hit, who to call, the flows to cut, what depends on them. + +1. In Muster, create an account for Strata with the **analyst** role (**Admin › Users**). +2. In Strata, set `MUSTER_URL` (how the Strata server reaches Muster), `MUSTER_USER` and `MUSTER_PASSWORD`. +3. Declare an incident and open **Link to Strata**: the Strata IS id, the assets hit (names or Strata ids) and, for the *Exploited critical vulnerability* playbook, the CVE. + +Strata lists the incident in the incident sheet of that IS. Opened there, it marks the assets, and each step shows what it acts on: the step's **Strata targets** (`affected`, `flows:affected`, `vuln:{cve}`, `role:backup`, `type:dc`, `reach:…`, `consumers:…`, `business:…`, `contacts:…`), set in the editor. Answering a decision in either app moves the response in both; a decision can also mark assets in Strata (`effects`, shipped on PB-003, PB-006 and PB-010). What Strata does (assets marked, flows cut) goes to the timeline, so the report keeps it, and the incident page shows the assets and their state. The shipped Ransomware, Compromised account, Malware and Exploited critical vulnerability playbooks already carry their targets; the YAML export includes them. + ## How it is built ``` diff --git a/apps/api/src/repo.ts b/apps/api/src/repo.ts index 026edb1..ea1adc8 100644 --- a/apps/api/src/repo.ts +++ b/apps/api/src/repo.ts @@ -78,7 +78,8 @@ export function listIncidents(s: Store) { id: inc.id, ref: inc.ref, name: inc.name, pbCode: inc.pbCode, pbName: inc.pbName, v: inc.v, mode: inc.mode, severity: inc.severity, status: inc.status, startedAt: inc.startedAt, elapsed, createdBy: inc.createdBy, reportId: inc.reportId || null, current: node && inc.status === 'active' ? { code: codes[node.id], title: node.title, role: node.role, overdue: !!(node.sla && stepElapsed > node.sla * 60000) } : null, - speed: inc.clock.speed, rev: r.rev + speed: inc.clock.speed, rev: r.rev, + strata: inc.strata ? { site: inc.strata.site, assets: inc.strata.assets, params: inc.strata.params } : null }; }); } diff --git a/apps/api/src/routes.ts b/apps/api/src/routes.ts index 543f615..cc68378 100644 --- a/apps/api/src/routes.ts +++ b/apps/api/src/routes.ts @@ -15,7 +15,7 @@ import { renderReportPdf } from '@muster/pdf'; import { type Role, type Graph, type Template, type Report, type Incident, type Severity, type SectionKey, uid, clone, validate, checkGraphShape, makeNode, makeEdge, layout, startIncident, advance, toggleTask, stopIncident, setSpeed, - reportData, autoSummary, formatId, toMarkdown, toYaml, SEVERITIES, SECTION_LABEL + reportData, autoSummary, formatId, toMarkdown, toYaml, SEVERITIES, SECTION_LABEL, cleanStrata, strataLog } from '@muster/core'; export interface Config { demo: boolean; secureCookies: boolean } @@ -229,8 +229,10 @@ export function buildRouter(store: Store, cfg: Config): Router { const mode = ctx.body.mode === 'exercise' ? 'exercise' : 'live'; const speed = mode === 'exercise' ? int(ctx.body.speed ?? 60, 'Speed', 1, 600) : 1; const severity = ctx.body.severity ? oneOf(ctx.body.severity, 'Severity', SEVERITIES) : p.severity; + let strata; + try { strata = cleanStrata(ctx.body.strata); } catch (e) { throw new HttpError(400, (e as Error).message); } const inc = store.tx(() => { - const i = startIncident({ id: uid('inc_'), ref: incidentRef(store), name, playbookId: p.id, pbCode: p.code, pbName: p.name, v: ver.v, graph: ver.graph, mode, speed, severity, by: u.name }); + const i = startIncident({ id: uid('inc_'), ref: incidentRef(store), name, playbookId: p.id, pbCode: p.code, pbName: p.name, v: ver.v, graph: ver.graph, mode, speed, severity, by: u.name, strata }); saveIncident(store, i, true); return i; }); @@ -270,6 +272,17 @@ export function buildRouter(store: Store, cfg: Config): Router { if (inc.status !== 'active') throw new HttpError(422, 'This exercise is over'); setSpeed(inc, int(ctx.body.speed, 'Speed', 1, 600)); })); + // Strata reports back: the state of the assets on its map (marks), their names, the IS it opened the incident + // on, and what it did there (log entries, kept in the timeline and the report) + r.post('/api/incidents/:id/strata', ctx => mutate(ctx, (inc, by) => { + try { inc.strata = cleanStrata({ site: ctx.body.site, marks: ctx.body.marks, names: ctx.body.names, assets: ctx.body.assets, params: ctx.body.params }, inc.strata); } + catch (e) { throw new HttpError(400, (e as Error).message); } + const log = Array.isArray(ctx.body.log) ? ctx.body.log.slice(0, 50) : []; + for (const l of log) { + const msg = str(l?.msg, 'Log entry', 500, { min: 1 }); + strataLog(inc, msg, (['info', 'ok', 'warn', 'crit', 'dec'] as const).find(k => k === l?.kind) || 'info', typeof l?.by === 'string' && l.by ? `${by} (${l.by.slice(0, 60)})` : by); + } + })); r.post('/api/incidents/:id/stop', ctx => mutate(ctx, (inc, by) => stopIncident(inc, by), 'incident.stop')); r.post('/api/incidents/:id/report', ctx => { const u = need(ctx, 'analyst'); diff --git a/apps/web/src/backend/artifact.ts b/apps/web/src/backend/artifact.ts index 9eb278c..9fdf4b1 100644 --- a/apps/web/src/backend/artifact.ts +++ b/apps/web/src/backend/artifact.ts @@ -16,7 +16,8 @@ import { type Playbook, type PlaybookSummary, type Incident, type Report, type Template, type Graph, type Severity, type Role, type Version, diff, validate, checkGraphShape, makeNode, makeEdge, layout, uid, clone, startIncident, advance as advanceInc, toggleTask, stopIncident, - setSpeed, reportData, autoSummary, formatId, toMarkdown, toYaml, defaultPlaybooks, defaultTemplates, currentStep, stepCodes, incidentTime, EMPTY_GRAPH + setSpeed, reportData, autoSummary, formatId, toMarkdown, toYaml, defaultPlaybooks, defaultTemplates, currentStep, stepCodes, incidentTime, EMPTY_GRAPH, + cleanStrata, type StrataLink } from '@muster/core'; import { renderReportPdf } from '@muster/pdf'; @@ -31,7 +32,9 @@ export interface IncidentRow { id: string; ref: string; name: string; pbCode: string; pbName: string; v: number; mode: 'live' | 'exercise'; severity: Severity; status: Incident['status']; startedAt: number; elapsed: number; createdBy: string; reportId: string | null; speed: number; current: { code: string; title: string; role: string; overdue: boolean } | null; rev: number; + strata: Pick | null; } +export type StrataInput = { site?: string; assets?: string[]; params?: Record } | null; export interface ReportRow { id: string; num: string; name: string; ref: string; created: string; severity: Severity; author: string; templateId: string; incidentId: string; mode: 'live' | 'exercise' } export interface AuditRow { id: number; at: string; user: string; action: string; target: string; detail: string } @@ -204,13 +207,16 @@ async function incidents(): Promise { return { id: inc.id, ref: inc.ref, name: inc.name, pbCode: inc.pbCode, pbName: inc.pbName, v: inc.v, mode: inc.mode, severity: inc.severity, status: inc.status, startedAt: inc.startedAt, elapsed, createdBy: inc.createdBy, reportId: inc.reportId || null, speed: inc.clock.speed, rev: inc.rev, - current: node && inc.status === 'active' ? { code: codes[node.id], title: node.title, role: node.role, overdue: !!(node.sla && stepElapsed > node.sla * 60000) } : null + current: node && inc.status === 'active' ? { code: codes[node.id], title: node.title, role: node.role, overdue: !!(node.sla && stepElapsed > node.sla * 60000) } : null, + strata: inc.strata ? { site: inc.strata.site, assets: inc.strata.assets, params: inc.strata.params } : null }; }); } async function incident(id: string): Promise { return { ...(await incDoc(id)), serverNow: Date.now() }; } -async function declare(b: { playbookId: string; v: number; name: string; mode: 'live' | 'exercise'; speed: number; severity: Severity }): Promise { +async function declare(b: { playbookId: string; v: number; name: string; mode: 'live' | 'exercise'; speed: number; severity: Severity; strata?: StrataInput }): Promise { if (!b.name?.trim()) throw new ApiError(400, 'Incident name is required'); + let strata; + try { strata = cleanStrata(b.strata); } catch (e) { throw new ApiError(400, (e as Error).message); } const ver = await getDoc(`playbooks/${b.playbookId}/versions/v${b.v}`); const p = await pbDoc(b.playbookId); if (!ver) throw new ApiError(422, 'Publish this playbook before running it'); @@ -218,7 +224,7 @@ async function declare(b: { playbookId: string; v: number; name: string; mode: ' const n = await bump('meta/counters', 'incident-' + y, 1); const inc = startIncident({ id: uid('inc_'), ref: `INC-${y}-${String(n).padStart(4, '0')}`, name: b.name.trim().slice(0, 160), playbookId: p.id, pbCode: p.code, pbName: p.name, - v: ver.v, graph: ver.graph, mode: b.mode, speed: b.mode === 'exercise' ? Math.min(600, Math.max(1, b.speed || 60)) : 1, severity: b.severity, by: await meId() + v: ver.v, graph: ver.graph, mode: b.mode, speed: b.mode === 'exercise' ? Math.min(600, Math.max(1, b.speed || 60)) : 1, severity: b.severity, by: await meId(), strata }); await setDoc(`incidents/${inc.id}`, { ...inc, rev: 1 } as any); return incident(inc.id); diff --git a/apps/web/src/backend/http.ts b/apps/web/src/backend/http.ts index 3a29766..56f2d9a 100644 --- a/apps/web/src/backend/http.ts +++ b/apps/web/src/backend/http.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import type { Playbook, PlaybookSummary, Incident, Report, Template, User, Graph, Severity, Role } from '@muster/core'; +import type { Playbook, PlaybookSummary, Incident, Report, Template, User, Graph, Severity, Role, StrataLink } from '@muster/core'; export class ApiError extends Error { constructor(public status: number, message: string, public data: any = {}) { super(message); } @@ -29,7 +29,10 @@ export interface IncidentRow { id: string; ref: string; name: string; pbCode: string; pbName: string; v: number; mode: 'live' | 'exercise'; severity: Severity; status: Incident['status']; startedAt: number; elapsed: number; createdBy: string; reportId: string | null; speed: number; current: { code: string; title: string; role: string; overdue: boolean } | null; rev: number; + strata: Pick | null; } +/** What the declare form sends to link an incident to Strata */ +export type StrataInput = { site?: string; assets?: string[]; params?: Record } | null; export interface ReportRow { id: string; num: string; name: string; ref: string; created: string; severity: Severity; author: string; templateId: string; incidentId: string; mode: 'live' | 'exercise' } export interface AuditRow { id: number; at: string; user: string; action: string; target: string; detail: string } @@ -52,7 +55,7 @@ export const api = { incidents: () => call('GET', '/api/incidents'), incident: (id: string) => call('GET', `/api/incidents/${id}`), - declare: (b: { playbookId: string; v: number; name: string; mode: 'live' | 'exercise'; speed: number; severity: Severity }) => call('POST', '/api/incidents', b), + declare: (b: { playbookId: string; v: number; name: string; mode: 'live' | 'exercise'; speed: number; severity: Severity; strata?: StrataInput }) => call('POST', '/api/incidents', b), advance: (id: string, edgeId: string, rev: number) => call('POST', `/api/incidents/${id}/advance`, { edgeId, rev }), task: (id: string, nodeId: string, index: number, done: boolean) => call('POST', `/api/incidents/${id}/tasks`, { nodeId, index, done }), notes: (id: string, notes: string) => call('POST', `/api/incidents/${id}/notes`, { notes }), diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 3da5d48..fec7dd3 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -625,3 +625,13 @@ pre.yaml{margin:0;background:var(--wash);border:1px solid var(--rule);border-rad .cp-demo .bar{width:4px;align-self:stretch;border-radius:2px} .cp-demo span{font:500 12px var(--f-mono)} .cp-demo em{font:650 13px var(--f-ui);font-style:normal;color:#121413} + +/* Link to Strata */ +.strata-link{border:1px solid var(--rule);border-radius:4px;padding:8px 12px;margin:4px 0 12px} +.strata-link summary{cursor:pointer;font-weight:600;font-size:13px} +.strata-link[open] summary{margin-bottom:8px} +.strata-b{padding:10px 16px;display:flex;flex-direction:column;gap:8px} +.strata-assets{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px} +.strata-assets li{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:13px} +.strata-assets b{font-family:var(--f-mono,monospace);font-weight:500} +.strata-link .row2{margin-top:8px} diff --git a/apps/web/src/views/Editor.tsx b/apps/web/src/views/Editor.tsx index a8f3897..ccf19a9 100644 --- a/apps/web/src/views/Editor.tsx +++ b/apps/web/src/views/Editor.tsx @@ -378,6 +378,13 @@ function Inspector({ pb, setPb, g, sel, setSel, readOnly, val, updateNode, commi {!ro && } )} + {!term && ( +

Strata targets

{plural((n.targets || []).length, 'selector')}
+