sparda-mcp 0.63.0 → 0.66.0
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 +2 -1
- package/package.json +12 -6
- package/src/commands/apocalypse.js +48 -3
- package/src/commands/badge.js +4 -1
- package/src/commands/dossier.js +4 -1
- package/src/commands/gate.js +114 -0
- package/src/commands/init.js +11 -0
- package/src/commands/prove.js +4 -1
- package/src/commands/review.js +6 -2
- package/src/index.js +11 -1
- package/src/server/stdio.js +4 -1
- package/src/ubg/apocalypse.js +175 -8
- package/src/ubg/extract.js +298 -1
- package/src/ubg/passes/state-minimization.js +6 -0
- package/src/ubg/prisma.js +35 -1
- package/src/ubg/resolve.js +17 -0
- package/src/ubg/translate.js +29 -0
package/README.md
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
<br/>
|
|
8
8
|
|
|
9
|
-
> 🇫🇷 **Français**
|
|
9
|
+
> 🇫🇷 **Français** — _L'IA écrit. SPARDA prouve._ Un gate déterministe et hors-ligne qui détecte quand une modif d'IA retire une garde, expose une route ou casse un invariant — sans clé API, directement dans la boucle d'édition de l'agent. Pour tout comprendre en 10 minutes (douleur, architecture, vision) : [SPARDA-EXPLIQUE.md](docs/SPARDA-EXPLIQUE.md).
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
<h1 align="center">AI writes. SPARDA proves.</h1>
|
|
14
|
+
<p align="center"><em>L'IA écrit. SPARDA prouve.</em></p>
|
|
14
15
|
|
|
15
16
|
**The trust layer for AI-written backends.** SPARDA compiles your backend — routes, database queries, state mutations, guards, side-effects — into one deterministic behavior graph, then **statically proves what can and can't break before you ship**: no unguarded mutation, no broken invariant, no non-atomic aggregate write.
|
|
16
17
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparda-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"mcpName": "io.github.zyx77550/sparda-mcp",
|
|
5
|
-
"description": "
|
|
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",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -46,10 +46,16 @@
|
|
|
46
46
|
"mcp",
|
|
47
47
|
"model-context-protocol",
|
|
48
48
|
"claude",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
49
|
+
"claude-code",
|
|
50
|
+
"ai-code-review",
|
|
51
|
+
"code-review",
|
|
52
|
+
"guardrails",
|
|
53
|
+
"static-analysis",
|
|
54
|
+
"security",
|
|
55
|
+
"authorization",
|
|
56
|
+
"bola",
|
|
57
|
+
"ai-agents",
|
|
58
|
+
"trust-layer"
|
|
53
59
|
],
|
|
54
60
|
"author": "Residual Labs (residual-labs.fr)",
|
|
55
61
|
"license": "BUSL-1.1",
|
|
@@ -8,12 +8,38 @@
|
|
|
8
8
|
// sparda apocalypse --json raw findings for tooling
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
11
12
|
import { compileUBG } from '../ubg/compile.js';
|
|
12
13
|
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
checkGraph,
|
|
16
|
+
diffGraphs,
|
|
17
|
+
verdictOf,
|
|
18
|
+
verdictState,
|
|
19
|
+
buildProofObjects,
|
|
20
|
+
} from '../ubg/apocalypse.js';
|
|
14
21
|
import { surveyBlindspots } from '../ubg/blindspots.js';
|
|
15
22
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
16
23
|
|
|
24
|
+
// version travels with the proof so an audit knows which prover produced it
|
|
25
|
+
const SPARDA_VERSION = (() => {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(
|
|
28
|
+
fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
|
29
|
+
).version;
|
|
30
|
+
} catch {
|
|
31
|
+
return '0.0.0';
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
// a deterministic fingerprint of the canonical graph: same source → same graph → same hash.
|
|
36
|
+
// The proof object binds to it, so a third party proves it audited the SAME artifact.
|
|
37
|
+
function graphHash(canonical) {
|
|
38
|
+
const h = crypto.createHash('sha256');
|
|
39
|
+
h.update(JSON.stringify({ nodes: canonical.nodes, edges: canonical.edges }));
|
|
40
|
+
return 'bh1_' + h.digest('hex').slice(0, 16);
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
|
|
18
44
|
|
|
19
45
|
export async function runApocalypse(opts) {
|
|
@@ -45,8 +71,12 @@ export async function runApocalypse(opts) {
|
|
|
45
71
|
const findings = [...staticFindings, ...diffFindings];
|
|
46
72
|
// the honesty companion: where does the proof stop? (see `sparda blindspots`)
|
|
47
73
|
const blind = surveyBlindspots(canonical, report);
|
|
48
|
-
// coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN
|
|
49
|
-
|
|
74
|
+
// coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN;
|
|
75
|
+
// and any high-risk blind spot pulls a bare PROVEN down to PARTIAL (E-047, the giant-test rung)
|
|
76
|
+
const verdict = verdictOf(findings, canonical, {
|
|
77
|
+
coverage: blind.coverage.ratio,
|
|
78
|
+
blindHigh: blind.byRisk.critical + blind.byRisk.high,
|
|
79
|
+
});
|
|
50
80
|
|
|
51
81
|
if (opts.sarif) {
|
|
52
82
|
const sarifPath = path.join(opts.cwd, '.sparda', 'apocalypse.sarif');
|
|
@@ -57,6 +87,21 @@ export async function runApocalypse(opts) {
|
|
|
57
87
|
);
|
|
58
88
|
}
|
|
59
89
|
|
|
90
|
+
if (opts.proof) {
|
|
91
|
+
const proofPath = path.join(opts.cwd, '.sparda', 'apocalypse.proof.json');
|
|
92
|
+
fs.mkdirSync(path.dirname(proofPath), { recursive: true });
|
|
93
|
+
const proof = {
|
|
94
|
+
sparda_version: SPARDA_VERSION,
|
|
95
|
+
graph_hash: graphHash(canonical),
|
|
96
|
+
verdict: verdictState(verdict),
|
|
97
|
+
discharged: buildProofObjects(canonical),
|
|
98
|
+
};
|
|
99
|
+
atomicWrite(proofPath, JSON.stringify(proof, null, 2) + '\n');
|
|
100
|
+
console.log(
|
|
101
|
+
`✓ Proof written: .sparda/apocalypse.proof.json (${proof.discharged.length} discharged obligation(s), re-verifiable against ${proof.graph_hash})`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
60
105
|
if (opts.json) {
|
|
61
106
|
console.log(
|
|
62
107
|
JSON.stringify({ verdict, obligations, findings, blindspots: blind }, null, 2),
|
package/src/commands/badge.js
CHANGED
|
@@ -16,7 +16,10 @@ export async function runBadge(opts) {
|
|
|
16
16
|
const canonical = canonicalizeGraph(graph);
|
|
17
17
|
const { findings } = checkGraph(canonical);
|
|
18
18
|
const blind = surveyBlindspots(canonical, report);
|
|
19
|
-
const verdict = verdictOf(findings, canonical, {
|
|
19
|
+
const verdict = verdictOf(findings, canonical, {
|
|
20
|
+
coverage: blind.coverage.ratio,
|
|
21
|
+
blindHigh: blind.byRisk.critical + blind.byRisk.high,
|
|
22
|
+
});
|
|
20
23
|
const cov = Math.round(blind.coverage.ratio * 100);
|
|
21
24
|
const { state, message, color } = badgeFor(verdict, { coverage: blind.coverage.ratio });
|
|
22
25
|
|
package/src/commands/dossier.js
CHANGED
|
@@ -21,7 +21,10 @@ export async function runDossier(opts) {
|
|
|
21
21
|
const { findings, polarity } = checkGraph(canonical);
|
|
22
22
|
const capsule = buildCapsule(canonical);
|
|
23
23
|
const blindspots = surveyBlindspots(canonical, compiled.report);
|
|
24
|
-
const verdict = verdictOf(findings, canonical, {
|
|
24
|
+
const verdict = verdictOf(findings, canonical, {
|
|
25
|
+
coverage: blindspots.coverage.ratio,
|
|
26
|
+
blindHigh: blindspots.byRisk.critical + blindspots.byRisk.high,
|
|
27
|
+
});
|
|
25
28
|
|
|
26
29
|
const data = {
|
|
27
30
|
app: path.basename(path.resolve(opts.cwd)) || 'app',
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// commands/gate.js — the agent edit-loop gate: did THIS edit lose a protection
|
|
2
|
+
// or introduce a risk, relative to the armed baseline? Delta-only by design: an
|
|
3
|
+
// edit gate must never scream about pre-existing state (that noise is fatal to
|
|
4
|
+
// adoption — see docs/TWO-FALSE-POSITIVE-CLASSES-2026-07-19.md), so everything
|
|
5
|
+
// already true at arm time is silent and only the regression speaks.
|
|
6
|
+
// sparda gate report the edit's behavior delta vs baseline (exit 1 on critical/high)
|
|
7
|
+
// sparda gate --arm freeze the current graph as the accepted baseline
|
|
8
|
+
// sparda gate --hook Claude Code PostToolUse contract: silent when clean,
|
|
9
|
+
// report on stderr + exit 2 (blocking feedback) on regression;
|
|
10
|
+
// arms itself on first run so there is nothing to configure.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
14
|
+
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
15
|
+
import { checkGraph, diffGraphs } from '../ubg/apocalypse.js';
|
|
16
|
+
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
17
|
+
|
|
18
|
+
// severities that block the edit; medium/info regressions are reported, never fatal
|
|
19
|
+
const BLOCKING = new Set(['critical', 'high']);
|
|
20
|
+
const findingKey = (f) => `${f.rule}|${f.entrypoint}`;
|
|
21
|
+
|
|
22
|
+
// The gate's whole judgment, pure over two canonical graphs (testable without a repo):
|
|
23
|
+
// diff regressions (guard/route/invariant lost, blast radius grown) + static findings
|
|
24
|
+
// 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
|
+
export function gateDelta(baseline, candidate) {
|
|
27
|
+
const diff = diffGraphs(baseline, candidate).findings;
|
|
28
|
+
const before = new Set(checkGraph(baseline).findings.map(findingKey));
|
|
29
|
+
const introduced = checkGraph(candidate).findings.filter(
|
|
30
|
+
(f) => !before.has(findingKey(f)),
|
|
31
|
+
);
|
|
32
|
+
const seen = new Set(diff.map(findingKey));
|
|
33
|
+
const findings = [...diff, ...introduced.filter((f) => !seen.has(findingKey(f)))];
|
|
34
|
+
return {
|
|
35
|
+
findings,
|
|
36
|
+
blocking: findings.filter((f) => BLOCKING.has(f.severity)),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function locOf(canonical, f) {
|
|
41
|
+
const loc = canonical.nodes.find((n) => n.id === f.entrypoint)?.loc;
|
|
42
|
+
return loc ? ` (${loc.file}:${loc.line || 1})` : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function runGate(opts) {
|
|
46
|
+
const t0 = process.hrtime.bigint();
|
|
47
|
+
const hook = opts.hook;
|
|
48
|
+
// in hook mode stdout belongs to nobody and stderr is what the agent reads
|
|
49
|
+
const say = hook ? (s) => console.error(s) : (s) => console.log(s);
|
|
50
|
+
|
|
51
|
+
const { graph } = compileUBG(opts.cwd, { write: false });
|
|
52
|
+
const candidate = canonicalizeGraph(graph);
|
|
53
|
+
const baselinePath = path.join(opts.cwd, '.sparda', 'ubg.baseline.json');
|
|
54
|
+
|
|
55
|
+
const arm = () => {
|
|
56
|
+
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
|
|
57
|
+
atomicWrite(baselinePath, JSON.stringify(candidate, null, 2) + '\n');
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (opts.arm || opts.saveBaseline) {
|
|
61
|
+
arm();
|
|
62
|
+
say(
|
|
63
|
+
`✓ GATE ARMED — baseline frozen (.sparda/ubg.baseline.json). Every edit is now proven against it.`,
|
|
64
|
+
);
|
|
65
|
+
return { armed: true, findings: [] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!fs.existsSync(baselinePath)) {
|
|
69
|
+
// first run arms instead of failing: a hook must work with zero configuration
|
|
70
|
+
arm();
|
|
71
|
+
say(
|
|
72
|
+
`✓ GATE ARMED (first run) — baseline frozen. Future edits are proven against it; re-arm with \`sparda gate --arm\` after intended changes.`,
|
|
73
|
+
);
|
|
74
|
+
return { armed: true, findings: [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
|
78
|
+
const { findings, blocking } = gateDelta(baseline, candidate);
|
|
79
|
+
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
80
|
+
|
|
81
|
+
if (opts.json) {
|
|
82
|
+
console.log(
|
|
83
|
+
JSON.stringify(
|
|
84
|
+
{ ok: blocking.length === 0, ms: Math.round(ms), findings },
|
|
85
|
+
null,
|
|
86
|
+
2,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
if (blocking.length) process.exitCode = 1;
|
|
90
|
+
return { armed: false, findings };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (findings.length === 0) {
|
|
94
|
+
if (!hook)
|
|
95
|
+
say(
|
|
96
|
+
`✓ GATE CLEAN — this edit lost no guard, dropped no route, grew no blast radius (proven vs baseline in ${Math.round(ms)} ms).`,
|
|
97
|
+
);
|
|
98
|
+
return { armed: false, findings };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
say(
|
|
102
|
+
`✗ SPARDA GATE — this edit changed the app's proven behavior (${Math.round(ms)} ms, deterministic):`,
|
|
103
|
+
);
|
|
104
|
+
for (const f of findings)
|
|
105
|
+
say(` [${f.severity}] ${f.rule} — ${f.message}${locOf(candidate, f)}`);
|
|
106
|
+
say(
|
|
107
|
+
blocking.length
|
|
108
|
+
? ` → fix the edit or, if intended, accept it with \`sparda gate --arm\`.`
|
|
109
|
+
: ` → advisory only (no critical/high regression) — review, then \`sparda gate --arm\` if intended.`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (blocking.length) process.exitCode = hook ? 2 : 1;
|
|
113
|
+
return { armed: false, findings };
|
|
114
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -48,6 +48,17 @@ export async function runInit(opts) {
|
|
|
48
48
|
s.stop(
|
|
49
49
|
`Stack detected: ${c.cyan('Next.js (App Router)')} — app dir: ${stack.entryFile}/, port: ${stack.port}`,
|
|
50
50
|
);
|
|
51
|
+
} else {
|
|
52
|
+
s.stop(
|
|
53
|
+
`Stack detected: ${c.cyan(stack.framework)} — but AST parsing is not supported yet.`,
|
|
54
|
+
);
|
|
55
|
+
throw Object.assign(
|
|
56
|
+
new Error(`AST extraction for ${stack.framework} is not yet supported.`),
|
|
57
|
+
{
|
|
58
|
+
code: 'USER',
|
|
59
|
+
hint: `SPARDA detected ${stack.framework}, but currently only supports Express, FastAPI, and Next.js for AST parsing. NestJS and others are on the roadmap!`,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
// Opt-in runtime probe (Brief #3): static is the floor, probe only ADDS routes
|
package/src/commands/prove.js
CHANGED
|
@@ -19,7 +19,10 @@ export async function runProve(opts) {
|
|
|
19
19
|
|
|
20
20
|
const { findings, obligations } = checkGraph(canonical);
|
|
21
21
|
const blind = surveyBlindspots(canonical, report);
|
|
22
|
-
const verdict = verdictOf(findings, canonical, {
|
|
22
|
+
const verdict = verdictOf(findings, canonical, {
|
|
23
|
+
coverage: blind.coverage.ratio,
|
|
24
|
+
blindHigh: blind.byRisk.critical + blind.byRisk.high,
|
|
25
|
+
});
|
|
23
26
|
const capsule = buildCapsule(canonical);
|
|
24
27
|
const prints = fingerprintGraph(canonical);
|
|
25
28
|
// one app-level seal: a content address over every route's behavior hash — the same
|
package/src/commands/review.js
CHANGED
|
@@ -62,11 +62,15 @@ export function reviewGraphs(baseGraph, candidateGraph) {
|
|
|
62
62
|
// how much of the changed app SPARDA could actually see — and whether this PR moved
|
|
63
63
|
// that boundary. A green review over a graph the PR made 20% blinder is not the same
|
|
64
64
|
// as a green review at full sight; the reviewer must be able to tell them apart.
|
|
65
|
-
const
|
|
65
|
+
const candBlind = surveyBlindspots(candidateGraph);
|
|
66
|
+
const candCov = candBlind.coverage.ratio;
|
|
66
67
|
const baseCov = surveyBlindspots(baseGraph).coverage.ratio;
|
|
67
68
|
|
|
68
69
|
return {
|
|
69
|
-
verdict: verdictOf(findings, candidateGraph, {
|
|
70
|
+
verdict: verdictOf(findings, candidateGraph, {
|
|
71
|
+
coverage: candCov,
|
|
72
|
+
blindHigh: candBlind.byRisk.critical + candBlind.byRisk.high,
|
|
73
|
+
}),
|
|
70
74
|
obligations,
|
|
71
75
|
findings,
|
|
72
76
|
endpoints: { added, removed: removedEps },
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ const opts = {
|
|
|
18
18
|
yes: flags.has('--yes') || flags.has('-y'),
|
|
19
19
|
saveBaseline: flags.has('--save-baseline'),
|
|
20
20
|
sarif: flags.has('--sarif'),
|
|
21
|
+
proof: flags.has('--proof'),
|
|
21
22
|
verbose: flags.has('--verbose'),
|
|
22
23
|
quiet: flags.has('--quiet'),
|
|
23
24
|
probe: flags.has('--probe'),
|
|
@@ -27,6 +28,8 @@ const opts = {
|
|
|
27
28
|
learn: flags.has('--learn'),
|
|
28
29
|
germinate: flags.has('--germinate'),
|
|
29
30
|
check: flags.has('--check'),
|
|
31
|
+
hook: flags.has('--hook'),
|
|
32
|
+
arm: flags.has('--arm'),
|
|
30
33
|
markdown: flags.has('--markdown'),
|
|
31
34
|
port: getOpt('port', null),
|
|
32
35
|
out: getOpt('out', null),
|
|
@@ -45,7 +48,8 @@ const opts = {
|
|
|
45
48
|
// reads opts.cwd, so `--dir <path>` works on all of them; only per-command extras are listed.
|
|
46
49
|
const HELP = {
|
|
47
50
|
prove: `sparda prove [--dir <path>] [--json | --markdown]\n The whole trust verdict: proof + coverage + a shareable seal.\n --markdown emit a sticky-PR-comment body (used by the GitHub Action).`,
|
|
48
|
-
apocalypse: `sparda apocalypse [--dir <path>] [--sarif] [--save-baseline]\n Prove the deploy — exit 1 on any critical/high finding.\n --sarif also write .sparda/apocalypse.sarif for the Security tab.\n --save-baseline freeze this graph; later runs diff against it.`,
|
|
51
|
+
apocalypse: `sparda apocalypse [--dir <path>] [--sarif] [--proof] [--save-baseline]\n Prove the deploy — exit 1 on any critical/high finding.\n --sarif also write .sparda/apocalypse.sarif for the Security tab.\n --proof also write .sparda/apocalypse.proof.json — a re-verifiable\n discharge trace (deny_path per guarded mutation) a third party\n can audit against the graph hash without re-compiling.\n --save-baseline freeze this graph; later runs diff against it.`,
|
|
52
|
+
gate: `sparda gate [--dir <path>] [--arm] [--hook] [--json]\n The agent edit-loop gate: prove THIS edit lost no guard, dropped no route,\n grew no blast radius — delta vs the armed baseline only (pre-existing state\n never blocks an edit). Arms itself on first run.\n --arm accept the current graph as the new baseline.\n --hook Claude Code PostToolUse contract: silent when clean, report on\n stderr + exit 2 (blocking feedback to the agent) on regression.`,
|
|
49
53
|
ubg: `sparda ubg [--dir <path>] [--json] [--out <file>] [--openapi <spec>]\n Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json).`,
|
|
50
54
|
stitch: `sparda stitch <dir1> <dir2> [...] [--json]\n Cross-service proof: join one service's outbound HTTP calls to another's routes,\n surface cross-service trust boundaries + BOLA no mono-repo tool can see.`,
|
|
51
55
|
badge: `sparda badge [--dir <path>] [--out <file>] [--json]\n Emit a shareable SVG badge + README snippet (verdict · coverage · routes).`,
|
|
@@ -162,6 +166,11 @@ try {
|
|
|
162
166
|
await runApocalypse(opts);
|
|
163
167
|
break;
|
|
164
168
|
}
|
|
169
|
+
case 'gate': {
|
|
170
|
+
const { runGate } = await import('./commands/gate.js');
|
|
171
|
+
await runGate(opts);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
165
174
|
case 'review': {
|
|
166
175
|
const { runReview } = await import('./commands/review.js');
|
|
167
176
|
await runReview(opts);
|
|
@@ -234,6 +243,7 @@ try {
|
|
|
234
243
|
PROVE — the point
|
|
235
244
|
prove The whole trust verdict in one gesture: proof + coverage + seal (--json / --markdown / --openapi)
|
|
236
245
|
apocalypse Prove the deploy: guards, invariants, transactions, aggregates (--save-baseline)
|
|
246
|
+
gate The agent edit-loop gate: prove an edit lost no protection (--arm / --hook)
|
|
237
247
|
review Semantic diff of a PR vs a base ref (--base main / --json / --markdown)
|
|
238
248
|
blindspots Map SPARDA's own blindness — every unseen route/effect/guard, ranked (--json)
|
|
239
249
|
badge Emit a shareable SVG badge + README snippet (verdict · coverage · routes)
|
package/src/server/stdio.js
CHANGED
|
@@ -1186,7 +1186,10 @@ export function proveApp(cwd, { route } = {}) {
|
|
|
1186
1186
|
const blind = surveyBlindspots(canonical, report);
|
|
1187
1187
|
// The verdict word always reflects the WHOLE app (a route filter narrows the finding list,
|
|
1188
1188
|
// never the safety claim — an AI must never read "PROVEN" because it hid the rest).
|
|
1189
|
-
const verdict = verdictOf(all, canonical, {
|
|
1189
|
+
const verdict = verdictOf(all, canonical, {
|
|
1190
|
+
coverage: blind.coverage.ratio,
|
|
1191
|
+
blindHigh: blind.byRisk.critical + blind.byRisk.high,
|
|
1192
|
+
});
|
|
1190
1193
|
return {
|
|
1191
1194
|
verdict: verdictState(verdict),
|
|
1192
1195
|
provable: verdict.provable,
|
package/src/ubg/apocalypse.js
CHANGED
|
@@ -42,6 +42,66 @@ export function indexGraph(graph) {
|
|
|
42
42
|
return { nodes, cfOut, mutOut, gateTargets, compensators, entrypoints };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Proof objects (the re-verifiable discharge trace, ADR-corroboration). `apocalypse` emits a
|
|
46
|
+
// VERDICT; a skeptic must re-run SPARDA to believe it. A proof object makes each discharged
|
|
47
|
+
// obligation auditable WITHOUT re-compiling: for every mutating route that passes its guard
|
|
48
|
+
// obligation, the exact `deny_path` — a real chain of node ids in the committed ubg.json — that
|
|
49
|
+
// a third party can trace to confirm the guard gates the write. Deterministic (same graph → same
|
|
50
|
+
// object, testable like a `verify` law), and honest: it reports provenance (verified vs asserted)
|
|
51
|
+
// and corroboration (which independent paths agree), never a bare "trust me, it's PROVEN".
|
|
52
|
+
export function buildProofObjects(graph) {
|
|
53
|
+
const g = indexGraph(graph);
|
|
54
|
+
const proofs = [];
|
|
55
|
+
for (const ep of g.entrypoints) {
|
|
56
|
+
const reached = [...reachOf(ep.id, g.cfOut)]
|
|
57
|
+
.sort()
|
|
58
|
+
.map((id) => g.nodes.get(id))
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
const guards = reached.filter((n) => n.kind === 'guard');
|
|
61
|
+
const writes = [];
|
|
62
|
+
for (const n of reached)
|
|
63
|
+
if (n.kind === 'effect')
|
|
64
|
+
for (const e of g.mutOut.get(n.id) ?? [])
|
|
65
|
+
writes.push({ effect: n, stateId: e.to });
|
|
66
|
+
// a proof object is emitted ONLY for a genuinely DISCHARGED obligation: a mutating route
|
|
67
|
+
// (O1 applies) that actually reaches a guard. A guardless mutation is a FINDING, not a proof.
|
|
68
|
+
if (!writes.length || !guards.length) continue;
|
|
69
|
+
const guardInfo = guards
|
|
70
|
+
.map((gd) => ({
|
|
71
|
+
guard: gd.label,
|
|
72
|
+
node: gd.id,
|
|
73
|
+
// MUST-analysis honesty: 'verified' only when a deny path was SEEN in the body;
|
|
74
|
+
// an opaque middleware trusted by name is 'asserted' — the proof says which.
|
|
75
|
+
provenance: gd.meta?.verified ? 'verified' : 'asserted',
|
|
76
|
+
via: gd.meta?.verifiedVia ?? 'structural',
|
|
77
|
+
}))
|
|
78
|
+
.sort((a, b) => cmp(a.node, b.node));
|
|
79
|
+
proofs.push({
|
|
80
|
+
obligation: 'GUARDED_MUTATION',
|
|
81
|
+
route: ep.label,
|
|
82
|
+
entrypoint: ep.id,
|
|
83
|
+
discharged_by: {
|
|
84
|
+
// the obligation is only as strong as its weakest guard: verified iff ALL are verified
|
|
85
|
+
provenance: guardInfo.every((x) => x.provenance === 'verified')
|
|
86
|
+
? 'verified'
|
|
87
|
+
: 'asserted',
|
|
88
|
+
corroboratedBy: [...new Set(guardInfo.map((x) => x.via))].sort(),
|
|
89
|
+
guards: guardInfo.map(({ guard, node, provenance }) => ({
|
|
90
|
+
guard,
|
|
91
|
+
node,
|
|
92
|
+
provenance,
|
|
93
|
+
})),
|
|
94
|
+
// the re-checkable path: every id exists in ubg.json; a gate edge links guard→handler,
|
|
95
|
+
// and the handler control-flow-reaches each listed mutation. Trace it to confirm.
|
|
96
|
+
deny_path: [ep.id, ...guardInfo.map((x) => x.node)],
|
|
97
|
+
mutations: [...new Set(writes.map((w) => w.effect.id))].sort(),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
proofs.sort((a, b) => cmp(a.entrypoint, b.entrypoint));
|
|
102
|
+
return proofs;
|
|
103
|
+
}
|
|
104
|
+
|
|
45
105
|
export function reachOf(epId, cfOut) {
|
|
46
106
|
const seen = new Set();
|
|
47
107
|
const queue = [epId];
|
|
@@ -100,15 +160,100 @@ export function checkGraph(graph) {
|
|
|
100
160
|
// sharpens the worst routes (open AND fed by user input) without adding false
|
|
101
161
|
// alarms. Under-approximated (SOUNDNESS Direction 1): a missed tag hides nothing.
|
|
102
162
|
const tainted = writes.filter((w) => w.effect.meta.tainted);
|
|
163
|
+
// G2 (guard taxonomy families B–D): a public-by-design route whose body checks a
|
|
164
|
+
// CREDENTIAL and can refuse — a stored-token lookup (reset/invite/verification), a
|
|
165
|
+
// signature/token verify call, or a webhook/callback handshake — is not "unguarded"
|
|
166
|
+
// in the critical sense; it is gated by a mechanism the guard model doesn't carry.
|
|
167
|
+
// The invariant (non-negotiable): this can only DOWNGRADE critical → advisory, with
|
|
168
|
+
// the mechanism named. It never proves (identity scope stays unproven), never
|
|
169
|
+
// silences (the finding remains, visible), so it can never make a false PROVEN on
|
|
170
|
+
// its own — though fewer hard findings CAN promote a clean app; that is the
|
|
171
|
+
// taxonomy's sanctioned direction (a stored-token reset flow is not a vuln).
|
|
172
|
+
// Credential/refusal signals reach the entrypoint two ways: from its own middleware chain
|
|
173
|
+
// (ep.meta.*, set in translate) OR from a body it REACHES through the call graph (the
|
|
174
|
+
// owner.meta.body* tags — the imported/DI service where the throw actually lives). The
|
|
175
|
+
// second path is what closes the first-run and API-key false criticals: their refusal sits
|
|
176
|
+
// in a service method with no effect of its own, so the signal never rode the direct chain.
|
|
177
|
+
const reachedDenies = reached.some((n) => n?.meta?.bodyDenies);
|
|
178
|
+
const reachedVerifies = reached.some((n) => n?.meta?.bodyVerifies);
|
|
179
|
+
const reachedRedirects = reached.some((n) => n?.meta?.bodyRedirects);
|
|
180
|
+
const credGates = ep.meta?.credentialGates === true || reachedDenies;
|
|
181
|
+
const credVerify = ep.meta?.credentialVerify === true || reachedVerifies;
|
|
182
|
+
const credRedirects = ep.meta?.credentialRedirects === true || reachedRedirects;
|
|
183
|
+
// family A/B: a stored-credential lookup — reset/invite/OTP/magic tokens AND long-lived API
|
|
184
|
+
// keys / access tokens / personal-access tokens. Same "read the secret, refuse if it doesn't
|
|
185
|
+
// match" mechanism; only the secret's lifetime differs.
|
|
186
|
+
const tokenRead = reached.some(
|
|
187
|
+
(n) =>
|
|
188
|
+
n?.kind === 'effect' &&
|
|
189
|
+
n.meta.effectType === 'db_read' &&
|
|
190
|
+
/token|verif|invite|otp|magic|api[_-]?key|access[_-]?token|credential|personal[_-]?access/i.test(
|
|
191
|
+
n.meta.table ?? '',
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
const callbackish = /(^|[:/])(callback|webhook|hook)s?([/:]|$)/i.test(ep.label);
|
|
195
|
+
// family E (first-run / admin-setup): a bootstrap-shaped route — sign-up-admin, setup,
|
|
196
|
+
// install, onboard, restore/recover — whose body reads an identity table and refuses. The
|
|
197
|
+
// "only before an admin exists" / "only an admin may" gate. Bounded to bootstrap-shaped
|
|
198
|
+
// PATHS so an ordinary route's generic "user not found → throw" can never trip it, AND still
|
|
199
|
+
// requires a real refusal shape below (credGates) to downgrade.
|
|
200
|
+
const bootstrapish =
|
|
201
|
+
/(^|[:/\- ])(setup|install|onboard(ing)?|bootstrap|first[-_ ]?run|initiali[sz]e|admin[-_ ]?sign[-_ ]?up|register[-_ ]?admin|restore|recover)([/:\- ]|$)/i.test(
|
|
202
|
+
ep.label,
|
|
203
|
+
);
|
|
204
|
+
const identityRead = reached.some(
|
|
205
|
+
(n) =>
|
|
206
|
+
n?.kind === 'effect' &&
|
|
207
|
+
n.meta.effectType === 'db_read' &&
|
|
208
|
+
/user|account|admin|member|identit/i.test(n.meta.table ?? ''),
|
|
209
|
+
);
|
|
210
|
+
const family = tokenRead
|
|
211
|
+
? 'a stored credential-token lookup'
|
|
212
|
+
: credVerify
|
|
213
|
+
? 'a signature/token verification call'
|
|
214
|
+
: callbackish
|
|
215
|
+
? 'a webhook/callback handshake'
|
|
216
|
+
: bootstrapish && identityRead
|
|
217
|
+
? 'a first-run/admin-setup guard'
|
|
218
|
+
: null;
|
|
219
|
+
// refusal shapes per family: token/verify/first-run families must throw or 4xx; the callback
|
|
220
|
+
// family may also refuse by redirecting away (the browser-facing OAuth deny idiom).
|
|
221
|
+
const credentialGated =
|
|
222
|
+
family !== null && (credGates || (callbackish && credRedirects));
|
|
223
|
+
// Class 1 (public-by-design re-label): a route whose PATH is a curated public signature —
|
|
224
|
+
// login/register/logout, forgot/reset-password, verify-email, oauth/sso/saml, callback/webhook,
|
|
225
|
+
// health/metrics/.well-known — is *conventionally* meant to run without a session guard. Unlike
|
|
226
|
+
// the credential families above this carries NO evidence of a gate; it is triage, not proof. So
|
|
227
|
+
// it only ever RE-LABELS critical → info (`expectedPublic`), names the convention, and says
|
|
228
|
+
// "confirm intent" — never hidden, never marked safe, never touches PROVEN. The list is the
|
|
229
|
+
// HEAD of the distribution (deliberately precise, not `**/auth/**` blanket: change-password /
|
|
230
|
+
// 2fa / session management are NOT public, and re-labeling those would hide a real hole).
|
|
231
|
+
const expectedPublic =
|
|
232
|
+
!credentialGated &&
|
|
233
|
+
(/(^|[:/\- ])(log-?in|sign-?in|sign-?up|register|log-?out|sign-?out|forgot-?password|reset-?password|forgot|verify-?email|email-?verification|confirm-?email|magic-?link|oauth\d?|openid|sso|saml|health(z|check|-?check)?|livez|readyz|metrics|well-known)([/:\- ]|$)/i.test(
|
|
234
|
+
ep.label,
|
|
235
|
+
) ||
|
|
236
|
+
/\.well-known/i.test(ep.label) ||
|
|
237
|
+
callbackish);
|
|
238
|
+
const softened = credentialGated || expectedPublic;
|
|
103
239
|
findings.push({
|
|
104
240
|
rule: 'UNGUARDED_MUTATION',
|
|
105
|
-
severity: 'critical',
|
|
241
|
+
severity: softened ? 'info' : 'critical',
|
|
242
|
+
...(credentialGated
|
|
243
|
+
? { advisory: true, credentialFamily: family }
|
|
244
|
+
: expectedPublic
|
|
245
|
+
? { advisory: true, expectedPublic: true }
|
|
246
|
+
: {}),
|
|
106
247
|
entrypoint: ep.id,
|
|
107
|
-
message:
|
|
108
|
-
|
|
109
|
-
|
|
248
|
+
message: credentialGated
|
|
249
|
+
? `${ep.label} mutates ${fmtStates(writes)} with no modeled guard — but the body checks ${family} and can refuse; verify that credential's scope covers the mutation`
|
|
250
|
+
: expectedPublic
|
|
251
|
+
? `${ep.label} mutates ${fmtStates(writes)} with no modeled guard — but its path matches a public-by-design signature (auth / oauth / callback / health / metrics); confirm this endpoint is meant to be unauthenticated`
|
|
252
|
+
: `${ep.label} mutates ${fmtStates(writes)} with no guard anywhere on the path${
|
|
253
|
+
tainted.length ? ' — and request data flows straight into the write' : ''
|
|
254
|
+
}`,
|
|
110
255
|
evidence: writes.map((w) => `${w.effect.id} (${locOf(w.effect)})`),
|
|
111
|
-
...(tainted.length ? { tainted: true } : {}),
|
|
256
|
+
...(tainted.length && !softened ? { tainted: true } : {}),
|
|
112
257
|
});
|
|
113
258
|
}
|
|
114
259
|
|
|
@@ -244,7 +389,18 @@ export function checkGraph(graph) {
|
|
|
244
389
|
const adminish =
|
|
245
390
|
/(^|[:/])(admin|internal|system|cron|webhooks?|callback)([/:]|$)/i.test(ep.label) ||
|
|
246
391
|
guards.some((gd) => /admin|internal|system|cron|super/i.test(gd?.label ?? ''));
|
|
247
|
-
|
|
392
|
+
// G1: a call-site ownership assertion on the route (`getXOrThrow({ workspaceId:
|
|
393
|
+
// workspace.id })`) scopes the object to the caller even when the proof lives in an
|
|
394
|
+
// imported helper we don't expand — so it clears the BOLA advisory, same as a visible
|
|
395
|
+
// `ownerScoped` query. Advisory-only, so this can never mask a hard finding.
|
|
396
|
+
const ownerAsserted = ep.meta?.ownerAsserted === true;
|
|
397
|
+
if (
|
|
398
|
+
idScoped.length &&
|
|
399
|
+
!ownerScopedSeen &&
|
|
400
|
+
!ownerAsserted &&
|
|
401
|
+
guards.length &&
|
|
402
|
+
!adminish
|
|
403
|
+
) {
|
|
248
404
|
const tables = [
|
|
249
405
|
...new Set(idScoped.map((n) => n.meta.table).filter(Boolean)),
|
|
250
406
|
].sort();
|
|
@@ -488,7 +644,7 @@ const COVERAGE_FLOOR = 0.05;
|
|
|
488
644
|
// finding, never changes the CI gate; a PARTIAL app is still clean, just qualified.
|
|
489
645
|
const COVERAGE_COMPLETE = 0.6;
|
|
490
646
|
|
|
491
|
-
export function verdictOf(findings, graph, { coverage } = {}) {
|
|
647
|
+
export function verdictOf(findings, graph, { coverage, blindHigh = 0 } = {}) {
|
|
492
648
|
const counts = { critical: 0, high: 0, medium: 0, info: 0 };
|
|
493
649
|
for (const f of findings) counts[f.severity]++;
|
|
494
650
|
// Advisory findings (BOLA/IDOR, ADR-058) are absence-based and FP-prone: they point a
|
|
@@ -528,9 +684,20 @@ export function verdictOf(findings, graph, { coverage } = {}) {
|
|
|
528
684
|
// A clean whole-app proof below the completeness bar is PARTIAL — proved, but not over
|
|
529
685
|
// the whole surface. Only meaningful when coverage was measured (whole-app run); a partial
|
|
530
686
|
// graph (heal delta, coverage undefined) is never labelled partial.
|
|
687
|
+
//
|
|
688
|
+
// E-047 (the blind-spot rung, from the cal.com giant test): coverage is a RATIO, so on a
|
|
689
|
+
// huge app it can clear the bar (cal.com/api/v2: 71%) while the ABSOLUTE count of high-risk
|
|
690
|
+
// blind spots is large (46 guarded mutations whose writes never resolved). A bare "PROVEN"
|
|
691
|
+
// over 46 unproven high-risk mutations over-claims. So a clean app is also PARTIAL when any
|
|
692
|
+
// high-severity blind spot remains — the label carries the uncertainty (metrology's error
|
|
693
|
+
// bars), independent of the ratio. Sound: only ever SOFTENS PROVEN→PARTIAL, never masks a
|
|
694
|
+
// finding, never touches the CI gate (`safe`). blindHigh defaults to 0, so a caller that
|
|
695
|
+
// did not survey blind spots (heal delta) is unaffected.
|
|
531
696
|
const clean = provable && !surfaceOnly && hardCount === 0;
|
|
532
697
|
const partial =
|
|
533
|
-
clean &&
|
|
698
|
+
clean &&
|
|
699
|
+
entrypoints > 0 &&
|
|
700
|
+
((coverage != null && coverage < COVERAGE_COMPLETE) || blindHigh > 0);
|
|
534
701
|
// `safe` is the CI gate (block a risky deploy): a surface-only app has no
|
|
535
702
|
// critical/high findings and is NOT risky, so it does not fail the gate — it just
|
|
536
703
|
// isn't a positive proof. `clean` is the strong claim (PROVEN) and DOES require
|
package/src/ubg/extract.js
CHANGED
|
@@ -140,6 +140,8 @@ const moduleCache = new Map(); // absFile -> module facts (parse once per compil
|
|
|
140
140
|
export function clearModuleCache() {
|
|
141
141
|
moduleCache.clear();
|
|
142
142
|
tsconfigCache.clear();
|
|
143
|
+
workspaceCache.clear();
|
|
144
|
+
workspaceRootOf.clear();
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
// absFile → { ast, functions: Map<name,{node,line,exported}>, imports: Map<local,abs>, error }
|
|
@@ -357,7 +359,159 @@ export function resolveRelImport(fromFile, spec) {
|
|
|
357
359
|
// cross-module hop (controller → service → repository) dead-ends and effects behind
|
|
358
360
|
// DI are invisible. A bare npm package (`kysely`, `@nestjs/common`) simply resolves
|
|
359
361
|
// to nothing here (no matching file under the project), which is correct.
|
|
360
|
-
|
|
362
|
+
//
|
|
363
|
+
// The WORKSPACE fallback (the mycorrhizal network): a monorepo app's real mutation
|
|
364
|
+
// logic lives in shared workspace packages it imports by NAME, not by path — cal.com's
|
|
365
|
+
// `this.svc.updateEventType()` delegates to `@calcom/platform-libraries` → `@calcom/trpc`
|
|
366
|
+
// → `prisma.update()`, three packages away and entirely outside the analyzed app dir. A
|
|
367
|
+
// tsconfig alias can't reach them (they're resolved via the workspace, not `paths`). So
|
|
368
|
+
// when the alias miss, map the `@scope/pkg[/subpath]` specifier to the package's real
|
|
369
|
+
// directory under the workspace and resolve into it. Trees share nutrients across the
|
|
370
|
+
// fungal network, not just their own root ball — the schema/effect code is a shared
|
|
371
|
+
// nutrient drawn from the workspace, not the app's own folder.
|
|
372
|
+
return (
|
|
373
|
+
resolveAliasedImport(fromFile, clean(spec)) ??
|
|
374
|
+
resolveWorkspaceImport(fromFile, clean(spec))
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// name -> absolute package dir, for every package in the workspace that owns `fromFile`.
|
|
379
|
+
// Cached per workspace root (built once, ~100 package.json reads on a giant monorepo).
|
|
380
|
+
const workspaceCache = new Map(); // rootDir -> Map(name -> dir)
|
|
381
|
+
const workspaceRootOf = new Map(); // dir -> rootDir | null
|
|
382
|
+
|
|
383
|
+
// walk up to the monorepo root: the nearest ancestor whose package.json declares
|
|
384
|
+
// `workspaces`, or that carries a pnpm-workspace.yaml. `null` = not a workspace.
|
|
385
|
+
function findWorkspaceRoot(fromFile) {
|
|
386
|
+
let dir = path.dirname(fromFile);
|
|
387
|
+
const chain = [];
|
|
388
|
+
for (let i = 0; i < 40; i++) {
|
|
389
|
+
if (workspaceRootOf.has(dir)) {
|
|
390
|
+
const v = workspaceRootOf.get(dir);
|
|
391
|
+
for (const d of chain) workspaceRootOf.set(d, v);
|
|
392
|
+
return v;
|
|
393
|
+
}
|
|
394
|
+
chain.push(dir);
|
|
395
|
+
let root = null;
|
|
396
|
+
try {
|
|
397
|
+
const pj = path.join(dir, 'package.json');
|
|
398
|
+
if (fs.existsSync(pj)) {
|
|
399
|
+
const pkg = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
400
|
+
if (pkg.workspaces) root = dir;
|
|
401
|
+
}
|
|
402
|
+
if (!root && fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) root = dir;
|
|
403
|
+
} catch {
|
|
404
|
+
// unreadable manifest — keep walking up
|
|
405
|
+
}
|
|
406
|
+
if (root) {
|
|
407
|
+
for (const d of chain) workspaceRootOf.set(d, root);
|
|
408
|
+
return root;
|
|
409
|
+
}
|
|
410
|
+
const parent = path.dirname(dir);
|
|
411
|
+
if (parent === dir) break;
|
|
412
|
+
dir = parent;
|
|
413
|
+
}
|
|
414
|
+
for (const d of chain) workspaceRootOf.set(d, null);
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// the workspace's package globs, from package.json `workspaces` (array or {packages})
|
|
419
|
+
// or a minimal pnpm-workspace.yaml parse (the `- '...'` list under `packages:`).
|
|
420
|
+
function workspaceGlobs(root) {
|
|
421
|
+
const globs = [];
|
|
422
|
+
try {
|
|
423
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
424
|
+
const ws = pkg.workspaces;
|
|
425
|
+
if (Array.isArray(ws)) globs.push(...ws);
|
|
426
|
+
else if (ws && Array.isArray(ws.packages)) globs.push(...ws.packages);
|
|
427
|
+
} catch {
|
|
428
|
+
// no/invalid root package.json — fall through to pnpm
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const yml = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
|
|
432
|
+
for (const m of yml.matchAll(/^\s*-\s*['"]?([^'"\n]+?)['"]?\s*$/gm)) globs.push(m[1]);
|
|
433
|
+
} catch {
|
|
434
|
+
// no pnpm-workspace.yaml
|
|
435
|
+
}
|
|
436
|
+
return globs;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// expand one workspace glob to concrete package dirs. Supports the two forms real
|
|
440
|
+
// workspaces use: an exact path (`packages/app-store`) and a trailing `/*` (one level of
|
|
441
|
+
// subdirs, e.g. `packages/*`, `packages/platform/*`). `**` is treated as a single level —
|
|
442
|
+
// good enough for every workspace layout in the wild, and never unbounded.
|
|
443
|
+
function expandGlob(root, glob) {
|
|
444
|
+
const g = glob.replace(/\/\*\*$/, '/*');
|
|
445
|
+
const star = g.indexOf('*');
|
|
446
|
+
if (star === -1) return [path.resolve(root, g)];
|
|
447
|
+
const baseRel = g.slice(0, star).replace(/\/$/, '');
|
|
448
|
+
const baseDir = path.resolve(root, baseRel);
|
|
449
|
+
try {
|
|
450
|
+
return fs
|
|
451
|
+
.readdirSync(baseDir, { withFileTypes: true })
|
|
452
|
+
.filter((e) => e.isDirectory())
|
|
453
|
+
.map((e) => path.join(baseDir, e.name));
|
|
454
|
+
} catch {
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function workspacePackages(fromFile) {
|
|
460
|
+
const root = findWorkspaceRoot(fromFile);
|
|
461
|
+
if (!root) return null;
|
|
462
|
+
if (workspaceCache.has(root)) return workspaceCache.get(root);
|
|
463
|
+
const map = new Map();
|
|
464
|
+
for (const glob of workspaceGlobs(root)) {
|
|
465
|
+
for (const dir of expandGlob(root, glob)) {
|
|
466
|
+
try {
|
|
467
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
468
|
+
if (typeof pkg.name === 'string' && !map.has(pkg.name)) map.set(pkg.name, dir);
|
|
469
|
+
} catch {
|
|
470
|
+
// not a package (no/invalid package.json) — skip
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
workspaceCache.set(root, map);
|
|
475
|
+
return map;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// the entry file of a package with no subpath import (`@calcom/trpc`): its declared
|
|
479
|
+
// source/main/module, else the conventional src/index or index.
|
|
480
|
+
function packageEntry(dir) {
|
|
481
|
+
const fields = [];
|
|
482
|
+
try {
|
|
483
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
484
|
+
for (const f of [pkg.source, pkg.module, pkg.main])
|
|
485
|
+
if (typeof f === 'string') fields.push(f);
|
|
486
|
+
} catch {
|
|
487
|
+
// no package.json fields — conventions below still apply
|
|
488
|
+
}
|
|
489
|
+
for (const f of [...fields, 'src/index', 'index']) {
|
|
490
|
+
const hit = firstModuleFile(path.resolve(dir, f.replace(/\.(m?[jt]s|cjs)$/, '')));
|
|
491
|
+
if (hit) return hit;
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// resolve `@scope/pkg/subpath` (or `pkg/subpath`) to a real source file under the
|
|
497
|
+
// workspace. Longest package-name match wins (`@calcom/platform-libraries` beats a
|
|
498
|
+
// hypothetical `@calcom/platform`), then the subpath resolves against the package dir.
|
|
499
|
+
function resolveWorkspaceImport(fromFile, spec) {
|
|
500
|
+
if (spec.startsWith('.') || spec.startsWith('/')) return null;
|
|
501
|
+
const map = workspacePackages(fromFile);
|
|
502
|
+
if (!map) return null;
|
|
503
|
+
let best = null;
|
|
504
|
+
for (const name of map.keys()) {
|
|
505
|
+
if (
|
|
506
|
+
(spec === name || spec.startsWith(name + '/')) &&
|
|
507
|
+
name.length > (best?.length ?? -1)
|
|
508
|
+
)
|
|
509
|
+
best = name;
|
|
510
|
+
}
|
|
511
|
+
if (!best) return null;
|
|
512
|
+
const dir = map.get(best);
|
|
513
|
+
const sub = spec.length > best.length ? spec.slice(best.length + 1) : '';
|
|
514
|
+
return sub ? firstModuleFile(path.resolve(dir, sub)) : packageEntry(dir);
|
|
361
515
|
}
|
|
362
516
|
|
|
363
517
|
// probe the standard TS/JS extensions + index files for a resolved base path.
|
|
@@ -677,6 +831,14 @@ export function scanFunction(fnNode, env = {}) {
|
|
|
677
831
|
calls: [],
|
|
678
832
|
guardSignals: { deniesWithStatus: false },
|
|
679
833
|
validatesInput: false,
|
|
834
|
+
// O7/BOLA only (G1): the body asserts caller-ownership at a call site
|
|
835
|
+
// (`getXOrThrow({ workspaceId: workspace.id })`) — an advisory-only signal.
|
|
836
|
+
ownerAsserted: false,
|
|
837
|
+
// G2 (guard-taxonomy families B–F): credential-check signals, SEPARATE from
|
|
838
|
+
// guardSignals on purpose — they may only DOWNGRADE an UNGUARDED_MUTATION critical to
|
|
839
|
+
// an advisory (never prove a guard, never silence a finding), so widening them can
|
|
840
|
+
// never fabricate a gate (E-042) or a false PROVEN.
|
|
841
|
+
credentialSignals: { verifyCall: false, denies4xxOrThrows: false, redirects: false },
|
|
680
842
|
async: Boolean(fnNode?.async),
|
|
681
843
|
};
|
|
682
844
|
if (!fnNode) return result;
|
|
@@ -772,6 +934,64 @@ function whereOwnerScoped(whereNode) {
|
|
|
772
934
|
return scoped;
|
|
773
935
|
}
|
|
774
936
|
|
|
937
|
+
// Values that reference the caller's VERIFIED identity — the principal a guard puts on the
|
|
938
|
+
// path: dub's `withWorkspace` → `workspace`, a session → `session.user`, `req.user`. Broader
|
|
939
|
+
// than OWNERSHIP_ROOT because it must catch the framework's identity object by its conventional
|
|
940
|
+
// name (the thing a scoped mutation is measured against).
|
|
941
|
+
const IDENTITY_ROOT =
|
|
942
|
+
/^(workspace|session|auth|user|me|currentuser|actor|viewer|principal|org|organization|team|project|tenant|account|membership)s?$/i;
|
|
943
|
+
// an identity token anywhere in an access path (`req.session.user`, `workspace.id`)
|
|
944
|
+
const IDENTITY_TOKEN =
|
|
945
|
+
/^(workspace|session|auth|user|me|currentuser|actor|viewer|principal|org|organization|team|project|tenant|account|member|membership)s?$/i;
|
|
946
|
+
// request INPUT the caller controls — never the verified identity, even if named `workspaceId`
|
|
947
|
+
// (`req.body.workspaceId` is an attacker value; matching it would silence a REAL BOLA).
|
|
948
|
+
const REQ_INPUT = /^(body|query|params|input|payload|args|data)$/i;
|
|
949
|
+
function valueIsIdentity(node) {
|
|
950
|
+
if (!node) return false;
|
|
951
|
+
if (node.type === 'Identifier') return IDENTITY_ROOT.test(node.name);
|
|
952
|
+
if (node.type !== 'MemberExpression') return false;
|
|
953
|
+
// collect every identifier segment of the access path
|
|
954
|
+
const parts = [];
|
|
955
|
+
let cur = node;
|
|
956
|
+
while (cur?.type === 'MemberExpression') {
|
|
957
|
+
if (cur.property?.type === 'Identifier') parts.push(cur.property.name);
|
|
958
|
+
cur = cur.object;
|
|
959
|
+
}
|
|
960
|
+
if (cur?.type === 'Identifier') parts.push(cur.name);
|
|
961
|
+
if (parts.some((p) => REQ_INPUT.test(p))) return false; // caller-controlled → not identity
|
|
962
|
+
return parts.some((p) => IDENTITY_TOKEN.test(p));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Does this call assert caller-ownership at the site — an argument object binding an ownership
|
|
966
|
+
// KEY to a VERIFIED-IDENTITY value, `getCustomerOrThrow({ workspaceId: workspace.id, id })`?
|
|
967
|
+
// This is the caller stating, before it mutates, that the object it is about to touch is scoped
|
|
968
|
+
// to its own identity — visible WITHOUT resolving the (imported) helper. It feeds ONLY the O7
|
|
969
|
+
// BOLA advisory, never a hard rule, so it can never create a false PROVEN: at worst it silences
|
|
970
|
+
// an advisory the field test wants silenced (dub's ~60 false BOLA are exactly this pattern).
|
|
971
|
+
// the identity object passed to the helper BY NAME — `getDomainOrThrow({ workspace, domain })`
|
|
972
|
+
// hands the whole verified principal, not a `workspaceId:` key.
|
|
973
|
+
const IDENTITY_KEY =
|
|
974
|
+
/^(workspace|session|user|team|project|tenant|account|org|organization|member|membership|owner|actor|principal)$/i;
|
|
975
|
+
function callAssertsOwnership(node) {
|
|
976
|
+
for (const arg of node.arguments ?? []) {
|
|
977
|
+
if (arg?.type !== 'ObjectExpression') continue;
|
|
978
|
+
for (const p of arg.properties) {
|
|
979
|
+
if (p.type !== 'ObjectProperty' || p.key?.type !== 'Identifier') continue;
|
|
980
|
+
// (1) the verified identity handed in by name: `{ workspace, … }` / `{ session }`
|
|
981
|
+
if (IDENTITY_KEY.test(p.key.name)) return true;
|
|
982
|
+
// (2) an ownership key bound to the identity OR to a scope-named local (shorthand):
|
|
983
|
+
// `{ workspaceId: workspace.id }` / `{ workspaceId }`. Same generosity whereOwnerScoped
|
|
984
|
+
// already grants a `where` key — and O7 is advisory, so it can only silence an advisory.
|
|
985
|
+
if (
|
|
986
|
+
OWNERSHIP_KEY.test(p.key.name) &&
|
|
987
|
+
(valueIsIdentity(p.value) || p.value?.type === 'Identifier')
|
|
988
|
+
)
|
|
989
|
+
return true;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
|
|
775
995
|
// does this `where` target a specific object by a bare `id` key (the BOLA shape)?
|
|
776
996
|
function whereHasIdKey(whereNode) {
|
|
777
997
|
if (whereNode?.type !== 'ObjectExpression') return false;
|
|
@@ -867,6 +1087,11 @@ function visit(node, out, ctx) {
|
|
|
867
1087
|
return;
|
|
868
1088
|
}
|
|
869
1089
|
|
|
1090
|
+
// G2 credential signal (advisory-only): the body can refuse — any throw, or any 4xx
|
|
1091
|
+
// response. Deliberately BROADER than guardSignals.deniesWithStatus (401/403) because it
|
|
1092
|
+
// can only ever downgrade a critical to an advisory, never verify a guard.
|
|
1093
|
+
if (node.type === 'ThrowStatement') out.credentialSignals.denies4xxOrThrows = true;
|
|
1094
|
+
|
|
870
1095
|
// try/catch — try-effects are compensable by catch-effects (SBIR §2.2)
|
|
871
1096
|
if (node.type === 'TryStatement') {
|
|
872
1097
|
const tryLine = node.loc?.start.line ?? 0;
|
|
@@ -969,9 +1194,42 @@ function inspectCall(node, out, ctx) {
|
|
|
969
1194
|
if (out.returnShapes.length < MAX_RETURN_SHAPES && jsonShape !== null)
|
|
970
1195
|
out.returnShapes.push({ line, shape: jsonShape });
|
|
971
1196
|
if (deniedStatusOf(node)) out.guardSignals.deniesWithStatus = true;
|
|
1197
|
+
if (statusIn4xx(node)) out.credentialSignals.denies4xxOrThrows = true;
|
|
972
1198
|
return;
|
|
973
1199
|
}
|
|
974
1200
|
if (deniedStatusOf(node)) out.guardSignals.deniesWithStatus = true;
|
|
1201
|
+
if (statusIn4xx(node)) out.credentialSignals.denies4xxOrThrows = true;
|
|
1202
|
+
|
|
1203
|
+
// G1 (O7/BOLA only): a call that asserts caller-ownership at the site scopes the path —
|
|
1204
|
+
// `getCustomerOrThrow({ workspaceId: workspace.id, id })`. Advisory-only, so it can never
|
|
1205
|
+
// create a false PROVEN; it silences the false BOLA the imported ownership helper would raise.
|
|
1206
|
+
if (callAssertsOwnership(node)) out.ownerAsserted = true;
|
|
1207
|
+
|
|
1208
|
+
// G2 (advisory-only): a call whose NAME says it verifies a credential — `verifyUnsubscribeToken`,
|
|
1209
|
+
// `jwt.verify`, an HMAC/signature check. Family B/D of the guard taxonomy. Never a guard by
|
|
1210
|
+
// itself (E-042: a name fabricates nothing) — it only feeds the UNGUARDED downgrade.
|
|
1211
|
+
const verifyName =
|
|
1212
|
+
callee.type === 'Identifier'
|
|
1213
|
+
? callee.name
|
|
1214
|
+
: callee.type === 'MemberExpression' && callee.property?.type === 'Identifier'
|
|
1215
|
+
? callee.property.name
|
|
1216
|
+
: '';
|
|
1217
|
+
if (/verify|signature|hmac/i.test(verifyName)) out.credentialSignals.verifyCall = true;
|
|
1218
|
+
// OAuth/browser callbacks refuse by REDIRECTING away (family F) — no throw, no 4xx.
|
|
1219
|
+
// Only ever consulted for callback-shaped routes, and only to downgrade to advisory.
|
|
1220
|
+
if (verifyName === 'redirect') out.credentialSignals.redirects = true;
|
|
1221
|
+
// A named REFUSAL helper is the dominant Next.js / App-Router idiom — the deny is a call like
|
|
1222
|
+
// `responses.notAuthenticatedResponse()` / `unauthorized()` / `throwForbidden()`, never a literal
|
|
1223
|
+
// `res.status(401)` or a bare `throw`, so statusIn4xx and the ThrowStatement check both miss it.
|
|
1224
|
+
// The refusal is real; only its spelling is a helper. Advisory-only (feeds the UNGUARDED
|
|
1225
|
+
// downgrade, never a guard): a route still needs a matching credential FAMILY to downgrade, so a
|
|
1226
|
+
// plain 404 helper on an ordinary route changes nothing.
|
|
1227
|
+
if (
|
|
1228
|
+
/(not_?authenticated|un_?authenticated|not_?authori[sz]ed|un_?authori[sz]ed|forbidden|access_?denied|bad_?request|too_?many_?requests|rate_?limit(ed|_?exceeded)?|missing_?fields|unprocessable|invalid_?(api_?key|token|credential|secret|request|permission))/i.test(
|
|
1229
|
+
verifyName,
|
|
1230
|
+
)
|
|
1231
|
+
)
|
|
1232
|
+
out.credentialSignals.denies4xxOrThrows = true;
|
|
975
1233
|
|
|
976
1234
|
// ---- local calls: bare identifier calls → call-graph edges later
|
|
977
1235
|
if (callee.type === 'Identifier') {
|
|
@@ -1330,6 +1588,45 @@ function isDenyOptions(arg) {
|
|
|
1330
1588
|
return false;
|
|
1331
1589
|
}
|
|
1332
1590
|
|
|
1591
|
+
// G2 (advisory-only): ANY 4xx refusal — broader than deniedStatusOf's 401/403 on purpose.
|
|
1592
|
+
// A stored-token flow refuses with 400/404/410 as often as 401; since this signal can only
|
|
1593
|
+
// downgrade a critical to an advisory (never verify a guard), the width is safe.
|
|
1594
|
+
function statusIn4xx(node) {
|
|
1595
|
+
const is4xx = (v) => typeof v === 'number' && v >= 400 && v < 500;
|
|
1596
|
+
const inArgs = (args) =>
|
|
1597
|
+
(args ?? []).some(
|
|
1598
|
+
(a) =>
|
|
1599
|
+
a?.type === 'ObjectExpression' &&
|
|
1600
|
+
a.properties.some(
|
|
1601
|
+
(p) =>
|
|
1602
|
+
p.type === 'ObjectProperty' &&
|
|
1603
|
+
p.key?.name === 'status' &&
|
|
1604
|
+
p.value?.type === 'NumericLiteral' &&
|
|
1605
|
+
is4xx(p.value.value),
|
|
1606
|
+
),
|
|
1607
|
+
);
|
|
1608
|
+
let cur = node;
|
|
1609
|
+
while (cur) {
|
|
1610
|
+
if (cur.type === 'CallExpression') {
|
|
1611
|
+
const m =
|
|
1612
|
+
cur.callee?.type === 'MemberExpression' ? cur.callee.property?.name : null;
|
|
1613
|
+
if (
|
|
1614
|
+
(m === 'status' || m === 'sendStatus') &&
|
|
1615
|
+
cur.arguments[0]?.type === 'NumericLiteral' &&
|
|
1616
|
+
is4xx(cur.arguments[0].value)
|
|
1617
|
+
)
|
|
1618
|
+
return true;
|
|
1619
|
+
if (m === 'json' && inArgs(cur.arguments)) return true;
|
|
1620
|
+
}
|
|
1621
|
+
cur =
|
|
1622
|
+
cur.callee?.type === 'MemberExpression' &&
|
|
1623
|
+
cur.callee.object?.type === 'CallExpression'
|
|
1624
|
+
? cur.callee.object
|
|
1625
|
+
: null;
|
|
1626
|
+
}
|
|
1627
|
+
return false;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1333
1630
|
function deniedStatusOf(node) {
|
|
1334
1631
|
const isDeny = (v) => v === 401 || v === 403;
|
|
1335
1632
|
// NextResponse.json(_, { status: 401 }) / res.json(_, { status: 403 }) — the deny is
|
|
@@ -73,6 +73,12 @@ function mergeNodes(graph, aId, bId) {
|
|
|
73
73
|
if (b.meta.returnShapes) {
|
|
74
74
|
a.meta.returnShapes = [...(a.meta.returnShapes ?? []), ...b.meta.returnShapes];
|
|
75
75
|
}
|
|
76
|
+
// Carry G1/G2 advisory body signals (a credential refusal or an ownership assertion) from the
|
|
77
|
+
// absorbed node onto the survivor. A thin delegator whose only job is to call a helper that
|
|
78
|
+
// throws/refuses would otherwise lose that signal at the merge and read as a false critical.
|
|
79
|
+
// These flags only ever DOWNGRADE a critical to advisory — never prove.
|
|
80
|
+
for (const k of ['bodyDenies', 'bodyVerifies', 'bodyRedirects', 'ownerAsserted'])
|
|
81
|
+
if (b.meta[k]) a.meta[k] = true;
|
|
76
82
|
|
|
77
83
|
const nextEdges = [];
|
|
78
84
|
for (const e of graph.edges) {
|
package/src/ubg/prisma.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { cmp } from './schema.js';
|
|
13
|
+
import { workspacePackages } from './extract.js';
|
|
13
14
|
|
|
14
15
|
const SCHEMA_CANDIDATES = ['prisma/schema.prisma', 'schema.prisma', 'db/schema.prisma'];
|
|
15
16
|
// Prisma's `prismaSchemaFolder` layout (stable since 6.x): the schema is SPLIT across many
|
|
@@ -55,6 +56,35 @@ function collectSchemaFiles(cwd, fileCandidates, dirCandidates) {
|
|
|
55
56
|
return [];
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// P4 (E-048, the mycorrhizal network for STATE): a monorepo app usually declares NO schema of
|
|
60
|
+
// its own — it depends on a shared `@scope/prisma`-style workspace package that owns the whole
|
|
61
|
+
// schema (cal.com: apps/web has 0 tables locally; `@calcom/prisma` has 100 models). Without
|
|
62
|
+
// this the app's entire state layer is invisible and every mutation reasons against nothing.
|
|
63
|
+
// When the app dir yields no schema, look through its workspace DEPENDENCIES for the package
|
|
64
|
+
// that owns one — the same name→dir map the module resolver already builds. Schema-ish deps are
|
|
65
|
+
// tried first (a `prisma`/`db` package is the DB package), then any remaining workspace dep.
|
|
66
|
+
function workspaceSchemaFiles(cwd, fileCandidates, dirCandidates) {
|
|
67
|
+
let deps;
|
|
68
|
+
try {
|
|
69
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
70
|
+
deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const map = workspacePackages(path.join(cwd, 'package.json'));
|
|
75
|
+
if (!map) return [];
|
|
76
|
+
const inWorkspace = Object.keys(deps).filter((n) => map.has(n));
|
|
77
|
+
const ordered = [
|
|
78
|
+
...inWorkspace.filter((n) => /prisma|schema|(^|[-/])db($|[-/])|database/i.test(n)),
|
|
79
|
+
...inWorkspace.filter((n) => !/prisma|schema|(^|[-/])db($|[-/])|database/i.test(n)),
|
|
80
|
+
];
|
|
81
|
+
for (const name of ordered) {
|
|
82
|
+
const files = collectSchemaFiles(map.get(name), fileCandidates, dirCandidates);
|
|
83
|
+
if (files.length) return files;
|
|
84
|
+
}
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
const TYPE_MAP = {
|
|
59
89
|
string: 'string',
|
|
60
90
|
int: 'number',
|
|
@@ -78,7 +108,11 @@ export function parsePrismaSchemas(cwd) {
|
|
|
78
108
|
} catch {
|
|
79
109
|
// no package.json / unparsable — the default candidates stand
|
|
80
110
|
}
|
|
81
|
-
|
|
111
|
+
// local first; then the workspace dependency that owns the shared schema (P4)
|
|
112
|
+
const local = collectSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
|
|
113
|
+
const files = local.length
|
|
114
|
+
? local
|
|
115
|
+
: workspaceSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
|
|
82
116
|
if (!files.length) return { tables: [], skipped, sourceFile: null };
|
|
83
117
|
|
|
84
118
|
// Read + strip comments once per file; keep the relative path for correct per-table locs.
|
package/src/ubg/resolve.js
CHANGED
|
@@ -39,6 +39,23 @@ export function mergeScan(into, add) {
|
|
|
39
39
|
into.validatesInput = into.validatesInput || add.validatesInput;
|
|
40
40
|
into.async = into.async || add.async;
|
|
41
41
|
if (add.guardSignals?.deniesWithStatus) into.guardSignals.deniesWithStatus = true;
|
|
42
|
+
// G1/G2 (advisory-only): a delegated service method that asserts caller-ownership, or refuses on
|
|
43
|
+
// a credential check (throw/4xx/verify/redirect), carries that signal UP to the handler it is
|
|
44
|
+
// reached from. Without this a Nest/DI refusal that lives one class away (`this.service.x()`) is
|
|
45
|
+
// dropped at the merge and its route reads as a false critical — the first-run / admin-setup and
|
|
46
|
+
// API-key families. These signals can only DOWNGRADE a critical to advisory, never prove.
|
|
47
|
+
if (add.ownerAsserted) into.ownerAsserted = true;
|
|
48
|
+
if (add.credentialSignals) {
|
|
49
|
+
into.credentialSignals ??= {
|
|
50
|
+
verifyCall: false,
|
|
51
|
+
denies4xxOrThrows: false,
|
|
52
|
+
redirects: false,
|
|
53
|
+
};
|
|
54
|
+
if (add.credentialSignals.verifyCall) into.credentialSignals.verifyCall = true;
|
|
55
|
+
if (add.credentialSignals.denies4xxOrThrows)
|
|
56
|
+
into.credentialSignals.denies4xxOrThrows = true;
|
|
57
|
+
if (add.credentialSignals.redirects) into.credentialSignals.redirects = true;
|
|
58
|
+
}
|
|
42
59
|
}
|
|
43
60
|
|
|
44
61
|
export const relOf = (cwd, abs) => path.relative(cwd, abs).split(path.sep).join('/');
|
package/src/ubg/translate.js
CHANGED
|
@@ -204,6 +204,22 @@ function translateRoute(
|
|
|
204
204
|
for (const cn of chainNodes) {
|
|
205
205
|
if (cn.scan) attachBody(graph, cn.id, cn.scan, helperByName, scanCache, expanded);
|
|
206
206
|
}
|
|
207
|
+
|
|
208
|
+
// G1 (O7/BOLA only): if any step on this route asserts caller-ownership at a call site
|
|
209
|
+
// (`getXOrThrow({ workspaceId: workspace.id })`), the route scopes its objects to the
|
|
210
|
+
// caller — even when the assertion lives in an imported helper SPARDA doesn't expand.
|
|
211
|
+
// Advisory-only signal: it silences the false BOLA, never touches a hard rule.
|
|
212
|
+
if (chainNodes.some((cn) => cn.scan?.ownerAsserted))
|
|
213
|
+
graph.nodes.get(epId).meta.ownerAsserted = true;
|
|
214
|
+
|
|
215
|
+
// G2 (guard taxonomy): credential-check signals from the route's own bodies. Advisory-only —
|
|
216
|
+
// they can downgrade an UNGUARDED critical to an advisory, never verify a guard.
|
|
217
|
+
if (chainNodes.some((cn) => cn.scan?.credentialSignals?.verifyCall))
|
|
218
|
+
graph.nodes.get(epId).meta.credentialVerify = true;
|
|
219
|
+
if (chainNodes.some((cn) => cn.scan?.credentialSignals?.denies4xxOrThrows))
|
|
220
|
+
graph.nodes.get(epId).meta.credentialGates = true;
|
|
221
|
+
if (chainNodes.some((cn) => cn.scan?.credentialSignals?.redirects))
|
|
222
|
+
graph.nodes.get(epId).meta.credentialRedirects = true;
|
|
207
223
|
}
|
|
208
224
|
|
|
209
225
|
// A chain step (middleware or handler) becomes a guard or logic node.
|
|
@@ -258,6 +274,19 @@ function attachBody(graph, ownerId, scan, helperByName, scanCache, expanded) {
|
|
|
258
274
|
expanded.add(ownerId);
|
|
259
275
|
const owner = graph.nodes.get(ownerId);
|
|
260
276
|
|
|
277
|
+
// G2 propagation (advisory-only): a body that itself refuses on a credential check — throw/4xx,
|
|
278
|
+
// a verify call, or an OAuth redirect — carries that refusal AT ITS OWN node. This is why the
|
|
279
|
+
// first-run and API-key gates read as false criticals: their refusal lives in an imported/DI
|
|
280
|
+
// service method that has no effect of its own, so the signal never rode the entrypoint's direct
|
|
281
|
+
// middleware chain. Tagging the reached body lets the prover see the mechanism through the call
|
|
282
|
+
// graph. Reading this tag can only DOWNGRADE a critical to advisory — never prove, never silence,
|
|
283
|
+
// never fabricate a guard. Tagged once, on first expansion; the node is route-independent.
|
|
284
|
+
if (owner) {
|
|
285
|
+
if (scan.credentialSignals?.denies4xxOrThrows) owner.meta.bodyDenies = true;
|
|
286
|
+
if (scan.credentialSignals?.verifyCall) owner.meta.bodyVerifies = true;
|
|
287
|
+
if (scan.credentialSignals?.redirects) owner.meta.bodyRedirects = true;
|
|
288
|
+
}
|
|
289
|
+
|
|
261
290
|
let order = 0;
|
|
262
291
|
let ordinal = 0;
|
|
263
292
|
const created = []; // effects of THIS body — compensation pairs live here
|