sparda-mcp 0.66.0 → 0.66.2
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.
- package/README.md +3 -3
- package/package.json +13 -12
- package/src/commands/evolve.js +1 -1
- package/src/commands/gate.js +85 -11
- package/src/commands/heal.js +1 -1
- package/src/commands/seed.js +1 -1
- package/src/detect.js +37 -4
- package/src/flight/heal.js +1 -1
- package/src/generator/express.js +1 -1
- package/src/generator/manifest.js +1 -1
- package/src/parser/fastapi.js +1 -0
- package/src/ubg/apocalypse.js +27 -3
- package/src/ubg/compile.js +3 -0
- package/src/ubg/extract.js +117 -0
- package/src/ubg/fastapi.js +1 -0
- package/src/ubg/fastapi_extract.py +252 -21
- package/src/ubg/genome.js +1 -1
- package/src/ubg/nextjs.js +189 -0
- package/src/ubg/resolve.js +7 -0
- package/src/ubg/translate.js +21 -2
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
## 60-second proof
|
|
27
27
|
|
|
28
|
-
From your Express, FastAPI, Next.js, NestJS or Medusa app — nothing to configure:
|
|
28
|
+
From your Express, FastAPI, Flask, Next.js, NestJS or Medusa app — nothing to configure:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
31
|
npx sparda-mcp apocalypse # prove the tree is safe to deploy — exit 1 on any real risk
|
|
@@ -53,7 +53,7 @@ Under the hood it compiles your backend into one language-agnostic graph — the
|
|
|
53
53
|
| **`heal`** | _Self-heal, **proven**_ — the gate Copilot Autofix doesn't have: a fix ships **only if** replay matches, `verify` still passes, and `apocalypse` finds no new risk / no dropped guard. Whoever wrote the fix, the machine judges it. |
|
|
54
54
|
| **`badge`** | _The shareable artifact_ — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
|
|
55
55
|
| **`dossier`** | _The public report_ — one self-contained HTML page: verdict, risks, and SPARDA's own blind spots |
|
|
56
|
-
| **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI)
|
|
56
|
+
| **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Flask · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
|
|
57
57
|
| **`timeless`** | _Time-travel_ — record a production request, replay it byte-identically, export the bug as a test |
|
|
58
58
|
| **`mirror`** | _Execute the graph_ — serve the compiled behavior over HTTP with no framework and no source |
|
|
59
59
|
| **`init` / `dev`** | _Runtime, optional_ — expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution) |
|
|
@@ -213,7 +213,7 @@ The gate is honest in both directions: an unfixed bug, or a "fix" that silently
|
|
|
213
213
|
|
|
214
214
|
## Any Backend On Earth: OpenAPI Lowering
|
|
215
215
|
|
|
216
|
-
SPARDA parses Express, FastAPI and Next.js natively — and **every other stack through the format the industry already agreed on**. Go, Java, Rails, Laravel, .NET: if it has an OpenAPI spec, it compiles.
|
|
216
|
+
SPARDA parses Express, FastAPI, Flask and Next.js natively — and **every other stack through the format the industry already agreed on**. Go, Java, Rails, Laravel, .NET: if it has an OpenAPI spec, it compiles.
|
|
217
217
|
|
|
218
218
|
```bash
|
|
219
219
|
npx sparda-mcp ubg --openapi openapi.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparda-mcp",
|
|
3
|
-
"version": "0.66.
|
|
3
|
+
"version": "0.66.2",
|
|
4
4
|
"mcpName": "io.github.zyx77550/sparda-mcp",
|
|
5
5
|
"description": "AI writes. SPARDA proves. A deterministic, offline gate that catches when an AI edit removes a guard, exposes a route, or breaks an invariant — no API key, right in the agent edit loop.",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"url": "https://github.com/zyx77550/sparda/issues"
|
|
14
14
|
},
|
|
15
15
|
"bin": {
|
|
16
|
-
"sparda": "
|
|
16
|
+
"sparda": "src/index.js"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "vitest run",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"mcp",
|
|
47
|
+
"mcp-server",
|
|
47
48
|
"model-context-protocol",
|
|
48
49
|
"claude",
|
|
49
50
|
"claude-code",
|
|
@@ -60,19 +61,19 @@
|
|
|
60
61
|
"author": "Residual Labs (residual-labs.fr)",
|
|
61
62
|
"license": "BUSL-1.1",
|
|
62
63
|
"dependencies": {
|
|
63
|
-
"@babel/parser": "7.26.5",
|
|
64
|
-
"@babel/traverse": "7.26.5",
|
|
65
|
-
"@clack/prompts": "0.9.1",
|
|
66
64
|
"@modelcontextprotocol/sdk": "1.29.0"
|
|
67
65
|
},
|
|
68
66
|
"devDependencies": {
|
|
69
|
-
"@
|
|
70
|
-
"@
|
|
71
|
-
"
|
|
72
|
-
"eslint
|
|
73
|
-
"
|
|
74
|
-
"
|
|
67
|
+
"@babel/parser": "^8.0.4",
|
|
68
|
+
"@babel/traverse": "^8.0.4",
|
|
69
|
+
"@clack/prompts": "^1.7.0",
|
|
70
|
+
"@eslint/js": "^10.0.1",
|
|
71
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
72
|
+
"eslint": "^10.7.0",
|
|
73
|
+
"eslint-config-prettier": "^10.1.8",
|
|
74
|
+
"express": "^5.2.1",
|
|
75
|
+
"globals": "^17.7.0",
|
|
75
76
|
"prettier": "^3.8.4",
|
|
76
|
-
"vitest": "^
|
|
77
|
+
"vitest": "^4.1.10"
|
|
77
78
|
}
|
|
78
79
|
}
|
package/src/commands/evolve.js
CHANGED
package/src/commands/gate.js
CHANGED
|
@@ -3,11 +3,29 @@
|
|
|
3
3
|
// edit gate must never scream about pre-existing state (that noise is fatal to
|
|
4
4
|
// adoption — see docs/TWO-FALSE-POSITIVE-CLASSES-2026-07-19.md), so everything
|
|
5
5
|
// already true at arm time is silent and only the regression speaks.
|
|
6
|
-
//
|
|
6
|
+
//
|
|
7
|
+
// The load-bearing subtlety (E-GATE-1): a PostToolUse hook fires after EVERY
|
|
8
|
+
// edit, including the mid-sequence ones that leave a file temporarily
|
|
9
|
+
// unparseable. When a file fails to parse its routes vanish from the graph, so a
|
|
10
|
+
// naive diff reads that as ENTRYPOINT_REMOVED / GUARD_REMOVED and cries a false
|
|
11
|
+
// regression on the agent's own in-progress work. That false alarm, fired
|
|
12
|
+
// constantly, is what gets the gate uninstalled in five minutes. So: a regression
|
|
13
|
+
// whose baseline entrypoint lives in a now-unparseable file is NOT a regression —
|
|
14
|
+
// it is "unproven, come back when it parses". The gate ABSTAINS, never claims
|
|
15
|
+
// clean (it proved nothing) and never claims safe. An unparseable tree has no
|
|
16
|
+
// defined behavior to regress against — abstention is the sound call.
|
|
17
|
+
//
|
|
18
|
+
// Note on PostToolUse semantics: the edit has already landed, so exit 2 does not
|
|
19
|
+
// *prevent* it — it feeds the regression back to the agent as stderr, which the
|
|
20
|
+
// agent reads and corrects in the same session (self-heal in the loop). That is
|
|
21
|
+
// the honest claim: immediate feedback, not a hard block. (A hard pre-commit
|
|
22
|
+
// block is the pre-commit hook / CI Action's job — same command, `apocalypse`.)
|
|
23
|
+
// sparda gate report the edit's behavior delta vs baseline
|
|
7
24
|
// sparda gate --arm freeze the current graph as the accepted baseline
|
|
8
25
|
// sparda gate --hook Claude Code PostToolUse contract: silent when clean,
|
|
9
|
-
// report on stderr + exit 2 (
|
|
10
|
-
//
|
|
26
|
+
// report on stderr + exit 2 (feedback to the agent) on a
|
|
27
|
+
// real regression; abstains (exit 0) while a file is
|
|
28
|
+
// mid-edit; arms itself on first run — zero config.
|
|
11
29
|
import fs from 'node:fs';
|
|
12
30
|
import path from 'node:path';
|
|
13
31
|
import { compileUBG } from '../ubg/compile.js';
|
|
@@ -18,22 +36,49 @@ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
|
18
36
|
// severities that block the edit; medium/info regressions are reported, never fatal
|
|
19
37
|
const BLOCKING = new Set(['critical', 'high']);
|
|
20
38
|
const findingKey = (f) => `${f.rule}|${f.entrypoint}`;
|
|
39
|
+
// diff rules that mean "this route/protection disappeared" — the ones a vanished
|
|
40
|
+
// (unparseable) file can forge. Introduced-risk rules can never be forged by a
|
|
41
|
+
// missing file, so they are never suppressed.
|
|
42
|
+
const REMOVAL_RULES = new Set(['ENTRYPOINT_REMOVED', 'GUARD_REMOVED']);
|
|
43
|
+
|
|
44
|
+
// files the compiler could not read/parse this run — reason is prefixed by the
|
|
45
|
+
// module error (`parse error: …` / `unreadable: …`) in every extractor's skipped list.
|
|
46
|
+
function unparsedFiles(report) {
|
|
47
|
+
const out = new Set();
|
|
48
|
+
for (const s of report?.skipped ?? []) {
|
|
49
|
+
if (s.file && /(^|\b)(parse error|unreadable|Unexpected|SyntaxError)/i.test(s.reason))
|
|
50
|
+
out.add(s.file);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
21
54
|
|
|
22
55
|
// The gate's whole judgment, pure over two canonical graphs (testable without a repo):
|
|
23
56
|
// diff regressions (guard/route/invariant lost, blast radius grown) + static findings
|
|
24
57
|
// the candidate has that the baseline did not — the same composition `review` proved
|
|
25
|
-
// out (ADR-030), pointed at the baseline instead of a git ref.
|
|
26
|
-
|
|
58
|
+
// out (ADR-030), pointed at the baseline instead of a git ref. `unparsed` (the set of
|
|
59
|
+
// files that failed to parse in the candidate) splits the diff removals into real
|
|
60
|
+
// regressions vs in-progress edits whose baseline route lives in a now-broken file.
|
|
61
|
+
export function gateDelta(baseline, candidate, unparsed = new Set()) {
|
|
62
|
+
const baseLoc = new Map((baseline.nodes ?? []).map((n) => [n.id, n.loc?.file]));
|
|
27
63
|
const diff = diffGraphs(baseline, candidate).findings;
|
|
28
64
|
const before = new Set(checkGraph(baseline).findings.map(findingKey));
|
|
29
65
|
const introduced = checkGraph(candidate).findings.filter(
|
|
30
66
|
(f) => !before.has(findingKey(f)),
|
|
31
67
|
);
|
|
32
68
|
const seen = new Set(diff.map(findingKey));
|
|
33
|
-
const
|
|
69
|
+
const all = [...diff, ...introduced.filter((f) => !seen.has(findingKey(f)))];
|
|
70
|
+
|
|
71
|
+
const findings = [];
|
|
72
|
+
const abstained = [];
|
|
73
|
+
for (const f of all) {
|
|
74
|
+
if (REMOVAL_RULES.has(f.rule) && unparsed.has(baseLoc.get(f.entrypoint)))
|
|
75
|
+
abstained.push(f);
|
|
76
|
+
else findings.push(f);
|
|
77
|
+
}
|
|
34
78
|
return {
|
|
35
79
|
findings,
|
|
36
80
|
blocking: findings.filter((f) => BLOCKING.has(f.severity)),
|
|
81
|
+
abstained,
|
|
37
82
|
};
|
|
38
83
|
}
|
|
39
84
|
|
|
@@ -48,10 +93,25 @@ export async function runGate(opts) {
|
|
|
48
93
|
// in hook mode stdout belongs to nobody and stderr is what the agent reads
|
|
49
94
|
const say = hook ? (s) => console.error(s) : (s) => console.log(s);
|
|
50
95
|
|
|
51
|
-
const { graph } = compileUBG(opts.cwd, { write: false });
|
|
52
|
-
const candidate = canonicalizeGraph(graph);
|
|
53
96
|
const baselinePath = path.join(opts.cwd, '.sparda', 'ubg.baseline.json');
|
|
54
97
|
|
|
98
|
+
// A tree that does not compile at all (broken entry / detection throw) has no
|
|
99
|
+
// behavior to judge. Abstain instead of crashing the hook — the agent is mid-edit.
|
|
100
|
+
let graph, report;
|
|
101
|
+
try {
|
|
102
|
+
({ graph, report } = compileUBG(opts.cwd, { write: false }));
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (opts.json)
|
|
105
|
+
console.log(JSON.stringify({ ok: true, abstained: err.message }, null, 2));
|
|
106
|
+
else if (!hook)
|
|
107
|
+
say(
|
|
108
|
+
`⏳ GATE ABSTAINED — the tree does not compile yet (${err.message}). Fix it, then re-check.`,
|
|
109
|
+
);
|
|
110
|
+
return { armed: false, findings: [], abstained: true };
|
|
111
|
+
}
|
|
112
|
+
const candidate = canonicalizeGraph(graph);
|
|
113
|
+
const unparsed = unparsedFiles(report);
|
|
114
|
+
|
|
55
115
|
const arm = () => {
|
|
56
116
|
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
|
|
57
117
|
atomicWrite(baselinePath, JSON.stringify(candidate, null, 2) + '\n');
|
|
@@ -75,13 +135,18 @@ export async function runGate(opts) {
|
|
|
75
135
|
}
|
|
76
136
|
|
|
77
137
|
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
|
78
|
-
const { findings, blocking } = gateDelta(baseline, candidate);
|
|
138
|
+
const { findings, blocking, abstained } = gateDelta(baseline, candidate, unparsed);
|
|
79
139
|
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
80
140
|
|
|
81
141
|
if (opts.json) {
|
|
82
142
|
console.log(
|
|
83
143
|
JSON.stringify(
|
|
84
|
-
{
|
|
144
|
+
{
|
|
145
|
+
ok: blocking.length === 0,
|
|
146
|
+
ms: Math.round(ms),
|
|
147
|
+
findings,
|
|
148
|
+
abstained: abstained.map((f) => f.entrypoint),
|
|
149
|
+
},
|
|
85
150
|
null,
|
|
86
151
|
2,
|
|
87
152
|
),
|
|
@@ -90,8 +155,17 @@ export async function runGate(opts) {
|
|
|
90
155
|
return { armed: false, findings };
|
|
91
156
|
}
|
|
92
157
|
|
|
158
|
+
// Abstention notice: a route vanished only because its file doesn't parse yet.
|
|
159
|
+
// Never blocking — this is the normal mid-edit state, not a regression.
|
|
160
|
+
if (abstained.length) {
|
|
161
|
+
const files = [...new Set([...unparsed])].join(', ');
|
|
162
|
+
say(
|
|
163
|
+
`⏳ GATE HELD — ${abstained.length} route(s) can't be proven while ${files} doesn't parse (mid-edit?). Not a regression; I'll re-check once it parses.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
93
167
|
if (findings.length === 0) {
|
|
94
|
-
if (!hook)
|
|
168
|
+
if (!hook && !abstained.length)
|
|
95
169
|
say(
|
|
96
170
|
`✓ GATE CLEAN — this edit lost no guard, dropped no route, grew no blast radius (proven vs baseline in ${Math.round(ms)} ms).`,
|
|
97
171
|
);
|
package/src/commands/heal.js
CHANGED
|
@@ -100,7 +100,7 @@ async function gate(opts, id, flight, healDir) {
|
|
|
100
100
|
// 3. apocalypse: no new critical/high, nothing protected got removed
|
|
101
101
|
const fixedGraph = canonicalizeGraph(compileUBG(opts.cwd, { write: false }).graph);
|
|
102
102
|
const staticFindings = checkGraph(fixedGraph).findings;
|
|
103
|
-
let regressions
|
|
103
|
+
let regressions;
|
|
104
104
|
const baselinePath = path.join(healDir, 'baseline.json');
|
|
105
105
|
if (fs.existsSync(baselinePath)) {
|
|
106
106
|
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
package/src/commands/seed.js
CHANGED
|
@@ -277,7 +277,7 @@ export async function runSeed(opts, args) {
|
|
|
277
277
|
if (opts.germinate) {
|
|
278
278
|
const { buildGrammar } = await import('./grammar.js');
|
|
279
279
|
const { twinFilePath } = await import('./twin.js');
|
|
280
|
-
let exemplars
|
|
280
|
+
let exemplars;
|
|
281
281
|
try {
|
|
282
282
|
exemplars =
|
|
283
283
|
JSON.parse(fs.readFileSync(twinFilePath(opts.cwd), 'utf8')).exemplars ?? null;
|
package/src/detect.js
CHANGED
|
@@ -92,7 +92,7 @@ export function detectStack(cwd) {
|
|
|
92
92
|
const known = ['fastify', 'koa'].find((d) => deps[d]);
|
|
93
93
|
if (known)
|
|
94
94
|
throw err(
|
|
95
|
-
`${known} detected — not supported yet. Express, NestJS &
|
|
95
|
+
`${known} detected — not supported yet. Express, NestJS, FastAPI & Flask in v0.`,
|
|
96
96
|
'+1 the framework vote: github.com/zyx77550/sparda/issues/1',
|
|
97
97
|
);
|
|
98
98
|
}
|
|
@@ -108,6 +108,18 @@ export function detectStack(cwd) {
|
|
|
108
108
|
return { framework: 'fastapi', entryFile, port, pythonCmd };
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
+
// Flask — checked AFTER FastAPI (a FastAPI project may pull flask transitively; FastAPI
|
|
112
|
+
// wins). The same Python extractor handles it; only the entry-file marker differs.
|
|
113
|
+
for (const f of ['requirements.txt', 'pyproject.toml']) {
|
|
114
|
+
const p = path.join(cwd, f);
|
|
115
|
+
if (
|
|
116
|
+
fs.existsSync(p) &&
|
|
117
|
+
/(^|[^a-z])flask([^a-z]|$)/i.test(fs.readFileSync(p, 'utf8'))
|
|
118
|
+
) {
|
|
119
|
+
const entryFile = findFlaskEntry(cwd);
|
|
120
|
+
return { framework: 'flask', entryFile, port: 5000, pythonCmd: detectPython() };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
111
123
|
|
|
112
124
|
// Last resort before giving up — monorepo app dirs whose framework config lives
|
|
113
125
|
// ELSEWHERE than the pointed dir. Purely structural, so it never mis-fires on a
|
|
@@ -130,7 +142,7 @@ export function detectStack(cwd) {
|
|
|
130
142
|
|
|
131
143
|
const suggestions = suggestAppDirs(cwd);
|
|
132
144
|
throw err(
|
|
133
|
-
'No supported framework found (Express, Next.js, NestJS, Medusa, FastAPI).',
|
|
145
|
+
'No supported framework found (Express, Next.js, NestJS, Medusa, FastAPI, Flask).',
|
|
134
146
|
suggestions.length
|
|
135
147
|
? `This looks like a monorepo — the analyzable app is in a sub-directory. Try:\n` +
|
|
136
148
|
suggestions
|
|
@@ -401,7 +413,28 @@ function findFastAPIEntry(cwd) {
|
|
|
401
413
|
);
|
|
402
414
|
}
|
|
403
415
|
|
|
404
|
-
|
|
416
|
+
// Flask's entry is the file that calls `Flask(__name__)` — conventionally app.py /
|
|
417
|
+
// wsgi.py / run.py / main.py, else the first .py declaring it. Same shape as the
|
|
418
|
+
// FastAPI finder; the Python extractor handles both frameworks from that entry.
|
|
419
|
+
function findFlaskEntry(cwd) {
|
|
420
|
+
const candidates = ['app.py', 'wsgi.py', 'run.py', 'main.py', 'src/app.py'];
|
|
421
|
+
for (const rel of candidates) {
|
|
422
|
+
const abs = path.resolve(cwd, rel);
|
|
423
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
424
|
+
if (fs.readFileSync(abs, 'utf8').includes('Flask(')) {
|
|
425
|
+
return path.relative(cwd, abs).split(path.sep).join('/');
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const entry = searchPyFiles(cwd, cwd, { val: 0 }, 'Flask(');
|
|
430
|
+
if (entry) return path.relative(cwd, entry).split(path.sep).join('/');
|
|
431
|
+
throw err(
|
|
432
|
+
'Could not locate your Flask entry file (the one calling Flask()).',
|
|
433
|
+
'Specify it manually, or make sure Flask() is declared in one of your python files.',
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function searchPyFiles(dir, root, countRef = { val: 0 }, marker = 'FastAPI(') {
|
|
405
438
|
const EXCLUDE = new Set([
|
|
406
439
|
'node_modules',
|
|
407
440
|
'.git',
|
|
@@ -434,7 +467,7 @@ function searchPyFiles(dir, root, countRef = { val: 0 }) {
|
|
|
434
467
|
countRef.val++;
|
|
435
468
|
if (countRef.val > 200) return null;
|
|
436
469
|
const src = fs.readFileSync(abs, 'utf8');
|
|
437
|
-
if (src.includes(
|
|
470
|
+
if (src.includes(marker)) {
|
|
438
471
|
return abs;
|
|
439
472
|
}
|
|
440
473
|
}
|
package/src/flight/heal.js
CHANGED
|
@@ -27,7 +27,7 @@ export function evaluateHealing(flight, replay, expectation = null) {
|
|
|
27
27
|
reasons.push('response is byte-identical to the recorded bug — nothing changed');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
let expectationMet
|
|
30
|
+
let expectationMet;
|
|
31
31
|
if (expectation) {
|
|
32
32
|
expectationMet = true;
|
|
33
33
|
if (expectation.status !== undefined && replay.actual.status !== expectation.status) {
|
package/src/generator/express.js
CHANGED
|
@@ -42,7 +42,7 @@ export function ensureSpardaKey(cwd, prevManifest = null) {
|
|
|
42
42
|
// state (opt-in flags + observed circuits), and the `sparding` security
|
|
43
43
|
// memory survive as long as the tool's method+path are unchanged.
|
|
44
44
|
export function carryOverManifest(cwd, tools) {
|
|
45
|
-
let prev
|
|
45
|
+
let prev;
|
|
46
46
|
try {
|
|
47
47
|
prev = JSON.parse(fs.readFileSync(path.join(cwd, 'sparda.json'), 'utf8'));
|
|
48
48
|
} catch {
|
package/src/parser/fastapi.js
CHANGED
package/src/ubg/apocalypse.js
CHANGED
|
@@ -58,11 +58,14 @@ export function buildProofObjects(graph) {
|
|
|
58
58
|
.map((id) => g.nodes.get(id))
|
|
59
59
|
.filter(Boolean);
|
|
60
60
|
const guards = reached.filter((n) => n.kind === 'guard');
|
|
61
|
-
const
|
|
61
|
+
const allWrites = [];
|
|
62
62
|
for (const n of reached)
|
|
63
63
|
if (n.kind === 'effect')
|
|
64
64
|
for (const e of g.mutOut.get(n.id) ?? [])
|
|
65
|
-
|
|
65
|
+
allWrites.push({ effect: n, stateId: e.to });
|
|
66
|
+
// guard-dominance (C2): a write that runs BEFORE the route's guard is NOT discharged by it —
|
|
67
|
+
// never claim it in a proof (scan-level truth, authoritative over the graph's hoisted gate edge).
|
|
68
|
+
const writes = allWrites.filter((w) => !w.effect.meta?.bypassesGuard);
|
|
66
69
|
// a proof object is emitted ONLY for a genuinely DISCHARGED obligation: a mutating route
|
|
67
70
|
// (O1 applies) that actually reaches a guard. A guardless mutation is a FINDING, not a proof.
|
|
68
71
|
if (!writes.length || !guards.length) continue;
|
|
@@ -153,7 +156,28 @@ export function checkGraph(graph) {
|
|
|
153
156
|
|
|
154
157
|
// O1 — every mutation path must pass a guard
|
|
155
158
|
obligations++;
|
|
156
|
-
|
|
159
|
+
// Guard-DOMINANCE (kills the C2 false PROVEN): a guard on the route does NOT cover a mutation
|
|
160
|
+
// that runs BEFORE it — an early-return branch that mutates then checks auth after, or a write in
|
|
161
|
+
// a branch a sibling guards. `bypassesGuard` is the SCAN-LEVEL truth (set only when a guard
|
|
162
|
+
// demonstrably follows the write in the SAME body), and it is authoritative: the graph hoists an
|
|
163
|
+
// in-body guard into a chain node with a gate edge, so a gate edge cannot be trusted to mean
|
|
164
|
+
// "runs before the whole body". Under-approximated by construction, so it can only ever turn a
|
|
165
|
+
// PROVEN into NOT_PROVEN — never fabricate a guard, never a false PROVEN.
|
|
166
|
+
const bypassWrites =
|
|
167
|
+
guards.length > 0 ? writes.filter((w) => w.effect.meta.bypassesGuard) : [];
|
|
168
|
+
if (writes.length) vec.auth = guards.length === 0 || bypassWrites.length ? -1 : 1;
|
|
169
|
+
if (bypassWrites.length) {
|
|
170
|
+
// A bypassed guard is worse than no guard (it reads as protected but isn't) — a hard critical,
|
|
171
|
+
// never softened by the credential/public-by-design taxonomy below.
|
|
172
|
+
findings.push({
|
|
173
|
+
rule: 'UNGUARDED_MUTATION',
|
|
174
|
+
severity: 'critical',
|
|
175
|
+
guardBypass: true,
|
|
176
|
+
entrypoint: ep.id,
|
|
177
|
+
message: `${ep.label} mutates ${fmtStates(bypassWrites)} BEFORE its guard runs — an early-return / pre-check branch reaches the write without passing the route's auth check, so the guard does not cover it`,
|
|
178
|
+
evidence: bypassWrites.map((w) => `${w.effect.id} (${locOf(w.effect)})`),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
157
181
|
if (writes.length && guards.length === 0) {
|
|
158
182
|
// taint enrichment (ADR-P1): does request data provably flow into one of these
|
|
159
183
|
// writes? A tag on an ALREADY-emitted finding — never a finding of its own — so it
|
package/src/ubg/compile.js
CHANGED
|
@@ -34,6 +34,9 @@ export function compileUBG(
|
|
|
34
34
|
medusa: () => extractMedusa(cwd, stack.entryFile),
|
|
35
35
|
nextjs: () => extractNext(cwd, stack.entryFile),
|
|
36
36
|
fastapi: () => extractFastAPI(cwd, stack.entryFile, stack.pythonCmd),
|
|
37
|
+
// Flask reuses the FastAPI (Python) extractor — same stdlib-ast walk, Flask route
|
|
38
|
+
// discovery folded in (Flask()/Blueprint/@app.route/register_blueprint/@login_required).
|
|
39
|
+
flask: () => extractFastAPI(cwd, stack.entryFile, stack.pythonCmd),
|
|
37
40
|
openapi: () => extractOpenAPI(cwd, stack.entryFile),
|
|
38
41
|
};
|
|
39
42
|
if (!extractors[stack.framework]) {
|
package/src/ubg/extract.js
CHANGED
|
@@ -839,6 +839,8 @@ export function scanFunction(fnNode, env = {}) {
|
|
|
839
839
|
// an advisory (never prove a guard, never silence a finding), so widening them can
|
|
840
840
|
// never fabricate a gate (E-042) or a false PROVEN.
|
|
841
841
|
credentialSignals: { verifyCall: false, denies4xxOrThrows: false, redirects: false },
|
|
842
|
+
// guard-dominance (C2): set true once any in-body guard barrier is seen while walking the body.
|
|
843
|
+
hasInBodyGuard: false,
|
|
842
844
|
async: Boolean(fnNode?.async),
|
|
843
845
|
};
|
|
844
846
|
if (!fnNode) return result;
|
|
@@ -853,6 +855,14 @@ export function scanFunction(fnNode, env = {}) {
|
|
|
853
855
|
// `this.knex(this.collection)` inside a service resolve to `:collection`.
|
|
854
856
|
thisSymbols: env.thisSymbols ?? null,
|
|
855
857
|
});
|
|
858
|
+
// guard-dominance: a mutation on an unguarded path is a real bypass ONLY when THIS body also holds
|
|
859
|
+
// a guard (so a guard is genuinely being circumvented). A body with no guard of its own is guarded
|
|
860
|
+
// elsewhere (positional middleware / a caller) — leave it to the route model, untouched. Strip the
|
|
861
|
+
// temporary marker either way so it never leaks into the graph.
|
|
862
|
+
for (const e of result.effects) {
|
|
863
|
+
if (result.hasInBodyGuard && e._unguardedPath) e.bypassesGuard = true;
|
|
864
|
+
delete e._unguardedPath;
|
|
865
|
+
}
|
|
856
866
|
return result;
|
|
857
867
|
}
|
|
858
868
|
|
|
@@ -1080,6 +1090,91 @@ function collectReqDerived(fnNode) {
|
|
|
1080
1090
|
// SBIR §2.2 — transaction wrappers whose function arguments open a scope
|
|
1081
1091
|
const TX_WRAPPERS = new Set(['transaction', '$transaction', 'withTransaction']);
|
|
1082
1092
|
|
|
1093
|
+
// Mutating effect kinds — the ones a guard is supposed to dominate (O1). A read is never a bypass.
|
|
1094
|
+
const MUTATING_EFFECTS = new Set(['db_write', 'http_call', 'fs_write']);
|
|
1095
|
+
|
|
1096
|
+
// Guard-DOMINANCE (kills the C2 false PROVEN): a guard on a route's chain does not make EVERY
|
|
1097
|
+
// write on the route safe — the guard must run BEFORE the write. A handler that mutates in an
|
|
1098
|
+
// early-return branch and only checks auth afterwards (`if (preview) { charge.create(); return }
|
|
1099
|
+
// … await requireAuth(req)`) bypasses its own guard. We compute, per body, which mutations
|
|
1100
|
+
// execute on a path that has not yet passed a guard, AND where a guard follows on the same spine
|
|
1101
|
+
// (so there genuinely IS a guard being bypassed — a body whose guard lives cross-procedurally is
|
|
1102
|
+
// left to the route-level model, unchanged). This can only ever SUBTRACT guard credit — it never
|
|
1103
|
+
// invents a guard — so a heuristic barrier is sound: at worst it misses a bypass, never fabricates.
|
|
1104
|
+
//
|
|
1105
|
+
// A spine statement sets the barrier when everything sequentially after it has passed the check:
|
|
1106
|
+
// (a) an early-deny gate — `if (test) throw | return <4xx>` (fall-through ⇒ the check passed), or
|
|
1107
|
+
// (b) a guard CALL on the spine — `await requireAuth(req)` / `const s = await getSession()`.
|
|
1108
|
+
function spineCall(stmt) {
|
|
1109
|
+
let e = null;
|
|
1110
|
+
if (stmt.type === 'ExpressionStatement') e = stmt.expression;
|
|
1111
|
+
else if (stmt.type === 'VariableDeclaration' && stmt.declarations[0])
|
|
1112
|
+
e = stmt.declarations[0].init;
|
|
1113
|
+
if (e?.type === 'AwaitExpression') e = e.argument;
|
|
1114
|
+
return e?.type === 'CallExpression' ? e : null;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// An AUTHORIZATION gate, tightly — NOT the broad GUARD_NAME (which matches business calls like
|
|
1118
|
+
// `hasAdmin`/`getRole`/`verifyEmail` and would flood false bypasses). Only names that unambiguously
|
|
1119
|
+
// gate access: `require/ensure/assert{Auth,Session,User,Owner,Permission,Role,Admin,Login,Access}`,
|
|
1120
|
+
// `authenticate`/`authorize`, `canActivate`, `get(Server)Session`, `check/verify{Auth,Session,…}`.
|
|
1121
|
+
const AUTH_GATE_CALL =
|
|
1122
|
+
/(^|[^a-z])(require|ensure|assert)[_]?(auth|session|user|owner|permission|role|admin|login|access)|authenticate|authoriz(e|ation)|can[_]?activate|get[_]?server[_]?session|get[_]?session|check[_]?(auth|permission|access|session)|verify[_]?(auth|session|permission)|protect[_]?route/i;
|
|
1123
|
+
|
|
1124
|
+
function isGuardCall(call) {
|
|
1125
|
+
const callee = call.callee;
|
|
1126
|
+
const name =
|
|
1127
|
+
callee?.type === 'Identifier'
|
|
1128
|
+
? callee.name
|
|
1129
|
+
: callee?.type === 'MemberExpression' && callee.property?.type === 'Identifier'
|
|
1130
|
+
? callee.property.name
|
|
1131
|
+
: '';
|
|
1132
|
+
return AUTH_GATE_CALL.test(name);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Does this subtree AUTH-deny — refuse with 401/403 or an auth exception? (Deliberately NOT any
|
|
1136
|
+
// throw or any 4xx: a validation `throw new BadRequestException()` inside a service is not an auth
|
|
1137
|
+
// gate, and treating it as one is what produced false bypasses.)
|
|
1138
|
+
function subtreeAuthDenies(node) {
|
|
1139
|
+
let found = false;
|
|
1140
|
+
const walk = (n) => {
|
|
1141
|
+
if (found || !n || typeof n !== 'object') return;
|
|
1142
|
+
if (Array.isArray(n)) {
|
|
1143
|
+
for (const x of n) walk(x);
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (n.type === 'CallExpression' && deniedStatusOf(n)) return void (found = true);
|
|
1147
|
+
if (n.type === 'NewExpression') {
|
|
1148
|
+
const nm = n.callee?.name;
|
|
1149
|
+
if (
|
|
1150
|
+
/^(Unauthorized|Forbidden)(Exception|Error)$/.test(nm ?? '') ||
|
|
1151
|
+
(Array.isArray(n.arguments) && n.arguments.some(isDenyOptions))
|
|
1152
|
+
)
|
|
1153
|
+
return void (found = true);
|
|
1154
|
+
}
|
|
1155
|
+
for (const k of Object.keys(n)) {
|
|
1156
|
+
if (
|
|
1157
|
+
k === 'loc' ||
|
|
1158
|
+
k === 'range' ||
|
|
1159
|
+
k === 'leadingComments' ||
|
|
1160
|
+
k === 'trailingComments'
|
|
1161
|
+
)
|
|
1162
|
+
continue;
|
|
1163
|
+
const v = n[k];
|
|
1164
|
+
if (v && typeof v === 'object') walk(v);
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
walk(node);
|
|
1168
|
+
return found;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function statementSetsBarrier(stmt) {
|
|
1172
|
+
if (!stmt) return false;
|
|
1173
|
+
if (stmt.type === 'IfStatement') return subtreeAuthDenies(stmt.consequent);
|
|
1174
|
+
const call = spineCall(stmt);
|
|
1175
|
+
return call ? isGuardCall(call) : false;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1083
1178
|
function visit(node, out, ctx) {
|
|
1084
1179
|
if (!node || typeof node !== 'object') return;
|
|
1085
1180
|
if (Array.isArray(node)) {
|
|
@@ -1087,6 +1182,24 @@ function visit(node, out, ctx) {
|
|
|
1087
1182
|
return;
|
|
1088
1183
|
}
|
|
1089
1184
|
|
|
1185
|
+
// Ordered spine walk of a block, tracking whether a guard has executed ON THE CURRENT PATH. A
|
|
1186
|
+
// spine guard covers everything sequentially after it AND everything nested inside those later
|
|
1187
|
+
// statements; a guard INSIDE a branch covers only that branch, never a sibling. Each mutation
|
|
1188
|
+
// visited while the path is still unguarded is tagged `_unguardedPath` — a write reachable without
|
|
1189
|
+
// passing a guard. (scanFunction promotes those to `bypassesGuard` only when THIS body actually
|
|
1190
|
+
// has an in-body guard, so a body guarded cross-procedurally is left to the route model, unchanged.)
|
|
1191
|
+
if (node.type === 'BlockStatement') {
|
|
1192
|
+
let guarded = ctx.guarded === true;
|
|
1193
|
+
for (const stmt of node.body) {
|
|
1194
|
+
visit(stmt, out, { ...ctx, guarded });
|
|
1195
|
+
if (statementSetsBarrier(stmt)) {
|
|
1196
|
+
guarded = true;
|
|
1197
|
+
out.hasInBodyGuard = true;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1090
1203
|
// G2 credential signal (advisory-only): the body can refuse — any throw, or any 4xx
|
|
1091
1204
|
// response. Deliberately BROADER than guardSignals.deniesWithStatus (401/403) because it
|
|
1092
1205
|
// can only ever downgrade a critical to an advisory, never verify a guard.
|
|
@@ -1491,6 +1604,10 @@ function pushEffect(out, ctx, effect) {
|
|
|
1491
1604
|
}
|
|
1492
1605
|
if (ctx?.tryId != null) effect.tryId = ctx.tryId;
|
|
1493
1606
|
if (ctx?.catchOf != null) effect.catchOf = ctx.catchOf;
|
|
1607
|
+
// guard-dominance (C2): a mutation reached while no guard has run on this path. Temporary — promoted
|
|
1608
|
+
// to `bypassesGuard` in scanFunction only if this body also HAS a guard (else it is left unchanged).
|
|
1609
|
+
if (ctx?.guarded !== true && MUTATING_EFFECTS.has(effect.effectType))
|
|
1610
|
+
effect._unguardedPath = true;
|
|
1494
1611
|
out.effects.push(effect);
|
|
1495
1612
|
}
|
|
1496
1613
|
|
package/src/ubg/fastapi.js
CHANGED
|
@@ -38,6 +38,7 @@ export function extractFastAPI(cwd, entryFile, pythonCmd = 'python') {
|
|
|
38
38
|
} catch (err) {
|
|
39
39
|
throw new Error(
|
|
40
40
|
`FastAPI UBG extractor returned invalid JSON: ${err.message}. Raw: ${res.stdout.slice(0, 200)}`,
|
|
41
|
+
{ cause: err },
|
|
41
42
|
);
|
|
42
43
|
}
|
|
43
44
|
if (payload.error) throw new Error(payload.error);
|
|
@@ -305,12 +305,42 @@ def scan_function(fn):
|
|
|
305
305
|
"validatesInput": False,
|
|
306
306
|
"async": isinstance(fn, ast.AsyncFunctionDef),
|
|
307
307
|
}
|
|
308
|
-
|
|
308
|
+
# var -> model table, from `u = User(...)` — so `db.session.add(u)` (the dominant
|
|
309
|
+
# Flask-SQLAlchemy write) resolves to a real table instead of an untable-able blind write.
|
|
310
|
+
var_models = {}
|
|
311
|
+
for n in ast.walk(fn):
|
|
312
|
+
if (isinstance(n, ast.Assign) and len(n.targets) == 1
|
|
313
|
+
and isinstance(n.targets[0], ast.Name)
|
|
314
|
+
and isinstance(n.value, ast.Call)
|
|
315
|
+
and isinstance(n.value.func, ast.Name)
|
|
316
|
+
and n.value.func.id[:1].isupper()):
|
|
317
|
+
var_models[n.targets[0].id] = n.value.func.id.lower()
|
|
318
|
+
ctx = {"tx": None, "iso": "default", "tryId": None, "catchOf": None,
|
|
319
|
+
"var_models": var_models}
|
|
309
320
|
for stmt in fn.body:
|
|
310
321
|
_visit(stmt, out, ctx)
|
|
311
322
|
return out
|
|
312
323
|
|
|
313
324
|
|
|
325
|
+
def query_model_of(node):
|
|
326
|
+
"""Flask-SQLAlchemy Query API: `Model.query.filter_by(...).delete()` /
|
|
327
|
+
`User.query.get(id)` → 'model' — the capitalized Name that `.query` hangs off,
|
|
328
|
+
however deep the chain (filter/filter_by/order_by/… are all calls)."""
|
|
329
|
+
cur = node
|
|
330
|
+
while cur is not None:
|
|
331
|
+
if (isinstance(cur, ast.Attribute) and cur.attr == "query"
|
|
332
|
+
and isinstance(cur.value, ast.Name)
|
|
333
|
+
and cur.value.id[:1].isupper()):
|
|
334
|
+
return cur.value.id.lower()
|
|
335
|
+
if isinstance(cur, ast.Attribute):
|
|
336
|
+
cur = cur.value
|
|
337
|
+
elif isinstance(cur, ast.Call):
|
|
338
|
+
cur = cur.func.value if isinstance(cur.func, ast.Attribute) else None
|
|
339
|
+
else:
|
|
340
|
+
cur = None
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
314
344
|
def _visit(node, out, ctx):
|
|
315
345
|
if isinstance(node, ast.Try):
|
|
316
346
|
try_line = node.lineno
|
|
@@ -434,6 +464,16 @@ def inspect_call(node, out, ctx):
|
|
|
434
464
|
# The receiver is matched on the full dotted chain, so `self.db.add(…)`
|
|
435
465
|
# inside a service class reads like `db.add(…)` at module level.
|
|
436
466
|
recv = dotted_name(func.value)
|
|
467
|
+
# Flask-SQLAlchemy Query API: Model.query.filter_by(...).delete() / .update() — a write on
|
|
468
|
+
# the model the chain roots at. (A bare Model.query.get()/all() is a read, handled as select.)
|
|
469
|
+
if method in ("delete", "update"):
|
|
470
|
+
qm = query_model_of(func.value)
|
|
471
|
+
if qm:
|
|
472
|
+
push_effect(out, ctx, {
|
|
473
|
+
"effectType": "db_write", "op": method, "table": qm,
|
|
474
|
+
"line": line, "driver": root or "unknown",
|
|
475
|
+
})
|
|
476
|
+
return
|
|
437
477
|
if method == "query" and node.args and isinstance(node.args[0], ast.Name):
|
|
438
478
|
push_effect(out, ctx, {
|
|
439
479
|
"effectType": "db_read", "op": "select",
|
|
@@ -443,8 +483,18 @@ def inspect_call(node, out, ctx):
|
|
|
443
483
|
return
|
|
444
484
|
if (method in ("add", "add_all", "merge") and recv
|
|
445
485
|
and re.search(r"session|db", recv, re.I)):
|
|
486
|
+
# resolve the model: db.session.add(u) where `u = User(...)`, or the inline
|
|
487
|
+
# db.session.add(User(...)). An unresolved add stays table-less (still a write).
|
|
488
|
+
table = None
|
|
489
|
+
if node.args:
|
|
490
|
+
a = node.args[0]
|
|
491
|
+
if isinstance(a, ast.Name):
|
|
492
|
+
table = ctx.get("var_models", {}).get(a.id)
|
|
493
|
+
elif (isinstance(a, ast.Call) and isinstance(a.func, ast.Name)
|
|
494
|
+
and a.func.id[:1].isupper()):
|
|
495
|
+
table = a.func.id.lower()
|
|
446
496
|
push_effect(out, ctx, {
|
|
447
|
-
"effectType": "db_write", "op": "insert", "table":
|
|
497
|
+
"effectType": "db_write", "op": "insert", "table": table,
|
|
448
498
|
"line": line, "driver": root,
|
|
449
499
|
})
|
|
450
500
|
return
|
|
@@ -873,6 +923,10 @@ class UbgExtractor:
|
|
|
873
923
|
for t in self.dep_targets_of(kw.value):
|
|
874
924
|
self.global_middlewares.append(
|
|
875
925
|
{"target": t, "file": abs_file})
|
|
926
|
+
elif cname == "Flask":
|
|
927
|
+
# Flask app: `app = Flask(__name__)`. Routes hang off @app.route /
|
|
928
|
+
# @app.get/@app.post — same decorator-on-function shape as FastAPI.
|
|
929
|
+
app_vars.add(var)
|
|
876
930
|
elif cname == "APIRouter":
|
|
877
931
|
router_vars.add(var)
|
|
878
932
|
router_prefixes[var] = ""
|
|
@@ -882,6 +936,16 @@ class UbgExtractor:
|
|
|
882
936
|
router_prefixes[var] = lit(kw.value)
|
|
883
937
|
if kw.arg == "dependencies":
|
|
884
938
|
router_deps[var] = self.dep_targets_of(kw.value)
|
|
939
|
+
elif cname == "Blueprint":
|
|
940
|
+
# Flask Blueprint: `bp = Blueprint('x', __name__, url_prefix='/y')`.
|
|
941
|
+
# A blueprint is FastAPI's APIRouter by another name — the same
|
|
942
|
+
# prefix + register-later model, so it reuses the router machinery.
|
|
943
|
+
router_vars.add(var)
|
|
944
|
+
router_prefixes[var] = ""
|
|
945
|
+
router_deps[var] = []
|
|
946
|
+
for kw in call.keywords:
|
|
947
|
+
if kw.arg == "url_prefix" and isinstance(lit(kw.value), str):
|
|
948
|
+
router_prefixes[var] = lit(kw.value)
|
|
885
949
|
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
886
950
|
functions[node.name] = node
|
|
887
951
|
|
|
@@ -926,18 +990,20 @@ class UbgExtractor:
|
|
|
926
990
|
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
|
|
927
991
|
self.collect_mount(node.value, abs_file, prefix, import_map,
|
|
928
992
|
router_vars, router_prefixes, depth)
|
|
993
|
+
self.collect_cbv(node.value, abs_file, prefix, modctx, rel_file,
|
|
994
|
+
router_vars, router_prefixes)
|
|
929
995
|
continue
|
|
930
996
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
931
997
|
continue
|
|
932
998
|
found = self.route_decorator_of(node, app_vars, router_vars)
|
|
933
999
|
if not found:
|
|
934
1000
|
continue
|
|
935
|
-
dec, obj_name,
|
|
1001
|
+
dec, obj_name, methods = found
|
|
936
1002
|
raw_path = lit(dec.args[0]) if dec.args else None
|
|
937
1003
|
if not isinstance(raw_path, str):
|
|
938
1004
|
self.skipped.append({
|
|
939
1005
|
"reason": "dynamic path on %s (non-literal first arg)"
|
|
940
|
-
%
|
|
1006
|
+
% "/".join(m.upper() for m in methods),
|
|
941
1007
|
"file": rel_file, "line": node.lineno,
|
|
942
1008
|
})
|
|
943
1009
|
continue
|
|
@@ -954,6 +1020,28 @@ class UbgExtractor:
|
|
|
954
1020
|
continue
|
|
955
1021
|
|
|
956
1022
|
chain = []
|
|
1023
|
+
# Flask auth decorators — @login_required / @jwt_required() / @requires_auth —
|
|
1024
|
+
# gate the handler exactly like a resolved FastAPI dependency that denies. A
|
|
1025
|
+
# decorator whose NAME is an auth gate is a VERIFIED guard (the framework enforces
|
|
1026
|
+
# the 401), so a mutation behind it is not a false UNGUARDED_MUTATION.
|
|
1027
|
+
for d in node.decorator_list:
|
|
1028
|
+
dname = (d.func.attr if isinstance(d, ast.Call)
|
|
1029
|
+
and isinstance(d.func, ast.Attribute)
|
|
1030
|
+
else d.func.id if isinstance(d, ast.Call)
|
|
1031
|
+
and isinstance(d.func, ast.Name)
|
|
1032
|
+
else d.attr if isinstance(d, ast.Attribute)
|
|
1033
|
+
else d.id if isinstance(d, ast.Name) else "")
|
|
1034
|
+
if re.search(r'login_required|jwt_required|requires?_auth'
|
|
1035
|
+
r'|auth_required|require_login|token_required'
|
|
1036
|
+
r'|permission_required', dname or "", re.I):
|
|
1037
|
+
chain.append({
|
|
1038
|
+
"name": dname, "role": "middleware", "sourceFile": rel_file,
|
|
1039
|
+
"sourceLine": node.lineno,
|
|
1040
|
+
"scan": {"effects": [], "returnShapes": [], "calls": [],
|
|
1041
|
+
"async": False, "validatesInput": False,
|
|
1042
|
+
"guardSignals": {"deniesWithStatus": True}},
|
|
1043
|
+
})
|
|
1044
|
+
break
|
|
957
1045
|
dep_names = list(router_deps.get(obj_name, []))
|
|
958
1046
|
for kw in dec.keywords:
|
|
959
1047
|
if kw.arg == "dependencies":
|
|
@@ -971,7 +1059,9 @@ class UbgExtractor:
|
|
|
971
1059
|
else:
|
|
972
1060
|
self.skipped.append({
|
|
973
1061
|
"reason": "dependency '%s' not resolved on %s %s"
|
|
974
|
-
% (dep_name,
|
|
1062
|
+
% (dep_name,
|
|
1063
|
+
"/".join(m.upper() for m in methods),
|
|
1064
|
+
full_path),
|
|
975
1065
|
"file": rel_file, "line": node.lineno,
|
|
976
1066
|
})
|
|
977
1067
|
# DI bindings: `svc: UserService = Depends(get_user_service)` /
|
|
@@ -1003,31 +1093,172 @@ class UbgExtractor:
|
|
|
1003
1093
|
"sourceLine": node.lineno, "scan": handler_scan,
|
|
1004
1094
|
})
|
|
1005
1095
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1096
|
+
# one entrypoint per HTTP method (a Flask `@app.route(methods=[…])` can
|
|
1097
|
+
# declare several; FastAPI/@app.get yields exactly one)
|
|
1098
|
+
for method in methods:
|
|
1099
|
+
self.routes.append({
|
|
1100
|
+
"method": method,
|
|
1101
|
+
"path": full_path,
|
|
1102
|
+
"sourceFile": rel_file,
|
|
1103
|
+
"sourceLine": node.lineno,
|
|
1104
|
+
"params": params,
|
|
1105
|
+
"chain": chain,
|
|
1106
|
+
"description": (ast.get_docstring(node) or "")[:400],
|
|
1107
|
+
})
|
|
1015
1108
|
|
|
1016
1109
|
def route_decorator_of(self, fn, app_vars, router_vars):
|
|
1110
|
+
# Returns (decorator, owner_var, [methods]). Matches BOTH:
|
|
1111
|
+
# @app.get / @router.post / @app.delete (FastAPI + Flask 2.0 shorthand)
|
|
1112
|
+
# @app.route("/x", methods=["GET","POST"]) (Flask — methods in a kwarg, default GET)
|
|
1017
1113
|
for dec in fn.decorator_list:
|
|
1018
|
-
if isinstance(dec, ast.Call)
|
|
1019
|
-
and dec.func.
|
|
1020
|
-
and isinstance(dec.func.value, ast.Name)
|
|
1114
|
+
if not (isinstance(dec, ast.Call)
|
|
1115
|
+
and isinstance(dec.func, ast.Attribute)
|
|
1116
|
+
and isinstance(dec.func.value, ast.Name)
|
|
1021
1117
|
and (dec.func.value.id in app_vars
|
|
1022
|
-
or dec.func.value.id in router_vars):
|
|
1023
|
-
|
|
1118
|
+
or dec.func.value.id in router_vars)):
|
|
1119
|
+
continue
|
|
1120
|
+
attr = dec.func.attr
|
|
1121
|
+
if attr in HTTP_VERBS:
|
|
1122
|
+
return dec, dec.func.value.id, [attr]
|
|
1123
|
+
if attr == "route":
|
|
1124
|
+
methods = ["get"]
|
|
1125
|
+
for kw in dec.keywords:
|
|
1126
|
+
if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
|
|
1127
|
+
got = [lit(e).lower() for e in kw.value.elts
|
|
1128
|
+
if isinstance(lit(e), str)
|
|
1129
|
+
and lit(e).lower() in HTTP_VERBS]
|
|
1130
|
+
if got:
|
|
1131
|
+
methods = got
|
|
1132
|
+
return dec, dec.func.value.id, methods
|
|
1024
1133
|
return None
|
|
1025
1134
|
|
|
1135
|
+
def collect_cbv(self, call, abs_file, prefix, modctx, rel_file,
|
|
1136
|
+
router_vars, router_prefixes):
|
|
1137
|
+
"""Flask class-based views: a MethodView registered via
|
|
1138
|
+
`app.add_url_rule('/x', view_func=Cls.as_view('n'), methods=[…])`, or a
|
|
1139
|
+
Flask-RESTful `api.add_resource(Cls, '/x')`. Each HTTP-verb method on the
|
|
1140
|
+
class (or up its bases) becomes a route, scanned like any handler — so the
|
|
1141
|
+
DELETE/POST inside a MethodView is no longer an invisible mutation surface."""
|
|
1142
|
+
if not (isinstance(call.func, ast.Attribute) and call.args):
|
|
1143
|
+
return
|
|
1144
|
+
attr = call.func.attr
|
|
1145
|
+
if attr not in ("add_url_rule", "add_resource"):
|
|
1146
|
+
return
|
|
1147
|
+
owner = call.func.value
|
|
1148
|
+
owner_name = owner.id if isinstance(owner, ast.Name) else None
|
|
1149
|
+
local_prefix = (router_prefixes.get(owner_name, "")
|
|
1150
|
+
if owner_name in router_vars else "")
|
|
1151
|
+
cbv_name, raw_path, restrict = None, None, None
|
|
1152
|
+
if attr == "add_url_rule":
|
|
1153
|
+
raw_path = lit(call.args[0]) if call.args else None
|
|
1154
|
+
for kw in call.keywords:
|
|
1155
|
+
if (kw.arg == "view_func" and isinstance(kw.value, ast.Call)
|
|
1156
|
+
and isinstance(kw.value.func, ast.Attribute)
|
|
1157
|
+
and kw.value.func.attr == "as_view"
|
|
1158
|
+
and isinstance(kw.value.func.value, ast.Name)):
|
|
1159
|
+
cbv_name = kw.value.func.value.id
|
|
1160
|
+
elif kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
|
|
1161
|
+
restrict = {lit(e).lower() for e in kw.value.elts
|
|
1162
|
+
if isinstance(lit(e), str)}
|
|
1163
|
+
else: # add_resource(Cls, "/path")
|
|
1164
|
+
if isinstance(call.args[0], ast.Name):
|
|
1165
|
+
cbv_name = call.args[0].id
|
|
1166
|
+
raw_path = lit(call.args[1]) if len(call.args) > 1 else None
|
|
1167
|
+
if not cbv_name or not isinstance(raw_path, str):
|
|
1168
|
+
return
|
|
1169
|
+
hit = self.resolve_class(modctx, cbv_name)
|
|
1170
|
+
if not hit:
|
|
1171
|
+
return
|
|
1172
|
+
cls, cmod = hit
|
|
1173
|
+
full_path = (prefix + local_prefix + raw_path).replace("//", "/")
|
|
1174
|
+
if not full_path.startswith("/"):
|
|
1175
|
+
full_path = "/" + full_path
|
|
1176
|
+
# class-level guards: MethodView `decorators = [...]` / Flask-RESTful
|
|
1177
|
+
# `method_decorators = [...]` apply to every verb method.
|
|
1178
|
+
class_guard = self.cbv_class_guard(cls, rel_file)
|
|
1179
|
+
for verb in HTTP_VERBS:
|
|
1180
|
+
if restrict is not None and verb not in restrict:
|
|
1181
|
+
continue
|
|
1182
|
+
m = self.method_in_class_chain(cls, cmod, verb)
|
|
1183
|
+
if not m:
|
|
1184
|
+
continue
|
|
1185
|
+
fn, fmod = m
|
|
1186
|
+
mrel = self.rel(fmod["file"])
|
|
1187
|
+
chain = []
|
|
1188
|
+
for g in (class_guard + self.auth_guard_steps(fn, mrel)):
|
|
1189
|
+
chain.append(g)
|
|
1190
|
+
chain.append({
|
|
1191
|
+
"name": "%s.%s" % (cbv_name, verb), "role": "handler",
|
|
1192
|
+
"sourceFile": mrel, "sourceLine": fn.lineno,
|
|
1193
|
+
"scan": self.deep_scan(fn, fmod),
|
|
1194
|
+
})
|
|
1195
|
+
self.routes.append({
|
|
1196
|
+
"method": verb, "path": full_path,
|
|
1197
|
+
"sourceFile": mrel, "sourceLine": fn.lineno,
|
|
1198
|
+
"params": self.params_of(fn, full_path),
|
|
1199
|
+
"chain": chain,
|
|
1200
|
+
"description": (ast.get_docstring(fn) or "")[:400],
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
def auth_guard_steps(self, fn, rel_file):
|
|
1204
|
+
"""@login_required / @jwt_required / @requires_auth on a handler → a verified guard step."""
|
|
1205
|
+
steps = []
|
|
1206
|
+
for d in getattr(fn, "decorator_list", []):
|
|
1207
|
+
dname = (d.func.attr if isinstance(d, ast.Call)
|
|
1208
|
+
and isinstance(d.func, ast.Attribute)
|
|
1209
|
+
else d.func.id if isinstance(d, ast.Call)
|
|
1210
|
+
and isinstance(d.func, ast.Name)
|
|
1211
|
+
else d.attr if isinstance(d, ast.Attribute)
|
|
1212
|
+
else d.id if isinstance(d, ast.Name) else "")
|
|
1213
|
+
if re.search(r'login_required|jwt_required|requires?_auth|auth_required'
|
|
1214
|
+
r'|require_login|token_required|permission_required',
|
|
1215
|
+
dname or "", re.I):
|
|
1216
|
+
steps.append(self._guard_step(dname, rel_file, fn.lineno))
|
|
1217
|
+
break
|
|
1218
|
+
return steps
|
|
1219
|
+
|
|
1220
|
+
def cbv_class_guard(self, cls, rel_file):
|
|
1221
|
+
"""MethodView `decorators = [login_required]` / Flask-RESTful
|
|
1222
|
+
`method_decorators = [jwt_required]` — a class-level guard over every method."""
|
|
1223
|
+
for stmt in cls.body:
|
|
1224
|
+
if (isinstance(stmt, ast.Assign) and len(stmt.targets) == 1
|
|
1225
|
+
and isinstance(stmt.targets[0], ast.Name)
|
|
1226
|
+
and stmt.targets[0].id in ("decorators", "method_decorators")
|
|
1227
|
+
and isinstance(stmt.value, (ast.List, ast.Tuple))):
|
|
1228
|
+
for e in stmt.value.elts:
|
|
1229
|
+
nm = (e.func.id if isinstance(e, ast.Call)
|
|
1230
|
+
and isinstance(e.func, ast.Name)
|
|
1231
|
+
else e.func.attr if isinstance(e, ast.Call)
|
|
1232
|
+
and isinstance(e.func, ast.Attribute)
|
|
1233
|
+
else e.id if isinstance(e, ast.Name)
|
|
1234
|
+
else e.attr if isinstance(e, ast.Attribute) else "")
|
|
1235
|
+
if re.search(r'login_required|jwt_required|requires?_auth'
|
|
1236
|
+
r'|auth_required|require_login|token_required'
|
|
1237
|
+
r'|permission_required', nm or "", re.I):
|
|
1238
|
+
return [self._guard_step(nm, rel_file, cls.lineno)]
|
|
1239
|
+
return []
|
|
1240
|
+
|
|
1241
|
+
@staticmethod
|
|
1242
|
+
def _guard_step(name, rel_file, line):
|
|
1243
|
+
return {
|
|
1244
|
+
"name": name, "role": "middleware", "sourceFile": rel_file,
|
|
1245
|
+
"sourceLine": line,
|
|
1246
|
+
"scan": {"effects": [], "returnShapes": [], "calls": [],
|
|
1247
|
+
"async": False, "validatesInput": False,
|
|
1248
|
+
"guardSignals": {"deniesWithStatus": True}},
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1026
1251
|
def collect_mount(self, call, abs_file, prefix, import_map, router_vars,
|
|
1027
1252
|
router_prefixes, depth=0):
|
|
1253
|
+
# FastAPI `app.include_router(r, prefix=…)` and Flask
|
|
1254
|
+
# `app.register_blueprint(bp, url_prefix=…)` are the same act — mount a
|
|
1255
|
+
# sub-router under a prefix. `register_blueprint` names its prefix `url_prefix`.
|
|
1028
1256
|
if not (isinstance(call.func, ast.Attribute)
|
|
1029
|
-
and call.func.attr
|
|
1257
|
+
and call.func.attr in ("include_router", "register_blueprint")
|
|
1258
|
+
and call.args):
|
|
1030
1259
|
return
|
|
1260
|
+
prefix_kw = "url_prefix" if call.func.attr == "register_blueprint" \
|
|
1261
|
+
else "prefix"
|
|
1031
1262
|
arg0 = call.args[0]
|
|
1032
1263
|
router_name = None
|
|
1033
1264
|
if isinstance(arg0, ast.Name):
|
|
@@ -1038,7 +1269,7 @@ class UbgExtractor:
|
|
|
1038
1269
|
return
|
|
1039
1270
|
mount_prefix = ""
|
|
1040
1271
|
for kw in call.keywords:
|
|
1041
|
-
if kw.arg ==
|
|
1272
|
+
if kw.arg == prefix_kw and isinstance(lit(kw.value), str):
|
|
1042
1273
|
mount_prefix = lit(kw.value)
|
|
1043
1274
|
resolved = import_map.get(router_name)
|
|
1044
1275
|
cum = (prefix + mount_prefix).replace("//", "/")
|
package/src/ubg/genome.js
CHANGED
|
@@ -134,7 +134,7 @@ export function verifyAntibody(ab) {
|
|
|
134
134
|
const id = `${ANTIBODY_VERSION}_${sha256(body).slice(0, 32)}`;
|
|
135
135
|
if (id !== ab.id) return { ok: false, reason: 'content-address' };
|
|
136
136
|
// signature: Ed25519 over the same claim bytes the id commits to.
|
|
137
|
-
let sigOk
|
|
137
|
+
let sigOk;
|
|
138
138
|
try {
|
|
139
139
|
sigOk = crypto.verify(
|
|
140
140
|
null,
|
package/src/ubg/nextjs.js
CHANGED
|
@@ -9,6 +9,77 @@ import { parseModule, resolveExportedFunction, scanFunction } from './extract.js
|
|
|
9
9
|
import { walkCalls } from './resolve.js';
|
|
10
10
|
|
|
11
11
|
const VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
12
|
+
|
|
13
|
+
// Read `export const config = { matcher: "…" | ["…", …] }` from a middleware module.
|
|
14
|
+
// Returns { patterns: string[]|null, unresolved: boolean }. patterns=null means no
|
|
15
|
+
// matcher (Next runs the middleware on every path). unresolved=true means a matcher
|
|
16
|
+
// exists but isn't a static string/array we can evaluate — the SOUND response is to
|
|
17
|
+
// NOT credit the guard (never a false PROVEN), handled in middlewareCovers().
|
|
18
|
+
export function readMatcher(mod) {
|
|
19
|
+
if (!mod?.ast) return { patterns: null, unresolved: false };
|
|
20
|
+
for (const node of mod.ast.program.body) {
|
|
21
|
+
const decl = node.type === 'ExportNamedDeclaration' ? node.declaration : node;
|
|
22
|
+
if (decl?.type !== 'VariableDeclaration') continue;
|
|
23
|
+
for (const d of decl.declarations) {
|
|
24
|
+
if (d.id?.name !== 'config' || d.init?.type !== 'ObjectExpression') continue;
|
|
25
|
+
const m = d.init.properties.find(
|
|
26
|
+
(p) => (p.key?.name ?? p.key?.value) === 'matcher',
|
|
27
|
+
);
|
|
28
|
+
if (!m) return { patterns: null, unresolved: false }; // config without matcher = all paths
|
|
29
|
+
const lit = (n) => (n?.type === 'StringLiteral' ? n.value : null);
|
|
30
|
+
if (lit(m.value) !== null) return { patterns: [lit(m.value)], unresolved: false };
|
|
31
|
+
if (m.value?.type === 'ArrayExpression') {
|
|
32
|
+
const out = m.value.elements.map(lit);
|
|
33
|
+
return out.every((s) => s !== null)
|
|
34
|
+
? { patterns: out, unresolved: false }
|
|
35
|
+
: { patterns: null, unresolved: true };
|
|
36
|
+
}
|
|
37
|
+
return { patterns: null, unresolved: true }; // computed matcher — can't decide
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { patterns: null, unresolved: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Does a middleware with these matcher patterns run on `routePath`? Returns
|
|
44
|
+
// true | false | null(=unknown). Handles the two dominant vibe-coded forms:
|
|
45
|
+
// positive path globs (`/dashboard/:path*`) and the negative-lookahead exclude
|
|
46
|
+
// (`/((?!api/|_next/).*)`). Anything else → null, and the caller treats unknown
|
|
47
|
+
// as "do not attribute the guard" so an unresolved matcher never fabricates a proof.
|
|
48
|
+
export function matcherCovers(patterns, routePath) {
|
|
49
|
+
let anyUnknown = false;
|
|
50
|
+
for (const p of patterns) {
|
|
51
|
+
const r = onePattern(p, routePath);
|
|
52
|
+
if (r === true) return true;
|
|
53
|
+
if (r === null) anyUnknown = true;
|
|
54
|
+
}
|
|
55
|
+
return anyUnknown ? null : false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onePattern(pattern, routePath) {
|
|
59
|
+
if (typeof pattern !== 'string' || pattern[0] !== '/') return null;
|
|
60
|
+
// negative lookahead: "/((?!api/|_next/|favicon.ico).*)" → covers everything but the excludes
|
|
61
|
+
const neg = pattern.match(/^\/\(\(\?!(.+?)\)\.\*\)\/?$/);
|
|
62
|
+
if (neg) {
|
|
63
|
+
const excludes = neg[1].split('|').map((s) => '/' + s.replace(/\/$/, ''));
|
|
64
|
+
return !excludes.some((ex) => routePath === ex || routePath.startsWith(ex + '/'));
|
|
65
|
+
}
|
|
66
|
+
// any other regex-ish pattern → not statically decidable here
|
|
67
|
+
if (/[()?!+^$|\\]/.test(pattern)) return null;
|
|
68
|
+
// positive path glob: `:seg` and `*` are wildcards, everything else literal.
|
|
69
|
+
// `/:path*` (slash + star modifier) matches zero-or-more trailing segments, so
|
|
70
|
+
// `/dashboard/:path*` covers `/dashboard` itself as well as `/dashboard/x`.
|
|
71
|
+
const rx = new RegExp(
|
|
72
|
+
'^' +
|
|
73
|
+
pattern
|
|
74
|
+
.replace(/[.]/g, '\\$&')
|
|
75
|
+
.replace(/\/:[A-Za-z0-9_]+\*/g, '(?:/.*)?')
|
|
76
|
+
.replace(/:[A-Za-z0-9_]+\*/g, '.*')
|
|
77
|
+
.replace(/:[A-Za-z0-9_]+/g, '[^/]+')
|
|
78
|
+
.replace(/\*/g, '.*') +
|
|
79
|
+
'$',
|
|
80
|
+
);
|
|
81
|
+
return rx.test(routePath);
|
|
82
|
+
}
|
|
12
83
|
const ROUTE_FILES = new Set([
|
|
13
84
|
'route.js',
|
|
14
85
|
'route.ts',
|
|
@@ -34,12 +105,19 @@ export function extractNext(cwd, appDir) {
|
|
|
34
105
|
if (mod.error) continue;
|
|
35
106
|
const fn = mod.functions.get('middleware') ?? mod.functions.get('default');
|
|
36
107
|
if (fn) {
|
|
108
|
+
// `export const config = { matcher: [...] }` scopes which paths the middleware
|
|
109
|
+
// runs on. Ignoring it is a FALSE-PROVEN generator: a middleware that denies
|
|
110
|
+
// for /dashboard would otherwise be credited as a guard on /api too. Carry the
|
|
111
|
+
// matcher so the translator only attributes the guard to paths it truly covers.
|
|
112
|
+
const { patterns, unresolved } = readMatcher(mod);
|
|
37
113
|
globalMiddlewares.push({
|
|
38
114
|
name: 'middleware',
|
|
39
115
|
role: 'middleware',
|
|
40
116
|
sourceFile: rel(abs),
|
|
41
117
|
sourceLine: fn.line,
|
|
42
118
|
fn: fn.node,
|
|
119
|
+
matcherPatterns: patterns, // null = no matcher = runs on every path (Next default)
|
|
120
|
+
matcherUnresolved: unresolved, // matcher present but not statically decidable
|
|
43
121
|
});
|
|
44
122
|
scannedFiles.push(rel(abs));
|
|
45
123
|
}
|
|
@@ -91,10 +169,74 @@ export function extractNext(cwd, appDir) {
|
|
|
91
169
|
walk(abs, [...segments, m ? `:${m[1]}` : name]);
|
|
92
170
|
} else if (item.isFile() && ROUTE_FILES.has(item.name)) {
|
|
93
171
|
parseRouteFile(abs, '/' + segments.join('/') || '/');
|
|
172
|
+
} else if (item.isFile() && /\.(t|j)sx?$/.test(item.name)) {
|
|
173
|
+
// C3: a Next server action (`'use server'`) is a remotely-invocable entrypoint just like a
|
|
174
|
+
// route — a client form can call it with any args. An unguarded mutating action is a real
|
|
175
|
+
// hole, but SPARDA saw only `route.ts` files, so it was INVISIBLE and coverage read a false
|
|
176
|
+
// 100%. Extract exported server actions so they get the same O1 guard analysis as routes.
|
|
177
|
+
parseServerActions(abs);
|
|
94
178
|
}
|
|
95
179
|
}
|
|
96
180
|
}
|
|
97
181
|
|
|
182
|
+
// A server action = an exported async function in a `'use server'` MODULE, or any async function
|
|
183
|
+
// with a function-level `'use server'` directive. Registered as a POST entrypoint (actions mutate
|
|
184
|
+
// via a POST-like RPC); its body scan gets the normal in-body guard + guard-dominance treatment.
|
|
185
|
+
function parseServerActions(absFile) {
|
|
186
|
+
let src;
|
|
187
|
+
try {
|
|
188
|
+
src = fs.readFileSync(absFile, 'utf8');
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!src.includes('use server')) return; // cheap pre-filter — never parse an ordinary component
|
|
193
|
+
const mod = parseModule(absFile);
|
|
194
|
+
if (mod.error || !mod.ast) return;
|
|
195
|
+
const relFile = rel(absFile);
|
|
196
|
+
const relNoExt = relFile.replace(/\.(t|j)sx?$/, '');
|
|
197
|
+
const moduleLevel = (mod.ast.program.directives ?? []).some(
|
|
198
|
+
(d) => d.value?.value === 'use server',
|
|
199
|
+
);
|
|
200
|
+
let found = false;
|
|
201
|
+
for (const { name, fn, line } of exportedAsyncFns(mod.ast)) {
|
|
202
|
+
if (!moduleLevel && !fnDeclaresUseServer(fn)) continue;
|
|
203
|
+
found = true;
|
|
204
|
+
// in-body auth verifier → a guard step, exactly as a plain route handler gets (nextjs.js's
|
|
205
|
+
// bodyGuardScan is tight: a verifier-shaped NAME that PROVABLY denies). No wrapper on actions.
|
|
206
|
+
const guardSteps = [];
|
|
207
|
+
const bg = bodyGuardScan(fn, mod);
|
|
208
|
+
if (bg)
|
|
209
|
+
guardSteps.push({
|
|
210
|
+
name: bg,
|
|
211
|
+
role: 'middleware',
|
|
212
|
+
sourceFile: relFile,
|
|
213
|
+
sourceLine: line,
|
|
214
|
+
fn: null,
|
|
215
|
+
scan: {
|
|
216
|
+
effects: [],
|
|
217
|
+
returnShapes: [],
|
|
218
|
+
calls: [],
|
|
219
|
+
async: true,
|
|
220
|
+
validatesInput: false,
|
|
221
|
+
guardSignals: { deniesWithStatus: true },
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
routes.push({
|
|
225
|
+
method: 'post',
|
|
226
|
+
path: `(action) ${relNoExt}#${name}`,
|
|
227
|
+
sourceFile: relFile,
|
|
228
|
+
sourceLine: line,
|
|
229
|
+
params: [],
|
|
230
|
+
chain: [
|
|
231
|
+
...guardSteps,
|
|
232
|
+
{ name, role: 'handler', sourceFile: relFile, sourceLine: line, fn },
|
|
233
|
+
],
|
|
234
|
+
description: 'server action',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (found && !scannedFiles.includes(relFile)) scannedFiles.push(relFile);
|
|
238
|
+
}
|
|
239
|
+
|
|
98
240
|
function parseRouteFile(absFile, urlPath) {
|
|
99
241
|
const mod = parseModule(absFile);
|
|
100
242
|
const relFile = rel(absFile);
|
|
@@ -239,6 +381,53 @@ function verbHandlers(mod) {
|
|
|
239
381
|
return out;
|
|
240
382
|
}
|
|
241
383
|
|
|
384
|
+
// Exported async functions of a module — `export async function f(){}`,
|
|
385
|
+
// `export const f = async () => {}`, `export default async function(){}`. Server actions are
|
|
386
|
+
// always async; a non-async export in a `'use server'` file is a re-exported constant, not an action.
|
|
387
|
+
function exportedAsyncFns(ast) {
|
|
388
|
+
const out = [];
|
|
389
|
+
const consider = (name, fn, line) => {
|
|
390
|
+
if (fn && fn.async) out.push({ name, fn, line: line ?? fn.loc?.start.line ?? 0 });
|
|
391
|
+
};
|
|
392
|
+
for (const node of ast.program.body) {
|
|
393
|
+
if (node.type === 'ExportNamedDeclaration' && node.declaration) {
|
|
394
|
+
const d = node.declaration;
|
|
395
|
+
if (d.type === 'FunctionDeclaration' && d.id)
|
|
396
|
+
consider(d.id.name, d, d.loc?.start.line);
|
|
397
|
+
else if (d.type === 'VariableDeclaration')
|
|
398
|
+
for (const decl of d.declarations)
|
|
399
|
+
if (
|
|
400
|
+
decl.id?.type === 'Identifier' &&
|
|
401
|
+
(decl.init?.type === 'ArrowFunctionExpression' ||
|
|
402
|
+
decl.init?.type === 'FunctionExpression')
|
|
403
|
+
)
|
|
404
|
+
consider(decl.id.name, decl.init, decl.loc?.start.line);
|
|
405
|
+
} else if (node.type === 'ExportDefaultDeclaration') {
|
|
406
|
+
const d = node.declaration;
|
|
407
|
+
if (
|
|
408
|
+
d?.type === 'FunctionDeclaration' ||
|
|
409
|
+
d?.type === 'ArrowFunctionExpression' ||
|
|
410
|
+
d?.type === 'FunctionExpression'
|
|
411
|
+
)
|
|
412
|
+
consider(d.id?.name ?? 'default', d, d.loc?.start.line);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// A function-level `'use server'` directive — `async function f(){ 'use server'; … }`.
|
|
419
|
+
function fnDeclaresUseServer(fn) {
|
|
420
|
+
const b = fn?.body;
|
|
421
|
+
if (b?.type !== 'BlockStatement') return false;
|
|
422
|
+
if ((b.directives ?? []).some((d) => d.value?.value === 'use server')) return true;
|
|
423
|
+
const first = b.body?.[0];
|
|
424
|
+
return (
|
|
425
|
+
first?.type === 'ExpressionStatement' &&
|
|
426
|
+
first.expression?.type === 'StringLiteral' &&
|
|
427
|
+
first.expression.value === 'use server'
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
242
431
|
// resolve an `export const VERB = <init>` right-hand side to a scannable function node
|
|
243
432
|
// (or null — route still registered, body blind) PLUS the names of any HOC wrappers the
|
|
244
433
|
// handler is nested inside. Handles inline fns, local aliases, and wrapper calls whose
|
package/src/ubg/resolve.js
CHANGED
|
@@ -33,6 +33,13 @@ export const MAX_RESOLVE_DEPTH = 6;
|
|
|
33
33
|
// merge one scan's findings into another — the single copy of the contract
|
|
34
34
|
// every follower shares.
|
|
35
35
|
export function mergeScan(into, add) {
|
|
36
|
+
// Guard-dominance (C2) is a property of the ROUTE HANDLER's own body: a mutation that runs before
|
|
37
|
+
// the handler's auth check. A `bypassesGuard` computed inside a delegated/transitively-called body
|
|
38
|
+
// is that body's INTERNAL ordering (a service that mutates then runs a permission check) — not the
|
|
39
|
+
// route's auth gate, exactly as "effects merge; guards do not" (below). Strip it at the merge so a
|
|
40
|
+
// service's internal shape never flags the route. The handler's own effects live in `into` from
|
|
41
|
+
// the start and are never merged, so they keep their flag.
|
|
42
|
+
for (const e of add.effects) if (e.bypassesGuard) delete e.bypassesGuard;
|
|
36
43
|
into.effects.push(...add.effects);
|
|
37
44
|
into.returnShapes = [...(into.returnShapes ?? []), ...(add.returnShapes ?? [])];
|
|
38
45
|
into.calls = [...(into.calls ?? []), ...(add.calls ?? [])];
|
package/src/ubg/translate.js
CHANGED
|
@@ -24,6 +24,18 @@ import {
|
|
|
24
24
|
stateId,
|
|
25
25
|
} from './schema.js';
|
|
26
26
|
import { isGuardLike, isNoOpGuard, scanFunction } from './extract.js';
|
|
27
|
+
import { matcherCovers } from './nextjs.js';
|
|
28
|
+
|
|
29
|
+
// A global middleware only guards a route whose path its Next `config.matcher`
|
|
30
|
+
// actually covers. No matcher → every path (Next default). A matcher present but
|
|
31
|
+
// unresolved, or one that resolves to "not covered", → do NOT attribute (an
|
|
32
|
+
// unproven guard is never fabricated — SOUNDNESS.md, no false PROVEN).
|
|
33
|
+
function middlewareAppliesTo(mw, routePath) {
|
|
34
|
+
if (mw.role !== 'middleware' || mw.matcherPatterns == null) {
|
|
35
|
+
return !mw.matcherUnresolved; // no matcher = all paths; unresolved matcher = abstain
|
|
36
|
+
}
|
|
37
|
+
return matcherCovers(mw.matcherPatterns, routePath) === true;
|
|
38
|
+
}
|
|
27
39
|
|
|
28
40
|
// Is this chain step / helper a REAL guard? Named or deny-bodied like a guard, and
|
|
29
41
|
// NOT a visible no-op pass-through (a disabled `(req,res,next)=>next()` guards nothing).
|
|
@@ -149,8 +161,12 @@ function translateRoute(
|
|
|
149
161
|
),
|
|
150
162
|
);
|
|
151
163
|
|
|
152
|
-
// global middlewares run before route-level ones — same chain, lower order
|
|
153
|
-
|
|
164
|
+
// global middlewares run before route-level ones — same chain, lower order —
|
|
165
|
+
// but only those whose matcher actually covers this route's path (E-NEXT-MW)
|
|
166
|
+
const applicable = globalMiddlewares.filter((mw) =>
|
|
167
|
+
middlewareAppliesTo(mw, route.path),
|
|
168
|
+
);
|
|
169
|
+
const fullChain = [...applicable, ...route.chain];
|
|
154
170
|
let prevId = epId;
|
|
155
171
|
let order = 0;
|
|
156
172
|
const chainNodes = [];
|
|
@@ -325,6 +341,9 @@ function attachBody(graph, ownerId, scan, helperByName, scanCache, expanded) {
|
|
|
325
341
|
// Advisory provenance — it enriches an UNGUARDED_MUTATION, never a finding of
|
|
326
342
|
// its own (a per-function under-approximation can't see service-layer validation).
|
|
327
343
|
...(eff.tainted ? { tainted: true } : {}),
|
|
344
|
+
// guard-dominance (kills the C2 false PROVEN): this mutation runs BEFORE a guard that
|
|
345
|
+
// follows it on the same body spine — i.e. it executes without having passed that check.
|
|
346
|
+
...(eff.bypassesGuard ? { bypassesGuard: true } : {}),
|
|
328
347
|
// object-scope provenance (ADR-058 B): the query targets a bare `id`, and whether
|
|
329
348
|
// it is scoped to the caller. A route with an idScoped access and NO ownerScoped
|
|
330
349
|
// access anywhere on its resolved path is a BOLA candidate (advisory).
|