sparda-mcp 0.58.0 → 0.62.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 +1 -1
- package/package.json +5 -4
- package/src/commands/stitch.js +58 -0
- package/src/index.js +37 -0
- package/src/ubg/apocalypse.js +107 -2
- package/src/ubg/llm-resolve.js +46 -0
- package/src/ubg/prisma.js +77 -30
- package/src/ubg/stitch.js +105 -0
package/README.md
CHANGED
|
@@ -47,11 +47,11 @@ Under the hood it compiles your backend into one language-agnostic graph — the
|
|
|
47
47
|
|---|---|
|
|
48
48
|
| **`prove`** | *The whole trust verdict in one gesture* — proof + coverage + a shareable seal (`--json` / `--markdown`) |
|
|
49
49
|
| **`apocalypse`** | *Prove the deploy* — no guard, invariant, transaction or aggregate boundary can be broken (SARIF + CI gate) |
|
|
50
|
+
| **`heal`** | *Self-heal, **proven*** — the gate Copilot Autofix doesn't have: a fix ships **only if** replay matches, `verify` still passes, and `apocalypse` finds no new risk / no dropped guard. Whoever wrote the fix, the machine judges it. |
|
|
50
51
|
| **`badge`** | *The shareable artifact* — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
|
|
51
52
|
| **`dossier`** | *The public report* — one self-contained HTML page: verdict, risks, and SPARDA's own blind spots |
|
|
52
53
|
| **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
|
|
53
54
|
| **`timeless`** | *Time-travel* — record a production request, replay it byte-identically, export the bug as a test |
|
|
54
|
-
| **`heal`** | *Self-heal, proven* — bug → fix → the machine proves the fix is correct and breaks nothing |
|
|
55
55
|
| **`mirror`** | *Execute the graph* — serve the compiled behavior over HTTP with no framework and no source |
|
|
56
56
|
| **`init` / `dev`** | *Runtime, optional* — expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution) |
|
|
57
57
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparda-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"mcpName": "io.github.zyx77550/sparda-mcp",
|
|
5
5
|
"description": "Compile backends to behavior graphs. Statically prove security, simulate APIs, and replay bugs.",
|
|
6
6
|
"type": "module",
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
"lint": "eslint .",
|
|
27
27
|
"lint:fix": "eslint . --fix",
|
|
28
28
|
"format": "prettier --write \"**/*.{js,cjs,mjs}\"",
|
|
29
|
-
"format:check": "prettier --check \"**/*.{js,cjs,mjs}\""
|
|
29
|
+
"format:check": "prettier --check \"**/*.{js,cjs,mjs}\"",
|
|
30
|
+
"bench:check": "node bench/check-readme.mjs",
|
|
31
|
+
"mutation": "node tests/mutation/run.mjs"
|
|
30
32
|
},
|
|
31
33
|
"files": [
|
|
32
34
|
"src",
|
|
@@ -55,8 +57,7 @@
|
|
|
55
57
|
"@babel/parser": "7.26.5",
|
|
56
58
|
"@babel/traverse": "7.26.5",
|
|
57
59
|
"@clack/prompts": "0.9.1",
|
|
58
|
-
"@modelcontextprotocol/sdk": "1.29.0"
|
|
59
|
-
"js-tokens": "^10.0.0"
|
|
60
|
+
"@modelcontextprotocol/sdk": "1.29.0"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@eslint/js": "^9.39.4",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// commands/stitch.js — cross-repo / cross-service behavior stitching. Point it at two or more app
|
|
2
|
+
// directories (microservices of the same system); it compiles each, joins the outbound HTTP calls
|
|
3
|
+
// of one to the entrypoints of another, and reports the trust boundaries + any cross-service BOLA
|
|
4
|
+
// the join reveals — a finding no mono-repo tool can produce. Quorum sensing: each service's graph
|
|
5
|
+
// is a signal; the collective behavior emerges from reading them together.
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
8
|
+
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
9
|
+
import { checkGraph } from '../ubg/apocalypse.js';
|
|
10
|
+
import { stitchServices } from '../ubg/stitch.js';
|
|
11
|
+
|
|
12
|
+
export async function runStitch(opts, dirs) {
|
|
13
|
+
if (!dirs || dirs.length < 2) {
|
|
14
|
+
console.error(
|
|
15
|
+
'Usage: sparda stitch <dir1> <dir2> [...] — two or more service/app directories',
|
|
16
|
+
);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const services = [];
|
|
21
|
+
for (const d of dirs) {
|
|
22
|
+
const abs = path.resolve(opts.cwd, d);
|
|
23
|
+
try {
|
|
24
|
+
const { graph } = compileUBG(abs, { write: false });
|
|
25
|
+
const g = canonicalizeGraph(graph);
|
|
26
|
+
const { findings } = checkGraph(g);
|
|
27
|
+
services.push({ name: path.basename(abs), graph: g, findings });
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.error(` ✗ ${d}: ${e.message.slice(0, 70)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { edges, findings } = stitchServices(services);
|
|
34
|
+
|
|
35
|
+
if (opts.json) {
|
|
36
|
+
console.log(
|
|
37
|
+
JSON.stringify({ services: services.map((s) => s.name), edges, findings }, null, 2),
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`\nSPARDA · stitch — ${services.map((s) => s.name).join(' · ')}`);
|
|
43
|
+
console.log('─'.repeat(52));
|
|
44
|
+
if (!edges.length) {
|
|
45
|
+
console.log(
|
|
46
|
+
' no cross-service calls resolved (targets may be dynamic or unrelated)',
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
console.log(` ${edges.length} cross-service call(s):`);
|
|
50
|
+
for (const e of edges)
|
|
51
|
+
console.log(` ${e.fromService} → ${e.toService} ${e.method} ${e.path}`);
|
|
52
|
+
}
|
|
53
|
+
if (findings.length) {
|
|
54
|
+
console.log(`\n ◐ ${findings.length} cross-service advisory(ies):`);
|
|
55
|
+
for (const f of findings) console.log(` ${f.message}`);
|
|
56
|
+
}
|
|
57
|
+
console.log('');
|
|
58
|
+
}
|
package/src/index.js
CHANGED
|
@@ -40,6 +40,34 @@ const opts = {
|
|
|
40
40
|
cwd: path.resolve(process.cwd(), getOpt('dir', null) ?? getOpt('cwd', null) ?? '.'),
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
// Per-command help — `sparda <cmd> --help` (or -h) prints usage and exits, instead of running
|
|
44
|
+
// the command (the old behavior: `--help` was ignored and the command just ran). Every command
|
|
45
|
+
// reads opts.cwd, so `--dir <path>` works on all of them; only per-command extras are listed.
|
|
46
|
+
const HELP = {
|
|
47
|
+
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.`,
|
|
49
|
+
ubg: `sparda ubg [--dir <path>] [--json] [--out <file>] [--openapi <spec>]\n Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json).`,
|
|
50
|
+
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
|
+
badge: `sparda badge [--dir <path>] [--out <file>] [--json]\n Emit a shareable SVG badge + README snippet (verdict · coverage · routes).`,
|
|
52
|
+
dossier: `sparda dossier [--dir <path>] [--json]\n Render the whole proof as one self-contained, shareable HTML page.`,
|
|
53
|
+
review: `sparda review [--dir <path>] --base <ref> [--json | --markdown]\n Semantic behavior diff of the working tree vs a base git ref.`,
|
|
54
|
+
init: `sparda init [--dir <path>] [--yes]\n Scan the app, generate & inject the reversible /mcp router.`,
|
|
55
|
+
dev: `sparda dev [--dir <path>] [--port <n>]\n Run the MCP stdio bridge (connect an AI client).`,
|
|
56
|
+
remove: `sparda remove [--dir <path>] [--yes]\n Remove SPARDA from this project — byte-for-byte clean git diff.`,
|
|
57
|
+
heal: `sparda heal <flightId> [--check] [--expect <json>] [--agent <cli>]\n Turn a production bug into a fix brief + a proof gate.`,
|
|
58
|
+
timeless: `sparda timeless [list | replay <id> | export <id>]\n Record / replay a production request byte-identically.`,
|
|
59
|
+
mirror: `sparda mirror [--dir <path>] [--port <n>]\n Serve the compiled graph over HTTP — no framework, no source.`,
|
|
60
|
+
};
|
|
61
|
+
if (flags.has('--help') || rest.includes('-h')) {
|
|
62
|
+
if (cmd && HELP[cmd]) console.log(HELP[cmd]);
|
|
63
|
+
else if (cmd)
|
|
64
|
+
console.log(
|
|
65
|
+
`No detailed help for \`${cmd}\`. Run \`sparda\` for the full command list.`,
|
|
66
|
+
);
|
|
67
|
+
else console.log('Run `sparda` for the command list, or `sparda <command> --help`.');
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
try {
|
|
44
72
|
switch (cmd) {
|
|
45
73
|
case 'demo': {
|
|
@@ -121,6 +149,14 @@ try {
|
|
|
121
149
|
await runBadge(opts);
|
|
122
150
|
break;
|
|
123
151
|
}
|
|
152
|
+
case 'stitch': {
|
|
153
|
+
const { runStitch } = await import('./commands/stitch.js');
|
|
154
|
+
await runStitch(
|
|
155
|
+
opts,
|
|
156
|
+
rest.filter((a) => !a.startsWith('--')),
|
|
157
|
+
);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
124
160
|
case 'apocalypse': {
|
|
125
161
|
const { runApocalypse } = await import('./commands/apocalypse.js');
|
|
126
162
|
await runApocalypse(opts);
|
|
@@ -201,6 +237,7 @@ PROVE — the point
|
|
|
201
237
|
review Semantic diff of a PR vs a base ref (--base main / --json / --markdown)
|
|
202
238
|
blindspots Map SPARDA's own blindness — every unseen route/effect/guard, ranked (--json)
|
|
203
239
|
badge Emit a shareable SVG badge + README snippet (verdict · coverage · routes)
|
|
240
|
+
stitch Cross-service proof — join N repos' graphs, find boundary-crossing BOLA
|
|
204
241
|
dossier Render the whole proof as one self-contained HTML page anyone can read
|
|
205
242
|
verify Prove the compiler's own laws (determinism, soundness, round-trip)
|
|
206
243
|
heal Turn a production bug into a fix brief + a gate (--check / --expect / --agent)
|
package/src/ubg/apocalypse.js
CHANGED
|
@@ -68,6 +68,10 @@ export function checkGraph(graph) {
|
|
|
68
68
|
const g = indexGraph(graph);
|
|
69
69
|
const findings = [];
|
|
70
70
|
let obligations = 0;
|
|
71
|
+
// Resource tables by name — the input to BolaRay ownership-model inference (O7 below).
|
|
72
|
+
const statesByTable = new Map();
|
|
73
|
+
for (const n of g.nodes.values())
|
|
74
|
+
if (n?.kind === 'state' && n.meta?.table) statesByTable.set(n.meta.table, n);
|
|
71
75
|
// Behavior polarity (ADR-036): per entrypoint, a ternary vector over the same
|
|
72
76
|
// obligations checked below — +1 protection present, 0 not applicable, -1
|
|
73
77
|
// violated. Built HERE so a -1 is literally the same condition as the finding
|
|
@@ -208,9 +212,15 @@ export function checkGraph(graph) {
|
|
|
208
212
|
);
|
|
209
213
|
vec.aggregate = touchesRoot && vec.aggregate !== -1 ? 1 : -1;
|
|
210
214
|
if (!touchesRoot) {
|
|
215
|
+
// ADVISORY (E-046): a direct member-table write is a design-smell, not a proven
|
|
216
|
+
// violation — many ORMs/apps legitimately write members directly. On a real
|
|
217
|
+
// schema-rich app it fires in bulk (dub: 174), so it points a human at the pattern,
|
|
218
|
+
// it never gates the verdict. (Surfaced once the split-schema state layer became
|
|
219
|
+
// visible; making it hard would have flooded every folder-schema app.)
|
|
211
220
|
findings.push({
|
|
212
221
|
rule: 'AGGREGATE_MEMBER_BYPASS',
|
|
213
222
|
severity: 'info',
|
|
223
|
+
advisory: true,
|
|
214
224
|
entrypoint: ep.id,
|
|
215
225
|
message: `${ep.label} mutates member table "${state.meta.table}" of aggregate "${state.meta.consistencyDomain}" without going through its root`,
|
|
216
226
|
evidence: [w.stateId],
|
|
@@ -238,12 +248,27 @@ export function checkGraph(graph) {
|
|
|
238
248
|
const tables = [
|
|
239
249
|
...new Set(idScoped.map((n) => n.meta.table).filter(Boolean)),
|
|
240
250
|
].sort();
|
|
251
|
+
// BolaRay (CCS 2024) step 1: infer the ownership MODEL each accessed table SHOULD carry
|
|
252
|
+
// (from its declared columns/FKs), so the advisory names the scope the access is MISSING
|
|
253
|
+
// instead of a vague "verify authorization". Still advisory (soundness unchanged): the
|
|
254
|
+
// schema tells us the model, never the runtime intent — that gap is why O7 never gates.
|
|
255
|
+
const ownership = tables.map((t) => ({
|
|
256
|
+
table: t,
|
|
257
|
+
...(inferOwnershipModel(t, statesByTable) ?? { model: null, key: null }),
|
|
258
|
+
}));
|
|
259
|
+
const hints = ownership
|
|
260
|
+
.filter((o) => o.model)
|
|
261
|
+
.map((o) => `${o.table} should be ${o.model} (${o.key})`);
|
|
241
262
|
findings.push({
|
|
242
263
|
rule: 'OBJECT_SCOPE_UNPROVEN',
|
|
243
264
|
severity: 'info',
|
|
244
265
|
advisory: true,
|
|
245
266
|
entrypoint: ep.id,
|
|
246
|
-
|
|
267
|
+
ownership,
|
|
268
|
+
message:
|
|
269
|
+
`${ep.label} accesses ${tables.join(', ')} by a request-supplied id with no ownership scope proven on the path` +
|
|
270
|
+
(hints.length ? ` — ${hints.join('; ')}` : '') +
|
|
271
|
+
` — verify object-level authorization (BOLA/IDOR)`,
|
|
247
272
|
evidence: idScoped.map((n) => `${n.id} (${locOf(n)})`),
|
|
248
273
|
});
|
|
249
274
|
}
|
|
@@ -251,7 +276,55 @@ export function checkGraph(graph) {
|
|
|
251
276
|
polarity.push({ entrypoint: ep.id, vector: vec });
|
|
252
277
|
}
|
|
253
278
|
|
|
254
|
-
|
|
279
|
+
// Lateral inhibition (ADR-060): a rule that fires on a large FRACTION of the routes is a
|
|
280
|
+
// codebase-wide PATTERN, not N independent findings — 97 identical lines bury the rare, sharp
|
|
281
|
+
// signals (loss of contrast). Collapse a flooding rule into ONE summary at its MAX severity.
|
|
282
|
+
// SOUND + verdict-neutral: a hard rule stays hard and still gates (we never HIDE a finding — a
|
|
283
|
+
// suppressed danger would be a false PROVEN); we only stop it flooding the report. The retina
|
|
284
|
+
// suppresses uniform light, never an edge.
|
|
285
|
+
const collapsed = collapseFloods(findings, g.entrypoints.length);
|
|
286
|
+
|
|
287
|
+
return { findings: sortFindings(collapsed), obligations, polarity };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// A rule is "pervasive" when it fires on more than this fraction of the routes AND on at least
|
|
291
|
+
// FLOOD_MIN of them — measured on the corpus: real floods sit at 18% (dub member-bypass) / 41%
|
|
292
|
+
// (directus irreversible) on 100+ routes, while the sharp per-route signals (BOLA 10%, unvalidated
|
|
293
|
+
// 11%, unguarded 1%) stay below. The absolute floor keeps a small app (a 2-route fixture where one
|
|
294
|
+
// finding is trivially "50%") from ever collapsing — a pattern needs real breadth to be a pattern.
|
|
295
|
+
const FLOOD_DENSITY = 0.15;
|
|
296
|
+
const FLOOD_MIN = 10;
|
|
297
|
+
export function collapseFloods(findings, totalRoutes) {
|
|
298
|
+
if (!totalRoutes) return findings;
|
|
299
|
+
const byRule = new Map();
|
|
300
|
+
for (const f of findings) {
|
|
301
|
+
if (!byRule.has(f.rule)) byRule.set(f.rule, []);
|
|
302
|
+
byRule.get(f.rule).push(f);
|
|
303
|
+
}
|
|
304
|
+
const out = [];
|
|
305
|
+
for (const [rule, list] of [...byRule].sort((a, b) => cmp(a[0], b[0]))) {
|
|
306
|
+
const routes = new Set(list.map((f) => f.entrypoint));
|
|
307
|
+
if (routes.size >= FLOOD_MIN && routes.size / totalRoutes > FLOOD_DENSITY) {
|
|
308
|
+
// strongest severity wins; advisory only if EVERY collapsed finding was advisory (a hard
|
|
309
|
+
// finding in the flood keeps the summary hard — it must still gate the verdict).
|
|
310
|
+
const severity = list
|
|
311
|
+
.map((f) => f.severity)
|
|
312
|
+
.sort((a, b) => SEVERITY_RANK[a] - SEVERITY_RANK[b])[0];
|
|
313
|
+
const anyHard = list.some((f) => !f.advisory);
|
|
314
|
+
out.push({
|
|
315
|
+
rule,
|
|
316
|
+
severity,
|
|
317
|
+
...(anyHard ? {} : { advisory: true }),
|
|
318
|
+
entrypoint: '(codebase-wide)',
|
|
319
|
+
pervasive: routes.size,
|
|
320
|
+
message: `${rule} is pervasive — fires on ${routes.size}/${totalRoutes} routes; review the pattern, not each route (all routes listed in evidence)`,
|
|
321
|
+
evidence: [...routes].sort(),
|
|
322
|
+
});
|
|
323
|
+
} else {
|
|
324
|
+
out.push(...list);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
255
328
|
}
|
|
256
329
|
|
|
257
330
|
// ---------------------------------------------------------------------------
|
|
@@ -365,6 +438,38 @@ export function countProvable(graph) {
|
|
|
365
438
|
return n;
|
|
366
439
|
}
|
|
367
440
|
|
|
441
|
+
// Ownership-model inference (BolaRay, CCS 2024): from a resource table's declared columns and
|
|
442
|
+
// foreign keys, which object-level authorization model SHOULD it carry? DIRECT_OWNER (a
|
|
443
|
+
// user/owner column), GROUP_SCOPED (a workspace/team/tenant column — caller must belong to the
|
|
444
|
+
// group), TRANSITIVE (ownership reached via a FK to an owned table), or null (a shared/global
|
|
445
|
+
// resource, or intent SPARDA cannot see). Columns are lowercased (prisma.js / sql.js). Pure and
|
|
446
|
+
// bounded. Used ONLY to enrich the O7 advisory — it never changes the verdict (soundness: the
|
|
447
|
+
// schema reveals the model, never the runtime authorization intent, which is the semantic gap
|
|
448
|
+
// OWASP/BolaRay both name as the reason no static tool can PROVE access control).
|
|
449
|
+
const OWNER_DIRECT = /^(user|owner|author|creator)_?id$/;
|
|
450
|
+
const OWNER_GROUP =
|
|
451
|
+
/^(workspace|project|team|tenant|org|organization|store|customer|partner|program|account|company|group)_?id$/;
|
|
452
|
+
function inferOwnershipModel(tableName, statesByTable, seen = new Set(), depth = 0) {
|
|
453
|
+
const st = statesByTable.get(tableName);
|
|
454
|
+
if (!st || seen.has(tableName) || depth > 3) return null;
|
|
455
|
+
seen.add(tableName);
|
|
456
|
+
const cols = (st.meta?.columns ?? []).map((c) => c.name);
|
|
457
|
+
const direct = cols.find((n) => OWNER_DIRECT.test(n));
|
|
458
|
+
if (direct) return { model: 'direct-owner', key: direct };
|
|
459
|
+
const group = cols.find((n) => OWNER_GROUP.test(n));
|
|
460
|
+
if (group) return { model: 'group-scoped', key: group };
|
|
461
|
+
for (const ref of st.meta?.references ?? []) {
|
|
462
|
+
if (!ref.table || ref.table === tableName) continue;
|
|
463
|
+
const sub = inferOwnershipModel(ref.table, statesByTable, seen, depth + 1);
|
|
464
|
+
if (sub)
|
|
465
|
+
return {
|
|
466
|
+
model: 'transitive',
|
|
467
|
+
key: `${(ref.fields ?? []).join(',')}->${ref.table}.${sub.key}`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
368
473
|
// Below this resolved/surface coverage ratio, a CLEAN app cannot claim PROVEN — it
|
|
369
474
|
// resolved too little of its own behavior for "no violation" to mean anything (the
|
|
370
475
|
// PROVEN-COMPLETE vs PROVEN-PARTIAL line). Measured floor: real PROVEN apps sit at 60%+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ubg/llm-resolve.js — LLM-assisted resolution of unverified guards, with the
|
|
2
|
+
// verify-before-admit guardrail the literature identifies as the critical step (Thakur et al.,
|
|
3
|
+
// "Interleaving static analysis and LLM prompting", STTT 2025; "Boosting Pointer Analysis with
|
|
4
|
+
// LLM-Enhanced Allocation Detection", arXiv 2509.22530). See docs/RESEARCH-AND-10X-IDEAS §Part 1.
|
|
5
|
+
//
|
|
6
|
+
// THE CONTRACT (soundness — non-negotiable): an LLM may only produce a *resolution hint* (where a
|
|
7
|
+
// guard's deny logic likely lives) or a *candidate classification*. It never asserts behavior.
|
|
8
|
+
// SPARDA re-verifies the hint STRUCTURALLY against the real graph before anything enters the
|
|
9
|
+
// proof. A guard becomes `verified` ONLY when a deny path is structurally proven — never on the
|
|
10
|
+
// model's word. Skipping this exact step is what produced E-022/E-025/E-026 (false PROVEN), the
|
|
11
|
+
// cardinal sin. This module makes the guardrail executable and testable; the hint *producer*
|
|
12
|
+
// (MCP sampling — the host never pays, CLAUDE.md rule 1) plugs in on top and is out of scope
|
|
13
|
+
// here on purpose, so no LLM dependency and no un-verified path can ever leak into the graph.
|
|
14
|
+
|
|
15
|
+
// The unverified guards worth an LLM resolution attempt: protection asserted by name, whose body
|
|
16
|
+
// SPARDA never saw deny (the `unverified-guard` blind-spot category, blindspots.js). These are
|
|
17
|
+
// the ambiguous DI/factory/opaque-import patterns the AST walker can't resolve structurally.
|
|
18
|
+
export function resolutionTargets(graph) {
|
|
19
|
+
if (!graph?.nodes) return [];
|
|
20
|
+
return graph.nodes.filter((n) => n.kind === 'guard' && !n.meta?.verified);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Admit an LLM resolution hint for ONE guard — and ONLY if SPARDA's own structural prover
|
|
24
|
+
// confirms a deny path at the hinted location. `proveDeny(hint)` is SPARDA's deny-detection,
|
|
25
|
+
// injected so this module stays testable and carries no LLM dependency itself. The hint is NEVER
|
|
26
|
+
// trusted on its own; it only tells the structural prover where to look.
|
|
27
|
+
//
|
|
28
|
+
// On success the guard is marked `verified: true` with `verifiedVia: 'llm-guided'` — a distinct
|
|
29
|
+
// provenance so an LLM-guided verification is always auditable and never silently indistinguish-
|
|
30
|
+
// able from a natively-proven guard (guardsVerified counting, dossier, audits can separate them).
|
|
31
|
+
// Returns { admitted, reason }; mutates guardNode.meta only on a confirmed admit.
|
|
32
|
+
export function admitResolutionHint(guardNode, hint, { proveDeny } = {}) {
|
|
33
|
+
if (!guardNode || guardNode.kind !== 'guard')
|
|
34
|
+
return { admitted: false, reason: 'not-a-guard' };
|
|
35
|
+
if (guardNode.meta?.verified) return { admitted: false, reason: 'already-verified' };
|
|
36
|
+
if (typeof proveDeny !== 'function')
|
|
37
|
+
return { admitted: false, reason: 'no-structural-prover' };
|
|
38
|
+
if (!hint) return { admitted: false, reason: 'no-hint' };
|
|
39
|
+
|
|
40
|
+
// THE GUARDRAIL. The model's hint buys nothing until SPARDA itself proves the deny.
|
|
41
|
+
const denies = proveDeny(hint) === true;
|
|
42
|
+
if (!denies) return { admitted: false, reason: 'hint-not-structurally-confirmed' };
|
|
43
|
+
|
|
44
|
+
guardNode.meta = { ...guardNode.meta, verified: true, verifiedVia: 'llm-guided' };
|
|
45
|
+
return { admitted: true, reason: 'structurally-confirmed' };
|
|
46
|
+
}
|
package/src/ubg/prisma.js
CHANGED
|
@@ -12,6 +12,48 @@ import path from 'node:path';
|
|
|
12
12
|
import { cmp } from './schema.js';
|
|
13
13
|
|
|
14
14
|
const SCHEMA_CANDIDATES = ['prisma/schema.prisma', 'schema.prisma', 'db/schema.prisma'];
|
|
15
|
+
// Prisma's `prismaSchemaFolder` layout (stable since 6.x): the schema is SPLIT across many
|
|
16
|
+
// `*.prisma` files under a directory, no single schema.prisma. Modern apps use it (dub: 36
|
|
17
|
+
// files). Without this, their entire state layer — invariants, aggregates, ownership — is
|
|
18
|
+
// invisible (E-046). These directory candidates are scanned when no single file is found.
|
|
19
|
+
const SCHEMA_DIR_CANDIDATES = ['prisma/schema', 'schema', 'db/schema'];
|
|
20
|
+
|
|
21
|
+
// Gather every `.prisma` file to parse: the single file if present, else all files in a schema
|
|
22
|
+
// folder (bounded, deterministic order). Returns [{ file, rel }] or [] if nothing found.
|
|
23
|
+
function collectSchemaFiles(cwd, fileCandidates, dirCandidates) {
|
|
24
|
+
for (const c of fileCandidates) {
|
|
25
|
+
const abs = path.resolve(cwd, c);
|
|
26
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return [abs];
|
|
27
|
+
}
|
|
28
|
+
for (const c of dirCandidates) {
|
|
29
|
+
const abs = path.resolve(cwd, c);
|
|
30
|
+
let stat;
|
|
31
|
+
try {
|
|
32
|
+
stat = fs.statSync(abs);
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!stat.isDirectory()) continue;
|
|
37
|
+
const files = [];
|
|
38
|
+
const walk = (dir, depth) => {
|
|
39
|
+
if (depth > 3) return;
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
47
|
+
const p = path.join(dir, e.name);
|
|
48
|
+
if (e.isDirectory()) walk(p, depth + 1);
|
|
49
|
+
else if (e.name.endsWith('.prisma')) files.push(p);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk(abs, 0);
|
|
53
|
+
if (files.length) return files;
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
15
57
|
|
|
16
58
|
const TYPE_MAP = {
|
|
17
59
|
string: 'string',
|
|
@@ -36,42 +78,47 @@ export function parsePrismaSchemas(cwd) {
|
|
|
36
78
|
} catch {
|
|
37
79
|
// no package.json / unparsable — the default candidates stand
|
|
38
80
|
}
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
const files = collectSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
|
|
82
|
+
if (!files.length) return { tables: [], skipped, sourceFile: null };
|
|
83
|
+
|
|
84
|
+
// Read + strip comments once per file; keep the relative path for correct per-table locs.
|
|
85
|
+
const parts = [];
|
|
86
|
+
for (const abs of files) {
|
|
87
|
+
try {
|
|
88
|
+
parts.push({
|
|
89
|
+
rel: path.relative(cwd, abs).split(path.sep).join('/'),
|
|
90
|
+
clean: fs.readFileSync(abs, 'utf8').replace(/\/\/[^\n]*/g, ''),
|
|
91
|
+
});
|
|
92
|
+
} catch (err) {
|
|
93
|
+
skipped.push({ reason: `unreadable prisma schema: ${err.message}` });
|
|
94
|
+
}
|
|
50
95
|
}
|
|
51
|
-
|
|
52
|
-
const
|
|
96
|
+
if (!parts.length) return { tables: [], skipped, sourceFile: null };
|
|
97
|
+
const sourceFile = parts.length === 1 ? parts[0].rel : path.dirname(parts[0].rel);
|
|
53
98
|
|
|
54
|
-
//
|
|
99
|
+
// Enums and model names are collected ACROSS ALL files first — a model in one file may
|
|
100
|
+
// reference an enum (or a relation target) declared in another (the whole point of the
|
|
101
|
+
// folder layout). Then models are parsed per-file so each table keeps its true file:line.
|
|
55
102
|
const enums = new Map(); // lowercase name -> [values lowercase]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
103
|
+
const modelNames = new Set();
|
|
104
|
+
for (const { clean } of parts) {
|
|
105
|
+
for (const m of clean.matchAll(/enum\s+(\w+)\s*\{([^}]*)\}/g)) {
|
|
106
|
+
const values = m[2]
|
|
107
|
+
.split(/\s+/)
|
|
108
|
+
.map((v) => v.trim())
|
|
109
|
+
.filter((v) => v && !v.startsWith('@'))
|
|
110
|
+
.map((v) => v.toLowerCase());
|
|
111
|
+
enums.set(m[1].toLowerCase(), values);
|
|
112
|
+
}
|
|
113
|
+
for (const m of clean.matchAll(/model\s+(\w+)\s*\{/g)) modelNames.add(m[1]);
|
|
63
114
|
}
|
|
64
115
|
|
|
65
|
-
const modelNames = new Set([...clean.matchAll(/model\s+(\w+)\s*\{/g)].map((m) => m[1]));
|
|
66
|
-
|
|
67
116
|
const tables = [];
|
|
68
|
-
for (const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
parseModel(modelName, body, line, sourceFile, enums, modelNames, skipped),
|
|
74
|
-
);
|
|
117
|
+
for (const { rel, clean } of parts) {
|
|
118
|
+
for (const m of clean.matchAll(/model\s+(\w+)\s*\{([^}]*)\}/g)) {
|
|
119
|
+
const line = clean.slice(0, m.index).split('\n').length;
|
|
120
|
+
tables.push(parseModel(m[1], m[2], line, rel, enums, modelNames, skipped));
|
|
121
|
+
}
|
|
75
122
|
}
|
|
76
123
|
tables.sort((a, b) => a.name.localeCompare(b.name));
|
|
77
124
|
return { tables, skipped, sourceFile };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// ubg/stitch.js — cross-repo / cross-service stitching (the moat). SAST incumbents
|
|
2
|
+
// (CodeQL/Semgrep/Snyk) are mono-repo by construction — a violation that crosses a service
|
|
3
|
+
// boundary (service A forwards a caller-supplied id to service B, which trusts it blindly) is
|
|
4
|
+
// invisible to all of them. SPARDA already produces the artifact that makes it possible: a
|
|
5
|
+
// committed `ubg.json` per repo. This pass reads N of them and joins the outbound HTTP calls of
|
|
6
|
+
// one to the entrypoints of another — the reproduction of BACTERIAL QUORUM SENSING: each service
|
|
7
|
+
// emits a signal (its graph); the collective behavior emerges from reading them together, with no
|
|
8
|
+
// central coordinator and no network access between services (each graph is committed in git).
|
|
9
|
+
//
|
|
10
|
+
// Soundness: cross-service findings are ADVISORY (same stance as BOLA/O7) — the join is a
|
|
11
|
+
// structural match, never a proof of runtime intent across the boundary. It points a human at a
|
|
12
|
+
// trust boundary to review; it never gates a verdict.
|
|
13
|
+
|
|
14
|
+
// A route path or an outbound URL target → comparable segment list. Strips scheme+host and query,
|
|
15
|
+
// lowercases, collapses every parameter form (:id, {id}, *) to a single '*' token.
|
|
16
|
+
function segmentsOf(target) {
|
|
17
|
+
let s = String(target ?? '');
|
|
18
|
+
const url = s.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+(\/.*)?$/i);
|
|
19
|
+
if (url) s = url[1] ?? '/';
|
|
20
|
+
s = s.split(/[?#]/)[0].toLowerCase();
|
|
21
|
+
return s
|
|
22
|
+
.split('/')
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.map((seg) => (/^[:{*]/.test(seg) || seg.includes('*') ? '*' : seg));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// method from an entrypoint label ("GET /users/:id") or an http_call ("httpMethod"); default GET.
|
|
28
|
+
const methodOf = (label) =>
|
|
29
|
+
(String(label).match(/^([A-Z]+)\s/)?.[1] ?? 'GET').toUpperCase();
|
|
30
|
+
const pathPart = (label) => String(label).replace(/^[A-Z]+\s+/, '');
|
|
31
|
+
|
|
32
|
+
// B's entrypoint (callee) matches A's outbound call when the methods agree and the callee's path
|
|
33
|
+
// segments are a SUFFIX of the caller's target segments — so a base-URL prefix
|
|
34
|
+
// (`http://host/api/v1/users/*` calling B's `/users/:id`) still matches. '*' matches anything.
|
|
35
|
+
function suffixMatch(callerSegs, calleeSegs) {
|
|
36
|
+
if (calleeSegs.length === 0 || calleeSegs.length > callerSegs.length) return false;
|
|
37
|
+
const off = callerSegs.length - calleeSegs.length;
|
|
38
|
+
for (let i = 0; i < calleeSegs.length; i++) {
|
|
39
|
+
const a = callerSegs[off + i];
|
|
40
|
+
const b = calleeSegs[i];
|
|
41
|
+
if (a !== b && a !== '*' && b !== '*') return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// services: [{ name, graph (canonical), findings? }]. Returns cross-service call edges and the
|
|
47
|
+
// advisory findings that ride them.
|
|
48
|
+
export function stitchServices(services) {
|
|
49
|
+
const edges = [];
|
|
50
|
+
const findings = [];
|
|
51
|
+
|
|
52
|
+
// index every service's entrypoints (the callees) by method + segments
|
|
53
|
+
const callees = [];
|
|
54
|
+
for (const svc of services) {
|
|
55
|
+
for (const n of svc.graph?.nodes ?? []) {
|
|
56
|
+
if (n.kind !== 'entrypoint') continue;
|
|
57
|
+
callees.push({
|
|
58
|
+
service: svc.name,
|
|
59
|
+
entrypoint: n.id,
|
|
60
|
+
label: n.label,
|
|
61
|
+
method: methodOf(n.label),
|
|
62
|
+
segs: segmentsOf(pathPart(n.label)),
|
|
63
|
+
bola: (svc.findings ?? []).some(
|
|
64
|
+
(f) => f.rule === 'OBJECT_SCOPE_UNPROVEN' && f.entrypoint === n.id,
|
|
65
|
+
),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const svc of services) {
|
|
71
|
+
for (const n of svc.graph?.nodes ?? []) {
|
|
72
|
+
if (n.kind !== 'effect' || n.meta?.effectType !== 'http_call') continue;
|
|
73
|
+
const callerMethod = (n.meta.httpMethod ?? 'GET').toUpperCase();
|
|
74
|
+
const callerSegs = segmentsOf(n.meta.target);
|
|
75
|
+
if (!callerSegs.length) continue;
|
|
76
|
+
for (const c of callees) {
|
|
77
|
+
if (c.service === svc.name) continue; // never stitch a service to itself
|
|
78
|
+
if (c.method !== callerMethod) continue;
|
|
79
|
+
if (!suffixMatch(callerSegs, c.segs)) continue;
|
|
80
|
+
edges.push({
|
|
81
|
+
fromService: svc.name,
|
|
82
|
+
fromEffect: n.id,
|
|
83
|
+
toService: c.service,
|
|
84
|
+
toEntrypoint: c.entrypoint,
|
|
85
|
+
method: callerMethod,
|
|
86
|
+
path: '/' + c.segs.join('/'),
|
|
87
|
+
});
|
|
88
|
+
// the trust-boundary finding: A forwards to a B endpoint B itself flagged as accessing an
|
|
89
|
+
// object by a request id with no ownership scope — so the object-level authorization
|
|
90
|
+
// across the A→B boundary is unproven. Advisory (a structural match, not runtime intent).
|
|
91
|
+
if (c.bola) {
|
|
92
|
+
findings.push({
|
|
93
|
+
rule: 'CROSS_SERVICE_OBJECT_SCOPE',
|
|
94
|
+
severity: 'info',
|
|
95
|
+
advisory: true,
|
|
96
|
+
entrypoint: c.entrypoint,
|
|
97
|
+
message: `${svc.name} calls ${c.service} ${c.method} ${'/' + c.segs.join('/')}, which accesses an object by a request id with no ownership scope proven — the object-level authorization across this service boundary is unproven (cross-service BOLA/IDOR)`,
|
|
98
|
+
evidence: [`${svc.name}:${n.id}`, `${c.service}:${c.entrypoint}`],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { edges, findings };
|
|
105
|
+
}
|