proxy2model 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. proxy2model/__init__.py +19 -0
  2. proxy2model/__main__.py +6 -0
  3. proxy2model/atomic.py +68 -0
  4. proxy2model/cli/__init__.py +3 -0
  5. proxy2model/cli/analyze.py +501 -0
  6. proxy2model/cli/app.py +408 -0
  7. proxy2model/cli/browse.py +279 -0
  8. proxy2model/cli/build.py +399 -0
  9. proxy2model/cli/common.py +190 -0
  10. proxy2model/cli/history.py +224 -0
  11. proxy2model/cli/mcp.py +184 -0
  12. proxy2model/cli/ui.py +62 -0
  13. proxy2model/core/__init__.py +1 -0
  14. proxy2model/core/changes.py +124 -0
  15. proxy2model/core/context.py +571 -0
  16. proxy2model/core/diff.py +330 -0
  17. proxy2model/core/domains.py +98 -0
  18. proxy2model/core/graph.py +562 -0
  19. proxy2model/core/methodology.py +343 -0
  20. proxy2model/core/model.py +998 -0
  21. proxy2model/core/stack.py +218 -0
  22. proxy2model/data/fonts/LICENSES.md +16 -0
  23. proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  25. proxy2model/data/fonts/nippo-200.woff2 +0 -0
  26. proxy2model/data/fonts/nippo-300.woff2 +0 -0
  27. proxy2model/data/fonts/nippo-400.woff2 +0 -0
  28. proxy2model/data/fonts/nippo-500.woff2 +0 -0
  29. proxy2model/data/fonts/nippo-700.woff2 +0 -0
  30. proxy2model/data/public_suffix_list.dat +16497 -0
  31. proxy2model/db/__init__.py +1 -0
  32. proxy2model/db/bql.py +710 -0
  33. proxy2model/db/bql_js.py +278 -0
  34. proxy2model/db/store.py +547 -0
  35. proxy2model/export/__init__.py +1 -0
  36. proxy2model/export/openapi.py +248 -0
  37. proxy2model/ingest/__init__.py +1 -0
  38. proxy2model/ingest/browse.py +181 -0
  39. proxy2model/ingest/browser.py +1058 -0
  40. proxy2model/ingest/crawl.py +673 -0
  41. proxy2model/ingest/extract.py +208 -0
  42. proxy2model/ingest/infer.py +75 -0
  43. proxy2model/ingest/journey.py +148 -0
  44. proxy2model/ingest/live.py +96 -0
  45. proxy2model/ingest/parse.py +1275 -0
  46. proxy2model/ingest/redact.py +535 -0
  47. proxy2model/py.typed +0 -0
  48. proxy2model/recon/__init__.py +1 -0
  49. proxy2model/recon/osint.py +607 -0
  50. proxy2model/report/__init__.py +1 -0
  51. proxy2model/report/animate.py +163 -0
  52. proxy2model/report/dash.py +469 -0
  53. proxy2model/report/fonts.py +51 -0
  54. proxy2model/report/html.py +988 -0
  55. proxy2model/report/inventory.py +258 -0
  56. proxy2model/report/map.py +515 -0
  57. proxy2model/report/pages.py +537 -0
  58. proxy2model/report/query.py +306 -0
  59. proxy2model/report/shot.py +55 -0
  60. proxy2model/report/view.py +213 -0
  61. proxy2model-2.0.0.dist-info/METADATA +148 -0
  62. proxy2model-2.0.0.dist-info/RECORD +66 -0
  63. proxy2model-2.0.0.dist-info/WHEEL +5 -0
  64. proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
  65. proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
  66. proxy2model-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,306 @@
1
+ """The Query view of the offline report: a BQL console with highlighting, completion and a library.
2
+
3
+ The engine is `BQL` (bql_js.py); this is only the front end. Left, a library of ready queries built
4
+ from this model (its busiest endpoint, a code-only one, ...), pins and history. Right, the editor
5
+ with live syntax colouring and completion, then the results as a sortable table, or as chains
6
+ for graph paths.
7
+ """
8
+
9
+ QRY_CSS = r"""
10
+ /* ask: one box for plain questions and BQL */
11
+ .atabs{display:flex;gap:4px;margin:16px 0 10px;border-bottom:1px solid var(--line)}
12
+ .atab{font:600 12.5px var(--sans);padding:8px 14px;border:0;background:none;color:var(--muted);border-bottom:2px solid transparent;margin-bottom:-1px}
13
+ .atab.on{color:var(--text);border-bottom-color:var(--signal)}
14
+ .achips{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:6px}
15
+ .achip{font:500 13px var(--sans);padding:7px 13px;border-radius:999px;border:1px solid var(--line2);background:var(--panel);color:var(--text);max-width:100%}
16
+ .achip.mono{font:500 12px var(--mono)}
17
+ .achip:hover{border-color:var(--signal)}
18
+ .achip small{color:var(--faint);margin-left:6px;font-size:11px}
19
+ .qed{margin-top:4px;position:relative;border:1px solid var(--line2);border-radius:12px;background:var(--panel);box-shadow:var(--shadow)}
20
+ .qed:focus-within{border-color:var(--signal)}
21
+ .qed .hl,.qed textarea{font:14px/1.6 var(--mono);padding:14px 16px;margin:0;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;min-height:76px;letter-spacing:0;tab-size:2}
22
+ .qed .hl{position:absolute;inset:0;pointer-events:none;color:var(--text);overflow:hidden}
23
+ .qed textarea{position:relative;display:block;width:100%;height:76px;resize:vertical;border:0;background:transparent;color:transparent;caret-color:var(--signal);outline:none}
24
+ .qed textarea::selection{background:rgba(232,80,2,.28);color:transparent}
25
+ .hl .f{color:var(--observed)}.hl .o{color:var(--inferred)}.hl .v{color:var(--ok)}.hl .n{color:var(--warn)}.hl .k{color:var(--signal);font-weight:700}.hl .vb{color:var(--l3);font-weight:700}.hl .p{color:var(--faint)}
26
+ .qbar2{display:flex;align-items:center;gap:8px;padding:8px 10px;border-top:1px solid var(--line);background:var(--panel2);border-radius:0 0 12px 12px}
27
+ .qbar2 .hint{color:var(--faint);font:11.5px var(--mono);margin-right:auto}
28
+ .qbar2 kbd{font:600 10.5px var(--mono);border:1px solid var(--line2);border-bottom-width:2px;border-radius:4px;padding:0 5px;background:var(--panel);color:var(--muted)}
29
+ .qrun2{height:32px;padding:0 18px;border:1px solid var(--signal);border-radius:8px;background:var(--signal);color:#fff;font:700 13px var(--sans);display:inline-flex;align-items:center;gap:7px}
30
+ .qrun2:hover{filter:brightness(1.07)}
31
+ .qbtn{height:32px;padding:0 11px;border:1px solid var(--line2);border-radius:8px;background:var(--panel);color:var(--muted);font:600 12px var(--sans)}
32
+ .qbtn:hover{color:var(--text)}
33
+ .qac{position:absolute;left:14px;top:calc(100% + 4px);z-index:30;min-width:260px;max-width:420px;background:var(--panel);border:1px solid var(--line2);border-radius:10px;box-shadow:var(--shadow);padding:4px;max-height:260px;overflow:auto}
34
+ .qac button{display:flex;gap:10px;width:100%;text-align:left;padding:6px 9px;border:0;background:none;border-radius:6px;font:12.5px var(--mono);color:var(--text);align-items:baseline}
35
+ .qac button.on,.qac button:hover{background:var(--signal-soft)}
36
+ .qac .dd{color:var(--faint);font:11px var(--sans);margin-left:auto;white-space:nowrap}
37
+ .qerr2{margin-top:14px;padding:12px 14px;border:1px solid var(--bad);border-left-width:4px;border-radius:10px;background:var(--panel);color:var(--bad);font:13px/1.5 var(--mono)}
38
+ .qerr2 .tip{display:block;color:var(--muted);font:12px var(--sans);margin-top:4px}
39
+ .qres{margin-top:16px;display:flex;flex-direction:column;min-height:0}
40
+ .qst{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;font:12px var(--mono);color:var(--muted)}
41
+ .qst b{color:var(--text)}
42
+ .qst .sp{flex:1}
43
+ .qfacets{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
44
+ .qf{font:600 11px var(--mono);padding:3px 9px;border-radius:999px;border:1px solid var(--line2);background:var(--panel);color:var(--muted)}
45
+ .qf:hover{border-color:var(--signal);color:var(--text)}
46
+ .qf b{color:var(--text)}
47
+ .qtab{border:1px solid var(--line);border-radius:12px;background:var(--panel);overflow:auto;max-height:calc(100vh - 360px);min-height:120px}
48
+ .qtab table{font:12.5px var(--mono)}
49
+ .qtab th{position:sticky;top:0;z-index:2}
50
+ .qtab td{max-width:520px;overflow-wrap:anywhere;vertical-align:top}
51
+ .qtab tbody tr:nth-child(even){background:var(--panel3)}
52
+ .qtab tr.rowclick{cursor:pointer}
53
+ .qtab .sg{color:var(--ok)}.qtab .sy{color:var(--warn)}.qtab .sr{color:var(--bad)}.qtab .sb{color:var(--observed)}
54
+ .qtab .st2{font-weight:700}
55
+ .qtab .ob{color:var(--ok)}.qtab .in{color:var(--inferred)}.qtab .ex{color:var(--l6)}
56
+ .qtab .evl{color:var(--signal);font-weight:700}
57
+ .qpaths{display:flex;flex-direction:column;gap:12px}
58
+ .qpath{border:1px solid var(--line);border-radius:12px;background:var(--panel);padding:14px 16px}
59
+ .qpath .ph2{font:600 10.5px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin-bottom:10px}
60
+ .qchain{display:flex;flex-wrap:wrap;align-items:center;gap:6px 4px}
61
+ .qn{font:600 12px var(--mono);padding:5px 10px;border-radius:8px;background:var(--panel2);border:1px solid var(--line2)}
62
+ .qa{font:600 10.5px var(--mono);color:var(--c,var(--muted));display:inline-flex;align-items:center;gap:3px;padding:0 4px}
63
+ .qa::after{content:"→";font-size:14px}
64
+ .qa.ob{--c:var(--observed)}.qa.in{--c:var(--inferred)}.qa.ex{--c:var(--l6)}
65
+ .qref{margin-top:14px;padding:16px 18px;border:1px solid var(--line);border-radius:12px;background:var(--panel);font:12.5px/1.7 var(--mono);color:var(--muted)}
66
+ .qref b{color:var(--text)}
67
+ .qref .row{display:grid;grid-template-columns:110px 1fr;gap:12px;padding:4px 0;border-bottom:1px solid var(--line)}
68
+ .qref .row:last-child{border:0}
69
+ .qempty{margin-top:24px;padding:30px;border:1px dashed var(--line2);border-radius:14px;text-align:center;color:var(--faint);font-size:13px}
70
+ .qempty .big{font:700 15px var(--sans);color:var(--muted);margin-bottom:6px}
71
+ """
72
+
73
+ QRY_JS = r"""
74
+ /* ---------- QUERY (BQL console) ---------- */
75
+ let qText="",qRes=null,qErr=null,qHist=[],qMs=0,qSort=null;
76
+ try{qHist=JSON.parse(localStorage.getItem("b2m-qhist")||"[]");}catch(e){}
77
+ const qSave=()=>{try{localStorage.setItem("b2m-qhist",JSON.stringify(qHist));}catch(e){}};
78
+ function qRun(text){
79
+ qText=text;qErr=null;qRes=null;qSort=null;
80
+ if(!BQLDB){qErr="This report was built without graph.db. Rebuild with proxy2model to enable queries.";return;}
81
+ const t0=performance.now();
82
+ try{qRes=BQL.run(BQLDB,text);qMs=performance.now()-t0;
83
+ qHist=[text].concat(qHist.filter(x=>x!==text)).slice(0,15);qSave();
84
+ }catch(e){qErr=e instanceof BQL.BQLError?e.message:"error: "+e.message;}
85
+ }
86
+ /* ready queries, filled in from this model so they work the first time */
87
+ function qLibrary(){
88
+ const so=EP.find(e=>e.state==="STATIC_ONLY"),busy=[...EP].sort((a,b)=>b.requests-a.requests)[0],pv=EP.find(e=>e.privileged&&e.requests>0),
89
+ post=EP.find(e=>e.method!=="GET"&&e.method!=="*"&&e.requests>0)||busy;
90
+ const lab=e=>e?e.label:null;
91
+ const g=[
92
+ ["Requests",[
93
+ ["POST requests that did not fail","req.method:POST AND resp.code.lt:400","every state-changing call that worked"],
94
+ ["Client and server errors","resp.code.gte:400","4xx and 5xx responses"],
95
+ ["API traffic","req.path.cont:\"/api/\"","anything under /api/"],
96
+ ["Set-Cookie responses","resp.header.cont:\"set-cookie\"","who hands out cookies"],
97
+ ["Mentions of tokens or traces","resp.body.regex:\"stack ?trace|exception|token\"","masked bodies, searched"]]],
98
+ ["Nodes and edges",[
99
+ ["Endpoints named only in code","node.type:endpoint AND node.state:STATIC_ONLY","never called in this capture"],
100
+ ["Everything one role reached","node.role:"+((D.roles||[])[0]||"user"),"nodes tagged with a role"],
101
+ ["Inferred edges","edge.state:INFERRED limit 50","references read from code"]]],
102
+ ["Paths",[]],
103
+ ["Model",[["Open questions","unknowns","what the capture could not answer"],["Stats","stats","counts for this graph"]]]];
104
+ if(post)g[2][1].push(["How "+lab(post)+" is reached","reach \""+lab(post)+"\"","entry point to endpoint"]);
105
+ if(busy&&busy!==post)g[2][1].push(["Blast radius of "+lab(busy),"blast \""+lab(busy)+"\" depth 2","what it touches"]);
106
+ if(so)g[2][1].push(["Who references "+lab(so),"upstream \""+lab(so)+"\"","scripts and pages naming it"]);
107
+ if(pv)g[2][1].push(["Neighbours of "+lab(pv),"neighbors \""+lab(pv)+"\"","one hop each way"]);
108
+ if(!g[2][1].length)g.splice(2,1);
109
+ if(BQLDB&&BQLDB.osint)g.push(["Recon",[["TLS","osint tls","certificate facts"],["Hosts seen only via recon","node.type:subdomain AND node.attr.seen_in_capture:0","never visited"]]]);
110
+ return g;
111
+ }
112
+ const Q_FIELDS=[
113
+ ["req.method","request method"],["req.host","request host"],["req.path","request path"],["req.template","templated path"],["req.header","request headers"],["req.body","request body"],
114
+ ["resp.code","status code (gte, lt…)"],["resp.mime","response type"],["resp.header","response headers"],["resp.body","response body"],["role","role that sent it"],["id","evidence id"],
115
+ ["node.type","node kind"],["node.label","node label"],["node.id","node id"],["node.layer","layer 1-6"],["node.source","traffic, code or recon"],["node.role","roles that reached it"],["node.state","BOTH · STATIC_ONLY · RUNTIME_ONLY"],["node.evidence","evidence ids"],["node.attr.","any node attribute"],
116
+ ["edge.type","edge kind"],["edge.state","OBSERVED · INFERRED · EXTERNAL"],["edge.src","source id"],["edge.dst","target id"],["edge.src.type","source kind"],["edge.dst.type","target kind"],["edge.src.label","source label"],["edge.dst.label","target label"]];
117
+ const Q_VERBS=[["reach","how a node is reached"],["blast","what a node touches"],["upstream","what leads to a node"],["neighbors","one hop each way"],["path","a to b"],["osint","recon facts"],["unknowns","open questions"],["stats","graph counts"],["help","the language"],["AND",""],["OR",""],["NOT",""],["limit","cap the rows"]];
118
+ const Q_OPS=[["cont","contains"],["ncont","does not contain"],["eq","equals"],["ne","not equal"],["like","wildcard *"],["regex","regular expression"],["gt","greater"],["gte","greater or equal"],["lt","less"],["lte","less or equal"]];
119
+ function qValues(field){
120
+ const u=a=>[...new Set(a.filter(Boolean))].sort();
121
+ if(/^req\.method/.test(field))return u(EVL.map(e=>e.method));
122
+ if(/^req\.host|^host/.test(field))return u(EVL.map(e=>e.host));
123
+ if(/^role|^node\.role/.test(field))return u(D.roles||[]);
124
+ if(/^resp\.mime/.test(field))return u(EVL.map(e=>e.mime));
125
+ if(/^node\.type/.test(field))return u((D.graph.nodes||[]).map(n=>n.type));
126
+ if(/^node\.state/.test(field))return ["BOTH","STATIC_ONLY","RUNTIME_ONLY"];
127
+ if(/^edge\.state/.test(field))return ["OBSERVED","INFERRED","EXTERNAL"];
128
+ if(/^edge\.type/.test(field))return u((D.graph.edges||[]).map(e=>e.type));
129
+ if(/^resp\.code/.test(field))return ["200","301","401","403","404","500"];
130
+ return [];
131
+ }
132
+ /* syntax colouring: one pass, tokens never overlap */
133
+ function qHighlight(t){
134
+ const re=/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|\b([A-Za-z_][\w.\-]*)(:)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s()]*)|\b(AND|OR|NOT)\b|\b(reach|blast|upstream|neighbors|path|osint|unknowns|stats|help|limit|depth|to)\b|(\d+)|([()])/g;
135
+ let out="",i=0,m;
136
+ while((m=re.exec(t))){
137
+ out+=esc(t.slice(i,m.index));i=re.lastIndex;
138
+ if(m[1])out+=`<span class="v">${esc(m[1])}</span>`;
139
+ else if(m[2]){const parts=m[2].split(".");let op="";if(parts.length>1&&Q_OPS.some(o=>o[0]===parts[parts.length-1]))op=parts.pop();
140
+ out+=`<span class="f">${esc(parts.join("."))}</span>${op?`<span class="o">.${esc(op)}</span>`:""}<span class="p">:</span><span class="${/^["']/.test(m[4])||isNaN(+m[4])?"v":"n"}">${esc(m[4])}</span>`;}
141
+ else if(m[5])out+=`<span class="k">${m[5]}</span>`;
142
+ else if(m[6])out+=`<span class="vb">${m[6]}</span>`;
143
+ else if(m[7])out+=`<span class="n">${m[7]}</span>`;
144
+ else out+=`<span class="p">${esc(m[8])}</span>`;
145
+ }
146
+ return out+esc(t.slice(i))+"\n";
147
+ }
148
+ function qCell2(col,v,row){
149
+ if(Array.isArray(v))v=v.join(", ");if(v==null)return "";
150
+ if(col==="status"||col==="code"){const n=+v,c=n>=500?"sr":n>=400?"sy":n>=300?"sb":n>=200?"sg":"";return `<span class="st2 ${c}">${esc(v)}</span>`;}
151
+ if(col==="method")return methodm(String(v));
152
+ if(col==="state")return `<span class="${v==="OBSERVED"?"ob":v==="INFERRED"?"in":v==="EXTERNAL"?"ex":""}">${esc(v)}</span>`;
153
+ if(col==="ev"||col==="evidence")return `<span class="evl">${esc(String(v).replace(/\bev_(\d+)/g,"EVD $1"))}</span>`;
154
+ return esc(String(v));
155
+ }
156
+ function qCsv(r){const q=s=>'"'+String(s==null?"":Array.isArray(s)?s.join(";"):s).replace(/"/g,'""')+'"';return [r.columns.map(q).join(",")].concat(r.rows.map(x=>r.columns.map(c=>q(x[c])).join(","))).join("\n");}
157
+ function qDownload(name,text,type){const b=new Blob([text],{type});const u=URL.createObjectURL(b);const a=document.createElement("a");a.href=u;a.download=name;a.click();URL.revokeObjectURL(u);}
158
+ function qResults(){
159
+ if(qErr){
160
+ const tip=/cannot read|unexpected|expected|missing/.test(qErr)?"Terms look like field:value, joined with AND, OR, NOT. Type a field name for suggestions.":/no node matches|ambiguous/.test(qErr)?"Use a label from the Map or the Inventory, in quotes.":"";
161
+ return `<div class="qerr2">${esc(qErr)}${tip?`<span class="tip">${esc(tip)}</span>`:""}</div>`;
162
+ }
163
+ const r=qRes;
164
+ if(!r)return `<div class="qempty"><div class="big">Ask a question</div>Type one above or pick one. Answers are worked out from this report and cite the requests behind them.</div>`;
165
+ if(r.kind==="text")return `<div class="qref">${qReference()}</div>`;
166
+ let facets="";
167
+ if(r.kind==="requests"&&r.rows.length){
168
+ const m=tally(r.rows,x=>x.method),s=tally(r.rows,x=>statusClass(x.status));
169
+ facets=`<div class="qfacets">${m.slice(0,5).map(([k,v])=>`<button class="qf" data-add="req.method:${esc(k)}">${esc(k)} <b>${v}</b></button>`).join("")}${s.map(([k,v])=>`<button class="qf" data-add="${k==="2xx"?"resp.code.gte:200 AND resp.code.lt:300":k==="3xx"?"resp.code.gte:300 AND resp.code.lt:400":k==="4xx"?"resp.code.gte:400 AND resp.code.lt:500":"resp.code.gte:500"}">${esc(k)} <b>${v}</b></button>`).join("")}</div>`;
170
+ }
171
+ const bar=`<div class="qst"><span><b>${r.total==null?r.rows.length:r.total}</b> ${r.kind==="paths"?"path(s)":"row(s)"}</span><span>· ${qMs<1?"<1":Math.round(qMs)} ms</span><span>· ${esc(r.kind)}</span><span class="sp"></span>
172
+ <button class="qbtn" id="qcopyj">Copy JSON</button><button class="qbtn" id="qcsv">CSV</button></div>`;
173
+ if(r.kind==="paths"){
174
+ const by={};r.rows.forEach(x=>{(by[x.path]=by[x.path]||[]).push(x);});
175
+ const cls=s=>s==="INFERRED"?"in":s==="EXTERNAL"?"ex":"ob";
176
+ return bar+`<div class="qpaths">${Object.keys(by).map(k=>{const hs=by[k];return `<div class="qpath"><div class="ph2">path ${k} · ${hs.length} hop${hs.length===1?"":"s"}</div><div class="qchain"><span class="qn">${esc(hs[0].from)}</span>${hs.map(h=>`<span class="qa ${cls(h.state)}" title="${esc(h.state)} · ${esc(h.evidence||"")}">${esc(humanize(h.edge).toLowerCase())}</span><span class="qn">${esc(h.to)}</span>`).join("")}</div></div>`;}).join("")||'<div class="empty">No path found.</div>'}</div>`;
177
+ }
178
+ let rows=r.rows.slice();
179
+ if(qSort){const{k,d}=qSort;rows.sort((a,b)=>{const x=a[k],y=b[k];return (typeof x==="number"&&typeof y==="number"?x-y:String(x==null?"":x).localeCompare(String(y==null?"":y)))*d;});}
180
+ const rowAttr=row=>row.ev&&/^ev_\d+$/.test(row.ev)?` class="rowclick" data-ev="${row.ev.slice(3)}"`:(row.id&&r.kind==="nodes"?` class="rowclick" data-node="${esc(row.id)}"`:"");
181
+ return bar+facets+`<div class="qtab"><table><thead><tr>${r.columns.map(c=>`<th data-sk="${esc(c)}">${esc(c==="ev"?"evidence":c)}${qSort&&qSort.k===c?`<span class="ar">${qSort.d>0?" ▲":" ▼"}</span>`:""}</th>`).join("")}</tr></thead><tbody>${
182
+ rows.length?rows.map(row=>`<tr${rowAttr(row)}>${r.columns.map(c=>`<td>${qCell2(c,row[c],row)}</td>`).join("")}</tr>`).join(""):`<tr><td colspan="${r.columns.length}"><div class="empty">No rows match.</div></td></tr>`}</tbody></table></div>`;
183
+ }
184
+ function qReference(){
185
+ return `<b>BQL</b> filters requests, nodes or edges, and walks the graph.
186
+ <div class="row"><b>request</b><span>req.method req.host req.path req.template req.header req.body · resp.code resp.mime resp.header resp.body · role id</span></div>
187
+ <div class="row"><b>node</b><span>node.type node.label node.id node.layer node.source node.role node.state node.evidence node.attr.&lt;name&gt;</span></div>
188
+ <div class="row"><b>edge</b><span>edge.type edge.state edge.src edge.dst edge.src.type edge.dst.type edge.src.label edge.dst.label</span></div>
189
+ <div class="row"><b>operators</b><span>${Q_OPS.map(o=>o[0]).join(" ")} · e.g. <span style="color:var(--text)">resp.code.gte:400</span></span></div>
190
+ <div class="row"><b>combine</b><span>AND OR NOT ( ) and end with <span style="color:var(--text)">limit N</span></span></div>
191
+ <div class="row"><b>verbs</b><span>reach &lt;node&gt; · blast &lt;node&gt; [depth N] · upstream &lt;node&gt; · neighbors &lt;node&gt; · path &lt;a&gt; to &lt;b&gt; · osint [section] · unknowns · stats</span></div>`;
192
+ }
193
+ /* a question is BQL when it has field:value terms, a graph verb or AND/OR/NOT; anything else is plain English */
194
+ function looksBql(t){
195
+ t=(t||"").trim();if(!t)return false;
196
+ return /^(reach|blast|upstream|neighbors|path|osint|unknowns|stats|help|schema)\b/i.test(t)||/\b[a-z][\w.]*:\S/i.test(t)||/\b(req|resp|node|edge)\.[a-z]/i.test(t)||/\s(AND|OR|NOT)\s/.test(t);
197
+ }
198
+ const ASK_PRESETS=[
199
+ ["List the endpoints","list the endpoints"],
200
+ ["What is in the code but never called?","what is referenced in code but never called"],
201
+ ["Which endpoints only returned errors?","which endpoints only returned errors"],
202
+ ["Which paths look privileged?","which endpoints look privileged"],
203
+ ["Who are the third parties?","third parties"],
204
+ ["What needs a login?","auth and cookies"],
205
+ ["Which endpoints take parameters?","parameters"],
206
+ ["What is still unknown?","what are the open questions"],
207
+ ];
208
+ let aTab="questions",aNL=null;
209
+ function aAnswerHtml(a){return `<div class="answer"><div class="ahd">${esc(a.title)}</div>${a.html}</div><div class="askfoot">Answered from this report. No AI was called.</div>`;}
210
+ function aOut(){
211
+ if(aNL)return aAnswerHtml(aNL);
212
+ return qResults();
213
+ }
214
+ function aChips(){
215
+ if(aTab==="questions")return ASK_PRESETS.map(([t,q])=>`<button class="achip" data-nl="${esc(q)}">${esc(t)}</button>`).join("");
216
+ if(aTab==="bql")return qLibrary().flatMap(([g,xs])=>xs).map(x=>`<button class="achip" data-q="${esc(x[1])}" title="${esc(x[1])}">${esc(x[0])}</button>`).join("")+`<button class="achip" id="qhelp">BQL reference</button>`;
217
+ return qHist.length?qHist.slice(0,10).map(q=>`<button class="achip mono" data-q="${esc(q)}">${esc(q.length>70?q.slice(0,69)+"…":q)}</button>`).join(""):'<span style="color:var(--faint);font-size:13px">Nothing yet. Questions you run will show up here.</span>';
218
+ }
219
+ function vAsk(){
220
+ return splitShell(`<h1 class="vt">Ask</h1><p class="vsub">Ask in plain English, or type a BQL query for exact filters and graph paths. Answers come from this report. Pick a row to see its request below.</p>
221
+ <div class="qed"><div class="hl" id="qhl"></div><textarea id="qin" spellcheck="false" autocomplete="off" placeholder='What is in the code but never called? · req.method:POST AND resp.code.gte:400 · reach "POST /api/checkout"'>${esc(qText)}</textarea>
222
+ <div class="qbar2"><span class="hint"><kbd>Enter</kbd> to ask · <kbd>Shift</kbd>+<kbd>Enter</kbd> new line</span><button class="qrun2" id="qgo">Ask</button></div>
223
+ <div class="qac" id="qac" hidden></div></div>
224
+ <div class="atabs"><button class="atab${aTab==="questions"?" on":""}" data-atab="questions">Questions</button>${BQLDB?`<button class="atab${aTab==="bql"?" on":""}" data-atab="bql">BQL examples</button>`:""}<button class="atab${aTab==="recent"?" on":""}" data-atab="recent">Recent</button></div>
225
+ <div class="achips" id="achips">${aChips()}</div>
226
+ <div class="qres" id="qout">${aOut()}</div>`);
227
+ }
228
+ function initAsk(){
229
+ splitInit();
230
+ const inp=$("#qin"),hl=$("#qhl"),ac=$("#qac");
231
+ const paint=()=>{hl.innerHTML=looksBql(inp.value)?qHighlight(inp.value):esc(inp.value)+"\n";hl.scrollTop=inp.scrollTop;const h=Math.max(76,Math.min(240,inp.scrollHeight));if(inp.scrollHeight>inp.clientHeight)inp.style.height=h+"px";};
232
+ const out=()=>{$("#qout").innerHTML=aOut();wire();};
233
+ function ask(text){
234
+ qText=text;aNL=null;qRes=null;qErr=null;qSort=null;
235
+ if(!text.trim()){render();return;}
236
+ if(looksBql(text)&&BQLDB)qRun(text);
237
+ else{aNL=askEngine(text);qHist=[text].concat(qHist.filter(x=>x!==text)).slice(0,15);qSave();}
238
+ render();
239
+ }
240
+ const go=()=>{ac.hidden=true;ask(inp.value);};
241
+ /* completion only for BQL-looking words */
242
+ let items=[],sel=0;
243
+ function suggest(){
244
+ const v=inp.value,pos=inp.selectionStart,left=v.slice(0,pos);
245
+ items=[];
246
+ const m=/([A-Za-z_][\w.\-]*)(:)?([^\s():]*)?$/.exec(left);
247
+ if(m&&BQLDB){
248
+ const word=m[1],hasColon=m[2]===":",val=m[3]||"";
249
+ const fieldish=/^(req|resp|node|edge|role|id)\b/i.test(word),first=left.trim()===word&&word.length>=2;
250
+ if(hasColon){const f=word.replace(/\.(cont|ncont|eq|ne|like|regex|gt|gte|lt|lte)$/,"");
251
+ items=qValues(f).filter(x=>x.toLowerCase().startsWith(val.toLowerCase())&&x!==val).slice(0,8).map(x=>({ins:/\s/.test(x)?'"'+x+'"':x,lab:x,d:"value",at:pos-val.length,end:pos}));}
252
+ else if(fieldish){
253
+ const parts=word.split("."),last=parts[parts.length-1],base=parts.slice(0,-1).join(".");
254
+ if(parts.length>1&&Q_FIELDS.some(f=>f[0]===base)&&!Q_OPS.some(o=>o[0]===last)&&base!=="node.attr")
255
+ items=Q_OPS.filter(o=>o[0].startsWith(last)&&o[0]!==last).map(o=>({ins:o[0]+":",lab:base+"."+o[0],d:o[1],at:pos-last.length,end:pos}));
256
+ if(!items.length)items=Q_FIELDS.filter(f=>f[0].toLowerCase().startsWith(word.toLowerCase())&&f[0].toLowerCase()!==word.toLowerCase()).slice(0,8)
257
+ .map(f=>({ins:f[0]+(f[0].endsWith(".")?"":":"),lab:f[0],d:f[1],at:pos-word.length,end:pos}));
258
+ }else if(first)items=Q_VERBS.filter(f=>/^[a-z]/.test(f[0])&&f[0].startsWith(word.toLowerCase())&&f[0]!==word.toLowerCase()).slice(0,6).map(f=>({ins:f[0]+" ",lab:f[0],d:f[1],at:pos-word.length,end:pos}));
259
+ }
260
+ sel=0;
261
+ if(!items.length){ac.hidden=true;return;}
262
+ ac.innerHTML=items.map((it,i)=>`<button data-i="${i}" class="${i===sel?"on":""}"><span>${esc(it.lab)}</span><span class="dd">${esc(it.d)}</span></button>`).join("");
263
+ ac.hidden=false;
264
+ $$("button",ac).forEach(b=>b.onmousedown=e=>{e.preventDefault();accept(+b.dataset.i);});
265
+ }
266
+ function accept(i){const it=items[i];if(!it)return;const v=inp.value;inp.value=v.slice(0,it.at)+it.ins+v.slice(it.end);const p=it.at+it.ins.length;inp.setSelectionRange(p,p);ac.hidden=true;paint();suggest();}
267
+ function mark(){$$("button",ac).forEach((b,i)=>b.classList.toggle("on",i===sel));const b=$$("button",ac)[sel];if(b)b.scrollIntoView({block:"nearest"});}
268
+ inp.addEventListener("input",()=>{paint();suggest();});
269
+ inp.addEventListener("scroll",()=>{hl.scrollTop=inp.scrollTop;});
270
+ inp.addEventListener("click",()=>{ac.hidden=true;});
271
+ inp.addEventListener("blur",()=>setTimeout(()=>{ac.hidden=true;},120));
272
+ inp.addEventListener("keydown",e=>{
273
+ if(!ac.hidden){
274
+ if(e.key==="ArrowDown"){e.preventDefault();sel=(sel+1)%items.length;mark();return;}
275
+ if(e.key==="ArrowUp"){e.preventDefault();sel=(sel-1+items.length)%items.length;mark();return;}
276
+ if(e.key==="Tab"){e.preventDefault();accept(sel);return;}
277
+ if(e.key==="Escape"){ac.hidden=true;return;}
278
+ }
279
+ if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();go();}
280
+ });
281
+ function wire(){
282
+ const o=$("#qout");
283
+ const mark=el=>{$$(".sel",o).forEach(x=>x.classList.remove("sel"));el.classList.add("sel");};
284
+ $$("[data-ep]",o).forEach(el=>el.onclick=()=>{const e=EP.find(x=>x.id===el.dataset.ep);if(!e)return;mark(el);
285
+ splitShow(e.evidence,{title:e.label,find:e.state==="STATIC_ONLY"?e.path:null,empty:{head:"Not requested",text:"The code names this endpoint but nothing in the capture called it."}});});
286
+ $$("[data-ev]",o).forEach(r=>r.onclick=()=>{mark(r);splitShow([+r.dataset.ev],{});});
287
+ $$("[data-node]",o).forEach(r=>r.onclick=()=>{mark(r);const id=r.dataset.node,ep=EP.find(x=>x.id===id),n=(D.graph.nodes||[]).find(x=>x.id===id);
288
+ splitShow((ep&&ep.evidence)||(n&&n.evidence)||[],{title:(n&&n.label)||id,empty:{head:"Nothing to open",text:"This node has no request of its own."}});});
289
+ $$("[data-sk]",o).forEach(h=>h.onclick=()=>{const k=h.dataset.sk;qSort=qSort&&qSort.k===k?{k,d:-qSort.d}:{k,d:1};out();});
290
+ $$("[data-add]",o).forEach(b=>b.onclick=()=>{inp.value=inp.value.replace(/\s+limit\s+\d+\s*$/i,"").trim()+" AND "+b.dataset.add;paint();go();});
291
+ const cj=$("#qcopyj");if(cj)cj.onclick=()=>fallbackCopy(JSON.stringify(qRes,null,2),()=>{cj.textContent="Copied";setTimeout(()=>cj.textContent="Copy JSON",1200);});
292
+ const cv=$("#qcsv");if(cv)cv.onclick=()=>qDownload((D.app||"query")+".bql.csv",qCsv(qRes),"text/csv");
293
+ const first=$("[data-ep],[data-ev],[data-node]",o);
294
+ if(first)first.click();else splitShow([]);
295
+ }
296
+ $("#qgo").onclick=go;
297
+ $$("[data-atab]").forEach(b=>b.onclick=()=>{aTab=b.dataset.atab;$$(".atab").forEach(x=>x.classList.toggle("on",x===b));$("#achips").innerHTML=aChips();bindChips();});
298
+ function bindChips(){
299
+ $$("[data-nl]",$("#achips")).forEach(b=>b.onclick=()=>{inp.value=b.dataset.nl;ask(b.dataset.nl);});
300
+ $$("[data-q]",$("#achips")).forEach(b=>b.onclick=()=>{inp.value=b.dataset.q;ask(b.dataset.q);});
301
+ const h=$("#qhelp");if(h)h.onclick=()=>{aNL=null;qErr=null;qRes={kind:"text",columns:[],rows:[],note:"",total:null};out();};
302
+ }
303
+ bindChips();paint();wire();
304
+ if(!inp.value)inp.focus();
305
+ }
306
+ """
@@ -0,0 +1,55 @@
1
+ """A picture of the app for the report: validated, size-capped, embedded as a data URI.
2
+
3
+ The pixels are not masked the way text is, so a screenshot is taken automatically only from an
4
+ anonymous crawl's start page. For anything else the user supplies one with --screenshot.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import os
11
+
12
+ MAX_BYTES = 3 * 1024 * 1024
13
+ _NAMES = {"image/jpeg": "screenshot.jpg", "image/png": "screenshot.png", "image/webp": "screenshot.webp"}
14
+
15
+
16
+ def kind(data: bytes) -> str | None:
17
+ if data[:3] == b"\xff\xd8\xff":
18
+ return "image/jpeg"
19
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
20
+ return "image/png"
21
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
22
+ return "image/webp"
23
+ return None
24
+
25
+
26
+ def load(path: str) -> tuple[str, bytes]:
27
+ """Read an image file; ValueError says what is wrong with it."""
28
+ with open(path, "rb") as f:
29
+ data = f.read(MAX_BYTES + 1)
30
+ if len(data) > MAX_BYTES:
31
+ raise ValueError(f"{path} is larger than {MAX_BYTES // (1024 * 1024)} MB")
32
+ mime = kind(data)
33
+ if mime is None:
34
+ raise ValueError(f"{path} is not a PNG, JPEG or WebP image")
35
+ return mime, data
36
+
37
+
38
+ def filename(mime: str) -> str:
39
+ return _NAMES[mime]
40
+
41
+
42
+ def existing(outdir: str) -> tuple[str, bytes] | None:
43
+ """A screenshot a previous build saved next to the outputs."""
44
+ for name in _NAMES.values():
45
+ p = os.path.join(outdir, name)
46
+ if os.path.exists(p):
47
+ try:
48
+ return load(p)
49
+ except (OSError, ValueError):
50
+ return None
51
+ return None
52
+
53
+
54
+ def data_uri(mime: str, data: bytes) -> str:
55
+ return f"data:{mime};base64," + base64.b64encode(data).decode("ascii")
@@ -0,0 +1,213 @@
1
+ """The request and response viewer the report uses on every page, and the code beautifiers behind it.
2
+
3
+ `rrMount(el, ids, opts)` draws the masked request and response for one or more evidence ids into `el`.
4
+ JavaScript, HTML and JSON bodies are indented and coloured; Raw shows them untouched. The same
5
+ component sits under the Requests table, the leads list, the reference details and the answers.
6
+ """
7
+
8
+ RR_CSS = r"""
9
+ /* request / response viewer */
10
+ .rr{display:flex;flex-direction:column;min-height:0;height:100%;background:var(--panel)}
11
+ .rrbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:7px 12px;border-bottom:1px solid var(--line);background:var(--panel2);flex:none}
12
+ .rrbar .rrt{font:600 12.5px var(--sans);max-width:46%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
13
+ .rrbar .rrt code{font:600 12px var(--mono)}
14
+ .rrchips{display:flex;gap:4px;flex-wrap:wrap;align-items:center}
15
+ .rrchip{font:600 11px var(--mono);padding:3px 8px;border-radius:6px;border:1px solid var(--line2);background:var(--panel);color:var(--muted)}
16
+ .rrchip.on{background:var(--text);color:var(--ink);border-color:var(--text)}
17
+ .rrchip:hover{border-color:var(--faint);color:var(--text)}
18
+ .rrbar .sp{flex:1}
19
+ .rrpanes{display:flex;flex:1;min-height:0}
20
+ .rr.vert .rrpanes{flex-direction:column}
21
+ .rrp{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;border-right:1px solid var(--line)}
22
+ .rrp:last-child{border:0}
23
+ .rr.vert .rrp{border-right:0;border-bottom:1px solid var(--line)}
24
+ .rrp .ph{display:flex;align-items:center;gap:6px;padding:5px 8px;background:var(--panel2);border-bottom:1px solid var(--line);flex:none}
25
+ .rrp .ph b{font:700 11px var(--mono);letter-spacing:.06em;text-transform:uppercase;margin-right:4px}
26
+ .rrp .ph .st{font:600 11px var(--mono);color:var(--muted)}
27
+ .rrp .ph .tag{font:600 10px var(--mono);color:var(--inferred);border:1px solid var(--inferred);border-radius:4px;padding:0 5px}
28
+ .rrp .ph .sp{flex:1}
29
+ .rrp .code{flex:1;margin:0;overflow:auto;padding:8px 0;font:12px/1.55 var(--mono);background:var(--panel);counter-reset:l}
30
+ .rrp .code .l{display:block;padding:0 12px 0 52px;position:relative;white-space:pre-wrap;word-break:break-all;min-height:1.55em}
31
+ .rrp .code .l::before{counter-increment:l;content:counter(l);position:absolute;left:0;width:40px;text-align:right;color:var(--faint);font-size:11px;user-select:none}
32
+ .rrp .code.nol .l{padding-left:12px}.rrp .code.nol .l::before{display:none}
33
+ .rrp .ln{color:var(--signal);font-weight:700}.rrp .hn{color:var(--observed)}.rrp .hv{color:var(--text)}.rrp .rd{color:var(--inferred);font-weight:700}
34
+ .rrp .tk{color:var(--signal);font-weight:600}.rrp .ts{color:var(--ok)}.rrp .tc{color:var(--faint);font-style:italic}.rrp .tnum{color:var(--warn)}.rrp .tg{color:var(--observed)}.rrp .ta{color:var(--inferred)}
35
+ .rrp .jk{color:var(--observed)}.rrp .js{color:var(--ok)}.rrp .jn{color:var(--warn)}.rrp .jb{color:var(--inferred)}
36
+ .rrp .note{color:var(--faint);font-style:italic}
37
+ .rrp .gap{height:8px}
38
+ .rrempty{display:grid;place-items:center;height:100%;padding:30px;text-align:center;color:var(--faint);font-size:13px;background:var(--panel)}
39
+ .rrempty b{display:block;color:var(--muted);font:600 14px var(--sans);margin-bottom:4px}
40
+ .rrbtn{font:600 11px var(--mono);padding:3px 8px;border-radius:5px;border:1px solid var(--line2);background:var(--panel);color:var(--muted)}
41
+ .rrbtn:hover{color:var(--text)}
42
+ """
43
+
44
+ RR_JS = r"""
45
+ /* ---------- code beautifiers (plain JS, no libraries) ---------- */
46
+ function beautifyJS(src){
47
+ if(!src||src.length>300000)return src||"";
48
+ const n=src.length,IND=" ";let out="",ind=0,i=0,prev="",afterBrace=false;
49
+ const ctx=[],pstack=[];let paren=0;
50
+ const trimEnd=()=>{out=out.replace(/[ \t]+$/,"");};
51
+ const nl=()=>{trimEnd();out+="\n"+IND.repeat(Math.max(0,ind));};
52
+ const space=()=>{if(out&&!/[\s(\[]$/.test(out))out+=" ";};
53
+ const word=(j)=>{let k=j;while(k<n&&/[\w$]/.test(src[k]))k++;return src.slice(j,k);};
54
+ const lastWord=()=>{const m=/([A-Za-z_$][\w$]*)\s*$/.exec(out);return m?m[1]:"";};
55
+ while(i<n){
56
+ const ch=src[i],nx=src[i+1];
57
+ if(/\s/.test(ch)){let j=i;while(j<n&&/\s/.test(src[j]))j++;if(!afterBrace)space();i=j;continue;}
58
+ if(afterBrace){
59
+ afterBrace=false;
60
+ const w=/[A-Za-z]/.test(ch)?word(i):"";
61
+ if(ch===")"||ch===","||ch===";"||ch==="."||ch==="]"||w==="else"||w==="catch"||w==="finally"||(w==="while"&&false)){space();}
62
+ else nl();
63
+ }
64
+ if(ch==="/"&&nx==="/"){let j=src.indexOf("\n",i);if(j<0)j=n;space();out+=src.slice(i,j).trimEnd();nl();i=j;continue;}
65
+ if(ch==="/"&&nx==="*"){let j=src.indexOf("*/",i+2);j=j<0?n:j+2;if(out&&!/\n\s*$/.test(out))nl();out+=src.slice(i,j);nl();i=j;continue;}
66
+ if(ch==='"'||ch==="'"||ch==="`"){
67
+ let j=i+1;
68
+ while(j<n){const c=src[j];
69
+ if(c==="\\"){j+=2;continue;}
70
+ if(c===ch)break;
71
+ if(ch==="`"&&c==="$"&&src[j+1]==="{"){let d=1;j+=2;while(j<n&&d>0){if(src[j]==="{")d++;else if(src[j]==="}")d--;j++;}continue;}
72
+ if(c==="\n"&&ch!=="`")break;
73
+ j++;}
74
+ out+=src.slice(i,j+1);i=j+1;prev=ch;continue;}
75
+ if(ch==="/"&&(prev===""||/[(,=:\[!&|?{};+\-*%<>~^]/.test(prev)||/^(return|typeof|case|in|of|void|delete|throw)$/.test(lastWord()))){
76
+ let j=i+1,cls=false;
77
+ while(j<n){const c=src[j];if(c==="\\"){j+=2;continue;}if(c==="[")cls=true;else if(c==="]")cls=false;else if(c==="/"&&!cls)break;else if(c==="\n"){j=n;break;}j++;}
78
+ if(j<n&&src[j]==="/"){j++;while(j<n&&/[a-z]/i.test(src[j]))j++;out+=src.slice(i,j);i=j;prev=")";continue;}
79
+ }
80
+ if(ch==="{"){
81
+ let k=i+1;while(k<n&&/\s/.test(src[k]))k++;
82
+ if(src[k]==="}"){space();out+="{}";i=k+1;prev="}";continue;}
83
+ const lw=lastWord(),obj=/[(,=:\[?]$/.test(prev)||prev===""&&false||/^(return|case)$/.test(lw);
84
+ ctx.push(obj?"o":"b");pstack.push(paren);paren=0;
85
+ space();out+="{";ind++;nl();prev="{";i++;continue;}
86
+ if(ch==="}"){
87
+ ctx.pop();paren=pstack.length?pstack.pop():0;ind--;nl();out+="}";prev="}";afterBrace=true;i++;continue;}
88
+ if(ch===";"){out+=";";prev=";";if(paren<=0)nl();else out+=" ";i++;continue;}
89
+ if(ch===","){out+=",";prev=",";if(ctx[ctx.length-1]==="o"&&paren<=0)nl();else out+=" ";i++;continue;}
90
+ if(ch==="("||ch==="["){paren++;out+=ch;prev=ch;i++;continue;}
91
+ if(ch===")"||ch==="]"){paren--;out+=ch;prev=ch;i++;continue;}
92
+ if(ch==="="&&nx==="="||ch==="!"&&nx==="="||ch==="="&&nx===">"||ch==="&"&&nx==="&"||ch==="|"&&nx==="|"){
93
+ let j=i+2;while(src[j]==="=")j++;space();out+=src.slice(i,j)+" ";prev=src[j-1];i=j;continue;}
94
+ out+=ch;prev=ch;i++;
95
+ }
96
+ return out.replace(/\n\s*\n\s*\n/g,"\n\n").trim();
97
+ }
98
+ const VOID=new Set(["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"]);
99
+ function beautifyHTML(src){
100
+ if(!src||src.length>300000)return src||"";
101
+ const parts=src.split(/(<!--[\s\S]*?-->|<script\b[\s\S]*?<\/script>|<style\b[\s\S]*?<\/style>|<[^>]+>)/gi);
102
+ const IND=" ";let d=0,out=[];
103
+ for(const p of parts){
104
+ if(!p||!p.trim())continue;
105
+ if(/^<script\b/i.test(p)){
106
+ const m=/^(<script\b[^>]*>)([\s\S]*?)(<\/script>)$/i.exec(p);
107
+ if(m){out.push(IND.repeat(d)+m[1]);const body=m[2].trim();if(body)beautifyJS(body).split("\n").forEach(l=>out.push(IND.repeat(d+1)+l));out.push(IND.repeat(d)+m[3]);continue;}
108
+ }
109
+ if(/^<style\b/i.test(p)){
110
+ const m=/^(<style\b[^>]*>)([\s\S]*?)(<\/style>)$/i.exec(p);
111
+ if(m){out.push(IND.repeat(d)+m[1]);const body=m[2].trim().replace(/\}\s*/g,"}\n").replace(/\{\s*/g,"{\n ").replace(/;\s*(?!\})/g,";\n ");if(body)body.split("\n").forEach(l=>out.push(IND.repeat(d+1)+l.trim()));out.push(IND.repeat(d)+m[3]);continue;}
112
+ }
113
+ if(p[0]!=="<"){p.split(/\n/).map(x=>x.trim()).filter(Boolean).forEach(x=>out.push(IND.repeat(d)+x));continue;}
114
+ if(/^<!/.test(p)||/^<\?/.test(p)){out.push(IND.repeat(d)+p);continue;}
115
+ const close=/^<\//.test(p),name=((/^<\/?([\w:-]+)/.exec(p)||[])[1]||"").toLowerCase();
116
+ if(close){d=Math.max(0,d-1);out.push(IND.repeat(d)+p);}
117
+ else{out.push(IND.repeat(d)+p);if(!VOID.has(name)&&!/\/>$/.test(p))d++;}
118
+ }
119
+ return out.join("\n");
120
+ }
121
+ /* colouring: one regex pass per language over already-escaped text */
122
+ const JS_KW=/^(?:const|let|var|function|return|if|else|for|while|do|new|class|extends|import|export|from|async|await|try|catch|finally|throw|switch|case|break|continue|default|typeof|instanceof|in|of|this|null|undefined|true|false|void|delete|yield|static|get|set)$/;
123
+ function hlJS(text){
124
+ const re=/(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\b([A-Za-z_$][\w$]*)\b|(\b\d[\d._]*\b)/g;
125
+ let out="",last=0,m;
126
+ while((m=re.exec(text))){
127
+ out+=esc(text.slice(last,m.index));last=re.lastIndex;
128
+ if(m[1])out+=`<span class="tc">${esc(m[1])}</span>`;
129
+ else if(m[2])out+=`<span class="ts">${esc(m[2])}</span>`;
130
+ else if(m[3])out+=JS_KW.test(m[3])?`<span class="tk">${m[3]}</span>`:esc(m[3]);
131
+ else out+=`<span class="tnum">${esc(m[4])}</span>`;
132
+ }
133
+ return out+esc(text.slice(last));
134
+ }
135
+ function hlHTML(text){
136
+ const re=/(<!--[\s\S]*?-->)|(<\/?)([\w:-]+)((?:\s+[\w:@.-]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?>)|(<![^>]*>)/g;
137
+ let out="",last=0,m;
138
+ while((m=re.exec(text))){
139
+ out+=esc(text.slice(last,m.index));last=re.lastIndex;
140
+ if(m[1])out+=`<span class="tc">${esc(m[1])}</span>`;
141
+ else if(m[6])out+=`<span class="tc">${esc(m[6])}</span>`;
142
+ else{const attrs=esc(m[4]||"").replace(/([\w:@.-]+)(=)(&quot;[^&]*?&quot;|&#39;[^&]*?&#39;|[^\s&]+)/g,'<span class="ta">$1</span>$2<span class="ts">$3</span>');
143
+ out+=`<span class="tg">${esc(m[2])}${esc(m[3])}</span>${attrs}<span class="tg">${esc(m[5])}</span>`;}
144
+ }
145
+ return out+esc(text.slice(last));
146
+ }
147
+ function hlJSON(text){
148
+ try{const o=JSON.parse(text);return esc(JSON.stringify(o,null,2)).replace(/(&quot;(?:[^&]|&(?!quot;))*?&quot;)(\s*:)?|\b(true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g,
149
+ (m,s,c,b)=>s?(c?`<span class="jk">${s}</span>${c}`:`<span class="js">${s}</span>`):b?`<span class="jb">${m}</span>`:`<span class="jn">${m}</span>`);}catch(e){return null;}
150
+ }
151
+ function bodyKind(m,ev,isReq){
152
+ const ct=((m.headers||[]).find(h=>/^content-type$/i.test(h[0]))||["",""])[1].toLowerCase();
153
+ if(/json/.test(ct))return "json";
154
+ if(!isReq&&(/javascript|ecmascript/.test(ct)||(ev&&ev.mime==="script")||/\.m?js(\?|$)/.test((ev&&ev.path)||"")))return "js";
155
+ if(/html/.test(ct)||(!isReq&&ev&&ev.mime==="html"))return "html";
156
+ if(/xml/.test(ct))return "xml";
157
+ return "text";
158
+ }
159
+ /* turn one body into numbered, coloured lines (or plain lines when raw) */
160
+ function bodyLines(body,kind,mode){
161
+ if(!body)return [];
162
+ let text=body,html;
163
+ if(mode==="pretty"){
164
+ if(kind==="json"){const h=hlJSON(body);if(h!=null)return h.split("\n");}
165
+ else if(kind==="js"){text=beautifyJS(body);html=hlJS(text);}
166
+ else if(kind==="html"){text=beautifyHTML(body);html=hlHTML(text);}
167
+ }
168
+ return (html!=null?html:esc(text)).split("\n");
169
+ }
170
+ function rrPane(title,m,ev,isReq,mode,extra){
171
+ const kind=m?bodyKind(m,ev,isReq):"text";
172
+ const has=m&&(m.line||(m.headers||[]).length||m.body);
173
+ const head=has?`<span class="l"><span class="ln">${esc(m.line||"")}</span></span>`+(m.headers||[]).map(([k,v])=>`<span class="l"><span class="hn">${esc(k)}</span>: <span class="${/\[REDACTED/i.test(v)?"rd":"hv"}">${esc(v)}</span></span>`).join(""):"";
174
+ const lines=has?bodyLines(m.body,kind,mode):[];
175
+ const pretty=mode==="pretty"&&(kind==="js"||kind==="html"||kind==="json")&&m&&m.body;
176
+ const body=lines.length?`<span class="l gap"></span>`+lines.map(l=>`<span class="l">${l}</span>`).join(""):"";
177
+ const trunc=has&&m.truncated&&m.body?`<span class="l note">... body cut here (the report keeps ${kind==="js"?"40":kind==="html"?"20":"6"} KB)</span>`:"";
178
+ return {kind,html:has?`<div class="code${mode==="raw"?"":""}">${head}${body}${trunc}</div>`:`<div class="code nol"><span class="l note">Not captured in this export.</span></div>`,pretty};
179
+ }
180
+ const RRS={layout:"h",tab:{req:"pretty",res:"pretty"}};
181
+ try{const s=JSON.parse(localStorage.getItem("b2m-rr")||"{}");if(s.layout)RRS.layout=s.layout;if(s.tab)RRS.tab=Object.assign(RRS.tab,s.tab);}catch(e){}
182
+ const rrSave=()=>{try{localStorage.setItem("b2m-rr",JSON.stringify(RRS));}catch(e){}};
183
+ /* draw the viewer for evidence ids into el. opts: {title, sel, empty:{head,text}, actions:[[label,fn]]} */
184
+ function rrMount(el,ids,opts){
185
+ opts=opts||{};
186
+ /* real requests first; the page or script that merely names an endpoint comes after */
187
+ const code=i=>{const m=(D.evidence["ev_"+i]||{}).mime;return m==="script"||m==="html"?1:0;};
188
+ ids=(ids||[]).filter(i=>D.evidence["ev_"+i]).map((i,k)=>[i,k]).sort((a,b)=>code(a[0])-code(b[0])||a[1]-b[1]).map(x=>x[0]);
189
+ const st=el._rr=el._rr||{};st.ids=ids;
190
+ if(!ids.includes(st.sel))st.sel=opts.sel!=null&&ids.includes(opts.sel)?opts.sel:ids[0];
191
+ if(!ids.length){const e=opts.empty||{};el.innerHTML=`<div class="rrempty"><div><b>${esc(e.head||"Nothing to show")}</b>${esc(e.text||"Pick something from the list to see its request and response here.")}</div></div>`;return;}
192
+ const ev=D.evidence["ev_"+st.sel];
193
+ const mk=(t,m,isReq,k)=>{
194
+ const p=rrPane(t,m,ev,isReq,RRS.tab[k]);
195
+ const stx=isReq?"":`<span class="st">${ev.status==null?"":ev.status}${ev.mime?" · "+esc(ev.mime):""}</span>`;
196
+ return `<div class="rrp"><div class="ph"><b>${t}</b><button class="ptab${RRS.tab[k]==="pretty"?" on":""}" data-rt="${k}:pretty">${p.kind==="js"?"Beautified":"Pretty"}</button><button class="ptab${RRS.tab[k]==="raw"?" on":""}" data-rt="${k}:raw">Raw</button><span class="sp"></span>${stx}${isReq?(opts.actions||[]).map((a,i)=>`<button class="rrbtn" data-ra="${i}">${esc(a[0])}</button>`).join(""):`<button class="rrbtn" data-rcopy>Copy body</button>`}</div>${p.html}</div>`;
197
+ };
198
+ const chips=ids.length>1?`<div class="rrchips">${ids.slice(0,14).map(i=>`<button class="rrchip${i===st.sel?" on":""}" data-rs="${i}">${EVD(i)}</button>`).join("")}${ids.length>14?`<span style="color:var(--faint);font:11px var(--mono)">+${ids.length-14}</span>`:""}</div>`:"";
199
+ const vert=opts.vert||RRS.layout==="v";
200
+ el.innerHTML=`<div class="rr${vert?" vert":""}"><div class="rrbar"><span class="rrt">${opts.title&&opts.title.trim()?esc(opts.title):opts.title?"":`<code>${esc(ev.method)} ${esc(ev.host)}${esc(ev.path)}</code>`}</span>${chips}<span class="sp"></span>${opts.vert?"":`<button class="rrbtn" data-rl>${RRS.layout==="v"?"Side by side":"Stacked"}</button>`}</div>
201
+ <div class="rrpanes">${mk("Request",ev.request,true,"req")}${mk("Response",ev.response,false,"res")}</div></div>`;
202
+ $$("[data-rs]",el).forEach(b=>b.onclick=()=>{st.sel=+b.dataset.rs;rrMount(el,ids,opts);});
203
+ $$("[data-rt]",el).forEach(b=>b.onclick=()=>{const [k,v]=b.dataset.rt.split(":");RRS.tab[k]=v;rrSave();rrMount(el,ids,opts);});
204
+ $$("[data-ra]",el).forEach(b=>b.onclick=()=>{const a=(opts.actions||[])[+b.dataset.ra];if(a)a[1](st.sel,b);});
205
+ const rl=$("[data-rl]",el);if(rl)rl.onclick=()=>{RRS.layout=RRS.layout==="v"?"h":"v";rrSave();rrMount(el,ids,opts);};
206
+ /* code reads from its first line, not from the response headers */
207
+ const resp=$$(".rrp",el)[1],gap=resp&&$(".code .gap",resp);
208
+ if(gap&&ev.response&&["js","html"].includes(bodyKind(ev.response,ev,false))&&RRS.tab.res==="pretty"){const box=gap.closest(".code");box.scrollTop=Math.max(0,gap.offsetTop-6);}
209
+ const rc=$("[data-rcopy]",el);if(rc)rc.onclick=()=>fallbackCopy(((ev.response||{}).body)||"",()=>{rc.textContent="Copied";setTimeout(()=>rc.textContent="Copy body",1200);});
210
+ }
211
+ /* evidence ids that best show an endpoint, page, script or host */
212
+ const evOf=x=>(x&&x.evidence)||[];
213
+ """