sparda-mcp 0.64.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 +42 -1
- package/src/commands/gate.js +114 -0
- package/src/index.js +11 -1
- package/src/ubg/apocalypse.js +162 -6
- package/src/ubg/extract.js +143 -0
- package/src/ubg/passes/state-minimization.js +6 -0
- 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) {
|
|
@@ -61,6 +87,21 @@ export async function runApocalypse(opts) {
|
|
|
61
87
|
);
|
|
62
88
|
}
|
|
63
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
|
+
|
|
64
105
|
if (opts.json) {
|
|
65
106
|
console.log(
|
|
66
107
|
JSON.stringify({ verdict, obligations, findings, blindspots: blind }, null, 2),
|
|
@@ -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/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/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();
|
package/src/ubg/extract.js
CHANGED
|
@@ -831,6 +831,14 @@ export function scanFunction(fnNode, env = {}) {
|
|
|
831
831
|
calls: [],
|
|
832
832
|
guardSignals: { deniesWithStatus: false },
|
|
833
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 },
|
|
834
842
|
async: Boolean(fnNode?.async),
|
|
835
843
|
};
|
|
836
844
|
if (!fnNode) return result;
|
|
@@ -926,6 +934,64 @@ function whereOwnerScoped(whereNode) {
|
|
|
926
934
|
return scoped;
|
|
927
935
|
}
|
|
928
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
|
+
|
|
929
995
|
// does this `where` target a specific object by a bare `id` key (the BOLA shape)?
|
|
930
996
|
function whereHasIdKey(whereNode) {
|
|
931
997
|
if (whereNode?.type !== 'ObjectExpression') return false;
|
|
@@ -1021,6 +1087,11 @@ function visit(node, out, ctx) {
|
|
|
1021
1087
|
return;
|
|
1022
1088
|
}
|
|
1023
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
|
+
|
|
1024
1095
|
// try/catch — try-effects are compensable by catch-effects (SBIR §2.2)
|
|
1025
1096
|
if (node.type === 'TryStatement') {
|
|
1026
1097
|
const tryLine = node.loc?.start.line ?? 0;
|
|
@@ -1123,9 +1194,42 @@ function inspectCall(node, out, ctx) {
|
|
|
1123
1194
|
if (out.returnShapes.length < MAX_RETURN_SHAPES && jsonShape !== null)
|
|
1124
1195
|
out.returnShapes.push({ line, shape: jsonShape });
|
|
1125
1196
|
if (deniedStatusOf(node)) out.guardSignals.deniesWithStatus = true;
|
|
1197
|
+
if (statusIn4xx(node)) out.credentialSignals.denies4xxOrThrows = true;
|
|
1126
1198
|
return;
|
|
1127
1199
|
}
|
|
1128
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;
|
|
1129
1233
|
|
|
1130
1234
|
// ---- local calls: bare identifier calls → call-graph edges later
|
|
1131
1235
|
if (callee.type === 'Identifier') {
|
|
@@ -1484,6 +1588,45 @@ function isDenyOptions(arg) {
|
|
|
1484
1588
|
return false;
|
|
1485
1589
|
}
|
|
1486
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
|
+
|
|
1487
1630
|
function deniedStatusOf(node) {
|
|
1488
1631
|
const isDeny = (v) => v === 401 || v === 403;
|
|
1489
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/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
|