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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|