attackmap 0.6.4__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 (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1067 @@
1
+ """Webhook signature verification (#243, part B).
2
+
3
+ A public webhook is only as trustworthy as the check on its payload. For each
4
+ webhook surface this module reads the handler (its span, see
5
+ :mod:`handler_span`), the functions it delegates to (one hop, through
6
+ :class:`authz._HopResolver`) and JS middleware mounted with ``.use(...)`` in
7
+ the same file, and decides how the events are authenticated:
8
+
9
+ - ``verified``: a provider verifier (Stripe ``constructEvent``, Svix / Standard
10
+ Webhooks ``verify``, Twilio ``validateRequest``, Slack's ``SignatureVerifier``,
11
+ an SNS ``MessageValidator``…) or an HMAC checked with a constant-time compare.
12
+ - ``weak_compare``: an HMAC compared with ``==`` / ``===`` / ``bytes.Equal``,
13
+ which leaks how much of a forged signature matched.
14
+ - ``no_replay_guard``: a scheme that signs a timestamp (Slack, Stripe, Svix)
15
+ verified by hand without checking that timestamp, so a captured event can be
16
+ replayed. Schemes without a signed timestamp (GitHub, Shopify, Twilio) aren't
17
+ held to this: they have nothing to check it against.
18
+ - ``absent``: nothing in reach verifies the payload.
19
+ - ``unknown``: the handler couldn't be read, or verification code exists in
20
+ the file that the handler doesn't visibly reach.
21
+
22
+ The result is set on ``AttackSurface.webhook_verification``. The webhook
23
+ findings, the attack path and the detection opportunity read it.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from pathlib import Path
30
+
31
+ from .authz import _block_from, _HopResolver, _read_file_content
32
+ from .handler_span import span_bounds
33
+ from .models import AttackSurface, ScanResult, WebhookVerification
34
+ from .safe_fs import walk_repo
35
+ from .srcpaths import JS_TS_SUFFIXES, split_lines
36
+
37
+ # --- provider verifiers ------------------------------------------------------
38
+
39
+ # A provider SDK call that verifies the signature (and, where the scheme signs
40
+ # one, the timestamp) — the handler needs nothing else.
41
+ _SDK_VERIFIERS: tuple[tuple[str, re.Pattern[str]], ...] = (
42
+ (
43
+ "stripe",
44
+ re.compile(
45
+ r"\bconstructEvent(?:Async)?\s*\(" # stripe-node, stripe-php
46
+ r"|\bWebhook\s*\.\s*construct_event\s*\(" # stripe-python
47
+ r"|\bwebhook\s*\.\s*ConstructEvent\w*\s*\(" # stripe-go
48
+ r"|\bWebhookSignature\s*(?:::|\.)\s*verify_?[Hh]eader\s*\(" # stripe-php, stripe-python
49
+ ),
50
+ ),
51
+ (
52
+ "github",
53
+ re.compile(r"\.\s*verifyAndReceive\s*\(|\bwebhooks\s*\.\s*verify\s*\(|\bcreateNodeMiddleware\s*\(\s*webhooks\b"),
54
+ ),
55
+ (
56
+ "slack",
57
+ re.compile(
58
+ r"\bSignatureVerifier\s*\(" # slack_sdk
59
+ r"|\.\s*is_valid_request\s*\("
60
+ r"|\bNewSecretsVerifier\s*\(" # slack-go
61
+ r"|\bverifySlackRequest\s*\(" # @slack/bolt helper
62
+ ),
63
+ ),
64
+ (
65
+ "twilio",
66
+ re.compile(
67
+ r"\bvalidateRequest(?:WithBody)?\s*\("
68
+ r"|\btwilio\s*\.\s*webhook\s*\("
69
+ r"|\bRequestValidator\s*\("
70
+ r"|\bvalidate_twilio_request\b"
71
+ ),
72
+ ),
73
+ ("shopify", re.compile(r"\bwebhooks\s*\.\s*validate\s*\(|\bshopify\w*\s*\.\s*validateHmac\s*\(")),
74
+ ("sns", re.compile(r"\bMessageValidator\s*\(|\bSNSMessageValidator\b|\bvalidate_sns_message\s*\(")),
75
+ )
76
+
77
+ # Verifiers whose call is too generic to trust on its own (`wh.verify(...)`,
78
+ # `validator.validate(...)`): they count only when the library is imported.
79
+ _IMPORTED_VERIFIERS: tuple[tuple[str, re.Pattern[str], re.Pattern[str]], ...] = (
80
+ (
81
+ "svix",
82
+ re.compile(r"""["'](?:svix|standardwebhooks)["']|\bfrom\s+(?:svix|standardwebhooks)\b|\bimport\s+(?:svix|standardwebhooks)\b"""),
83
+ re.compile(r"\.\s*verify\s*\("),
84
+ ),
85
+ ("sns", re.compile(r"""["']sns-validator["']"""), re.compile(r"\.\s*validate\s*\(")),
86
+ )
87
+
88
+ # Provider, by the signature header the handler reads.
89
+ _SIGNATURE_HEADERS: tuple[tuple[str, re.Pattern[str]], ...] = (
90
+ ("github", re.compile(r"x-hub-signature", re.IGNORECASE)),
91
+ ("slack", re.compile(r"x-slack-signature", re.IGNORECASE)),
92
+ ("shopify", re.compile(r"x-shopify-hmac-sha256", re.IGNORECASE)),
93
+ ("stripe", re.compile(r"stripe-signature", re.IGNORECASE)),
94
+ ("svix", re.compile(r"(?:svix|webhook)-signature", re.IGNORECASE)),
95
+ ("twilio", re.compile(r"x-twilio-signature", re.IGNORECASE)),
96
+ ("hmac", re.compile(r"x-[\w-]*signature", re.IGNORECASE)),
97
+ )
98
+
99
+ # Schemes whose signature covers a timestamp the receiver must check.
100
+ _TIMESTAMPED = frozenset({"slack", "stripe", "svix"})
101
+
102
+ # PayPal's verify-webhook-signature API with a `verification_status` check:
103
+ # `unknown` (provider `paypal`) with a note, never `verified` — whether the
104
+ # check rejects the event isn't established (#366).
105
+ _PAYPAL_VERIFY_API = re.compile(r"/v1/notifications/verify-webhook-signature")
106
+ _PAYPAL_STATUS_CHECK = re.compile(r"""verification_status["'\]]?\s*!==?\s*["']SUCCESS["']""")
107
+
108
+ # A shared secret sent in a header (`Authorization: Bearer …`, `X-Webhook-Token`)
109
+ # compared in constant time with the configured secret. Whether the compare's
110
+ # result actually rejects the request can't be established from keywords, so
111
+ # this is `unknown` (provider `shared_secret`) with a note, never `verified` (#366).
112
+ # A secret header *read* into a variable: `const auth = req.headers.get("authorization")`,
113
+ # `$token = $request->bearerToken()`, `token = request.headers["X-Webhook-Token"]`.
114
+ _HEADER_READ = re.compile(
115
+ r"""(?i)(?<![\w$])\$?([A-Za-z_]\w{0,60}+)\s*+(?::\s*+[\w<>\[\] |]{1,60}+)?=(?![=>])\s*+"""
116
+ r"""[^=\n]{0,200}?(?:headers?\s*+(?:\.\s*+get\s*+)?[\[(]\s*+["'](?:authorization|x-[\w-]{0,60}?(?:token|secret|key))["']"""
117
+ r"""|headers\s*+\.\s*+(?:authorization|x_\w{0,60}?(?:token|secret|key))\b|bearerToken\s*+\(\s*+\))"""
118
+ )
119
+ # `token = auth.startsWith("Bearer ") ? auth.slice(7) : ""`: one derived assignment.
120
+ _ASSIGNMENT = re.compile(r"(?<![\w$])\$?([A-Za-z_]\w{0,60}+)\s*+(?::\s*+[\w<>\[\] |]{1,60}+)?=(?![=>])\s*+([^\n]{1,300})")
121
+
122
+ _SHARED_SECRET_NOTE = (
123
+ "a header secret is compared in constant time, but whether a mismatch rejects the request isn't "
124
+ "established; even when it does, a shared secret authenticates the sender, not the payload"
125
+ )
126
+ _PAYPAL_NOTE = (
127
+ "PayPal's verify-webhook-signature API is called and its status checked, but whether a failed "
128
+ "verification rejects the event isn't established"
129
+ )
130
+
131
+ # NestJS `@UseGuards(A, B)`; Laravel `$this->middleware(X::class)` and
132
+ # `'XController@method'` / `[XController::class, 'method']` actions;
133
+ # `x = new Cls(...)` then `x.method(...)`.
134
+ _NEW_INSTANCE = re.compile(r"(?<![\w$])([A-Za-z_$][\w$]*+)\s*+=\s*+new\s++([A-Za-z_$][\w$]*+)\s*+\(")
135
+ _USE_GUARDS = re.compile(r"@UseGuards\(\s*([^)\n]{1,300})\)")
136
+ _JS_IMPORT_ANY = re.compile(r"""import\s+(?:type\s+)?(?P<clause>[^;'"]{1,400}?)\s+from\s+["'](?P<spec>[^"'\n]{1,300})["']""")
137
+ _PHP_MIDDLEWARE = re.compile(r"""\$this\s*->\s*middleware\s*\(\s*(?:\\?([\w\\]{1,200})::class|["']([\w\\]{1,200})["'])""")
138
+ _LARAVEL_ACTION = re.compile(
139
+ r"""["']([A-Z][\w\\]{0,200}Controller)@(\w{1,100})["']|\[\s*\\?([\w\\]{1,200}Controller)::class\s*,\s*["'](\w{1,100})["']\s*\]"""
140
+ )
141
+ _PHP_USE = re.compile(r"^[ \t]*use[ \t]+\\?([\w\\]{1,300}?)(?:[ \t]+as[ \t]+(\w{1,100}))?[ \t]*;", re.MULTILINE)
142
+ _PHP_IF = re.compile(r"\bif\s*\(")
143
+ _MAX_EXTRA_BODIES = 8
144
+ _MAX_PHP_FILES = 20_000
145
+
146
+ # --- manual HMAC -------------------------------------------------------------
147
+
148
+ _HMAC = re.compile(
149
+ r"\bcreateHmac\s*\("
150
+ r"|\bhmac\s*\.\s*(?:new|New|digest)\s*\("
151
+ r"|\bhash_hmac\s*\("
152
+ r"|\bOpenSSL::HMAC\b"
153
+ r"|\bMac\s*\.\s*getInstance\s*\("
154
+ r"|\bcrypto\s*\.\s*subtle\s*\.\s*(?:sign|importKey)\s*\("
155
+ )
156
+ _CONSTANT_TIME = re.compile(
157
+ r"\btimingSafeEqual\s*\("
158
+ r"|\bcompare_digest\s*\("
159
+ r"|\bhmac\s*\.\s*Equal\s*\("
160
+ r"|\bConstantTimeCompare\s*\("
161
+ r"|\bhash_equals\s*\("
162
+ r"|\bMessageDigest\s*\.\s*isEqual\s*\("
163
+ r"|\bsecure_compare\s*\("
164
+ r"|\b(?:safe|secure|constantTime)Compare\s*\("
165
+ # Helpers named for what they do: `timingSafeCompare`, `constantTimeEquals`,
166
+ # `secureCompare`, `safeEqual` (#366).
167
+ r"|\b(?:timing|constantTime|constant_time)?_?[Ss]afe_?(?:Compare|Equals?|Eq|compare|equals?)\s*\("
168
+ r"|\b(?:secure|constantTime|constant_time)_?(?:Compare|Equals?|compare|equals?)\s*\("
169
+ r"|\btsscmp\s*\("
170
+ r"|\bcrypto\s*\.\s*subtle\s*\.\s*verify\s*\("
171
+ )
172
+ # A signature-ish operand on a line.
173
+ _SIG_WORD = re.compile(r"(?i:signature|digest|hmac|expected|computed|calculated)|\bsig(?![a-z])|Sig(?![a-z])")
174
+ # An equality test: `==`, `!=`, `===`, `!==` (not `<=`/`>=`/`=>`/`=`), or a
175
+ # non-constant-time equality call.
176
+ _EQUALITY = re.compile(r"(?<![<>=!])(?:===?|!==?)(?!=)|\bbytes\s*\.\s*Equal\s*\(|\.\s*equals\s*\(|\bstrcmp\s*\(")
177
+ # Presence tests aren't signature comparisons: `sig == null`, `sig !== undefined`,
178
+ # `signature == ""`, `x.length !== y.length`.
179
+ _PRESENCE = re.compile(
180
+ r"[!=]==?\s*(?:null|undefined|None|nil|0)\b|[!=]==?\s*(?:''|\"\")|\blength\s*[!=]==?|\blen\s*\("
181
+ )
182
+
183
+ # --- replay protection -------------------------------------------------------
184
+
185
+ _REPLAY_GUARD = re.compile(
186
+ r"\bDate\s*\.\s*now\s*\("
187
+ r"|\bnew\s+Date\s*\(\s*\)"
188
+ r"|\btime\s*\.\s*time\s*\("
189
+ r"|\btime\s*\.\s*Now\s*\("
190
+ r"|\btime\s*\.\s*Since\s*\("
191
+ r"|\b(?:datetime|timezone)\s*\.\s*(?:now|utcnow)\s*\("
192
+ r"|(?<![\w.$])time\s*\(\s*\)" # PHP time()
193
+ r"|\btolerance\b"
194
+ r"|idempoten"
195
+ r"|x-github-delivery"
196
+ r"|\b(?:processed|seen)_?(?:events?|ids?|deliveries)\b",
197
+ re.IGNORECASE,
198
+ )
199
+
200
+ # A signature over a re-serialized body, not the raw bytes that were signed.
201
+ _RESERIALIZED_BODY = re.compile(r"\bJSON\s*\.\s*stringify\s*\(\s*req\w*\s*\.\s*body\b|\bjson\s*\.\s*dumps\s*\(\s*request\b")
202
+
203
+ # `export { default } from "…"`: the route file only re-exports its handler (#366).
204
+ _REEXPORT_DEFAULT = re.compile(r"""^[ \t]*export[ \t]*\{[ \t]*default\b[^}\n]{0,200}\}[ \t]*from[ \t]*["']([^"'\n]{1,300})["']""", re.MULTILINE)
205
+ # A handler that only answers "not found" / "gone": `res.status(404)`,
206
+ # `return new Response(…, { status: 404 })`, `HttpResponseNotFound()`, `abort(404)`.
207
+ _NOT_FOUND_RESPONSE = re.compile(
208
+ r"\bstatus(?:Code|_code)?\s*(?:\(|=|:)\s*(?:404|410)\b"
209
+ r"|\bsendStatus\s*\(\s*(?:404|410)\b"
210
+ r"|\bHttpResponse(?:NotFound|Gone)\b|\babort\s*\(\s*(?:404|410)\b|\bnotFound\s*\(\s*\)"
211
+ )
212
+ _PACKAGE_NAME = re.compile(r'"name"\s*:\s*"([^"\n]{1,200})"')
213
+ _STUB_NOISE = re.compile(r"^\s*(?:[{}()\[\];,]+|(?:export\s+)?(?:default\s+)?(?:async\s+)?function\b[^{\n]*\{?|(?:async\s+)?def\b.*:|return\s*;?)\s*$")
214
+ _MAX_STUB_LINES = 3
215
+ _STRING_LITERAL = re.compile(r"""'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`""")
216
+ _STUB_DISQUALIFIER = re.compile(
217
+ r"\?|&&|\|\||\bawait\b|\b(?:if|else|elif|for|while|switch|case|try|catch|unless)\b"
218
+ r"|^\s*(?:const|let|var)\b|^\s*[\w.\[\]]+\s*(?<![=!<>])=(?![=>])"
219
+ )
220
+ _CALL_NAME = re.compile(r"(?<![\w$])([A-Za-z_$][\w$]*+)\s*+\(")
221
+ _RESPONSE_CALLS = frozenset(
222
+ {
223
+ "status", "json", "send", "end", "sendStatus", "abort", "notFound", "Response", "NextResponse",
224
+ "HttpResponse", "HttpResponseNotFound", "HttpResponseGone", "JsonResponse", "jsonify", "make_response",
225
+ "type", "set", "header", "setHeader", "writeHead", "response",
226
+ }
227
+ )
228
+ _MAX_SECOND_HOP_FUNCTIONS = 8
229
+ _MAX_WORKSPACE_PACKAGES = 2_000
230
+ _WORKSPACE_SKIP = frozenset({"node_modules", ".git", "dist", "build", ".next", "out", "vendor", "coverage"})
231
+
232
+ # JS middleware mounted for the whole app or a path: `app.use(verifySig)`,
233
+ # `router.use('/webhooks', verifyGithub)`.
234
+ _USE_LINE = re.compile(r"\.\s*use\s*\(")
235
+ _USE_PATH = re.compile(r"""\.\s*use\s*\(\s*["'`]([^"'`]*)["'`]""")
236
+
237
+ _COMMENT_LINE = re.compile(r"^\s*(?://|#|/?\*)")
238
+
239
+ _MUTATING = frozenset({"POST", "PUT", "PATCH", "DELETE", "ANY"})
240
+
241
+
242
+ class _Line:
243
+ __slots__ = ("file", "line", "text")
244
+
245
+ def __init__(self, file: str, line: int, text: str):
246
+ self.file = file
247
+ self.line = line
248
+ self.text = text
249
+
250
+ def where(self) -> str:
251
+ return f"{self.file}:{self.line}"
252
+
253
+
254
+ def _lines_of(file: str, start: int, text: str) -> list[_Line]:
255
+ return [
256
+ _Line(file, start + i, t)
257
+ for i, t in enumerate(split_lines(text))
258
+ if t.strip() and not _COMMENT_LINE.match(t)
259
+ ]
260
+
261
+
262
+ def _first(lines: list[_Line], pattern: re.Pattern[str]) -> _Line | None:
263
+ return next((ln for ln in lines if pattern.search(ln.text)), None)
264
+
265
+
266
+ def _provider_by_header(lines: list[_Line]) -> str | None:
267
+ for name, pattern in _SIGNATURE_HEADERS:
268
+ if _first(lines, pattern):
269
+ return name
270
+ return None
271
+
272
+
273
+ def _weak_compare(lines: list[_Line]) -> _Line | None:
274
+ for ln in lines:
275
+ text = ln.text
276
+ if _EQUALITY.search(text) and _SIG_WORD.search(text) and not _PRESENCE.search(text):
277
+ return ln
278
+ return None
279
+
280
+
281
+ def _shared_secret_check(lines: list[_Line]) -> _Line | None:
282
+ """A constant-time compare whose operands include the value read from a
283
+ secret header (directly or through one assignment)."""
284
+ tracked: dict[str, set[str]] = {}
285
+ for ln in lines:
286
+ match = _HEADER_READ.search(ln.text)
287
+ if match:
288
+ tracked.setdefault(ln.file, set()).add(match.group(1))
289
+ if not tracked:
290
+ return None
291
+ for ln in lines:
292
+ names = tracked.get(ln.file)
293
+ if not names:
294
+ continue
295
+ match = _ASSIGNMENT.search(ln.text)
296
+ if match and any(re.search(rf"(?<![\w$]){re.escape(n)}(?![\w$])", match.group(2)) for n in names):
297
+ names.add(match.group(1))
298
+ for ln in lines:
299
+ names = tracked.get(ln.file)
300
+ call = _CONSTANT_TIME.search(ln.text)
301
+ if not names or call is None:
302
+ continue
303
+ args = ln.text[call.end() : call.end() + 300]
304
+ if any(re.search(rf"(?<![\w$]){re.escape(n)}(?![\w$])", args) for n in names):
305
+ return ln
306
+ return None
307
+
308
+
309
+ # A verifier inside `try` whose `catch`/`except` only logs or is empty: the
310
+ # failure is swallowed and the event processed anyway.
311
+ _TRY = re.compile(r"\btry\b\s*[:{]?\s*$|\btry\s*\{")
312
+ _CATCH = re.compile(r"\bcatch\b|\bexcept\b")
313
+ _CATCH_EXIT = re.compile(
314
+ r"\bthrow\b|\braise\b|\breturn\b|\babort\s*\(|\bexit\s*\(|\bdie\s*\(|\b4\d\d\b|\breject\s*\(|\bnext\s*\(\s*\w"
315
+ )
316
+ _TRY_WINDOW = 25
317
+
318
+
319
+ def _swallowed(lines: list[_Line], hit: _Line) -> bool:
320
+ same = sorted((ln for ln in lines if ln.file == hit.file), key=lambda ln: ln.line)
321
+ if not any(_TRY.search(ln.text) and 0 <= hit.line - ln.line <= _TRY_WINDOW for ln in same):
322
+ return False
323
+ catch = next((ln for ln in same if ln.line > hit.line and ln.line - hit.line <= _TRY_WINDOW and _CATCH.search(ln.text)), None)
324
+ if catch is None:
325
+ return False
326
+ body = []
327
+ for ln in same:
328
+ if ln.line <= catch.line:
329
+ continue
330
+ stripped = ln.text.strip()
331
+ if stripped.startswith("}") or ln.line - catch.line > 8:
332
+ break
333
+ body.append(ln)
334
+ return not any(_CATCH_EXIT.search(ln.text) for ln in [catch, *body])
335
+
336
+
337
+ def classify(lines: list[_Line], *, imported: frozenset[str] = frozenset()) -> WebhookVerification:
338
+ """Decide the verification state from the lines a handler reaches.
339
+ ``imported`` names the providers whose library the handler's files import."""
340
+ for provider, pattern in _SDK_VERIFIERS:
341
+ hit = _first(lines, pattern)
342
+ if hit:
343
+ if _swallowed(lines, hit):
344
+ return WebhookVerification(
345
+ state="unknown",
346
+ provider=provider,
347
+ evidence=hit.where(),
348
+ notes=["the verifier's failure is caught and only logged or ignored, so forged events are processed"],
349
+ )
350
+ return WebhookVerification(state="verified", provider=provider, evidence=hit.where())
351
+ for provider, _, call in _IMPORTED_VERIFIERS:
352
+ hit = _first(lines, call) if provider in imported else None
353
+ if hit:
354
+ return WebhookVerification(state="verified", provider=provider, evidence=hit.where())
355
+
356
+ paypal_api = _first(lines, _PAYPAL_VERIFY_API)
357
+ paypal_check = _first(lines, _PAYPAL_STATUS_CHECK) if paypal_api else None
358
+ if paypal_check is not None:
359
+ return WebhookVerification(
360
+ state="unknown", provider="paypal", evidence=paypal_check.where(), notes=[_PAYPAL_NOTE]
361
+ )
362
+
363
+ hmac = _first(lines, _HMAC)
364
+ header_provider = _provider_by_header(lines)
365
+ if hmac is None:
366
+ shared = _shared_secret_check(lines)
367
+ if shared is not None:
368
+ return WebhookVerification(
369
+ state="unknown", provider="shared_secret", evidence=shared.where(), notes=[_SHARED_SECRET_NOTE]
370
+ )
371
+ if header_provider is not None:
372
+ # The signature is read but handed to something we can't see.
373
+ hit = _first(lines, dict(_SIGNATURE_HEADERS)[header_provider])
374
+ return WebhookVerification(
375
+ state="unknown",
376
+ provider=header_provider,
377
+ evidence=hit.where() if hit else None,
378
+ notes=["the signature header is read, but no verification was found in reach"],
379
+ )
380
+ return WebhookVerification(state="absent")
381
+
382
+ provider = header_provider or "hmac"
383
+ notes: list[str] = []
384
+ reserialized = _first(lines, _RESERIALIZED_BODY)
385
+ if reserialized:
386
+ notes.append(
387
+ f"the signature is computed over a re-serialized body at {reserialized.where()}, "
388
+ "not the raw request bytes; verification will fail on valid events"
389
+ )
390
+ replay_guarded = _first(lines, _REPLAY_GUARD) is not None
391
+ if provider in _TIMESTAMPED and not replay_guarded:
392
+ notes.append("the signed timestamp is never checked against the current time")
393
+
394
+ good = _first(lines, _CONSTANT_TIME)
395
+ if good is None:
396
+ weak = _weak_compare(lines)
397
+ if weak is not None:
398
+ return WebhookVerification(state="weak_compare", provider=provider, evidence=weak.where(), notes=notes)
399
+ return WebhookVerification(
400
+ state="unknown",
401
+ provider=provider,
402
+ evidence=hmac.where(),
403
+ notes=[*notes, "an HMAC is computed, but no comparison was found in reach"],
404
+ )
405
+ if provider in _TIMESTAMPED and not replay_guarded:
406
+ return WebhookVerification(state="no_replay_guard", provider=provider, evidence=good.where(), notes=notes)
407
+ return WebhookVerification(state="verified", provider=provider, evidence=good.where(), notes=notes)
408
+
409
+
410
+ class _Assessor:
411
+ def __init__(self, scan: ScanResult, root: Path):
412
+ self.root = root
413
+ self.cache: dict[str, str | None] = {}
414
+ self.hop = _HopResolver(root, self.cache)
415
+ self._packages: dict[str, str] | None = None
416
+ self._php_index: dict[str, list[str]] | None = None
417
+ self.route_lines: dict[str, list[int]] = {}
418
+ for r in scan.routes:
419
+ if r.line is not None:
420
+ self.route_lines.setdefault(r.file, []).append(r.line)
421
+
422
+ def _resolve_spec(self, rel_file: str, spec: str) -> str | None:
423
+ """A relative specifier, or a workspace package (`@scope/pkg/sub/path`)
424
+ found by the `name` in its package.json."""
425
+ base = Path(rel_file).parent
426
+ if spec.startswith("."):
427
+ return self.hop._js_module(base, spec)
428
+ packages = self._workspace_packages()
429
+ parts = spec.split("/")
430
+ for cut in range(min(len(parts), 3), 0, -1):
431
+ pkg_dir = packages.get("/".join(parts[:cut]))
432
+ if pkg_dir is not None:
433
+ rest = "/".join(parts[cut:]) or "index"
434
+ return self.hop._js_module(Path(pkg_dir), "./" + rest)
435
+ return None
436
+
437
+ def _workspace_packages(self) -> dict[str, str]:
438
+ """Workspace package name → its directory (repo-relative), from the
439
+ repo's own package.json files (bounded; node_modules skipped)."""
440
+ if self._packages is not None:
441
+ return self._packages
442
+ packages: dict[str, str] = {}
443
+ seen = 0
444
+ for path in walk_repo(self.root, prune=_WORKSPACE_SKIP.__contains__):
445
+ if path.name != "package.json":
446
+ continue
447
+ seen += 1
448
+ if seen > _MAX_WORKSPACE_PACKAGES:
449
+ break
450
+ rel = path.relative_to(self.root).as_posix()
451
+ text = _read_file_content(rel, self.root, self.cache) or ""
452
+ match = _PACKAGE_NAME.search(text[:4096])
453
+ if match:
454
+ packages.setdefault(match.group(1), str(Path(rel).parent))
455
+ self._packages = packages
456
+ return packages
457
+
458
+ # -- framework reach (#366 part 2) -----------------------------------
459
+
460
+ def _framework_reach(
461
+ self,
462
+ rel: str,
463
+ content: str,
464
+ file_lines: list[str],
465
+ start: int,
466
+ end: int,
467
+ handler: str,
468
+ located: list[tuple[str, int, str]],
469
+ notes: list[str],
470
+ reexported: list[tuple[str, str]] = (), # type: ignore[assignment]
471
+ ) -> list[tuple[str, int, str]]:
472
+ out: list[tuple[str, int, str]] = []
473
+ suffix = Path(rel).suffix.lower()
474
+ if suffix in JS_TS_SUFFIXES:
475
+ # NestJS guards on the handler or on its controller class.
476
+ guard_text = handler + "\n" + "\n".join(self._class_decorators(file_lines, start))
477
+ for match in _USE_GUARDS.finditer(guard_text):
478
+ for name in re.findall(r"[A-Za-z_$][\w$]*", match.group(1)):
479
+ if name == "new":
480
+ continue
481
+ module = self._js_symbol_module(rel, content, name)
482
+ member = self._class_member(module, name, "canActivate") if module else None
483
+ if member:
484
+ out.append(member)
485
+ # The handler, a module it re-exports, and what they reached.
486
+ texts = [(rel, content, handler)] + [(f, src, src) for f, src in reexported] + [
487
+ (f, _read_file_content(f, self.root, self.cache) or "", body) for f, _, body in located[:_MAX_EXTRA_BODIES]
488
+ ]
489
+ for file, source, text in texts:
490
+ # Functions imported from a workspace package (`@scope/pkg/lib/x`),
491
+ # which the relative-only hop resolver doesn't follow.
492
+ for match in _JS_IMPORT_ANY.finditer(source):
493
+ if match.group("spec").startswith("."):
494
+ continue
495
+ for name in re.findall(r"[A-Za-z_$][\w$]*", match.group("clause").replace(" as ", " ")):
496
+ if name in {"type", "as"} or not re.search(rf"(?<![\w$.]){re.escape(name)}\s*\(", text):
497
+ continue
498
+ module = self._resolve_spec(file, match.group("spec"))
499
+ body = self.hop._definition(module, name) if module else ""
500
+ if body:
501
+ out.append((module, self.hop._defs[module][1][name] + 1, body))
502
+ # `x = new Cls(...)` then `x.method(...)`: the method's own body
503
+ # (a long class is cut off by the hop's line cap).
504
+ for var, cls in _NEW_INSTANCE.findall(text):
505
+ for method in sorted(set(re.findall(rf"(?<![\w$]){re.escape(var)}\s*\.\s*([A-Za-z_$][\w$]*)\s*\(", text))):
506
+ module = self._js_symbol_module(file, source, cls)
507
+ member = self._class_member(module, cls, method) if module else None
508
+ if member:
509
+ out.append(member)
510
+ elif suffix == ".php":
511
+ out.extend(self._laravel_reach(handler, notes))
512
+ return out
513
+
514
+ @staticmethod
515
+ def _class_decorators(file_lines: list[str], start: int) -> list[str]:
516
+ """The decorator lines right above the class declaring the handler."""
517
+ for i in range(start - 1, -1, -1):
518
+ if re.match(r"\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s", file_lines[i]):
519
+ decorators = []
520
+ j = i - 1
521
+ while j >= 0 and i - j <= 20:
522
+ line = file_lines[j].strip()
523
+ if not line or line.startswith(("@", "}", ")", "{", "//", "*", "/*")) or line.endswith((",", "(", "{")):
524
+ decorators.append(file_lines[j])
525
+ j -= 1
526
+ continue
527
+ break
528
+ return decorators
529
+ return []
530
+
531
+ def _js_symbol_module(self, rel: str, content: str, name: str) -> str | None:
532
+ target = self.hop._import_map(rel, content).get(name)
533
+ if target:
534
+ return target
535
+ for match in _JS_IMPORT_ANY.finditer(content):
536
+ if re.search(rf"(?<![\w$]){re.escape(name)}(?![\w$])", match.group("clause")):
537
+ return self._resolve_spec(rel, match.group("spec"))
538
+ return None
539
+
540
+ def _class_member(self, rel: str | None, cls: str, member: str) -> tuple[str, int, str] | None:
541
+ """``(file, 1-based line, body)`` of ``cls.member`` declared in ``rel``."""
542
+ if not rel:
543
+ return None
544
+ source = _read_file_content(rel, self.root, self.cache)
545
+ if not source:
546
+ return None
547
+ lines = split_lines(source)
548
+ decl = re.compile(rf"^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+|final\s+)?class\s+{re.escape(cls)}\b")
549
+ head = re.compile(
550
+ rf"^\s*(?:(?:public|private|protected|static|async|override|readonly|final)\s+)*(?:function\s+)?{re.escape(member)}\s*\("
551
+ )
552
+ start = next((i for i, line in enumerate(lines) if decl.match(line)), None)
553
+ if start is None:
554
+ return None
555
+ for i in range(start + 1, min(len(lines), start + 2000)):
556
+ if decl.match(lines[i]) is None and re.match(r"\s*(?:export\s+)?(?:abstract\s+|final\s+)?class\s", lines[i]):
557
+ return None # the next class: the member isn't in this one
558
+ if head.match(lines[i]):
559
+ return rel, i + 1, _block_from(lines, i)
560
+ return None
561
+
562
+ def _laravel_reach(self, handler: str, notes: list[str]) -> list[tuple[str, int, str]]:
563
+ """`Route::post('webhook', 'WebhookController@handleWebhook')`: the action,
564
+ and the `handle` of every middleware the controller's constructor adds."""
565
+ out: list[tuple[str, int, str]] = []
566
+ for match in _LARAVEL_ACTION.finditer(handler):
567
+ fqcn, method = (match.group(1), match.group(2)) if match.group(1) else (match.group(3), match.group(4))
568
+ cls = fqcn.rsplit("\\", 1)[-1]
569
+ rel = self._php_class_file(cls)
570
+ if rel is None:
571
+ continue
572
+ action = self._class_member(rel, cls, method)
573
+ if action:
574
+ out.append(action)
575
+ ctor = self._class_member(rel, cls, "__construct")
576
+ if ctor is None:
577
+ continue
578
+ out.append(ctor)
579
+ source = _read_file_content(rel, self.root, self.cache) or ""
580
+ uses = {(alias or name.rsplit("\\", 1)[-1]): name for name, alias in _PHP_USE.findall(source)}
581
+ body_lines = ctor[2].splitlines()
582
+ for k, line in enumerate(body_lines):
583
+ mw = _PHP_MIDDLEWARE.search(line)
584
+ if mw is None:
585
+ continue
586
+ statement = " ".join(body_lines[k : k + 4]).split(";", 1)[0]
587
+ if not _applies_to(statement, method):
588
+ continue # `->only([...])` / `->except([...])` exclude this action
589
+ ref = mw.group(1) or mw.group(2)
590
+ short = ref.rsplit("\\", 1)[-1]
591
+ mw_cls = uses.get(short, ref).rsplit("\\", 1)[-1]
592
+ mw_rel = self._php_class_file(mw_cls)
593
+ handle = self._class_member(mw_rel, mw_cls, "handle") if mw_rel else None
594
+ if handle is None:
595
+ continue
596
+ out.append(handle)
597
+ at_statement_start = line.strip().startswith("$this")
598
+ if not at_statement_start or any(_PHP_IF.search(prev) for prev in body_lines[:k]):
599
+ notes.append(
600
+ f"the verifying middleware {mw_cls} is added only when a condition holds "
601
+ f"({rel}:{ctor[1]}); verification isn't guaranteed"
602
+ )
603
+ break
604
+ return out
605
+
606
+ def _php_class_file(self, cls: str) -> str | None: # noqa: D401
607
+ """The repo file declaring PHP class ``cls`` (PSR-4: `Cls.php`), skipping vendor/."""
608
+ if self._php_index is None:
609
+ index: dict[str, list[str]] = {}
610
+ seen = 0
611
+ for path in walk_repo(self.root, prune=_WORKSPACE_SKIP.__contains__):
612
+ if path.suffix != ".php":
613
+ continue
614
+ seen += 1
615
+ if seen > _MAX_PHP_FILES:
616
+ break
617
+ index.setdefault(path.stem, []).append(path.relative_to(self.root).as_posix())
618
+ self._php_index = index
619
+ decl = re.compile(rf"^\s*(?:abstract\s+|final\s+)?class\s+{re.escape(cls)}\b", re.MULTILINE)
620
+ for rel in sorted(self._php_index.get(cls, []), key=lambda r: ("test" in r.lower(), len(r))):
621
+ source = _read_file_content(rel, self.root, self.cache) or ""
622
+ if decl.search(source):
623
+ return rel
624
+ return None
625
+
626
+ def assess(self, surface: AttackSurface) -> WebhookVerification:
627
+ content = _read_file_content(surface.file, self.root, self.cache)
628
+ if not content:
629
+ return WebhookVerification(state="unknown", notes=["the handler's source could not be read"])
630
+ file_lines = split_lines(content)
631
+ if surface.line is not None:
632
+ start, end = span_bounds(surface.line, file_lines, self.route_lines.get(surface.file, []))
633
+ else:
634
+ start, end = 0, len(file_lines)
635
+ handler = "\n".join(file_lines[start:end])
636
+ if _is_not_found_stub(handler):
637
+ return WebhookVerification(
638
+ state="absent",
639
+ evidence=f"{surface.file}:{start + 1}",
640
+ notes=["the handler only answers 404/410; nothing is processed"],
641
+ inert=True,
642
+ )
643
+ reach = _lines_of(surface.file, start + 1, handler)
644
+ located: list[tuple[str, int, str]] = []
645
+ reexported: list[tuple[str, str]] = []
646
+ self.hop.delegated_text(surface.file, content, handler, located)
647
+ if Path(surface.file).suffix.lower() in JS_TS_SUFFIXES:
648
+ # The route file only re-exports its handler: assess the module it names.
649
+ for match in _REEXPORT_DEFAULT.finditer(content):
650
+ target = self._resolve_spec(surface.file, match.group(1))
651
+ target_content = _read_file_content(target, self.root, self.cache) if target else None
652
+ if not target or not target_content:
653
+ continue
654
+ reach.extend(_lines_of(target, 1, target_content))
655
+ self.hop.delegated_text(target, target_content, target_content, located)
656
+ reexported.append((target, target_content))
657
+ break
658
+ if Path(surface.file).suffix.lower() in JS_TS_SUFFIXES:
659
+ for i, text in enumerate(file_lines):
660
+ if (start <= i < end) or not _USE_LINE.search(text) or _COMMENT_LINE.match(text):
661
+ continue
662
+ mount = _USE_PATH.search(text)
663
+ if mount and not _mount_covers(mount.group(1), surface.route):
664
+ continue
665
+ reach.append(_Line(surface.file, i + 1, text))
666
+ self.hop.delegated_text(surface.file, content, text, located)
667
+ notes: list[str] = []
668
+ extra = self._framework_reach(
669
+ surface.file, content, file_lines, start, end, handler, located, notes, reexported
670
+ )
671
+ located.extend(extra[:_MAX_EXTRA_BODIES])
672
+ # A second hop: verification usually sits in a helper the middleware
673
+ # or controller calls (`webhookAuth` → `validateSignature`) (#366).
674
+ # Bounded to _MAX_SECOND_HOP_FUNCTIONS functions in total.
675
+ first_hop = list(located)
676
+ limit = len(located) + _MAX_SECOND_HOP_FUNCTIONS
677
+ for file, _line, body in first_hop:
678
+ if len(located) >= limit:
679
+ break
680
+ source = _read_file_content(file, self.root, self.cache)
681
+ if source:
682
+ self.hop.delegated_text(file, source, body, located)
683
+ del located[limit:]
684
+ seen_bodies: set[tuple[str, int]] = set()
685
+ for file, line, body in located:
686
+ if (file, line) in seen_bodies:
687
+ continue
688
+ seen_bodies.add((file, line))
689
+ reach.extend(_lines_of(file, line, body))
690
+
691
+ sources = [content, *(self.cache.get(f) or "" for f in {f for f, _, _ in located})]
692
+ imported = frozenset(
693
+ provider for provider, lib, _ in _IMPORTED_VERIFIERS if any(lib.search(text) for text in sources)
694
+ )
695
+ result = classify(reach, imported=imported)
696
+ if result.state == "verified" and notes:
697
+ # Verification applied only when something is configured: not guaranteed.
698
+ return WebhookVerification(
699
+ state="unknown", provider=result.provider, evidence=result.evidence, notes=[*result.notes, *notes]
700
+ )
701
+ if result.state == "absent":
702
+ # Verification code elsewhere in the file that we can't connect to
703
+ # this handler (a decorator, a framework hook): don't call it absent.
704
+ whole = _lines_of(surface.file, 1, content)
705
+ if classify(whole, imported=imported).state != "absent":
706
+ return WebhookVerification(
707
+ state="unknown",
708
+ notes=["verification code exists in the file but the handler doesn't visibly reach it"],
709
+ )
710
+ where = f"{surface.file}:{surface.line}" if surface.line is not None else surface.file
711
+ result.evidence = where
712
+ return result
713
+
714
+
715
+ def _is_not_found_stub(handler: str) -> bool:
716
+ """A handler whose every statement is a 404/410 response. One that also
717
+ looks something up, assigns or awaits (and answers 404 only on a miss) is
718
+ a live receiver."""
719
+ code = [
720
+ ln
721
+ for ln in handler.splitlines()
722
+ if ln.strip() and not _COMMENT_LINE.match(ln) and not _STUB_NOISE.match(ln)
723
+ ]
724
+ return 0 < len(code) <= _MAX_STUB_LINES and all(_pure_not_found(ln) for ln in code)
725
+
726
+
727
+ def _pure_not_found(line: str) -> bool:
728
+ """`res.status(404).json({...})`, `return new Response(null, { status: 404 })`,
729
+ `return HttpResponseNotFound()`: a 404/410 response and nothing else — no
730
+ condition, `await`, `?`, `&&`/`||`, assignment, or call other than the response."""
731
+ if not _NOT_FOUND_RESPONSE.search(line):
732
+ return False
733
+ code = _STRING_LITERAL.sub('""', line)
734
+ if _STUB_DISQUALIFIER.search(code):
735
+ return False
736
+ return all(name in _RESPONSE_CALLS for name in _CALL_NAME.findall(code))
737
+
738
+
739
+ # --- inbound receivers vs. webhook management (#365) -------------------------
740
+ #
741
+ # A path containing `webhook` is usually an inbound receiver, but apps that send
742
+ # webhooks also expose CRUD for their own webhook configuration
743
+ # (`/settings/webhooks`, `/v2/webhooks/:webhookId`, a DRF `WebhookViewSet`).
744
+ # Those are ordinary application routes, not receivers of provider events.
745
+ # Precision first: a route is management only on a structural signal, and a
746
+ # candidate whose handler reaches signature verification stays a receiver.
747
+
748
+ _WEBHOOK_SEGMENT = re.compile(r"web-?hooks?", re.IGNORECASE)
749
+ # A route parameter in any framework's syntax: `{id}`, `:id`, `<int:pk>`, `[id]`, `(?P<id>…)`.
750
+ _PARAM_SEGMENT = re.compile(r"^(?:\{([^{}]*)\}|:([\w-]+)\??|<(?:[\w]+:)?([^<>]*)>|\[{1,2}(?:\.\.\.)?([^\[\]]*)\]{1,2}|\(\?P<([\w]+)>.*\))$")
751
+ # Parameter names that identify a stored record (a webhook's own id), as opposed
752
+ # to a provider name (`:provider`, `[source]`) that routes inbound events.
753
+ _ID_PARAM = re.compile(r"^(?:id|pk|uuid|hook|webhook|webhook_?id|webhook_?uuid|\w*(?:Id|_id|ID|Uuid|_uuid|_pk))$")
754
+ # An id that scopes a tenant, not a stored webhook: `/webhooks/:accountId` may
755
+ # route a provider's events to a tenant.
756
+ _TENANT_PARAM = re.compile(
757
+ r"^(?:account|tenant|org|organization|organisation|workspace|team|project|user|customer|shop|store|site"
758
+ r"|merchant|app|application|client|company|integration|installation|instance|space|channel|provider|source)"
759
+ r"_?(?:id|uuid|key|slug)?$",
760
+ re.IGNORECASE,
761
+ )
762
+ # Guards that are a user or session login, as opposed to a shared secret,
763
+ # signature or token header a plugin may also declare `required`.
764
+ _USER_GUARD = re.compile(r"(?i)session|login|user|jwt|permission|role|admin|member|apiauth|authenticated|isauth|auth_?required|login_?required")
765
+ _SECRET_GUARD = re.compile(r"(?i)webhook|signature|secret|hmac|token|api_?key|svix|stripe|github|slack|twilio|shopify|verif|sig\b|basic")
766
+ # A receiver's subscription handshake: Meta/WhatsApp `hub.challenge`, Twitter `crc_token`.
767
+ _HANDSHAKE = re.compile(r"hub[._]challenge|hub[._]mode|hub[._]verify_token|crc_token")
768
+ _ID_CONVERTERS = re.compile(r"^<(?:int|uuid|pk):", re.IGNORECASE)
769
+ # Words that, fused with `webhook` in one segment, name a management page or
770
+ # sub-resource: `add_webhook`, `webhooklogs`, `edit-webhook`.
771
+ # Fused with `webhook` in one segment, only words that can't be an inbound
772
+ # event name: `webhooklogs`, `add_webhook`, `webhook_settings`, `edit-webhook`.
773
+ # Event-ish words (`subscription-webhook`, `new-order-webhook`,
774
+ # `update-webhook`, `delivery_webhook`) stay receivers, and a POST never counts.
775
+ _MANAGEMENT_WORDS = frozenset({"add", "edit", "delete", "list", "log", "logs", "setting", "settings"})
776
+ # Actions on a stored webhook (`POST /webhooks/:id/test`). A POST to
777
+ # `/webhooks/:id[/:token]` without one may be an incoming-webhook receiver
778
+ # (Discord/Slack style, authenticated by the id/token in the URL), so it isn't
779
+ # management on the id alone.
780
+ _MANAGEMENT_ACTIONS = frozenset(
781
+ {
782
+ "test", "ping", "redeliver", "redeliveries", "deliveries", "logs", "rotate", "secret", "enable",
783
+ "disable", "edit", "delete", "retry", "resend", "links", "folders", "attempts", "history",
784
+ }
785
+ )
786
+ _READ_WRITE_METHODS = frozenset({"GET", "HEAD", "PUT", "PATCH", "DELETE"})
787
+ _SETTINGS_SEGMENTS = frozenset({"settings", "preferences"})
788
+ # CRUD actions in a settings path: `POST /settings/webhooks/create`.
789
+ _CRUD_ACTIONS = frozenset({"create", "new", "store", "edit", "update", "delete", "destroy"})
790
+ # `router.register(r"webhooks", WebhookViewSet)`: group 1 the prefix, group 2 the ViewSet.
791
+ _DRF_VIEWSET_REGISTRATION = re.compile(
792
+ r"""\.register\(\s*r?["']([^"'\n]{0,200})["']\s*,\s*([\w.]{1,120})"""
793
+ )
794
+ _PY_CLASS_BASES = r"^[ \t]*class[ \t]+{name}[ \t]*\(([^)\n]{{0,400}})\)"
795
+ # A POST that creates a stored webhook: the handler persists a target URL with
796
+ # a secret or event list (`subscriberUrl`, `payload_url`, `events`, `secret`).
797
+ _CONFIG_URL_FIELD = re.compile(r"(?i:subscriber_?url|payload_?url|target_?url|callback_?url|endpoint_?url)|\burl\s*[:=]")
798
+ _CONFIG_OTHER_FIELD = re.compile(r"\b(?:secret|events|event_?types|eventTriggers|active)\b")
799
+ _PERSIST_CALL = re.compile(r"\b(?:create|save|insert|upsert|persist|add)\w{0,40}\s*\(|\.objects\.create\s*\(")
800
+
801
+
802
+ def _segments(path: str) -> list[str]:
803
+ return [seg for seg in path.split("?", 1)[0].strip("/").split("/") if seg]
804
+
805
+
806
+ def _param_name(segment: str) -> str | None:
807
+ match = _PARAM_SEGMENT.match(segment)
808
+ if match is None:
809
+ return None
810
+ return next((g for g in match.groups() if g is not None), "")
811
+
812
+
813
+ def _is_id_param(segment: str) -> bool:
814
+ name = _param_name(segment)
815
+ if name is None:
816
+ return False
817
+ bare = name.split(":", 1)[-1].strip()
818
+ if _TENANT_PARAM.match(bare):
819
+ return False
820
+ if _ID_CONVERTERS.match(segment):
821
+ return True
822
+ return _ID_PARAM.match(bare) is not None
823
+
824
+
825
+ def _drf_model_viewset(base: Path, cache: dict[str, str | None], hop: _HopResolver, route, line: str) -> bool: # noqa: ANN001
826
+ """`router.register('webhooks', WebhookViewSet)` where the prefix names the
827
+ webhook collection itself (not a provider: `webhooks/stripe`) and the
828
+ ViewSet is model CRUD (`ModelViewSet`, `NetBoxModelViewSet`, `ModelCrudViewSet`)."""
829
+ match = _DRF_VIEWSET_REGISTRATION.search(line)
830
+ if match is None:
831
+ return False
832
+ prefix = [seg.lower() for seg in match.group(1).strip("^$/").split("/") if seg]
833
+ if not prefix or not _WEBHOOK_SEGMENT.fullmatch(prefix[-1].replace("logs", "").replace("log", "") or "x"):
834
+ return False
835
+ ref = match.group(2)
836
+ module_name, _, class_name = ref.rpartition(".")
837
+ content = _read_file_content(route.file, base, cache) or ""
838
+ candidates = [route.file]
839
+ imports = hop._import_map(route.file, content)
840
+ if module_name and module_name in imports:
841
+ candidates = [imports[module_name]]
842
+ elif class_name in imports:
843
+ candidates = [imports[class_name], route.file]
844
+ pattern = re.compile(_PY_CLASS_BASES.format(name=re.escape(class_name)), re.MULTILINE)
845
+ for module in candidates:
846
+ source = _read_file_content(module, base, cache) or ""
847
+ found = pattern.search(source)
848
+ if found:
849
+ bases = [b.strip().rsplit(".", 1)[-1] for b in found.group(1).split(",")]
850
+ return any("Model" in b and b.endswith("ViewSet") for b in bases)
851
+ return False
852
+
853
+
854
+ def _handler_text(base: Path, cache: dict[str, str | None], route, route_lines: list[int]) -> str: # noqa: ANN001
855
+ content = _read_file_content(route.file, base, cache) or ""
856
+ lines = split_lines(content)
857
+ if route.line is None or not lines:
858
+ return ""
859
+ start, end = span_bounds(route.line, lines, route_lines)
860
+ return "\n".join(lines[start:end])
861
+
862
+
863
+ def _persists_webhook_config(text: str) -> bool:
864
+ return bool(_CONFIG_URL_FIELD.search(text) and _CONFIG_OTHER_FIELD.search(text) and _PERSIST_CALL.search(text))
865
+
866
+
867
+ def _fused_management_word(segment: str) -> bool:
868
+ rest = _WEBHOOK_SEGMENT.sub(" ", segment.lower())
869
+ words = [w for w in re.split(r"[\s_\-.]+", rest) if w]
870
+ return bool(words) and any(w in _MANAGEMENT_WORDS for w in words)
871
+
872
+
873
+ def _member_management(method: str, segs: list[str], hook: int, *, collection_known: bool) -> str | None:
874
+ """`/…/webhooks/:webhookId[/…]`: a stored webhook addressed by id.
875
+ ``"strong"`` when it is read or deleted (GET/HEAD/DELETE), acted on
876
+ (`POST …/:id/test`), or a method-less bare member of a known collection;
877
+ ``"weak"`` for a PUT/PATCH, which needs other evidence; else None."""
878
+ if hook + 1 >= len(segs) or not _is_id_param(segs[hook + 1]):
879
+ return None
880
+ tail = [s.lower() for s in segs[hook + 2 :]]
881
+ if any(word in _MANAGEMENT_ACTIONS for word in tail if _param_name(word) is None):
882
+ return "strong"
883
+ if method in {"GET", "HEAD", "DELETE"}:
884
+ return "strong"
885
+ if method in {"PUT", "PATCH"}:
886
+ return "weak"
887
+ return "strong" if method == "ANY" and not tail and collection_known else None
888
+
889
+
890
+ def _user_authenticated(route) -> bool: # noqa: ANN001
891
+ """Required auth by a user/session guard. A plugin may also declare a
892
+ shared-secret or signature check `required`; that proves nothing here."""
893
+ if route.auth != "required" or not route.guards:
894
+ return False
895
+ return all(not _SECRET_GUARD.search(g) for g in route.guards) and any(_USER_GUARD.search(g) for g in route.guards)
896
+
897
+
898
+ def _route_line(root: Path, cache: dict[str, str | None], route) -> str: # noqa: ANN001
899
+ if route.line is None:
900
+ return ""
901
+ content = _read_file_content(route.file, root, cache) or ""
902
+ lines = split_lines(content)
903
+ return lines[route.line - 1] if 0 < route.line <= len(lines) else ""
904
+
905
+
906
+ def webhook_management_routes(scan: ScanResult, root: str | Path | None = None) -> dict[tuple[str, str, str], str]:
907
+ """Routes whose path names webhooks but which manage the app's own webhook
908
+ configuration, keyed ``(method, path, file)`` → the reason."""
909
+ base = Path(root or scan.root).resolve()
910
+ cache: dict[str, str | None] = {}
911
+ out: dict[tuple[str, str, str], str] = {}
912
+ # Collections that have an id-addressed member somewhere: `/v2/webhooks`
913
+ # when `/v2/webhooks/:webhookId` exists.
914
+ collections: set[str] = set()
915
+ # Members (`/webhooks/:id`) that something reads, deletes or acts on.
916
+ members_read: set[str] = set()
917
+ member_reads: list[tuple[str, object]] = []
918
+ parsed = []
919
+ for route in scan.routes:
920
+ segs = _segments(route.path)
921
+ hook = next((i for i, seg in enumerate(segs) if _WEBHOOK_SEGMENT.search(seg)), None)
922
+ parsed.append((route, segs, hook))
923
+ if hook is not None and _member_management(route.method.upper(), segs, hook, collection_known=True) == "strong":
924
+ collections.add("/".join(s.lower() for s in segs[: hook + 1]))
925
+ member_reads.append(("/".join(s.lower() for s in segs[: hook + 2]), route))
926
+ collections_paths = {"/".join(s.lower() for s in segs) for _, segs, hook in parsed if hook is not None and hook + 1 == len(segs)}
927
+ hop = _HopResolver(base, cache)
928
+ route_lines: dict[str, list[int]] = {}
929
+ for r in scan.routes:
930
+ if r.line is not None:
931
+ route_lines.setdefault(r.file, []).append(r.line)
932
+ for member, route in member_reads:
933
+ # A receiver's subscription handshake (`hub.challenge`, `crc_token`)
934
+ # on the member is no evidence that the member is managed.
935
+ if route.method.upper() in {"GET", "HEAD", "ANY"} and _HANDSHAKE.search(
936
+ _handler_text(base, cache, route, route_lines.get(route.file, []))
937
+ ):
938
+ continue
939
+ members_read.add(member)
940
+ for route, segs, hook in parsed:
941
+ if hook is None:
942
+ continue
943
+ method = route.method.upper()
944
+ lowered = [s.lower() for s in segs]
945
+ after = lowered[hook + 1 :]
946
+ authenticated = _user_authenticated(route)
947
+ reason = None
948
+ if any(s in _SETTINGS_SEGMENTS for s in lowered[:hook]) and (
949
+ method in _READ_WRITE_METHODS
950
+ or any(s in _CRUD_ACTIONS for s in after)
951
+ or (after and _is_id_param(segs[hook + 1]))
952
+ or authenticated
953
+ ):
954
+ # `POST /settings/integrations/slack/webhook` stays a receiver.
955
+ reason = "a settings page for the app's webhooks"
956
+ elif "trpc" in lowered[:hook]:
957
+ reason = "a tRPC procedure endpoint"
958
+ elif method != "POST" and _fused_management_word(segs[hook]):
959
+ reason = f"`{segs[hook]}` names a management page"
960
+ elif (
961
+ member := _member_management(
962
+ method, segs, hook, collection_known="/".join(lowered[: hook + 1]) in collections_paths
963
+ )
964
+ ) is not None:
965
+ if member == "strong":
966
+ reason = f"`{segs[hook + 1]}` addresses a stored webhook"
967
+ elif (
968
+ "/".join(lowered[: hook + 2]) in members_read
969
+ or authenticated
970
+ or _persists_webhook_config(_handler_text(base, cache, route, route_lines.get(route.file, [])))
971
+ ):
972
+ # A PUT/PATCH alone isn't management: a provider may PUT events.
973
+ reason = f"`{segs[hook + 1]}` updates a stored webhook"
974
+ elif hook + 1 == len(segs) and "/".join(lowered) in collections:
975
+ # The collection of id-addressed webhooks. Listing it is management;
976
+ # a POST (or a method-less route) may be a receiver beside an
977
+ # event log, so it needs positive evidence.
978
+ if method in _READ_WRITE_METHODS:
979
+ reason = "the collection of a webhook resource with id-addressed members"
980
+ elif authenticated:
981
+ reason = "an authenticated create on a webhook collection"
982
+ elif _persists_webhook_config(_handler_text(base, cache, route, route_lines.get(route.file, []))):
983
+ reason = "a create that persists a webhook's URL and settings"
984
+ if reason is None and _drf_model_viewset(base, cache, hop, route, _route_line(base, cache, route)):
985
+ reason = "a model CRUD ViewSet over the app's webhooks"
986
+ if reason is not None and method in {"GET", "HEAD", "ANY"} and _HANDSHAKE.search(
987
+ _handler_text(base, cache, route, route_lines.get(route.file, []))
988
+ ):
989
+ reason = None # a receiver's subscription handshake (`hub.challenge`, `crc_token`)
990
+ if reason is not None:
991
+ out[(route.method, route.path, route.file)] = reason
992
+ return out
993
+
994
+
995
+ def reclaim_receivers(scan: ScanResult, candidates: list[AttackSurface], root: str | Path | None = None) -> list[AttackSurface]:
996
+ """Of the management candidates, the ones whose handler reaches signature
997
+ verification after all: those are receivers and stay webhooks."""
998
+ if not candidates:
999
+ return []
1000
+ assessor = _Assessor(scan, Path(root or scan.root).resolve())
1001
+ reclaimed = []
1002
+ for surface in candidates:
1003
+ result = assessor.assess(surface)
1004
+ if result.state != "absent" and "the handler's source could not be read" not in result.notes:
1005
+ reclaimed.append(surface)
1006
+ return reclaimed
1007
+
1008
+
1009
+ _PHP_ONLY_EXCEPT = re.compile(r"""->\s*(only|except)\s*\(\s*\[?([^)\]]{0,400})""")
1010
+
1011
+
1012
+ def _applies_to(statement: str, action: str) -> bool:
1013
+ """Laravel `->only(['a'])` / `->except(['b'])` on a `$this->middleware(...)`."""
1014
+ for kind, names in _PHP_ONLY_EXCEPT.findall(statement):
1015
+ listed = set(re.findall(r"""["'](\w+)["']""", names))
1016
+ if kind == "only" and action not in listed:
1017
+ return False
1018
+ if kind == "except" and action in listed:
1019
+ return False
1020
+ return True
1021
+
1022
+
1023
+ def _mount_covers(mount: str, route: str) -> bool:
1024
+ mount = mount.rstrip("/")
1025
+ return not mount or route.startswith(mount) or "webhook" in mount.lower() or "hook" in mount.lower()
1026
+
1027
+
1028
+ def assess_webhook_surfaces(scan: ScanResult, surfaces: list[AttackSurface], root: str | Path | None = None) -> None:
1029
+ """Set ``webhook_verification`` on every webhook surface, in place."""
1030
+ webhooks = [s for s in surfaces if s.category == "webhook"]
1031
+ if not webhooks:
1032
+ return
1033
+ assessor = _Assessor(scan, Path(root or scan.root).resolve())
1034
+ for surface in webhooks:
1035
+ surface.webhook_verification = assessor.assess(surface)
1036
+
1037
+
1038
+ def is_mutating(surface: AttackSurface) -> bool:
1039
+ return surface.method.upper() in _MUTATING
1040
+
1041
+
1042
+ def describe(surface: AttackSurface) -> str:
1043
+ """One evidence line: the route and what its verification state rests on."""
1044
+ v = surface.webhook_verification
1045
+ route = f"{surface.method} {surface.route} in {surface.location()}"
1046
+ if v is None or v.state == "unknown":
1047
+ detail = "; ".join(v.notes) if v is not None and v.notes else "verification could not be determined"
1048
+ return f"{route} — {detail}"
1049
+ provider = f"{v.provider} " if v.provider and v.provider != "hmac" else ""
1050
+ if v.state == "absent":
1051
+ return f"{route} — no signature verification found in the handler"
1052
+ if v.state == "weak_compare":
1053
+ return f"{route} — {provider}HMAC signature compared with a non-constant-time equality at {v.evidence}"
1054
+ if v.state == "no_replay_guard":
1055
+ return f"{route} — {provider}signature verified at {v.evidence}, but the signed timestamp is never checked"
1056
+ return f"{route} — {provider}signature verified at {v.evidence}"
1057
+
1058
+
1059
+ __all__ = [
1060
+ "assess_webhook_surfaces",
1061
+ "classify",
1062
+ "describe",
1063
+ "is_mutating",
1064
+ "reclaim_receivers",
1065
+ "webhook_management_routes",
1066
+ ]
1067
+