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..54946e0 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. 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). ## [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..6907dd5 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(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(){ @@ -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(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]; @@ -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 `

${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])}`:''}`; 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+'
  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); + 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?``:'

    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

    `:''}`; + }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?`

    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=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)} +
    ${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){ @@ -3364,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');} @@ -3389,23 +3400,94 @@ 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(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');} 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(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();}); 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(!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)}; +} +// 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(!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; + 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||!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});} + 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||{},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'}); + 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 +3823,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..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); @@ -111,19 +120,61 @@ 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; } -// 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 ---- */ +// 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:[]}; + 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; @@ -384,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, - PLAYBOOKS,step,nextOf,start,advance,route,demoFeed,placeholders,prompts,sensitive,leaks,reidentify,yamlCheck}; +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/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..ead8a88 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'); @@ -215,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) { @@ -478,7 +485,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 +512,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 +857,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/incident.test.mjs b/tests/incident.test.mjs index e9600fd..3b7e46f 100644 --- a/tests/incident.test.mjs +++ b/tests/incident.test.mjs @@ -122,3 +122,37 @@ 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']); +}); + +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'); +}); 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)); +});