Skip to content

Commit f276bde

Browse files
Subject:
chore(release): bump version to v1.0.1 Body: Release v1.0.1: minor fixes and quality-of-life improvements Fix: interactive attack graph — comprehensive, click-driven detail panel (copyable URL/script/finding metadata) and JS escaping bug Fix: CI — install and run ruff reliably; opt into Node 24 runtime compatibility Fix: Wayback timeout/retry logging and other robustness fixes Fix: enum serialization, timezone-aware timestamps, cert deprecation warnings Docs: README and CI/workflow polish Tests: all unit tests and lint checks passing locally Footer: Bumps from v1.0.0 → v1.0.1; no breaking changes.
1 parent 717851d commit f276bde

2 files changed

Lines changed: 301 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ recon_toolkit/
6363
### Method A — Standard (venv)
6464

6565
```bash
66-
git clone https://github.com/<your-username>/recon_toolkit.git
66+
git clone https://github.com/SagarBiswas-MultiHAT/recon_toolkit.git
6767
cd recon_toolkit
6868
python -m venv venv
6969
source venv/bin/activate # Windows: venv\Scripts\activate

‎graph/attack_graph.py‎

Lines changed: 300 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from pathlib import Path
6+
from urllib.parse import urlparse
67

78
import networkx as nx
89
from pyvis.network import Network
@@ -18,26 +19,289 @@
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

22218
def _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+
26253
def 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}\nRisk: {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

Comments
 (0)