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 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.58.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)
@@ -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
- message: `${ep.label} accesses ${tables.join(', ')} by a request-supplied id with no ownership scope proven on the path — verify object-level authorization (BOLA/IDOR)`,
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
- return { findings: sortFindings(findings), obligations, polarity };
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 file = candidates
40
- .map((c) => path.resolve(cwd, c))
41
- .find((abs) => fs.existsSync(abs));
42
- if (!file) return { tables: [], skipped, sourceFile: null };
43
-
44
- let src;
45
- try {
46
- src = fs.readFileSync(file, 'utf8');
47
- } catch (err) {
48
- skipped.push({ reason: `unreadable schema.prisma: ${err.message}` });
49
- return { tables: [], skipped, sourceFile: null };
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
- const sourceFile = path.relative(cwd, file).split(path.sep).join('/');
52
- const clean = src.replace(/\/\/[^\n]*/g, '');
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
- // enums first — model fields reference them
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
- for (const m of clean.matchAll(/enum\s+(\w+)\s*\{([^}]*)\}/g)) {
57
- const values = m[2]
58
- .split(/\s+/)
59
- .map((v) => v.trim())
60
- .filter((v) => v && !v.startsWith('@'))
61
- .map((v) => v.toLowerCase());
62
- enums.set(m[1].toLowerCase(), values);
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 m of clean.matchAll(/model\s+(\w+)\s*\{([^}]*)\}/g)) {
69
- const modelName = m[1];
70
- const body = m[2];
71
- const line = clean.slice(0, m.index).split('\n').length;
72
- tables.push(
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
+ }