643 lines
36 KiB
HTML
643 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Kill Chain Assessment — Brownhat / CQRE</title>
|
|
<style>
|
|
:root{
|
|
--bg:#0d1117; --panel:#161b22; --panel2:#1c2330; --line:#30363d; --line2:#3d4654;
|
|
--ink:#e6edf3; --muted:#9aa6b2; --faint:#6e7781;
|
|
--p0:#ff4d4f; --p1:#ff9f0a; --p2:#3fb950; --dark:#a371f7; --entry:#58a6ff; --jewel:#f7c948;
|
|
--accent:#58a6ff; --accent2:#1f6feb;
|
|
--crit:#ff4d4f; --sev:#ff9f0a; --std:#3fb950; --darkq:#a371f7; --house:#6e7781;
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
|
|
header{padding:16px 22px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:16px;flex-wrap:wrap;background:linear-gradient(180deg,#11161d,#0d1117)}
|
|
header h1{font-size:18px;margin:0;letter-spacing:.3px}
|
|
header .tag{font-size:11px;color:var(--faint);border:1px solid var(--line);padding:2px 8px;border-radius:20px}
|
|
header .sub{color:var(--muted);font-size:12.5px;margin-left:auto;max-width:520px;text-align:right}
|
|
.wrap{display:grid;grid-template-columns:340px 1fr 360px;gap:0;height:calc(100vh - 59px)}
|
|
.col{overflow-y:auto;padding:16px}
|
|
.col.left{border-right:1px solid var(--line)}
|
|
.col.right{border-left:1px solid var(--line);background:#0b0f14}
|
|
h2{font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin:4px 0 10px;font-weight:600}
|
|
h2 .hint{text-transform:none;letter-spacing:0;font-weight:400;color:var(--faint);display:block;font-size:11.5px;margin-top:3px}
|
|
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:13px;margin-bottom:14px}
|
|
label{display:block;font-size:11.5px;color:var(--muted);margin:9px 0 3px}
|
|
input,select,textarea,button{font:inherit;color:var(--ink)}
|
|
input[type=text],select,textarea{width:100%;background:var(--panel2);border:1px solid var(--line2);border-radius:7px;padding:7px 9px}
|
|
input[type=text]:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}
|
|
textarea{resize:vertical;min-height:34px}
|
|
.row{display:flex;gap:8px}
|
|
.row>*{flex:1}
|
|
.chk{display:flex;align-items:center;gap:7px;margin:8px 0;font-size:12.5px;color:var(--ink)}
|
|
.chk input{width:auto}
|
|
button{cursor:pointer;background:var(--panel2);border:1px solid var(--line2);border-radius:7px;padding:8px 12px;transition:.12s}
|
|
button:hover{border-color:var(--accent);color:#fff}
|
|
button.primary{background:var(--accent2);border-color:var(--accent2);color:#fff;font-weight:600}
|
|
button.primary:hover{background:#388bfd}
|
|
button.ghost{background:transparent}
|
|
button.danger:hover{border-color:var(--p0);color:var(--p0)}
|
|
.btnrow{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
|
.btnrow button{flex:1;min-width:0}
|
|
.pill{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.5px;padding:2px 7px;border-radius:20px;text-transform:uppercase}
|
|
.pill.entry{background:rgba(88,166,255,.16);color:var(--entry);border:1px solid var(--entry)}
|
|
.pill.jewel{background:rgba(247,201,72,.14);color:var(--jewel);border:1px solid var(--jewel)}
|
|
.node-item{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 10px;margin-bottom:7px;cursor:pointer}
|
|
.node-item:hover{border-color:var(--accent)}
|
|
.node-item.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent) inset}
|
|
.node-item .nm{font-weight:600;display:flex;justify-content:space-between;align-items:center;gap:6px}
|
|
.node-item .meta{font-size:11px;color:var(--faint);margin-top:3px;display:flex;gap:6px;flex-wrap:wrap}
|
|
.edge-item{font-size:12px;background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:7px 9px;margin-bottom:6px;display:flex;justify-content:space-between;gap:8px;align-items:flex-start}
|
|
.edge-item .x{cursor:pointer;color:var(--faint);flex-shrink:0}
|
|
.edge-item .x:hover{color:var(--p0)}
|
|
.tabs{display:flex;gap:4px;margin-bottom:12px;border-bottom:1px solid var(--line)}
|
|
.tabs button{border:none;border-bottom:2px solid transparent;border-radius:0;background:none;color:var(--muted);padding:8px 12px}
|
|
.tabs button.on{color:#fff;border-bottom-color:var(--accent)}
|
|
svg{width:100%;display:block}
|
|
.empty{color:var(--faint);font-size:12.5px;text-align:center;padding:30px 10px;border:1px dashed var(--line2);border-radius:10px}
|
|
.kc-box{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;margin-bottom:14px}
|
|
.kc-step{display:flex;align-items:center;gap:10px;padding:7px 0}
|
|
.kc-arrow{color:var(--p0);font-size:18px;text-align:center;margin:-2px 0}
|
|
.kc-node{flex:1;background:var(--panel2);border:1px solid var(--line2);border-left:3px solid var(--p0);border-radius:6px;padding:7px 10px}
|
|
.kc-node .n{font-weight:600;font-size:13px}
|
|
.kc-node .m{font-size:11px;color:var(--muted)}
|
|
.kc-mech{font-size:11px;color:var(--faint);font-style:italic;padding-left:14px}
|
|
.stat{display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--line);font-size:13px}
|
|
.stat:last-child{border:none}
|
|
.stat b{font-variant-numeric:tabular-nums}
|
|
.q{border-radius:8px;border:1px solid var(--line);padding:10px 12px;margin-bottom:9px;background:var(--panel)}
|
|
.q .qh{display:flex;justify-content:space-between;align-items:center;font-weight:700;font-size:12px;letter-spacing:.5px;text-transform:uppercase}
|
|
.q.crit{border-left:4px solid var(--crit)} .q.crit .qh{color:var(--crit)}
|
|
.q.sev{border-left:4px solid var(--sev)} .q.sev .qh{color:var(--sev)}
|
|
.q.std{border-left:4px solid var(--std)} .q.std .qh{color:var(--std)}
|
|
.q.darkq{border-left:4px solid var(--darkq)} .q.darkq .qh{color:var(--darkq)}
|
|
.q .ql{font-size:12.5px;margin-top:7px}
|
|
.q .qi{padding:4px 0;border-top:1px solid var(--line);margin-top:5px}
|
|
.q .qi:first-of-type{border:none}
|
|
.q .qi .qn{font-weight:600}
|
|
.q .qi .qd{font-size:11px;color:var(--muted)}
|
|
.q .budget{font-size:10.5px;color:var(--faint);font-weight:400;text-transform:none;letter-spacing:0}
|
|
.discovery h3{font-size:12.5px;margin:12px 0 5px;color:var(--accent)}
|
|
.discovery ul{margin:0 0 6px;padding-left:18px;color:var(--muted);font-size:12px}
|
|
.discovery li{margin-bottom:3px}
|
|
.discovery code{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:1px 5px;color:#e6edf3;font-size:11px}
|
|
.note{font-size:11.5px;color:var(--faint);margin-top:6px}
|
|
.legend{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;color:var(--muted);margin-bottom:8px}
|
|
.legend span{display:flex;align-items:center;gap:5px}
|
|
.dot{width:10px;height:10px;border-radius:50%}
|
|
.topbtns{display:flex;gap:8px}
|
|
.file-in{display:none}
|
|
::-webkit-scrollbar{width:10px;height:10px}
|
|
::-webkit-scrollbar-thumb{background:#222b36;border-radius:6px}
|
|
::-webkit-scrollbar-track{background:transparent}
|
|
.muted{color:var(--muted)} .small{font-size:11.5px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>⛓ Kill Chain Assessment</h1>
|
|
<span class="tag">Brownhat · CQRE</span>
|
|
<div class="topbtns">
|
|
<button class="ghost" onclick="loadSample()">Load sample</button>
|
|
<button class="ghost" onclick="exportJSON()">Save .json</button>
|
|
<button class="ghost" onclick="document.getElementById('imp').click()">Open .json</button>
|
|
<button class="primary" onclick="exportMD()">Export report .md</button>
|
|
<input type="file" id="imp" class="file-in" accept=".json" onchange="importJSON(event)">
|
|
</div>
|
|
<div class="sub">Map unknown territory into nodes and attacker moves. The tool finds the shortest path from a foothold to an existential asset — that path <b>is</b> the kill chain — and sizes each node into a remediation quantum.</div>
|
|
</header>
|
|
|
|
<div class="wrap">
|
|
<!-- LEFT: capture -->
|
|
<div class="col left">
|
|
<div class="tabs">
|
|
<button id="t-node" class="on" onclick="tab('node')">Nodes</button>
|
|
<button id="t-edge" onclick="tab('edge')">Moves</button>
|
|
<button id="t-disc" onclick="tab('disc')">Discovery</button>
|
|
</div>
|
|
|
|
<!-- NODE form -->
|
|
<div id="pane-node">
|
|
<div class="panel">
|
|
<h2>Add / edit node<span class="hint">An asset, foothold, identity, or system in the estate.</span></h2>
|
|
<label>Name</label>
|
|
<input type="text" id="n-name" placeholder="e.g. Entra ID Connect sync server">
|
|
<div class="row">
|
|
<div>
|
|
<label>Layer</label>
|
|
<select id="n-type">
|
|
<option value="entry">Entry / exposure</option>
|
|
<option value="identity">Identity</option>
|
|
<option value="privilege">Privilege</option>
|
|
<option value="device">Device / endpoint</option>
|
|
<option value="data">Data / collaboration</option>
|
|
<option value="infra">Infrastructure / OT</option>
|
|
<option value="recovery">Recovery / backup</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Tier</label>
|
|
<select id="n-tier">
|
|
<option value="">— unknown —</option>
|
|
<option value="T0">T0 (control plane)</option>
|
|
<option value="T1">T1 (servers/apps)</option>
|
|
<option value="T2">T2 (workstations)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="chk"><input type="checkbox" id="n-entry"><label style="margin:0;color:var(--entry)">Adversary entry point (internet-reachable / unauth foothold)</label></div>
|
|
<div class="chk"><input type="checkbox" id="n-jewel"><label style="margin:0;color:var(--jewel)">Crown jewel (existential — org cannot operate if lost)</label></div>
|
|
<div class="row">
|
|
<div>
|
|
<label>Reachable by adversary?</label>
|
|
<select id="n-reach"><option value="unknown">Unknown</option><option value="yes">Yes</option><option value="no">No</option></select>
|
|
</div>
|
|
<div>
|
|
<label>Exploit / path available?</label>
|
|
<select id="n-expl"><option value="unknown">Unknown</option><option value="yes">Yes</option><option value="no">No</option></select>
|
|
</div>
|
|
</div>
|
|
<div class="chk"><input type="checkbox" id="n-comp"><label style="margin:0">Compensating control already in front of it (EDR, WAF, segmentation)</label></div>
|
|
<label>Finding / note (optional)</label>
|
|
<textarea id="n-note" placeholder="What's wrong here, evidence, CVE…"></textarea>
|
|
<div class="btnrow">
|
|
<button class="primary" onclick="saveNode()">Save node</button>
|
|
<button class="ghost" onclick="clearNodeForm()">Clear</button>
|
|
</div>
|
|
</div>
|
|
<h2>Nodes <span id="n-count" class="muted small"></span></h2>
|
|
<div id="node-list"></div>
|
|
</div>
|
|
|
|
<!-- EDGE form -->
|
|
<div id="pane-edge" style="display:none">
|
|
<div class="panel">
|
|
<h2>Add attacker move<span class="hint">A directed step: "from here, an attacker can reach there."</span></h2>
|
|
<label>From</label>
|
|
<select id="e-from"></select>
|
|
<label>To</label>
|
|
<select id="e-to"></select>
|
|
<label>Mechanism (how)</label>
|
|
<input type="text" id="e-mech" placeholder="e.g. DCSync via sync-account rights">
|
|
<label>Adversary effort: <span id="e-wlabel">3 — moderate</span></label>
|
|
<input type="range" id="e-weight" min="1" max="5" value="3" style="width:100%" oninput="document.getElementById('e-wlabel').textContent=effortLabel(this.value)">
|
|
<div class="note">Lower effort = easier for the attacker. The kill chain is the <i>lowest-effort</i> path to a crown jewel.</div>
|
|
<div class="btnrow"><button class="primary" onclick="saveEdge()">Add move</button></div>
|
|
</div>
|
|
<h2>Moves <span id="e-count" class="muted small"></span></h2>
|
|
<div id="edge-list"></div>
|
|
</div>
|
|
|
|
<!-- DISCOVERY -->
|
|
<div id="pane-disc" style="display:none">
|
|
<div class="panel discovery">
|
|
<h2>Discovering the chain in unknown territory<span class="hint">What to ask and run to surface the edges you can't see yet. Each answer becomes a node or a move.</span></h2>
|
|
|
|
<h3>1 · Find the entry points (reachability)</h3>
|
|
<ul>
|
|
<li>What does the internet see? External scan / Shodan / attack-surface mapping → every internet-facing service is a candidate entry node.</li>
|
|
<li>Internet-facing VPN, RDP, mail, web apps, appliances — firmware current? MFA enforced?</li>
|
|
<li>Legacy auth still enabled? (bypasses MFA — a silent entry edge)</li>
|
|
</ul>
|
|
|
|
<h3>2 · Find the identity bridges (Book II)</h3>
|
|
<ul>
|
|
<li><code>Entra Connect sync account</code> — does it hold DCSync rights on-prem? That's a cloud→on-prem edge.</li>
|
|
<li>Federation / PTA / PHS path, writeback, seamless SSO — map the bridge.</li>
|
|
</ul>
|
|
|
|
<h3>3 · Find privilege paths (Book III)</h3>
|
|
<ul>
|
|
<li>BloodHound: <code>shortestPath</code> to Domain Admins from non-admins — every path is a chain of edges.</li>
|
|
<li>Kerberoastable / AS-REP-roastable high-priv accounts; KRBTGT last-set date.</li>
|
|
<li>App registrations with <code>RoleManagement.ReadWrite.Directory</code>, <code>Mail.ReadWrite</code> — OAuth consent edges.</li>
|
|
</ul>
|
|
|
|
<h3>4 · Find the crown jewels (existential nodes)</h3>
|
|
<ul>
|
|
<li>Ask the business, not IT: "what stops the company operating?" ERP, payment rails, OT control, the customer DB.</li>
|
|
<li>Backups & recovery — are they reachable from the estate they protect? If yes, that's an edge into your lifeboat.</li>
|
|
</ul>
|
|
|
|
<h3>5 · Map blast radius (the edges between)</h3>
|
|
<ul>
|
|
<li>Flat network? NTLM relay, lateral movement → dense edges, short chains.</li>
|
|
<li>Segmentation, least privilege, T0 isolation → sparse edges, long chains. Note where they're <i>missing</i>.</li>
|
|
</ul>
|
|
|
|
<p class="note">Anything you can't characterise (reachable? unknown) becomes a <span style="color:var(--darkq)">dark quantum</span> — capture the node anyway and mark reachability/exploit "unknown". An uncharacterised asset is the dangerous kind.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CENTER: graph + chain -->
|
|
<div class="col center">
|
|
<h2>Attack graph & kill chain</h2>
|
|
<div class="legend">
|
|
<span><span class="dot" style="background:var(--entry)"></span>entry</span>
|
|
<span><span class="dot" style="background:var(--jewel)"></span>crown jewel</span>
|
|
<span><span class="dot" style="background:var(--p0)"></span>on shortest chain (P0)</span>
|
|
<span><span class="dot" style="background:var(--p1)"></span>on a chain (P1)</span>
|
|
<span><span class="dot" style="background:var(--p2)"></span>off-chain (P2)</span>
|
|
</div>
|
|
<div class="panel" style="padding:6px"><div id="graph"></div></div>
|
|
<div id="chain-out"></div>
|
|
</div>
|
|
|
|
<!-- RIGHT: results -->
|
|
<div class="col right">
|
|
<h2>Assessment</h2>
|
|
<div class="panel" id="summary"></div>
|
|
<h2>Remediation quanta<span class="hint">Sized by time-to-existential-impact, not CVSS.</span></h2>
|
|
<div id="quanta"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
/* ---------------- state ---------------- */
|
|
let nodes = []; // {id,name,type,tier,entry,jewel,reach,expl,comp,note}
|
|
let edges = []; // {id,from,to,mech,w}
|
|
let editingId = null;
|
|
let uid = () => 'n'+Math.random().toString(36).slice(2,8);
|
|
|
|
const STORE='brownhat-killchain-v1';
|
|
function persist(){ try{localStorage.setItem(STORE,JSON.stringify({nodes,edges}));}catch(e){} }
|
|
function restore(){ try{const s=JSON.parse(localStorage.getItem(STORE));if(s&&s.nodes){nodes=s.nodes;edges=s.edges||[];}}catch(e){} }
|
|
|
|
function effortLabel(v){return {1:'1 — trivial',2:'2 — easy',3:'3 — moderate',4:'4 — hard',5:'5 — very hard'}[v];}
|
|
|
|
/* ---------------- tabs ---------------- */
|
|
function tab(t){
|
|
['node','edge','disc'].forEach(x=>{
|
|
document.getElementById('pane-'+x).style.display = x===t?'block':'none';
|
|
document.getElementById('t-'+x).classList.toggle('on',x===t);
|
|
});
|
|
if(t==='edge') refreshEdgeSelects();
|
|
}
|
|
|
|
/* ---------------- node CRUD ---------------- */
|
|
function saveNode(){
|
|
const name=document.getElementById('n-name').value.trim();
|
|
if(!name){alert('Name the node first.');return;}
|
|
const data={
|
|
name,
|
|
type:document.getElementById('n-type').value,
|
|
tier:document.getElementById('n-tier').value,
|
|
entry:document.getElementById('n-entry').checked,
|
|
jewel:document.getElementById('n-jewel').checked,
|
|
reach:document.getElementById('n-reach').value,
|
|
expl:document.getElementById('n-expl').value,
|
|
comp:document.getElementById('n-comp').checked,
|
|
note:document.getElementById('n-note').value.trim()
|
|
};
|
|
if(editingId){ Object.assign(nodes.find(n=>n.id===editingId),data); }
|
|
else { nodes.push(Object.assign({id:uid()},data)); }
|
|
clearNodeForm(); render();
|
|
}
|
|
function editNode(id){
|
|
const n=nodes.find(x=>x.id===id); if(!n)return;
|
|
editingId=id;
|
|
document.getElementById('n-name').value=n.name;
|
|
document.getElementById('n-type').value=n.type;
|
|
document.getElementById('n-tier').value=n.tier||'';
|
|
document.getElementById('n-entry').checked=n.entry;
|
|
document.getElementById('n-jewel').checked=n.jewel;
|
|
document.getElementById('n-reach').value=n.reach;
|
|
document.getElementById('n-expl').value=n.expl;
|
|
document.getElementById('n-comp').checked=n.comp;
|
|
document.getElementById('n-note').value=n.note||'';
|
|
tab('node'); window.scrollTo(0,0);
|
|
}
|
|
function delNode(id){
|
|
if(!confirm('Delete this node and its moves?'))return;
|
|
nodes=nodes.filter(n=>n.id!==id);
|
|
edges=edges.filter(e=>e.from!==id&&e.to!==id);
|
|
if(editingId===id)clearNodeForm();
|
|
render();
|
|
}
|
|
function clearNodeForm(){
|
|
editingId=null;
|
|
['n-name','n-note'].forEach(i=>document.getElementById(i).value='');
|
|
document.getElementById('n-type').value='entry';
|
|
document.getElementById('n-tier').value='';
|
|
['n-entry','n-jewel','n-comp'].forEach(i=>document.getElementById(i).checked=false);
|
|
document.getElementById('n-reach').value='unknown';
|
|
document.getElementById('n-expl').value='unknown';
|
|
}
|
|
|
|
/* ---------------- edge CRUD ---------------- */
|
|
function refreshEdgeSelects(){
|
|
const opts=nodes.map(n=>`<option value="${n.id}">${esc(n.name)}</option>`).join('');
|
|
document.getElementById('e-from').innerHTML=opts;
|
|
document.getElementById('e-to').innerHTML=opts;
|
|
}
|
|
function saveEdge(){
|
|
const from=document.getElementById('e-from').value, to=document.getElementById('e-to').value;
|
|
if(!from||!to){alert('Add at least two nodes first.');return;}
|
|
if(from===to){alert('A move must go between two different nodes.');return;}
|
|
edges.push({id:uid(),from,to,mech:document.getElementById('e-mech').value.trim(),w:+document.getElementById('e-weight').value});
|
|
document.getElementById('e-mech').value='';
|
|
render();
|
|
}
|
|
function delEdge(id){ edges=edges.filter(e=>e.id!==id); render(); }
|
|
|
|
/* ---------------- analysis: Dijkstra shortest existential path ---------------- */
|
|
function analyse(){
|
|
const entryIds=nodes.filter(n=>n.entry).map(n=>n.id);
|
|
const jewelIds=new Set(nodes.filter(n=>n.jewel).map(n=>n.id));
|
|
const adj={}; nodes.forEach(n=>adj[n.id]=[]);
|
|
edges.forEach(e=>{ if(adj[e.from]) adj[e.from].push(e); });
|
|
|
|
// multi-source Dijkstra from all entry points
|
|
const dist={}, prev={}, prevEdge={};
|
|
nodes.forEach(n=>dist[n.id]=Infinity);
|
|
const pq=[];
|
|
entryIds.forEach(id=>{dist[id]=0; pq.push([0,id]);});
|
|
while(pq.length){
|
|
pq.sort((a,b)=>a[0]-b[0]);
|
|
const [d,u]=pq.shift();
|
|
if(d>dist[u])continue;
|
|
(adj[u]||[]).forEach(e=>{
|
|
const nd=d+e.w;
|
|
if(nd<dist[e.to]){dist[e.to]=nd;prev[e.to]=u;prevEdge[e.to]=e;pq.push([nd,e.to]);}
|
|
});
|
|
}
|
|
// best jewel = reachable jewel with min dist
|
|
let best=null;
|
|
jewelIds.forEach(j=>{ if(dist[j]<Infinity && (!best||dist[j]<dist[best])) best=j; });
|
|
// reconstruct shortest chain
|
|
let chain=[],chainEdges=[];
|
|
if(best!=null){
|
|
let cur=best;
|
|
while(cur!=null){ chain.unshift(cur); if(prevEdge[cur]){chainEdges.unshift(prevEdge[cur]);cur=prev[cur];} else cur=null; }
|
|
}
|
|
const onShortest=new Set(chain);
|
|
|
|
// nodes on ANY existential path: reachable from entry AND can reach a jewel
|
|
const reachFromEntry=new Set();
|
|
(function(){const st=[...entryIds];entryIds.forEach(i=>reachFromEntry.add(i));
|
|
while(st.length){const u=st.pop();(adj[u]||[]).forEach(e=>{if(!reachFromEntry.has(e.to)){reachFromEntry.add(e.to);st.push(e.to);}});}})();
|
|
// reverse reachability to a jewel
|
|
const radj={}; nodes.forEach(n=>radj[n.id]=[]); edges.forEach(e=>{if(radj[e.to])radj[e.to].push(e.from);});
|
|
const canReachJewel=new Set();
|
|
(function(){const st=[...jewelIds];jewelIds.forEach(i=>canReachJewel.add(i));
|
|
while(st.length){const u=st.pop();(radj[u]||[]).forEach(f=>{if(!canReachJewel.has(f)){canReachJewel.add(f);st.push(f);}});}})();
|
|
const onAnyChain=new Set(nodes.filter(n=>reachFromEntry.has(n.id)&&canReachJewel.has(n.id)).map(n=>n.id));
|
|
|
|
return {chain,chainEdges,onShortest,onAnyChain,dist,best,entryIds,jewelIds,reachable:reachFromEntry};
|
|
}
|
|
|
|
/* priority + quantum per node */
|
|
function priority(n,a){
|
|
if(a.onShortest.has(n.id))return 'P0';
|
|
if(a.onAnyChain.has(n.id))return 'P1';
|
|
return 'P2';
|
|
}
|
|
function quantum(n,a){
|
|
const onChain = a.onShortest.has(n.id)||a.onAnyChain.has(n.id);
|
|
if(!onChain) return 'house';
|
|
if(n.reach==='unknown'||n.expl==='unknown') return 'dark';
|
|
if(a.onShortest.has(n.id) && n.reach==='yes' && n.expl==='yes' && !n.comp) return 'crit';
|
|
if(n.reach==='yes' || n.expl==='yes') return 'sev';
|
|
return 'std';
|
|
}
|
|
const QMETA={
|
|
crit:{label:'Critical quantum',budget:'hours · compensating control, not the patch',cls:'crit'},
|
|
sev:{label:'Severe quantum',budget:'days · batched into one change window',cls:'sev'},
|
|
std:{label:'Standard quantum',budget:'sprint · drained in finishable batches',cls:'std'},
|
|
dark:{label:'Dark quantum',budget:'unsized · route to discovery',cls:'darkq'},
|
|
house:{label:'Housekeeping',budget:'off every kill chain — not urgent',cls:'std'}
|
|
};
|
|
|
|
/* ---------------- render ---------------- */
|
|
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
|
const TYPELBL={entry:'Entry',identity:'Identity',privilege:'Privilege',device:'Device',data:'Data',infra:'Infra/OT',recovery:'Recovery'};
|
|
|
|
function render(){
|
|
persist();
|
|
renderNodeList(); renderEdgeList(); refreshEdgeSelects();
|
|
const a = analyse();
|
|
renderGraph(a); renderChain(a); renderSummary(a); renderQuanta(a);
|
|
}
|
|
|
|
function renderNodeList(){
|
|
document.getElementById('n-count').textContent = nodes.length?`(${nodes.length})`:'';
|
|
const el=document.getElementById('node-list');
|
|
if(!nodes.length){el.innerHTML='<div class="empty">No nodes yet. Add the footholds and assets you find — or “Load sample”.</div>';return;}
|
|
const a=analyse();
|
|
el.innerHTML=nodes.map(n=>{
|
|
const p=priority(n,a);
|
|
const pc=p==='P0'?'var(--p0)':p==='P1'?'var(--p1)':'var(--p2)';
|
|
return `<div class="node-item ${editingId===n.id?'sel':''}" onclick="editNode('${n.id}')">
|
|
<div class="nm"><span>${esc(n.name)}</span>
|
|
<span style="display:flex;gap:5px;align-items:center">
|
|
${n.entry?'<span class="pill entry">entry</span>':''}
|
|
${n.jewel?'<span class="pill jewel">jewel</span>':''}
|
|
<span style="color:${pc};font-weight:700;font-size:11px">${(a.onShortest.has(n.id)||a.onAnyChain.has(n.id))?p:'—'}</span>
|
|
<span class="x" onclick="event.stopPropagation();delNode('${n.id}')" style="cursor:pointer;color:var(--faint)">✕</span>
|
|
</span>
|
|
</div>
|
|
<div class="meta"><span>${TYPELBL[n.type]||n.type}</span>${n.tier?`<span>· ${n.tier}</span>`:''}
|
|
<span>· reach:${n.reach}</span><span>· exploit:${n.expl}</span>${n.comp?'<span>· compensated</span>':''}</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderEdgeList(){
|
|
document.getElementById('e-count').textContent = edges.length?`(${edges.length})`:'';
|
|
const el=document.getElementById('edge-list');
|
|
if(!edges.length){el.innerHTML='<div class="empty">No moves yet. A move is one attacker step from one node to another.</div>';return;}
|
|
const nm=id=>{const n=nodes.find(x=>x.id===id);return n?esc(n.name):'?';};
|
|
el.innerHTML=edges.map(e=>`<div class="edge-item">
|
|
<div><b>${nm(e.from)}</b> → <b>${nm(e.to)}</b><br>
|
|
<span class="muted small">${esc(e.mech)||'(mechanism unspecified)'} · effort ${e.w}</span></div>
|
|
<span class="x" onclick="delEdge('${e.id}')">✕</span></div>`).join('');
|
|
}
|
|
|
|
function renderGraph(a){
|
|
const g=document.getElementById('graph');
|
|
if(!nodes.length){g.innerHTML='<div class="empty" style="margin:10px">The attack graph renders here.</div>';return;}
|
|
// simple layered layout by distance-from-entry (BFS depth), entries left → jewels right
|
|
const depth={}; nodes.forEach(n=>depth[n.id]=n.entry?0:null);
|
|
const adj={};nodes.forEach(n=>adj[n.id]=[]);edges.forEach(e=>{if(adj[e.from])adj[e.from].push(e.to);});
|
|
let q=nodes.filter(n=>n.entry).map(n=>n.id),guard=0;
|
|
while(q.length&&guard++<999){const u=q.shift();(adj[u]||[]).forEach(v=>{if(depth[v]==null||depth[v]>depth[u]+1){depth[v]=depth[u]+1;q.push(v);}});}
|
|
let maxd=0;nodes.forEach(n=>{if(depth[n.id]==null)depth[n.id]=999;maxd=Math.max(maxd,depth[n.id]===999?0:depth[n.id]);});
|
|
// orphans (no depth) put in a trailing column
|
|
const cols={};nodes.forEach(n=>{const d=depth[n.id]===999?maxd+1:depth[n.id];(cols[d]=cols[d]||[]).push(n);});
|
|
const colKeys=Object.keys(cols).map(Number).sort((x,y)=>x-y);
|
|
const W=Math.max(640,colKeys.length*180), colW=W/colKeys.length;
|
|
let maxRows=0;colKeys.forEach(k=>maxRows=Math.max(maxRows,cols[k].length));
|
|
const H=Math.max(220,maxRows*72+40);
|
|
const pos={};
|
|
colKeys.forEach((k,ci)=>{cols[k].forEach((n,ri)=>{const rows=cols[k].length;
|
|
pos[n.id]={x:colW*ci+colW/2,y:H/(rows+1)*(ri+1)};});});
|
|
const col=n=>{if(a.onShortest.has(n.id))return'var(--p0)';if(a.onAnyChain.has(n.id))return'var(--p1)';if(n.jewel)return'var(--jewel)';if(n.entry)return'var(--entry)';return'#3fb95066';};
|
|
const onChainEdge=new Set(a.chainEdges.map(e=>e.id));
|
|
let svg=`<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
|
|
<defs><marker id="arr" markerWidth="9" markerHeight="9" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#5b6675"/></marker>
|
|
<marker id="arrR" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="var(--p0)"/></marker></defs>`;
|
|
edges.forEach(e=>{const a1=pos[e.from],b=pos[e.to];if(!a1||!b)return;
|
|
const hot=onChainEdge.has(e.id);
|
|
const mx=(a1.x+b.x)/2,my=(a1.y+b.y)/2-18;
|
|
svg+=`<path d="M${a1.x},${a1.y} Q${mx},${my} ${b.x},${b.y}" fill="none" stroke="${hot?'var(--p0)':'#39414d'}" stroke-width="${hot?2.4:1.2}" marker-end="url(#${hot?'arrR':'arr'})" opacity="${hot?1:.7}"/>`;
|
|
});
|
|
nodes.forEach(n=>{const p=pos[n.id];if(!p)return;const c=col(n);
|
|
const r=n.jewel||n.entry?20:16;
|
|
svg+=`<g>
|
|
<circle cx="${p.x}" cy="${p.y}" r="${r}" fill="${c}" fill-opacity="${a.onShortest.has(n.id)?0.95:0.18}" stroke="${c}" stroke-width="2"/>
|
|
${n.jewel?`<text x="${p.x}" y="${p.y+4}" text-anchor="middle" font-size="14">★</text>`:''}
|
|
${n.entry?`<text x="${p.x}" y="${p.y+4}" text-anchor="middle" font-size="12">▶</text>`:''}
|
|
<text x="${p.x}" y="${p.y+r+13}" text-anchor="middle" font-size="11" fill="#c9d4df">${esc(n.name.length>22?n.name.slice(0,21)+'…':n.name)}</text>
|
|
</g>`;});
|
|
svg+='</svg>';
|
|
g.innerHTML=svg;
|
|
}
|
|
|
|
function renderChain(a){
|
|
const el=document.getElementById('chain-out');
|
|
if(!a.entryIds.length||!a.jewelIds.size){
|
|
el.innerHTML=`<div class="kc-box"><b>No kill chain yet.</b><div class="note">Mark at least one node as an <span style="color:var(--entry)">entry point</span> and one as a <span style="color:var(--jewel)">crown jewel</span>, then connect them with moves.</div></div>`;return;}
|
|
if(!a.chain.length){
|
|
el.innerHTML=`<div class="kc-box"><b style="color:var(--p2)">No path found from any entry point to a crown jewel.</b><div class="note">Either the estate is genuinely segmented here (good — note it), or you haven't mapped the connecting moves yet. In unknown territory, assume the latter until proven.</div></div>`;return;}
|
|
const nm=id=>nodes.find(n=>n.id===id);
|
|
let html=`<div class="kc-box"><h2 style="color:var(--p0);margin-top:0">⛓ The kill chain<span class="hint">Lowest-effort path from foothold to existential impact. Total adversary effort: ${a.dist[a.best]}.</span></h2>`;
|
|
a.chain.forEach((id,i)=>{
|
|
const n=nm(id);
|
|
html+=`<div class="kc-step"><div class="kc-node">
|
|
<div class="n">${esc(n.name)} ${n.entry?'<span class="pill entry">entry</span>':''} ${n.jewel?'<span class="pill jewel">jewel</span>':''}</div>
|
|
<div class="m">${TYPELBL[n.type]||n.type}${n.tier?' · '+n.tier:''}${n.note?' · '+esc(n.note):''}</div>
|
|
</div></div>`;
|
|
if(i<a.chainEdges.length){const e=a.chainEdges[i];
|
|
html+=`<div class="kc-arrow">↓</div><div class="kc-mech">${esc(e.mech)||'move'} · effort ${e.w}</div>`;}
|
|
});
|
|
html+=`<div class="note" style="margin-top:10px">Every node on this path is a <b style="color:var(--p0)">P0</b>. Fix the chain first — break any single link and the existential path is severed. After the incident, ask: did this chain get <i>shorter</i>?</div></div>`;
|
|
el.innerHTML=html;
|
|
}
|
|
|
|
function renderSummary(a){
|
|
const counts={P0:0,P1:0,P2:0};
|
|
nodes.forEach(n=>{counts[priority(n,a)]++;});
|
|
const qc={crit:0,sev:0,std:0,dark:0,house:0};
|
|
nodes.forEach(n=>qc[quantum(n,a)]++);
|
|
document.getElementById('summary').innerHTML=`
|
|
<div class="stat"><span>Nodes mapped</span><b>${nodes.length}</b></div>
|
|
<div class="stat"><span>Attacker moves</span><b>${edges.length}</b></div>
|
|
<div class="stat"><span>Entry points</span><b>${a.entryIds.length}</b></div>
|
|
<div class="stat"><span>Crown jewels</span><b>${a.jewelIds.size}</b></div>
|
|
<div class="stat"><span style="color:var(--p0)">Kill-chain length</span><b style="color:var(--p0)">${a.chain.length||'—'}</b></div>
|
|
<div class="stat"><span style="color:var(--p0)">P0 nodes (on shortest chain)</span><b style="color:var(--p0)">${counts.P0}</b></div>
|
|
<div class="stat"><span style="color:var(--p1)">P1 nodes (on a chain)</span><b style="color:var(--p1)">${counts.P1}</b></div>
|
|
<div class="stat"><span style="color:var(--darkq)">Dark quanta (unsized)</span><b style="color:var(--darkq)">${qc.dark}</b></div>`;
|
|
}
|
|
|
|
function renderQuanta(a){
|
|
const buckets={crit:[],sev:[],std:[],dark:[]};
|
|
nodes.forEach(n=>{const q=quantum(n,a);if(buckets[q])buckets[q].push(n);});
|
|
const order=['crit','sev','std','dark'];
|
|
let html='';
|
|
order.forEach(k=>{
|
|
const list=buckets[k];if(!list.length)return;
|
|
const m=QMETA[k];
|
|
html+=`<div class="q ${m.cls}"><div class="qh"><span>${m.label}</span><span class="budget">${m.budget}</span></div>`;
|
|
list.forEach(n=>{
|
|
const action = k==='crit'?'Sever reachability / compensating control now'
|
|
: k==='sev'?'Remediate in next change window, verify enforcement'
|
|
: k==='std'?'Batch into sprint; this is where patch velocity fits'
|
|
: 'Characterise: establish reachability & exploitability';
|
|
html+=`<div class="qi"><div class="qn">${esc(n.name)}</div><div class="qd">${action}${n.note?' — '+esc(n.note):''}</div></div>`;
|
|
});
|
|
html+='</div>';
|
|
});
|
|
if(!html) html='<div class="empty">Quanta appear once nodes sit on a kill chain. Map entries, jewels, and the moves between.</div>';
|
|
document.getElementById('quanta').innerHTML=html;
|
|
}
|
|
|
|
/* ---------------- import / export ---------------- */
|
|
function exportJSON(){
|
|
dl('kill-chain-assessment.json', JSON.stringify({nodes,edges,exported:new Date().toISOString()},null,2));
|
|
}
|
|
function importJSON(ev){
|
|
const f=ev.target.files[0];if(!f)return;
|
|
const r=new FileReader();
|
|
r.onload=()=>{try{const s=JSON.parse(r.result);nodes=s.nodes||[];edges=s.edges||[];clearNodeForm();render();}catch(e){alert('Could not read that file.');}};
|
|
r.readAsText(f); ev.target.value='';
|
|
}
|
|
function exportMD(){
|
|
const a=analyse();const nm=id=>{const n=nodes.find(x=>x.id===id);return n?n.name:'?';};
|
|
let md=`# Kill Chain Assessment\n\n_Generated ${new Date().toLocaleString()} · Brownhat / CQRE_\n\n`;
|
|
md+=`## Summary\n\n- Nodes mapped: ${nodes.length}\n- Attacker moves: ${edges.length}\n- Entry points: ${a.entryIds.length}\n- Crown jewels: ${a.jewelIds.size}\n- Kill-chain length: ${a.chain.length||'—'}\n\n`;
|
|
if(a.chain.length){
|
|
md+=`## The kill chain (shortest existential path)\n\nLowest-effort path from foothold to existential impact (total adversary effort ${a.dist[a.best]}):\n\n\`\`\`\n`;
|
|
a.chain.forEach((id,i)=>{md+=`${nm(id)}`;if(i<a.chainEdges.length)md+=`\n → [${a.chainEdges[i].mech||'move'} · effort ${a.chainEdges[i].w}]\n`;});
|
|
md+=`\n\`\`\`\n\nEvery node on this path is a **P0**. Break any single link to sever the existential path.\n\n`;
|
|
} else {
|
|
md+=`## The kill chain\n\nNo path from an entry point to a crown jewel was mapped. Either the estate is segmented here, or the connecting moves are not yet discovered.\n\n`;
|
|
}
|
|
// quanta
|
|
const buckets={crit:[],sev:[],std:[],dark:[]};nodes.forEach(n=>{const q=quantum(n,a);if(buckets[q])buckets[q].push(n);});
|
|
md+=`## Remediation quanta\n\n`;
|
|
[['crit','Critical quantum — hours (compensating control, not the patch)'],
|
|
['sev','Severe quantum — days (one change window)'],
|
|
['std','Standard quantum — sprint (patch velocity fits here)'],
|
|
['dark','Dark quantum — unsized (route to discovery)']].forEach(([k,t])=>{
|
|
if(!buckets[k].length)return;
|
|
md+=`### ${t}\n\n`;
|
|
buckets[k].forEach(n=>{md+=`- **${n.name}**${n.tier?` (${n.tier})`:''}${n.note?` — ${n.note}`:''} _(reach:${n.reach}, exploit:${n.expl}${n.comp?', compensated':''})_\n`;});
|
|
md+=`\n`;
|
|
});
|
|
// findings table
|
|
md+=`## All nodes by priority\n\n| Node | Layer | Tier | Priority | Quantum | Reach | Exploit |\n|---|---|---|---|---|---|---|\n`;
|
|
const pri=n=>priority(n,a);
|
|
nodes.slice().sort((x,y)=>({P0:0,P1:1,P2:2}[pri(x)]-{P0:0,P1:1,P2:2}[pri(y)])).forEach(n=>{
|
|
md+=`| ${n.name} | ${TYPELBL[n.type]||n.type} | ${n.tier||'—'} | ${(a.onShortest.has(n.id)||a.onAnyChain.has(n.id))?pri(n):'off-chain'} | ${QMETA[quantum(n,a)].label} | ${n.reach} | ${n.expl} |\n`;
|
|
});
|
|
md+=`\n---\n\n_See Book VII — Vulnerability Management and the Quantum Vulnerability Management framework for how to size and drain these quanta._\n`;
|
|
dl('kill-chain-assessment.md', md);
|
|
}
|
|
function dl(name,content){
|
|
const b=new Blob([content],{type:'text/plain'});const u=URL.createObjectURL(b);
|
|
const a=document.createElement('a');a.href=u;a.download=name;a.click();URL.revokeObjectURL(u);
|
|
}
|
|
|
|
/* ---------------- sample (repo: mid-market engagement) ---------------- */
|
|
function loadSample(){
|
|
if(nodes.length && !confirm('Replace current assessment with the sample engagement?'))return;
|
|
nodes=[
|
|
mk('Stale contractor credential','identity','',{entry:1,reach:'yes',expl:'yes',note:'Active 6 months after offboarding; no MFA'}),
|
|
mk('Internet-facing VPN (legacy firmware)','entry','',{entry:1,reach:'yes',expl:'yes',note:'Cisco ASA, firmware 18mo stale, no MFA'}),
|
|
mk('M365 / Entra ID','identity','T1',{reach:'yes',expl:'yes',note:'34% sign-ins without MFA; CA in report-only'}),
|
|
mk('SharePoint / Teams / Exchange','data','T1',{reach:'yes',expl:'no',note:'All collaboration data + email'}),
|
|
mk('Entra admin account','privilege','T0',{reach:'yes',expl:'yes',note:'Reachable via password spray'}),
|
|
mk('Entra Connect sync account','privilege','T0',{reach:'yes',expl:'yes',note:'Has DCSync rights on-prem'}),
|
|
mk('On-prem Active Directory','privilege','T0',{jewel:0,reach:'yes',expl:'yes',note:'KRBTGT never rotated (847d)'}),
|
|
mk('SAP ERP','infra','T1',{jewel:1,reach:'unknown',expl:'unknown',note:'Financial + operational; default creds on secondary instance'}),
|
|
mk('Backups (same segment as ERP)','recovery','T1',{jewel:1,reach:'yes',expl:'yes',comp:0,note:'Never restore-tested; reachable from estate'})
|
|
];
|
|
const id=n=>nodes.find(x=>x.name.startsWith(n)).id;
|
|
edges=[
|
|
ed('Stale contractor','M365','Credential valid, no MFA',1),
|
|
ed('Internet-facing VPN','On-prem','VPN auth → internal network',1),
|
|
ed('M365','SharePoint','Token grants data access',1),
|
|
ed('M365','Entra admin','Password spray → privilege escalation',2),
|
|
ed('Entra admin','Entra Connect','Admin controls sync identity',2),
|
|
ed('Entra Connect','On-prem','DCSync via sync-account rights',2),
|
|
ed('On-prem','SAP ERP','Domain creds reused on ERP',3),
|
|
ed('On-prem','Backups','Backups reachable from domain',1),
|
|
ed('SAP ERP','Backups','Same network segment',1)
|
|
];
|
|
function mk(name,type,tier,o){return Object.assign({id:uid(),name,type,tier,entry:!!o.entry,jewel:!!o.jewel,reach:o.reach||'unknown',expl:o.expl||'unknown',comp:!!o.comp,note:o.note||''},{});}
|
|
function ed(a,b,mech,w){return {id:uid(),from:id(a),to:id(b),mech,w};}
|
|
clearNodeForm();render();
|
|
}
|
|
|
|
/* ---------------- boot ---------------- */
|
|
restore();
|
|
if(!nodes.length) loadSample(); else render();
|
|
</script>
|
|
</body>
|
|
</html>
|