33from __future__ import annotations
44
55from pathlib import Path
6+ from urllib .parse import urlparse
67
78import networkx as nx
89from pyvis .network import Network
1819 "js" : "#f1c40f" ,
1920}
2021
22+ DETAIL_PANEL = r"""
23+ <style>
24+ #node-details-panel {
25+ position: fixed;
26+ right: 20px;
27+ bottom: 20px;
28+ width: min(520px, 92vw);
29+ background: rgba(13, 17, 23, 0.96);
30+ border: 1px solid #30363d;
31+ border-radius: 10px;
32+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
33+ color: #c9d1d9;
34+ z-index: 9999;
35+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif;
36+ }
37+ #node-details-header {
38+ display: flex;
39+ justify-content: space-between;
40+ align-items: center;
41+ gap: 10px;
42+ padding: 10px 12px;
43+ border-bottom: 1px solid #30363d;
44+ font-size: 14px;
45+ font-weight: 700;
46+ }
47+ #node-details-actions {
48+ display: flex;
49+ gap: 8px;
50+ }
51+ #node-copy-btn, #node-clear-btn {
52+ border: 1px solid #30363d;
53+ background: #21262d;
54+ color: #c9d1d9;
55+ border-radius: 6px;
56+ padding: 4px 8px;
57+ font-size: 12px;
58+ cursor: pointer;
59+ }
60+ #node-copy-btn[disabled] {
61+ opacity: 0.45;
62+ cursor: not-allowed;
63+ }
64+ #node-details-body {
65+ padding: 12px;
66+ font-size: 13px;
67+ line-height: 1.5;
68+ max-height: 360px;
69+ overflow: auto;
70+ word-break: break-word;
71+ user-select: text;
72+ white-space: pre-wrap;
73+ }
74+ </style>
75+ <script>
76+ (function () {
77+ function copyText(value) {
78+ if (!value) {
79+ return Promise.resolve(false);
80+ }
81+ if (navigator.clipboard && navigator.clipboard.writeText) {
82+ return navigator.clipboard.writeText(value).then(function () { return true; }).catch(function () { return false; });
83+ }
84+ var textArea = document.createElement('textarea');
85+ textArea.value = value;
86+ textArea.style.position = 'fixed';
87+ textArea.style.opacity = '0';
88+ document.body.appendChild(textArea);
89+ textArea.focus();
90+ textArea.select();
91+ var ok = false;
92+ try {
93+ ok = document.execCommand('copy');
94+ } catch (e) {
95+ ok = false;
96+ }
97+ document.body.removeChild(textArea);
98+ return Promise.resolve(ok);
99+ }
100+
101+ function initNodePanel() {
102+ if (typeof network === 'undefined' || typeof nodes === 'undefined') {
103+ return;
104+ }
105+
106+ var panel = document.createElement('div');
107+ panel.id = 'node-details-panel';
108+ panel.innerHTML = ""
109+ + '<div id="node-details-header">'
110+ + ' <span id="node-details-title">Node Details</span>'
111+ + ' <div id="node-details-actions">'
112+ + ' <button id="node-copy-btn" disabled>Copy</button>'
113+ + ' <button id="node-clear-btn">Clear</button>'
114+ + ' </div>'
115+ + '</div>'
116+ + '<div id="node-details-body">Click a node to view full details.</div>';
117+
118+ document.body.appendChild(panel);
119+
120+ var titleEl = document.getElementById('node-details-title');
121+ var bodyEl = document.getElementById('node-details-body');
122+ var copyBtn = document.getElementById('node-copy-btn');
123+ var clearBtn = document.getElementById('node-clear-btn');
124+ var copyValue = '';
125+
126+ function relationDetails(nodeId) {
127+ if (!nodeId || !allEdges) {
128+ return '';
129+ }
130+
131+ var incoming = 0;
132+ var outgoing = 0;
133+ var connected = [];
134+ var seen = {};
135+
136+ Object.keys(allEdges).forEach(function (edgeId) {
137+ var edge = allEdges[edgeId];
138+ if (!edge) {
139+ return;
140+ }
141+
142+ if (edge.to === nodeId) {
143+ incoming += 1;
144+ if (edge.from && edge.from !== nodeId && !seen[edge.from]) {
145+ seen[edge.from] = true;
146+ connected.push(edge.from);
147+ }
148+ }
149+
150+ if (edge.from === nodeId) {
151+ outgoing += 1;
152+ if (edge.to && edge.to !== nodeId && !seen[edge.to]) {
153+ seen[edge.to] = true;
154+ connected.push(edge.to);
155+ }
156+ }
157+ });
158+
159+ var neighborLabels = connected.slice(0, 12).map(function (neighborId) {
160+ var neighbor = nodes.get(neighborId);
161+ return neighbor ? (neighbor.label || neighbor.id || neighborId) : neighborId;
162+ });
163+ var hasMore = connected.length > 12;
164+
165+ return ''
166+ + '\n\nGraph Context:'
167+ + '\n- Incoming edges: ' + incoming
168+ + '\n- Outgoing edges: ' + outgoing
169+ + '\n- Connected nodes: ' + connected.length
170+ + (neighborLabels.length > 0 ? ('\n- Neighbors: ' + neighborLabels.join(', ') + (hasMore ? ', ...' : '')) : '');
171+ }
172+
173+ function setContent(node) {
174+ if (!node) {
175+ titleEl.textContent = 'Node Details';
176+ bodyEl.textContent = 'Click a node to view full details.';
177+ copyBtn.disabled = true;
178+ copyValue = '';
179+ return;
180+ }
181+ var typeLabel = node.detail_type || node.node_type || 'node';
182+ titleEl.textContent = typeLabel + ': ' + (node.label || node.id || 'unknown');
183+ bodyEl.textContent = (node.detail_body || node.title || node.id || '') + relationDetails(node.id);
184+ copyValue = node.copy_value || node.detail_body || node.title || '';
185+ copyBtn.disabled = !copyValue;
186+ }
187+
188+ network.on('click', function (params) {
189+ if (!params.nodes || params.nodes.length === 0) {
190+ return;
191+ }
192+ setContent(nodes.get(params.nodes[0]));
193+ });
194+
195+ copyBtn.addEventListener('click', function () {
196+ copyText(copyValue).then(function (ok) {
197+ var old = copyBtn.textContent;
198+ copyBtn.textContent = ok ? 'Copied' : 'Copy failed';
199+ setTimeout(function () { copyBtn.textContent = old; }, 1000);
200+ });
201+ });
202+
203+ clearBtn.addEventListener('click', function () {
204+ setContent(null);
205+ });
206+ }
207+
208+ if (document.readyState === 'loading') {
209+ document.addEventListener('DOMContentLoaded', initNodePanel);
210+ } else {
211+ initNodePanel();
212+ }
213+ })();
214+ </script>
215+ """
216+
21217
22218def _risk_size (score_impact : int ) -> int :
23219 return min (60 , 16 + score_impact * 4 )
24220
25221
222+ def _line (label : str , value : str | int | None ) -> str :
223+ return f"{ label } : { value if value not in (None , '' ) else '-' } "
224+
225+
226+ def _url_detail_block (kind : str , url : str , base_domain : str ) -> str :
227+ parsed = urlparse (url )
228+ hostname = parsed .hostname or "-"
229+ is_internal = hostname == base_domain or hostname .endswith (f".{ base_domain } " )
230+ return "\n " .join (
231+ [
232+ _line ("Type" , kind ),
233+ _line ("Full Value" , url ),
234+ _line ("Scheme" , parsed .scheme or "-" ),
235+ _line ("Hostname" , hostname ),
236+ _line ("Port" , parsed .port if parsed .port is not None else "default" ),
237+ _line ("Path" , parsed .path or "/" ),
238+ _line ("Query" , parsed .query or "-" ),
239+ _line ("Fragment" , parsed .fragment or "-" ),
240+ _line ("Internal" , "Yes" if is_internal else "No" ),
241+ ]
242+ )
243+
244+
245+ def _inject_detail_panel (output_path : Path ) -> None :
246+ html = output_path .read_text (encoding = "utf-8" )
247+ if "node-details-panel" in html :
248+ return
249+ html = html .replace ("</body>" , f"{ DETAIL_PANEL } \n </body>" )
250+ output_path .write_text (html , encoding = "utf-8" )
251+
252+
26253def generate_attack_surface_graph (result : ReconResult , output_dir : Path ) -> Path :
27254 """Build interactive attack surface graph and export standalone HTML."""
28255
29256 output_dir .mkdir (parents = True , exist_ok = True )
30257 graph = nx .DiGraph ()
31258
32259 root_id = result .domain
33- graph .add_node (root_id , label = result .domain , node_type = "root" , color = NODE_COLORS ["root" ], size = 44 )
260+ graph .add_node (
261+ root_id ,
262+ label = result .domain ,
263+ node_type = "root" ,
264+ detail_type = "Domain" ,
265+ detail_body = (
266+ "\n " .join (
267+ [
268+ _line ("Type" , "Root Domain" ),
269+ _line ("Domain" , result .domain ),
270+ _line ("Timestamp" , result .timestamp ),
271+ _line ("Subdomains" , len (result .subdomains )),
272+ _line ("Internal Links" , len (result .surface .internal_links )),
273+ _line ("External Links" , len (result .surface .external_links )),
274+ _line ("Scripts" , len (result .surface .scripts )),
275+ _line ("Forms" , len (result .surface .forms )),
276+ _line ("API Routes" , len (result .surface .api_routes )),
277+ _line ("Admin Paths" , len (result .surface .admin_paths )),
278+ ]
279+ )
280+ ),
281+ copy_value = result .domain ,
282+ color = NODE_COLORS ["root" ],
283+ size = 44 ,
284+ )
34285
35286 for item in result .subdomains :
36287 node_id = f"sub::{ item .name } "
37288 graph .add_node (
38289 node_id ,
39290 label = item .name ,
40291 node_type = "subdomain" ,
292+ detail_type = "Subdomain" ,
293+ detail_body = "\n " .join (
294+ [
295+ _line ("Type" , "Subdomain" ),
296+ _line ("Name" , item .name ),
297+ _line ("Status" , item .status ),
298+ _line ("IP" , item .ip or "-" ),
299+ _line ("Source" , item .source ),
300+ _line ("CDN/WAF" , item .cdn_provider or "-" ),
301+ _line ("Redirect Target" , item .redirect_target or "-" ),
302+ ]
303+ ),
304+ copy_value = item .name ,
41305 color = NODE_COLORS ["subdomain" ],
42306 size = 24 ,
43307 title = f"Status: { item .status } | IP: { item .ip or '-' } " ,
@@ -52,6 +316,9 @@ def generate_attack_surface_graph(result: ReconResult, output_dir: Path) -> Path
52316 node_id ,
53317 label = endpoint .split ("//" , 1 )[- 1 ][:40 ],
54318 node_type = node_type ,
319+ detail_type = "Admin URL" if is_admin else "URL" ,
320+ detail_body = _url_detail_block ("Admin URL" if is_admin else "Internal URL" , endpoint , result .domain ),
321+ copy_value = endpoint ,
55322 color = NODE_COLORS [node_type ],
56323 size = 28 if is_admin else 16 ,
57324 title = f"URL: { endpoint } " ,
@@ -64,6 +331,9 @@ def generate_attack_surface_graph(result: ReconResult, output_dir: Path) -> Path
64331 node_id ,
65332 label = link .split ("//" , 1 )[- 1 ][:40 ],
66333 node_type = "external" ,
334+ detail_type = "External URL" ,
335+ detail_body = _url_detail_block ("External URL" , link , result .domain ),
336+ copy_value = link ,
67337 color = NODE_COLORS ["external" ],
68338 size = 16 ,
69339 title = f"External URL: { link } " ,
@@ -72,10 +342,24 @@ def generate_attack_surface_graph(result: ReconResult, output_dir: Path) -> Path
72342
73343 for script in result .surface .scripts [:80 ]:
74344 node_id = f"js::{ script } "
345+ script_details = _line ("Type" , "Script" )
346+ if script .startswith ("http://" ) or script .startswith ("https://" ):
347+ script_details = _url_detail_block ("Script" , script , result .domain )
348+ else :
349+ script_details = "\n " .join (
350+ [
351+ _line ("Type" , "Script" ),
352+ _line ("Value" , script ),
353+ _line ("Nature" , "Inline/Relative script reference" ),
354+ ]
355+ )
75356 graph .add_node (
76357 node_id ,
77358 label = script .split ("//" , 1 )[- 1 ][:40 ],
78359 node_type = "js" ,
360+ detail_type = "Script" ,
361+ detail_body = script_details ,
362+ copy_value = script ,
79363 color = NODE_COLORS ["js" ],
80364 size = 14 ,
81365 title = f"Script: { script } " ,
@@ -90,6 +374,20 @@ def generate_attack_surface_graph(result: ReconResult, output_dir: Path) -> Path
90374 finding_id ,
91375 label = finding .id ,
92376 node_type = "endpoint" ,
377+ detail_type = "High Risk Finding" ,
378+ detail_body = "\n " .join (
379+ [
380+ _line ("Type" , "High Risk Finding" ),
381+ _line ("ID" , finding .id ),
382+ _line ("Category" , finding .category ),
383+ _line ("Risk" , finding .risk ),
384+ _line ("Score Impact" , finding .score_impact ),
385+ _line ("Finding" , finding .finding ),
386+ _line ("Recommendation" , finding .recommendation ),
387+ _line ("References" , ", " .join (finding .references ) if finding .references else "-" ),
388+ ]
389+ ),
390+ copy_value = f"{ finding .id } : { finding .finding } " ,
93391 color = "#ff4d4f" ,
94392 size = _risk_size (finding .score_impact ),
95393 title = f"{ finding .finding } \n Risk: { finding .risk } " ,
@@ -102,4 +400,5 @@ def generate_attack_surface_graph(result: ReconResult, output_dir: Path) -> Path
102400
103401 output_path = output_dir / "attack_surface_graph.html"
104402 vis .write_html (str (output_path ), notebook = False )
403+ _inject_detail_panel (output_path )
105404 return output_path
0 commit comments