mcp-authscan 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,238 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-authscan
3
+ Version: 0.8.0
4
+ Summary: Static security scanner for self-rolled OAuth/auth failure classes in MCP servers, gateways, and OAuth clients. Rules seeded from live-reproduced, chain-of-custody-anchored VATA findings. SARIF output for GitHub code scanning.
5
+ Author: Leroy H. Mason
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/LHMisme420/mcp-authscan
8
+ Project-URL: Repository, https://github.com/LHMisme420/mcp-authscan
9
+ Project-URL: Dashboard, https://lhmisme420.github.io/VATA-SCORES-0311
10
+ Keywords: mcp,security,oauth,pkce,sast,sarif,scanner,vulnerability
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: Topic :: Security
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Operating System :: OS Independent
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # mcp-authscan
25
+
26
+ ![license](https://img.shields.io/badge/license-MIT-blue)
27
+ ![python](https://img.shields.io/badge/python-3%20%C2%B7%20stdlib%20only-blue)
28
+ ![output](https://img.shields.io/badge/output-SARIF%202.1.0-brightgreen)
29
+ ![findings](https://img.shields.io/badge/findings-anchored%20on--chain-8A2BE2)
30
+
31
+ > Static security scanner for the classic web-auth failures that keep shipping in
32
+ > MCP servers, gateways, and OAuth clients — the boring bugs the AI-native scanners skip.
33
+
34
+ *Built and maintained by **Leroy H. Mason** (RU∞X / VATA) — an independent security
35
+ researcher applying two decades of law-enforcement and risk-management
36
+ chain-of-custody discipline to AI-agent security.*
37
+
38
+ Most MCP security tools chase AI-native failures (tool poisoning, prompt injection,
39
+ rug pulls). `mcp-authscan` targets the plain web-auth failures that keep shipping in
40
+ MCP gateway and OAuth code — authorization servers that skip PKCE, authorization
41
+ codes that replay or never expire, issuer-binding gaps in M2M clients, hardcoded
42
+ admin credentials. **Every rule is seeded from a vulnerability VATA found and
43
+ live-reproduced in a real MCP project.** Stdlib Python 3, zero dependencies.
44
+
45
+ **What makes it different:** every scan emits a SHA-256 over its findings that you can
46
+ anchor to a public blockchain — a tamper-evident, timestamped receipt. Commodity
47
+ scanners give you output; this gives you proof. *Receipts Over Promises.*
48
+
49
+ ## Quick start
50
+
51
+ ```
52
+ git clone https://github.com/LHMisme420/mcp-authscan.git
53
+ python3 mcp-authscan/mcp_authscan.py <path-to-repo> # scan
54
+ python3 mcp-authscan/mcp_authscan.py <path> --sarif # SARIF for GitHub code scanning
55
+ python3 mcp-authscan/mcp_authscan.py <path> --json # emits report_sha256 to anchor
56
+ ```
57
+
58
+ No install step, no dependencies — stdlib Python 3. *(PyPI package coming next.)*
59
+
60
+ ## Findings behind the rules
61
+
62
+ `mcp-authscan` is not theoretical. Its rules encode real vulnerabilities VATA
63
+ discovered, live-reproduced, and disclosed through coordinated channels:
64
+
65
+ - **PraisonAI** — CVE-2026-60085 (GHSA-5r6c-gj4g-r697), unenforced security policy in
66
+ the subprocess sandbox; fixed in 4.6.78, credited to LHMisme420.
67
+ - **B-series rules (PKCE / authorization-code lifecycle)** are seeded from findings
68
+ live-reproduced in the webrix, atrawog, and akshay5995 self-rolled OAuth gateways —
69
+ each anchored in the VATA corpus below.
70
+
71
+ Every finding is reproduced before disclosure and anchored on-chain for
72
+ chain-of-custody. Full corpus → [VATA dashboard](https://lhmisme420.github.io/VATA-SCORES-0311)
73
+ · [Zenodo (DOI, ORCID-linked)](https://doi.org/10.5281/zenodo.21839413)
74
+
75
+ ## Rules
76
+
77
+ | ID | Sev / Conf | Class | Type |
78
+ |----|-----------|-------|------|
79
+ | A1 | CRITICAL / HIGH | OAuth authorize handler reads `client_id`/`redirect_uri` but never validates against an allowlist (auth-bypass / open-redirect / ATO) | detector |
80
+ | A2 | CRITICAL–HIGH | Hardcoded default admin credentials; admin re-seed routines run on restart | detector |
81
+ | A3 | HIGH / MEDIUM | Auth middleware validates a token but enforces no role/tenant/scope (cross-tenant, CWE-863) | detector |
82
+ | A4 | MEDIUM / LOW | SSRF surface: outbound HTTP request to a non-literal, externally-influenced URL with no allowlist / internal-IP guard | review-list |
83
+ | A5 | LOW | `excluded_tools`/blocklist defined but enforcement unverified across surfaces | review pointer |
84
+ | C1 | HIGH / HIGH (MEDIUM when options are spread) | M2M MCP client provider built without an issuer binding: Python `ClientCredentialsOAuthProvider`/`PrivateKeyJWTOAuthProvider` without `issuer=`, TS `ClientCredentialsProvider`/`PrivateKeyJwtProvider`/`StaticPrivateKeyJwtProvider`/`CrossAppAccessProvider` without `expectedIssuer` | detector |
85
+ | C2 | HIGH / HIGH | Deprecated Python `RFC7523OAuthClientProvider` — has no issuer option, cannot be bound by config | detector |
86
+ | C3 | HIGH / MEDIUM | Issuer validation switched off (`skipIssuerMetadataValidation: true`, `verify_iss: False`, etc.) | detector |
87
+ | B1 | HIGH / HIGH | Self-rolled authorization server told to skip PKCE (`skipLocalPkceValidation: true`) with nothing else verifying the challenge (CWE-287) | detector |
88
+ | B1 | MEDIUM / MEDIUM | AS receives a PKCE `code_verifier` as input but no SHA-256/S256 transform appears in the file — verifier never checked against the stored challenge (CWE-287) | review-list |
89
+ | B2 | HIGH / MEDIUM | Authorization code redeemed at the token endpoint but never invalidated (delete/mark-used/revoke) in the same file — possible replay (CWE-294) | review-list |
90
+ | B3 | HIGH / HIGH | Authorization code issued with a lifetime far exceeding RFC 6749's ~600s recommendation (CWE-613) | detector |
91
+
92
+ A1–A3 are detectors: a hit is a finding to triage. A4–A5 are review-lists:
93
+ SSRF and enforcement-gap detection need dataflow, not pattern matching, so these
94
+ enumerate the surface to inspect rather than claiming a bug. Confidence labels
95
+ say which is which — read them.
96
+
97
+ ### Why the C-series exists
98
+
99
+ Upgrading the MCP SDK does not close the client-side issuer gap. The fixed
100
+ versions only enforce issuer binding on M2M providers when the issuer is
101
+ configured; without it, pre-provisioned client secrets and signed JWT
102
+ assertions are sent to whatever authorization server the MCP server
103
+ advertises. Version-based scanners report a pass on code that is still
104
+ exposed. Refs: python-sdk GHSA-qx49-fqc8-xw99; typescript-sdk PR #2887
105
+ (SEP-2352). C1 is a call-site check: options built in another file show up
106
+ as MEDIUM-confidence review items.
107
+
108
+ A runtime proof of the C1/C2 gap - a rogue authorization server harvesting
109
+ the client's credentials across all three M2M shapes, and refusing once
110
+ issuer= is set - is in
111
+ rule_corpus/client_issuer_binding/live_repro/ (--json emits a
112
+ report_sha256 to anchor).
113
+
114
+ ### Why the B-series exists
115
+
116
+ Every self-rolled OAuth 2.1 authorization server VATA has audited that
117
+ hand-rolls RFC 6749 / PKCE has failed at least one of three lifecycle
118
+ invariants; gateways delegating to an established IdP (Keycloak, Cognito, Dex)
119
+ have not. The B-series encodes the three most-repeated failures, seeded from
120
+ live-repro'd findings:
121
+
122
+ - **B1 — PKCE accepted but never enforced.** Either the provider is explicitly
123
+ told to skip local PKCE (`skipLocalPkceValidation: true`, webrix) or a
124
+ `code_verifier` is accepted but never hashed and compared (atrawog, akshay5995).
125
+ - **B2 — authorization code not single-use.** The code is redeemed but never
126
+ invalidated, so it replays (webrix).
127
+ - **B3 — authorization code never expires / excessive TTL.** Codes issued with
128
+ lifetimes orders of magnitude over the ~10-minute recommendation (atrawog, 1yr).
129
+
130
+ Honest limits, stated so findings are not over-claimed:
131
+
132
+ - B1 does **not** detect *optional* PKCE (validated when present, skippable when
133
+ absent) — that is a control-flow property, not a pattern, and is left to manual
134
+ review rather than guessed at.
135
+ - B2 is **same-file**: a server that invalidates its code in a separate storage
136
+ module will be flagged here as a review item (MEDIUM), not asserted as a bug.
137
+ Confirm the storage layer before filing.
138
+ - B3 fires only on an **explicit** excessive number tied to an authorization
139
+ code; expiry that is simply *absent* across a schema (a cross-file absence) is
140
+ not detected.
141
+
142
+ B1-skip and B3 are detectors; B1-verifier and B2 are review-lists, for the same
143
+ reason A4/A5 are — the certain cases are findings, the dataflow-dependent cases
144
+ enumerate surface to inspect. The confidence column says which.
145
+
146
+ ## Usage (all options)
147
+
148
+ ```
149
+ python3 mcp_authscan.py <path> --include-tests # test files skipped by default
150
+ python3 mcp_authscan.py <path> --fail-on high # CI gate: nonzero exit on >= sev
151
+ python3 mcp_authscan.py <path> --exclude DIR # skip a dir (repeatable)
152
+ ```
153
+
154
+ ## Ground truth
155
+
156
+ The rules are validated by re-detecting findings already filed and anchored by
157
+ VATA. A rule that stops re-detecting its seed finding is a regression, not a
158
+ release.
159
+
160
+ | Repo | Rule | Location |
161
+ |------|------|----------|
162
+ | lucky-aeon/mcp-gateway | A1 | `internal/gateway/auth.go` `handleOAuthAuthorize` |
163
+ | lucky-aeon/mcp-gateway | A2 | `internal/platform/config/config.go` defaults |
164
+ | lucky-aeon/mcp-gateway | A3 | `internal/gateway/auth.go` `mcpAuthMiddleware` |
165
+ | mcpjungle/mcpjungle | A4 | `internal/service/mcp/upstream_oauth.go` `RegistrationEndpoint` fetch |
166
+ | rule_corpus fixture | C1–C3 | `rule_corpus/client_issuer_binding` (bound providers must stay silent) |
167
+ | webrix-ai/secure-mcp-gateway | B1, B2 | `src/services/mcp-auth-provider.ts` (`skipLocalPkceValidation`, `exchangeAuthorizationCode`) |
168
+ | atrawog/mcp-oauth-dynamicclient | B1, B3 | `src/mcp_oauth_dynamicclient/routes.py` (1-year auth-code TTL) |
169
+ | akshay5995/mcp-oauth-gateway | B1 | `src/gateway.py` (`code_verifier` received, never validated) |
170
+
171
+ ## Provenance
172
+
173
+ Every scan is anchorable. `--json` emits a `report_sha256` over the findings;
174
+ anchor it to Ethereum before disclosure so the report is timestamped and
175
+ tamper-evident. That chain-of-custody is the differentiator — commodity scanners
176
+ don't give you a receipt.
177
+
178
+ Dashboard: https://lhmisme420.github.io/VATA-SCORES-0311
179
+
180
+ ## Limitations
181
+
182
+ Heuristic static analysis. It flags patterns; it does not prove exploitability.
183
+ A4/A5 are review-lists by design. **Absence of a finding is not proof of safety.**
184
+
185
+ ## GitHub code scanning (SARIF)
186
+
187
+ `mcp-authscan` can emit SARIF 2.1.0 so findings surface natively in a repo's
188
+ **Security → Code scanning** tab. Each rule carries a `security-severity` score
189
+ and its CWE identifier, so GitHub renders severity and taxonomy with no extra
190
+ configuration.
191
+
192
+ Result level follows severity: CRITICAL/HIGH map to `error`, MEDIUM to
193
+ `warning`, LOW to `note` — so review-list (MEDIUM) findings surface as
194
+ warnings, not errors, in the Security tab.
195
+
196
+ ```bash
197
+ # write SARIF to a file
198
+ mcp-authscan . --sarif results.sarif
199
+ ```
200
+
201
+ To scan on every push/PR and upload results, drop this into a consuming repo at
202
+ `.github/workflows/mcp-authscan.yml` (identical copy shipped at
203
+ `examples/github-code-scanning.yml`):
204
+
205
+ ```yaml
206
+ name: MCP auth scan
207
+ on:
208
+ push:
209
+ branches: [main]
210
+ pull_request:
211
+ schedule:
212
+ - cron: '0 6 * * 1'
213
+
214
+ permissions:
215
+ contents: read
216
+ security-events: write
217
+
218
+ jobs:
219
+ mcp-authscan:
220
+ runs-on: ubuntu-latest
221
+ steps:
222
+ - uses: actions/checkout@v4
223
+ - uses: actions/setup-python@v5
224
+ with:
225
+ python-version: '3.12'
226
+ - run: pip install git+https://github.com/LHMisme420/mcp-authscan.git
227
+ - run: mcp-authscan . --sarif results.sarif
228
+ continue-on-error: true
229
+ - if: always()
230
+ uses: github/codeql-action/upload-sarif@v3
231
+ with:
232
+ sarif_file: results.sarif
233
+ category: mcp-authscan
234
+ ```
235
+
236
+ `security-events: write` is mandatory for the upload. `continue-on-error` +
237
+ `if: always()` ensure findings still reach the Security tab when the scan exits
238
+ non-zero on a hit.
@@ -0,0 +1,7 @@
1
+ mcp_authscan.py,sha256=x6BQFsK8ENeEzCEuvK2orJa5DcimPM-yquYO02uHDaA,41194
2
+ mcp_authscan-0.8.0.dist-info/licenses/LICENSE,sha256=XbFtnnGgRasuLTw58Ti-eoLNHJT0TOFugrSqbsH36pA,1071
3
+ mcp_authscan-0.8.0.dist-info/METADATA,sha256=IuncdmyG1ARPnIC2QttUHGz12DOhEfEkvqt6T7GuSPE,11996
4
+ mcp_authscan-0.8.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ mcp_authscan-0.8.0.dist-info/entry_points.txt,sha256=_A4Iq8SBbtq0SkjxPm1RZ2R6MxZ2qp2bUIAxFJ8kiMg,51
6
+ mcp_authscan-0.8.0.dist-info/top_level.txt,sha256=BeetTi0jOtNdjJzQi0wRGhpX--5zDaI6tphUCgW3-bM,13
7
+ mcp_authscan-0.8.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-authscan = mcp_authscan:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leroy H. Mason
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ mcp_authscan
mcp_authscan.py ADDED
@@ -0,0 +1,791 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ VATA mcp_authscan v0.8 -- static detector for self-rolled-auth failure classes in MCP servers
4
+ and issuer-binding gaps in MCP OAuth clients.
5
+ Seeded from filed VATA findings (lucky-aeon, mcpjungle, metamcp).
6
+ Stdlib only. Heuristic static analysis: flags patterns, does not prove exploitability.
7
+ Confidence per rule is stated. Ground-truth test = re-detect your own known findings.
8
+
9
+ v0.8 changes:
10
+ - New client-side rule family C1-C3 (issuer binding on M2M MCP OAuth providers).
11
+ Upgrading the SDK does not close this: Python ClientCredentialsOAuthProvider /
12
+ PrivateKeyJWTOAuthProvider need issuer=, TS ClientCredentialsProvider /
13
+ PrivateKeyJwtProvider / StaticPrivateKeyJwtProvider / CrossAppAccessProvider need
14
+ expectedIssuer, or credentials follow whatever AS the MCP server advertises.
15
+ Refs: GHSA-qx49-fqc8-xw99 (python-sdk), typescript-sdk PR #2887 (SEP-2352).
16
+ - --exclude DIR (repeatable) so rule fixtures don't trip the self-scan gate.
17
+
18
+ v0.4 changes:
19
+ - A4 rebuilt sink-first: finds HTTP calls, inspects the URL argument, flags a non-literal
20
+ URL derived from config/metadata/request reached with no allowlist / internal-IP guard.
21
+ Catches Go http.NewRequest*/client.Do, JS fetch/axios, py requests/urlopen. This is the
22
+ mcpjungle upstream_oauth.go RegistrationEndpoint SSRF class the old A4 missed.
23
+ - A2 value must be whitespace-free (kills the "access token: "+err FP) and test-support
24
+ dirs (testhelpers/mocks/fixtures) are skipped.
25
+ - A5 skips comments and help-text string literals (was flagging CLI help text).
26
+ """
27
+ import argparse, hashlib, json, re, sys
28
+ from pathlib import Path
29
+
30
+ VERSION = "0.8.0"
31
+
32
+ SRC_EXT = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".mjs", ".cjs"}
33
+ SKIP_DIR = {".git", "node_modules", "dist", "build", "vendor", ".venv", "__pycache__"}
34
+ # A scanner must not flag its own rule definitions. Skip our own source file.
35
+ SELF_PATH = __import__("pathlib").Path(__file__).resolve()
36
+ TEST_DIR = {"__tests__", "test", "tests", "e2e", "testdata", "spec",
37
+ "testhelpers", "testhelper", "mocks", "fixtures", "testutil", "testutils"}
38
+ TEST_FILE = re.compile(
39
+ r"(_test\.go$|\.test\.[jt]sx?$|\.spec\.[jt]sx?$|_test\.py$|(^|/)test_[^/]+\.py$|"
40
+ r"(^|/)testhelpers?\.go$|(^|/)mock_[^/]+\.go$|_mock\.go$)", re.I)
41
+
42
+ def is_test_path(p):
43
+ if any(part.lower() in TEST_DIR for part in p.parts):
44
+ return True
45
+ return bool(TEST_FILE.search(p.as_posix()))
46
+
47
+ def iter_files(root, include_tests, exclude=()):
48
+ root = Path(root)
49
+ excl = [ (root / e).resolve() for e in exclude ]
50
+ for p in root.rglob("*"):
51
+ if not (p.is_file() and p.suffix in SRC_EXT):
52
+ continue
53
+ if p.resolve() == SELF_PATH:
54
+ continue
55
+ if any(s in p.parts for s in SKIP_DIR):
56
+ continue
57
+ if not include_tests and is_test_path(p):
58
+ continue
59
+ rp = p.resolve()
60
+ if any(rp == e or e in rp.parents for e in excl):
61
+ continue
62
+ yield p
63
+
64
+ def lineno(text, idx):
65
+ return text.count("\n", 0, idx) + 1
66
+
67
+ def linetext(text, idx):
68
+ lines = text.splitlines()
69
+ return lines[lineno(text, idx) - 1] if lines else ""
70
+
71
+ def snippet(text, idx, span=1):
72
+ lines = text.splitlines()
73
+ ln = lineno(text, idx) - 1
74
+ lo, hi = max(0, ln - span), min(len(lines), ln + span + 1)
75
+ return "\n".join(f" {i+1}: {lines[i]}" for i in range(lo, hi))
76
+
77
+ def defname(head):
78
+ """Pull the identifier from a func/function/def match head (handles Go receiver), or ''."""
79
+ m = re.search(r"(?:func|function|def)\s+(?:\([^)]*\)\s+)?(\w+)", head)
80
+ return m.group(1) if m else ""
81
+
82
+ def brace_block(text, start):
83
+ """Return substring of the { } block beginning at/after start. Fallback: 40-line window."""
84
+ i = text.find("{", start)
85
+ if i == -1:
86
+ lines = text[start:].splitlines()[:40]
87
+ return "\n".join(lines)
88
+ depth, j = 0, i
89
+ while j < len(text):
90
+ if text[j] == "{": depth += 1
91
+ elif text[j] == "}":
92
+ depth -= 1
93
+ if depth == 0: return text[i:j+1]
94
+ j += 1
95
+ return text[i:i+4000]
96
+
97
+ DEF_ANY = re.compile(r"(func\s+(?:\([^)]*\)\s+)?\w+|function\s+\w+|def\s+\w+)")
98
+
99
+ def enclosing_block(text, idx):
100
+ """Best-effort body of the function enclosing idx (Go/JS via braces; else a line window)."""
101
+ last = None
102
+ for m in DEF_ANY.finditer(text):
103
+ if m.start() > idx:
104
+ break
105
+ last = m
106
+ if last is None:
107
+ s = text.rfind("\n", 0, max(0, idx - 1500))
108
+ return text[max(0, s):idx + 400]
109
+ return brace_block(text, last.start())
110
+
111
+ def add(findings, rule, sev, title, path, text, idx, evidence, ref, conf):
112
+ findings.append({
113
+ "rule": rule, "severity": sev, "confidence": conf, "title": title,
114
+ "file": str(path), "line": lineno(text, idx),
115
+ "evidence": evidence, "snippet": snippet(text, idx), "ref": ref,
116
+ })
117
+
118
+ # ---- Rule A1: OAuth authorize endpoint never validates client_id / redirect_uri ----
119
+ # Confidence: HIGH. lucky-aeon handleOAuthAuthorize() class.
120
+ AUTHZ_HANDLER = re.compile(
121
+ r"(function\s+\w*[Aa]uthoriz\w*|"
122
+ r"func\s+(\([^)]*\)\s+)?\w*[Aa]uthoriz\w*|"
123
+ r"def\s+\w*authoriz\w*|"
124
+ r"\w*[Aa]uthoriz\w*\s*[:=]\s*(async\s*)?\()", re.I)
125
+ VALIDATES_CLIENT = re.compile(
126
+ r"client_id[\s\S]{0,160}?(includes|find|lookup|exists|validat|verif|allow|"
127
+ r"registr|db\.|query|SELECT|WHERE|clients?\[)", re.I)
128
+ VALIDATES_REDIRECT = re.compile(
129
+ r"redirect_uri[\s\S]{0,160}?(includes|startsWith|indexOf|allow|whitelist|allowlist|"
130
+ r"validat|verif|\.match|registr|RedirectURIs|registered)", re.I)
131
+
132
+ # A1 server/client discriminators: only an authorization SERVER endpoint is vulnerable.
133
+ # A client doing token exchange legitimately handles redirect_uri (sends it outbound).
134
+ CLIENT_EXCHANGE = re.compile(
135
+ r"(grant_type|token_endpoint|tokenEndpoint|URLSearchParams|postFormToToken|"
136
+ r"new FormData|params\.(set|append)\s*\(\s*['\"]redirect_uri|"
137
+ r"urlencode|authorization_url|authorizationUrl|credentials\[|generate_pkce)", re.I)
138
+ SERVER_AUTHZ_ROLE = re.compile(
139
+ r"(response_type|FormValue|\.Query\(|searchParams|req\.query|request\.query|"
140
+ r"\.Redirect\(|res\.redirect|\.redirect\(|StatusFound|Location|authorization_endpoint)", re.I)
141
+
142
+ def rule_oauth_authorize(path, text, F):
143
+ for m in AUTHZ_HANDLER.finditer(text):
144
+ if defname(m.group(0)).lower().startswith("test"):
145
+ continue
146
+ block = brace_block(text, m.start())
147
+ reads_client = "client_id" in block
148
+ reads_redirect = "redirect_uri" in block
149
+ if not (reads_client or reads_redirect):
150
+ continue
151
+ missing = []
152
+ if reads_client and not VALIDATES_CLIENT.search(block):
153
+ missing.append("client_id")
154
+ if reads_redirect and not VALIDATES_REDIRECT.search(block):
155
+ missing.append("redirect_uri")
156
+ if missing:
157
+ # server-role gate: skip client token-exchange / outbound URL builders;
158
+ # require authorize-server signals. Use a forward window (brace_block on
159
+ # Python grabs an inner dict literal and misses trailing client signals).
160
+ gate = text[m.start():m.start() + 900]
161
+ if CLIENT_EXCHANGE.search(gate) or not SERVER_AUTHZ_ROLE.search(gate):
162
+ continue
163
+ add(F, "A1", "CRITICAL",
164
+ f"OAuth authorize handler reads but never validates {', '.join(missing)}",
165
+ path, text, m.start(),
166
+ f"handler uses {'/'.join(missing)} with no validation/allowlist in block",
167
+ "VATA:lucky-aeon auth-bypass/ATO chain", "HIGH")
168
+
169
+ # ---- Rule A2: hardcoded default admin credentials ----
170
+ # Confidence: HIGH literal creds; MEDIUM re-seed. Value must be whitespace-free (no err-string FPs).
171
+ DEFAULT_CRED = re.compile(
172
+ r"(admin@[\w.-]+\.local|"
173
+ r"(password|passwd|secret|apikey|api_key|token)\s*[:=]\s*['\"][^'\"\s]{6,}['\"])", re.I)
174
+ RESEED = re.compile(r"(UpsertAdmin|seedAdmin|ensureAdmin|createDefaultAdmin|"
175
+ r"(func|def|const|async)\s+\w*[Bb]ootstrap\w*|\.[Bb]ootstrap\s*\()", re.I)
176
+ CRED_CONTEXT = re.compile(r"(admin|account|identity|password|credential|passwd|secret)", re.I)
177
+
178
+ # A2 noise filters: placeholder/example values and doctest/example lines are not real creds.
179
+ CRED_PLACEHOLDER = re.compile(
180
+ r"(\.\.\.|<[^>]+>|\$\{|your[-_ ]?|example|redacted|changeme|xxx+|placeholder|"
181
+ r"dummy|sample|test|doctest|foo|bar|jwt\.token\.here|token\.here|"
182
+ r"^[A-Z][A-Z0-9_]+$|-\d+\.\d+$)", re.I)
183
+
184
+ def _cred_value(evidence):
185
+ """Extract the quoted value from an A2 evidence string, or '' if none."""
186
+ m = re.search(r"['\"]([^'\"]{6,})['\"]", evidence)
187
+ return m.group(1) if m else ""
188
+
189
+ # A dangerous re-seed runs on every startup/restart. A bare setup function
190
+ # (idempotent, first-boot) is not a finding. Require a startup trigger nearby.
191
+ RESEED_TRIGGER = re.compile(r"on_?start|startup|on_?boot|every restart|on_event|lifespan|if __name__|OnInitialize", re.I)
192
+
193
+ def rule_default_creds(path, text, F):
194
+ for m in DEFAULT_CRED.finditer(text):
195
+ ev = m.group(0)
196
+ val = _cred_value(ev)
197
+ line = linetext(text, m.start())
198
+ # skip obvious placeholders/examples and doctest/comment lines
199
+ if val and CRED_PLACEHOLDER.search(val):
200
+ continue
201
+ if re.match(r"\s*(>>>|\.\.\.|#|//|\*)", line):
202
+ continue
203
+ if "pragma: allowlist secret" in line.lower():
204
+ continue
205
+ if any(seg in str(path).lower() for seg in ("/demo", "demo_", "/example", "example_", "/sample", "sample_")):
206
+ continue
207
+ add(F, "A2", "CRITICAL", "Hardcoded default credential literal",
208
+ path, text, m.start(), ev[:80],
209
+ "VATA:lucky-aeon default admin creds", "HIGH")
210
+ for m in RESEED.finditer(text):
211
+ window = text[max(0, m.start()-200):m.start()+200]
212
+ if not CRED_CONTEXT.search(window):
213
+ continue
214
+ if "idempotent" in window.lower():
215
+ continue
216
+ if not RESEED_TRIGGER.search(window):
217
+ continue
218
+ add(F, "A2", "HIGH", "Admin re-seed routine (verify not run every restart)",
219
+ path, text, m.start(), m.group(0),
220
+ "VATA:lucky-aeon UpsertAdmin/Bootstrap on restart", "MEDIUM")
221
+
222
+ # ---- Rule A3: auth MIDDLEWARE validates a token but enforces no role/tenant/scope ----
223
+ # Confidence: MEDIUM. Name must contain "middleware" (metamcp v1AuthMiddleware class).
224
+ MIDDLEWARE = re.compile(
225
+ r"(func\s+(\([^)]*\)\s+)?\w*[Mm]iddleware\w*|"
226
+ r"\w*[Mm]iddleware\w*\s*[:=]\s*(async\s*)?\(|"
227
+ r"def\s+\w*middleware\w*)", re.I)
228
+ AUTHZ_CHECK = re.compile(r"(role|tenant|workspace|scope|permission|rbac|org(_?id)?|is_?admin)", re.I)
229
+ # A3 must read an INBOUND credential to be an authentication gate (not a rate limiter,
230
+ # a handler factory, or a client-side OAuth flow that merely obtains a token).
231
+ INBOUND_AUTH = re.compile(
232
+ r"(Bearer\b|[\"']?[Aa]uthorization[\"']?\s*[)\]:,]|\.[Gg]etHeader\s*\(|"
233
+ r"[Rr]equest\(\)\.Header|req\.[Hh]eaders?|headers\[|bearerToken|parseBearer|"
234
+ r"stripBearer|extractToken|jwt\.(Parse|Verify)|ParseWithClaims|[Vv]alidateToken|"
235
+ r"[Vv]erifyToken|[Ii]ntrospect)", re.I)
236
+
237
+ def rule_middleware_authz(path, text, F):
238
+ for m in MIDDLEWARE.finditer(text):
239
+ if defname(m.group(0)).lower().startswith("test"):
240
+ continue
241
+ block = brace_block(text, m.start())
242
+ reads_inbound_cred = INBOUND_AUTH.search(block)
243
+ if reads_inbound_cred and not AUTHZ_CHECK.search(block):
244
+ add(F, "A3", "HIGH",
245
+ "Auth middleware validates token but enforces no role/tenant/scope",
246
+ path, text, m.start(),
247
+ "token checked; no role/tenant/workspace/scope in block",
248
+ "VATA:metamcp cross-tenant CWE-863", "MEDIUM")
249
+
250
+ # ---- Rule A4: SSRF -- HTTP request to a non-literal, externally-influenced URL, no guard ----
251
+ # Confidence: MEDIUM-LOW. Sink-first: find the HTTP call, inspect its URL argument.
252
+ # Catches mcpjungle upstream_oauth.go RegistrationEndpoint fetch (Go http.NewRequest*/Do).
253
+ # Each sink: (regex, group# holding the URL argument expression).
254
+ HTTP_SINKS = [
255
+ (re.compile(r"http\.NewRequestWithContext\s*\([^,]+,[^,]+,\s*([^,\)]+)", re.I), 1),
256
+ (re.compile(r"http\.NewRequest\s*\([^,]+,\s*([^,\)]+)", re.I), 1),
257
+ (re.compile(r"http\.(?:Get|Post|Head|PostForm)\s*\(\s*([^,\)]+)", re.I), 1),
258
+ (re.compile(r"\bfetch\s*\(\s*([^,\)]+)", re.I), 1),
259
+ (re.compile(r"\baxios(?:\.\w+)?\s*\(\s*([^,\)]+)", re.I), 1),
260
+ (re.compile(r"requests\.(?:get|post|put|delete|request)\s*\(\s*([^,\)]+)", re.I), 1),
261
+ (re.compile(r"(?:urlopen|urllib\.request\.urlopen)\s*\(\s*([^,\)]+)", re.I), 1),
262
+ (re.compile(r"\bgot\s*\(\s*([^,\)]+)", re.I), 1),
263
+ ]
264
+ # URL arg that suggests external influence (config/discovered/request-derived), not a fixed host.
265
+ URL_INFLUENCE = re.compile(
266
+ r"(endpoint|\buri\b|url|issuer|metadata|redirect|registration|jwks|"
267
+ r"server|host|target|resource|upstream|discover|callback|input\.|cfg\.|config\.|req\.|request\.|params|query)", re.I)
268
+ # Guards that make an outbound fetch defensible.
269
+ SSRF_GUARD = re.compile(
270
+ r"(allowlist|allow_list|whitelist|isInternal|is_internal|isPrivate|is_private|"
271
+ r"isLoopback|privateIP|private_ip|blockedHost|blocklist|denylist|deny_list|"
272
+ r"ssrf|ParseIP|net\.IP|isPublic|validateURL|validate_url|assertURL)", re.I)
273
+
274
+ def _is_literal(expr):
275
+ e = expr.strip()
276
+ if e[:1] in ("\"", "'"): # plain string literal
277
+ return True
278
+ if e[:1] == "`" and "${" not in e: # Go raw / JS template with no interpolation
279
+ return True
280
+ return False
281
+
282
+ def rule_ssrf(path, text, F):
283
+ seen = set()
284
+ for rx, g in HTTP_SINKS:
285
+ for m in rx.finditer(text):
286
+ urlarg = m.group(g) or ""
287
+ if _is_literal(urlarg):
288
+ continue
289
+ if not URL_INFLUENCE.search(urlarg):
290
+ continue
291
+ block = enclosing_block(text, m.start())
292
+ if SSRF_GUARD.search(block):
293
+ continue
294
+ key = lineno(text, m.start())
295
+ if key in seen:
296
+ continue
297
+ seen.add(key)
298
+ add(F, "A4", "MEDIUM",
299
+ "SSRF surface: outbound request to non-literal URL, no guard in function (review each)",
300
+ path, text, m.start(), f"url arg: {urlarg.strip()[:60]}",
301
+ "VATA:mcpjungle OAuth DCR SSRF", "LOW")
302
+
303
+ # ---- Rule A5: excluded_tools / blocklist defined but thinly enforced ----
304
+ # Confidence: LOW. Review pointer, not a detector. Skips comments and help-text strings.
305
+ EXCLUDED = re.compile(r"(excluded_tools|blocked_tools|tool_blocklist|disabled_tools)", re.I)
306
+
307
+ def _is_noise_line(line):
308
+ s = line.strip()
309
+ if s.startswith(("//", "#", "*", "/*")):
310
+ return True
311
+ # help-text / doc string fragment: line is dominated by a quoted string ending with \n
312
+ if ('\\n"' in s or s.endswith('" +') or s.endswith('",')) and '"' in s and "excluded" in s.lower() and ":" not in s.split("excluded")[0][-3:]:
313
+ # crude: mentions inside prose strings, not a struct field/tag
314
+ if "`json" not in s and "[]string" not in s:
315
+ return True
316
+ return False
317
+
318
+ def rule_excluded_tools(path, text, F):
319
+ hits = [m for m in EXCLUDED.finditer(text) if not _is_noise_line(linetext(text, m.start()))]
320
+ if len(hits) == 1:
321
+ m = hits[0]
322
+ add(F, "A5", "MEDIUM", "excluded_tools referenced once - verify all surfaces enforce it",
323
+ path, text, m.start(), m.group(0),
324
+ "VATA:mcpjungle excluded_tools bypass / REST Enabled-flag gap", "LOW")
325
+
326
+ # ---- Rule A6: authz/deny check gated on a specific method/path (review-list) ----
327
+ # Confidence: LOW. Review pointer, NOT a detector. Static analysis cannot prove
328
+ # whether OTHER methods bypass the check (that is control-flow reasoning), so this
329
+ # only surfaces authz checks that sit inside a method/path conditional for a human
330
+ # to verify a catch-all deny exists. Seeded by VATA:ttokkime tools/list bypass.
331
+ METHOD_GATE = re.compile(
332
+ r"(if\s+[^\n{]*\b(req\.)?[Mm]ethod\s*==|switch\s+[^\n{]*[Mm]ethod\b|"
333
+ r"case\s+[\"'][a-z]+/[a-z]+[\"']|==\s*[\"'](tools|resources|prompts)/)", re.I)
334
+ AUTHZ_DECISION = re.compile(
335
+ r"(DeniedRBAC|Unauthorized|[Ff]orbidden|StatusForbidden|403|"
336
+ r"!\s*\w*\.?(ToolAllowed|IsWildcard|hasRole|canAccess|isAllowed)|"
337
+ r"return\s+[^\n]*(deny|denied|unauthor))", re.I)
338
+
339
+ def rule_method_scoped_authz(path, text, F):
340
+ # Skip test/mock fixtures: they dispatch by method but carry no real authz.
341
+ if is_test_path(path) or "mock" in str(path).lower():
342
+ return
343
+ seen = set()
344
+ for m in METHOD_GATE.finditer(text):
345
+ # Look only in a tight window around the method conditional (the gated
346
+ # branch), not the whole function, so an authz word drifting elsewhere
347
+ # in the file does not trigger a match.
348
+ near = text[m.start():m.start()+400]
349
+ if not AUTHZ_DECISION.search(near):
350
+ continue
351
+ key = lineno(text, m.start())
352
+ if key in seen:
353
+ continue
354
+ seen.add(key)
355
+ line = linetext(text, m.start()).strip()
356
+ if line.startswith(("//", "#", "*", "/*")):
357
+ continue
358
+ add(F, "A6", "MEDIUM",
359
+ "Authz check gated on a specific method/path - verify other methods are not bypassed (review each)",
360
+ path, text, m.start(), line[:70],
361
+ "VATA:ttokkime method-scoped RBAC (tools/list bypass)", "LOW")
362
+
363
+ # ===================== Client-side rules (C-series) =====================
364
+ # Issuer binding on MCP OAuth CLIENT providers holding pre-provisioned credentials.
365
+ # These credentials were not obtained by the SDK, so only the configured issuer says
366
+ # which authorization server they belong to. Without it, a malicious or compromised
367
+ # MCP server can advertise its own AS (RFC 9728 PRM) and receive the client secret
368
+ # or signed JWT assertion. A fixed SDK version alone does NOT close this.
369
+
370
+ PY_M2M = re.compile(r"\b(ClientCredentialsOAuthProvider|PrivateKeyJWTOAuthProvider)\s*\(")
371
+ TS_M2M = re.compile(r"\bnew\s+(ClientCredentialsProvider|PrivateKeyJwtProvider|"
372
+ r"StaticPrivateKeyJwtProvider|CrossAppAccessProvider)\s*\(")
373
+ PY_LEGACY = re.compile(r"\bRFC7523OAuthClientProvider\s*\(")
374
+ ISSUER_ESCAPE = re.compile(
375
+ r"(skip\w*Issuer\w*\s*[:=]\s*(true|True|1)\b|"
376
+ r"skip_\w*issuer\w*\s*[:=]\s*(true|True|1)\b|"
377
+ r"(validate|verify|check)_?[Ii]ssuer\w*\s*[:=]\s*(false|False|0)\b|"
378
+ r"[\"']verify_iss[\"']\s*:\s*(false|False)\b)")
379
+
380
+ def call_args(text, open_idx):
381
+ """Return the argument text of a call whose '(' is at open_idx (paren-balanced,
382
+ string-aware), or the next 1500 chars if unbalanced."""
383
+ depth, i, q = 0, open_idx, None
384
+ while i < len(text):
385
+ c = text[i]
386
+ if q:
387
+ if c == "\\":
388
+ i += 2; continue
389
+ if c == q:
390
+ q = None
391
+ elif c in "\"'`":
392
+ q = c
393
+ elif c == "(":
394
+ depth += 1
395
+ elif c == ")":
396
+ depth -= 1
397
+ if depth == 0:
398
+ return text[open_idx + 1:i]
399
+ i += 1
400
+ return text[open_idx + 1:open_idx + 1500]
401
+
402
+ def _comment_line(line):
403
+ return line.strip().startswith(("#", "//", "*", "/*"))
404
+
405
+ def _resolve_ts_options(text, name, before_idx):
406
+ """If args are a bare identifier, find `const|let|var name = {...}` earlier in the
407
+ file and return that object literal, else None."""
408
+ rx = re.compile(r"\b(?:const|let|var)\s+" + re.escape(name) + r"\b[^=\n]*=\s*\{")
409
+ last = None
410
+ for m in rx.finditer(text, 0, before_idx):
411
+ last = m
412
+ if not last:
413
+ return None
414
+ return brace_block(text, last.end() - 1)
415
+
416
+ def rule_client_issuer_binding(path, text, F):
417
+ # C1 (Python): issuer= missing
418
+ for m in PY_M2M.finditer(text):
419
+ line = linetext(text, m.start())
420
+ if _comment_line(line) or re.match(r"\s*(class|def|from|import)\b", line):
421
+ continue
422
+ args = call_args(text, m.end() - 1)
423
+ if re.search(r"\bissuer\s*=", args):
424
+ continue
425
+ if "**" in args:
426
+ add(F, "C1", "HIGH",
427
+ f"{m.group(1)} built from **kwargs - verify issuer= is set (review)",
428
+ path, text, m.start(), "options spread from elsewhere; issuer not visible at call site",
429
+ "GHSA-qx49-fqc8-xw99 (python-sdk issuer binding)", "MEDIUM")
430
+ continue
431
+ add(F, "C1", "HIGH",
432
+ f"{m.group(1)} constructed without issuer= - credentials follow any AS the MCP server advertises",
433
+ path, text, m.start(), f"{m.group(1)}(...) has no issuer= argument",
434
+ "GHSA-qx49-fqc8-xw99 (python-sdk issuer binding)", "HIGH")
435
+
436
+ # C1 (TypeScript/JS): expectedIssuer missing
437
+ for m in TS_M2M.finditer(text):
438
+ line = linetext(text, m.start())
439
+ if _comment_line(line):
440
+ continue
441
+ args = call_args(text, m.end() - 1)
442
+ if "expectedIssuer" in args:
443
+ continue
444
+ ident = re.fullmatch(r"\s*([A-Za-z_$][\w$]*)\s*,?\s*", args)
445
+ if ident:
446
+ obj = _resolve_ts_options(text, ident.group(1), m.start())
447
+ if obj is not None and "expectedIssuer" in obj:
448
+ continue
449
+ if obj is None:
450
+ add(F, "C1", "HIGH",
451
+ f"{m.group(1)} options built elsewhere - verify expectedIssuer is set (review)",
452
+ path, text, m.start(), f"options identifier '{ident.group(1)}' not resolvable in file",
453
+ "typescript-sdk PR #2887 (SEP-2352 issuer binding)", "MEDIUM")
454
+ continue
455
+ elif "..." in args:
456
+ add(F, "C1", "HIGH",
457
+ f"{m.group(1)} options spread from elsewhere - verify expectedIssuer is set (review)",
458
+ path, text, m.start(), "object spread; expectedIssuer not visible at call site",
459
+ "typescript-sdk PR #2887 (SEP-2352 issuer binding)", "MEDIUM")
460
+ continue
461
+ add(F, "C1", "HIGH",
462
+ f"{m.group(1)} constructed without expectedIssuer - credentials follow any AS the MCP server advertises",
463
+ path, text, m.start(), f"new {m.group(1)}(...) has no expectedIssuer",
464
+ "typescript-sdk PR #2887 (SEP-2352 issuer binding)", "HIGH")
465
+
466
+ # C2: legacy provider with no issuer option at all
467
+ for m in PY_LEGACY.finditer(text):
468
+ line = linetext(text, m.start())
469
+ if _comment_line(line) or re.match(r"\s*(class|def|from|import)\b", line):
470
+ continue
471
+ add(F, "C2", "HIGH",
472
+ "Deprecated RFC7523OAuthClientProvider - has no issuer option; cannot be bound, migrate",
473
+ path, text, m.start(), "RFC7523OAuthClientProvider(...)",
474
+ "GHSA-qx49-fqc8-xw99 (legacy provider, no fix by config)", "HIGH")
475
+
476
+ # C3: issuer validation switched off
477
+ for m in ISSUER_ESCAPE.finditer(text):
478
+ line = linetext(text, m.start())
479
+ if _comment_line(line):
480
+ continue
481
+ add(F, "C3", "HIGH",
482
+ "Issuer validation disabled - mix-up / credential redirection guard turned off",
483
+ path, text, m.start(), m.group(0)[:70],
484
+ "RFC 8414 s3.3 / RFC 9207 / SEP-2352 escape hatch", "MEDIUM")
485
+
486
+ # ===================== SARIF 2.1.0 output (GitHub code scanning) =====================
487
+ # Rule metadata for the Security tab. Rules absent here still emit results via a
488
+ # graceful fallback, so adding a new detector rule never breaks SARIF output.
489
+ # security-severity is a string float GitHub buckets as: >=9 critical, 7-8.9 high,
490
+ # 4-6.9 medium, <4 low. It is a rule-level property, so a rule that spans severities
491
+ # (e.g. A2) buckets by its canonical value; the per-finding severity still rides on
492
+ # the result `level` (error/warning/note) below.
493
+ RULE_META = {
494
+ "A1": {"name": "OAuthAuthorizeNoClientValidation",
495
+ "desc": "OAuth authorize handler reads client_id/redirect_uri but never validates against an allowlist (auth bypass / open redirect / account takeover).",
496
+ "sev": 9.0, "cwe": ["CWE-862", "CWE-863", "CWE-601"]},
497
+ "A2": {"name": "HardcodedDefaultAdminCredential",
498
+ "desc": "Hardcoded default admin credential, or an admin re-seed routine that may run on every restart.",
499
+ "sev": 9.0, "cwe": ["CWE-798", "CWE-1392"]},
500
+ "A3": {"name": "AuthMiddlewareNoAuthorization",
501
+ "desc": "Auth middleware validates a token but enforces no role/tenant/scope (cross-tenant access).",
502
+ "sev": 7.5, "cwe": ["CWE-862", "CWE-863"]},
503
+ "A4": {"name": "SSRFOutboundNonLiteralURL",
504
+ "desc": "Outbound HTTP request to a non-literal, externally-influenced URL with no allowlist / internal-IP guard (review-list).",
505
+ "sev": 5.0, "cwe": ["CWE-918"]},
506
+ "A5": {"name": "ToolBlocklistEnforcementUnverified",
507
+ "desc": "excluded_tools/blocklist defined but enforcement unverified across surfaces (review pointer).",
508
+ "sev": 3.0, "cwe": ["CWE-863"]},
509
+ "A6": {"name": "MethodScopedAuthorization",
510
+ "desc": "Authorization check gated on a specific method/path; other methods may bypass it (review-list).",
511
+ "sev": 5.0, "cwe": ["CWE-863"]},
512
+ "C1": {"name": "MCPClientMissingIssuerBinding",
513
+ "desc": "M2M MCP OAuth client provider built without issuer/expectedIssuer; pre-provisioned credentials follow whatever AS the MCP server advertises.",
514
+ "sev": 7.5, "cwe": ["CWE-346", "CWE-290"]},
515
+ "C2": {"name": "MCPClientLegacyProviderNoIssuer",
516
+ "desc": "Deprecated RFC7523OAuthClientProvider has no issuer option and cannot be bound; migrate.",
517
+ "sev": 7.5, "cwe": ["CWE-346"]},
518
+ "C3": {"name": "MCPClientIssuerValidationDisabled",
519
+ "desc": "Issuer validation explicitly disabled; authorization-server mix-up / credential-redirection guard turned off.",
520
+ "sev": 7.5, "cwe": ["CWE-346", "CWE-290"]},
521
+ "B1": {"name": "PKCEAcceptedbutNeverEnforced",
522
+ "desc": "Self-rolled authorization server accepts a PKCE code_verifier (or is explicitly told to skip local PKCE) but never performs the SHA-256/S256 challenge check.",
523
+ "sev": 8.1, "cwe": ["CWE-287", "CWE-1390"]},
524
+ "B2": {"name": "AuthorizationCodeNotSingleUse",
525
+ "desc": "Authorization code is redeemed at the token endpoint but is never invalidated (delete/mark-used/revoke) in the same file; the code may be replayable. Review pointer: confirm invalidation is not delegated to an unscanned storage layer.",
526
+ "sev": 8.6, "cwe": ["CWE-294", "CWE-384"]},
527
+ "B3": {"name": "AuthorizationCodeExcessiveLifetime",
528
+ "desc": "Authorization code is issued with a lifetime far exceeding RFC 6749's ~600s (10 min) recommendation.",
529
+ "sev": 7.5, "cwe": ["CWE-613"]},
530
+ }
531
+ SARIF_LEVEL = {"CRITICAL": "error", "HIGH": "error", "MEDIUM": "warning", "LOW": "note"}
532
+
533
+ def _ghsa_uri(ref):
534
+ m = re.search(r"GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}", ref or "", re.I)
535
+ return f"https://github.com/advisories/{m.group(0)}" if m else None
536
+
537
+ def to_sarif(findings, target):
538
+ """Build a SARIF 2.1.0 log. Carries the same report_sha256 receipt as --json
539
+ (over the identical findings payload) in run.properties so a SARIF run is as
540
+ anchorable as a JSON one."""
541
+ root = Path(target).resolve()
542
+ # declare every known rule, plus any rule that fired but isn't in RULE_META
543
+ rule_ids = list(RULE_META.keys())
544
+ for f in findings:
545
+ if f["rule"] not in rule_ids:
546
+ rule_ids.append(f["rule"])
547
+ rule_index = {rid: i for i, rid in enumerate(rule_ids)}
548
+ rules = []
549
+ for rid in rule_ids:
550
+ meta = RULE_META.get(rid, {})
551
+ rules.append({
552
+ "id": rid,
553
+ "name": meta.get("name", f"Rule{rid}"),
554
+ "shortDescription": {"text": meta.get("desc", f"VATA mcp-authscan rule {rid}")},
555
+ "helpUri": "https://github.com/LHMisme420/mcp-authscan#rules",
556
+ "properties": {
557
+ "security-severity": f"{meta.get('sev', 5.0):.1f}",
558
+ "tags": ["security", "mcp", "oauth"] + meta.get("cwe", []),
559
+ },
560
+ })
561
+ results = []
562
+ for f in findings:
563
+ rid = f["rule"]
564
+ try:
565
+ uri = Path(f["file"]).resolve().relative_to(root).as_posix()
566
+ except Exception:
567
+ uri = Path(f["file"]).as_posix()
568
+ ref = f.get("ref", "")
569
+ fp = hashlib.sha256(f"{rid}|{uri}|{f.get('evidence','')}".encode()).hexdigest()[:16]
570
+ props = {"confidence": f.get("confidence", ""), "vataSeverity": f.get("severity", ""),
571
+ "ref": ref, "evidence": f.get("evidence", "")}
572
+ adv = _ghsa_uri(ref)
573
+ if adv:
574
+ props["advisory"] = adv
575
+ results.append({
576
+ "ruleId": rid,
577
+ "ruleIndex": rule_index[rid],
578
+ "level": SARIF_LEVEL.get(f["severity"], "warning"),
579
+ "message": {"text": f"{f['title']} [{f['severity']}/{f['confidence']}] ({ref})"},
580
+ "locations": [{"physicalLocation": {
581
+ "artifactLocation": {"uri": uri},
582
+ "region": {"startLine": max(1, int(f.get("line", 1)))},
583
+ }}],
584
+ "partialFingerprints": {"vataAuthscan/v1": fp},
585
+ "properties": props,
586
+ })
587
+ blob = json.dumps({"target": target, "count": len(findings), "findings": findings},
588
+ sort_keys=True).encode()
589
+ return {
590
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
591
+ "version": "2.1.0",
592
+ "runs": [{
593
+ "tool": {"driver": {
594
+ "name": "mcp-authscan",
595
+ "informationUri": "https://github.com/LHMisme420/mcp-authscan",
596
+ "version": VERSION,
597
+ "rules": rules,
598
+ }},
599
+ "properties": {"reportSha256": hashlib.sha256(blob).hexdigest()},
600
+ "results": results,
601
+ }],
602
+ }
603
+ # ---- Rules B1/B2/B3: self-rolled OAuth authorization-code lifecycle ----
604
+ # Seeded from VATA findings akshay5995 / atrawog / webrix. Validated v4 against a
605
+ # 4-repo ground-truth corpus (3/3 seeded recovered, 0 FP on IdP-delegating control).
606
+ # Per-file by design (matches the scan() driver). KNOWN LIMITS, not faked:
607
+ # * B1 does NOT detect PKCE-*optional* (validated-when-present, skippable-when-
608
+ # absent) -- that is control-flow, not a regex shape (atrawog's B1 case).
609
+ # * B2 is SAME-FILE only: a redeem path whose invalidation lives in a different
610
+ # file (storage layer) will false-positive. Hence MEDIUM/warning + review note.
611
+ # * B3 "no expiry field anywhere in schema" (cross-file absence) is out of scope;
612
+ # only explicit excessive TTL is detected.
613
+
614
+ B_OAUTH_HINT = re.compile(r"authorization_code|grant_type|code_verifier|code_challenge|token_endpoint|oauth|pkce|/token|/authorize", re.I)
615
+ B_AS_ROUTE = re.compile(r"""(?:@\w+\.(?:post|get|route|api_route)|(?:router|app|server|blueprint|bp)\.(?:post|get|route)|add_api_route|add_route)\s*\(\s*['"][^'"]*/(?:token|authorize|authorization|oauth)""", re.I)
616
+ B_AS_FORM = re.compile(r"\bForm\s*\(", re.I)
617
+ B_AS_INBOUND = re.compile(r"""request\.(?:form|json|body|data|query|args|POST)|req\.(?:body|query|params)|\.get\(\s*['"](?:grant_type|code_verifier|code)['"]""", re.I)
618
+ B_AS_FUNC = re.compile(r"""exchange[_]?authorization[_]?code|exchangeAuthorizationCode|create[_]?authorization[_]?code|createAuthorizationCode|store[_]?authorization[_]?code|storeAuthorizationCode|validate[_]?(?:authorization[_]?)?code|issue[_]?(?:access[_]?)?token|issueToken|def\s+token\b|def\s+authorize\b""", re.I)
619
+ B_CLIENT_REQ = re.compile(r"""(?:requests|httpx|aiohttp|urllib|session|axios)\.(?:post|request|get)\b|fetch\s*\([^\n]{0,120}token|data\s*=\s*\{[^}\n]*grant_type|token_(?:endpoint|url|uri)\s*[,=)]""", re.I)
620
+
621
+ def _b_is_authz_server(text):
622
+ strong = bool(B_AS_ROUTE.search(text) or B_AS_FORM.search(text) or B_AS_INBOUND.search(text))
623
+ func = bool(B_AS_FUNC.search(text))
624
+ client = bool(B_CLIENT_REQ.search(text))
625
+ return strong or (func and not client)
626
+
627
+ B_SKIP_PKCE = re.compile(r"skip[_-]?local[_-]?pkce[_-]?validation\s*[:=]\s*true", re.I)
628
+ B_PKCE_HASH = re.compile(r"sha-?256|s256|createhash|crypto\.createHash|subtle\.digest|hashlib\.sha256|\.digest\(|hashes\.SHA256", re.I)
629
+ B_VERIFIER = re.compile(r"code[_]?verifier", re.I)
630
+ B_GEN_VERIFIER = re.compile(r"code[_]?verifier\s*=\s*.*(?:secrets|token_|randombytes|random\.|base64|createhash|uuid|getrandom)", re.I)
631
+ B_RECV_HINT = re.compile(r"""Form\s*\(|Body\s*\(|Query\s*\(|:\s*Optional|:\s*str\b|\?\s*:\s*str|\.get\(\s*['"]code_verifier|request\.|req\.|body\.|params|\bdef\s|\bfunction\s|[(,]\s*code[_]?verifier\s*[:?,)]""", re.I)
632
+
633
+ B_GRANT_AC = re.compile(r"grant_type[^\n]{0,40}authorization_code|authorization_code[^\n]{0,40}grant_type|['\"]authorization_code['\"]", re.I)
634
+ B_REDEEM_FUNC = re.compile(r"exchange[_]?authorization[_]?code|exchangeAuthorizationCode|redeem[_\s\w]{0,20}code|validate[_]?authorization[_]?code|validateAuthorizationCode|consume[_\s\w]{0,20}code", re.I)
635
+ B_LOOKUP_CODE = re.compile(r"(?:get|find|lookup|fetch|load|retrieve)[_\s\w.]*authorization[_]?code|getAuthorizationCode|findAuthorizationCode|getByCode|authorization[_]?codes?\s*\[[^\]]+\]|where\s+code\s*=|from\s+\w*codes?\b|codes?\.get\(|codes?\.find", re.I)
636
+ B_INVALIDATE = re.compile(
637
+ r"delete[_\s]+\w*code|del\s+\w*code|delete\s+from\s+\w*codes?\b"
638
+ r"|revoke\w*code|revokeCode|revoke_authorization|invalidate\w*code|invalidateCode"
639
+ r"|mark[_]?used|markUsed|mark[_]?code[_]?used|consume\w*code|consumeCode|consume_authorization"
640
+ r"|redeemed\s*[:=]\s*[Tt]rue|used\s*[:=]\s*[Tt]rue|is[_]?used\s*[:=]\s*[Tt]rue"
641
+ r"|codes?\.(?:delete|remove|pop)\(|authorization[_]?codes?\.(?:delete|remove|pop)\("
642
+ r"|(?:delete|remove|pop|del)\([^)\n]*\bcode\b|\.(?:delete|remove|pop)\([^)\n]*\bcode", re.I)
643
+ B_EXPIRY_FIELD = re.compile(r"expires?(_at|_in)?\b|\bexp\b|\bttl\b|expiry|valid[_-]?until|not[_-]?after", re.I)
644
+ B_CODE_CTX = re.compile(r"\bcode\b|authorization", re.I)
645
+ # B3 gating: the excessive number must govern an AUTHORIZATION CODE.
646
+ B_AUTHCODE_TTL_CTX = re.compile(r"auth(?:oriz\w*)?[_ ]?code|authorization_code|code[_]?ttl|code[_]?expir|code[_]?lifetime|codes?\s*\[|store[_]?auth\w*code|create[_]?auth\w*code|issue\w*code", re.I)
647
+ # strong signal that THIS is explicitly an auth-code TTL (overrides the exclusion)
648
+ B_AUTHCODE_STRONG = re.compile(r"authorization_code|auth[_]?code[_]?(?:ttl|expir|lifetime|max_?age)|code[_]?ttl", re.I)
649
+ # access/refresh/session/id-token lifetimes: 3600-class values here are NORMAL, not B3
650
+ B_OTHER_TOKEN_TTL = re.compile(r"access[_]?token|refresh[_]?token|id[_]?token|session|jwt|bearer|expires_in|cookie|max_?age", re.I)
651
+ B_TTL_PATTERNS = [
652
+ (re.compile(r"timedelta\(\s*days\s*=\s*(\d+)", re.I), 86400),
653
+ (re.compile(r"timedelta\(\s*hours\s*=\s*(\d+)", re.I), 3600),
654
+ (re.compile(r"(\d+)\s*\*\s*86400"), 86400),
655
+ (re.compile(r"(\d+)\s*\*\s*3600"), 3600),
656
+ ]
657
+ B_BIG_INT_TTL = re.compile(r"(expires?_in|ttl|code[_-]?ttl|lifetime|max[_-]?age|expiry)\s*[:=]\s*(\d{3,})", re.I)
658
+ B_KNOWN_SECS = (31536000, 2592000, 604800)
659
+ B_RFC_MAX = 600
660
+
661
+ def _b_ttl_seconds(line):
662
+ for rx, mult in B_TTL_PATTERNS:
663
+ m = rx.search(line)
664
+ if m:
665
+ try: return int(m.group(1)) * mult
666
+ except (IndexError, ValueError): pass
667
+ m = B_BIG_INT_TTL.search(line)
668
+ if m:
669
+ try: return int(m.group(2))
670
+ except ValueError: pass
671
+ for c in B_KNOWN_SECS:
672
+ if re.search(r"\b%d\b" % c, line): return c
673
+ return None
674
+
675
+ def rule_pkce_not_enforced(path, text, F):
676
+ if not B_OAUTH_HINT.search(text):
677
+ return
678
+ # B1a: PKCE explicitly skipped (definitive -> HIGH/error)
679
+ for m in B_SKIP_PKCE.finditer(text):
680
+ add(F, "B1", "HIGH", "PKCE validation explicitly disabled",
681
+ path, text, m.start(),
682
+ "skipLocalPkceValidation=true with no local verifier check taking over",
683
+ "VATA:webrix skipLocalPkceValidation", "HIGH")
684
+ # B1b: verifier received as input but never hashed (AS-gated -> MEDIUM/warning)
685
+ if _b_is_authz_server(text) and not B_PKCE_HASH.search(text):
686
+ for m in B_VERIFIER.finditer(text):
687
+ ls = text.rfind("\n", 0, m.start()) + 1
688
+ le = text.find("\n", m.start()); le = len(text) if le < 0 else le
689
+ line = text[ls:le]
690
+ if B_GEN_VERIFIER.search(line) or not B_RECV_HINT.search(line):
691
+ continue
692
+ add(F, "B1", "MEDIUM", "PKCE code_verifier received but never validated",
693
+ path, text, m.start(),
694
+ "code_verifier received as input; no SHA-256/S256 transform in this file",
695
+ "VATA:atrawog/webrix verifier-never-hashed", "MEDIUM")
696
+ break
697
+ # B3: explicit excessive authorization-code TTL (definitive number -> HIGH/error)
698
+ for m in B_BIG_INT_TTL.finditer(text):
699
+ pass # covered by line loop below for context gating
700
+ for mline in re.finditer(r"[^\n]*\n?", text):
701
+ line = mline.group(0)
702
+ if not line.strip():
703
+ continue
704
+ secs = _b_ttl_seconds(line)
705
+ if secs is None or secs <= B_RFC_MAX:
706
+ continue
707
+ ctx = text[max(0, mline.start()-240):mline.start()+240]
708
+ # Must be tied to an AUTHORIZATION CODE specifically, not generic OAuth
709
+ # context. 3600 etc. is the usual access-token lifetime; only fire when the
710
+ # vicinity names an auth code and does NOT read as access/refresh/session TTL.
711
+ if not B_AUTHCODE_TTL_CTX.search(ctx):
712
+ continue
713
+ if B_OTHER_TOKEN_TTL.search(ctx) and not B_AUTHCODE_STRONG.search(ctx):
714
+ continue
715
+ add(F, "B3", "HIGH", f"Authorization-code lifetime ~{secs}s exceeds RFC 6749 ~600s",
716
+ path, text, mline.start(),
717
+ f"auth-code TTL {secs}s (RFC 6749 4.1.2 recommends <=600s)",
718
+ "VATA:atrawog 1-year auth-code TTL", "HIGH")
719
+
720
+ def rule_authcode_replay(path, text, F):
721
+ # B2 same-file: redeem path present, no invalidation in THIS file. MEDIUM/warning
722
+ # + review note, because true single-use may be enforced in a separate storage file.
723
+ if not B_OAUTH_HINT.search(text) or not _b_is_authz_server(text):
724
+ return
725
+ if not (B_GRANT_AC.search(text) or B_REDEEM_FUNC.search(text)):
726
+ return
727
+ rm = B_REDEEM_FUNC.search(text) or B_LOOKUP_CODE.search(text)
728
+ if rm is None:
729
+ return
730
+ if B_INVALIDATE.search(text):
731
+ return # something in this file invalidates a code -> not a same-file replay
732
+ add(F, "B2", "MEDIUM", "Authorization code redeemed but never invalidated (same-file)",
733
+ path, text, rm.start(),
734
+ "code redeemed/looked up here; no delete/mark-used/revoke in this file -- confirm storage layer before filing",
735
+ "VATA:webrix auth-code replay", "MEDIUM")
736
+
737
+ RULES = [rule_oauth_authorize, rule_default_creds, rule_middleware_authz,
738
+ rule_ssrf, rule_excluded_tools, rule_method_scoped_authz,
739
+ rule_client_issuer_binding,
740
+ rule_pkce_not_enforced, rule_authcode_replay]
741
+
742
+ def scan(root, include_tests, exclude=()):
743
+ F = []
744
+ for path in iter_files(root, include_tests, exclude):
745
+ try:
746
+ text = path.read_text(errors="ignore")
747
+ except Exception:
748
+ continue
749
+ for r in RULES:
750
+ r(path, text, F)
751
+ return F
752
+
753
+ def main():
754
+ ap = argparse.ArgumentParser(description=f"VATA mcp_authscan v{VERSION}")
755
+ ap.add_argument("target", help="path to MCP server repo/dir")
756
+ ap.add_argument("--json", action="store_true", help="emit JSON + report sha256")
757
+ ap.add_argument("--sarif", action="store_true", help="emit SARIF 2.1.0 (GitHub code scanning)")
758
+ ap.add_argument("--include-tests", action="store_true", help="also scan test files")
759
+ ap.add_argument("--exclude", action="append", default=[], metavar="DIR",
760
+ help="skip a directory relative to target (repeatable)")
761
+ ap.add_argument("--fail-on", choices=["critical", "high", "medium"], default=None)
762
+ args = ap.parse_args()
763
+
764
+ F = scan(args.target, args.include_tests, args.exclude)
765
+ order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
766
+ F.sort(key=lambda x: (order.get(x["severity"], 9), x["file"], x["line"]))
767
+
768
+ if args.sarif:
769
+ print(json.dumps(to_sarif(F, args.target), indent=2))
770
+ elif args.json:
771
+ payload = {"target": args.target, "count": len(F), "findings": F}
772
+ blob = json.dumps(payload, sort_keys=True).encode()
773
+ payload["report_sha256"] = hashlib.sha256(blob).hexdigest() # anchor hook
774
+ print(json.dumps(payload, indent=2))
775
+ else:
776
+ if not F:
777
+ print("No findings. (Absence of finding is not proof of safety.)")
778
+ for x in F:
779
+ print(f"[{x['severity']}/{x['confidence']}] {x['rule']} {x['title']}")
780
+ print(f" {x['file']}:{x['line']} ({x['ref']})")
781
+ print(f" {x['evidence']}")
782
+ print(x["snippet"]); print()
783
+ print(f"{len(F)} finding(s).")
784
+
785
+ if args.fail_on:
786
+ thr = {"critical": 0, "high": 1, "medium": 2}[args.fail_on]
787
+ if any(order.get(x["severity"], 9) <= thr for x in F):
788
+ sys.exit(1)
789
+
790
+ if __name__ == "__main__":
791
+ main()