stackscan 2.1.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 (45) hide show
  1. stackscan/__init__.py +5 -0
  2. stackscan/__main__.py +4 -0
  3. stackscan/analyzers/__init__.py +29 -0
  4. stackscan/analyzers/creds.py +218 -0
  5. stackscan/analyzers/cve.py +458 -0
  6. stackscan/analyzers/exposure.py +73 -0
  7. stackscan/analyzers/infra.py +114 -0
  8. stackscan/analyzers/security.py +26 -0
  9. stackscan/analyzers/services.py +285 -0
  10. stackscan/analyzers/tech.py +178 -0
  11. stackscan/cli.py +750 -0
  12. stackscan/config/__init__.py +19 -0
  13. stackscan/config/sigdb_loader.py +74 -0
  14. stackscan/config/sources.py +238 -0
  15. stackscan/core/__init__.py +3 -0
  16. stackscan/core/core.py +60 -0
  17. stackscan/data/builtin.sigdb +0 -0
  18. stackscan/data/cve.json.gz +0 -0
  19. stackscan/data/reekeer-logo.png +0 -0
  20. stackscan/data/subdomains.txt +522 -0
  21. stackscan/export.py +574 -0
  22. stackscan/net/__init__.py +22 -0
  23. stackscan/net/dns.py +168 -0
  24. stackscan/net/fingerprint.py +41 -0
  25. stackscan/net/geo.py +70 -0
  26. stackscan/net/ipinfo.py +94 -0
  27. stackscan/net/ports.py +219 -0
  28. stackscan/net/resolver.py +54 -0
  29. stackscan/net/subdomains.py +457 -0
  30. stackscan/net/tld.py +126 -0
  31. stackscan/net/tls.py +78 -0
  32. stackscan/render.py +541 -0
  33. stackscan/scan.py +545 -0
  34. stackscan/theme.py +23 -0
  35. stackscan/types/__init__.py +43 -0
  36. stackscan/types/models.py +18 -0
  37. stackscan/types/output.py +367 -0
  38. stackscan/utils/__init__.py +4 -0
  39. stackscan/utils/paths.py +10 -0
  40. stackscan/utils/urls.py +39 -0
  41. stackscan-2.1.0.dist-info/METADATA +341 -0
  42. stackscan-2.1.0.dist-info/RECORD +45 -0
  43. stackscan-2.1.0.dist-info/WHEEL +4 -0
  44. stackscan-2.1.0.dist-info/entry_points.txt +2 -0
  45. stackscan-2.1.0.dist-info/licenses/LICENSE +18 -0
stackscan/export.py ADDED
@@ -0,0 +1,574 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import html
5
+ import json
6
+ from functools import lru_cache
7
+ from importlib import resources
8
+ from typing import Any
9
+ from xml.sax.saxutils import escape
10
+
11
+ from stackscan import __version__, theme
12
+ from stackscan.utils import host_of
13
+
14
+ Payload = dict[str, Any]
15
+
16
+
17
+ @lru_cache(maxsize=1)
18
+ def _logo_data_uri() -> str:
19
+ try:
20
+ raw = resources.files("stackscan.data").joinpath("reekeer-logo.png").read_bytes()
21
+ except (FileNotFoundError, ModuleNotFoundError, OSError):
22
+ return ""
23
+ return "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
24
+
25
+
26
+ def to_json(payload: Payload) -> str:
27
+ return json.dumps(payload, ensure_ascii=False, indent=2)
28
+
29
+
30
+ def build_graph(reports: list[dict[str, Any]]) -> dict[str, Any]:
31
+ nodes: dict[str, dict[str, Any]] = {}
32
+ edges: set[tuple[str, str, str]] = set()
33
+
34
+ def node(nid: str, label: str, type_: str, meta: dict[str, Any] | None = None) -> str:
35
+ if nid not in nodes:
36
+ nodes[nid] = {"id": nid, "label": label, "type": type_, "meta": meta or {}}
37
+ return nid
38
+
39
+ def edge(source: str, target: str, relation: str) -> None:
40
+ edges.add((source, target, relation))
41
+
42
+ for report in reports:
43
+ url = report.get("final_url") or report.get("url") or "unknown"
44
+ label = url.replace("https://", "").replace("http://", "").split("/")[0]
45
+ target_id = node(
46
+ f"target:{url}", label, "target", {"url": url, "status": report.get("status")}
47
+ )
48
+
49
+ network = report.get("network") or {}
50
+ for ip in list(network.get("ipv4") or []) + list(network.get("ipv6") or []):
51
+ ip_id = node(f"ip:{ip}", ip, "ip")
52
+ edge(target_id, ip_id, "resolves_to")
53
+
54
+ for sub in report.get("subdomains") or []:
55
+ name = sub.get("name") or ""
56
+ sub_id = node(f"sub:{name}", name, "subdomain", {"source": sub.get("source")})
57
+ edge(target_id, sub_id, "has_subdomain")
58
+ for addr in sub.get("addresses") or []:
59
+ ip_id = node(f"ip:{addr}", addr, "ip")
60
+ edge(sub_id, ip_id, "resolves_to")
61
+
62
+ port_scan = report.get("ports") or {}
63
+ for port_info in port_scan.get("ports") or []:
64
+ port_num = port_info.get("port")
65
+ proto = port_info.get("protocol", "tcp")
66
+ port_host = port_info.get("host") or label
67
+ port_id = node(
68
+ f"port:{port_host}:{port_num}/{proto}",
69
+ f"{port_num}/{proto}",
70
+ "port",
71
+ {
72
+ "service": port_info.get("service"),
73
+ "product": port_info.get("product"),
74
+ "version": port_info.get("version"),
75
+ "host": port_host,
76
+ },
77
+ )
78
+ edge(target_id, port_id, "exposes")
79
+
80
+ for cve in report.get("cves") or []:
81
+ cve_id_str = cve.get("id") or "unknown"
82
+ cve_id = node(
83
+ f"cve:{cve_id_str}",
84
+ cve_id_str,
85
+ "cve",
86
+ {"severity": cve.get("severity"), "cvss": cve.get("cvss")},
87
+ )
88
+ edge(target_id, cve_id, "affected_by")
89
+
90
+ for svc in report.get("services") or []:
91
+ svc_name = svc.get("name") or "unknown"
92
+ svc_id = node(
93
+ f"service:{url}:{svc_name}",
94
+ svc_name,
95
+ "service",
96
+ {"kind": svc.get("kind"), "severity": svc.get("severity")},
97
+ )
98
+ edge(target_id, svc_id, "runs")
99
+
100
+ return {
101
+ "nodes": list(nodes.values()),
102
+ "edges": [{"source": s, "target": t, "relation": r} for s, t, r in edges],
103
+ }
104
+
105
+
106
+ def to_xml(payload: Payload) -> str:
107
+ lines = ['<?xml version="1.0" encoding="UTF-8"?>']
108
+ lines.append("<stackscan>")
109
+ _xml_node(lines, "meta", {k: v for k, v in payload.items() if k != "results"}, 1)
110
+ lines.append(" <results>")
111
+ for report in payload.get("results", []):
112
+ _xml_node(lines, "result", report, 2)
113
+ lines.append(" </results>")
114
+ lines.append("</stackscan>")
115
+ return "\n".join(lines) + "\n"
116
+
117
+
118
+ def _xml_node(lines: list[str], name: str, value: Any, depth: int) -> None:
119
+ pad = " " * depth
120
+ tag = _xml_tag(name)
121
+ if isinstance(value, dict):
122
+ lines.append(f"{pad}<{tag}>")
123
+ for key, val in value.items():
124
+ _xml_node(lines, str(key), val, depth + 1)
125
+ lines.append(f"{pad}</{tag}>")
126
+ elif isinstance(value, list):
127
+ lines.append(f"{pad}<{tag}>")
128
+ for item in value:
129
+ _xml_node(lines, "item", item, depth + 1)
130
+ lines.append(f"{pad}</{tag}>")
131
+ elif value is None:
132
+ lines.append(f"{pad}<{tag}/>")
133
+ else:
134
+ lines.append(f"{pad}<{tag}>{escape(str(value))}</{tag}>")
135
+
136
+
137
+ def _xml_tag(name: str) -> str:
138
+ tag = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in name)
139
+ if not tag or tag[0].isdigit():
140
+ tag = f"_{tag}"
141
+ return tag
142
+
143
+
144
+ def to_html(payload: Payload) -> str:
145
+ reports: list[dict[str, Any]] = payload.get("results", [])
146
+ cards = "\n".join(_html_card(r) for r in reports)
147
+ graph = _html_graph(reports)
148
+ generated = html.escape(str(payload.get("generated_at", "")))
149
+ elapsed = payload.get("elapsed_seconds", 0)
150
+ logo = _logo_data_uri()
151
+ logo_img = f'<img class="brand-icon" src="{logo}" alt="reekeer">' if logo else ""
152
+ return _HTML_TEMPLATE.format(
153
+ css=_CSS,
154
+ logo=logo_img,
155
+ credit=html.escape(theme.CREDIT),
156
+ version=html.escape(__version__),
157
+ generated=generated,
158
+ elapsed=elapsed,
159
+ count=len(reports),
160
+ graph=graph,
161
+ cards=cards,
162
+ )
163
+
164
+
165
+ def _e(value: Any) -> str:
166
+ return html.escape("" if value is None else str(value))
167
+
168
+
169
+ def _chips(items: list[Any], cls: str = "chip") -> str:
170
+ return "".join(f'<span class="{cls}">{_e(i)}</span>' for i in items if i)
171
+
172
+
173
+ def _kv(label: str, value: str) -> str:
174
+ return f'<div class="kv"><span class="k">{_e(label)}</span><span class="v">{value}</span></div>'
175
+
176
+
177
+ def _section(title: str, body: str) -> str:
178
+ if not body:
179
+ return ""
180
+ return f"<section><h3>{_e(title)}</h3>{body}</section>"
181
+
182
+
183
+ def _html_card(r: dict[str, Any]) -> str:
184
+ host = _e(r.get("final_url") or r.get("url"))
185
+ status = r.get("status")
186
+ if r.get("error") and status is None:
187
+ badge = '<span class="badge bad">unavailable</span>'
188
+ else:
189
+ badge = f'<span class="badge ok">{_e(status)}</span>'
190
+ elapsed = r.get("elapsed")
191
+ meta = f"{elapsed:.2f}s" if isinstance(elapsed, (int, float)) else ""
192
+ body_parts: list[str] = []
193
+ net = r.get("network") or {}
194
+ if net:
195
+ rows = []
196
+ for label, key in (
197
+ ("IPv4", "ipv4"),
198
+ ("IPv6", "ipv6"),
199
+ ("MX", "mx"),
200
+ ("NS", "ns"),
201
+ ("TXT", "txt"),
202
+ ("CNAME", "cname"),
203
+ ("SOA", "soa"),
204
+ ("CAA", "caa"),
205
+ ):
206
+ vals = net.get(key) or []
207
+ if vals:
208
+ rows.append(_kv(label, _chips(vals)))
209
+ domains = net.get("domains") or []
210
+ if domains:
211
+ rows.append(_kv("Domains", _chips(sorted(set(domains)))))
212
+ body_parts.append(_section("Network / DNS", "".join(rows)))
213
+ ipinfo = r.get("ip_info") or []
214
+ if ipinfo:
215
+ rows = "".join(
216
+ _kv(
217
+ _e(i.get("ip")),
218
+ _e(", ".join(x for x in (i.get("city"), i.get("country")) if x))
219
+ + f" · {_e(i.get('org') or i.get('isp') or '')}"
220
+ + (f" · {_e(i.get('asn'))}" if i.get("asn") else "")
221
+ + (" · CDN/proxy" if i.get("is_cdn") else "")
222
+ + (f" · source: {_e(i.get('source'))}" if i.get("source") else ""),
223
+ )
224
+ for i in ipinfo
225
+ )
226
+ body_parts.append(_section("IP intelligence", rows))
227
+ infra = r.get("infra") or {}
228
+ infra_rows = "".join(
229
+ (
230
+ _kv(label, _chips(infra.get(key) or []))
231
+ for label, key in (
232
+ ("CDN", "cdn"),
233
+ ("WAF", "waf"),
234
+ ("Server", "server"),
235
+ ("Proxy", "proxy"),
236
+ )
237
+ if infra.get(key)
238
+ )
239
+ )
240
+ body_parts.append(_section("Infrastructure", infra_rows))
241
+ protocols = r.get("protocols") or []
242
+ if protocols:
243
+ body_parts.append(_section("Protocol", _chips(protocols)))
244
+ tls = r.get("tls") or {}
245
+ if tls:
246
+ tls_rows = ""
247
+ if tls.get("issuer"):
248
+ tls_rows += _kv("Issuer", _e(tls["issuer"]))
249
+ if tls.get("not_after"):
250
+ tls_rows += _kv("Valid until", _e(tls["not_after"]))
251
+ if tls.get("alpn"):
252
+ tls_rows += _kv("ALPN", _e(tls["alpn"]))
253
+ body_parts.append(_section("TLS", tls_rows))
254
+ techs = r.get("technologies") or []
255
+ if techs:
256
+ by_cat: dict[str, list[str]] = {}
257
+ for t in techs:
258
+ cats = t.get("categories") or ["uncategorized"]
259
+ for c in cats:
260
+ label = t.get("name", "")
261
+ loc = t.get("location")
262
+ if loc and loc != host_of(r.get("url") or ""):
263
+ label += f" @{loc}"
264
+ by_cat.setdefault(c, []).append(label)
265
+ rows = "".join(
266
+ (_kv(cat, _chips(sorted(set(names)))) for cat, names in sorted(by_cat.items()))
267
+ )
268
+ body_parts.append(_section("Technologies", rows))
269
+ services = r.get("services") or []
270
+ if services:
271
+ rows = "".join(
272
+ f"<tr><td>{_e(s.get('name'))}</td><td>{_e(s.get('kind'))}</td>"
273
+ f'<td><span class="sev sev-{_e((s.get("severity") or "info").lower())}">'
274
+ f"{_e(s.get('severity'))}</span></td><td>{_e(s.get('evidence'))}</td></tr>"
275
+ for s in services
276
+ )
277
+ body_parts.append(
278
+ _section(
279
+ "Services",
280
+ f"<table><thead><tr><th>Service</th><th>Kind</th><th>Severity</th><th>Evidence</th></tr></thead><tbody>{rows}</tbody></table>",
281
+ )
282
+ )
283
+ software = r.get("software") or []
284
+ if software:
285
+ names = [f"{s.get('name')} {s.get('version')}".strip() for s in software]
286
+ body_parts.append(_section("Software", _chips(list(dict.fromkeys(names)))))
287
+ cves = r.get("cves") or []
288
+ if cves:
289
+ rows = "".join(_html_cve_row(c) for c in cves)
290
+ body_parts.append(
291
+ _section(
292
+ "Vulnerabilities (CVE)",
293
+ f'<table class="cve"><thead><tr><th>Severity</th><th>CVE</th><th>Affects</th><th>Source</th><th>Confidence</th><th>Summary</th></tr></thead><tbody>{rows}</tbody></table>',
294
+ )
295
+ )
296
+ ports = r.get("ports") or {}
297
+ plist = ports.get("ports") or []
298
+ if plist:
299
+ rows = "".join(
300
+ f"<tr><td>{_e(p.get('port'))}/{_e(p.get('protocol'))}</td>"
301
+ f"<td>{_e(p.get('host'))}</td><td>{_e(p.get('service'))}</td>"
302
+ f"<td>{_e(' '.join(x for x in (p.get('product'), p.get('version')) if x))}</td></tr>"
303
+ for p in plist
304
+ )
305
+ body_parts.append(
306
+ _section(
307
+ "Open ports",
308
+ f"<table><thead><tr><th>Port</th><th>Host</th><th>Service</th><th>Product</th></tr></thead><tbody>{rows}</tbody></table>",
309
+ )
310
+ )
311
+ creds = r.get("creds") or []
312
+ if creds:
313
+ rows = "".join(_html_cred_row(c) for c in creds)
314
+ body_parts.append(
315
+ _section("Default creds / open devices", f'<div class="creds">{rows}</div>')
316
+ )
317
+ subs = r.get("subdomains") or []
318
+ if subs:
319
+ rows = "".join(
320
+ _kv(_e(s.get("name")), _e(", ".join(s.get("addresses") or []))) for s in subs
321
+ )
322
+ body_parts.append(_section(f"Subdomains ({len(subs)})", rows))
323
+ exp = r.get("exposure") or {}
324
+ exp_flags = [k for k in ("git_exposed", "robots_txt", "sitemap", "security_txt") if exp.get(k)]
325
+ if exp_flags:
326
+ body_parts.append(_section("Exposure", _chips(exp_flags)))
327
+ danger = any((c.get("severity") or "").upper() in ("CRITICAL", "HIGH") for c in cves) or any(
328
+ c.get("kind") in ("default-creds", "open-no-auth") for c in creds
329
+ )
330
+ cls = "card danger" if danger else "card"
331
+ return f"""<article class="{cls}"><header><h2>{host}</h2><div class="badges">{badge}<span class="meta">{_e(meta)}</span></div></header>{"".join(body_parts)}</article>"""
332
+
333
+
334
+ def _html_cve_row(c: dict[str, Any]) -> str:
335
+ sev = (c.get("severity") or "UNKNOWN").upper()
336
+ cvss = c.get("cvss")
337
+ sev_label = f"{sev} {cvss:.1f}" if isinstance(cvss, (int, float)) else sev
338
+ conf = int(c.get("confidence") or 0)
339
+ affects = f"{c.get('product')} {c.get('version') or ''}".strip()
340
+ sources = ", ".join(c.get("sources") or ())
341
+ return f"""<tr><td><span class="sev sev-{sev.lower()}">{_e(sev_label)}</span></td><td>{_e(c.get("id"))}</td><td>{_e(affects)}</td><td>{_e(sources) or "-"}</td><td><div class="bar"><span style="width:{conf}%"></span></div>{conf}%</td><td class="sum">{_e(c.get("summary"))}</td></tr>"""
342
+
343
+
344
+ def _html_cred_row(c: dict[str, Any]) -> str:
345
+ kind = c.get("kind")
346
+ if kind == "default-creds":
347
+ tag = f"""<span class="sev sev-critical">DEFAULT {_e(c.get("username"))}:{_e(c.get("password"))}</span>"""
348
+ elif kind == "open-no-auth":
349
+ tag = '<span class="sev sev-critical">OPEN / NO AUTH</span>'
350
+ else:
351
+ tag = '<span class="chip">auth required</span>'
352
+ return f"""<div class="kv"><span class="k">{_e(c.get("target"))}</span><span class="v">{tag} {_e(c.get("detail"))}</span></div>"""
353
+
354
+
355
+ def _html_graph(reports: list[dict[str, Any]]) -> str:
356
+ graph = build_graph(reports)
357
+ if not graph["edges"]:
358
+ return ""
359
+
360
+ type_color = {
361
+ "target": theme.ACCENT,
362
+ "subdomain": theme.ACCENT_2,
363
+ "ip": theme.SUCCESS,
364
+ "port": theme.WARN,
365
+ "cve": theme.DANGER,
366
+ "service": theme.ACCENT_SOFT,
367
+ }
368
+ present_types = sorted(
369
+ {n["type"] for n in graph["nodes"]},
370
+ key=lambda t: ["target", "subdomain", "ip", "port", "service", "cve"].index(t),
371
+ )
372
+ if len(present_types) < 2:
373
+ return ""
374
+
375
+ graph_json = json.dumps(
376
+ {
377
+ "nodes": [
378
+ {
379
+ "id": n["id"],
380
+ "label": _short(n["label"]),
381
+ "type": n["type"],
382
+ "color": type_color.get(n["type"], theme.TEXT),
383
+ "meta": n.get("meta") or {},
384
+ }
385
+ for n in graph["nodes"]
386
+ ],
387
+ "edges": graph["edges"],
388
+ },
389
+ ensure_ascii=False,
390
+ )
391
+
392
+ legend_items = "".join(
393
+ f'<span class="graph-legend-item"><span class="graph-legend-dot" style="background:{type_color[t]}"></span>{t}</span>'
394
+ for t in present_types
395
+ )
396
+
397
+ graph_html = f"""<div class="graph-wrap">
398
+ <svg id="netgraph" class="graph" xmlns="http://www.w3.org/2000/svg">
399
+ <g class="graph-viewport"><g class="edges"></g><g class="nodes"></g></g>
400
+ </svg>
401
+ <div class="graph-tooltip"></div>
402
+ <div class="graph-legend">{legend_items}</div>
403
+ </div>
404
+ <script>
405
+ (function(){{
406
+ const colors = {json.dumps(type_color)};
407
+ const data = {graph_json};
408
+ const svg = document.getElementById('netgraph');
409
+ const viewport = svg.querySelector('.graph-viewport');
410
+ const edgesG = svg.querySelector('.edges');
411
+ const nodesG = svg.querySelector('.nodes');
412
+ const tooltip = svg.parentNode.querySelector('.graph-tooltip');
413
+ const width = svg.clientWidth || 960;
414
+ const height = 500;
415
+ svg.setAttribute('viewBox', `0 0 ${{width}} ${{height}}`);
416
+
417
+ data.nodes.forEach(n => {{
418
+ n.x = width/2 + (Math.random()-0.5)*width*0.4;
419
+ n.y = height/2 + (Math.random()-0.5)*height*0.4;
420
+ n.vx = 0; n.vy = 0;
421
+ }});
422
+
423
+ const nodeById = {{}};
424
+ data.nodes.forEach(n => nodeById[n.id] = n);
425
+
426
+ function createElements() {{
427
+ data.edges.forEach(e => {{
428
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
429
+ line.setAttribute('stroke', '{theme.BORDER}');
430
+ line.setAttribute('stroke-opacity', '0.55');
431
+ line.setAttribute('stroke-width', '1');
432
+ line.dataset.source = e.source;
433
+ line.dataset.target = e.target;
434
+ edgesG.appendChild(line);
435
+ }});
436
+ data.nodes.forEach(n => {{
437
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
438
+ g.style.cursor = 'grab';
439
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
440
+ circle.setAttribute('r', 6);
441
+ circle.setAttribute('fill', n.color);
442
+ circle.setAttribute('stroke', '{theme.BG}');
443
+ circle.setAttribute('stroke-width', 2);
444
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
445
+ text.textContent = n.label;
446
+ text.setAttribute('fill', '{theme.TEXT}');
447
+ text.setAttribute('font-size', '12');
448
+ text.setAttribute('dy', 4);
449
+ text.setAttribute('dx', 10);
450
+ g.appendChild(circle);
451
+ g.appendChild(text);
452
+ g.addEventListener('mouseenter', ev => showTip(ev, n));
453
+ g.addEventListener('mouseleave', hideTip);
454
+ g.addEventListener('mousedown', ev => startDrag(ev, n, g));
455
+ g.dataset.id = n.id;
456
+ nodesG.appendChild(g);
457
+ }});
458
+ }}
459
+
460
+ function showTip(ev, n) {{
461
+ const meta = Object.entries(n.meta || {{}}).map(([k,v]) => `${{k}}: ${{v}}`).join('<br>');
462
+ tooltip.innerHTML = `<strong>${{n.label}}</strong> (${{n.type}})${{meta ? '<br>' + meta : ''}}`;
463
+ tooltip.style.opacity = 1;
464
+ moveTip(ev);
465
+ }}
466
+ function moveTip(ev) {{
467
+ const rect = svg.getBoundingClientRect();
468
+ tooltip.style.left = (ev.clientX - rect.left + 12) + 'px';
469
+ tooltip.style.top = (ev.clientY - rect.top + 12) + 'px';
470
+ }}
471
+ function hideTip() {{ tooltip.style.opacity = 0; }}
472
+
473
+ let dragged = null;
474
+ let dragG = null;
475
+ function startDrag(ev, n, g) {{
476
+ dragged = n; dragG = g; g.style.cursor = 'grabbing';
477
+ ev.preventDefault();
478
+ }}
479
+ svg.addEventListener('mousemove', ev => {{
480
+ if (dragged) {{
481
+ const pt = svg.createSVGPoint();
482
+ pt.x = ev.clientX; pt.y = ev.clientY;
483
+ const loc = pt.matrixTransform(viewport.getCTM().inverse());
484
+ dragged.x = loc.x; dragged.y = loc.y; dragged.vx = 0; dragged.vy = 0;
485
+ }} else if (panning) {{
486
+ pan.x += ev.movementX; pan.y += ev.movementY;
487
+ updateTransform();
488
+ }}
489
+ if (tooltip.style.opacity === '1') moveTip(ev);
490
+ }});
491
+ window.addEventListener('mouseup', () => {{
492
+ if (dragG) dragG.style.cursor = 'grab';
493
+ dragged = null; dragG = null; panning = false;
494
+ }});
495
+
496
+ let pan = {{x:0, y:0}};
497
+ let panning = false;
498
+ svg.addEventListener('mousedown', ev => {{
499
+ if (ev.target === svg || ev.target === viewport || ev.target === edgesG) {{
500
+ panning = true; ev.preventDefault();
501
+ }}
502
+ }});
503
+ svg.addEventListener('wheel', ev => {{
504
+ ev.preventDefault();
505
+ const s = ev.deltaY > 0 ? 0.9 : 1.1;
506
+ pan.k = (pan.k || 1) * s;
507
+ updateTransform();
508
+ }}, {{passive: false}});
509
+ function updateTransform() {{
510
+ viewport.setAttribute('transform', `translate(${{pan.x}},${{pan.y}}) scale(${{pan.k || 1}})`);
511
+ }}
512
+
513
+ function tick() {{
514
+ for (let i = 0; i < data.nodes.length; i++) {{
515
+ const a = data.nodes[i];
516
+ for (let j = i+1; j < data.nodes.length; j++) {{
517
+ const b = data.nodes[j];
518
+ let dx = a.x - b.x, dy = a.y - b.y;
519
+ let d2 = dx*dx + dy*dy || 1;
520
+ let f = 8000 / d2;
521
+ let d = Math.sqrt(d2);
522
+ dx /= d; dy /= d;
523
+ if (dragged !== a) {{ a.vx += dx*f; a.vy += dy*f; }}
524
+ if (dragged !== b) {{ b.vx -= dx*f; b.vy -= dy*f; }}
525
+ }}
526
+ }}
527
+ data.edges.forEach(e => {{
528
+ const a = nodeById[e.source], b = nodeById[e.target];
529
+ if (!a || !b) return;
530
+ let dx = b.x - a.x, dy = b.y - a.y;
531
+ let d = Math.sqrt(dx*dx + dy*dy) || 1;
532
+ let f = (d - 80) * 0.003;
533
+ dx /= d; dy /= d;
534
+ if (dragged !== a) {{ a.vx += dx*f; a.vy += dy*f; }}
535
+ if (dragged !== b) {{ b.vx -= dx*f; b.vy -= dy*f; }}
536
+ }});
537
+ data.nodes.forEach(n => {{
538
+ if (n === dragged) return;
539
+ n.vx += (width/2 - n.x) * 0.0003;
540
+ n.vy += (height/2 - n.y) * 0.0003;
541
+ n.vx *= 0.92; n.vy *= 0.92;
542
+ n.x += n.vx; n.y += n.vy;
543
+ n.x = Math.max(20, Math.min(width-20, n.x));
544
+ n.y = Math.max(20, Math.min(height-20, n.y));
545
+ }});
546
+
547
+ data.nodes.forEach(n => {{
548
+ const g = nodesG.querySelector(`[data-id="${{n.id}}"]`);
549
+ if (g) g.setAttribute('transform', `translate(${{n.x}},${{n.y}})`);
550
+ }});
551
+ Array.from(edgesG.children).forEach(line => {{
552
+ const a = nodeById[line.dataset.source];
553
+ const b = nodeById[line.dataset.target];
554
+ if (!a || !b) return;
555
+ line.setAttribute('x1', a.x); line.setAttribute('y1', a.y);
556
+ line.setAttribute('x2', b.x); line.setAttribute('y2', b.y);
557
+ }});
558
+ requestAnimationFrame(tick);
559
+ }}
560
+
561
+ createElements();
562
+ tick();
563
+ }})();
564
+ </script>"""
565
+ return _section("Network graph", graph_html)
566
+
567
+
568
+ def _short(label: str) -> str:
569
+ label = label.replace("https://", "").replace("http://", "").rstrip("/")
570
+ return label if len(label) <= 28 else label[:27] + "…"
571
+
572
+
573
+ _CSS = f"\n:root {{ color-scheme: dark; }}\n* {{ box-sizing: border-box; }}\nbody {{ margin:0; background:{theme.BG}; color:{theme.TEXT};\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace; padding:32px; }}\na {{ color:{theme.ACCENT}; }}\n.brand {{ max-width:1000px; margin:0 auto 24px; display:flex; align-items:center; gap:12px; }}\n.brand-icon {{ flex:0 0 auto; width:48px; height:48px; object-fit:cover;\n border-radius:50%; background:{theme.BG_SOFT}; border:1px solid {theme.BORDER};\n box-shadow:0 8px 20px rgba(125,120,234,0.30); }}\n.brand-copy strong {{ display:block; font-size:22px; letter-spacing:0.01em; white-space:nowrap;\n color:{theme.TEXT}; font-weight:800; }}\n.brand-copy sup {{ margin-left:6px; font-size:11px; font-weight:600; letter-spacing:0.16em;\n text-transform:lowercase; color:{theme.ACCENT}; vertical-align:super; }}\n.brand-sub {{ color:{theme.MUTED}; font-size:12px; margin-top:2px; }}\n.credit {{ max-width:1000px; margin:28px auto 0; color:{theme.MUTED}; font-size:12px;\n text-align:center; border-top:1px solid {theme.BORDER}; padding-top:14px; }}\n.wrap {{ max-width:1000px; margin:0 auto; display:flex; flex-direction:column; gap:20px; }}\n.card {{ background:{theme.BG_PANEL}; border:1px solid {theme.BORDER};\n border-radius:14px; padding:20px 24px; }}\n.card.danger {{ border-color:{theme.DANGER}; box-shadow:0 0 0 1px {theme.DANGER}22; }}\n.card header {{ display:flex; justify-content:space-between; align-items:center;\n gap:12px; flex-wrap:wrap; border-bottom:1px solid {theme.BORDER}; padding-bottom:12px; }}\n.card h2 {{ margin:0; font-size:18px; word-break:break-all; }}\n.badges {{ display:flex; gap:8px; align-items:center; }}\n.badge {{ padding:2px 10px; border-radius:999px; font-size:12px; font-weight:700; }}\n.badge.ok {{ background:{theme.SUCCESS}22; color:{theme.SUCCESS}; }}\n.badge.bad {{ background:{theme.DANGER}22; color:{theme.DANGER}; }}\n.meta {{ color:{theme.MUTED}; font-size:12px; }}\nsection {{ margin-top:16px; }}\nsection h3 {{ color:{theme.ACCENT_2}; font-size:13px; text-transform:uppercase;\n letter-spacing:1px; margin:0 0 8px; }}\n.kv {{ display:flex; gap:12px; padding:3px 0; font-size:13px; align-items:baseline; }}\n.kv .k {{ color:{theme.ACCENT}; min-width:120px; text-align:right; font-weight:700; }}\n.kv .v {{ color:{theme.TEXT}; word-break:break-word; }}\n.chip {{ display:inline-block; background:{theme.BG_SOFT}; border:1px solid {theme.BORDER};\n color:{theme.TEXT}; border-radius:6px; padding:1px 8px; margin:2px; font-size:12px; }}\ntable {{ width:100%; border-collapse:collapse; font-size:12px; }}\nth {{ text-align:left; color:{theme.MUTED}; font-weight:600; padding:4px 8px;\n border-bottom:1px solid {theme.BORDER}; }}\ntd {{ padding:5px 8px; border-bottom:1px solid {theme.BORDER}22; vertical-align:top; }}\n.sum {{ color:{theme.MUTED}; }}\n.sev {{ padding:1px 7px; border-radius:5px; font-weight:700; white-space:nowrap; }}\n.sev-critical {{ background:{theme.DANGER}; color:#111; }}\n.sev-high {{ background:{theme.SEVERITY['HIGH']}; color:#111; }}\n.sev-medium {{ background:{theme.WARN}; color:#111; }}\n.sev-low {{ background:{theme.ACCENT_SOFT}; color:#111; }}\n.bar {{ display:inline-block; width:80px; height:7px; border-radius:4px;\n background:{theme.BG_SOFT}; overflow:hidden; margin-right:6px; vertical-align:middle; }}\n.bar span {{ display:block; height:100%; background:linear-gradient(90deg,{theme.ACCENT},{theme.ACCENT_SOFT}); }}\n.graph {{ width:100%; height:500px; cursor:grab; }}\n.graph-wrap {{ position:relative; background:{theme.BG_SOFT}; border:1px solid {theme.BORDER}; border-radius:10px; overflow:hidden; }}\n.graph:active {{ cursor:grabbing; }}\n.graph-legend {{ display:flex; flex-wrap:wrap; gap:12px; margin-top:12px; }}\n.graph-legend-item {{ display:flex; align-items:center; gap:6px; font-size:12px; color:{theme.MUTED}; text-transform:capitalize; }}\n.graph-legend-dot {{ width:10px; height:10px; border-radius:50%; }}\n.graph-tooltip {{ position:absolute; pointer-events:none; background:{theme.BG_PANEL}; border:1px solid {theme.BORDER}; border-radius:6px; padding:6px 10px; font-size:12px; color:{theme.TEXT}; opacity:0; transition:opacity 0.15s; z-index:10; max-width:260px; }}\n"
574
+ _HTML_TEMPLATE = '<!doctype html>\n<html lang="en"><head><meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>reekeer report</title>\n<meta name="generator" content="reekeer/stackscan" \n<style>{css}</style></head>\n<body>\n<header class="brand">\n {logo}\n <div class="brand-copy">\n <strong>reekeer<sup>report</sup></strong>\n <div class="brand-sub">stackscan · {count} target(s) · {elapsed}s · {generated}</div>\n </div>\n</header>\n<div class="wrap">\n{graph}\n{cards}\n</div>\n<footer class="credit">{credit} · v{version}</footer>\n</body></html>\n'
@@ -0,0 +1,22 @@
1
+ from stackscan.net.dns import resolve_host
2
+ from stackscan.net.geo import GeoProvider, lookup_geo
3
+ from stackscan.net.ipinfo import enrich_ips
4
+ from stackscan.net.ports import nmap_available, scan_ports
5
+ from stackscan.net.subdomains import enumerate_subdomains, resolve_existing
6
+ from stackscan.net.tld import expand_wildcard_target, has_wildcard, load_tlds
7
+ from stackscan.net.tls import fetch_tls_info
8
+
9
+ __all__ = [
10
+ "GeoProvider",
11
+ "enrich_ips",
12
+ "enumerate_subdomains",
13
+ "expand_wildcard_target",
14
+ "fetch_tls_info",
15
+ "has_wildcard",
16
+ "load_tlds",
17
+ "lookup_geo",
18
+ "nmap_available",
19
+ "resolve_existing",
20
+ "resolve_host",
21
+ "scan_ports",
22
+ ]