From c9803ee370a08b2f0e695bc19591608aee4156b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 23:03:40 +0000 Subject: [PATCH 1/6] Incident mode with Muster, and anonymised AI prompts per asset Mark assets compromised, suspected or contained, from their sheet or from a Muster playbook decision. The incident sheet lists who to call, the flows to cut (ranked, with the firewall where to cut each), what depends on the assets hit and what an attacker reaches next, plus a timeline. The 3D model and the 2D diagram ring the assets hit and draw the flows to cut. A Muster incident opens on the map; each playbook step points at it through `targets` selectors, and decisions can mark assets. Each asset sheet gets anonymised AI prompts: major risks, and a guide that has a public AI write response playbooks in muster/v1 YAML. Names, addresses, contacts and the IS name become placeholders, a leak check runs on the text, and pasted answers get the real names back locally. Server: GET/PUT /api/incidents (contributors and up, no proposal), kept in incidents.json with an `incidents` live event. `npm run build:online` writes the whole interface as one page that runs without a server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ke7yEW5dhDYFVpV2cdgs87 Signed-off-by: Claude --- .gitignore | 1 + CHANGELOG.md | 13 ++ README.md | 3 + package.json | 5 +- public/app.css | 83 +++++++++ public/app.js | 283 +++++++++++++++++++++++++++- public/incident.js | 390 +++++++++++++++++++++++++++++++++++++++ public/index.html | 7 + scripts/build-online.mjs | 53 ++++++ server/server.js | 36 +++- tests/api.test.mjs | 20 ++ tests/e2e.mjs | 42 +++++ tests/incident.test.mjs | 124 +++++++++++++ 13 files changed, 1050 insertions(+), 10 deletions(-) create mode 100644 public/incident.js create mode 100644 scripts/build-online.mjs create mode 100644 tests/incident.test.mjs diff --git a/.gitignore b/.gitignore index 883d48d..a9a07b5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ public/vendor/ data/ result result-* +dist-online/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 412a12a..b2251f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to Strata. Versions follow [Semantic Versioning](https://semver.org/). +## [Unreleased] +### Added +- **Incident mode, with Muster** (*Incident* in the controls of an IS). Mark assets compromised, suspected or contained, from their sheet or from a playbook decision. The incident sheet then shows: + - **who to call**: the contacts of the assets hit, on-call and 24/7 first, the owners of the business services they run, the IS contacts; + - **which flows to cut**: ranked (the Internet first, then flows that reach critical assets or carry administration protocols), each with the firewall where to cut it and the business services the cut stops; an isolated (contained) host counts its flows as cut; + - **what depends on them**: business services hit, the assets that call them, the assets an attacker reaches next along the flows that leave them; + - a timeline of every mark, cut and decision. + The 3D model and the 2D diagram dim the rest, ring the assets hit and draw the flows to cut in red, the cut ones in grey. A red band in the title block keeps the incident in view. +- **Muster incidents open on the map.** A Muster incident carries Strata asset ids or parameters (a CVE); its playbook runs in the incident sheet, and each step points at the map through `targets` selectors (`affected`, `vuln:{cve}`, `role:backup`, `type:dc`, `flows:…`, `reach:…`, `consumers:…`, `business:…`, `contacts:…`). A decision can mark assets (`effects`). Ransomware (PB-001) and Exploited critical vulnerability (PB-010) are included. Until Muster is connected to a Strata server, the sheet offers two example incidents built from the map. +- **Anonymised AI prompts per asset** (*AI prompts* on an asset sheet): one prompt for the major risks of the asset, one guide that has the AI list plausible incidents and write their response playbooks in Muster's `muster/v1` YAML, with `targets`. Names, addresses, subnets, contacts and the IS name are replaced by placeholders (`ASSET-T`, `ASSET-01`, `SEG-1`, `SVC-B1`); a leak check lists anything identifying left in the text, with your own terms to never send; the answer pasted back gets your names again, in the page only. French or English. +- **API**: `GET /api/incidents`, `PUT /api/incidents/{IS id}` (contributors and up, no proposal), and an `incidents` event on `/api/events`. Kept in `incidents.json`, outside the map and its history. +- **One-page build**: `npm run build:online` writes `dist-online/strata.html`, the whole interface without a server (map kept in the browser, three.js from jsDelivr). + ## [0.9.0] - 2026-10-26 ### Added - **API reference** ([`docs/api.html`](docs/api.html), on the site under *API*): every route with its role, request and answer, authentication, errors, the event stream, the export format, the module API. diff --git a/README.md b/README.md index 0259ca9..2534be6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Connectors are now **modules**: a folder with a manifest (`strata-module.json`: - **Attack paths** (*Analysis*): which critical assets an attacker could reach from the Internet, every path to each one along the allowed flows, most likely first, and **where to cut** (the assets to harden and the equipment whose rules to tighten, that every path to a critical asset goes through). Hover a path to pick it out on the model, in 3D or in 2D. - **Blast radius**: pick an asset (in the panel, or *Blast radius* on its sheet) and see everything an attacker holding it could reach, the most likely way to each asset, and the business services hit. - A step counts for the highest of: the vulnerabilities of the asset taken over (as for the risk score), 50% on a remote administration port (SSH, RDP, SMB, WinRM, VNC, Telnet, RPC), 10% on any other service; a path for the product of its steps. Both lists export as CSV. +- **Incident mode, with Muster** (*Incident* in the controls of an IS): mark assets compromised, suspected or contained; the incident sheet lists who to call, the flows to cut and the firewall where to cut each, the business services and assets that depend on them, what the attacker reaches next. A Muster incident opens on the map and its playbook decisions point at the assets. Each asset also has anonymised *AI prompts*: its major risks, and a guide for an AI to write the response playbooks of its plausible incidents in Muster's format. - **Architecture file**: one document per IS (*Analysis › Architecture file*, or the IS summary), in the shape audits and security accreditation expect: cover and sign-off table, summary, scope, network diagram, zones and VLANs, applications, asset inventory, flow matrix, the rules each firewall needs, exposure, vulnerabilities and remediation order, attack paths, plans of the exposed and critical assets, incident contacts, change history and method. *Download* saves it as one HTML file, with no outside resource, that prints to A4 PDF from the browser. - **Nix and NixOS**: the repository is a flake, with a package and a hardened `services.strata` module (section 6, *Nix and NixOS*). - **Failed sign-ins are limited** per account and per client address (`429`), and passwords can be read from files (`EDIT_PASS_FILE`, `VIEW_PASS_FILE`): section 3. @@ -458,6 +459,8 @@ Every route needs the same credentials as the interface (a local account with Ba | `DELETE /api/assets/{id}` | Deletes an asset | | `PUT /api/wan` | Replaces the links between IS: `{"links": [...]}` | | `POST /api/import` | Replaces the whole map: `{si, assets, wan, services}` | +| `GET /api/incidents` | Incident mode of every IS: `{incidents: {: {on, marks, cuts, run, log}}}` | +| `PUT /api/incidents/{IS id}` | Replaces the incident of an IS (`contributor` and up, no proposal): marks are `compromised`, `suspected` or `contained` | | `PUT /api/services` | Replaces the common catalogue of Internet services: `{"services": [...]}` | | `GET /api/history` | Latest changes (`?before=REV&limit=N`); `GET /api/history/{rev}`: the before/after details | | `GET /api/state?at=REV` | The map as it was right after version REV | diff --git a/package.json b/package.json index 7b3e6be..867cdf7 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,10 @@ "scripts": { "vendor": "node scripts/vendor.mjs", "start": "node server/server.js", - "test": "node --test tests/api.test.mjs tests/analysis.test.mjs tests/secrets.test.mjs tests/signin.test.mjs tests/hardening.test.mjs tests/readonly.test.mjs tests/modules.test.mjs tests/sso.test.mjs", + "test": "node --test tests/api.test.mjs tests/analysis.test.mjs tests/incident.test.mjs tests/secrets.test.mjs tests/signin.test.mjs tests/hardening.test.mjs tests/readonly.test.mjs tests/modules.test.mjs tests/sso.test.mjs", "test:e2e": "node --test tests/e2e.mjs", - "setup": "npm ci && npm run vendor" + "setup": "npm ci && npm run vendor", + "build:online": "node scripts/build-online.mjs" }, "dependencies": { "@fontsource/archivo": "^5.0.0", diff --git a/public/app.css b/public/app.css index 03dbeb2..3c9b40f 100644 --- a/public/app.css +++ b/public/app.css @@ -477,3 +477,86 @@ button.chip:hover{border-color:var(--accent);color:var(--accent)} .arch-view{flex:1;overflow:auto;background:#e9ece9;border:1px solid var(--line);border-radius:4px;padding:16px 0} .archwait{font-family:var(--f-mono);font-size:12px;color:#3d4744;text-align:center;margin:40px 0} @media (max-width:760px){#archf{padding:8px}.arch-view{padding:0}} + +/* ---- Incident mode ---- */ +#det.wide{width:430px} +@media (max-width:760px){#det.wide{width:auto}} +#cart .incband{grid-column:1/-1;display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;background:var(--crit);color:#fff;border-bottom:0;font-family:var(--f-mono);font-size:11.5px} +#cart .incband b{font-family:var(--f-display);font-weight:700;letter-spacing:.1em;text-transform:uppercase;font-size:12px} +#cart .incband span{flex:1;min-width:0} +#cart .incband button{background:transparent;color:#fff;border:1px solid rgba(255,255,255,.7);border-radius:3px;padding:2px 8px;font:inherit;cursor:pointer} +#incBtn.on{background:var(--crit);color:#fff;border-color:var(--crit);box-shadow:none} +#incBtn.on .mono{color:#fff} +.site .incb{grid-column:2;grid-row:1;font-family:var(--f-display);font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#fff;background:var(--crit);border-radius:2px;padding:1px 5px;margin-right:6px;justify-self:end} +.site .incb+.c{grid-row:1;grid-column:2;display:none} +.inc-acts{align-items:center} +.inc-acts .cap{width:100%} +.inc-acts .btn{flex:1 1 0;justify-content:center} +.inc-acts .btn.inc-compromised[aria-pressed="true"]{background:var(--crit);color:#fff;border-color:var(--crit);box-shadow:none} +.inc-acts .btn.inc-suspected[aria-pressed="true"]{background:var(--high);color:#fff;border-color:var(--high);box-shadow:none} +.inc-acts .btn.inc-contained[aria-pressed="true"]{background:var(--low);color:#fff;border-color:var(--low);box-shadow:none} +.inc-state{margin:10px 0 0} +.inc-chip{display:inline-flex;align-items:center;gap:5px;cursor:pointer;color:var(--fg);font-size:11.5px;padding:2px 7px} +.inc-chip:hover{border-color:var(--accent)} +.inc-chip i{width:8px;height:8px;border-radius:2px;background:var(--none)} +.inc-chip.compromised i{background:var(--crit)} .inc-chip.suspected i{background:var(--high)} .inc-chip.contained i{background:var(--low)} +.inc-why{display:block;color:var(--muted);font-size:11.5px} +.inc-why b{color:var(--fg);font-weight:600} +.inc-flow{display:flex;flex-direction:column;gap:3px;padding:8px 10px;background:var(--panel-2);border-radius:3px} +.inc-flow.cut{opacity:.6} +.inc-flow.cut .inc-fh b{text-decoration:line-through} +.inc-fh{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px} +.inc-fh b{font-family:var(--f-mono);font-size:12px;font-weight:500;overflow-wrap:anywhere;min-width:0} +.inc-fh .meta{font-family:var(--f-mono);font-size:11px;color:var(--muted)} +.inc-p{font-family:var(--f-mono);font-size:10.5px;font-weight:600;color:#fff;border-radius:2px;padding:0 5px} +.inc-p.p1{background:var(--crit)} .inc-p.p2{background:var(--high)} .inc-p.p3{background:var(--none)} +.inc-fa{display:flex;justify-content:flex-end} +.inc-fa .btn{width:auto} +.inc-run{border:1px solid var(--line);border-left:4px solid var(--crit);border-radius:3px;padding:9px 11px;background:var(--panel-2);display:flex;flex-direction:column;gap:6px} +.inc-rh{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;color:var(--muted);font-size:11px} +.inc-rn{font-family:var(--f-display);font-size:17px;font-weight:700;letter-spacing:.02em} +.inc-run .actions{margin-top:2px} +.inc-steps{list-style:none;margin:0;padding:0} +.inc-step{display:grid;grid-template-columns:22px minmax(0,1fr);gap:9px;padding:5px 0;position:relative} +.inc-step::before{content:"";position:absolute;left:10px;top:26px;bottom:-5px;width:2px;background:var(--line)} +.inc-step:last-child::before{display:none} +.inc-step .dot{width:22px;height:22px;border-radius:50%;border:2px solid var(--line);background:var(--panel);display:grid;place-items:center;font-family:var(--f-mono);font-size:10.5px;z-index:1;box-sizing:border-box} +.inc-step.decision .dot{border-radius:4px} +.inc-step.done .dot{background:var(--low);border-color:var(--low);color:#fff} +.inc-step.cur .dot{background:var(--crit);border-color:var(--crit);color:#fff} +.inc-step.ahead{opacity:.6} +.inc-sb{min-width:0;display:flex;flex-direction:column;gap:1px} +.inc-sb>b{font-weight:600} +.inc-sb>small{font-family:var(--f-mono);font-size:10.5px;color:var(--muted)} +.inc-ans{font-family:var(--f-mono);font-size:11.5px;color:var(--accent)} +.inc-cur{margin-top:6px;border:1px solid var(--crit);border-radius:3px;padding:9px;display:flex;flex-direction:column;gap:7px;background:var(--panel)} +.inc-chk{display:flex;gap:7px;align-items:flex-start;font-size:12.5px} +.inc-chk input{margin-top:2px} +.inc-tgs{display:flex;flex-direction:column;gap:8px;border-top:1px dashed var(--line);padding-top:8px} +.inc-tg{display:flex;flex-direction:column;gap:4px} +.inc-tg .btn{width:auto;align-self:flex-start} +.inc-sel{font-family:var(--f-mono);font-size:10.5px;color:var(--muted)} +.inc-sel b{color:var(--accent);font-weight:500} +.inc-log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:3px;font-size:12px;max-height:220px;overflow:auto} +.inc-log li{display:grid;grid-template-columns:42px minmax(0,1fr);gap:6px} +.inc-log .mono{color:var(--muted);font-size:11px} +.inc-log small{color:var(--muted)} +.inc-log .crit span:last-child{color:var(--crit)} .inc-log .ok span:last-child{color:var(--low)} .inc-log .dec span:last-child{color:var(--accent)} .inc-log .sync span:last-child{color:var(--x-net)} +.inc-contract{margin-top:10px} +.inc-contract summary{cursor:pointer;font-size:11.5px;color:var(--muted)} +.inc-contract pre,.ai-p pre{font-family:var(--f-mono);font-size:11px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;background:var(--panel-2);border-radius:3px;padding:8px;margin:6px 0 0;max-height:280px;overflow:auto} +.ai-opts{display:flex;flex-direction:column;gap:5px;font-size:12.5px;margin-bottom:10px} +.ai-opts label{display:flex;gap:7px;align-items:flex-start} +.ai-opts input{margin-top:2px} +#det .f+.f,#det .f+.seg{margin-top:8px} +.ai-leak{font-size:12px;border-radius:3px;padding:6px 9px;margin:10px 0 0} +.ai-leak.ok{background:color-mix(in srgb,var(--low) 14%,transparent);color:var(--low)} +.ai-leak.bad{background:color-mix(in srgb,var(--crit) 14%,transparent);color:var(--crit)} +.ai-p{border:1px solid var(--line);border-radius:3px} +.ai-ph{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid var(--line)} +.ai-ph b{flex:1;font-size:12.5px} +.ai-ph .mono{font-size:10.5px;color:var(--muted)} +.ai-ph .btn{width:auto} +.ai-p pre{margin:0;border-radius:0} +.ai-map{border-collapse:collapse;width:100%;font-size:12px} +.ai-map td{border-top:1px solid var(--line);padding:3px 6px} diff --git a/public/app.js b/public/app.js index 0a37aa9..9a06b13 100644 --- a/public/app.js +++ b/public/app.js @@ -26,7 +26,7 @@ const F=(id,title,cvss,sev)=>Object.assign({id,title,cvss:cvss==null?null:cvss,s const TYPE_LABEL={server:'Server',dc:'Domain controller',db:'Database',hypervisor:'Hypervisor / storage',firewall:'Firewall',gateway:'VPN gateway',router:'Router / L3 core',switch:'Switch / load balancer',workstation:'Workstation',printer:'Printer',phone:'IP phone',ap:'Wi-Fi access point',camera:'IP camera',plc:'PLC',iot:'IoT device'}; // Interconnection equipment: flows can be routed through these const INTERCO=new Set(['firewall','router','switch','gateway']); -const SA=StrataAnalysis; +const SA=StrataAnalysis,IR=StrataIncident; // Colours of the "Per flow" mode: --f0 to --f9 in app.css const FLOW_PAL=10; // Internet endpoints: the Internet as a whole, or a named service (ESET updates, Ubuntu mirrors…) as "svc:" @@ -386,6 +386,7 @@ const Store={ es.addEventListener('change',e=>{try{const {rev}=JSON.parse(e.data);if(rev!==this.rev&&!this.bulk)this.pull();}catch(err){}}); es.addEventListener('proposals',()=>{onProposalsChanged();}); es.addEventListener('connectors',()=>{onConnectorsChanged();}); + es.addEventListener('incidents',()=>{onIncidentsChanged();}); let first=true;es.addEventListener('open',()=>{if(!first)this.pull();first=false;}); }, async api(method,url,body){ @@ -620,8 +621,9 @@ function renderCart(){ const s=SITE.stats; c.innerHTML=`

IS ${esc(SITE.name)}

${esc(SITE.kind||'Information system')}
Sheet${SITE.planche}
Zones ${ZONES.length}Scale 1 plate = 1 VLANStorage ${storeBadge()}
- ${nums([[s.assets,'assets'],[s.xNet,'internet-facing','net'],[s.crit,'critical vulns','alert'],[s.risk,'risky flows','alert']])}`; + ${nums([[s.assets,'assets'],[s.xNet,'internet-facing','net'],[s.crit,'critical vulns','alert'],[s.risk,'risky flows','alert']])}${incBand()}`; } + document.body.classList.toggle('incident-on',incOn()); placeCtl(); } function placeCtl(){ @@ -631,7 +633,7 @@ function placeCtl(){ } addEventListener('resize',placeCtl); function renderSiteList(){ - $('#siteList').innerHTML=SITES.length?SITES.map(s=>``).join(''):'

No information system yet.

'; + $('#siteList').innerHTML=SITES.length?SITES.map(s=>``).join(''):'

No information system yet.

'; } function renderVlanList(){ // By zone: listed left to right as on the level. One VLAN per floor: listed top to bottom. @@ -671,6 +673,8 @@ function syncUI(){ $('#portsBtn').setAttribute('aria-pressed',String(state.panel==='ports')); $('#riskBtn').setAttribute('aria-pressed',String(state.panel==='risk')); $('#atkBtn').setAttribute('aria-pressed',String(state.panel==='attack')); + $('#incBtn').setAttribute('aria-pressed',String(incOn()));$('#incBtn').classList.toggle('on',incOn()); + $('#incCount').textContent=incOn()?String(incView().aff.length):''; renderLegend();renderDirNote(); } function renderEmpty(){ @@ -968,6 +972,9 @@ function buildScene(){ if(DIFF&&DIFF.A[a.id]){const r=new THREE.Mesh(new THREE.RingGeometry(1.2,1.42,40).rotateX(-Math.PI/2),new THREE.MeshBasicMaterial({color:new THREE.Color(css(DIFF_COL[DIFF.A[a.id]])),depthWrite:false}));r.position.y=0.05;ag.add(r);} const top=Math.max(h,pinTop); const lift=compact&&sp.ci%2?0.62:0; + const im=incMark(a.id); + if(im){const ic=new THREE.Color(css(INC_COL[im]));const r=new THREE.Mesh(new THREE.RingGeometry(1.3,1.7,40).rotateX(-Math.PI/2),new THREE.MeshBasicMaterial({color:ic,depthWrite:false,transparent:true,opacity:im==='suspected'?0.75:0.95}));r.position.y=0.07;ag.add(r); + const it=tagLabel(INC_LBL[im].toUpperCase(),css(INC_COL[im]),0.5,600,css(INC_COL[im]));it.position.set(0,top+(a.ports.length?2.0:1.4)+lift,0);ag.add(it);} const label=tagLabel(a.name,a.sev==='none'?null:sevColor(a.sev));label.position.set(0,top+0.7+lift,0);ag.add(label); let portLabel=null; if(a.ports.length){portLabel=tagLabel(portText(a),expColor(a.exp),0.5,600,expColor(a.exp));portLabel.position.set(0,top+1.35+lift,0);ag.add(portLabel);} @@ -988,7 +995,7 @@ function buildScene(){ const lb=tagLabel(x.name,css('--x-net'),0.5);lb.position.y=1.15;g.add(lb);scene.add(g);byId[x.id]={id:x.id,name:x.name,group:g,h:0,isInternet:true,isSvc:true};return g;}); } function anchor(id){const a=byId[id],p=new THREE.Vector3();a.group.getWorldPosition(p);if(!a.isInternet)p.y+=a.h*0.55;else if(!a.isSvc)p.y-=0.6;return p;} -function flowColorVar(f){if(DIFF&&DIFF.F[f.id])return DIFF_COL[DIFF.F[f.id]];return state.color==='exp'?EXP[f.level].css:state.color==='flow'?'--f'+(SITE.fcol[f.id]||0):(f.risk?'--crit':'--thread');} +function flowColorVar(f){if(DIFF&&DIFF.F[f.id])return DIFF_COL[DIFF.F[f.id]];const iv=incView();if(iv&&iv.flows.has(f.id))return iv.flows.get(f.id).cut?'--none':'--crit';return state.color==='exp'?EXP[f.level].css:state.color==='flow'?'--f'+(SITE.fcol[f.id]||0):(f.risk?'--crit':'--thread');} // A flow is drawn hop by hop: source -> each interconnection device -> destination, // with an arrowhead at the end of every hop so the direction and the origin read at a glance const coneGeo=new THREE.ConeGeometry(0.2,0.55,10).rotateX(Math.PI/2),pickMat=new THREE.MeshBasicMaterial({colorWrite:false,depthWrite:false}); @@ -1053,7 +1060,9 @@ function computeVis(){ const dirPeers=sel&&state.dir!=='all'?new Set(FLOWS.filter(f=>dirOk(f,sel)).flatMap(f=>f.hops)):null; const aOp={},fv={},bdep=state.biz&&SITE.impact.byBusiness[state.biz]?SITE.impact.byBusiness[state.biz].assets:null; const atk=state.panel==='attack'?attackData():null,ahl=atk&&state.attackHl?atk.hl.get(state.attackHl):null,aon=atk&&(ahl||atk); + const iv=incView(),idim=iv&&iv.dim; ASSETS.forEach(a=>{let op=1; + if(idim&&!iv.focus.has(a.id))op=0.16; if(SEV[a.sev].rank`${SEV[k].label}`; const xtag=k=>`${EXP[k].label}`; -function openDet(html,panel){det.innerHTML=html;det.hidden=false;det.scrollTop=0;document.body.classList.add('has-det');const wasAtk=state.panel==='attack'&&panel!=='attack';state.panel=panel;renderEmpty();syncUI();placeLens();if(wasAtk&&state.view==='site')refresh();} +function openDet(html,panel){det.classList.toggle('wide',panel==='incident'||panel==='ai');det.innerHTML=html;det.hidden=false;det.scrollTop=0;document.body.classList.add('has-det');const wasAtk=state.panel==='attack'&&panel!=='attack';state.panel=panel;renderEmpty();syncUI();placeLens();if(wasAtk&&state.view==='site')refresh();} function closeDet(){const wasFlow=state.panel==='flow'||state.panel==='attack';det.hidden=true;document.body.classList.remove('has-det');state.panel=null;state.attackHl=null;renderEmpty();syncUI();placeLens();if(wasFlow&&state.view==='site')refresh();} function contactsHtml(list){ return `
    ${list.map(c=>`
  • ${esc(c.name||'Contact')}${esc(c.role||'')}${c.dispo?' · '+esc(c.dispo):''}
    @@ -1162,7 +1172,8 @@ function showAsset(a){ openDet(`
    ${esc(TYPE_LABEL[a.type])}

    ${esc(a.name)}

    ${esc(a.ip||'No IP address')}

    -
    + ${incAssetHtml(a)} +
    IS
    ${esc(SITE.name)}
    VLAN
    ${esc(v.num)} · ${esc(v.name)}
    @@ -2413,6 +2424,7 @@ function flatSvgMarkup(G,V,forExport,pal){ o+=``; if(isSel)o+=``; if(DIFF&&DIFF.A[a.id])o+=`${DIFF.A[a.id]==='added'?'NEW':'CHANGED'}`; + const im=forExport?null:incMark(a.id);if(im)o+=`${INC_LBL[im].toUpperCase()}`; o+=``; if(sev)o+=``; if(a.exp!=='none'&&state.ports)o+=`IN ${a.ports.length}`; @@ -3197,6 +3209,261 @@ async function exportSite(s){ await saveFile(`strata_${fileStem(s.name)}_${today()}.json`,'application/json',data); } +/* ================= Incident mode ================= */ +// During an incident the map shows what is hit: assets marked compromised, suspected or contained, the flows to cut +// and where, who to call, what depends on them. A Muster incident opens here and its playbook steps point at the map. +// The state of each IS is kept apart from the map: it changes by the minute and needs no proposal. It lives in this +// browser (local mode) or on the server (api/incidents, contributors and up). +const INC_KEY='strata-incidents'; +const INC_COL={compromised:'--crit',suspected:'--high',contained:'--low'}; +const INC_LBL={compromised:'Compromised',suspected:'Suspected',contained:'Contained'}; +const Inc={all:{},ver:0,timers:{},busy:0, + get canWrite(){return Store.mode==='local'||!!(Store.me&&Store.me.canPropose);}, + async load(){ + let all={}; + if(Store.mode==='api'){try{const j=await Store.api('GET','api/incidents');all=j.incidents||{};}catch(e){}} + else{try{all=JSON.parse(localStorage.getItem(INC_KEY)||'{}')||{};}catch(e){}} + this.all={};Object.keys(all).forEach(k=>{this.all[k]=IR.clean(all[k]);});this.ver++; + }, + of(id){if(!Object.hasOwn(this.all,id))this.all[id]=IR.empty();return this.all[id];}, + changed(id){ + this.ver++; + if(Store.mode!=='api'){try{localStorage.setItem(INC_KEY,JSON.stringify(this.all));}catch(e){toast('This browser refused to save the incident (storage full or disabled).',true);}return;} + clearTimeout(this.timers[id]);this.busy++; + this.timers[id]=setTimeout(()=>Store.api('PUT','api/incidents/'+encodeURIComponent(id),this.all[id]) + .catch(e=>toast('The incident could not be saved: '+(e.message||'error')+'.',true)).finally(()=>{this.busy--;}),250); + } +}; +// Another person changed an incident (server mode): read them again, unless a change of ours is on its way +function onIncidentsChanged(){if(Inc.busy)return;Inc.load().then(()=>incRedraw(true));} +const curInc=()=>state.view==='site'&&SITE?Inc.of(SITE.id):null; +const incOn=()=>{const i=curInc();return !!(i&&i.on);}; +const incMark=id=>incOn()?Inc.of(SITE.id).marks[id]||null:null; +const hhmm=t=>{const d=new Date(t);return `${pad(d.getHours())}:${pad(d.getMinutes())}`;}; +function incLog(inc,msg,kind){inc.log.unshift({at:Date.now(),msg,kind:kind||'info',by:Store.user||''});} +// What the model and the diagram show, worked out once per change +let INCV=null; +function incView(){ + if(!incOn())return null; + const hl=state.incHl||null; + if(INCV&&INCV.site===SITE&&INCV.ver===Inc.ver&&INCV.hl===hl)return INCV; + const inc=Inc.of(SITE.id),aff=IR.affected(SITE,inc),reach=IR.reach(SITE,aff),cons=IR.consumers(SITE,aff),rows=IR.flowsToCut(SITE,inc,aff); + const focus=new Set([...aff,...Object.keys(reach),...cons,...(hl||[])]);rows.forEach(r=>r.f.via.forEach(v=>focus.add(v))); + return INCV={site:SITE,ver:Inc.ver,hl,inc,aff,reach,cons,rows,flows:new Map(rows.map(r=>[r.f.id,r])),focus,dim:aff.length>0||!!(hl&&hl.length)}; +} +// Marks change the model (rings, labels, flow colours): rebuild it, then the open sheet +function incRedraw(full){ + INCV=null; + if(state.view==='site'&&SITE){if(full){buildScene();layout();}else buildFlows();refresh();} + renderSiteList();renderCart();syncUI(); + if(state.panel==='incident')showIncident(true); + else if(state.panel==='asset'&&selAsset())showAsset(selAsset()); +} +function incSave(full){Inc.changed(SITE.id);incRedraw(full);} +function incSetMark(inc,id,st,quiet){ + const was=inc.marks[id]||null;if(was===(st||null))return; + if(st)inc.marks[id]=st;else delete inc.marks[id]; + if(!quiet)incLog(inc,st?`${byId[id].name} marked ${st}`:`${byId[id].name}: mark cleared`,st==='compromised'?'crit':st==='contained'?'ok':'info'); +} +function incMarkAsset(id,st){ + if(!Inc.canWrite){toast('Your role cannot change the incident.',true);return;} + const inc=Inc.of(SITE.id);if(st&&!inc.on){inc.on=true;incLog(inc,'Incident mode on','info');} + incSetMark(inc,id,st);incSave(true); +} +function incToggle(on){ + if(!Inc.canWrite){toast('Your role cannot change the incident.',true);return;} + const inc=Inc.of(SITE.id);if(inc.on===on)return;inc.on=on;incLog(inc,on?'Incident mode on':'Incident mode off','info'); + incSave(true);if(on)showIncident();else if(state.panel==='incident')closeDet(); +} +// Shows an asset on the model without leaving the sheet: the rest dims, the view flies to it +function incLocate(id){ + const a=byId[id];if(!a)return; + if(state.hidden.has(a.vlan)){state.hidden.delete(a.vlan);const cb=$('#vl-'+a.vlan);if(cb)cb.checked=true;} + state.incHl=[id];INCV=null;refresh(); + if(state.flat)centerFlatOn(a);else if(a.group){const p=new THREE.Vector3();a.group.getWorldPosition(p);p.y+=a.h/2;flyTo(p);} + clearTimeout(incLocate.t);incLocate.t=setTimeout(()=>{state.incHl=null;INCV=null;refresh();},4000); +} +const incChip=id=>{const m=Inc.of(SITE.id).marks[id];return ``;}; +function incCallHtml(list){ + return list.length?`
      ${list.map(c=>`
    • ${esc(c.name||'Contact')}${esc(c.role||'')}${c.dispo?' · '+esc(c.dispo):''}
      + For ${esc(c.why.join(', '))} + ${c.phone?`
      ${esc(c.phone)}
      `:''} + ${c.email?`
      ${esc(c.email)}
      `:''} + ${!c.phone&&!c.email?'No number recorded: look them up in the directory.':''}
    • `).join('')}
    `:'

    Nobody recorded.

    '; +} +function incFlowsHtml(rows,compact){ + const w=Inc.canWrite; + return rows.length?`
      ${rows.map(r=>`
    • P${r.prio}${esc(r.srcName)} → ${esc(r.dstName)}${esc(r.f.port)}
      + ${r.why.length?esc(r.why.join(' · ')):'Internal flow'} + ${compact?'':`Cut at ${esc(r.at.label)}${r.breaks.length?` · stops ${esc(r.breaks.join(', '))}`:''}`} + ${r.iso?'Host isolated':w?``:r.cut?'Cut':''}
    • `).join('')}
    `:'

    No flow.

    '; +} +function incTargetHtml(sel,s,run){ + const r=IR.resolve(SITE,Inc.of(SITE.id),sel,run.params),head=`targets ${esc(sel.replace(/\{(\w+)\}/g,(m,k)=>run.params[k]!=null?run.params[k]:m))} · ${r.items.length} on the map`; + if(r.kind==='assets'){const more=s.act==='isolate'&&Inc.canWrite&&r.items.some(id=>Inc.of(SITE.id).marks[id]!=='contained')?``:''; + return `
    ${head}
    ${r.items.map(incChip).join('')||'None.'}
    ${more}
    `;} + if(r.kind==='flows')return `
    ${head}${incFlowsHtml(r.items.filter(x=>x.prio<3||x.cut).slice(0,6),true)}
    `; + if(r.kind==='business')return `
    ${head}
      ${r.items.map(b=>`
    • ${critTag(b.crit)}
    • `).join('')||'
    • None.
    • '}
    `; + return `
    ${head}${incCallHtml(r.items.slice(0,4))}
    `; +} +function incRunHtml(inc){ + const run=inc.run,pb=IR.PLAYBOOKS[run.pb];if(!pb)return `

    Playbook ${esc(run.pb)} is unknown here.

    `; + const R=IR.route(pb,run),w=Inc.canWrite; + return `
      ${R.map((sid,i)=>{const s=IR.step(pb,sid),cur=sid===run.cur&&!run.closed,done=i=run.path.length; + let h=`
    1. ${done?'✓':i+1}
      ${esc(s.title)}${esc(s.phase)}${s.role?' · '+esc(s.role):''}${s.sla?` · SLA ${s.sla} min`:''}${run.answers[sid]?`→ ${esc(run.answers[sid])}`:''}`; + if(cur){ + const out=IR.nextOf(pb,sid); + h+=`
      ${s.desc?`

      ${esc(s.desc)}

      `:''} + ${(s.checklist||[]).map((t,j)=>``).join('')} + ${s.targets.length?`
      ${s.targets.map(t=>incTargetHtml(t,s,run)).join('')}
      `:''} + ${w&&out.length?`
      ${s.type==='decision'?out.map(e=>``).join(''):``}
      `:''}
      `;} + return h+'
    2. ';}).join('')}
    `; +} +// What this integration sends back to Muster: the log entries and the assets linked to the incident +function incContract(inc){ + const run=inc.run,pb=IR.PLAYBOOKS[run.pb],s=pb&&IR.step(pb,run.cur); + return {'Muster → Strata':{ref:run.ref,playbook:`${run.pb} v${run.v}`,mode:run.mode,assets:run.assets,params:run.params,step:s?{id:s.id,title:s.title,targets:s.targets}:null}, + 'Strata → Muster':{log:inc.log.slice(0,4).map(l=>({at:new Date(l.at).toISOString(),msg:l.msg,by:l.by})),assets:IR.affected(SITE,inc).map(id=>({id,name:nameOf(id),state:inc.marks[id]}))}}; +} +function showIncident(keep){ + if(!SITE)return; + const inc=Inc.of(SITE.id),iv=incView(),run=inc.run,w=Inc.canWrite,top=keep?det.scrollTop:0; + const aff=iv?iv.aff:IR.affected(SITE,inc),rows=iv?iv.rows:[],open=rows.filter(r=>!r.cut).length; + let body=''; + if(!inc.on)body+=`

    Incident mode is off for IS ${esc(SITE.name)}.

    ${w?'
    ':'

    Your role can read the incident, not change it.

    '}`; + body+=`

    Muster

    `; + if(!run){ + const feed=IR.demoFeed(SITE); + body+=`

    No Muster incident is linked to this IS. When an analyst declares one in Muster with assets of this IS, it opens here and its playbook points at the model.

    + ${feed.length?`
      ${feed.map((sc,i)=>`
    • ${esc(sc.ref)} · ${esc(sc.name)}${esc(sc.pb)} ${esc(IR.PLAYBOOKS[sc.pb].name)} · ${esc(sc.mode)} · ${esc(sc.note)}${w?``:''}
    • `).join('')}
    +

    Muster is not connected to this Strata: these two incidents are examples built from this map.

    `:''}`; + }else{ + const pb=IR.PLAYBOOKS[run.pb],mins=Math.floor((Date.now()-run.startedAt)/60000); + body+=`
    ${esc(run.ref)} · ${esc(run.pb)} v${esc(run.v)} · ${esc(run.mode)}T+${pad(Math.floor(mins/60))}:${pad(mins%60)}
    + ${esc(run.name)} +
    ${esc(run.severity)}${esc(pb?pb.name:run.pb)}${run.params.cve?`${esc(run.params.cve)}`:''}${run.closed?'Closed':''}
    + ${w?'
    ':''}
    +

    Playbook · decisions point at the model

    ${incRunHtml(inc)} +
    What Strata and Muster exchange
    ${esc(JSON.stringify(incContract(inc),null,2))}
    `; + } + body+=`

    Assets hit · ${aff.length}

    ${aff.length?`
    ${aff.map(incChip).join('')}
    `:`

    Nothing marked. Select an asset on the model and mark it from its sheet${run?', or answer the playbook decisions':''}.

    `}`; + if(aff.length&&iv){ + const calls=IR.callList(SITE,aff),biz=IR.business(SITE,aff),reach=Object.entries(iv.reach).sort((a,b)=>a[1]-b[1]); + body+=`

    Who to call · ${calls.length}

    ${incCallHtml(calls)} +

    Flows to cut · ${open} open of ${rows.length}

    Internet first (command and control, data theft), then flows that reach critical assets or carry administration protocols. Cut at the first firewall the flow crosses.

    ${incFlowsHtml(rows,false)} +

    What depends on them

    +

    Business services hit

    ${biz.length?`
      ${biz.map(b=>`
    • ${critTag(b.crit)}
    • `).join('')}
    `:'

    No business service recorded on these assets.

    '} +

    Assets that call them: they lose a service when these are isolated

    ${iv.cons.length?`
    ${iv.cons.map(incChip).join('')}
    `:'

    None.

    '} +

    Reachable next by the attacker, along the flows that leave them

    + ${reach.length?`
      ${reach.slice(0,10).map(([id,n])=>{const a=byId[id],k=a.vulns.filter(v=>v.kev);return `
    • ${SITE.critIds.includes(id)?critTag(3):''}
    • `;}).join('')}
    `:'

    No flow leaves them.

    '}`; + } + body+=`

    Timeline · ${inc.log.length}

    ${inc.log.length?`
      ${inc.log.slice(0,60).map(l=>`
    1. ${hhmm(l.at)}${esc(l.msg)}${l.by?` ${esc(l.by)}`:''}
    2. `).join('')}
    `:'

    Empty.

    '} + ${w&&(inc.on||inc.log.length)?`
    ${inc.on?'':''}
    `:''}`; + openDet(`
    Incident · IS ${esc(SITE.name)}

    ${run?esc(run.name):'Incident mode'}

    ${inc.on?`${plural(aff.length,'asset')} hit · ${open} flow${open===1?'':'s'} to cut`:'Off'}

    ${body}
    `,'incident'); + if(keep)det.scrollTop=top; +} +function incOpenScenario(i){ + const sc=IR.demoFeed(SITE)[i],pb=sc&&IR.PLAYBOOKS[sc.pb];if(!pb)return; + const inc=Inc.of(SITE.id);inc.on=true;inc.run=IR.start(sc,pb,Date.now()-7*60000); + incLog(inc,`Muster ${sc.ref} opened on the map (${sc.pb} v${pb.v})`,'sync'); + sc.assets.forEach(id=>incSetMark(inc,id,'compromised')); + if(sc.params.cve){const f=IR.selectAssets(SITE,inc,'vuln:{cve}',sc.params);incLog(inc,`${sc.params.cve}: ${plural(f.length,'asset')} on the map${f.length?' ('+f.map(nameOf).join(', ')+')':''}`,'sync');} + incSave(true);showIncident(); +} +function incGo(to,label){ + const inc=Inc.of(SITE.id),run=inc.run,pb=IR.PLAYBOOKS[run.pb],s=IR.step(pb,run.cur); + const ch=IR.advance(SITE,inc,pb,to,label); + incLog(inc,label?`Decision “${s.title}” → ${label}`:`Step done: ${s.title}`,label?'dec':'ok'); + ch.forEach(([id,st])=>incSetMark(inc,id,st)); + incLog(inc,run.closed?'Incident closed in Muster':`Muster step: ${IR.step(pb,to).title}`,'sync'); + incSave(ch.length>0); +} +// Clicks in the incident sheet and on the incident buttons of an asset sheet +det.addEventListener('click',e=>{ + const q=s=>e.target.closest(s);if(state.view!=='site'||!SITE)return; + if(q('[data-inc-loc]')){incLocate(q('[data-inc-loc]').dataset.incLoc);return;} + if(q('[data-inc-mark]')){const b=q('[data-inc-mark]');incMarkAsset(b.dataset.id,b.dataset.incMark||null);return;} + if(q('[data-ai-asset]')){showPrompts(byId[q('[data-ai-asset]').dataset.aiAsset]);return;} + if(!Inc.canWrite)return; + const inc=Inc.of(SITE.id); + if(q('[data-inc-on]')){incToggle(true);return;} + if(q('[data-inc-off]')){incToggle(false);return;} + if(q('[data-inc-open]')){incOpenScenario(+q('[data-inc-open]').dataset.incOpen);return;} + if(q('[data-inc-go]')){const b=q('[data-inc-go]');incGo(b.dataset.incGo,b.dataset.incLbl||'');return;} + if(q('[data-inc-cut]')){const id=q('[data-inc-cut]').dataset.incCut,f=FLOWS.find(x=>x.id===id),r=IR.flowsToCut(SITE,inc,IR.affected(SITE,inc)).find(x=>x.f.id===id); + const i=inc.cuts.indexOf(id);if(i>=0){inc.cuts.splice(i,1);incLog(inc,`Flow reopened: ${nameOf(f.src)} → ${nameOf(f.dst)} ${f.port}`);} + else{inc.cuts.push(id);incLog(inc,`Flow cut at ${r?r.at.label:'the host'}: ${nameOf(f.src)} → ${nameOf(f.dst)} ${f.port}`,'ok');} + incSave(false);return;} + if(q('[data-inc-isolate]')){IR.selectAssets(SITE,inc,q('[data-inc-isolate]').dataset.incIsolate,inc.run&&inc.run.params).forEach(id=>incSetMark(inc,id,'contained'));incSave(true);return;} + if(q('[data-inc-unlink]')){incLog(inc,`Muster ${inc.run.ref} unlinked`,'sync');inc.run=null;incSave(false);return;} + if(q('[data-inc-reset]')){const b=q('[data-inc-reset]');if(!b.classList.contains('armed')){b.classList.add('armed');b.textContent='Confirm: clear marks, cuts, run and timeline';return;} + Inc.all[SITE.id]=IR.empty();incSave(true);toast('Incident cleared.');return;} +}); +det.addEventListener('change',e=>{const c=e.target.closest('[data-inc-chk]');if(!c||!SITE)return;const inc=Inc.of(SITE.id);if(!inc.run)return;inc.run.checks[c.dataset.incChk]=c.checked;Inc.changed(SITE.id);}); +$('#incBtn').addEventListener('click',()=>{if(!SITE)return;if(state.panel==='incident'){closeDet();return;}if(!incOn()&&Inc.canWrite)incToggle(true);else showIncident();closeCtlMobile(true);}); +$('#cart').addEventListener('click',e=>{if(e.target.closest('[data-inc-board]'))showIncident();}); +function incBand(){ + const iv=incView();if(!iv)return '';const run=iv.inc.run,open=iv.rows.filter(r=>!r.cut).length; + return `
    Incident mode${run?`${esc(run.ref)} · ${esc(run.pb)}`:'No Muster incident'} · ${plural(iv.aff.length,'asset')} hit · ${open} to cut
    `; +} +// The asset sheet: mark it, and prompt a public AI about it +function incAssetHtml(a){ + const m=Inc.of(SITE.id).marks[a.id]||null; + if(!Inc.canWrite)return m?`

    ${INC_LBL[m]}

    `:''; + return `
    Incident${IR.STATES.map(s=>``).join('')}${m?``:''}
    `; +} + +/* ---- Anonymised prompts for a public AI ---- */ +// Options are a convenience of this browser: language, what to keep, a context sentence per IS, terms never to send +const AI_KEY='strata-ai-prompts'; +let AIO={lang:'fr',versions:true,cves:true,biz:false,neigh:true,ctx:{},never:''},AI_ANSWER='',AI_FOR=null; +try{Object.assign(AIO,JSON.parse(localStorage.getItem(AI_KEY)||'{}'));}catch(e){} +const aiSave=()=>{try{localStorage.setItem(AI_KEY,JSON.stringify(AIO));}catch(e){}}; +function showPrompts(a,keep){ + if(!a)return;if(AI_FOR!==a.id){AI_ANSWER='';AI_FOR=a.id;} + const top=keep?det.scrollTop:0,o=Object.assign({},AIO,{ctx:AIO.ctx[SITE.id]||''}); + const {P,risk,play}=IR.prompts(SITE,a.id,o),lk=[...new Set([...IR.leaks(SITE,risk,AIO.never),...IR.leaks(SITE,play,AIO.never)])],yc=AI_ANSWER?IR.yamlCheck(AI_ANSWER):null; + const box=(k,t,txt)=>`
    ${t}${txt.length} chars
    ${esc(txt)}
    `; + const real=r=>r.kind==='asset'?byId[r.real].name:r.kind==='segment'?(v=>`VLAN ${v.num} · ${v.name}`)(vlanById[r.real.slice(5)]):SITE.bizById[r.real.slice(4)].name; + openDet(`
    AI prompts · anonymised

    ${esc(a.name)}

    Copy a prompt into the AI of your choice. Nothing leaves Strata by itself.

    +
    +

    Anonymisation

    +
    + + + + + +
    +
    +
    +
    + ${lk.length?`

    Found in the prompts: ${esc(lk.join(', '))}. Change the context or the options before copying.

    `:'

    Leak check passed: no name, address, contact, IS name or listed term in the prompts.

    '} +

    1 · Major risks of this asset

    ${box('risk','Risks, scenarios, mitigations',risk)} +

    2 · Plausible incidents and their playbooks

    ${box('play','Incidents, then Muster playbooks',play)} +

    Asks for playbooks in muster/v1 YAML, the format Muster exports, with a targets field on each step so the playbook points at this map.

    +

    Placeholders · kept here, never in a prompt

    + ${P.rev.map(r=>``).join('')}
    ${r.ph}${esc(real(r))}
    +

    Bring the answer back

    +
    + ${AI_ANSWER?`${yc?`

    ${plural(yc.blocks,'muster/v1 playbook')} · ${plural(yc.steps,'step')} · ${yc.targets} with targets${yc.bad.length?' · unknown step types: '+esc(yc.bad.join(', ')):''}

    `:''}${box('back','With your names',IR.reidentify(SITE,AI_ANSWER,P))}`:''} +
    `,'ai'); + if(keep)det.scrollTop=top; +} +det.addEventListener('click',async e=>{ + const q=s=>e.target.closest(s);if(state.panel!=='ai'||!SITE)return;const a=byId[AI_FOR]; + if(q('[data-ai-back]')){if(a)select(a);return;} + if(q('[data-ai-lang]')){AIO.lang=q('[data-ai-lang]').dataset.aiLang;aiSave();showPrompts(a,true);return;} + if(q('[data-ai-copy]')){const el=$('#ai-'+q('[data-ai-copy]').dataset.aiCopy);try{await navigator.clipboard.writeText(el.textContent);toast('Prompt copied.');}catch(err){const r=document.createRange();r.selectNodeContents(el);const s=getSelection();s.removeAllRanges();s.addRange(r);toast('Text selected: copy it with Ctrl+C.');}} +}); +det.addEventListener('change',e=>{ + if(state.panel!=='ai'||!SITE)return;const t=e.target,a=byId[AI_FOR]; + if(t.dataset.aiOpt){AIO[t.dataset.aiOpt]=t.checked;aiSave();showPrompts(a,true);return;} + if(t.hasAttribute('data-ai-ctx')){AIO.ctx[SITE.id]=t.value;aiSave();showPrompts(a,true);return;} + if(t.hasAttribute('data-ai-never')){AIO.never=t.value;aiSave();showPrompts(a,true);return;} + if(t.hasAttribute('data-ai-answer')){AI_ANSWER=t.value;showPrompts(a,true);} +}); + /* ================= Navigation ================= */ let fly=null; function flyTo(target,dist){const off=camera.position.clone().sub(controls.target);off.setLength(dist||Math.min(off.length(),28)); @@ -3249,6 +3516,8 @@ function onData(){ else if(state.panel==='flow'){const f=FLOWS.find(x=>x.id===state.flowId);if(f)showFlow(f);else closeDet();} else if(state.panel==='risk')showRisk(); else if(state.panel==='attack')showAttack(); + else if(state.panel==='incident')showIncident(true); + else if(state.panel==='ai'){const a=byId[AI_FOR];if(a)showPrompts(a,true);else closeDet();} else if(state.panel==='business'){const b=SITE.bizById[state.bizPanel];if(b)showBusiness(b);else closeDet();} } }else{ @@ -3472,7 +3741,7 @@ function tick(t){ } function start(){ resize();buildOverview();renderCart();syncUI();renderEmpty();setViewNow();tick(); - Store.init().then(()=>{renderCollab();if(Store.features.proposals)loadProposals();}); + Store.init().then(()=>{renderCollab();if(Store.features.proposals)loadProposals();return Inc.load();}).then(()=>incRedraw(true)); } const ready=document.fonts&&document.fonts.ready?Promise.race([document.fonts.ready,new Promise(r=>setTimeout(r,1500))]):Promise.resolve(); ready.then(start,start); diff --git a/public/incident.js b/public/incident.js new file mode 100644 index 0000000..f616aeb --- /dev/null +++ b/public/incident.js @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Incident mode: the assets hit, who to call, which flows to cut and where, what depends on them, how a +// Muster playbook step points at the map, and the anonymised prompts for a public AI. +// Pure functions over a compiled IS, no DOM: the page loads this file before app.js, +// and the tests run it in Node (tests/incident.test.mjs). +(function(root){ +'use strict'; +const isNet=id=>id==='internet'||String(id).startsWith('svc:'); +const STATES=['compromised','suspected','contained']; +const TYPE_LABEL={server:'Server',dc:'Domain controller',db:'Database',hypervisor:'Hypervisor / storage',firewall:'Firewall',gateway:'VPN gateway',router:'Router / L3 core',switch:'Switch / load balancer',workstation:'Workstation',printer:'Printer',phone:'IP phone',ap:'Wi-Fi access point',camera:'IP camera',plc:'PLC',iot:'IoT device'}; +const SEV_RANK={none:0,low:1,med:2,high:3,crit:4}; + +/* ---- Incident state of one IS ---- */ +// {on, marks:{assetId:state}, cuts:[flowId], run:null|{…}, log:[{at,msg,kind,by}]} +function empty(){return {on:false,marks:{},cuts:[],run:null,log:[]};} +function clean(o){ + const e=empty();if(!o||typeof o!=='object')return e; + e.on=!!o.on; + if(o.marks&&typeof o.marks==='object')Object.keys(o.marks).forEach(k=>{if(STATES.includes(o.marks[k]))e.marks[k]=o.marks[k];}); + if(Array.isArray(o.cuts))e.cuts=o.cuts.filter(x=>typeof x==='string'); + if(o.run&&typeof o.run==='object')e.run=o.run; + if(Array.isArray(o.log))e.log=o.log.slice(0,500); + return e; +} +// Assets in the given states that still exist on the map +function marked(site,inc,states){return Object.keys(inc.marks).filter(id=>site.byId[id]&&states.includes(inc.marks[id]));} +const affected=(site,inc)=>marked(site,inc,STATES); + +/* ---- Along the flows ---- */ +// What an attacker holding the given assets reaches by opening the flows that leave them: {id: steps} +function reach(site,from,depth=3){ + const d={},q=[];from.forEach(x=>{d[x]=0;q.push(x);}); + while(q.length){const n=q.shift();if(d[n]>=depth)continue; + site.flows.forEach(f=>{if(f.src===n&&d[f.dst]===undefined&&!isNet(f.dst)){d[f.dst]=d[n]+1;q.push(f.dst);}});} + from.forEach(x=>delete d[x]);return d; +} +// Assets that call the given ones: they lose a service when these are isolated +function consumers(site,ids){const set=new Set(ids);return [...new Set(site.flows.filter(f=>set.has(f.dst)&&!isNet(f.src)&&!set.has(f.src)).map(f=>f.src))];} +// Business services that run on the given assets +function business(site,ids){const set=new Set(ids);return site.business.filter(b=>b.assets.some(x=>set.has(x)));} + +/* ---- Selectors: how a playbook step points at the map ---- + affected, compromised, suspected, contained, id:, type:, role:, vuln:, exposed:internet, + reach:, consumers: give assets; flows:, business:, contacts: + and contacts:is give the other kinds. {cve} (or any {param}) is replaced by the incident's parameters. */ +function fill(sel,params){return String(sel).replace(/\{(\w+)\}/g,(m,k)=>params&¶ms[k]!=null?String(params[k]):'');} +function selectAssets(site,inc,sel,params){ + sel=fill(sel,params); + if(sel==='affected')return affected(site,inc); + if(sel==='compromised')return marked(site,inc,['compromised','contained']); + if(sel==='suspected'||sel==='contained')return marked(site,inc,[sel]); + const i=sel.indexOf(':'),k=i<0?sel:sel.slice(0,i),rest=i<0?'':sel.slice(i+1); + if(k==='reach'){const d=reach(site,selectAssets(site,inc,rest,params));return Object.keys(d).sort((a,b)=>d[a]-d[b]);} + if(k==='consumers')return consumers(site,selectAssets(site,inc,rest,params)); + const A=site.assets; + if(k==='id')return site.byId[rest]?[rest]:A.filter(a=>a.name===rest).map(a=>a.id); + if(k==='type')return A.filter(a=>a.type===rest).map(a=>a.id); + if(k==='role')return A.filter(a=>String(a.role||'').toLowerCase().includes(rest.toLowerCase())).map(a=>a.id); + if(k==='vuln')return rest?A.filter(a=>a.vulns.some(v=>v.id===rest)).map(a=>a.id):[]; + if(k==='exposed')return A.filter(a=>site.flows.some(f=>isNet(f.src)&&f.dst===a.id)).map(a=>a.id); + return []; +} +function resolve(site,inc,sel,params){ + const i=sel.indexOf(':'),k=i<0?sel:sel.slice(0,i),rest=i<0?'':sel.slice(i+1); + if(k==='flows')return {kind:'flows',items:flowsToCut(site,inc,selectAssets(site,inc,rest,params))}; + if(k==='business')return {kind:'business',items:business(site,selectAssets(site,inc,rest,params))}; + if(k==='contacts')return {kind:'contacts',items:rest==='is'?site.contacts.map(c=>Object.assign({},c,{why:['IS contact']})):callList(site,selectAssets(site,inc,rest,params),false)}; + return {kind:'assets',items:selectAssets(site,inc,sel,params)}; +} + +/* ---- Flows to cut ---- */ +const ADMIN=new Set(['tcp/22','tcp/23','tcp/135','tcp/139','tcp/445','tcp/3389','tcp/5900','tcp/5985','tcp/5986','tcp/389','tcp/636','tcp/88','tcp/1433','tcp/3306','tcp/5432','tcp/1521','tcp/102','tcp/502']); +// Where a flow is cut: the first firewall it crosses, else the first piece of equipment, else the host itself +function cutPoint(site,f){ + const eq=(f.via||[]).map(id=>site.byId[id]).filter(Boolean); + const fw=eq.find(a=>a.type==='firewall')||eq.find(a=>a.type==='gateway'); + if(fw)return {id:fw.id,label:`${fw.name} (firewall rule)`}; + if(eq.length)return {id:eq[0].id,label:`${eq[0].name} (ACL)`}; + if(isNet(f.src)||isNet(f.dst))return {id:null,label:'Internet edge (no firewall recorded on the path)'}; + return {id:null,label:'On the host (EDR isolation or local firewall)'}; +} +function flowsToCut(site,inc,ids){ + const set=new Set(ids),cuts=new Set(inc.cuts||[]),name=id=>isNet(id)?(id==='internet'?'the Internet':(site.svcById[id]||{}).name||id):site.byId[id].name; + return site.flows.filter(f=>set.has(f.src)||set.has(f.dst)).map(f=>{ + const out=set.has(f.src),peer=out?f.dst:f.src,why=[];let score=0; + const p=isNet(peer)?null:site.byId[peer]; + if(isNet(peer)){score+=50;why.push(out?'Outbound to the Internet: command and control or data theft':'Inbound from the Internet: the way in');} + if(p&&!set.has(peer)&&(p.crit===3||(site.critIds||[]).includes(peer))){score+=30;why.push(`${out?'Reaches':'Comes from'} critical asset ${p.name}`);} + if(p&&ADMIN.has(f.port)){score+=out?20:8;if(out)why.push('Lateral movement protocol');} + if(p&&out&&p.vulns.some(v=>v.kev)){score+=15;why.push(`${p.name} has a known exploited vulnerability`);} + if(f.risk){score+=15;if(f.note)why.push(f.note);} + if(set.has(peer))score-=10; + const iso=[f.src,f.dst].some(x=>inc.marks[x]==='contained'); + return {f,out,peer,peerName:name(peer),srcName:name(f.src),dstName:name(f.dst),score,prio:score>=50?1:score>=25?2:3,why,cut:cuts.has(f.id)||iso,iso,at:cutPoint(site,f), + breaks:business(site,[f.src,f.dst].filter(x=>!isNet(x))).map(b=>b.name)}; + }).sort((a,b)=>a.cut-b.cut||b.score-a.score||a.f.id.localeCompare(b.f.id)); +} + +/* ---- Who to call ---- */ +const ROLE_ORDER={'On-call':0,'Technical owner':1,'Vendor / support':2,'Business owner':3,'CISO':4}; +function callList(site,ids,withIs=true){ + const m=new Map(),add=(c,why)=>{const k=(c.name||'')+'|'+(c.phone||c.email||c.role||'');if(!m.has(k))m.set(k,Object.assign({},c,{why:[]}));const e=m.get(k);if(!e.why.includes(why))e.why.push(why);}; + ids.forEach(id=>{const a=site.byId[id];if(!a)return;(a.contacts||[]).filter(c=>c.name||c.phone||c.email).forEach(c=>add(c,a.name));}); + business(site,ids).forEach(b=>{if(b.owner)add({name:b.owner,role:'Business owner',phone:'',email:'',dispo:''},b.name);}); + if(withIs&&ids.length)site.contacts.forEach(c=>add(c,'IS contact')); + const h24=c=>/24\s*\/\s*7|24h|on-call/i.test(`${c.dispo||''} ${c.role||''}`)?0:1; + return [...m.values()].sort((a,b)=>h24(a)-h24(b)||(ROLE_ORDER[a.role]??9)-(ROLE_ORDER[b.role]??9)||String(a.name).localeCompare(String(b.name))); +} + +/* ---- Running a Muster playbook ---- */ +const step=(pb,id)=>pb.steps.find(s=>s.id===id); +const nextOf=(pb,id)=>pb.edges.filter(e=>e[0]===id); +function start(sc,pb,now,by){ + const run={ref:sc.ref,name:sc.name,pb:pb.code,v:pb.v,mode:sc.mode,severity:sc.severity,assets:(sc.assets||[]).slice(),params:Object.assign({},sc.params||{}),source:sc.source||'muster', + path:[pb.steps[0].id],cur:pb.steps[0].id,answers:{},checks:{},startedAt:now,closed:false}; + const n=nextOf(pb,run.cur)[0];if(n){run.path.push(n[1]);run.cur=n[1];} + return run; +} +// Moves the run along an edge; returns the marks a decision asks for, as [[assetId,state]] +function advance(site,inc,pb,to,label){ + const run=inc.run,s=step(pb,run.cur),changes=[]; + if(label){run.answers[run.cur]=label;const eff=s.effects&&s.effects[label]; + if(eff)selectAssets(site,inc,eff[1],run.params).forEach(id=>{const was=inc.marks[id];if(was!=='compromised'&&was!=='contained'&&was!==eff[0])changes.push([id,eff[0]]);});} + run.path.push(to);run.cur=to;if(step(pb,to).type==='end')run.closed=true; + return changes; +} +// The most likely route ahead: the steps taken, then the first edge out of each step +function route(pb,run){ + const seen=new Set(run.path),r=run.path.slice();let c=run.cur; + for(;;){const e=nextOf(pb,c)[0];if(!e||seen.has(e[1]))break;r.push(e[1]);seen.add(e[1]);c=e[1];} + return r; +} + +/* ---- Muster playbooks (Muster's own, with the `targets` and `effects` this integration adds) ---- */ +const PLAYBOOKS={ + 'PB-001':{code:'PB-001',name:'Ransomware',v:3,severity:'Critical',steps:[ + {id:'start',type:'start',title:'Alert received',phase:'Detection',targets:[]}, + {id:'triage',type:'action',title:'Triage the alert',phase:'Detection',role:'L1 Analyst',sla:15,desc:'Confirm ransomware activity and rule out a false positive before anyone escalates.',checklist:['Check the alert source (EDR, SIEM, user report)','List affected hosts and accounts','Open the incident ticket'],targets:['affected','contacts:affected']}, + {id:'encrypt',type:'decision',title:'Encryption in progress?',phase:'Detection',role:'L1 Analyst',sla:5,desc:'Are files being encrypted or mass-renamed right now?',targets:['compromised']}, + {id:'isolate',type:'action',title:'Isolate affected hosts',phase:'Containment',role:'L2 Analyst',sla:10,desc:'Stop the spread before any analysis. Do not power machines off: memory is evidence.',checklist:['Isolate endpoints through the EDR','Disable exposed SMB shares','Disable compromised accounts'],targets:['affected','flows:affected'],act:'isolate'}, + {id:'crisis',type:'escalation',title:'Activate the crisis cell',phase:'Containment',role:'CISO',sla:15,desc:'Escalate to leadership, the insurer and the authorities.',checklist:['Notify executive leadership','Call the cyber insurer','Assess CNIL (72 h) and ANSSI notification'],targets:['business:affected','contacts:is']}, + {id:'forensic',type:'action',title:'Forensic analysis',phase:'Eradication',role:'IR Lead',sla:240,desc:'Find the initial access vector and every persistence mechanism.',checklist:['Capture memory and disks from key hosts','Identify patient zero','Extract IOCs and block them'],targets:['affected','reach:affected']}, + {id:'backup',type:'decision',title:'Clean backups available?',phase:'Recovery',role:'Sysadmin',sla:30,desc:'Are offline backups intact and older than the compromise?',targets:['role:backup','flows:role:backup']}, + {id:'restore',type:'action',title:'Restore from backups',phase:'Recovery',role:'Sysadmin',sla:480,checklist:['Restore into an isolated network segment','Scan restored data','Return to production in batches'],targets:['compromised','consumers:compromised']}, + {id:'rebuild',type:'action',title:'Rebuild systems',phase:'Recovery',role:'Sysadmin',sla:1440,desc:'Reinstall from trusted golden images.',checklist:['Reinstall from reference images','Rotate every domain secret'],targets:['compromised','type:dc']}, + {id:'watch',type:'action',title:'Heightened monitoring',phase:'Detection',role:'L1 Analyst',sla:60,desc:'No encryption observed. Monitor closely and document.',checklist:['Create a detection rule for the IOCs','Watch the hosts for 24 h'],targets:['affected','role:SIEM']}, + {id:'review',type:'action',title:'Post-incident review',phase:'Lessons learned',role:'IR Lead',sla:120,checklist:['Write the timeline','List corrective actions','Update this playbook'],targets:[]}, + {id:'end',type:'end',title:'Incident closed',phase:'Lessons learned',targets:[]}], + edges:[['start','triage'],['triage','encrypt'],['encrypt','isolate','Yes'],['encrypt','watch','No'],['isolate','crisis'],['crisis','forensic'],['forensic','backup'],['backup','restore','Yes'],['backup','rebuild','No'],['restore','review'],['rebuild','review'],['watch','review'],['review','end']]}, + 'PB-010':{code:'PB-010',name:'Exploited critical vulnerability',v:1,severity:'Critical',steps:[ + {id:'start',type:'start',title:'Critical vulnerability exploited',phase:'Detection',targets:[]}, + {id:'exposure',type:'action',title:'Identify exposed assets',phase:'Detection',role:'L2 Analyst',sla:60,checklist:['Match the affected product and versions','Check internet exposure','List system owners'],targets:['vuln:{cve}','contacts:vuln:{cve}']}, + {id:'found',type:'decision',title:'Exposed assets found?',phase:'Detection',role:'L2 Analyst',sla:10,targets:['vuln:{cve}'],effects:{Yes:['suspected','vuln:{cve}']}}, + {id:'doc',type:'action',title:'Document and monitor',phase:'Lessons learned',role:'L1 Analyst',sla:30,checklist:['Record that no asset is affected','Watch vendor updates'],targets:[]}, + {id:'mitigate',type:'action',title:'Apply mitigation',phase:'Containment',role:'Sysadmin',sla:120,checklist:['Patch or apply the vendor workaround','Restrict access at the firewall','Enable virtual patching on WAF or IPS'],targets:['vuln:{cve}','flows:vuln:{cve}']}, + {id:'hunt',type:'action',title:'Hunt for compromise',phase:'Eradication',role:'L2 Analyst',sla:240,checklist:['Search the vendor IOCs','Review logs since disclosure','Look for web shells and new accounts'],targets:['vuln:{cve}','reach:vuln:{cve}']}, + {id:'comp',type:'decision',title:'Signs of compromise?',phase:'Eradication',role:'L2 Analyst',sla:30,targets:['vuln:{cve}'],effects:{Yes:['compromised','vuln:{cve}']}}, + {id:'major',type:'escalation',title:'Open a major incident',phase:'Eradication',role:'IR Lead',sla:30,checklist:['Start the intrusion or ransomware playbook','Preserve evidence'],targets:['business:affected','contacts:affected']}, + {id:'review',type:'action',title:'Post-incident review',phase:'Lessons learned',role:'IR Lead',sla:60,checklist:['Measure time to patch','Review asset inventory gaps'],targets:[]}, + {id:'end',type:'end',title:'Incident closed',phase:'Lessons learned',targets:[]}], + edges:[['start','exposure'],['exposure','found'],['found','mitigate','Yes'],['found','doc','No'],['doc','end'],['mitigate','hunt'],['hunt','comp'],['comp','major','Yes'],['comp','review','No'],['major','review'],['review','end']]} +}; +// Incidents a Muster server would hand over for this IS. Without a Muster server: examples built from the map. +function demoFeed(site,now){ + const out=[],A=site.assets,find=n=>A.find(a=>a.name===n); + const fs=find('file-01')||A.filter(a=>/file/i.test(a.role||'')).sort((a,b)=>b.crit-a.crit)[0]||(site.risk&&site.risk.assets[0]&&site.byId[site.risk.assets[0].id]); + if(fs){const ws=find('pc-acct-01')||A.find(a=>a.type==='workstation'&&site.flows.some(f=>f.src===a.id&&f.dst===fs.id)); + out.push({ref:'IR-2026-047',name:`Ransom note on ${fs.name}`,pb:'PB-001',mode:'live',severity:'Critical',assets:[fs.id].concat(ws?[ws.id]:[]),params:{}, + note:`Declared in Muster with ${ws?'two Strata assets':'one Strata asset'} attached: ${[fs].concat(ws?[ws]:[]).map(a=>a.name).join(' and ')}.`});} + const kev=A.flatMap(a=>a.vulns.filter(v=>v.kev&&/^CVE-/.test(v.id)).map(v=>({a,v}))).sort((x,y)=>(y.a.exp==='internet')-(x.a.exp==='internet')||(y.v.epss||0)-(x.v.epss||0)||(y.v.cvss||0)-(x.v.cvss||0))[0]; + if(kev)out.push({ref:'IR-2026-048',name:`${kev.v.id} exploited in the wild`,pb:'PB-010',mode:'exercise',severity:'Critical',assets:[],params:{cve:kev.v.id}, + note:'Declared with one parameter, the CVE: Strata finds the assets that carry it.'}); + return out; +} + +/* ---- Anonymised prompts for a public AI ---- */ +const pad2=n=>String(n).padStart(2,'0'); +function placeholders(site,id,opts){ + const map=new Map(),rev=[]; + const put=(real,ph,kind)=>{if(!map.has(real)){map.set(real,ph);rev.push({ph,real,kind});}return map.get(real);}; + put(id,'ASSET-T','asset');let n=1,sg=1,bz=1; + const own=site.flows.filter(f=>f.src===id||f.dst===id); + const neigh=[...new Set(own.map(f=>f.src===id?f.dst:f.src))].filter(x=>!isNet(x)&&site.byId[x]); + const via=[...new Set(own.flatMap(f=>f.via||[]))].filter(x=>site.byId[x]&&!neigh.includes(x)&&x!==id); + const r=reach(site,[id],2),crit=Object.keys(r).filter(x=>!neigh.includes(x)&&!via.includes(x)&&(site.byId[x].crit===3||(site.critIds||[]).includes(x))); + [...neigh,...via,...crit].forEach(x=>put(x,'ASSET-'+pad2(n++),'asset')); + return {map,rev,neigh,via,crit, + seg:x=>{const v=site.vlanById[site.byId[x].vlan];return map.get('vlan:'+v.id)||put('vlan:'+v.id,'SEG-'+(sg++),'segment');}, + biz:b=>opts.biz?b.name:(map.get('biz:'+b.id)||put('biz:'+b.id,'SVC-B'+(bz++),'business')), + ph:x=>isNet(x)?(x==='internet'?'Internet':`${(site.svcById[x]||{}).name||'Internet service'} (${(site.svcById[x]||{}).category||'Internet service'})`):map.get(x)}; +} +const L={ + en:{type:'Type',role:'Role',sys:'System',apps:'Applications',crit:'Criticality',seg:'Network segment',zone:'zone',expo:'Exposure',vul:'Known vulnerabilities',none:'none recorded',in:'Inbound flows (who talks to it)',out:'Outbound flows (what it talks to)',biz:'Business services relying on it',neigh:'Neighbours mentioned above',reach:'Critical assets reachable within 2 hops',direct:'directly reachable from the Internet',hops:n=>`reachable from the Internet in ${n} hops along allowed flows`,unreach:'not reachable from the Internet along recorded flows',risky:'flagged risky',logs:'Sends logs to a SIEM',backup:'Backed up by',yes:'yes',no:'no recorded flow',via:'through',levels:['','low','medium','high'],ctx:'Context', + defCtx:'Mid-size organisation, one site, an in-house IT team and an outsourced SOC.'}, + fr:{type:'Type',role:'Rôle',sys:'Système',apps:'Applications',crit:'Criticité',seg:'Segment réseau',zone:'zone',expo:'Exposition',vul:'Vulnérabilités connues',none:'aucune enregistrée',in:'Flux entrants (qui lui parle)',out:'Flux sortants (à qui il parle)',biz:'Services métier qui en dépendent',neigh:'Voisins cités ci-dessus',reach:'Actifs critiques atteignables en 2 sauts',direct:'directement joignable depuis Internet',hops:n=>`joignable depuis Internet en ${n} sauts via les flux autorisés`,unreach:'non joignable depuis Internet selon les flux connus',risky:'signalé à risque',logs:'Envoie ses journaux à un SIEM',backup:'Sauvegardé par',yes:'oui',no:'aucun flux enregistré',via:'via',levels:['','faible','moyenne','haute'],ctx:'Contexte', + defCtx:'Organisation de taille moyenne, un site, une équipe informatique interne et un SOC externalisé.'} +}; +const ver=(s,opts)=>opts.versions?String(s||''):String(s||'').replace(/(\d+)(\.\d+)+/g,'$1.x'); +function assetBlock(site,id,P,opts){ + const a=site.byId[id],v=site.vlanById[a.vlan],T=L[opts.lang]||L.en,ty=x=>TYPE_LABEL[x]||x; + const peer=x=>{if(isNet(x))return P.ph(x);const b=site.byId[x];return `${P.ph(x)} (${ty(b.type)}${b.role?', '+b.role:''}, ${P.seg(x)})`;}; + const thru=f=>(f.via||[]).length?` · ${T.via} ${f.via.map(x=>`${P.ph(x)} (${ty(site.byId[x].type)})`).join(' → ')}`:''; + const fl=f=>`${f.src===id?peer(f.dst):peer(f.src)} · ${f.port} · ${f.vol} MB/h${thru(f)}${f.risk?` · ${T.risky}${f.note?': '+f.note:''}`:''}`; + const vul=a.vulns.map(x=>{const cfg=!/^CVE-|^GHSA-/i.test(x.id);return ` - ${cfg?'Configuration':opts.cves?x.id:'Vulnerability'}: ${x.title}${x.cvss!=null?`, CVSS ${x.cvss}`:''}${x.epss!=null?`, EPSS ${Math.round(x.epss*100)}%`:''}${x.kev?', CISA KEV':''}`;}); + const siem=site.flows.some(f=>f.src===id&&site.byId[f.dst]&&/siem|log/i.test(site.byId[f.dst].role||'')); + const bk=site.flows.filter(f=>f.dst===id&&site.byId[f.src]&&/backup|sauvegarde/i.test(site.byId[f.src].role||'')).map(f=>P.ph(f.src)); + const bz=business(site,[id]),inn=site.flows.filter(f=>f.dst===id),out=site.flows.filter(f=>f.src===id),hops=site.expo[id]; + const direct=site.flows.some(f=>isNet(f.src)&&f.dst===id); + const lines=[`## ASSET-T`,`- ${T.type}: ${ty(a.type)}`,`- ${T.role}: ${a.role||'—'}`,`- ${T.sys}: ${ver(a.os,opts)||'—'}`]; + if((a.apps||[]).length)lines.push(`- ${T.apps}: ${a.apps.map(x=>`${x.name}${x.version?' '+ver(x.version,opts):''}${x.ports.length?' ('+x.ports.join(', ')+')':''}`).join('; ')}`); + lines.push(`- ${T.crit}: ${T.levels[a.crit]||'—'} (${a.crit}/3)`,`- ${T.seg}: ${P.seg(id)} "${v.name}", ${T.zone} "${v.zone}"`, + `- ${T.expo}: ${direct?T.direct:hops!=null?T.hops(hops):T.unreach}`, + `- ${T.biz}: ${bz.map(b=>`${P.biz(b)} (${T.crit.toLowerCase()} ${T.levels[b.crit]})`).join(', ')||T.none}`, + `- ${T.logs}: ${siem?T.yes:T.no}`,`- ${T.backup}: ${bk.join(', ')||T.no}`, + `- ${T.vul}:`,...(vul.length?vul:[` - ${T.none}`]), + `- ${T.in}:`,...(inn.length?inn.map(f=>' - '+fl(f)):[` - ${T.none}`]), + `- ${T.out}:`,...(out.length?out.map(f=>' - '+fl(f)):[` - ${T.none}`])); + if(opts.neigh){ + const nb=[...P.neigh,...P.via].map(x=>{const b=site.byId[x],k=b.vulns.filter(z=>z.kev||z.sev==='crit'); + return ` - ${P.ph(x)}: ${ty(b.type)}, ${ver(b.os,opts)||'—'}${k.length?' — '+k.map(z=>(opts.cves&&/^CVE-/i.test(z.id)?z.id+' ':'')+z.title+(z.kev?' (KEV)':'')).join('; '):''}`;}); + if(nb.length)lines.push(`- ${T.neigh}:`,...nb); + if(P.crit.length)lines.push(`- ${T.reach}:`,...P.crit.map(x=>` - ${P.ph(x)}: ${ty(site.byId[x].type)}, ${site.byId[x].role||''}`)); + } + return lines.join('\n'); +} +const FENCE='```'; +const YAML_EXAMPLE=`schema: muster/v1 +playbook: "Ransomware on ASSET-T" +id: PB-ASSET-T-01 +category: "Malware" +steps: + - id: start + type: start + title: "EDR alert: mass file renames on ASSET-T" + phase: "Detection" + next: + - to: triage + - id: triage + type: action + title: "Confirm encryption activity" + phase: "Detection" + owner: "L1 Analyst" + sla_minutes: 15 + instructions: "Confirm before escalating; do not power off." + checklist: + - "Check the EDR process tree on ASSET-T" + - "Count renamed files in the last 10 minutes" + targets: ["id:ASSET-T", "contacts:id:ASSET-T"] + next: + - to: spread + - id: spread + type: decision + title: "Spread beyond ASSET-T?" + phase: "Containment" + owner: "L2 Analyst" + sla_minutes: 10 + targets: ["reach:id:ASSET-T"] + next: + - to: isolate_all + when: "Yes" + - to: isolate_one + when: "No" + # ... every path continues down to a step of type: end`; +function prompts(site,id,o){ + const opts=Object.assign({lang:'fr',versions:true,cves:true,biz:false,neigh:true,ctx:''},o||{}); + const fr=opts.lang==='fr',T=L[opts.lang]||L.en,P=placeholders(site,id,opts); + const ctx=String(opts.ctx||'').trim()||T.defCtx,block=assetBlock(site,id,P,opts); + const risk=fr?`Tu es architecte sécurité senior. Je te décris UN actif d'un système d'information, anonymisé : les noms, adresses et personnes sont remplacés par des repères (ASSET-T, ASSET-01, SEG-1…). Garde ces repères tels quels dans ta réponse. + +## ${T.ctx} +${ctx} + +${block} + +## Ce que j'attends +1. Les 5 risques majeurs pour ASSET-T, en tableau : risque, scénario d'attaque (accès initial → impact), techniques MITRE ATT&CK, vraisemblance (1-4), impact (1-4), justification (cite les faits ci-dessus). +2. Pour les 3 premiers : mesures rapides (moins d'une semaine), mesures de fond, et signaux de détection (source de journaux + ce qu'il faut y chercher). +3. Les flux ci-dessus que tu restreindrais ou couperais en premier, et pourquoi. +4. Les questions à me poser pour affiner l'analyse. +N'invente pas de faits absents de la description ; signale chaque hypothèse.` + :`You are a senior security architect. I am describing ONE asset of an information system, anonymised: names, addresses and people are replaced by placeholders (ASSET-T, ASSET-01, SEG-1…). Keep the placeholders as they are in your answer. + +## ${T.ctx} +${ctx} + +${block} + +## What I need +1. The 5 major risks for ASSET-T, as a table: risk, attack scenario (initial access → impact), MITRE ATT&CK techniques, likelihood (1-4), impact (1-4), reasoning (cite the facts above). +2. For the top 3: quick mitigations (under a week), structural ones, and detection signals (log source + what to look for). +3. Which of the flows above you would restrict or cut first, and why. +4. The questions you would ask me to refine this. +Do not invent facts that are not in the description; flag every assumption.`; + const sels='compromised, affected, id:ASSET-01, type:, role:, vuln:, exposed:internet, reach:, consumers:, flows:, business:, contacts:'; + const rules=fr?`Règles du format (muster/v1) : +- phase, uniquement : Detection, Containment, Eradication, Recovery, Lessons learned +- type d'étape : start (une seule), action, decision, escalation, end (au moins une) +- chaque decision a au moins 2 entrées next, chacune avec un libellé when ("Yes" / "No" ou le choix) +- owner, uniquement : L1 Analyst, L2 Analyst, IR Lead, CISO, Sysadmin, Network team, DPO, Communications, Business owner +- sla_minutes : délai cible réaliste pour chaque action, décision et escalade +- checklist : 2 à 6 tâches concrètes et propres à cet actif (consoles, commandes, journaux à extraire), pas de conseils génériques +- targets : les actifs, flux, services ou contacts de la cartographie sur lesquels l'étape agit, avec ces sélecteurs : ${sels} +- titres, consignes et checklist en français ; clés et valeurs de phase, owner et type en anglais, comme ci-dessus +- garde les repères ; n'invente jamais de vrais noms` + :`Format rules (muster/v1): +- phase, only: Detection, Containment, Eradication, Recovery, Lessons learned +- step type: start (exactly one), action, decision, escalation, end (at least one) +- every decision has 2 or more next entries, each with a when label ("Yes" / "No" or the choice) +- owner, only: L1 Analyst, L2 Analyst, IR Lead, CISO, Sysadmin, Network team, DPO, Communications, Business owner +- sla_minutes: a realistic target for every action, decision and escalation +- checklist: 2 to 6 concrete tasks specific to this asset (consoles, commands, logs to pull), no generic advice +- targets: the assets, flows, services or contacts of the map the step acts on, with these selectors: ${sels} +- keep the placeholders; never invent real names`; + const play=fr?`Tu es responsable de la réponse à incident. À partir de la description anonymisée ci-dessous, conçois les playbooks de réponse aux incidents les plus plausibles sur cet actif. Garde les repères (ASSET-T, ASSET-01, SEG-1…) tels quels. + +## ${T.ctx} +${ctx} + +${block} + +## Étape 1 — Incidents plausibles +Liste 3 à 5 incidents qui pourraient réellement toucher ASSET-T, classés par vraisemblance × impact. Pour chacun : nom, premier signal (qui le voit, dans quel outil), chemin d'attaque en 3 à 6 étapes avec les repères, techniques ATT&CK, impact métier, playbook générique le plus proche parmi PB-001 Ransomware, PB-002 Phishing, PB-003 Compte compromis, PB-005 Violation de données personnelles, PB-006 Malware sur un poste, PB-007 DDoS, PB-009 Menace interne, PB-010 Vulnérabilité critique exploitée, et ce que ce playbook générique rate pour cet actif. + +## Étape 2 — Playbooks +Pour les 3 premiers incidents, écris un playbook chacun au format YAML muster/v1 ci-dessous, pour l'importer dans notre outil de réponse à incident. Un bloc YAML par playbook. + +${rules} + +Exemple (forme seulement) : +${FENCE}yaml +${YAML_EXAMPLE} +${FENCE} + +## Étape 3 — Vérifications +Pour chaque playbook : les hypothèses à valider avec l'équipe, et un inject d'exercice sur table pour le tester (heure, signal, décision attendue).` + :`You are an incident response lead. From the anonymised description below, design response playbooks for the incidents most plausible on this asset. Keep the placeholders (ASSET-T, ASSET-01, SEG-1…) as they are. + +## ${T.ctx} +${ctx} + +${block} + +## Step 1 — Plausible incidents +List 3 to 5 incidents that could realistically hit ASSET-T, ranked by likelihood × impact. For each: name, first signal (who sees it, in which tool), attack path in 3 to 6 steps using the placeholders, ATT&CK techniques, business impact, the closest generic playbook among PB-001 Ransomware, PB-002 Phishing, PB-003 Compromised account, PB-005 Personal data breach, PB-006 Malware on an endpoint, PB-007 DDoS, PB-009 Insider threat, PB-010 Exploited critical vulnerability, and what that generic playbook misses for this asset. + +## Step 2 — Playbooks +For the top 3 incidents, write one playbook each in the muster/v1 YAML format below, so it can be imported into our incident response tool. One YAML block per playbook. + +${rules} + +Example (shape only): +${FENCE}yaml +${YAML_EXAMPLE} +${FENCE} + +## Step 3 — Checks +For each playbook: the assumptions to validate with the team, and one tabletop exercise inject to test it (time, signal, expected decision).`; + return {P,risk,play}; +} +// Strings that must never reach a public AI: names that identify (with a digit, dash, dot or underscore), +// addresses, subnets, phone numbers, e-mail addresses and their domains, people's names, the IS name, listed terms +function sensitive(site,never){ + const s=new Set(),add=x=>{x=String(x||'').trim();if(x.length>2)s.add(x);}; + const person=x=>/^[A-ZÀ-Ý][a-zà-ÿ'-]+(\s+[A-ZÀ-Ý][a-zà-ÿ'-]+)+$/.test(String(x||'').trim()); + const contact=c=>{add(c.phone);add(c.email);if(c.email&&c.email.includes('@'))add(c.email.split('@')[1]);if(person(c.name))add(c.name);}; + add(site.name); + site.assets.forEach(a=>{if(/[\d._-]/.test(a.name))add(a.name);add(a.ip);(a.contacts||[]).forEach(contact);}); + site.vlans.forEach(v=>add(v.cidr));site.contacts.forEach(contact); + site.business.forEach(b=>{if(person(b.owner))add(b.owner);}); + String(never||'').split(',').forEach(add); + return [...s]; +} +function leaks(site,text,never){const low=String(text).toLowerCase();return sensitive(site,never).filter(x=>low.includes(x.toLowerCase()));} +function reidentify(site,text,P){ + return String(text).replace(/\b(ASSET-T|ASSET-\d\d|SEG-\d+|SVC-B\d+)\b/g,ph=>{const r=P.rev.find(x=>x.ph===ph);if(!r)return ph; + if(r.kind==='asset')return site.byId[r.real].name; + if(r.kind==='segment'){const v=site.vlanById[r.real.slice(5)];return v?`VLAN ${v.num}`:ph;} + const b=site.bizById[r.real.slice(4)];return b?b.name:ph;}); +} +// A quick look at muster/v1 playbooks pasted back: how many, how many steps, unknown step types +function yamlCheck(text){ + const blocks=(String(text).match(/^\s*schema:\s*muster\/v1\s*$/gm)||[]).length;if(!blocks)return null; + const types=[...String(text).matchAll(/^\s*type:\s*(\w+)/gm)].map(m=>m[1]); + return {blocks,steps:types.length,bad:[...new Set(types.filter(t=>!['start','action','decision','escalation','end'].includes(t)))],targets:(String(text).match(/^\s*targets:/gm)||[]).length}; +} + +const api={STATES,empty,clean,marked,affected,reach,consumers,business,selectAssets,resolve,cutPoint,flowsToCut,callList, + PLAYBOOKS,step,nextOf,start,advance,route,demoFeed,placeholders,prompts,sensitive,leaks,reidentify,yamlCheck}; +root.StrataIncident=api; +})(typeof globalThis!=='undefined'?globalThis:this); diff --git a/public/index.html b/public/index.html index 9162665..ce6b915 100644 --- a/public/index.html +++ b/public/index.html @@ -108,6 +108,12 @@

    Minimum exposure

    Minimum severity

    +
    +

    Incident

    +
    + +
    +

    Analysis

    @@ -204,6 +210,7 @@

    Legend

    + diff --git a/scripts/build-online.mjs b/scripts/build-online.mjs new file mode 100644 index 0000000..805251f --- /dev/null +++ b/scripts/build-online.mjs @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Builds dist-online/strata.html: the whole interface in one page that runs without a server, as a Claude +// artifact or any static host. The map lives in the browser (local mode, as when api/me does not answer), +// three.js comes from jsDelivr through an import map, the fonts from Google Fonts, and the demo loads on first +// open. The self-hosted version is unchanged: it serves public/ and its vendored copies. +// Run with: npm run build:online +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PUB = path.join(ROOT, 'public'); +const OUT = path.join(ROOT, 'dist-online'); +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); +const THREE = pkg.dependencies.three.replace(/^[^\d]*/, ''); +const read = f => fs.readFileSync(path.join(PUB, f), 'utf8'); +// A script inlined in the page must not close its own + + + + +`; +fs.mkdirSync(OUT, { recursive: true }); +fs.writeFileSync(path.join(OUT, 'strata.html'), page); +console.log(`dist-online/strata.html written (${Math.round(page.length / 1024)} KB, three.js ${THREE} from jsDelivr)`); diff --git a/server/server.js b/server/server.js index 787b6e1..c4d2476 100644 --- a/server/server.js +++ b/server/server.js @@ -60,6 +60,7 @@ const DB_FILE = path.join(CONFIG.dataDir, 'db.json'); const USERS_FILE = path.join(CONFIG.dataDir, 'users.json'); const HISTORY_FILE = path.join(CONFIG.dataDir, 'history.jsonl'); const PROPOSALS_FILE = path.join(CONFIG.dataDir, 'proposals.json'); +const INCIDENTS_FILE = path.join(CONFIG.dataDir, 'incidents.json'); const BACKUP_DIR = path.join(CONFIG.dataDir, 'backups'); // Ids and account names become object keys: the names that reach Object.prototype are refused const ID_RE = /^(?!(?:__proto__|constructor|prototype)$)[A-Za-z0-9_.:@+~-]{1,120}$/; @@ -81,7 +82,7 @@ let state = { si: {}, assets: {}, wan: [], services: [], rev: 0, updatedAt: null // The data files describe the weak spots of the network, the accounts and the connector secrets: readable by // the server's own user only. Files written by older versions with the default mode are tightened at start. function protectDataFiles() { - const files = [DB_FILE, USERS_FILE, HISTORY_FILE, PROPOSALS_FILE, path.join(CONFIG.dataDir, 'connectors.json')]; + const files = [DB_FILE, USERS_FILE, HISTORY_FILE, PROPOSALS_FILE, INCIDENTS_FILE, path.join(CONFIG.dataDir, 'connectors.json')]; try { for (const f of fs.readdirSync(BACKUP_DIR)) files.push(path.join(BACKUP_DIR, f)); } catch { } for (const f of files) { try { if ((fs.statSync(f).mode & 0o077) !== 0) fs.chmodSync(f, 0o600); } catch { } } } @@ -196,6 +197,26 @@ const nameOfDoc = (kind, doc) => !doc ? '' : kind === 'si' ? (doc.name || '') : let proposals = {}; function loadProposals() { if (fs.existsSync(PROPOSALS_FILE)) { try { proposals = JSON.parse(fs.readFileSync(PROPOSALS_FILE, 'utf8')) || {}; } catch { proposals = {}; } } } const saveProposals = () => { broadcast('proposals', { at: Date.now() }); return writeJson(PROPOSALS_FILE, proposals); }; + +/* ---------------- Incidents ---------------- + Incident mode, one document per IS: the assets marked compromised, suspected or contained, the flows cut, the + Muster incident being run and its timeline. Kept apart from the map and its history: during an incident it + changes by the minute and goes through no proposal. Contributors and up change it. */ +let incidents = {}; +const INCIDENT_STATES = ['compromised', 'suspected', 'contained']; +function loadIncidents() { if (fs.existsSync(INCIDENTS_FILE)) { try { const o = JSON.parse(fs.readFileSync(INCIDENTS_FILE, 'utf8')); incidents = isObj(o) ? o : {}; } catch { incidents = {}; } } } +const saveIncidents = () => { broadcast('incidents', { at: Date.now() }); return writeJson(INCIDENTS_FILE, incidents); }; +function cleanIncident(b) { + if (!isObj(b)) throw httpError(400, 'Body must be a JSON object'); + const marks = {}; + if (isObj(b.marks)) for (const [k, v] of Object.entries(b.marks)) { if (ID_RE.test(k) && INCIDENT_STATES.includes(v)) marks[k] = v; } + return { + on: !!b.on, marks, + cuts: Array.isArray(b.cuts) ? b.cuts.filter(x => typeof x === 'string' && ID_RE.test(x)).slice(0, 5000) : [], + run: isObj(b.run) ? b.run : null, + log: Array.isArray(b.log) ? b.log.filter(isObj).slice(0, 500) : [], + }; +} function cleanChanges(c) { const out = { si: {}, assets: {} }; if (!isObj(c)) return out; @@ -484,6 +505,18 @@ async function api(req, res, parts, url, auth) { return; } + /* ---- incident mode: contributors and up, without a proposal ---- */ + if (r1 === 'incidents' && !id && method === 'GET') return send(res, 200, { incidents }); + if (r1 === 'incidents' && id !== undefined && !r3 && method === 'PUT') { + if (!ID_RE.test(id)) throw httpError(400, 'Invalid id'); + need(auth, 'contributor'); + if (!own(state.si, id)) throw httpError(404, 'Unknown IS'); + const doc = cleanIncident(await readBody(req, 512 * 1024)); + incidents[id] = { ...doc, updatedAt: new Date().toISOString(), updatedBy: auth.user || '' }; + await saveIncidents(); + return send(res, 200, { ok: true, incident: incidents[id] }); + } + /* ---- live map: approvers and admins ---- */ if ((r1 === 'si' || r1 === 'assets') && id !== undefined) { if (!ID_RE.test(id)) throw httpError(400, 'Invalid id'); @@ -798,6 +831,7 @@ const connectors = /^(0|false|no|off)$/i.test(env.CONNECTORS || '') ? null : cre }); const modStore = connectors ? createStore({ registryUrl: CONFIG.moduleRegistry, uploads: CONFIG.moduleUpload, installedDir: MODULES_INSTALLED, strataVersion: STRATA_VERSION, connectors, log }) : null; loadProposals(); +loadIncidents(); bootstrapUsers(); await backup('startup'); setInterval(() => backup('scheduled').catch(e => log('Backup failed:', e.message)), CONFIG.backupEveryHours * 3600 * 1000).unref(); diff --git a/tests/api.test.mjs b/tests/api.test.mjs index 1ebc201..b51e86e 100644 --- a/tests/api.test.mjs +++ b/tests/api.test.mjs @@ -317,3 +317,23 @@ test('live updates: /api/events streams the revision on every change, and propos assert.equal(typeof p.data.at, 'number'); } finally { ev.close(); } }); + +test('incidents: contributors mark assets and cut flows without a proposal, viewers only read', async () => { + const admin = as('admin'), alice = as('alice'), vic = as('vic'); + assert.equal((await admin.put('/api/si/inc-is', { name: 'Incident IS', zones: [], vlans: [], flows: [] })).status, 200); + const before = await rev(); + const doc = { on: true, marks: { 'a-1': 'compromised', 'a-2': 'owned' }, cuts: ['f-1', 3], run: null, log: [{ at: 1, msg: 'Incident mode on' }] }; + // a mark on a prototype key is dropped too + const r = await alice.put('/api/incidents/inc-is', JSON.stringify(doc).replace('"a-2"', '"__proto__":"compromised","a-2"')); + assert.equal(r.status, 200, JSON.stringify(r.data)); + assert.deepEqual(r.data.incident.marks, { 'a-1': 'compromised' }, 'unknown states are dropped'); + assert.deepEqual(r.data.incident.cuts, ['f-1']); + assert.equal(r.data.incident.updatedBy, 'alice'); + assert.equal(await rev(), before, 'the map and its history do not change'); + assert.equal((await vic.put('/api/incidents/inc-is', doc)).status, 403, 'viewers cannot change an incident'); + assert.equal((await alice.put('/api/incidents/no-such-is', doc)).status, 404); + assert.equal((await alice.put('/api/incidents/inc-is', [])).status, 400); + const all = await vic.get('/api/incidents'); + assert.equal(all.status, 200); + assert.equal(all.data.incidents['inc-is'].on, true); +}); diff --git a/tests/e2e.mjs b/tests/e2e.mjs index 9087e5d..822d3b3 100644 --- a/tests/e2e.mjs +++ b/tests/e2e.mjs @@ -161,3 +161,45 @@ test('walk-through: demo, 3D model, 2D diagram, then a proposal from draft to ap assert.deepEqual(errors, [], 'JavaScript errors in the pages'); }); + +test('incident mode: a Muster incident opens on the map, a contributor works it, others see it live', async () => { + // runs after the walk-through: the demo is loaded + const alice = await signIn('alice'); + await openIs(alice, 'lyon'); + const bob = await signIn('bob'); + await openIs(bob, 'lyon'); + + // the contributor switches incident mode on and opens the ransomware example + await alice.click('#incBtn'); + await alice.click('#det [data-inc-open="0"]'); + await alice.waitForSelector('#det .inc-run'); + await alice.click('#det [data-inc-go]'); + await alice.click('#det [data-inc-lbl="Yes"]'); + await alice.waitForFunction(() => /Isolate affected hosts/.test(document.querySelector('#det .inc-step.cur')?.textContent || '')); + const text = await alice.locator('#det').textContent(); + for (const s of ['file-01', 'pc-acct-01', 'Who to call', 'Flows to cut', 'Reachable next']) assert.ok(text.includes(s), `the incident sheet lacks ${s}`); + // the step points at the flows to cut, and cutting one is recorded + await alice.click('#det .inc-tg [data-inc-cut]'); + await alice.waitForFunction(() => /Flow cut at/.test(document.querySelector('#det .inc-log')?.textContent || '')); + // the 2D diagram shows the marks + await alice.click('#viewSeg [data-v="2d"]'); + await alice.waitForSelector('#flatSvg text'); + assert.match(await alice.locator('#flatSvg').textContent(), /COMPROMISED/); + + // the approver's page follows without a reload + await bob.waitForFunction(() => /Incident mode/i.test(document.querySelector('#cart .incband')?.textContent || '')); + const saved = await api('bob', 'GET', 'api/incidents'); + assert.equal(saved.data.incidents.lyon.run.cur, 'isolate'); + assert.equal(saved.data.incidents.lyon.cuts.length, 1); + + // the anonymised prompts of an asset name nothing that identifies it + await alice.fill('#search', 'db-erp'); + await alice.press('#search', 'Enter'); + await alice.click('#det [data-ai-asset]'); + await alice.waitForSelector('#ai-risk'); + const prompt = await alice.locator('#ai-risk').textContent(); + for (const s of ['db-erp', '10.20.0.21', 'Lyon', 'example.com']) assert.ok(!prompt.includes(s), `the prompt leaks ${s}`); + assert.match(prompt, /ASSET-T/); + + assert.deepEqual(errors, [], 'JavaScript errors in the pages'); +}); diff --git a/tests/incident.test.mjs b/tests/incident.test.mjs new file mode 100644 index 0000000..e9600fd --- /dev/null +++ b/tests/incident.test.mjs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Unit tests of public/incident.js: incident mode and the anonymised AI prompts. Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; + +const ctx = {}; +runInNewContext(readFileSync(new URL('../public/incident.js', import.meta.url), 'utf8'), ctx); +const I = ctx.StrataIncident; +const J = x => JSON.parse(JSON.stringify(x)); + +// A small compiled IS: the Internet reaches a web server through the edge firewall, the web server calls a +// database, a workstation reaches the file server, the backup server pulls from it +function site() { + const asset = (id, type, role, crit, vlan, extra = {}) => Object.assign({ id, name: id, type, role, crit, vlan, ip: '10.0.0.' + id.length, os: 'Debian 12.4', vulns: [], contacts: [], apps: [] }, extra); + const assets = [ + asset('fw', 'firewall', 'Edge firewall', 3, 'v1'), + asset('web-01', 'server', 'Customer portal', 2, 'v1', { vulns: [{ id: 'CVE-2021-44228', title: 'Log4Shell', cvss: 10, sev: 'crit', kev: true, epss: 0.97 }], contacts: [{ name: 'Web team', role: 'Technical owner', phone: '+33 1 00 00 00 01', email: 'web@acme.example', dispo: 'Business hours' }] }), + asset('db-01', 'db', 'ERP database', 3, 'v2', { contacts: [{ name: 'Jeanne Martin', role: 'On-call', phone: '+33 1 00 00 00 02', email: '', dispo: '24/7' }] }), + asset('pc-01', 'workstation', 'Accounting workstation', 1, 'v3'), + asset('file-01', 'server', 'File server', 2, 'v2'), + asset('bk-01', 'server', 'Backup server', 3, 'v2'), + ]; + const flow = (id, src, dst, port, via = [], extra = {}) => Object.assign({ id, src, dst, port, via, vol: 10, risk: false, note: '' }, extra); + const flows = [ + flow('f1', 'internet', 'web-01', 'tcp/443', ['fw']), + flow('f2', 'web-01', 'db-01', 'tcp/5432', ['fw']), + flow('f3', 'pc-01', 'file-01', 'tcp/445'), + flow('f4', 'bk-01', 'file-01', 'tcp/445'), + flow('f5', 'pc-01', 'svc:m365', 'tcp/443'), + flow('f6', 'pc-01', 'db-01', 'tcp/5432', [], { risk: true, note: 'Workstation straight to the database' }), + ]; + const byId = {}; assets.forEach(a => { byId[a.id] = a; }); + const vlans = [{ id: 'v1', num: 10, name: 'DMZ', cidr: '10.10.0.0/24', zone: 'Exposed' }, { id: 'v2', num: 20, name: 'Servers', cidr: '10.20.0.0/24', zone: 'Core' }, { id: 'v3', num: 30, name: 'Users', cidr: '10.30.0.0/24', zone: 'Office' }]; + const vlanById = {}; vlans.forEach(v => { vlanById[v.id] = v; }); + const business = [{ id: 'b1', name: 'ERP', crit: 3, owner: 'Finance director', assets: ['db-01'] }, { id: 'b2', name: 'Portal', crit: 2, owner: 'Paul Durand', assets: ['web-01'] }]; + return { name: 'Lyon', assets, byId, flows, vlans, vlanById, business, bizById: { b1: business[0], b2: business[1] }, svcById: { 'svc:m365': { name: 'Microsoft 365', category: 'SaaS' } }, + contacts: [{ name: 'SOC', role: 'On-call', phone: '+33 1 00 00 00 09', email: 'soc@acme.example', dispo: '24/7' }], expo: { 'web-01': 1, 'db-01': 2 }, critIds: ['fw', 'db-01', 'bk-01'] }; +} + +test('clean: keeps known states only and gives an empty incident for anything else', () => { + assert.deepEqual(J(I.clean({ on: 1, marks: { a: 'compromised', b: 'pwned' }, cuts: ['f1', 2] })), { on: true, marks: { a: 'compromised' }, cuts: ['f1'], run: null, log: [] }); + assert.deepEqual(J(I.clean('x')), J(I.empty())); +}); + +test('selectors: assets by state, type, role, vulnerability, exposure, reach and consumers', () => { + const s = site(), inc = I.clean({ marks: { 'pc-01': 'compromised', 'web-01': 'suspected' } }); + assert.deepEqual(J(I.selectAssets(s, inc, 'affected')).sort(), ['pc-01', 'web-01']); + assert.deepEqual(J(I.selectAssets(s, inc, 'compromised')), ['pc-01']); + assert.deepEqual(J(I.selectAssets(s, inc, 'type:db')), ['db-01']); + assert.deepEqual(J(I.selectAssets(s, inc, 'role:backup')), ['bk-01']); + assert.deepEqual(J(I.selectAssets(s, inc, 'vuln:{cve}', { cve: 'CVE-2021-44228' })), ['web-01']); + assert.deepEqual(J(I.selectAssets(s, inc, 'vuln:{cve}', {})), [], 'a missing parameter selects nothing'); + assert.deepEqual(J(I.selectAssets(s, inc, 'exposed:internet')), ['web-01']); + assert.deepEqual(J(I.selectAssets(s, inc, 'reach:compromised')).sort(), ['db-01', 'file-01']); + assert.deepEqual(J(I.selectAssets(s, inc, 'consumers:id:file-01')).sort(), ['bk-01', 'pc-01']); + assert.equal(I.resolve(s, inc, 'flows:compromised').kind, 'flows'); + assert.deepEqual(J(I.resolve(s, inc, 'business:affected').items.map(b => b.id)), ['b2']); +}); + +test('flows to cut: Internet first, cut at the firewall crossed, isolated hosts count as cut', () => { + const s = site(), inc = I.clean({ marks: { 'web-01': 'compromised' } }); + const rows = I.flowsToCut(s, inc, ['web-01']); + assert.equal(rows[0].f.id, 'f1', 'the way in from the Internet comes first'); + assert.equal(rows[0].prio, 1); + assert.equal(rows[0].at.id, 'fw'); + assert.match(rows[1].why.join(' '), /critical asset db-01/); + const pc = I.flowsToCut(s, I.clean({ marks: { 'pc-01': 'contained' } }), ['pc-01']); + assert.ok(pc.every(r => r.cut && r.iso)); + assert.equal(I.cutPoint(s, s.flows[2]).id, null); + assert.match(I.cutPoint(s, s.flows[2]).label, /host/); +}); + +test('who to call: on-call first, then owners, business owners and the IS contacts', () => { + const s = site(), list = J(I.callList(s, ['db-01', 'web-01'])); + assert.deepEqual(list.map(c => c.name), ['Jeanne Martin', 'SOC', 'Web team', 'Finance director', 'Paul Durand']); + assert.deepEqual(list[0].why, ['db-01']); +}); + +test('playbook run: decisions move along the edges and their effects mark the assets', () => { + const s = site(), pb = I.PLAYBOOKS['PB-010'], inc = I.empty(); + inc.run = I.start({ ref: 'IR-1', name: 'x', mode: 'exercise', severity: 'Critical', params: { cve: 'CVE-2021-44228' } }, pb, 0); + assert.equal(inc.run.cur, 'exposure'); + I.advance(s, inc, pb, 'found'); + assert.deepEqual(J(I.advance(s, inc, pb, 'mitigate', 'Yes')), [['web-01', 'suspected']]); + assert.equal(inc.run.answers.found, 'Yes'); + assert.deepEqual(J(I.route(pb, inc.run)), ['start', 'exposure', 'found', 'mitigate', 'hunt', 'comp', 'major', 'review', 'end'], 'the route ahead follows the first edge out of each step'); + I.advance(s, inc, pb, 'hunt'); I.advance(s, inc, pb, 'comp'); I.advance(s, inc, pb, 'review', 'No'); I.advance(s, inc, pb, 'end'); + assert.equal(inc.run.closed, true); +}); + +test('prompts: nothing identifying reaches the text, and the answer comes back with real names', () => { + const s = site(), { P, risk, play } = I.prompts(s, 'db-01', { lang: 'en' }); + for (const t of [risk, play]) { + assert.deepEqual(J(I.leaks(s, t, '')), []); + for (const x of ['db-01', 'web-01', '10.20.0.0/24', 'Jeanne Martin', 'acme.example', 'Lyon']) assert.ok(!t.includes(x), x); + } + assert.match(risk, /ASSET-T/); + assert.match(risk, /Log4Shell \(KEV\)/, 'neighbours keep their exploited vulnerabilities'); + assert.match(play, /schema: muster\/v1/); + assert.ok(!/ERP/.test(risk.split('## What I need')[0].replace('ERP database', '')), 'business names are replaced by default'); + const back = I.reidentify(s, 'Isolate ASSET-T and watch ASSET-01 in SEG-1.', P); + assert.match(back, /Isolate db-01 and watch/); + assert.match(back, /VLAN 20/); + assert.deepEqual(J(I.leaks(s, 'call Jeanne Martin', '')), ['Jeanne Martin']); + assert.deepEqual(J(I.leaks(s, 'Acme Corp runs it', 'Acme Corp')), ['Acme Corp']); + const v = I.prompts(s, 'db-01', { lang: 'fr', versions: false }).risk; + assert.match(v, /Debian 12\.x/); + assert.match(v, /Tu es architecte/); +}); + +test('yamlCheck: counts muster/v1 playbooks and flags unknown step types', () => { + assert.equal(I.yamlCheck('no yaml'), null); + assert.deepEqual(J(I.yamlCheck('schema: muster/v1\nsteps:\n - id: a\n type: start\n targets: ["affected"]\n - id: b\n type: banana\n')), { blocks: 1, steps: 2, bad: ['banana'], targets: 1 }); +}); + +test('demo feed: builds the two Muster examples from the map', () => { + const s = site(); + const f = J(I.demoFeed(s)); + assert.deepEqual(f.map(x => x.pb), ['PB-001', 'PB-010']); + assert.deepEqual(f[0].assets, ['file-01', 'pc-01']); + assert.equal(f[1].params.cve, 'CVE-2021-44228'); +}); From 17efdce85a6a2cde541a70349919c80e21461425 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:27:42 +0000 Subject: [PATCH 2/6] One-page build: start without asking for a server Inside a host page the api/me request could stay unanswered, and the map waited for it forever on "Loading". The one-page build no longer sends it. three.js is now imported by absolute URL from jsDelivr's ES module builds, with no import map, and an error at start-up shows on the page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ke7yEW5dhDYFVpV2cdgs87 Signed-off-by: Claude --- scripts/build-online.mjs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/build-online.mjs b/scripts/build-online.mjs index 805251f..0ad974e 100644 --- a/scripts/build-online.mjs +++ b/scripts/build-online.mjs @@ -1,8 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Builds dist-online/strata.html: the whole interface in one page that runs without a server, as a Claude // artifact or any static host. The map lives in the browser (local mode, as when api/me does not answer), -// three.js comes from jsDelivr through an import map, the fonts from Google Fonts, and the demo loads on first +// three.js comes from jsDelivr, the fonts from Google Fonts, and the demo loads on first // open. The self-hosted version is unchanged: it serves public/ and its vendored copies. +// three.js is imported by absolute URL from jsDelivr's ES module builds (+esm), whose own imports are absolute +// too: no import map, which a host page that has already loaded a module would ignore. // Run with: npm run build:online import fs from 'node:fs'; import path from 'node:path'; @@ -20,12 +22,20 @@ const js = s => s.replace(/<\/script/gi, '<\\/script'); const html = read('index.html'); const body = html.slice(html.indexOf('') + 6, html.indexOf('')) .replace(/ + From 728f62e0181068ebcc1dfba5eff012fea3f2f7dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:39:08 +0000 Subject: [PATCH 3/6] Connect to Muster: its incidents open on the map and run from there With MUSTER_URL, MUSTER_USER and MUSTER_PASSWORD, the server signs in to Muster with an analyst account, reads its incidents every few seconds and passes calls on (api/muster: read for every role, act for contributors and up). The incident sheet lists the responses linked to the IS, or naming its assets, and offers the others. Opening one marks the assets it names; the playbook then runs from the map with the targets Muster stores on each step. Answers, finished steps and tasks move the response in Muster, what Muster's analysts do shows on the map, and a decision answered anywhere applies its effects. Marks and flows cut go to Muster's timeline, written by the Strata account with the name of the person who did them. Without MUSTER_URL the two example incidents stay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ke7yEW5dhDYFVpV2cdgs87 Signed-off-by: Claude --- .env.example | 7 +++ CHANGELOG.md | 5 +- README.md | 6 +++ package.json | 2 +- public/app.js | 114 +++++++++++++++++++++++++++++++++++------- public/incident.js | 50 ++++++++++++++++-- server/muster.js | 69 +++++++++++++++++++++++++ server/server.js | 28 ++++++++++- tests/muster.test.mjs | 105 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 360 insertions(+), 26 deletions(-) create mode 100644 server/muster.js create mode 100644 tests/muster.test.mjs diff --git a/.env.example b/.env.example index 793eb4b..cd8149c 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,13 @@ HOST=0.0.0.0 # Longest a connector run may take before it is stopped # CONNECTOR_TIMEOUT_MINUTES=30 +# Muster (incident response playbooks): its incidents open on the map. Create the account in Muster with the +# analyst role. MUSTER_PASSWORD_FILE reads the password from a file. +# MUSTER_URL=http://muster:8080 +# MUSTER_USER=strata +# MUSTER_PASSWORD= +# MUSTER_POLL_SECONDS=10 + # "Source code" link in the app (AGPL). Point it at your fork if you modify Strata. # SOURCE_URL=https://github.com/SolidGeeks/strata diff --git a/CHANGELOG.md b/CHANGELOG.md index b2251f4..086ee35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,10 @@ All notable changes to Strata. Versions follow [Semantic Versioning](https://sem - **what depends on them**: business services hit, the assets that call them, the assets an attacker reaches next along the flows that leave them; - a timeline of every mark, cut and decision. The 3D model and the 2D diagram dim the rest, ring the assets hit and draw the flows to cut in red, the cut ones in grey. A red band in the title block keeps the incident in view. -- **Muster incidents open on the map.** A Muster incident carries Strata asset ids or parameters (a CVE); its playbook runs in the incident sheet, and each step points at the map through `targets` selectors (`affected`, `vuln:{cve}`, `role:backup`, `type:dc`, `flows:…`, `reach:…`, `consumers:…`, `business:…`, `contacts:…`). A decision can mark assets (`effects`). Ransomware (PB-001) and Exploited critical vulnerability (PB-010) are included. Until Muster is connected to a Strata server, the sheet offers two example incidents built from the map. +- **Muster incidents open on the map.** A Muster incident carries Strata asset ids or parameters (a CVE); its playbook runs in the incident sheet, and each step points at the map through `targets` selectors (`affected`, `vuln:{cve}`, `role:backup`, `type:dc`, `flows:…`, `reach:…`, `consumers:…`, `business:…`, `contacts:…`). A decision can mark assets (`effects`). Ransomware (PB-001) and Exploited critical vulnerability (PB-010) are included. Muster stores these on each step (see its changelog); without a Muster connection, the sheet offers two example incidents built from the map. - **Anonymised AI prompts per asset** (*AI prompts* on an asset sheet): one prompt for the major risks of the asset, one guide that has the AI list plausible incidents and write their response playbooks in Muster's `muster/v1` YAML, with `targets`. Names, addresses, subnets, contacts and the IS name are replaced by placeholders (`ASSET-T`, `ASSET-01`, `SEG-1`, `SVC-B1`); a leak check lists anything identifying left in the text, with your own terms to never send; the answer pasted back gets your names again, in the page only. French or English. -- **API**: `GET /api/incidents`, `PUT /api/incidents/{IS id}` (contributors and up, no proposal), and an `incidents` event on `/api/events`. Kept in `incidents.json`, outside the map and its history. +- **The connection to Muster** (`MUSTER_URL`, `MUSTER_USER`, `MUSTER_PASSWORD`). This server signs in to Muster with an analyst account and lists its responses; the incident sheet of an IS shows those linked to it (declared in Muster with this IS, or naming its assets), and the others with *Open on this IS*. Opening one marks the assets it names, then the playbook runs from the map: answering a decision or finishing a step moves the response in Muster, the tasks ticked go both ways, and what Muster's analysts do shows on the map within seconds. A decision answered anywhere marks the assets its `effects` name. Every mark and every flow cut goes to Muster's timeline, so its report keeps them, written by the Strata account with the name of the person who did it. Without `MUSTER_URL`, the two example incidents stay. +- **API**: `GET /api/incidents`, `PUT /api/incidents/{IS id}` (contributors and up, no proposal), `GET /api/muster`, `GET /api/muster/incidents/{id}`, `POST /api/muster/incidents/{id}/advance`, `/tasks`, `/strata`, and the `incidents` and `muster` events on `/api/events`. Kept in `incidents.json`, outside the map and its history. - **One-page build**: `npm run build:online` writes `dist-online/strata.html`, the whole interface without a server (map kept in the browser, three.js from jsDelivr). ## [0.9.0] - 2026-10-26 diff --git a/README.md b/README.md index 2534be6..193967d 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,9 @@ By default, the port only listens on the machine itself (`127.0.0.1:8080`). To o | `MODULE_UPLOAD` | `false` | `true` lets administrators install a module package from a file (no checksum: whoever uploads it vouches for it). | | `MODULES_DIR` | — | A folder of connector modules you provide yourself, one sub-folder each ([MODULES.md](MODULES.md)). | | `CONNECTOR_TIMEOUT_MINUTES` | `30` | Longest a connector run may take before it is stopped (2 minutes at most for a test). | +| `MUSTER_URL` | — | Address of your [Muster](https://github.com/SolidGeeks/muster) server, as this server reaches it (`http://muster:8080`). Its incidents then open on the map, in the incident sheet of the IS they name. Empty: no connection. | +| `MUSTER_USER` / `MUSTER_PASSWORD` | `strata` / — | The Muster account Strata signs in with: create it in Muster with the **analyst** role. `MUSTER_PASSWORD_FILE` reads the password from a file. | +| `MUSTER_POLL_SECONDS` | `10` | How often Strata reads Muster's incidents; every page then follows. | | `SOURCE_URL` | `https://github.com/SolidGeeks/strata` | The app's *Source code* link (AGPL): point it at your fork if you modify the code. | After changing `.env`, apply it with `docker compose up -d`. @@ -460,6 +463,9 @@ Every route needs the same credentials as the interface (a local account with Ba | `PUT /api/wan` | Replaces the links between IS: `{"links": [...]}` | | `POST /api/import` | Replaces the whole map: `{si, assets, wan, services}` | | `GET /api/incidents` | Incident mode of every IS: `{incidents: {: {on, marks, cuts, run, log}}}` | +| `GET /api/muster` | Muster's incidents and whether Muster answers (`{url, ok, error, incidents}`), when `MUSTER_URL` is set | +| `GET /api/muster/incidents/{id}` | One Muster incident: its frozen playbook, the steps taken, the link to Strata | +| `POST /api/muster/incidents/{id}/advance`, `/tasks`, `/strata` | Passed on to Muster (`contributor` and up): answer a step `{edgeId}`, tick a task `{nodeId, index, done}`, report the assets and what was done `{site, marks, names, log}` | | `PUT /api/incidents/{IS id}` | Replaces the incident of an IS (`contributor` and up, no proposal): marks are `compromised`, `suspected` or `contained` | | `PUT /api/services` | Replaces the common catalogue of Internet services: `{"services": [...]}` | | `GET /api/history` | Latest changes (`?before=REV&limit=N`); `GET /api/history/{rev}`: the before/after details | diff --git a/package.json b/package.json index 867cdf7..de9dbf5 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "scripts": { "vendor": "node scripts/vendor.mjs", "start": "node server/server.js", - "test": "node --test tests/api.test.mjs tests/analysis.test.mjs tests/incident.test.mjs tests/secrets.test.mjs tests/signin.test.mjs tests/hardening.test.mjs tests/readonly.test.mjs tests/modules.test.mjs tests/sso.test.mjs", + "test": "node --test tests/api.test.mjs tests/analysis.test.mjs tests/incident.test.mjs tests/muster.test.mjs tests/secrets.test.mjs tests/signin.test.mjs tests/hardening.test.mjs tests/readonly.test.mjs tests/modules.test.mjs tests/sso.test.mjs", "test:e2e": "node --test tests/e2e.mjs", "setup": "npm ci && npm run vendor", "build:online": "node scripts/build-online.mjs" diff --git a/public/app.js b/public/app.js index 9a06b13..8c8eaf1 100644 --- a/public/app.js +++ b/public/app.js @@ -387,6 +387,7 @@ const Store={ es.addEventListener('proposals',()=>{onProposalsChanged();}); es.addEventListener('connectors',()=>{onConnectorsChanged();}); es.addEventListener('incidents',()=>{onIncidentsChanged();}); + es.addEventListener('muster',()=>{musterLoad().then(musterSync);}); let first=true;es.addEventListener('open',()=>{if(!first)this.pull();first=false;}); }, async api(method,url,body){ @@ -3240,7 +3241,8 @@ const curInc=()=>state.view==='site'&&SITE?Inc.of(SITE.id):null; const incOn=()=>{const i=curInc();return !!(i&&i.on);}; const incMark=id=>incOn()?Inc.of(SITE.id).marks[id]||null:null; const hhmm=t=>{const d=new Date(t);return `${pad(d.getHours())}:${pad(d.getMinutes())}`;}; -function incLog(inc,msg,kind){inc.log.unshift({at:Date.now(),msg,kind:kind||'info',by:Store.user||''});} +function incLog(inc,msg,kind){inc.log.unshift({at:Date.now(),msg,kind:kind||'info',by:Store.user||''}); + if(inc.run&&inc.run.source==='muster'&&kind!=='sync')MU.out.push({msg,kind:kind==='crit'?'warn':kind==='dec'||kind==='ok'?kind:'info'});} // What the model and the diagram show, worked out once per change let INCV=null; function incView(){ @@ -3259,7 +3261,7 @@ function incRedraw(full){ if(state.panel==='incident')showIncident(true); else if(state.panel==='asset'&&selAsset())showAsset(selAsset()); } -function incSave(full){Inc.changed(SITE.id);incRedraw(full);} +function incSave(full){const inc=Inc.of(SITE.id);Inc.changed(SITE.id);if(inc.run&&inc.run.source==='muster')musterPush(SITE.id);incRedraw(full);} function incSetMark(inc,id,st,quiet){ const was=inc.marks[id]||null;if(was===(st||null))return; if(st)inc.marks[id]=st;else delete inc.marks[id]; @@ -3307,8 +3309,8 @@ function incTargetHtml(sel,s,run){ return `
    ${head}${incCallHtml(r.items.slice(0,4))}
    `; } function incRunHtml(inc){ - const run=inc.run,pb=IR.PLAYBOOKS[run.pb];if(!pb)return `

    Playbook ${esc(run.pb)} is unknown here.

    `; - const R=IR.route(pb,run),w=Inc.canWrite; + const rv=runView(inc);if(!rv)return `

    ${inc.run.source==='muster'?'Reading the incident from Muster…':`Playbook ${esc(inc.run.pb)} is unknown here.`}

    `; + const {pb,run}=rv,R=IR.route(pb,run),w=Inc.canWrite;if(!run.cur)return '

    This response has no step.

    '; return `
      ${R.map((sid,i)=>{const s=IR.step(pb,sid),cur=sid===run.cur&&!run.closed,done=i=run.path.length; let h=`
    1. ${done?'✓':i+1}
      ${esc(s.title)}${esc(s.phase)}${s.role?' · '+esc(s.role):''}${s.sla?` · SLA ${s.sla} min`:''}${run.answers[sid]?`→ ${esc(run.answers[sid])}`:''}`; if(cur){ @@ -3316,12 +3318,12 @@ function incRunHtml(inc){ h+=`
      ${s.desc?`

      ${esc(s.desc)}

      `:''} ${(s.checklist||[]).map((t,j)=>``).join('')} ${s.targets.length?`
      ${s.targets.map(t=>incTargetHtml(t,s,run)).join('')}
      `:''} - ${w&&out.length?`
      ${s.type==='decision'?out.map(e=>``).join(''):``}
      `:''}
      `;} + ${w&&out.length?`
      ${s.type==='decision'?out.map(e=>``).join(''):out.map(e=>``).join('')}
      `:''}
      `;} return h+'
  • ';}).join('')}`; } // What this integration sends back to Muster: the log entries and the assets linked to the incident function incContract(inc){ - const run=inc.run,pb=IR.PLAYBOOKS[run.pb],s=pb&&IR.step(pb,run.cur); + const run=inc.run,pb=IR.PLAYBOOKS[run.pb],s=pb&&IR.step(pb,run.cur);if(!pb)return {}; return {'Muster → Strata':{ref:run.ref,playbook:`${run.pb} v${run.v}`,mode:run.mode,assets:run.assets,params:run.params,step:s?{id:s.id,title:s.title,targets:s.targets}:null}, 'Strata → Muster':{log:inc.log.slice(0,4).map(l=>({at:new Date(l.at).toISOString(),msg:l.msg,by:l.by})),assets:IR.affected(SITE,inc).map(id=>({id,name:nameOf(id),state:inc.marks[id]}))}}; } @@ -3332,19 +3334,27 @@ function showIncident(keep){ let body=''; if(!inc.on)body+=`

    Incident mode is off for IS ${esc(SITE.name)}.

    ${w?'
    ':'

    Your role can read the incident, not change it.

    '}`; body+=`

    Muster

    `; - if(!run){ + if(!run&&musterOn()){ + const act=(MU.list||[]).filter(i=>i.status==='active'),{mine,other}=IR.musterFor(SITE,act); + const li=(i,linked)=>`
  • ${esc(i.ref)} · ${esc(i.name)}${esc(i.pbCode)} ${esc(i.pbName)} v${esc(i.v)} · ${esc(i.mode)}${i.current?' · now: '+esc(i.current.title):''}${i.strata&&i.strata.assets.length?' · assets: '+esc(i.strata.assets.join(', ')):''}${i.strata&&i.strata.params.cve?' · '+esc(i.strata.params.cve):''}${w?``:''}
  • `; + body+=`

    ${MU.ok?`Connected to ${esc(MU.url)}.`:`${esc(MU.error||'Muster cannot be reached.')}`}

    + ${mine.length?`
      ${mine.map(i=>li(i,true)).join('')}
    `:'

    No response in progress in Muster for this IS. In Muster, declare an incident and link it to Strata IS '+esc(SITE.id)+'.

    '} + ${other.length?`

    Other responses in progress in Muster

      ${other.map(i=>li(i,false)).join('')}
    `:''}`; + }else if(!run){ const feed=IR.demoFeed(SITE); body+=`

    No Muster incident is linked to this IS. When an analyst declares one in Muster with assets of this IS, it opens here and its playbook points at the model.

    ${feed.length?`
      ${feed.map((sc,i)=>`
    • ${esc(sc.ref)} · ${esc(sc.name)}${esc(sc.pb)} ${esc(IR.PLAYBOOKS[sc.pb].name)} · ${esc(sc.mode)} · ${esc(sc.note)}${w?``:''}
    • `).join('')}

    Muster is not connected to this Strata: these two incidents are examples built from this map.

    `:''}`; }else{ - const pb=IR.PLAYBOOKS[run.pb],mins=Math.floor((Date.now()-run.startedAt)/60000); - body+=`
    ${esc(run.ref)} · ${esc(run.pb)} v${esc(run.v)} · ${esc(run.mode)}T+${pad(Math.floor(mins/60))}:${pad(mins%60)}
    - ${esc(run.name)} -
    ${esc(run.severity)}${esc(pb?pb.name:run.pb)}${run.params.cve?`${esc(run.params.cve)}`:''}${run.closed?'Closed':''}
    - ${w?'
    ':''}
    + const rv=runView(inc),R=rv?rv.run:run,pb=rv&&rv.pb,real=run.source==='muster',mins=Math.floor((Date.now()-(R.startedAt||Date.now()))/60000); + const ended=R.closed?`${R.status==='stopped'?'Stopped':'Closed'}`:''; + body+=`
    ${esc(R.ref)} · ${esc(R.pb)} v${esc(R.v)} · ${esc(R.mode||'')}T+${pad(Math.floor(mins/60))}:${pad(mins%60)}
    + ${esc(R.name)} +
    ${esc(R.severity||'')}${esc(pb?pb.name:R.pb)}${R.params&&R.params.cve?`${esc(R.params.cve)}`:''}${ended}
    + ${real?`

    ${MU.ok?'Live from Muster: answers, tasks and steps go both ways.':`${esc(MU.error||'Muster cannot be reached.')}`}

    `:''} +
    ${real&&MU.url?`Open in Muster`:''}${w?'':''}

    Playbook · decisions point at the model

    ${incRunHtml(inc)} -
    What Strata and Muster exchange
    ${esc(JSON.stringify(incContract(inc),null,2))}
    `; + ${real?'':`
    What Strata and Muster exchange
    ${esc(JSON.stringify(incContract(inc),null,2))}
    `}`; } body+=`

    Assets hit · ${aff.length}

    ${aff.length?`
    ${aff.map(incChip).join('')}
    `:`

    Nothing marked. Select an asset on the model and mark it from its sheet${run?', or answer the playbook decisions':''}.

    `}`; if(aff.length&&iv){ @@ -3389,23 +3399,93 @@ det.addEventListener('click',e=>{ if(q('[data-inc-on]')){incToggle(true);return;} if(q('[data-inc-off]')){incToggle(false);return;} if(q('[data-inc-open]')){incOpenScenario(+q('[data-inc-open]').dataset.incOpen);return;} - if(q('[data-inc-go]')){const b=q('[data-inc-go]');incGo(b.dataset.incGo,b.dataset.incLbl||'');return;} + if(q('[data-inc-mopen]')){musterOpen(q('[data-inc-mopen]').dataset.incMopen);return;} + if(q('[data-inc-go]')){const b=q('[data-inc-go]');if(inc.run&&inc.run.source==='muster')musterGo(b.dataset.incEdge,b);else incGo(b.dataset.incGo,b.dataset.incLbl||'');return;} if(q('[data-inc-cut]')){const id=q('[data-inc-cut]').dataset.incCut,f=FLOWS.find(x=>x.id===id),r=IR.flowsToCut(SITE,inc,IR.affected(SITE,inc)).find(x=>x.f.id===id); const i=inc.cuts.indexOf(id);if(i>=0){inc.cuts.splice(i,1);incLog(inc,`Flow reopened: ${nameOf(f.src)} → ${nameOf(f.dst)} ${f.port}`);} else{inc.cuts.push(id);incLog(inc,`Flow cut at ${r?r.at.label:'the host'}: ${nameOf(f.src)} → ${nameOf(f.dst)} ${f.port}`,'ok');} incSave(false);return;} - if(q('[data-inc-isolate]')){IR.selectAssets(SITE,inc,q('[data-inc-isolate]').dataset.incIsolate,inc.run&&inc.run.params).forEach(id=>incSetMark(inc,id,'contained'));incSave(true);return;} + if(q('[data-inc-isolate]')){IR.selectAssets(SITE,inc,q('[data-inc-isolate]').dataset.incIsolate,(runView(inc)||{run:{}}).run.params).forEach(id=>incSetMark(inc,id,'contained'));incSave(true);return;} if(q('[data-inc-unlink]')){incLog(inc,`Muster ${inc.run.ref} unlinked`,'sync');inc.run=null;incSave(false);return;} if(q('[data-inc-reset]')){const b=q('[data-inc-reset]');if(!b.classList.contains('armed')){b.classList.add('armed');b.textContent='Confirm: clear marks, cuts, run and timeline';return;} Inc.all[SITE.id]=IR.empty();incSave(true);toast('Incident cleared.');return;} }); -det.addEventListener('change',e=>{const c=e.target.closest('[data-inc-chk]');if(!c||!SITE)return;const inc=Inc.of(SITE.id);if(!inc.run)return;inc.run.checks[c.dataset.incChk]=c.checked;Inc.changed(SITE.id);}); +det.addEventListener('change',e=>{const c=e.target.closest('[data-inc-chk]');if(!c||!SITE)return;const inc=Inc.of(SITE.id);if(!inc.run)return; + if(inc.run.source==='muster'){const [node,i]=c.dataset.incChk.split(':');musterCall('tasks',{nodeId:node,index:+i,done:c.checked});return;} + inc.run.checks[c.dataset.incChk]=c.checked;Inc.changed(SITE.id);}); $('#incBtn').addEventListener('click',()=>{if(!SITE)return;if(state.panel==='incident'){closeDet();return;}if(!incOn()&&Inc.canWrite)incToggle(true);else showIncident();closeCtlMobile(true);}); $('#cart').addEventListener('click',e=>{if(e.target.closest('[data-inc-board]'))showIncident();}); function incBand(){ const iv=incView();if(!iv)return '';const run=iv.inc.run,open=iv.rows.filter(r=>!r.cut).length; return `
    Incident mode${run?`${esc(run.ref)} · ${esc(run.pb)}`:'No Muster incident'} · ${plural(iv.aff.length,'asset')} hit · ${open} to cut
    `; } +/* ---- Muster: real incidents, through this Strata's server ---- */ +// The server signs in to Muster and passes calls on (api/muster). Strata keeps a pointer to the Muster incident +// in the incident of the IS; the playbook, the steps taken and the tasks ticked are read from Muster each time. +const MU={list:null,ok:false,error:'',url:'',full:{},out:[],t:null}; +const musterOn=()=>Store.mode==='api'&&!!(Store.me&&Store.me.muster); +async function musterLoad(){ + if(!musterOn())return; + try{const j=await Store.api('GET','api/muster');Object.assign(MU,{list:j.incidents||[],ok:j.ok,error:j.error,url:j.url});} + catch(e){MU.ok=false;MU.error=e.message||'Muster cannot be reached.';} +} +// The run shown in the sheet: the example kept in the incident, or the Muster incident it points to +function runView(inc){ + const r=inc.run;if(!r)return null; + if(r.source!=='muster'){const pb=IR.PLAYBOOKS[r.pb];return pb?{pb,run:r}:null;} + const m=MU.full[r.id];if(!m)return null; + const pb=IR.fromGraph(m);return {pb,run:IR.runOf(m,pb)}; +} +// Reads the linked Muster incident again; answers given since (here or in Muster) mark the assets they name +let musterSyncing=false; +async function musterSync(){ + if(!SITE||!musterOn()||musterSyncing)return; + const id=SITE.id,inc=Inc.of(id),r=inc.run;if(!r||r.source!=='muster'){if(state.panel==='incident')showIncident(true);return;} + musterSyncing=true; + try{ + const m=await Store.api('GET','api/muster/incidents/'+encodeURIComponent(r.id));MU.full[r.id]=m; + let changed=false; + if(Inc.canWrite){ + const pb=IR.fromGraph(m); + IR.newAnswers(SITE,inc,pb,m,r.seen).forEach(a=>a.marks.forEach(([aid,st])=>{incSetMark(inc,aid,st);changed=true;})); + if(r.seen!==(m.steps||[]).length){r.seen=(m.steps||[]).length;Inc.changed(id);} + } + if(changed){Inc.changed(id);musterPush(id);} + incRedraw(changed); + }catch(e){toast('Muster: '+(e.message||'cannot be reached')+'.',true);incRedraw(false);} + finally{musterSyncing=false;} +} +// Sends the state of the assets and what was done on the map to Muster's timeline, a moment after the last change +function musterPush(siteId){ + clearTimeout(MU.t); + MU.t=setTimeout(async()=>{ + const s=siteById[siteId],inc=Inc.of(siteId),r=inc.run;if(!s||!r||r.source!=='muster')return; + const marks={},names={};IR.affected(s,inc).forEach(a=>{marks[a]=inc.marks[a];names[a]=s.byId[a].name;}); + const log=MU.out.splice(0); + try{await Store.api('POST',`api/muster/incidents/${encodeURIComponent(r.id)}/strata`,{site:siteId,marks,names,log});} + catch(e){MU.out.unshift(...log);toast('Muster did not take the update: '+(e.message||'error')+'.',true);} + },300); +} +async function musterCall(act,body,btn){ + const r=Inc.of(SITE.id).run;if(!r)return;if(btn)btn.disabled=true; + try{MU.full[r.id]=await Store.api('POST',`api/muster/incidents/${encodeURIComponent(r.id)}/${act}`,body);await musterSync();} + catch(e){toast(e.message||'Muster refused it.',true);if(btn)btn.disabled=false;musterSync();} +} +function musterGo(edgeId,btn){if(edgeId)musterCall('advance',{edgeId},btn);} +async function musterOpen(id){ + let m;try{m=await Store.api('GET','api/muster/incidents/'+encodeURIComponent(id));}catch(e){toast(e.message||'Muster cannot be reached.',true);return;} + MU.full[id]=m; + const inc=Inc.of(SITE.id),st=m.strata||{}; + inc.on=true;inc.run={source:'muster',id:m.id,ref:m.ref,name:m.name,pb:m.pbCode,v:m.v,mode:m.mode,severity:m.severity,seen:0}; + incLog(inc,`Muster ${m.ref} opened on the map (${m.pbCode} v${m.v})`,'sync'); + MU.out.push({msg:`opened on the map of IS ${SITE.name}`,kind:'info'}); + const ids=IR.idsOf(SITE,st.assets),missing=(st.assets||[]).filter(a=>!SITE.byId[a]&&!SITE.assets.some(x=>x.name===a)); + ids.forEach(a=>incSetMark(inc,a,'compromised')); + if(missing.length)incLog(inc,`Not on this map: ${missing.join(', ')}`,'crit'); + if(st.params&&st.params.cve){const f=IR.selectAssets(SITE,inc,'vuln:{cve}',st.params);incLog(inc,`${st.params.cve}: ${plural(f.length,'asset')} on the map${f.length?' ('+f.map(nameOf).join(', ')+')':''}`,'info');} + incSave(true);showIncident();musterSync(); +} + // The asset sheet: mark it, and prompt a public AI about it function incAssetHtml(a){ const m=Inc.of(SITE.id).marks[a.id]||null; @@ -3741,7 +3821,7 @@ function tick(t){ } function start(){ resize();buildOverview();renderCart();syncUI();renderEmpty();setViewNow();tick(); - Store.init().then(()=>{renderCollab();if(Store.features.proposals)loadProposals();return Inc.load();}).then(()=>incRedraw(true)); + Store.init().then(()=>{renderCollab();if(Store.features.proposals)loadProposals();return Inc.load();}).then(()=>musterLoad()).then(()=>{incRedraw(true);musterSync();}); } const ready=document.fonts&&document.fonts.ready?Promise.race([document.fonts.ready,new Promise(r=>setTimeout(r,1500))]):Promise.resolve(); ready.then(start,start); diff --git a/public/incident.js b/public/incident.js index f616aeb..5a5f1a0 100644 --- a/public/incident.js +++ b/public/incident.js @@ -116,14 +116,54 @@ function start(sc,pb,now,by){ const n=nextOf(pb,run.cur)[0];if(n){run.path.push(n[1]);run.cur=n[1];} return run; } -// Moves the run along an edge; returns the marks a decision asks for, as [[assetId,state]] +// The marks a decision asks for once answered, as [[assetId,state]]: never back from compromised or contained +function effects(site,inc,s,label,params){ + const eff=s&&s.effects&&s.effects[label],out=[]; + if(eff)selectAssets(site,inc,eff[1],params).forEach(id=>{const was=inc.marks[id];if(was!=='compromised'&&was!=='contained'&&was!==eff[0])out.push([id,eff[0]]);}); + return out; +} +// Moves the run along an edge; returns the marks a decision asks for function advance(site,inc,pb,to,label){ - const run=inc.run,s=step(pb,run.cur),changes=[]; - if(label){run.answers[run.cur]=label;const eff=s.effects&&s.effects[label]; - if(eff)selectAssets(site,inc,eff[1],run.params).forEach(id=>{const was=inc.marks[id];if(was!=='compromised'&&was!=='contained'&&was!==eff[0])changes.push([id,eff[0]]);});} + const run=inc.run,s=step(pb,run.cur),changes=label?effects(site,inc,s,label,run.params):[]; + if(label)run.answers[run.cur]=label; run.path.push(to);run.cur=to;if(step(pb,to).type==='end')run.closed=true; return changes; } + +/* ---- A Muster incident, as the map runs it ---- */ +// The playbook frozen in the incident, in the shape above; edges keep their Muster id +function fromGraph(m){ + const g=m.graph||{nodes:[],edges:[]}; + return {code:m.pbCode,name:m.pbName,v:m.v,severity:m.severity, + steps:g.nodes.map(n=>({id:n.id,type:n.type,title:n.title,phase:n.phase||'',role:n.role||'',sla:n.sla||0,desc:n.desc||'',checklist:n.checklist||[],targets:Array.isArray(n.targets)?n.targets:[],effects:n.effects||null})), + edges:g.edges.map(e=>[e.from,e.to,e.label||'',e.id])}; +} +// Where the response stands: the steps taken, the answers given, the tasks ticked +function runOf(m,pb){ + const steps=m.steps||[],answers={},checks={}; + steps.forEach(st=>{const e=st.edge&&pb.edges.find(x=>x[3]===st.edge),s=step(pb,st.id);if(e&&s&&s.type==='decision')answers[st.id]=e[2];}); + Object.entries(m.checks||{}).forEach(([id,arr])=>(arr||[]).forEach((v,i)=>{if(v)checks[id+':'+i]=true;})); + const st=m.strata||{}; + return {source:'muster',id:m.id,ref:m.ref,name:m.name,pb:m.pbCode,v:m.v,mode:m.mode,severity:m.severity,status:m.status, + assets:st.assets||[],params:st.params||{},path:steps.map(x=>x.id),cur:steps.length?steps[steps.length-1].id:null,answers,checks,startedAt:m.startedAt,closed:m.status!=='active'}; +} +// Answers given since the map last looked (in Strata or in Muster), with the marks they ask for +function newAnswers(site,inc,pb,m,seen){ + const out=[],steps=m.steps||[]; + for(let i=Math.max(0,seen||0);ix[3]===st.edge); + if(s&&e&&s.type==='decision')out.push({step:s,label:e[2],marks:effects(site,inc,s,e[2],(m.strata||{}).params)});} + return out; +} +// Muster incidents that belong to this IS: linked to it, or naming one of its assets +function musterFor(site,list){ + const mine=[],other=[]; + (list||[]).forEach(i=>{const st=i.strata||{}; + const named=(st.assets||[]).some(a=>site.byId[a]||site.assets.some(x=>x.name===a)); + (st.site===site.id||(!st.site&&named)?mine:other).push(i);}); + return {mine,other}; +} +// Asset ids from what Muster names: Strata ids, or names +const idsOf=(site,names)=>[...new Set((names||[]).map(a=>site.byId[a]?a:(site.assets.find(x=>x.name===a)||{}).id).filter(Boolean))]; // The most likely route ahead: the steps taken, then the first edge out of each step function route(pb,run){ const seen=new Set(run.path),r=run.path.slice();let c=run.cur; @@ -385,6 +425,6 @@ function yamlCheck(text){ } const api={STATES,empty,clean,marked,affected,reach,consumers,business,selectAssets,resolve,cutPoint,flowsToCut,callList, - PLAYBOOKS,step,nextOf,start,advance,route,demoFeed,placeholders,prompts,sensitive,leaks,reidentify,yamlCheck}; + PLAYBOOKS,step,nextOf,start,effects,advance,route,fromGraph,runOf,newAnswers,musterFor,idsOf,demoFeed,placeholders,prompts,sensitive,leaks,reidentify,yamlCheck}; root.StrataIncident=api; })(typeof globalThis!=='undefined'?globalThis:this); diff --git a/server/muster.js b/server/muster.js new file mode 100644 index 0000000..df1f7cb --- /dev/null +++ b/server/muster.js @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Connection to Muster, the incident response playbooks. Strata signs in to Muster with an account of its own +// (the analyst role: it moves responses along and writes to their timeline), lists the incidents, and passes on +// what the people working on the map do: the answers to decisions, the tasks ticked, the state of the assets and +// the flows cut. Muster's own API, with its own session cookie and CSRF header; nothing is stored here. +const COOKIE = 'muster_session'; +const ID_RE = /^[A-Za-z0-9_-]{1,80}$/; + +export function createMuster({ url, user, password, pollMs = 10000, timeoutMs = 10000, log = () => {}, onChange = () => {} }) { + const base = String(url).replace(/\/+$/, ''); + let cookie = '', sig = '', timer = null; + let status = { ok: false, error: 'Not checked yet', checkedAt: null, incidents: [] }; + const fail = (code, message) => Object.assign(new Error(message), { status: code }); + + async function login() { + let r; + try { + r = await fetch(base + '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Muster': '1' }, body: JSON.stringify({ username: user, password }), signal: AbortSignal.timeout(timeoutMs) }); + } catch (e) { throw fail(502, `Muster cannot be reached at ${base} (${e.cause?.code || e.message})`); } + if (!r.ok) { const d = await r.json().catch(() => ({})); throw fail(502, `Muster refused the sign-in of ${user}: ${d.error || r.status}`); } + const m = new RegExp(`${COOKIE}=([^;]+)`).exec(r.headers.get('set-cookie') || ''); + if (!m) throw fail(502, 'Muster answered the sign-in without a session'); + cookie = `${COOKIE}=${m[1]}`; + } + // One call to Muster; a session that has expired is opened again once + async function call(method, path, body, again = true) { + if (!cookie) await login(); + let r; + try { + r = await fetch(base + path, { + method, signal: AbortSignal.timeout(timeoutMs), + headers: { Cookie: cookie, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(method !== 'GET' ? { 'X-Muster': '1' } : {}) }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch (e) { throw fail(502, `Muster cannot be reached at ${base} (${e.cause?.code || e.message})`); } + if (r.status === 401 && again) { cookie = ''; return call(method, path, body, false); } + const data = await r.json().catch(() => ({})); + // Muster's refusals (a route that does not leave the current step, a role…) reach the user as they are + if (!r.ok) throw fail(r.status >= 500 || r.status === 401 ? 502 : r.status, `Muster: ${data.error || 'error ' + r.status}`); + return data; + } + async function poll() { + try { + const list = await call('GET', '/api/incidents'); + status = { ok: true, error: '', checkedAt: new Date().toISOString(), incidents: Array.isArray(list) ? list : [] }; + } catch (e) { + if (status.ok) log(`Muster: ${e.message}`); + status = { ...status, ok: false, error: e.message, checkedAt: new Date().toISOString() }; + } + const next = JSON.stringify([status.ok, status.error, status.incidents.map(i => [i.id, i.rev, i.status])]); + if (next !== sig) { sig = next; onChange(); } + return status; + } + const incPath = (id, r = '') => { if (!ID_RE.test(String(id))) throw fail(400, 'Invalid Muster incident id'); return `/api/incidents/${id}${r}`; }; + // After a change: read the list again at once, so every Strata page follows + const then = async p => { const out = await p; await poll(); return out; }; + + return { + url: base, + start() { poll(); timer = setInterval(poll, pollMs); timer.unref?.(); }, + stop() { clearInterval(timer); }, + status: () => status, + refresh: poll, + incident: id => call('GET', incPath(id)), + advance: (id, edgeId) => then(call('POST', incPath(id, '/advance'), { edgeId: String(edgeId || '') })), + task: (id, nodeId, index, done) => then(call('POST', incPath(id, '/tasks'), { nodeId: String(nodeId || ''), index, done: !!done })), + report: (id, body) => then(call('POST', incPath(id, '/strata'), body)), + }; +} diff --git a/server/server.js b/server/server.js index c4d2476..5c38ad1 100644 --- a/server/server.js +++ b/server/server.js @@ -13,6 +13,7 @@ import { createConnectors } from './connectors/index.js'; import { createStore } from './connectors/store.js'; import { ID_RE as MODULE_ID_RE } from './connectors/modules.js'; import { ssoConfig, createSso, loginPage } from './sso.js'; +import { createMuster } from './muster.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const env = process.env; @@ -55,6 +56,11 @@ const CONFIG = { connectorTimeoutMs: Math.max(2000, (parseFloat(env.CONNECTOR_TIMEOUT_MINUTES || '30') || 30) * 60000), // AGPL-3.0 section 13: users of a modified version must be offered its source. Point this at your fork. sourceUrl: /^https?:\/\//.test(env.SOURCE_URL || '') ? env.SOURCE_URL : 'https://github.com/SolidGeeks/strata', + // Muster: its address and the account Strata signs in with (analyst role). Empty: no connection. + musterUrl: /^https?:\/\//.test(env.MUSTER_URL || '') ? env.MUSTER_URL : '', + musterUser: env.MUSTER_USER || 'strata', + musterPass: secret('MUSTER_PASSWORD'), + musterPollMs: Math.max(2, parseFloat(env.MUSTER_POLL_SECONDS || '10') || 10) * 1000, }; const DB_FILE = path.join(CONFIG.dataDir, 'db.json'); const USERS_FILE = path.join(CONFIG.dataDir, 'users.json'); @@ -478,7 +484,7 @@ async function api(req, res, parts, url, auth) { if (r1 === 'me' && !id && method === 'GET') return send(res, 200, { user: auth.user, role: auth.role, auth: !auth.open, via: auth.via, signOut: auth.via === 'sso', sso: sso.oidc || sso.header, readOnly: CONFIG.readOnly, allowSelfApproval: CONFIG.allowSelfApproval, - canWrite: can(auth, 'approver'), canPropose: can(auth, 'contributor'), canReview: can(auth, 'approver'), canAdmin: can(auth, 'admin'), connectors: !!connectors, sourceUrl: CONFIG.sourceUrl, + canWrite: can(auth, 'approver'), canPropose: can(auth, 'contributor'), canReview: can(auth, 'approver'), canAdmin: can(auth, 'admin'), connectors: !!connectors, sourceUrl: CONFIG.sourceUrl, muster: muster ? { url: muster.url } : null, }); if (r1 === 'me' && id === 'password' && method === 'PUT') { if (auth.open) throw httpError(400, 'Accounts are not enabled on this server.'); @@ -505,6 +511,24 @@ async function api(req, res, parts, url, auth) { return; } + /* ---- Muster: every role reads, contributors and up act on a response ---- */ + if (r1 === 'muster') { + if (!muster) throw httpError(404, 'Muster is not connected to this Strata (MUSTER_URL)'); + if (!id && method === 'GET') { const st = muster.status(); return send(res, 200, { url: muster.url, ok: st.ok, error: st.error, checkedAt: st.checkedAt, incidents: st.incidents }); } + if (id === 'incidents' && r3 && method === 'GET' && !parts[3]) return send(res, 200, await muster.incident(r3)); + if (id === 'incidents' && r3 && method === 'POST') { + need(auth, 'contributor'); + const b = await readBody(req, 128 * 1024) || {}, act = parts[3], who = auth.user || 'strata'; + if (act === 'advance') return send(res, 200, await muster.advance(r3, b.edgeId)); + if (act === 'tasks') return send(res, 200, await muster.task(r3, b.nodeId, Number(b.index), b.done)); + if (act === 'strata') { + const log = (Array.isArray(b.log) ? b.log : []).slice(0, 50).map(l => ({ msg: String(l?.msg || '').slice(0, 400), kind: String(l?.kind || 'info'), by: who })).filter(l => l.msg); + return send(res, 200, await muster.report(r3, { site: b.site, marks: b.marks, names: b.names, log })); + } + } + return fail(res, 404, 'Unknown Muster route'); + } + /* ---- incident mode: contributors and up, without a proposal ---- */ if (r1 === 'incidents' && !id && method === 'GET') return send(res, 200, { incidents }); if (r1 === 'incidents' && id !== undefined && !r3 && method === 'PUT') { @@ -832,6 +856,8 @@ const connectors = /^(0|false|no|off)$/i.test(env.CONNECTORS || '') ? null : cre const modStore = connectors ? createStore({ registryUrl: CONFIG.moduleRegistry, uploads: CONFIG.moduleUpload, installedDir: MODULES_INSTALLED, strataVersion: STRATA_VERSION, connectors, log }) : null; loadProposals(); loadIncidents(); +const muster = CONFIG.musterUrl ? createMuster({ url: CONFIG.musterUrl, user: CONFIG.musterUser, password: CONFIG.musterPass, pollMs: CONFIG.musterPollMs, log, onChange: () => broadcast('muster', { at: Date.now() }) }) : null; +if (muster) { muster.start(); log(`Muster: ${muster.url}, signed in as ${CONFIG.musterUser}`); } bootstrapUsers(); await backup('startup'); setInterval(() => backup('scheduled').catch(e => log('Backup failed:', e.message)), CONFIG.backupEveryHours * 3600 * 1000).unref(); diff --git a/tests/muster.test.mjs b/tests/muster.test.mjs new file mode 100644 index 0000000..821ccef --- /dev/null +++ b/tests/muster.test.mjs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The connection to Muster: a stand-in Muster server, a real Strata server signed in to it. Run with: npm test +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const dataDir = mkdtempSync(join(tmpdir(), 'strata-muster-')); +const port = 9300 + Math.floor(Math.random() * 300), mport = port + 400; +const base = `http://127.0.0.1:${port}`; +const PASS = { admin: 'admin-pass-123', alice: 'alice-pass-123', vic: 'vic-pass-1234' }; +let server, fake; + +// Muster as Strata sees it: a sign-in that sets a cookie, the X-Muster header on writes, the incident routes +const seen = []; +const inc = { id: 'inc_1', ref: 'INC-2026-0001', name: 'Ransom note', pbCode: 'PB-001', pbName: 'Ransomware', v: 3, status: 'active', rev: 1, mode: 'live', severity: 'Critical', + graph: { nodes: [{ id: 'start', type: 'start' }, { id: 'triage', type: 'action', checklist: ['a'] }, { id: 'end', type: 'end' }], edges: [{ id: 'e1', from: 'start', to: 'triage' }, { id: 'e2', from: 'triage', to: 'end' }] }, + steps: [{ id: 'start', edge: 'e1' }, { id: 'triage' }], checks: {}, log: [], strata: { site: 'lyon', assets: ['file-01'], params: {}, marks: {}, names: {} } }; +function fakeMuster(req, res) { + let body = ''; + req.on('data', c => { body += c; }); + req.on('end', () => { + const json = (code, data, h = {}) => { res.writeHead(code, { 'Content-Type': 'application/json', ...h }); res.end(JSON.stringify(data)); }; + const b = body ? JSON.parse(body) : {}; + seen.push({ method: req.method, url: req.url, body: b, cookie: req.headers.cookie || '', csrf: req.headers['x-muster'] }); + if (req.url === '/api/auth/login') return b.username === 'strata' && b.password === 'right' ? json(200, { user: {} }, { 'Set-Cookie': 'muster_session=tok1; HttpOnly' }) : json(401, { error: 'Wrong username or password' }); + if (req.headers.cookie !== 'muster_session=tok1') return json(401, { error: 'Sign in to continue' }); + if (req.method !== 'GET' && req.headers['x-muster'] !== '1') return json(403, { error: 'Missing X-Muster header' }); + if (req.url === '/api/incidents') return json(200, [{ id: inc.id, ref: inc.ref, status: inc.status, rev: inc.rev, strata: inc.strata }]); + if (req.url === '/api/incidents/inc_1') return json(200, inc); + if (req.url === '/api/incidents/inc_1/advance') { if (b.edgeId !== 'e2') return json(422, { error: 'That route does not leave the current step' }); inc.steps.at(-1).edge = 'e2'; inc.steps.push({ id: 'end' }); inc.status = 'closed'; inc.rev++; return json(200, inc); } + if (req.url === '/api/incidents/inc_1/strata') { inc.strata.marks = b.marks; inc.log.push(...b.log); inc.rev++; return json(200, inc); } + json(404, { error: 'Not found' }); + }); +} +const as = user => (method, path, body) => fetch(base + path, { + method, headers: { Authorization: 'Basic ' + Buffer.from(`${user}:${PASS[user]}`).toString('base64'), ...(body ? { 'Content-Type': 'application/json', 'X-Requested-With': 'strata' } : {}) }, + body: body ? JSON.stringify(body) : undefined, +}).then(async r => ({ status: r.status, data: await r.json().catch(() => null) })); + +before(async () => { + fake = createServer(fakeMuster); + await new Promise(r => fake.listen(mport, '127.0.0.1', r)); + server = spawn(process.execPath, [join(root, 'server/server.js')], { + env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', DATA_DIR: dataDir, EDIT_USER: 'admin', EDIT_PASS: PASS.admin, CONNECTORS: 'false', + MUSTER_URL: `http://127.0.0.1:${mport}`, MUSTER_USER: 'strata', MUSTER_PASSWORD: 'right', MUSTER_POLL_SECONDS: '60' }, + stdio: ['ignore', 'pipe', 'inherit'], + }); + await new Promise((res, rej) => { server.stdout.on('data', d => { if (String(d).includes('listening')) res(); }); server.on('exit', c => rej(new Error('exit ' + c))); }); + for (const [name, role] of [['alice', 'contributor'], ['vic', 'viewer']]) assert.equal((await as('admin')('PUT', `/api/users/${name}`, { role, password: PASS[name] })).status, 200); +}); +after(async () => { + if (server && server.exitCode === null) await new Promise(res => { server.once('exit', res); server.kill(); }); + await new Promise(r => fake.close(r)); + rmSync(dataDir, { recursive: true, force: true, maxRetries: 5 }); +}); + +test('Muster: Strata signs in once, lists and reads incidents for every role', async () => { + const me = await as('vic')('GET', '/api/me'); + assert.deepEqual(me.data.muster, { url: `http://127.0.0.1:${mport}` }); + const list = await as('vic')('GET', '/api/muster'); + assert.equal(list.status, 200); + assert.equal(list.data.ok, true); + assert.deepEqual(list.data.incidents.map(i => i.id), ['inc_1']); + const one = await as('vic')('GET', '/api/muster/incidents/inc_1'); + assert.equal(one.data.ref, 'INC-2026-0001'); + assert.equal(seen.filter(s => s.url === '/api/auth/login').length, 1, 'the session is kept'); + assert.equal((await as('vic')('GET', '/api/muster/incidents/bad%20id')).status, 400); +}); + +test('Muster: contributors move a response along and report back, viewers cannot', async () => { + assert.equal((await as('vic')('POST', '/api/muster/incidents/inc_1/advance', { edgeId: 'e2' })).status, 403); + const wrong = await as('alice')('POST', '/api/muster/incidents/inc_1/advance', { edgeId: 'e1' }); + assert.equal(wrong.status, 422, 'Muster refusals reach the user'); + assert.match(wrong.data.error, /does not leave the current step/); + const r = await as('alice')('POST', '/api/muster/incidents/inc_1/strata', { site: 'lyon', marks: { 'lyo-file-01': 'compromised' }, names: { 'lyo-file-01': 'file-01' }, log: [{ msg: 'file-01 marked compromised', kind: 'warn', by: 'someone else' }] }); + assert.equal(r.status, 200, JSON.stringify(r.data)); + const sent = seen.filter(s => s.url === '/api/incidents/inc_1/strata').at(-1); + assert.equal(sent.csrf, '1'); + assert.deepEqual(sent.body.log, [{ msg: 'file-01 marked compromised', kind: 'warn', by: 'alice' }], 'the author is the Strata account, not what the page claims'); + const ok = await as('alice')('POST', '/api/muster/incidents/inc_1/advance', { edgeId: 'e2' }); + assert.equal(ok.data.status, 'closed'); +}); + +test('Muster: an expired session is opened again, an unreachable Muster gives a clear error', async () => { + const logins = seen.filter(s => s.url === '/api/auth/login').length; + inc.rev++; // the stand-in forgets the session: the next call answers 401 once + const old = fakeMuster; + fake.removeAllListeners('request'); + let first = true; + fake.on('request', (req, res) => { if (first && req.url !== '/api/auth/login') { first = false; res.writeHead(401, { 'Content-Type': 'application/json' }); res.end('{"error":"Sign in to continue"}'); return; } old(req, res); }); + assert.equal((await as('vic')('GET', '/api/muster/incidents/inc_1')).status, 200); + assert.equal(seen.filter(s => s.url === '/api/auth/login').length, logins + 1); + await new Promise(r => fake.close(r)); + const down = await as('vic')('GET', '/api/muster/incidents/inc_1'); + assert.equal(down.status, 502); + assert.match(down.data.error, /cannot be reached/); + fake = createServer(fakeMuster); + await new Promise(r => fake.listen(mport, '127.0.0.1', r)); +}); From 823139431f29b27fe047b4b7f1ac771740832670 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:48:22 +0000 Subject: [PATCH 4/6] Incident mode: tell example runs from Muster incidents Example runs were saved with source "muster", so with the Muster code in place the sheet waited for a Muster incident that does not exist and showed no step. A run now points to Muster only with a Muster id; the examples, including those saved by the previous version, run as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ke7yEW5dhDYFVpV2cdgs87 Signed-off-by: Claude --- public/app.js | 18 +++++++++--------- public/incident.js | 6 ++++-- tests/incident.test.mjs | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/public/app.js b/public/app.js index 8c8eaf1..c93f587 100644 --- a/public/app.js +++ b/public/app.js @@ -3242,7 +3242,7 @@ const incOn=()=>{const i=curInc();return !!(i&&i.on);}; const incMark=id=>incOn()?Inc.of(SITE.id).marks[id]||null:null; const hhmm=t=>{const d=new Date(t);return `${pad(d.getHours())}:${pad(d.getMinutes())}`;}; function incLog(inc,msg,kind){inc.log.unshift({at:Date.now(),msg,kind:kind||'info',by:Store.user||''}); - if(inc.run&&inc.run.source==='muster'&&kind!=='sync')MU.out.push({msg,kind:kind==='crit'?'warn':kind==='dec'||kind==='ok'?kind:'info'});} + if(IR.isMuster(inc.run)&&kind!=='sync')MU.out.push({msg,kind:kind==='crit'?'warn':kind==='dec'||kind==='ok'?kind:'info'});} // What the model and the diagram show, worked out once per change let INCV=null; function incView(){ @@ -3261,7 +3261,7 @@ function incRedraw(full){ if(state.panel==='incident')showIncident(true); else if(state.panel==='asset'&&selAsset())showAsset(selAsset()); } -function incSave(full){const inc=Inc.of(SITE.id);Inc.changed(SITE.id);if(inc.run&&inc.run.source==='muster')musterPush(SITE.id);incRedraw(full);} +function incSave(full){const inc=Inc.of(SITE.id);Inc.changed(SITE.id);if(IR.isMuster(inc.run))musterPush(SITE.id);incRedraw(full);} function incSetMark(inc,id,st,quiet){ const was=inc.marks[id]||null;if(was===(st||null))return; if(st)inc.marks[id]=st;else delete inc.marks[id]; @@ -3309,7 +3309,7 @@ function incTargetHtml(sel,s,run){ return `
    ${head}${incCallHtml(r.items.slice(0,4))}
    `; } function incRunHtml(inc){ - const rv=runView(inc);if(!rv)return `

    ${inc.run.source==='muster'?'Reading the incident from Muster…':`Playbook ${esc(inc.run.pb)} is unknown here.`}

    `; + const rv=runView(inc);if(!rv)return `

    ${IR.isMuster(inc.run)?'Reading the incident from Muster…':`Playbook ${esc(inc.run.pb)} is unknown here.`}

    `; const {pb,run}=rv,R=IR.route(pb,run),w=Inc.canWrite;if(!run.cur)return '

    This response has no step.

    '; return `
      ${R.map((sid,i)=>{const s=IR.step(pb,sid),cur=sid===run.cur&&!run.closed,done=i=run.path.length; let h=`
    1. ${done?'✓':i+1}
      ${esc(s.title)}${esc(s.phase)}${s.role?' · '+esc(s.role):''}${s.sla?` · SLA ${s.sla} min`:''}${run.answers[sid]?`→ ${esc(run.answers[sid])}`:''}`; @@ -3346,7 +3346,7 @@ function showIncident(keep){ ${feed.length?`
        ${feed.map((sc,i)=>`
      • ${esc(sc.ref)} · ${esc(sc.name)}${esc(sc.pb)} ${esc(IR.PLAYBOOKS[sc.pb].name)} · ${esc(sc.mode)} · ${esc(sc.note)}${w?``:''}
      • `).join('')}

      Muster is not connected to this Strata: these two incidents are examples built from this map.

      `:''}`; }else{ - const rv=runView(inc),R=rv?rv.run:run,pb=rv&&rv.pb,real=run.source==='muster',mins=Math.floor((Date.now()-(R.startedAt||Date.now()))/60000); + const rv=runView(inc),R=rv?rv.run:run,pb=rv&&rv.pb,real=IR.isMuster(run),mins=Math.floor((Date.now()-(R.startedAt||Date.now()))/60000); const ended=R.closed?`${R.status==='stopped'?'Stopped':'Closed'}`:''; body+=`
      ${esc(R.ref)} · ${esc(R.pb)} v${esc(R.v)} · ${esc(R.mode||'')}T+${pad(Math.floor(mins/60))}:${pad(mins%60)}
      ${esc(R.name)} @@ -3400,7 +3400,7 @@ det.addEventListener('click',e=>{ if(q('[data-inc-off]')){incToggle(false);return;} if(q('[data-inc-open]')){incOpenScenario(+q('[data-inc-open]').dataset.incOpen);return;} if(q('[data-inc-mopen]')){musterOpen(q('[data-inc-mopen]').dataset.incMopen);return;} - if(q('[data-inc-go]')){const b=q('[data-inc-go]');if(inc.run&&inc.run.source==='muster')musterGo(b.dataset.incEdge,b);else incGo(b.dataset.incGo,b.dataset.incLbl||'');return;} + if(q('[data-inc-go]')){const b=q('[data-inc-go]');if(IR.isMuster(inc.run))musterGo(b.dataset.incEdge,b);else incGo(b.dataset.incGo,b.dataset.incLbl||'');return;} if(q('[data-inc-cut]')){const id=q('[data-inc-cut]').dataset.incCut,f=FLOWS.find(x=>x.id===id),r=IR.flowsToCut(SITE,inc,IR.affected(SITE,inc)).find(x=>x.f.id===id); const i=inc.cuts.indexOf(id);if(i>=0){inc.cuts.splice(i,1);incLog(inc,`Flow reopened: ${nameOf(f.src)} → ${nameOf(f.dst)} ${f.port}`);} else{inc.cuts.push(id);incLog(inc,`Flow cut at ${r?r.at.label:'the host'}: ${nameOf(f.src)} → ${nameOf(f.dst)} ${f.port}`,'ok');} @@ -3411,7 +3411,7 @@ det.addEventListener('click',e=>{ Inc.all[SITE.id]=IR.empty();incSave(true);toast('Incident cleared.');return;} }); det.addEventListener('change',e=>{const c=e.target.closest('[data-inc-chk]');if(!c||!SITE)return;const inc=Inc.of(SITE.id);if(!inc.run)return; - if(inc.run.source==='muster'){const [node,i]=c.dataset.incChk.split(':');musterCall('tasks',{nodeId:node,index:+i,done:c.checked});return;} + if(IR.isMuster(inc.run)){const [node,i]=c.dataset.incChk.split(':');musterCall('tasks',{nodeId:node,index:+i,done:c.checked});return;} inc.run.checks[c.dataset.incChk]=c.checked;Inc.changed(SITE.id);}); $('#incBtn').addEventListener('click',()=>{if(!SITE)return;if(state.panel==='incident'){closeDet();return;}if(!incOn()&&Inc.canWrite)incToggle(true);else showIncident();closeCtlMobile(true);}); $('#cart').addEventListener('click',e=>{if(e.target.closest('[data-inc-board]'))showIncident();}); @@ -3432,7 +3432,7 @@ async function musterLoad(){ // The run shown in the sheet: the example kept in the incident, or the Muster incident it points to function runView(inc){ const r=inc.run;if(!r)return null; - if(r.source!=='muster'){const pb=IR.PLAYBOOKS[r.pb];return pb?{pb,run:r}:null;} + if(!IR.isMuster(r)){const pb=IR.PLAYBOOKS[r.pb];return pb?{pb,run:r}:null;} const m=MU.full[r.id];if(!m)return null; const pb=IR.fromGraph(m);return {pb,run:IR.runOf(m,pb)}; } @@ -3440,7 +3440,7 @@ function runView(inc){ let musterSyncing=false; async function musterSync(){ if(!SITE||!musterOn()||musterSyncing)return; - const id=SITE.id,inc=Inc.of(id),r=inc.run;if(!r||r.source!=='muster'){if(state.panel==='incident')showIncident(true);return;} + const id=SITE.id,inc=Inc.of(id),r=inc.run;if(!IR.isMuster(r)){if(state.panel==='incident')showIncident(true);return;} musterSyncing=true; try{ const m=await Store.api('GET','api/muster/incidents/'+encodeURIComponent(r.id));MU.full[r.id]=m; @@ -3459,7 +3459,7 @@ async function musterSync(){ function musterPush(siteId){ clearTimeout(MU.t); MU.t=setTimeout(async()=>{ - const s=siteById[siteId],inc=Inc.of(siteId),r=inc.run;if(!s||!r||r.source!=='muster')return; + const s=siteById[siteId],inc=Inc.of(siteId),r=inc.run;if(!s||!IR.isMuster(r))return; const marks={},names={};IR.affected(s,inc).forEach(a=>{marks[a]=inc.marks[a];names[a]=s.byId[a].name;}); const log=MU.out.splice(0); try{await Store.api('POST',`api/muster/incidents/${encodeURIComponent(r.id)}/strata`,{site:siteId,marks,names,log});} diff --git a/public/incident.js b/public/incident.js index 5a5f1a0..ea6c78b 100644 --- a/public/incident.js +++ b/public/incident.js @@ -111,7 +111,7 @@ function callList(site,ids,withIs=true){ const step=(pb,id)=>pb.steps.find(s=>s.id===id); const nextOf=(pb,id)=>pb.edges.filter(e=>e[0]===id); function start(sc,pb,now,by){ - const run={ref:sc.ref,name:sc.name,pb:pb.code,v:pb.v,mode:sc.mode,severity:sc.severity,assets:(sc.assets||[]).slice(),params:Object.assign({},sc.params||{}),source:sc.source||'muster', + const run={ref:sc.ref,name:sc.name,pb:pb.code,v:pb.v,mode:sc.mode,severity:sc.severity,assets:(sc.assets||[]).slice(),params:Object.assign({},sc.params||{}),source:sc.source||'example', path:[pb.steps[0].id],cur:pb.steps[0].id,answers:{},checks:{},startedAt:now,closed:false}; const n=nextOf(pb,run.cur)[0];if(n){run.path.push(n[1]);run.cur=n[1];} return run; @@ -131,6 +131,8 @@ function advance(site,inc,pb,to,label){ } /* ---- A Muster incident, as the map runs it ---- */ +// A run that points to a real Muster incident (an example has no Muster id; older ones say source:'muster') +const isMuster=r=>!!(r&&r.source==='muster'&&r.id); // The playbook frozen in the incident, in the shape above; edges keep their Muster id function fromGraph(m){ const g=m.graph||{nodes:[],edges:[]}; @@ -425,6 +427,6 @@ function yamlCheck(text){ } const api={STATES,empty,clean,marked,affected,reach,consumers,business,selectAssets,resolve,cutPoint,flowsToCut,callList, - PLAYBOOKS,step,nextOf,start,effects,advance,route,fromGraph,runOf,newAnswers,musterFor,idsOf,demoFeed,placeholders,prompts,sensitive,leaks,reidentify,yamlCheck}; + PLAYBOOKS,step,nextOf,start,effects,isMuster,advance,route,fromGraph,runOf,newAnswers,musterFor,idsOf,demoFeed,placeholders,prompts,sensitive,leaks,reidentify,yamlCheck}; root.StrataIncident=api; })(typeof globalThis!=='undefined'?globalThis:this); diff --git a/tests/incident.test.mjs b/tests/incident.test.mjs index e9600fd..8f24fd9 100644 --- a/tests/incident.test.mjs +++ b/tests/incident.test.mjs @@ -122,3 +122,26 @@ test('demo feed: builds the two Muster examples from the map', () => { assert.deepEqual(f[0].assets, ['file-01', 'pc-01']); assert.equal(f[1].params.cve, 'CVE-2021-44228'); }); + +test('Muster: a frozen playbook and its steps become a run on the map, answers apply their effects once', () => { + const s = site(), inc = I.empty(); + assert.equal(I.isMuster(I.start({ ref: 'IR-1', name: 'x', params: {} }, I.PLAYBOOKS['PB-001'], 0)), false, 'an example is not a Muster incident'); + assert.equal(I.isMuster({ source: 'muster' }), false, 'nor an example saved by an earlier version'); + const m = { id: 'inc_1', ref: 'INC-1', name: 'Log4Shell', pbCode: 'PB-010', pbName: 'Exploited', v: 1, status: 'active', mode: 'live', severity: 'Critical', startedAt: 0, + graph: { nodes: [{ id: 'start', type: 'start', title: 'S' }, { id: 'found', type: 'decision', title: 'Found?', targets: ['vuln:{cve}'], effects: { Yes: ['suspected', 'vuln:{cve}'] } }, { id: 'act', type: 'action', title: 'Act', checklist: ['a', 'b'] }, { id: 'end', type: 'end', title: 'E' }], + edges: [{ id: 'e1', from: 'start', to: 'found', label: '' }, { id: 'e2', from: 'found', to: 'act', label: 'Yes' }, { id: 'e3', from: 'found', to: 'end', label: 'No' }, { id: 'e4', from: 'act', to: 'end', label: '' }] }, + steps: [{ id: 'start', edge: 'e1' }, { id: 'found', edge: 'e2' }, { id: 'act' }], checks: { act: [false, true] }, strata: { site: 'lyon', assets: ['web-01'], params: { cve: 'CVE-2021-44228' } } }; + const pb = I.fromGraph(m), run = I.runOf(m, pb); + assert.deepEqual(J(pb.edges[1]), ['found', 'act', 'Yes', 'e2']); + assert.equal(run.cur, 'act'); + assert.deepEqual(J(run.answers), { found: 'Yes' }); + assert.deepEqual(J(run.checks), { 'act:1': true }); + assert.equal(I.isMuster(run), true); + const news = I.newAnswers(s, inc, pb, m, 0); + assert.deepEqual(J(news.map(a => a.marks)), [[['web-01', 'suspected']]]); + assert.deepEqual(J(I.newAnswers(s, inc, pb, m, 3)), [], 'answers already seen are not applied again'); + assert.deepEqual(J(I.idsOf(s, ['web-01', 'db-01', 'nowhere'])), ['web-01', 'db-01']); + const { mine, other } = I.musterFor(Object.assign(s, { id: 'lyon' }), [m, { id: 'x', strata: { site: 'paris', assets: [], params: {} } }, { id: 'y', strata: null }]); + assert.deepEqual(J(mine.map(i => i.id)), ['inc_1']); + assert.deepEqual(J(other.map(i => i.id)), ['x', 'y']); +}); From 87db00c14832203dd5bfde71466e90a50e8a4ece Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:57:14 +0000 Subject: [PATCH 5/6] Incident mode: each incident starts from its own assets Opening another incident on an IS puts the marks and cuts of the previous one aside, with a line in the timeline; marks made by hand before any incident was opened stay. The incident keeps which incident its marks belong to (`from`), on the server too, so Muster's report of a new incident lists only its own assets. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ke7yEW5dhDYFVpV2cdgs87 Signed-off-by: Claude --- CHANGELOG.md | 2 +- public/app.js | 6 ++++-- public/incident.js | 13 +++++++++++-- server/server.js | 1 + tests/incident.test.mjs | 11 +++++++++++ 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 086ee35..54946e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to Strata. Versions follow [Semantic Versioning](https://sem The 3D model and the 2D diagram dim the rest, ring the assets hit and draw the flows to cut in red, the cut ones in grey. A red band in the title block keeps the incident in view. - **Muster incidents open on the map.** A Muster incident carries Strata asset ids or parameters (a CVE); its playbook runs in the incident sheet, and each step points at the map through `targets` selectors (`affected`, `vuln:{cve}`, `role:backup`, `type:dc`, `flows:…`, `reach:…`, `consumers:…`, `business:…`, `contacts:…`). A decision can mark assets (`effects`). Ransomware (PB-001) and Exploited critical vulnerability (PB-010) are included. Muster stores these on each step (see its changelog); without a Muster connection, the sheet offers two example incidents built from the map. - **Anonymised AI prompts per asset** (*AI prompts* on an asset sheet): one prompt for the major risks of the asset, one guide that has the AI list plausible incidents and write their response playbooks in Muster's `muster/v1` YAML, with `targets`. Names, addresses, subnets, contacts and the IS name are replaced by placeholders (`ASSET-T`, `ASSET-01`, `SEG-1`, `SVC-B1`); a leak check lists anything identifying left in the text, with your own terms to never send; the answer pasted back gets your names again, in the page only. French or English. -- **The connection to Muster** (`MUSTER_URL`, `MUSTER_USER`, `MUSTER_PASSWORD`). This server signs in to Muster with an analyst account and lists its responses; the incident sheet of an IS shows those linked to it (declared in Muster with this IS, or naming its assets), and the others with *Open on this IS*. Opening one marks the assets it names, then the playbook runs from the map: answering a decision or finishing a step moves the response in Muster, the tasks ticked go both ways, and what Muster's analysts do shows on the map within seconds. A decision answered anywhere marks the assets its `effects` name. Every mark and every flow cut goes to Muster's timeline, so its report keeps them, written by the Strata account with the name of the person who did it. Without `MUSTER_URL`, the two example incidents stay. +- **The connection to Muster** (`MUSTER_URL`, `MUSTER_USER`, `MUSTER_PASSWORD`). This server signs in to Muster with an analyst account and lists its responses; the incident sheet of an IS shows those linked to it (declared in Muster with this IS, or naming its assets), and the others with *Open on this IS*. Opening one marks the assets it names, then the playbook runs from the map: answering a decision or finishing a step moves the response in Muster, the tasks ticked go both ways, and what Muster's analysts do shows on the map within seconds. A decision answered anywhere marks the assets its `effects` name. Every mark and every flow cut goes to Muster's timeline, so its report keeps them, written by the Strata account with the name of the person who did it. Each incident is its own situation: opening another one on the same IS puts the marks and cuts of the previous one aside (marks made by hand before any incident stay). A decision answered in Muster marks the assets as soon as a contributor has the IS open in Strata. Muster's report gets an *Assets hit (Strata)* section. Without `MUSTER_URL`, the two example incidents stay. - **API**: `GET /api/incidents`, `PUT /api/incidents/{IS id}` (contributors and up, no proposal), `GET /api/muster`, `GET /api/muster/incidents/{id}`, `POST /api/muster/incidents/{id}/advance`, `/tasks`, `/strata`, and the `incidents` and `muster` events on `/api/events`. Kept in `incidents.json`, outside the map and its history. - **One-page build**: `npm run build:online` writes `dist-online/strata.html`, the whole interface without a server (map kept in the browser, three.js from jsDelivr). diff --git a/public/app.js b/public/app.js index c93f587..6907dd5 100644 --- a/public/app.js +++ b/public/app.js @@ -3374,7 +3374,8 @@ function showIncident(keep){ } function incOpenScenario(i){ const sc=IR.demoFeed(SITE)[i],pb=sc&&IR.PLAYBOOKS[sc.pb];if(!pb)return; - const inc=Inc.of(SITE.id);inc.on=true;inc.run=IR.start(sc,pb,Date.now()-7*60000); + const inc=Inc.of(SITE.id),was=inc.from,n=IR.adopt(inc,sc.ref);inc.on=true;inc.run=IR.start(sc,pb,Date.now()-7*60000); + if(n)incLog(inc,`${plural(n,'mark')} of ${was} put aside: ${sc.ref} starts from its own assets`,'info'); incLog(inc,`Muster ${sc.ref} opened on the map (${sc.pb} v${pb.v})`,'sync'); sc.assets.forEach(id=>incSetMark(inc,id,'compromised')); if(sc.params.cve){const f=IR.selectAssets(SITE,inc,'vuln:{cve}',sc.params);incLog(inc,`${sc.params.cve}: ${plural(f.length,'asset')} on the map${f.length?' ('+f.map(nameOf).join(', ')+')':''}`,'sync');} @@ -3475,7 +3476,8 @@ function musterGo(edgeId,btn){if(edgeId)musterCall('advance',{edgeId},btn);} async function musterOpen(id){ let m;try{m=await Store.api('GET','api/muster/incidents/'+encodeURIComponent(id));}catch(e){toast(e.message||'Muster cannot be reached.',true);return;} MU.full[id]=m; - const inc=Inc.of(SITE.id),st=m.strata||{}; + const inc=Inc.of(SITE.id),st=m.strata||{},was=inc.from,n=IR.adopt(inc,m.ref); + if(n)incLog(inc,`${plural(n,'mark')} of ${was} put aside: ${m.ref} starts from its own assets`,'info'); inc.on=true;inc.run={source:'muster',id:m.id,ref:m.ref,name:m.name,pb:m.pbCode,v:m.v,mode:m.mode,severity:m.severity,seen:0}; incLog(inc,`Muster ${m.ref} opened on the map (${m.pbCode} v${m.v})`,'sync'); MU.out.push({msg:`opened on the map of IS ${SITE.name}`,kind:'info'}); diff --git a/public/incident.js b/public/incident.js index ea6c78b..5ba5f1e 100644 --- a/public/incident.js +++ b/public/incident.js @@ -11,7 +11,7 @@ const TYPE_LABEL={server:'Server',dc:'Domain controller',db:'Database',hyperviso const SEV_RANK={none:0,low:1,med:2,high:3,crit:4}; /* ---- Incident state of one IS ---- */ -// {on, marks:{assetId:state}, cuts:[flowId], run:null|{…}, log:[{at,msg,kind,by}]} +// {on, marks:{assetId:state}, cuts:[flowId], run:null|{…}, log:[{at,msg,kind,by}], from: the incident the marks belong to} function empty(){return {on:false,marks:{},cuts:[],run:null,log:[]};} function clean(o){ const e=empty();if(!o||typeof o!=='object')return e; @@ -20,8 +20,17 @@ function clean(o){ if(Array.isArray(o.cuts))e.cuts=o.cuts.filter(x=>typeof x==='string'); if(o.run&&typeof o.run==='object')e.run=o.run; if(Array.isArray(o.log))e.log=o.log.slice(0,500); + if(typeof o.from==='string'&&o.from)e.from=o.from.slice(0,120); return e; } +// Opening an incident on the map: the marks and cuts of another incident are put aside (an incident is its own +// situation), those made by hand before any incident was opened stay (they are often the one being declared). +// Returns how many marks were put aside. +function adopt(inc,key){ + let n=0; + if(inc.from&&inc.from!==key){n=Object.keys(inc.marks).length;inc.marks={};inc.cuts=[];} + inc.from=key;return n; +} // Assets in the given states that still exist on the map function marked(site,inc,states){return Object.keys(inc.marks).filter(id=>site.byId[id]&&states.includes(inc.marks[id]));} const affected=(site,inc)=>marked(site,inc,STATES); @@ -426,7 +435,7 @@ function yamlCheck(text){ return {blocks,steps:types.length,bad:[...new Set(types.filter(t=>!['start','action','decision','escalation','end'].includes(t)))],targets:(String(text).match(/^\s*targets:/gm)||[]).length}; } -const api={STATES,empty,clean,marked,affected,reach,consumers,business,selectAssets,resolve,cutPoint,flowsToCut,callList, +const api={STATES,empty,clean,adopt,marked,affected,reach,consumers,business,selectAssets,resolve,cutPoint,flowsToCut,callList, PLAYBOOKS,step,nextOf,start,effects,isMuster,advance,route,fromGraph,runOf,newAnswers,musterFor,idsOf,demoFeed,placeholders,prompts,sensitive,leaks,reidentify,yamlCheck}; root.StrataIncident=api; })(typeof globalThis!=='undefined'?globalThis:this); diff --git a/server/server.js b/server/server.js index 5c38ad1..ead8a88 100644 --- a/server/server.js +++ b/server/server.js @@ -221,6 +221,7 @@ function cleanIncident(b) { cuts: Array.isArray(b.cuts) ? b.cuts.filter(x => typeof x === 'string' && ID_RE.test(x)).slice(0, 5000) : [], run: isObj(b.run) ? b.run : null, log: Array.isArray(b.log) ? b.log.filter(isObj).slice(0, 500) : [], + ...(typeof b.from === 'string' && b.from ? { from: b.from.slice(0, 120) } : {}), }; } function cleanChanges(c) { diff --git a/tests/incident.test.mjs b/tests/incident.test.mjs index 8f24fd9..3b7e46f 100644 --- a/tests/incident.test.mjs +++ b/tests/incident.test.mjs @@ -145,3 +145,14 @@ test('Muster: a frozen playbook and its steps become a run on the map, answers a assert.deepEqual(J(mine.map(i => i.id)), ['inc_1']); assert.deepEqual(J(other.map(i => i.id)), ['x', 'y']); }); + +test('adopt: a new incident starts from its own assets, marks made by hand before any incident stay', () => { + const inc = I.clean({ marks: { a: 'compromised' }, cuts: ['f1'] }); + assert.equal(I.adopt(inc, 'IR-1'), 0, 'marks made by hand are kept for the first incident'); + assert.deepEqual(J(inc.marks), { a: 'compromised' }); + assert.equal(I.adopt(inc, 'IR-1'), 0, 'opening the same incident again changes nothing'); + assert.equal(I.adopt(inc, 'IR-2'), 1); + assert.deepEqual(J(inc.marks), {}); + assert.deepEqual(J(inc.cuts), []); + assert.equal(I.clean(J(inc)).from, 'IR-2', 'kept when saved'); +}); From b68fb4dd5ba40abeb38279b60c0a1de8f95b82f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 21:42:10 +0000 Subject: [PATCH 6/6] One-page build: remove the script tags by name, not with a regexp The build took the three script tags out of index.html with a regular expression, which CodeQL flags (it would miss forms such as ""). It now removes exactly the three tags it inlines, and stops if index.html loads any other script. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ke7yEW5dhDYFVpV2cdgs87 Signed-off-by: Claude --- scripts/build-online.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/build-online.mjs b/scripts/build-online.mjs index 0ad974e..bd36406 100644 --- a/scripts/build-online.mjs +++ b/scripts/build-online.mjs @@ -20,8 +20,14 @@ const read = f => fs.readFileSync(path.join(PUB, f), 'utf8'); const js = s => s.replace(/<\/script/gi, '<\\/script'); const html = read('index.html'); -const body = html.slice(html.indexOf('') + 6, html.indexOf('')) - .replace(/`; + if (!body.includes(tag)) throw new Error(`index.html no longer loads ${f}: check scripts/build-online.mjs`); + body = body.replace(tag, ''); +} +if (body.toLowerCase().includes('