diff --git a/CHANGELOG.md b/CHANGELOG.md index db0f67a..f5c064b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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. +- **Assets hit (Strata)** in the incident report (screen, PDF, Markdown): each asset with its state, as Strata last reported it. Shown only for incidents linked to Strata; existing templates get the section after *Key decisions*, and can hide or rename it like the others. +- 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..e197c81 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, the incident page shows the assets and their state, and the report gets an **Assets hit (Strata)** section (shown only for linked incidents; templates saved before it get it too, and can hide or rename it). A decision answered in Muster marks the assets in Strata as soon as someone allowed to change the incident has that IS open in Strata: nothing is lost in between. 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..53a2b96 100644 --- a/apps/api/src/repo.ts +++ b/apps/api/src/repo.ts @@ -2,7 +2,7 @@ import type { Store } from './db'; import { type Playbook, type PlaybookSummary, type Graph, type Incident, type Report, type Template, type User, type Version, - diff, EMPTY_GRAPH, stepCodes, currentStep, incidentTime + diff, EMPTY_GRAPH, stepCodes, currentStep, incidentTime, withAllSections } from '@muster/core'; /* ---------- Playbooks ---------- */ @@ -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 }; }); } @@ -106,12 +107,12 @@ export function listReports(s: Store) { /* ---------- Templates ---------- */ export function listTemplates(s: Store): Template[] { - return s.all('SELECT data, is_default FROM templates ORDER BY is_default DESC, position, id').map(r => ({ ...JSON.parse(r.data), isDefault: !!r.is_default })); + return s.all('SELECT data, is_default FROM templates ORDER BY is_default DESC, position, id').map(r => withAllSections({ ...JSON.parse(r.data), isDefault: !!r.is_default })); } export function getTemplate(s: Store, id: string): Template | undefined { const r = s.get('SELECT data, is_default FROM templates WHERE id = ?', id); - return r ? { ...JSON.parse(r.data), isDefault: !!r.is_default } : undefined; + return r ? withAllSections({ ...JSON.parse(r.data), isDefault: !!r.is_default }) : undefined; } export function defaultTemplate(s: Store): Template { 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..c421a28 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, withAllSections, 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); @@ -239,8 +245,8 @@ async function mutate(id: string, fn: (inc: Incident, by: string) => void, rev?: async function templates(): Promise { const [all, seq] = await Promise.all([getAll('templates'), getDoc('meta/seq')]); return (all as (Template & { position?: number })[]) - .map(t => ({ ...t, seq: typeof seq?.[t.id] === 'number' ? seq[t.id] : t.seq })) - .sort((a, b) => (Number(b.isDefault) - Number(a.isDefault)) || ((a.position || 0) - (b.position || 0))); + .sort((a, b) => (Number(b.isDefault) - Number(a.isDefault)) || ((a.position || 0) - (b.position || 0))) + .map(t => withAllSections({ ...t, seq: typeof seq?.[t.id] === 'number' ? seq[t.id] : t.seq })); } async function setSeq(id: string, value: number) { const ref = (await store()).doc('meta/seq'); 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')}
+