huntkit 0.3.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 (86) hide show
  1. huntkit/__init__.py +4 -0
  2. huntkit/__main__.py +8 -0
  3. huntkit/ai/__init__.py +42 -0
  4. huntkit/ai/advisor.py +133 -0
  5. huntkit/ai/providers.py +200 -0
  6. huntkit/app/__init__.py +13 -0
  7. huntkit/app/config_cmd.py +65 -0
  8. huntkit/app/context.py +50 -0
  9. huntkit/app/main.py +1166 -0
  10. huntkit/app/plugins_cmd.py +79 -0
  11. huntkit/app/workspace_cmd.py +62 -0
  12. huntkit/core/__init__.py +6 -0
  13. huntkit/core/cache.py +67 -0
  14. huntkit/core/config.py +272 -0
  15. huntkit/core/logger.py +66 -0
  16. huntkit/core/pacing.py +71 -0
  17. huntkit/core/runner.py +117 -0
  18. huntkit/core/state.py +154 -0
  19. huntkit/core/workspace.py +145 -0
  20. huntkit/enrich/__init__.py +39 -0
  21. huntkit/enrich/asn.py +139 -0
  22. huntkit/enrich/engine.py +167 -0
  23. huntkit/enrich/favicon.py +91 -0
  24. huntkit/enrich/mmh3.py +69 -0
  25. huntkit/fingerprint/__init__.py +26 -0
  26. huntkit/fingerprint/engine.py +83 -0
  27. huntkit/fingerprint/rules.py +170 -0
  28. huntkit/history/__init__.py +38 -0
  29. huntkit/history/diff.py +95 -0
  30. huntkit/history/store.py +133 -0
  31. huntkit/intel/__init__.py +48 -0
  32. huntkit/intel/engine.py +233 -0
  33. huntkit/intel/signals.py +826 -0
  34. huntkit/knowledge/__init__.py +27 -0
  35. huntkit/knowledge/playbooks.py +452 -0
  36. huntkit/monitor/__init__.py +24 -0
  37. huntkit/monitor/runner.py +111 -0
  38. huntkit/notify/__init__.py +16 -0
  39. huntkit/notify/webhook.py +73 -0
  40. huntkit/pipeline.py +302 -0
  41. huntkit/plugins/__init__.py +36 -0
  42. huntkit/plugins/amass.py +25 -0
  43. huntkit/plugins/arjun.py +18 -0
  44. huntkit/plugins/assetfinder.py +18 -0
  45. huntkit/plugins/base.py +220 -0
  46. huntkit/plugins/chaos.py +20 -0
  47. huntkit/plugins/dalfox.py +24 -0
  48. huntkit/plugins/dnsx.py +19 -0
  49. huntkit/plugins/ffuf.py +34 -0
  50. huntkit/plugins/findomain.py +18 -0
  51. huntkit/plugins/gau.py +18 -0
  52. huntkit/plugins/hakrawler.py +19 -0
  53. huntkit/plugins/httpx.py +30 -0
  54. huntkit/plugins/katana.py +23 -0
  55. huntkit/plugins/naabu.py +22 -0
  56. huntkit/plugins/nmap.py +39 -0
  57. huntkit/plugins/nuclei.py +27 -0
  58. huntkit/plugins/registry.py +117 -0
  59. huntkit/plugins/subfinder.py +21 -0
  60. huntkit/plugins/waybackurls.py +29 -0
  61. huntkit/plugins/whatweb.py +19 -0
  62. huntkit/py.typed +0 -0
  63. huntkit/report/__init__.py +103 -0
  64. huntkit/report/builder.py +145 -0
  65. huntkit/report/render.py +323 -0
  66. huntkit/scanner/__init__.py +28 -0
  67. huntkit/scanner/runner.py +239 -0
  68. huntkit/secrets/__init__.py +34 -0
  69. huntkit/secrets/patterns.py +172 -0
  70. huntkit/secrets/scanner.py +133 -0
  71. huntkit/shots/__init__.py +29 -0
  72. huntkit/shots/runner.py +186 -0
  73. huntkit/tui/__init__.py +13 -0
  74. huntkit/tui/dashboard.py +160 -0
  75. huntkit/utils/__init__.py +6 -0
  76. huntkit/utils/filesystem.py +109 -0
  77. huntkit/utils/process.py +104 -0
  78. huntkit/utils/severity.py +49 -0
  79. huntkit/utils/terminal.py +88 -0
  80. huntkit/utils/validators.py +99 -0
  81. huntkit-0.3.0.dist-info/METADATA +340 -0
  82. huntkit-0.3.0.dist-info/RECORD +86 -0
  83. huntkit-0.3.0.dist-info/WHEEL +5 -0
  84. huntkit-0.3.0.dist-info/entry_points.txt +2 -0
  85. huntkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  86. huntkit-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,826 @@
1
+ """Signal catalog + matchers — the rule base of the intelligence engine.
2
+
3
+ Each rule turns one concrete observation (an open port, a URL path, a query
4
+ parameter, a subdomain label) into a typed :class:`Signal` carrying a
5
+ **severity** and the bug-class **playbooks** worth trying there. Rules are
6
+ plain data, so sharpening HuntKit's intelligence is a one-line edit — the
7
+ same "add a row, not a branch" ethos as the plugin registry.
8
+
9
+ Everything here is pure and offline: it reasons over recon output HuntKit
10
+ already gathered and never touches the target.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from urllib.parse import parse_qsl, urlsplit
18
+
19
+ from ..utils.severity import Severity
20
+
21
+ __all__ = [
22
+ "Severity",
23
+ "Signal",
24
+ "Rule",
25
+ "url_host",
26
+ "scan_signals",
27
+ "signals_from_ports",
28
+ "signals_from_paths",
29
+ "signals_from_params",
30
+ "signals_from_labels",
31
+ "signals_from_findings",
32
+ ]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Signal:
37
+ """One scored observation about one host."""
38
+
39
+ id: str
40
+ title: str
41
+ severity: Severity
42
+ category: str # port | path | param | subdomain
43
+ host: str
44
+ evidence: str # a representative hit (first seen)
45
+ playbooks: tuple[str, ...] = ()
46
+ count: int = 1 # how many raw hits collapsed into this signal
47
+
48
+ def to_dict(self) -> dict:
49
+ return {
50
+ "id": self.id,
51
+ "title": self.title,
52
+ "severity": self.severity.label,
53
+ "category": self.category,
54
+ "host": self.host,
55
+ "evidence": self.evidence,
56
+ "count": self.count,
57
+ "playbooks": list(self.playbooks),
58
+ }
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Rule:
63
+ """A catalog entry: what a match means, how bad, and what to try."""
64
+
65
+ id: str
66
+ title: str
67
+ severity: Severity
68
+ playbooks: tuple[str, ...] = ()
69
+
70
+
71
+ # --------------------------------------------------------------------------
72
+ # Port rules — keyed by port number. Unauthenticated-by-default datastores
73
+ # (redis/mongo/elastic/docker) are Critical; authenticated DBs are High;
74
+ # alternate HTTP ports are Low (just extra attack surface).
75
+ # --------------------------------------------------------------------------
76
+ PORT_RULES: dict[str, Rule] = {
77
+ "21": Rule("ftp-open", "FTP exposed — anon login / plaintext creds", Severity.MEDIUM),
78
+ "22": Rule("ssh-open", "SSH exposed — version/CVE check", Severity.INFO),
79
+ "23": Rule("telnet-open", "Telnet exposed — plaintext, near-always a finding", Severity.HIGH),
80
+ "25": Rule("smtp-open", "SMTP exposed — open relay / user enum", Severity.LOW),
81
+ "110": Rule("pop3-open", "POP3 exposed — plaintext auth", Severity.LOW),
82
+ "135": Rule("msrpc-open", "MSRPC exposed", Severity.MEDIUM),
83
+ "139": Rule("netbios-open", "NetBIOS exposed", Severity.MEDIUM),
84
+ "445": Rule("smb-open", "SMB exposed — null session / share enum", Severity.HIGH),
85
+ "1433": Rule(
86
+ "mssql-open", "MSSQL exposed — auth & injection surface", Severity.HIGH, ("sqli",)
87
+ ),
88
+ "1521": Rule("oracle-open", "Oracle DB exposed", Severity.HIGH, ("sqli",)),
89
+ "2375": Rule("docker-open", "Docker API exposed — unauth = host RCE", Severity.CRITICAL),
90
+ "2379": Rule("etcd-open", "etcd exposed — cluster secrets", Severity.HIGH),
91
+ "3306": Rule(
92
+ "mysql-open", "MySQL exposed — auth surface, remote root?", Severity.HIGH, ("sqli",)
93
+ ),
94
+ "3389": Rule("rdp-open", "RDP exposed — CVE/BlueKeep, cred-spray risk", Severity.HIGH),
95
+ "5432": Rule("postgres-open", "PostgreSQL exposed — auth surface", Severity.HIGH, ("sqli",)),
96
+ "5601": Rule(
97
+ "kibana-open", "Kibana exposed — often unauth data access", Severity.HIGH, ("authn",)
98
+ ),
99
+ "5900": Rule("vnc-open", "VNC exposed — weak/no auth", Severity.HIGH),
100
+ "6379": Rule("redis-open", "Redis exposed — frequently unauth = RCE", Severity.CRITICAL),
101
+ "8000": Rule("http-alt-8000", "Alt HTTP (8000) — dev/admin apps", Severity.LOW, ("bac",)),
102
+ "8080": Rule(
103
+ "http-alt-8080", "Alt HTTP (8080) — dev/admin panels, actuator", Severity.LOW, ("bac",)
104
+ ),
105
+ "8443": Rule(
106
+ "https-alt-8443", "Alt HTTPS (8443) — staging apps, weaker auth", Severity.LOW, ("bac",)
107
+ ),
108
+ "8888": Rule("http-alt-8888", "Alt HTTP (8888) — notebooks/dev apps", Severity.LOW, ("bac",)),
109
+ "9000": Rule("http-alt-9000", "Port 9000 — SonarQube/PHP-FPM/consoles", Severity.MEDIUM),
110
+ "9200": Rule(
111
+ "elastic-open", "Elasticsearch exposed — often unauth data dump", Severity.CRITICAL
112
+ ),
113
+ "9300": Rule("elastic-transport", "Elasticsearch transport exposed", Severity.HIGH),
114
+ "11211": Rule("memcached-open", "Memcached exposed — unauth, DDoS amp", Severity.HIGH),
115
+ "15672": Rule(
116
+ "rabbitmq-open", "RabbitMQ mgmt exposed — default creds", Severity.MEDIUM, ("authn",)
117
+ ),
118
+ "27017": Rule("mongo-open", "MongoDB exposed — often no auth", Severity.CRITICAL),
119
+ "6443": Rule("kube-api", "Kubernetes API exposed", Severity.HIGH),
120
+ "10250": Rule("kubelet-open", "Kubelet exposed — unauth exec risk", Severity.HIGH),
121
+ }
122
+
123
+
124
+ # --------------------------------------------------------------------------
125
+ # Path rules — needle (lowercase) found in a URL path -> Signal. Ordered
126
+ # roughly most→least specific; every independent match becomes its own signal.
127
+ # --------------------------------------------------------------------------
128
+ PATH_RULES: tuple[tuple[str, Rule], ...] = (
129
+ ("/.git/", Rule("git-exposed", "Exposed .git directory — source/secret leak", Severity.HIGH)),
130
+ ("/.svn/", Rule("svn-exposed", "Exposed .svn directory", Severity.HIGH)),
131
+ ("/.hg/", Rule("hg-exposed", "Exposed .hg directory", Severity.HIGH)),
132
+ ("/.env", Rule("env-exposed", "Exposed .env — credentials/secrets", Severity.CRITICAL)),
133
+ ("/.aws/", Rule("aws-creds", "Exposed .aws directory — cloud credentials", Severity.CRITICAL)),
134
+ ("/.htaccess", Rule("htaccess", "Exposed .htaccess", Severity.MEDIUM)),
135
+ ("/.ds_store", Rule("dsstore", "Exposed .DS_Store — path disclosure", Severity.LOW)),
136
+ (
137
+ "/actuator",
138
+ Rule(
139
+ "spring-actuator",
140
+ "Spring Actuator — env/heapdump/SSRF surface",
141
+ Severity.HIGH,
142
+ ("ssrf",),
143
+ ),
144
+ ),
145
+ (
146
+ "/phpmyadmin",
147
+ Rule("phpmyadmin", "phpMyAdmin panel exposed", Severity.HIGH, ("authn", "sqli")),
148
+ ),
149
+ ("/adminer", Rule("adminer", "Adminer DB panel exposed", Severity.HIGH, ("authn", "sqli"))),
150
+ (
151
+ "/jenkins",
152
+ Rule("jenkins", "Jenkins exposed — script console / CVEs", Severity.HIGH, ("authn",)),
153
+ ),
154
+ (
155
+ "/wp-admin",
156
+ Rule(
157
+ "wp-admin",
158
+ "WordPress admin — enum, weak plugin CVEs",
159
+ Severity.MEDIUM,
160
+ ("authn", "bac"),
161
+ ),
162
+ ),
163
+ (
164
+ "/wp-login",
165
+ Rule("wp-login", "WordPress login — brute/enum surface", Severity.MEDIUM, ("authn",)),
166
+ ),
167
+ ("/wp-json", Rule("wp-json", "WordPress REST API — user enum / authz", Severity.LOW, ("bac",))),
168
+ (
169
+ "/xmlrpc.php",
170
+ Rule(
171
+ "xmlrpc", "WordPress xmlrpc — brute/pingback SSRF", Severity.MEDIUM, ("authn", "ssrf")
172
+ ),
173
+ ),
174
+ (
175
+ "/administrator",
176
+ Rule("joomla-admin", "Joomla administrator panel", Severity.MEDIUM, ("authn", "bac")),
177
+ ),
178
+ (
179
+ "/graphql",
180
+ Rule(
181
+ "graphql",
182
+ "GraphQL — introspection, batching, field authz",
183
+ Severity.MEDIUM,
184
+ ("bac", "idor"),
185
+ ),
186
+ ),
187
+ (
188
+ "/swagger",
189
+ Rule(
190
+ "swagger", "Swagger UI — full endpoint map to test authz", Severity.LOW, ("bac", "idor")
191
+ ),
192
+ ),
193
+ (
194
+ "/api-docs",
195
+ Rule("api-docs", "API docs exposed — endpoint map", Severity.LOW, ("bac", "idor")),
196
+ ),
197
+ (
198
+ "/openapi",
199
+ Rule("openapi", "OpenAPI spec exposed — endpoint map", Severity.LOW, ("bac", "idor")),
200
+ ),
201
+ (
202
+ "/server-status",
203
+ Rule("apache-status", "Apache server-status — request/IP leak", Severity.MEDIUM),
204
+ ),
205
+ ("/phpinfo", Rule("phpinfo", "phpinfo() exposed — env/config disclosure", Severity.MEDIUM)),
206
+ ("/console", Rule("web-console", "Web console endpoint", Severity.MEDIUM, ("bac",))),
207
+ ("/debug", Rule("debug-endpoint", "Debug endpoint — verbose errors/state", Severity.MEDIUM)),
208
+ (
209
+ "/admin",
210
+ Rule("admin-panel", "Admin path — privileged surface", Severity.MEDIUM, ("authn", "bac")),
211
+ ),
212
+ (
213
+ "/internal",
214
+ Rule("internal-path", "Internal path — should not be public", Severity.MEDIUM, ("bac",)),
215
+ ),
216
+ ("/backup", Rule("backup-path", "Backup path — exposed archives/dumps", Severity.MEDIUM)),
217
+ ("/.well-known/security.txt", Rule("securitytxt", "security.txt present", Severity.INFO)),
218
+ (".sql", Rule("sql-dump", "SQL dump in URL — data exposure", Severity.HIGH)),
219
+ (".bak", Rule("bak-file", "Backup file (.bak) in URL", Severity.MEDIUM)),
220
+ (".zip", Rule("archive-file", "Archive (.zip) in URL — possible source/backup", Severity.LOW)),
221
+ (".tar.gz", Rule("targz-file", "Archive (.tar.gz) in URL", Severity.LOW)),
222
+ (".log", Rule("log-file", "Log file in URL — info disclosure", Severity.LOW)),
223
+ )
224
+
225
+
226
+ # --------------------------------------------------------------------------
227
+ # Param rules — grouped param names -> Rule. Built into a name->rule map with
228
+ # "first group wins", so higher-impact classes (listed first) take priority
229
+ # when a name could belong to several.
230
+ # --------------------------------------------------------------------------
231
+ _PARAM_RULES: tuple[tuple[tuple[str, ...], Rule], ...] = (
232
+ (
233
+ ("cmd", "exec", "command", "execute", "run", "ping", "code", "shell", "daemon", "cli"),
234
+ Rule("cmd-param", "Command-like parameter — probe for RCE", Severity.HIGH),
235
+ ),
236
+ (
237
+ (
238
+ "url",
239
+ "uri",
240
+ "link",
241
+ "redirect",
242
+ "redir",
243
+ "redirect_uri",
244
+ "redirect_url",
245
+ "next",
246
+ "dest",
247
+ "destination",
248
+ "return",
249
+ "returnurl",
250
+ "return_url",
251
+ "continue",
252
+ "callback",
253
+ "webhook",
254
+ "feed",
255
+ "forward",
256
+ "out",
257
+ "to",
258
+ "imageurl",
259
+ "image_url",
260
+ "proxy",
261
+ "fetch",
262
+ "load",
263
+ "site",
264
+ "domain",
265
+ "host",
266
+ "open",
267
+ ),
268
+ Rule(
269
+ "ssrf-param",
270
+ "URL/redirect parameter — test SSRF & open-redirect",
271
+ Severity.MEDIUM,
272
+ ("ssrf",),
273
+ ),
274
+ ),
275
+ (
276
+ (
277
+ "password",
278
+ "passwd",
279
+ "pwd",
280
+ "pass",
281
+ "token",
282
+ "access_token",
283
+ "api_key",
284
+ "apikey",
285
+ "secret",
286
+ "auth",
287
+ "authorization",
288
+ "session",
289
+ "sessionid",
290
+ "jwt",
291
+ "key",
292
+ ),
293
+ Rule(
294
+ "secret-param",
295
+ "Secret in query string — credential exposure",
296
+ Severity.MEDIUM,
297
+ ("authn",),
298
+ ),
299
+ ),
300
+ (
301
+ (
302
+ "file",
303
+ "filename",
304
+ "path",
305
+ "template",
306
+ "include",
307
+ "page",
308
+ "doc",
309
+ "document",
310
+ "folder",
311
+ "dir",
312
+ "root",
313
+ "download",
314
+ "pdf",
315
+ "style",
316
+ "lang",
317
+ "view",
318
+ ),
319
+ Rule(
320
+ "lfi-param",
321
+ "File/template parameter — test LFI/traversal & SSTI",
322
+ Severity.MEDIUM,
323
+ ("ssti",),
324
+ ),
325
+ ),
326
+ (
327
+ (
328
+ "filter",
329
+ "sort",
330
+ "order",
331
+ "orderby",
332
+ "order_by",
333
+ "where",
334
+ "column",
335
+ "select",
336
+ "field",
337
+ "sortby",
338
+ ),
339
+ Rule(
340
+ "sqli-param", "Filter/sort parameter — reaches the data store", Severity.LOW, ("sqli",)
341
+ ),
342
+ ),
343
+ (
344
+ (
345
+ "q",
346
+ "s",
347
+ "search",
348
+ "query",
349
+ "keyword",
350
+ "kw",
351
+ "term",
352
+ "name",
353
+ "title",
354
+ "comment",
355
+ "message",
356
+ "msg",
357
+ "content",
358
+ "description",
359
+ "desc",
360
+ "feedback",
361
+ "body",
362
+ "text",
363
+ "subject",
364
+ ),
365
+ Rule(
366
+ "reflect-param",
367
+ "Reflected text parameter — test XSS & SQLi",
368
+ Severity.LOW,
369
+ ("xss", "sqli"),
370
+ ),
371
+ ),
372
+ (
373
+ (
374
+ "id",
375
+ "uid",
376
+ "user",
377
+ "userid",
378
+ "user_id",
379
+ "account",
380
+ "accountid",
381
+ "account_id",
382
+ "orderid",
383
+ "order_id",
384
+ "invoice",
385
+ "number",
386
+ "pid",
387
+ "gid",
388
+ "item",
389
+ "itemid",
390
+ "cart",
391
+ "profile",
392
+ "customer",
393
+ "customerid",
394
+ "groupid",
395
+ "record",
396
+ "ref",
397
+ ),
398
+ Rule("idor-param", "Object-id parameter — test IDOR / BOLA", Severity.LOW, ("idor",)),
399
+ ),
400
+ (
401
+ ("xml", "data", "input", "xmlfile"),
402
+ Rule("xxe-param", "XML/data parameter — test XXE", Severity.LOW),
403
+ ),
404
+ )
405
+
406
+ PARAM_MAP: dict[str, Rule] = {}
407
+ for _names, _rule in _PARAM_RULES:
408
+ for _n in _names:
409
+ PARAM_MAP.setdefault(_n, _rule)
410
+
411
+
412
+ # --------------------------------------------------------------------------
413
+ # Subdomain-label rules — a label in the host (e.g. "dev" in dev.api.x.com)
414
+ # hints at weaker controls or a named service. Matched exact-first, then by
415
+ # longest prefix ("dev01" -> dev).
416
+ # --------------------------------------------------------------------------
417
+ LABEL_RULES: tuple[tuple[str, Rule], ...] = (
418
+ (
419
+ "dev",
420
+ Rule(
421
+ "nonprod-host",
422
+ "Non-prod host — typically weaker controls",
423
+ Severity.MEDIUM,
424
+ ("authn", "bac"),
425
+ ),
426
+ ),
427
+ (
428
+ "test",
429
+ Rule(
430
+ "nonprod-host",
431
+ "Non-prod host — typically weaker controls",
432
+ Severity.MEDIUM,
433
+ ("authn", "bac"),
434
+ ),
435
+ ),
436
+ (
437
+ "staging",
438
+ Rule(
439
+ "nonprod-host",
440
+ "Staging host — typically weaker controls",
441
+ Severity.MEDIUM,
442
+ ("authn", "bac"),
443
+ ),
444
+ ),
445
+ (
446
+ "stage",
447
+ Rule(
448
+ "nonprod-host",
449
+ "Staging host — typically weaker controls",
450
+ Severity.MEDIUM,
451
+ ("authn", "bac"),
452
+ ),
453
+ ),
454
+ (
455
+ "stg",
456
+ Rule(
457
+ "nonprod-host",
458
+ "Staging host — typically weaker controls",
459
+ Severity.MEDIUM,
460
+ ("authn", "bac"),
461
+ ),
462
+ ),
463
+ (
464
+ "uat",
465
+ Rule(
466
+ "nonprod-host",
467
+ "UAT host — typically weaker controls",
468
+ Severity.MEDIUM,
469
+ ("authn", "bac"),
470
+ ),
471
+ ),
472
+ (
473
+ "qa",
474
+ Rule(
475
+ "nonprod-host", "QA host — typically weaker controls", Severity.MEDIUM, ("authn", "bac")
476
+ ),
477
+ ),
478
+ (
479
+ "preprod",
480
+ Rule(
481
+ "nonprod-host",
482
+ "Pre-prod host — typically weaker controls",
483
+ Severity.MEDIUM,
484
+ ("authn", "bac"),
485
+ ),
486
+ ),
487
+ (
488
+ "sandbox",
489
+ Rule(
490
+ "nonprod-host",
491
+ "Sandbox host — typically weaker controls",
492
+ Severity.MEDIUM,
493
+ ("authn", "bac"),
494
+ ),
495
+ ),
496
+ ("demo", Rule("nonprod-host", "Demo host — often stale/loose", Severity.LOW, ("authn", "bac"))),
497
+ ("beta", Rule("nonprod-host", "Beta host — often stale/loose", Severity.LOW, ("authn", "bac"))),
498
+ (
499
+ "old",
500
+ Rule(
501
+ "legacy-host", "Legacy host — unpatched, forgotten", Severity.MEDIUM, ("authn", "bac")
502
+ ),
503
+ ),
504
+ (
505
+ "legacy",
506
+ Rule(
507
+ "legacy-host", "Legacy host — unpatched, forgotten", Severity.MEDIUM, ("authn", "bac")
508
+ ),
509
+ ),
510
+ ("backup", Rule("legacy-host", "Backup host — stale data/config", Severity.MEDIUM)),
511
+ (
512
+ "admin",
513
+ Rule("admin-host", "Admin host — privileged surface", Severity.MEDIUM, ("authn", "bac")),
514
+ ),
515
+ (
516
+ "internal",
517
+ Rule("internal-host", "Internal host exposed publicly", Severity.MEDIUM, ("bac",)),
518
+ ),
519
+ (
520
+ "intranet",
521
+ Rule("internal-host", "Intranet host exposed publicly", Severity.MEDIUM, ("bac",)),
522
+ ),
523
+ ("corp", Rule("internal-host", "Corporate host exposed publicly", Severity.LOW, ("bac",))),
524
+ ("vpn", Rule("vpn-host", "VPN endpoint — known-CVE surface", Severity.MEDIUM, ("authn",))),
525
+ ("remote", Rule("remote-host", "Remote-access host", Severity.MEDIUM, ("authn",))),
526
+ (
527
+ "jenkins",
528
+ Rule("ci-host", "Jenkins/CI host — script console/CVEs", Severity.HIGH, ("authn",)),
529
+ ),
530
+ (
531
+ "gitlab",
532
+ Rule("git-host", "GitLab host — auth/authz & CVEs", Severity.HIGH, ("authn", "bac")),
533
+ ),
534
+ ("git", Rule("git-host", "Git host — source/auth surface", Severity.MEDIUM, ("authn",))),
535
+ (
536
+ "jira",
537
+ Rule(
538
+ "atlassian-host", "Jira host — known SSRF/unauth CVEs", Severity.MEDIUM, ("ssrf", "bac")
539
+ ),
540
+ ),
541
+ (
542
+ "confluence",
543
+ Rule(
544
+ "atlassian-host",
545
+ "Confluence host — known RCE/SSRF CVEs",
546
+ Severity.MEDIUM,
547
+ ("ssrf", "bac"),
548
+ ),
549
+ ),
550
+ (
551
+ "grafana",
552
+ Rule("dashboard-host", "Grafana host — auth bypass CVEs", Severity.MEDIUM, ("authn",)),
553
+ ),
554
+ (
555
+ "kibana",
556
+ Rule("dashboard-host", "Kibana host — often unauth data", Severity.HIGH, ("authn",)),
557
+ ),
558
+ ("pma", Rule("db-admin-host", "DB admin host (phpMyAdmin)", Severity.HIGH, ("authn", "sqli"))),
559
+ (
560
+ "phpmyadmin",
561
+ Rule("db-admin-host", "DB admin host (phpMyAdmin)", Severity.HIGH, ("authn", "sqli")),
562
+ ),
563
+ (
564
+ "api",
565
+ Rule("api-host", "API host — object-level authz surface", Severity.LOW, ("idor", "bac")),
566
+ ),
567
+ ("gateway", Rule("api-host", "Gateway host — routing/authz surface", Severity.LOW, ("bac",))),
568
+ (
569
+ "portal",
570
+ Rule(
571
+ "portal-host", "Portal host — multi-role authz surface", Severity.LOW, ("authn", "bac")
572
+ ),
573
+ ),
574
+ ("mail", Rule("mail-host", "Mail host — webmail/auth surface", Severity.LOW, ("authn",))),
575
+ ("webmail", Rule("mail-host", "Webmail host — auth surface", Severity.LOW, ("authn",))),
576
+ ("ftp", Rule("ftp-host", "FTP host — plaintext/anon surface", Severity.MEDIUM)),
577
+ (
578
+ "s3",
579
+ Rule(
580
+ "storage-host", "Storage host — bucket ACL / takeover", Severity.LOW, ("subtakeover",)
581
+ ),
582
+ ),
583
+ (
584
+ "storage",
585
+ Rule(
586
+ "storage-host", "Storage host — bucket ACL / takeover", Severity.LOW, ("subtakeover",)
587
+ ),
588
+ ),
589
+ (
590
+ "assets",
591
+ Rule(
592
+ "storage-host", "Assets host — dangling-CNAME takeover", Severity.LOW, ("subtakeover",)
593
+ ),
594
+ ),
595
+ (
596
+ "cdn",
597
+ Rule("storage-host", "CDN host — dangling-CNAME takeover", Severity.LOW, ("subtakeover",)),
598
+ ),
599
+ (
600
+ "static",
601
+ Rule(
602
+ "storage-host", "Static host — dangling-CNAME takeover", Severity.LOW, ("subtakeover",)
603
+ ),
604
+ ),
605
+ )
606
+
607
+ _LABEL_MAP: dict[str, Rule] = {}
608
+ for _key, _r in LABEL_RULES:
609
+ _LABEL_MAP.setdefault(_key, _r)
610
+
611
+
612
+ # --------------------------------------------------------------------------
613
+ # URL helpers
614
+ # --------------------------------------------------------------------------
615
+ def url_host(url: str) -> str:
616
+ """Hostname of a URL, lowercased; '' if unparseable. Scheme optional."""
617
+ url = url.strip()
618
+ if not url:
619
+ return ""
620
+ try:
621
+ parts = urlsplit(url if "://" in url else "//" + url)
622
+ return (parts.hostname or "").lower()
623
+ except ValueError:
624
+ return ""
625
+
626
+
627
+ def _url_path(url: str) -> str:
628
+ try:
629
+ parts = urlsplit(url if "://" in url else "//" + url)
630
+ return (parts.path or "").lower()
631
+ except ValueError:
632
+ return ""
633
+
634
+
635
+ def _url_params(url: str) -> list[str]:
636
+ try:
637
+ parts = urlsplit(url if "://" in url else "//" + url)
638
+ return [name for name, _ in parse_qsl(parts.query, keep_blank_values=True)]
639
+ except ValueError:
640
+ return []
641
+
642
+
643
+ def _label_rule(label: str) -> Rule | None:
644
+ """Exact label match, else the longest-prefix rule (>=3 chars)."""
645
+ rule = _LABEL_MAP.get(label)
646
+ if rule is not None:
647
+ return rule
648
+ best: Rule | None = None
649
+ best_len = 0
650
+ for key, r in LABEL_RULES:
651
+ if len(key) >= 3 and label.startswith(key) and len(key) > best_len:
652
+ best, best_len = r, len(key)
653
+ return best
654
+
655
+
656
+ # --------------------------------------------------------------------------
657
+ # Accumulator — collapse many raw hits into one signal per (host, rule).
658
+ # --------------------------------------------------------------------------
659
+ class _Acc:
660
+ def __init__(self) -> None:
661
+ self._map: dict[tuple[str, str], list] = {}
662
+
663
+ def add(self, host: str, rule: Rule, category: str, evidence: str) -> None:
664
+ key = (host, rule.id)
665
+ entry = self._map.get(key)
666
+ if entry is None:
667
+ self._map[key] = [rule, category, evidence, 1]
668
+ else:
669
+ entry[3] += 1
670
+
671
+ def signals(self) -> list[Signal]:
672
+ out = []
673
+ for (host, _id), (rule, category, evidence, count) in self._map.items():
674
+ out.append(
675
+ Signal(
676
+ id=rule.id,
677
+ title=rule.title,
678
+ severity=rule.severity,
679
+ category=category,
680
+ host=host,
681
+ evidence=evidence,
682
+ playbooks=rule.playbooks,
683
+ count=count,
684
+ )
685
+ )
686
+ return out
687
+
688
+
689
+ # --------------------------------------------------------------------------
690
+ # Matchers — each pure, each returns deduped signals.
691
+ # --------------------------------------------------------------------------
692
+ def signals_from_ports(lines: list[str]) -> list[Signal]:
693
+ """`host:port` lines -> port signals."""
694
+ acc = _Acc()
695
+ for line in lines:
696
+ host, sep, port = line.strip().rpartition(":")
697
+ if not sep or not port.isdigit():
698
+ continue
699
+ rule = PORT_RULES.get(port)
700
+ if rule is not None:
701
+ host = host.strip().lower()
702
+ acc.add(host, rule, "port", f"{host}:{port}")
703
+ return acc.signals()
704
+
705
+
706
+ def signals_from_paths(urls: list[str]) -> list[Signal]:
707
+ """URLs -> path signals (interesting endpoints/files)."""
708
+ acc = _Acc()
709
+ for url in urls:
710
+ host = url_host(url)
711
+ if not host:
712
+ continue
713
+ path = _url_path(url)
714
+ if not path:
715
+ continue
716
+ for needle, rule in PATH_RULES:
717
+ if needle in path:
718
+ acc.add(host, rule, "path", url.strip())
719
+ return acc.signals()
720
+
721
+
722
+ def signals_from_params(urls: list[str]) -> list[Signal]:
723
+ """URLs -> query-parameter signals (injection/SSRF/IDOR hints)."""
724
+ acc = _Acc()
725
+ for url in urls:
726
+ host = url_host(url)
727
+ if not host:
728
+ continue
729
+ for name in _url_params(url):
730
+ rule = PARAM_MAP.get(name.lower())
731
+ if rule is not None:
732
+ acc.add(host, rule, "param", f"{name}= @ {url.strip()}")
733
+ return acc.signals()
734
+
735
+
736
+ def signals_from_labels(hosts: list[str]) -> list[Signal]:
737
+ """Hostnames -> subdomain-label signals (non-prod/named-service hints)."""
738
+ acc = _Acc()
739
+ for host in hosts:
740
+ h = host.strip().lower()
741
+ if not h:
742
+ continue
743
+ for label in h.split("."):
744
+ rule = _label_rule(label)
745
+ if rule is not None:
746
+ acc.add(h, rule, "subdomain", h)
747
+ return acc.signals()
748
+
749
+
750
+ # --------------------------------------------------------------------------
751
+ # Scanner findings — the output of the active `scan` phase (nuclei/dalfox).
752
+ # Unlike the recon matchers above (which score *potential* from the shape of
753
+ # the surface), these are *confirmed* hits from a scanner, so they are the
754
+ # strongest signal: a nuclei finding carries its own severity, a dalfox hit is
755
+ # a reflected/stored XSS. They flow into host scoring and attack paths like
756
+ # any other signal, with category "finding".
757
+ # --------------------------------------------------------------------------
758
+ _NUCLEI_SEVERITIES = {"info", "informational", "low", "medium", "high", "critical"}
759
+
760
+ # dalfox reports XSS; it has no severity column, so we fix it at High + xss.
761
+ _DALFOX_RULE = Rule("dalfox-xss", "dalfox — reflected/stored XSS", Severity.HIGH, ("xss",))
762
+
763
+
764
+ def _bracket_tokens(line: str) -> list[str]:
765
+ """The ``[...]`` tokens nuclei prefixes each finding with."""
766
+ return re.findall(r"\[([^\]]+)\]", line)
767
+
768
+
769
+ def _first_url(line: str) -> str:
770
+ """First http(s) URL in a line (how scanners cite the affected target)."""
771
+ for tok in line.split():
772
+ if tok.startswith(("http://", "https://")):
773
+ return tok
774
+ return ""
775
+
776
+
777
+ def _slug(text: str) -> str:
778
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:48] or "finding"
779
+
780
+
781
+ def _nuclei_signal_rule(line: str) -> Rule:
782
+ """Build a Rule from a nuclei output line (``[tid] [proto] [sev] url``)."""
783
+ tokens = _bracket_tokens(line)
784
+ severity = Severity.MEDIUM
785
+ for tok in tokens:
786
+ if tok.lower() in _NUCLEI_SEVERITIES:
787
+ severity = Severity.from_name(tok)
788
+ break
789
+ template = tokens[0] if tokens else "finding"
790
+ return Rule(f"nuclei-{_slug(template)}", f"nuclei: {template}", severity)
791
+
792
+
793
+ def signals_from_findings(findings_by_tool: dict[str, list[str]]) -> list[Signal]:
794
+ """Scanner output (``{tool: [lines]}``) -> confirmed-finding signals."""
795
+ acc = _Acc()
796
+ for tool, lines in findings_by_tool.items():
797
+ for raw in lines:
798
+ line = raw.strip()
799
+ if not line:
800
+ continue
801
+ host = url_host(_first_url(line))
802
+ if not host:
803
+ continue
804
+ if tool == "dalfox":
805
+ acc.add(host, _DALFOX_RULE, "finding", line)
806
+ else: # nuclei and any nuclei-shaped scanner
807
+ acc.add(host, _nuclei_signal_rule(line), "finding", line)
808
+ return acc.signals()
809
+
810
+
811
+ def scan_signals(
812
+ *,
813
+ ports: list[str],
814
+ urls: list[str],
815
+ hosts: list[str],
816
+ findings: dict[str, list[str]] | None = None,
817
+ ) -> list[Signal]:
818
+ """Run every matcher over the recon inputs and return the flat signal set."""
819
+ signals: list[Signal] = []
820
+ signals += signals_from_ports(ports)
821
+ signals += signals_from_paths(urls)
822
+ signals += signals_from_params(urls)
823
+ signals += signals_from_labels(hosts)
824
+ if findings:
825
+ signals += signals_from_findings(findings)
826
+ return signals