sparda-mcp 0.26.0 → 0.32.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 +8 -2
- package/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/commands/apocalypse.js +15 -2
- package/src/commands/blindspots.js +54 -0
- package/src/commands/dossier.js +24 -0
- package/src/index.js +6 -0
- package/src/ubg/blindspots.js +164 -0
- package/src/ubg/express.js +258 -23
- package/src/ubg/extract.js +132 -13
- package/src/ubg/nestjs.js +11 -44
- package/src/ubg/translate.js +8 -0
- package/demo-app/.sparda/immunity.json +0 -1
package/README.md
CHANGED
|
@@ -16,6 +16,9 @@ For twenty years software communicated through APIs. Then AI agents arrived, and
|
|
|
16
16
|
|
|
17
17
|
SPARDA compiles your backend — routes, database queries, state mutations, permissions, side-effects — into one language-agnostic, mathematical graph: the **Unified Behavior Graph (UBG)**, serialized as `.sparda/ubg.json` under the **SBIR** specification ([SPARDA Behavior IR](docs/SBIR_SPEC_V1.1.md)). Compile once; then every tool is a simple pass over that graph.
|
|
18
18
|
|
|
19
|
+
> [!IMPORTANT]
|
|
20
|
+
> **The 3700-Route Proof:** In our latest stress-test (v0.26.0), SPARDA successfully compiled and proved **3700+ routes** from elite open-source monsters (Next.js *Dub*, NestJS *Immich*, *MedusaJS*, and GitHub's OpenAPI) in **< 2.2 seconds** per repo, with zero crashes. It natively resolves deep Dependency Injection, external controllers, and Next.js handlers.
|
|
21
|
+
|
|
19
22
|
**What the graph unlocks — 100% local, deterministic, zero runtime dependencies, zero API key:**
|
|
20
23
|
|
|
21
24
|
| Command | What it does |
|
|
@@ -276,8 +279,11 @@ runtime, so the guidance never goes stale.
|
|
|
276
279
|
|
|
277
280
|
## Supported frameworks
|
|
278
281
|
|
|
279
|
-
- **Next.js App Router (13/14/15)** — file-based injection.
|
|
280
|
-
- **
|
|
282
|
+
- **Next.js App Router (13/14/15)** — file-based injection. SPARDA creates a catch-all route handler. It natively resolves wrapped handlers (`export const POST = withAuth(h)`) and deep effect chains.
|
|
283
|
+
- **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Supports Prisma, TypeORM, and Kysely.
|
|
284
|
+
- **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection. Deeply resolves external controllers, Mongoose schemas, and barrel re-exports. Uses dynamic tree-scanning to find non-standard entry points (`bootstrap.ts`, etc).
|
|
285
|
+
- **MedusaJS** — Native AST ingestion of complex e-commerce routing.
|
|
286
|
+
- **Any Backend On Earth (Go, Java, Rails, Laravel)** — Compiles flawlessly from OpenAPI 3.x specs.
|
|
281
287
|
- **FastAPI** (Python >= 3.9) — AST-based router injection.
|
|
282
288
|
|
|
283
289
|
## Security posture (honest)
|
package/SKILL.md
CHANGED
|
@@ -139,6 +139,8 @@ Writes are **disabled by default**. The protocol is not optional:
|
|
|
139
139
|
manifest validity, the semantic/immune cache, host reachability, and quarantine;
|
|
140
140
|
it exits non-zero so it can gate CI.
|
|
141
141
|
- **Formal Deployment Proof** → `sparda apocalypse` reads the compiled graph (`ubg.json`) and proves five correctness obligations: catches unguarded mutations, non-atomic aggregate writes, unvalidated writes to constrained tables, uncompensated observable effects, and aggregate root bypasses. Run `sparda apocalypse --save-baseline` to store the reference graph; subsequent runs diff against the baseline to catch dropped guards, dropped SQL invariants, or grown blast radiuses.
|
|
142
|
+
- **Safety Matrix Report** → `sparda dossier` generates an ultra-premium, self-contained HTML matrix of the app's safety proof, ideal for security engineers and audit compliance (Bloc C / Shadow tier).
|
|
143
|
+
- **Deep Framework Resolution** → SPARDA natively traces multi-hop Dependency Injection in NestJS, external controllers in Express, and wrapped handlers in Next.js, mapping them into the final Behavior Graph.
|
|
142
144
|
- **OpenAPI Ingestion** → Run `sparda ubg --openapi <openapi_spec.json>` to compile any non-JS/Python backend (Go, Java, Rails, Laravel, .NET) into a Unified Behavior Graph by mapping security schemes into guards and request/response structures. (JSON specs only — convert YAML once with `npx -y js-yaml spec.yaml > spec.json`.)
|
|
143
145
|
- **Executing the Graph (No code mock)** → Run `sparda mirror` to host a mock HTTP simulation server directly from `ubg.json` without any backend code. Enforces authentication guards, returns typed responses, and acts as a contract sandbox.
|
|
144
146
|
- **Exporting OpenAPI 3.1 Spec** → Run `sparda openapi` to generate a valid, deterministic OpenAPI 3.1 spec dynamically from the compiled behavior graph.
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import { compileUBG } from '../ubg/compile.js';
|
|
12
12
|
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
13
13
|
import { checkGraph, diffGraphs, verdictOf } from '../ubg/apocalypse.js';
|
|
14
|
+
import { surveyBlindspots } from '../ubg/blindspots.js';
|
|
14
15
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
15
16
|
|
|
16
17
|
const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
|
|
@@ -43,6 +44,8 @@ export async function runApocalypse(opts) {
|
|
|
43
44
|
|
|
44
45
|
const findings = [...staticFindings, ...diffFindings];
|
|
45
46
|
const verdict = verdictOf(findings, canonical);
|
|
47
|
+
// the honesty companion: where does the proof stop? (see `sparda blindspots`)
|
|
48
|
+
const blind = surveyBlindspots(canonical, report);
|
|
46
49
|
|
|
47
50
|
if (opts.sarif) {
|
|
48
51
|
const sarifPath = path.join(opts.cwd, '.sparda', 'apocalypse.sarif');
|
|
@@ -54,7 +57,9 @@ export async function runApocalypse(opts) {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
if (opts.json) {
|
|
57
|
-
console.log(
|
|
60
|
+
console.log(
|
|
61
|
+
JSON.stringify({ verdict, obligations, findings, blindspots: blind }, null, 2),
|
|
62
|
+
);
|
|
58
63
|
} else {
|
|
59
64
|
console.log(
|
|
60
65
|
`APOCALYPSE — deployment proof over ${canonical.nodes.length} nodes, ${canonical.edges.length} edges` +
|
|
@@ -95,10 +100,18 @@ export async function runApocalypse(opts) {
|
|
|
95
100
|
`${verdict.safe ? '⚠ RISKY' : '✗ NOT PROVEN'} — ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.info} info`,
|
|
96
101
|
);
|
|
97
102
|
}
|
|
103
|
+
// Honesty companion: where the proof stops. A green verdict over a graph riddled
|
|
104
|
+
// with blind spots is not omniscience — say so, on the same screen as the verdict.
|
|
105
|
+
if (blind.surface > 0) {
|
|
106
|
+
const hi = blind.byRisk.critical + blind.byRisk.high;
|
|
107
|
+
console.log(
|
|
108
|
+
` ◐ blind spots: ${blind.surface} (${hi} high+, ${blind.byRisk.medium} medium, ${blind.byRisk.low} low) · coverage ${(blind.coverage.ratio * 100).toFixed(0)}% — run \`sparda blindspots\` for the map`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
if (!verdict.safe) process.exitCode = 1; // CI gates on this
|
|
101
|
-
return { verdict, findings, obligations };
|
|
114
|
+
return { verdict, findings, obligations, blindspots: blind };
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
// SARIF 2.1.0 — GitHub code scanning eats this directly
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// commands/blindspots.js — hand the reader the map of SPARDA's own blindness.
|
|
2
|
+
// Compiles the tree, surveys the Unknown Behavior Surface, and prints every place
|
|
3
|
+
// SPARDA could not fully see, ranked by what it could be hiding. This is the honest
|
|
4
|
+
// complement to `apocalypse`: that command tells you what's proven, this one tells
|
|
5
|
+
// you exactly where the proof stops — so a green verdict is never mistaken for
|
|
6
|
+
// omniscience. Exit code 1 if any HIGH-or-worse blind spot sits on the surface
|
|
7
|
+
// (a state-changing route or write whose behavior went unseen is not something CI
|
|
8
|
+
// should wave through silently).
|
|
9
|
+
// sparda blindspots ranked ledger, human-readable
|
|
10
|
+
// sparda blindspots --json the raw survey for tooling
|
|
11
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
12
|
+
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
13
|
+
import { surveyBlindspots } from '../ubg/blindspots.js';
|
|
14
|
+
|
|
15
|
+
const ICON = { critical: '✗', high: '✗', medium: '⚠', low: '·' };
|
|
16
|
+
|
|
17
|
+
export async function runBlindspots(opts) {
|
|
18
|
+
const { graph, report } = compileUBG(opts.cwd, { write: false, openapi: opts.openapi });
|
|
19
|
+
const canonical = canonicalizeGraph(graph);
|
|
20
|
+
const survey = surveyBlindspots(canonical, report);
|
|
21
|
+
|
|
22
|
+
if (opts.json) {
|
|
23
|
+
console.log(JSON.stringify({ survey }, null, 2));
|
|
24
|
+
} else {
|
|
25
|
+
const { surface, byRisk, coverage } = survey;
|
|
26
|
+
console.log(
|
|
27
|
+
`BLINDSPOTS — SPARDA's Unknown Behavior Surface over ${report.routes} route(s)`,
|
|
28
|
+
);
|
|
29
|
+
console.log(
|
|
30
|
+
` ${surface} blind spot(s): ${byRisk.high + byRisk.critical} high+, ${byRisk.medium} medium, ${byRisk.low} low`,
|
|
31
|
+
);
|
|
32
|
+
console.log(
|
|
33
|
+
` coverage ${(coverage.ratio * 100).toFixed(1)}% — ${coverage.resolved} behaviors resolved, ${coverage.blind} left unseen`,
|
|
34
|
+
);
|
|
35
|
+
if (surface === 0) {
|
|
36
|
+
console.log(
|
|
37
|
+
` ✓ nothing hidden — every route, effect target and guard SPARDA saw was fully resolved.`,
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
const shown = opts.verbose ? survey.spots : survey.spots.slice(0, 20);
|
|
41
|
+
for (const s of shown) {
|
|
42
|
+
console.log(
|
|
43
|
+
` ${ICON[s.risk]} [${s.risk}] ${s.kind} — ${s.label}${s.location ? ` (${s.location})` : ''}`,
|
|
44
|
+
);
|
|
45
|
+
if (opts.verbose) console.log(` ${s.why}`);
|
|
46
|
+
}
|
|
47
|
+
if (!opts.verbose && survey.spots.length > shown.length)
|
|
48
|
+
console.log(` … ${survey.spots.length - shown.length} more (run --verbose)`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (survey.byRisk.critical > 0 || survey.byRisk.high > 0) process.exitCode = 1;
|
|
53
|
+
return { survey };
|
|
54
|
+
}
|
package/src/commands/dossier.js
CHANGED
|
@@ -10,6 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
import { compileUBG } from '../ubg/compile.js';
|
|
11
11
|
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
12
12
|
import { checkGraph, verdictOf } from '../ubg/apocalypse.js';
|
|
13
|
+
import { surveyBlindspots } from '../ubg/blindspots.js';
|
|
13
14
|
import { buildCapsule } from '../ubg/immunity.js';
|
|
14
15
|
import { AXES, POLARITY_SYMBOL, exposedAxes } from '../ubg/polarity.js';
|
|
15
16
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
@@ -20,6 +21,7 @@ export async function runDossier(opts) {
|
|
|
20
21
|
const { findings, polarity } = checkGraph(canonical);
|
|
21
22
|
const verdict = verdictOf(findings, canonical);
|
|
22
23
|
const capsule = buildCapsule(canonical);
|
|
24
|
+
const blindspots = surveyBlindspots(canonical, compiled.report);
|
|
23
25
|
|
|
24
26
|
const data = {
|
|
25
27
|
app: path.basename(path.resolve(opts.cwd)) || 'app',
|
|
@@ -38,6 +40,7 @@ export async function runDossier(opts) {
|
|
|
38
40
|
polarity: polarity.map((p) => ({ entrypoint: p.entrypoint, vector: p.vector })),
|
|
39
41
|
posture: capsule.posture,
|
|
40
42
|
capsuleBytes: capsule.bytes,
|
|
43
|
+
blindspots,
|
|
41
44
|
sourceHash: (canonical.meta?.sourceHash ?? '').slice(0, 12),
|
|
42
45
|
};
|
|
43
46
|
|
|
@@ -125,6 +128,24 @@ export function renderDossierHTML(d) {
|
|
|
125
128
|
const statCard = (n, label) =>
|
|
126
129
|
`<div class="stat"><b>${esc(n)}</b><span>${esc(label)}</span></div>`;
|
|
127
130
|
|
|
131
|
+
// The honesty section: where the proof stops. No other prover shows this.
|
|
132
|
+
const b = d.blindspots ?? { spots: [], surface: 0, coverage: { ratio: 1 } };
|
|
133
|
+
const blindRows = b.spots.length
|
|
134
|
+
? b.spots
|
|
135
|
+
.slice(0, 40)
|
|
136
|
+
.map(
|
|
137
|
+
(s) => `
|
|
138
|
+
<div class="finding" style="--sev:${SEV_COLOR[s.risk] ?? '#8b8f9a'}">
|
|
139
|
+
<div class="sev">${esc(s.risk)}</div>
|
|
140
|
+
<div class="fbody">
|
|
141
|
+
<div class="frule">${esc(s.kind)}${s.location ? ` · <span class="fep">${esc(s.location)}</span>` : ''}</div>
|
|
142
|
+
<p>${esc(s.label)} — ${esc(s.why)}</p>
|
|
143
|
+
</div>
|
|
144
|
+
</div>`,
|
|
145
|
+
)
|
|
146
|
+
.join('')
|
|
147
|
+
: '<p class="clean">Nothing hidden — every route, effect target and guard SPARDA saw was fully resolved.</p>';
|
|
148
|
+
|
|
128
149
|
return `<!doctype html>
|
|
129
150
|
<html lang="en"><head><meta charset="utf-8">
|
|
130
151
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -201,6 +222,9 @@ export function renderDossierHTML(d) {
|
|
|
201
222
|
<h2>What SPARDA found</h2>
|
|
202
223
|
${findingCards}
|
|
203
224
|
|
|
225
|
+
<h2>Where the proof stops <small>SPARDA's own blind spots · coverage ${(b.coverage.ratio * 100).toFixed(0)}% · ${b.surface} unseen, ranked by what each could hide</small></h2>
|
|
226
|
+
${blindRows}
|
|
227
|
+
|
|
204
228
|
<footer>
|
|
205
229
|
<span>graph ${esc(d.sourceHash)} · ${esc(d.nodes)} nodes / ${esc(d.edges)} edges</span>
|
|
206
230
|
<span>generated by SPARDA · residual-labs.fr</span>
|
package/src/index.js
CHANGED
|
@@ -142,6 +142,11 @@ try {
|
|
|
142
142
|
await runDossier(opts);
|
|
143
143
|
break;
|
|
144
144
|
}
|
|
145
|
+
case 'blindspots': {
|
|
146
|
+
const { runBlindspots } = await import('./commands/blindspots.js');
|
|
147
|
+
await runBlindspots(opts);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
145
150
|
case 'genome': {
|
|
146
151
|
const { runGenome } = await import('./commands/genome.js');
|
|
147
152
|
await runGenome(opts);
|
|
@@ -196,6 +201,7 @@ Usage:
|
|
|
196
201
|
npx sparda-mcp immunize Freeze proven safety into a tiny capsule (1 byte/route) — .sparda/immunity.json
|
|
197
202
|
npx sparda-mcp speculate Re-verify vs the frozen capsule by lookup — full proof only on novel shapes (--json)
|
|
198
203
|
npx sparda-mcp dossier Render the whole proof as one self-contained HTML page anyone can read (.sparda/dossier.html)
|
|
204
|
+
npx sparda-mcp blindspots Map SPARDA's own blindness: every unseen route/effect/guard, ranked by what it could hide (--json)
|
|
199
205
|
npx sparda-mcp genome Sign this app's proofs into the shared world memory — self-verifying antibodies, zero infra (--json)
|
|
200
206
|
npx sparda-mcp timeless Replay production requests (list | replay <id> | export <id> → vitest)
|
|
201
207
|
npx sparda-mcp mirror Serve the compiled graph over HTTP — no framework, no source (--port)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// ubg/blindspots.js — the Unknown Behavior Surface (UBS) ledger.
|
|
2
|
+
//
|
|
3
|
+
// Every other prover tells you what it PROVED. This one also tells you, precisely
|
|
4
|
+
// and ranked by danger, what it could NOT see — SPARDA's own blindness, made into a
|
|
5
|
+
// first-class, measurable artifact. A static tool that silently drops the 20% it
|
|
6
|
+
// can't resolve reads as green and lies by omission; a tool that hands you the map
|
|
7
|
+
// of its blind spots is the honest one. (Idea seeded by Reyna's UBS, but derived
|
|
8
|
+
// from the REAL graph + skip log here — no hand-authored regions, nothing invented.)
|
|
9
|
+
//
|
|
10
|
+
// A blind spot is one of four things, each computable without guessing:
|
|
11
|
+
// opaque-target — an effect SPARDA saw but whose target it could not name
|
|
12
|
+
// (db op with no table, http/fs with a computed path)
|
|
13
|
+
// skipped-surface — a route/handler/mount the extractor could not bring into the
|
|
14
|
+
// graph at all (a dynamic path, an unresolved handler arg/mount)
|
|
15
|
+
// blind-mutation — a MUTATING entrypoint that resolved to zero behavior: SPARDA
|
|
16
|
+
// saw the door but nothing behind it (an unreadable handler)
|
|
17
|
+
// unverified-guard — a guard trusted only by NAME, never seen to deny (auth on faith)
|
|
18
|
+
//
|
|
19
|
+
// Risk is assigned from what the blind spot could be HIDING, not from its name.
|
|
20
|
+
import { indexGraph, reachOf } from './apocalypse.js';
|
|
21
|
+
import { cmp } from './schema.js';
|
|
22
|
+
|
|
23
|
+
const RISK_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
24
|
+
const MUTATING_VERB = /\b(post|put|patch|delete)\b/i;
|
|
25
|
+
|
|
26
|
+
// An effect whose target SPARDA could not resolve to a concrete (or symbolic) name.
|
|
27
|
+
// A `symbolic` table (`:collection`, param-derived) is NOT opaque — it is a precise
|
|
28
|
+
// answer expressed as a rule, so it is deliberately excluded.
|
|
29
|
+
function isOpaqueTarget(node) {
|
|
30
|
+
if (node.kind !== 'effect') return false;
|
|
31
|
+
const m = node.meta ?? {};
|
|
32
|
+
if (m.symbolic) return false;
|
|
33
|
+
if (m.effectType === 'db_read' || m.effectType === 'db_write')
|
|
34
|
+
return m.table == null || m.table === '?';
|
|
35
|
+
if (
|
|
36
|
+
m.effectType === 'http_call' ||
|
|
37
|
+
m.effectType === 'fs_write' ||
|
|
38
|
+
m.effectType === 'fs_read'
|
|
39
|
+
)
|
|
40
|
+
return m.target == null || m.target === 'dynamic' || m.target === 'unknown';
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function opaqueRisk(effectType) {
|
|
45
|
+
if (
|
|
46
|
+
effectType === 'db_write' ||
|
|
47
|
+
effectType === 'fs_write' ||
|
|
48
|
+
effectType === 'http_call'
|
|
49
|
+
)
|
|
50
|
+
return 'high';
|
|
51
|
+
return 'medium'; // reads: SPARDA can't name what's read, but nothing changes state
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// → { surface, byRisk, coverage, spots } — deterministic (sorted), report-optional.
|
|
55
|
+
export function surveyBlindspots(graph, report = {}) {
|
|
56
|
+
const g = indexGraph(graph);
|
|
57
|
+
const spots = [];
|
|
58
|
+
|
|
59
|
+
// 1 — opaque-target effects (SPARDA saw the effect, not its target)
|
|
60
|
+
for (const node of graph.nodes) {
|
|
61
|
+
if (!isOpaqueTarget(node)) continue;
|
|
62
|
+
spots.push({
|
|
63
|
+
kind: 'opaque-target',
|
|
64
|
+
risk: opaqueRisk(node.meta.effectType),
|
|
65
|
+
location: node.loc ? `${node.loc.file}:${node.loc.line}` : null,
|
|
66
|
+
label: node.label,
|
|
67
|
+
why: `${node.meta.effectType} with an unresolved target — SPARDA saw the call but not what it touches`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2 — blind mutations: a mutating entrypoint that reached zero observable behavior.
|
|
72
|
+
// Its handler body was unreadable (opaque fn) or delegated somewhere static analysis
|
|
73
|
+
// couldn't follow — the single most dangerous blind spot, because an unguarded write
|
|
74
|
+
// could be hiding right there and the proof would never see it.
|
|
75
|
+
for (const ep of g.entrypoints) {
|
|
76
|
+
if (!ep.meta?.mutating) continue;
|
|
77
|
+
const reached = [...reachOf(ep.id, g.cfOut)].map((id) => g.nodes.get(id));
|
|
78
|
+
const sawBehavior = reached.some((n) => n?.kind === 'effect' || n?.kind === 'state');
|
|
79
|
+
if (sawBehavior) continue;
|
|
80
|
+
// Only a blind spot if a handler body was actually UNREADABLE (opaque) — a
|
|
81
|
+
// mutating route that resolved to a genuinely empty body (SPARDA read it, there
|
|
82
|
+
// was nothing) is not blindness, it's a no-op, and must not be flagged.
|
|
83
|
+
const hasOpaqueBody = reached.some((n) => n?.meta?.opaque);
|
|
84
|
+
if (!hasOpaqueBody) continue;
|
|
85
|
+
spots.push({
|
|
86
|
+
kind: 'blind-mutation',
|
|
87
|
+
risk: 'high',
|
|
88
|
+
entrypoint: ep.id,
|
|
89
|
+
location: ep.loc ? `${ep.loc.file}:${ep.loc.line}` : null,
|
|
90
|
+
label: ep.label,
|
|
91
|
+
why: 'a state-changing route whose behavior did not resolve — an unguarded write could hide here unseen',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3 — unverified guards: protection asserted by name, never seen to deny.
|
|
96
|
+
for (const node of graph.nodes) {
|
|
97
|
+
if (node.kind !== 'guard' || node.meta?.verified) continue;
|
|
98
|
+
spots.push({
|
|
99
|
+
kind: 'unverified-guard',
|
|
100
|
+
risk: 'low',
|
|
101
|
+
location: node.loc ? `${node.loc.file}:${node.loc.line}` : null,
|
|
102
|
+
label: node.label,
|
|
103
|
+
why: 'trusted as a guard by its name, but SPARDA never saw it deny (opaque body) — auth resting on faith',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 4 — skipped surface: routes/handlers/mounts the extractor could not graph at all.
|
|
108
|
+
for (const s of report.skipped ?? []) {
|
|
109
|
+
const reason = s.reason ?? '';
|
|
110
|
+
spots.push({
|
|
111
|
+
kind: 'skipped-surface',
|
|
112
|
+
risk: MUTATING_VERB.test(reason) ? 'high' : 'medium',
|
|
113
|
+
location: s.file ? `${s.file}${s.line ? `:${s.line}` : ''}` : null,
|
|
114
|
+
label: reason,
|
|
115
|
+
why: 'a surface the static walk could not bring into the graph — its behavior is entirely unseen',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
spots.sort(
|
|
120
|
+
(a, b) =>
|
|
121
|
+
RISK_RANK[a.risk] - RISK_RANK[b.risk] ||
|
|
122
|
+
cmp(a.kind, b.kind) ||
|
|
123
|
+
cmp(a.location ?? '', b.location ?? '') ||
|
|
124
|
+
cmp(a.label ?? '', b.label ?? ''),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const byRisk = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
128
|
+
for (const s of spots) byRisk[s.risk]++;
|
|
129
|
+
|
|
130
|
+
// coverage: of the state-touching behavior SPARDA found SIGNAL for (resolved effects
|
|
131
|
+
// + blind spots that are effect/route-shaped), what fraction did it fully resolve?
|
|
132
|
+
// The honest CBS/UBS ratio — 1.0 means nothing seen was left unresolved.
|
|
133
|
+
const resolved = countResolved(graph);
|
|
134
|
+
const blindBehavior = spots.filter(
|
|
135
|
+
(s) =>
|
|
136
|
+
s.kind === 'opaque-target' ||
|
|
137
|
+
s.kind === 'blind-mutation' ||
|
|
138
|
+
s.kind === 'skipped-surface',
|
|
139
|
+
).length;
|
|
140
|
+
const denom = resolved + blindBehavior;
|
|
141
|
+
const coverage = {
|
|
142
|
+
resolved,
|
|
143
|
+
blind: blindBehavior,
|
|
144
|
+
ratio: denom === 0 ? 1 : Math.round((resolved / denom) * 1000) / 1000,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return { surface: spots.length, byRisk, coverage, spots };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// resolved behavior = state nodes + effects whose target IS known (concrete or symbolic).
|
|
151
|
+
const OBSERVABLE = new Set(['db_write', 'db_read', 'http_call', 'fs_write', 'fs_read']);
|
|
152
|
+
function countResolved(graph) {
|
|
153
|
+
let n = 0;
|
|
154
|
+
for (const node of graph.nodes) {
|
|
155
|
+
if (node.kind === 'state') n++;
|
|
156
|
+
else if (
|
|
157
|
+
node.kind === 'effect' &&
|
|
158
|
+
OBSERVABLE.has(node.meta?.effectType) &&
|
|
159
|
+
!isOpaqueTarget(node)
|
|
160
|
+
)
|
|
161
|
+
n++;
|
|
162
|
+
}
|
|
163
|
+
return n;
|
|
164
|
+
}
|
package/src/ubg/express.js
CHANGED
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
// Depth stays bounded like the v0 parser: entry file + mounted routers.
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
parseModule,
|
|
11
|
+
resolveRelImport,
|
|
12
|
+
scanFunction,
|
|
13
|
+
classInModule,
|
|
14
|
+
baseClassOf,
|
|
15
|
+
methodInClassChain,
|
|
16
|
+
} from './extract.js';
|
|
10
17
|
|
|
11
18
|
const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
|
12
19
|
const MAX_HANDLER_DEPTH = 6;
|
|
@@ -21,14 +28,14 @@ function mergeScan(into, add) {
|
|
|
21
28
|
if (add.guardSignals?.deniesWithStatus) into.guardSignals.deniesWithStatus = true;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
// depth-first walk over an AST subtree, invoking fn on every
|
|
25
|
-
function
|
|
31
|
+
// depth-first walk over an AST subtree, invoking fn on every node.
|
|
32
|
+
function walkAst(node, fn) {
|
|
26
33
|
if (!node || typeof node !== 'object') return;
|
|
27
34
|
if (Array.isArray(node)) {
|
|
28
|
-
for (const n of node)
|
|
35
|
+
for (const n of node) walkAst(n, fn);
|
|
29
36
|
return;
|
|
30
37
|
}
|
|
31
|
-
if (node.type === '
|
|
38
|
+
if (typeof node.type === 'string') fn(node);
|
|
32
39
|
for (const k of Object.keys(node)) {
|
|
33
40
|
if (
|
|
34
41
|
k === 'loc' ||
|
|
@@ -38,10 +45,39 @@ function walkCalls(node, fn) {
|
|
|
38
45
|
)
|
|
39
46
|
continue;
|
|
40
47
|
const v = node[k];
|
|
41
|
-
if (v && typeof v === 'object')
|
|
48
|
+
if (v && typeof v === 'object') walkAst(v, fn);
|
|
42
49
|
}
|
|
43
50
|
}
|
|
44
51
|
|
|
52
|
+
function walkCalls(node, fn) {
|
|
53
|
+
walkAst(node, (n) => {
|
|
54
|
+
if (n.type === 'CallExpression') fn(n);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// varName → class name for every `const svc = new X(…)` (or `svc = new X(…)`)
|
|
59
|
+
// in the subtree — the raw material of instantiated-service resolution.
|
|
60
|
+
function collectInstances(fnNode) {
|
|
61
|
+
const map = new Map();
|
|
62
|
+
walkAst(fnNode, (node) => {
|
|
63
|
+
if (
|
|
64
|
+
node.type === 'VariableDeclarator' &&
|
|
65
|
+
node.id?.type === 'Identifier' &&
|
|
66
|
+
node.init?.type === 'NewExpression' &&
|
|
67
|
+
node.init.callee.type === 'Identifier'
|
|
68
|
+
)
|
|
69
|
+
map.set(node.id.name, node.init.callee.name);
|
|
70
|
+
else if (
|
|
71
|
+
node.type === 'AssignmentExpression' &&
|
|
72
|
+
node.left.type === 'Identifier' &&
|
|
73
|
+
node.right?.type === 'NewExpression' &&
|
|
74
|
+
node.right.callee.type === 'Identifier'
|
|
75
|
+
)
|
|
76
|
+
map.set(node.left.name, node.right.callee.name);
|
|
77
|
+
});
|
|
78
|
+
return map;
|
|
79
|
+
}
|
|
80
|
+
|
|
45
81
|
// → { routes, globalMiddlewares, helpers, skipped, scannedFiles }
|
|
46
82
|
export function extractExpress(cwd, entryFile) {
|
|
47
83
|
const routes = [];
|
|
@@ -51,6 +87,7 @@ export function extractExpress(cwd, entryFile) {
|
|
|
51
87
|
const scannedFiles = [];
|
|
52
88
|
const visited = new Set();
|
|
53
89
|
const mounts = [];
|
|
90
|
+
const classBundles = new Map(); // memo of resolved class methods — per extract
|
|
54
91
|
|
|
55
92
|
scanFile(path.resolve(cwd, entryFile), '', 0);
|
|
56
93
|
// growing queue, NOT a snapshot: a mounted router file can itself mount
|
|
@@ -87,12 +124,22 @@ export function extractExpress(cwd, entryFile) {
|
|
|
87
124
|
helpers.push({ name, sourceFile: relFile, sourceLine: f.line, fn: f.node });
|
|
88
125
|
}
|
|
89
126
|
|
|
127
|
+
// Flatten setup-function bodies into the statement stream. The overwhelmingly
|
|
128
|
+
// common production pattern builds the whole app inside a function —
|
|
129
|
+
// `export default async function createApp() { const app = express(); …;
|
|
130
|
+
// app.use('/x', xRouter); return app; }` (directus, and most real apps) — so the
|
|
131
|
+
// `express()` var and every mount live one level down, invisible to a top-level-only
|
|
132
|
+
// walk. We descend into function declarations, default-exported functions, top-level
|
|
133
|
+
// `const f = () => {…}`, and their control-flow blocks — but NOT into function
|
|
134
|
+
// *arguments* (route handlers), so handler bodies are never mistaken for setup.
|
|
135
|
+
const statements = flattenSetup(mod.ast.program.body);
|
|
136
|
+
|
|
90
137
|
const appVars = new Set();
|
|
91
138
|
const routerVars = new Set();
|
|
92
|
-
for (const node of
|
|
93
|
-
const routeArrays = collectRouteArrays(
|
|
139
|
+
for (const node of statements) collectAppVars(node, appVars, routerVars);
|
|
140
|
+
const routeArrays = collectRouteArrays(statements);
|
|
94
141
|
|
|
95
|
-
walkStatements(
|
|
142
|
+
walkStatements(statements);
|
|
96
143
|
|
|
97
144
|
function walkStatements(body) {
|
|
98
145
|
for (const stmt of body) {
|
|
@@ -243,13 +290,29 @@ export function extractExpress(cwd, entryFile) {
|
|
|
243
290
|
fn: arg,
|
|
244
291
|
};
|
|
245
292
|
}
|
|
246
|
-
// factory middleware: validate(schema), rateLimit({…}) — the factory
|
|
247
|
-
// name is known, the produced closure is out of static reach (blind node)
|
|
248
293
|
if (arg.type === 'CallExpression') {
|
|
249
294
|
const name =
|
|
250
295
|
arg.callee.type === 'Identifier'
|
|
251
296
|
? arg.callee.name
|
|
252
297
|
: (arg.callee.property?.name ?? 'anonymous');
|
|
298
|
+
// wrapped INLINE handler: asyncHandler(async (req, res) => {…}) — the
|
|
299
|
+
// wrapped function IS the behavior (the route-position analogue of the
|
|
300
|
+
// top-level `const h = catchAsync(…)` idiom). Without this, every
|
|
301
|
+
// directus-style route reads as a blind node.
|
|
302
|
+
const fnArg = arg.arguments.find(
|
|
303
|
+
(a) => a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression',
|
|
304
|
+
);
|
|
305
|
+
if (fnArg) {
|
|
306
|
+
return {
|
|
307
|
+
name,
|
|
308
|
+
sourceFile: relFile,
|
|
309
|
+
sourceLine: fnArg.loc?.start.line ?? 0,
|
|
310
|
+
fn: fnArg,
|
|
311
|
+
scan: deepScan(fnArg, mod),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// factory middleware: validate(schema), rateLimit({…}) — the factory
|
|
315
|
+
// name is known, the produced closure is out of static reach (blind node)
|
|
253
316
|
return {
|
|
254
317
|
name,
|
|
255
318
|
sourceFile: relFile,
|
|
@@ -348,6 +411,12 @@ export function extractExpress(cwd, entryFile) {
|
|
|
348
411
|
// `User.create()`. This is the CommonJS analogue of the Nest DI hop — the effect is
|
|
349
412
|
// usually two or three modules below the route, behind `service.method()` calls the
|
|
350
413
|
// flat scanner can't see. Precomputed so translate uses it as-is (like the Nest scan).
|
|
414
|
+
//
|
|
415
|
+
// The same walk also resolves INSTANTIATED services — `const svc = new XService(…);
|
|
416
|
+
// svc.readMany(…)` (directus and every class-service Express app) — through the
|
|
417
|
+
// class's `extends` chain, including `this.<m>()` / `super.<m>()` hops inside the
|
|
418
|
+
// resolved methods. `this.<m>()` dispatches from the INSTANTIATED class, so an
|
|
419
|
+
// override (ActivityService.readByQuery) wins over the base method it shadows.
|
|
351
420
|
function deepScan(fnNode, owningMod) {
|
|
352
421
|
const base = scanFunction(fnNode);
|
|
353
422
|
const merged = {
|
|
@@ -356,43 +425,209 @@ export function extractExpress(cwd, entryFile) {
|
|
|
356
425
|
returnShapes: [...(base.returnShapes ?? [])],
|
|
357
426
|
calls: [...(base.calls ?? [])],
|
|
358
427
|
};
|
|
359
|
-
|
|
428
|
+
followCalls(fnNode, owningMod, merged, new Set(), 0, null, new Set());
|
|
360
429
|
return merged;
|
|
361
430
|
}
|
|
362
431
|
|
|
363
|
-
|
|
432
|
+
// `seen` dedups sub-scans merged into THIS target; `stack` is the class-method
|
|
433
|
+
// cycle guard and spans the whole recursion; `clsCtx` (top + declaring class) is
|
|
434
|
+
// set while walking a class-method body so `this.` / `super.` calls resolve.
|
|
435
|
+
function followCalls(fnNode, mod, merged, seen, depth, clsCtx, stack) {
|
|
364
436
|
if (depth >= MAX_HANDLER_DEPTH) return;
|
|
437
|
+
const instances = collectInstances(fnNode);
|
|
365
438
|
walkCalls(fnNode, (node) => {
|
|
366
439
|
const callee = node.callee;
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
440
|
+
if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier')
|
|
441
|
+
return;
|
|
442
|
+
const method = callee.property.name;
|
|
443
|
+
const obj = callee.object;
|
|
444
|
+
|
|
445
|
+
// instantiated service: `svc.method()` where `const svc = new X(…)`,
|
|
446
|
+
// or the direct `new X(…).method()` chain
|
|
447
|
+
const className =
|
|
448
|
+
obj.type === 'Identifier' && instances.has(obj.name)
|
|
449
|
+
? instances.get(obj.name)
|
|
450
|
+
: obj.type === 'NewExpression' && obj.callee.type === 'Identifier'
|
|
451
|
+
? obj.callee.name
|
|
452
|
+
: null;
|
|
453
|
+
if (className) {
|
|
454
|
+
const bundle = classBundle(className, method, mod, depth, stack);
|
|
455
|
+
if (bundle && !seen.has(bundle.key)) {
|
|
456
|
+
seen.add(bundle.key);
|
|
457
|
+
mergeScan(merged, bundle);
|
|
458
|
+
}
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// inside a resolved class method: this.<m>() re-dispatches from the top
|
|
463
|
+
// (instantiated) class; super.<m>() from the declaring class's base
|
|
464
|
+
if (clsCtx && obj.type === 'ThisExpression') {
|
|
465
|
+
const bundle = classMethodBundle(
|
|
466
|
+
clsCtx.topCls,
|
|
467
|
+
clsCtx.topMod,
|
|
468
|
+
method,
|
|
469
|
+
depth,
|
|
470
|
+
stack,
|
|
471
|
+
);
|
|
472
|
+
if (bundle && !seen.has(bundle.key)) {
|
|
473
|
+
seen.add(bundle.key);
|
|
474
|
+
mergeScan(merged, bundle);
|
|
475
|
+
}
|
|
372
476
|
return;
|
|
373
|
-
|
|
477
|
+
}
|
|
478
|
+
if (clsCtx && obj.type === 'Super') {
|
|
479
|
+
const base = baseClassOf(clsCtx.declCls, clsCtx.declMod);
|
|
480
|
+
const bundle = base
|
|
481
|
+
? classMethodBundle(base.cls, base.mod, method, depth, stack)
|
|
482
|
+
: null;
|
|
483
|
+
if (bundle && !seen.has(bundle.key)) {
|
|
484
|
+
seen.add(bundle.key);
|
|
485
|
+
mergeScan(merged, bundle);
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (obj.type !== 'Identifier') return;
|
|
491
|
+
const targetFile = mod.imports.get(obj.name);
|
|
374
492
|
if (!targetFile) return;
|
|
375
493
|
const targetMod = parseModule(targetFile);
|
|
376
494
|
if (targetMod.error) return;
|
|
377
|
-
const fn = targetMod.functions.get(
|
|
495
|
+
const fn = targetMod.functions.get(method);
|
|
378
496
|
if (!fn) return;
|
|
379
|
-
const key = `${targetFile}#${
|
|
497
|
+
const key = `${targetFile}#${method}`;
|
|
380
498
|
if (seen.has(key)) return;
|
|
381
499
|
seen.add(key);
|
|
382
500
|
const fromRel = rel(targetFile);
|
|
383
501
|
if (!scannedFiles.includes(fromRel)) scannedFiles.push(fromRel);
|
|
384
502
|
helpers.push({
|
|
385
|
-
name: `${
|
|
503
|
+
name: `${obj.name}.${method}`,
|
|
386
504
|
sourceFile: fromRel,
|
|
387
505
|
sourceLine: fn.line,
|
|
388
506
|
fn: fn.node,
|
|
389
507
|
});
|
|
390
508
|
mergeScan(merged, scanFunction(fn.node));
|
|
391
|
-
|
|
509
|
+
followCalls(fn.node, targetMod, merged, seen, depth + 1, null, stack);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// `new X(…)` → class X (imported, or declared in the same module) → the
|
|
514
|
+
// memoized bundle of X.<methodName>.
|
|
515
|
+
function classBundle(className, methodName, mod, depth, stack) {
|
|
516
|
+
const file = mod.imports.get(className);
|
|
517
|
+
const clsMod = file ? parseModule(file) : mod;
|
|
518
|
+
if (clsMod.error) return null;
|
|
519
|
+
const cls = classInModule(clsMod, className);
|
|
520
|
+
if (!cls) return null;
|
|
521
|
+
return classMethodBundle(cls, clsMod, methodName, depth, stack);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// The fully-resolved scan of `<topCls>.<methodName>` — its body plus everything
|
|
525
|
+
// reachable below it — memoized per (instantiated class, method) across the whole
|
|
526
|
+
// extract, so a service method reached by N routes is resolved once (same
|
|
527
|
+
// rationale as the Nest bundleCache; twenty took 34s without it). A bundle is
|
|
528
|
+
// computed with its OWN dedup set, so the memo never stores a partial.
|
|
529
|
+
function classMethodBundle(topCls, topMod, methodName, depth, stack) {
|
|
530
|
+
if (depth >= MAX_HANDLER_DEPTH) return null;
|
|
531
|
+
const hit = methodInClassChain(topCls, topMod, methodName);
|
|
532
|
+
if (!hit) return null;
|
|
533
|
+
const key = `${topMod._file ?? ''}#${topCls.id?.name ?? 'anonymous'}.${methodName}`;
|
|
534
|
+
const cached = classBundles.get(key);
|
|
535
|
+
if (cached) return cached;
|
|
536
|
+
if (stack.has(key)) return null; // reference cycle — contribute nothing, don't cache
|
|
537
|
+
stack.add(key);
|
|
538
|
+
const base = scanFunction(hit.fn);
|
|
539
|
+
const bundle = {
|
|
540
|
+
...base,
|
|
541
|
+
key,
|
|
542
|
+
effects: [...base.effects],
|
|
543
|
+
returnShapes: [...(base.returnShapes ?? [])],
|
|
544
|
+
calls: [...(base.calls ?? [])],
|
|
545
|
+
};
|
|
546
|
+
const declRel = rel(hit.mod._file ?? '');
|
|
547
|
+
if (!scannedFiles.includes(declRel)) scannedFiles.push(declRel);
|
|
548
|
+
helpers.push({
|
|
549
|
+
name: `${topCls.id?.name ?? 'anonymous'}.${methodName}`,
|
|
550
|
+
sourceFile: declRel,
|
|
551
|
+
sourceLine: hit.fn.loc?.start.line ?? 0,
|
|
552
|
+
fn: hit.fn,
|
|
392
553
|
});
|
|
554
|
+
followCalls(
|
|
555
|
+
hit.fn,
|
|
556
|
+
hit.mod,
|
|
557
|
+
bundle,
|
|
558
|
+
new Set(),
|
|
559
|
+
depth + 1,
|
|
560
|
+
{ topCls, topMod, declCls: hit.cls, declMod: hit.mod },
|
|
561
|
+
stack,
|
|
562
|
+
);
|
|
563
|
+
stack.delete(key);
|
|
564
|
+
classBundles.set(key, bundle);
|
|
565
|
+
return bundle;
|
|
393
566
|
}
|
|
394
567
|
}
|
|
395
568
|
|
|
569
|
+
// Statements the route walk should see = the module top level PLUS the bodies of
|
|
570
|
+
// setup functions and the control-flow blocks inside them, in source order. Bounded
|
|
571
|
+
// (depth + count) and cycle-free by construction. It descends into function *bodies*
|
|
572
|
+
// (declarations, default exports, `const f = () => {}`) and if/for/try/while/block —
|
|
573
|
+
// never into a function passed as a call ARGUMENT, so route handlers stay opaque.
|
|
574
|
+
function flattenSetup(programBody) {
|
|
575
|
+
const out = [];
|
|
576
|
+
const blockOf = (n) => (!n ? [] : n.type === 'BlockStatement' ? n.body : [n]);
|
|
577
|
+
const push = (stmts, depth) => {
|
|
578
|
+
if (!Array.isArray(stmts) || depth > 6 || out.length > 8000) return;
|
|
579
|
+
for (const s of stmts) {
|
|
580
|
+
if (!s || typeof s !== 'object') continue;
|
|
581
|
+
out.push(s);
|
|
582
|
+
descend(s, depth);
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
const descend = (s, depth) => {
|
|
586
|
+
switch (s.type) {
|
|
587
|
+
case 'FunctionDeclaration':
|
|
588
|
+
push(s.body?.body, depth + 1);
|
|
589
|
+
break;
|
|
590
|
+
case 'ExportDefaultDeclaration':
|
|
591
|
+
case 'ExportNamedDeclaration':
|
|
592
|
+
if (s.declaration?.body?.body) push(s.declaration.body.body, depth + 1);
|
|
593
|
+
else if (s.declaration) push([s.declaration], depth);
|
|
594
|
+
break;
|
|
595
|
+
case 'VariableDeclaration':
|
|
596
|
+
for (const d of s.declarations) {
|
|
597
|
+
const init = d.init;
|
|
598
|
+
if (
|
|
599
|
+
(init?.type === 'ArrowFunctionExpression' ||
|
|
600
|
+
init?.type === 'FunctionExpression') &&
|
|
601
|
+
init.body?.body
|
|
602
|
+
)
|
|
603
|
+
push(init.body.body, depth + 1);
|
|
604
|
+
}
|
|
605
|
+
break;
|
|
606
|
+
case 'IfStatement':
|
|
607
|
+
push(blockOf(s.consequent), depth);
|
|
608
|
+
push(blockOf(s.alternate), depth);
|
|
609
|
+
break;
|
|
610
|
+
case 'TryStatement':
|
|
611
|
+
push(s.block?.body, depth);
|
|
612
|
+
if (s.handler?.body?.body) push(s.handler.body.body, depth);
|
|
613
|
+
push(s.finalizer?.body, depth);
|
|
614
|
+
break;
|
|
615
|
+
case 'ForStatement':
|
|
616
|
+
case 'ForInStatement':
|
|
617
|
+
case 'ForOfStatement':
|
|
618
|
+
case 'WhileStatement':
|
|
619
|
+
case 'DoWhileStatement':
|
|
620
|
+
push(blockOf(s.body), depth);
|
|
621
|
+
break;
|
|
622
|
+
case 'BlockStatement':
|
|
623
|
+
push(s.body, depth);
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
push(programBody, 0);
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
630
|
+
|
|
396
631
|
// const defaultRoutes = [{ path: '/auth', route: authRoute }, …] — collected
|
|
397
632
|
// per file so a later .forEach mount loop can be unrolled statically
|
|
398
633
|
function collectRouteArrays(body) {
|
package/src/ubg/extract.js
CHANGED
|
@@ -404,6 +404,61 @@ function resolveAliasedImport(fromFile, spec) {
|
|
|
404
404
|
return null;
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Class resolution — shared by the Nest DI follower (this.<dep>.<m>()) and the
|
|
409
|
+
// Express instantiated-service follower (new Service().<m>()). A method lookup
|
|
410
|
+
// climbs the `extends` chain because real services inherit their behavior
|
|
411
|
+
// (directus: ActivityService extends ItemsService, where the DB calls live).
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
const MAX_CLASS_DEPTH = 6;
|
|
415
|
+
|
|
416
|
+
// find a class declaration named `typeName` at a module's top level (plain,
|
|
417
|
+
// export-named, or export-default with a matching name).
|
|
418
|
+
export function classInModule(mod, typeName) {
|
|
419
|
+
for (const node of mod.ast?.program.body ?? []) {
|
|
420
|
+
const cls =
|
|
421
|
+
node.type === 'ClassDeclaration'
|
|
422
|
+
? node
|
|
423
|
+
: (node.type === 'ExportNamedDeclaration' ||
|
|
424
|
+
node.type === 'ExportDefaultDeclaration') &&
|
|
425
|
+
node.declaration?.type === 'ClassDeclaration'
|
|
426
|
+
? node.declaration
|
|
427
|
+
: null;
|
|
428
|
+
if (cls && cls.id?.name === typeName) return cls;
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// resolve `class X extends Base` → the Base class node + its module, via the import.
|
|
434
|
+
export function baseClassOf(cls, mod) {
|
|
435
|
+
if (cls.superClass?.type !== 'Identifier') return null;
|
|
436
|
+
const file = mod.imports.get(cls.superClass.name);
|
|
437
|
+
if (!file || !fs.existsSync(file)) return null;
|
|
438
|
+
const baseMod = parseModule(file);
|
|
439
|
+
if (baseMod.error) return null;
|
|
440
|
+
const baseCls = classInModule(baseMod, cls.superClass.name);
|
|
441
|
+
return baseCls ? { cls: baseCls, mod: baseMod } : null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// locate `methodName` on the class or up its `extends` chain →
|
|
445
|
+
// { fn, mod, cls } where `cls`/`mod` are the DECLARING class and its module
|
|
446
|
+
// (base-class methods live in the base module — the caller needs both for
|
|
447
|
+
// source locations and for resolving `super.<m>()` from the right link).
|
|
448
|
+
export function methodInClassChain(cls, mod, methodName, depth = 0) {
|
|
449
|
+
for (const m of cls.body.body) {
|
|
450
|
+
if (
|
|
451
|
+
m.type === 'ClassMethod' &&
|
|
452
|
+
m.key.type === 'Identifier' &&
|
|
453
|
+
m.key.name === methodName
|
|
454
|
+
)
|
|
455
|
+
return { fn: m, mod, cls };
|
|
456
|
+
}
|
|
457
|
+
if (depth >= MAX_CLASS_DEPTH) return null;
|
|
458
|
+
const base = baseClassOf(cls, mod);
|
|
459
|
+
return base ? methodInClassChain(base.cls, base.mod, methodName, depth + 1) : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
407
462
|
// ---------------------------------------------------------------------------
|
|
408
463
|
// scanFunction — what does this function DO?
|
|
409
464
|
// Plain recursive walk (no scope analysis: deterministic, dependency-free),
|
|
@@ -429,10 +484,61 @@ export function scanFunction(fnNode) {
|
|
|
429
484
|
isolation: 'default',
|
|
430
485
|
tryId: null,
|
|
431
486
|
catchOf: null,
|
|
487
|
+
reqDerived: collectReqDerived(fnNode),
|
|
432
488
|
});
|
|
433
489
|
return result;
|
|
434
490
|
}
|
|
435
491
|
|
|
492
|
+
// The request objects a handler names its input through. A table/target sourced from
|
|
493
|
+
// one of these is not a literal, but it is not unknown either: it is precisely "the
|
|
494
|
+
// value the caller supplies here" — a SYMBOLIC target, expressed as a rule.
|
|
495
|
+
const REQ_ROOTS = new Set(['req', 'request', 'ctx', 'context', 'event', 'request_']);
|
|
496
|
+
|
|
497
|
+
// `req.params.collection` / `req.query.table` / `req.body.type` / `req.collection`
|
|
498
|
+
// → the leaf name, prefixed ':' to mark it symbolic. Null if not request-derived.
|
|
499
|
+
function reqParamName(node, reqDerived) {
|
|
500
|
+
if (node?.type === 'Identifier') return reqDerived?.get(node.name) ?? null;
|
|
501
|
+
if (node?.type !== 'MemberExpression' || node.computed) return null;
|
|
502
|
+
if (node.property.type !== 'Identifier') return null;
|
|
503
|
+
const root = rootIdentifier(node);
|
|
504
|
+
if (!root || !REQ_ROOTS.has(root.toLowerCase())) return null;
|
|
505
|
+
return `:${node.property.name}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// map local vars assigned straight from a request member: `const c = req.params.collection`
|
|
509
|
+
// → { c: ':collection' }. One shallow pass; deterministic; bounded by the function body.
|
|
510
|
+
function collectReqDerived(fnNode) {
|
|
511
|
+
const map = new Map();
|
|
512
|
+
const walk = (n) => {
|
|
513
|
+
if (!n || typeof n !== 'object') return;
|
|
514
|
+
if (Array.isArray(n)) {
|
|
515
|
+
for (const x of n) walk(x);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (
|
|
519
|
+
n.type === 'VariableDeclarator' &&
|
|
520
|
+
n.id?.type === 'Identifier' &&
|
|
521
|
+
n.init?.type === 'MemberExpression'
|
|
522
|
+
) {
|
|
523
|
+
const name = reqParamName(n.init, null);
|
|
524
|
+
if (name) map.set(n.id.name, name);
|
|
525
|
+
}
|
|
526
|
+
for (const k of Object.keys(n)) {
|
|
527
|
+
if (
|
|
528
|
+
k === 'loc' ||
|
|
529
|
+
k === 'range' ||
|
|
530
|
+
k === 'leadingComments' ||
|
|
531
|
+
k === 'trailingComments'
|
|
532
|
+
)
|
|
533
|
+
continue;
|
|
534
|
+
const v = n[k];
|
|
535
|
+
if (v && typeof v === 'object') walk(v);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
walk(fnNode.body);
|
|
539
|
+
return map;
|
|
540
|
+
}
|
|
541
|
+
|
|
436
542
|
// SBIR §2.2 — transaction wrappers whose function arguments open a scope
|
|
437
543
|
const TX_WRAPPERS = new Set(['transaction', '$transaction', 'withTransaction']);
|
|
438
544
|
|
|
@@ -606,13 +712,16 @@ function inspectCall(node, out, ctx) {
|
|
|
606
712
|
// (not a chained .from()), so read it straight off arguments[0].
|
|
607
713
|
if (KYSELY_OPS[methodLower] !== undefined && callee.type === 'MemberExpression') {
|
|
608
714
|
const arg0 = node.arguments[0];
|
|
609
|
-
const
|
|
715
|
+
const literal = arg0?.type === 'StringLiteral' ? arg0.value.toLowerCase() : null;
|
|
716
|
+
const symbolic = literal ? null : reqParamName(arg0, ctx.reqDerived);
|
|
717
|
+
const table = literal ?? symbolic;
|
|
610
718
|
if (table) {
|
|
611
719
|
const op = KYSELY_OPS[methodLower];
|
|
612
720
|
pushEffect(out, ctx, {
|
|
613
721
|
effectType: op === 'select' ? 'db_read' : 'db_write',
|
|
614
722
|
op,
|
|
615
|
-
table
|
|
723
|
+
table,
|
|
724
|
+
...(symbolic ? { symbolic: true } : {}),
|
|
616
725
|
line,
|
|
617
726
|
});
|
|
618
727
|
return;
|
|
@@ -621,12 +730,13 @@ function inspectCall(node, out, ctx) {
|
|
|
621
730
|
|
|
622
731
|
// ---- supabase/knex builder: X.from('t').insert(...) or knex('t').update(...)
|
|
623
732
|
if (SUPABASE_OPS.has(methodLower)) {
|
|
624
|
-
const
|
|
625
|
-
if (
|
|
733
|
+
const resolved = builderTableOf(callee.object, ctx.reqDerived);
|
|
734
|
+
if (resolved) {
|
|
626
735
|
pushEffect(out, ctx, {
|
|
627
736
|
effectType: methodLower === 'select' ? 'db_read' : 'db_write',
|
|
628
737
|
op: methodLower,
|
|
629
|
-
table: table.toLowerCase(),
|
|
738
|
+
table: resolved.symbolic ? resolved.table : resolved.table.toLowerCase(),
|
|
739
|
+
...(resolved.symbolic ? { symbolic: true } : {}),
|
|
630
740
|
line,
|
|
631
741
|
});
|
|
632
742
|
return;
|
|
@@ -964,20 +1074,29 @@ function clientBaseName(node) {
|
|
|
964
1074
|
// knex('users') → users ; supabase.from('users') → users ;
|
|
965
1075
|
// supabase.from('users').select() chains: walk member/call chain to a
|
|
966
1076
|
// .from('t') or a base call with a string literal argument.
|
|
967
|
-
|
|
1077
|
+
// → { table, symbolic } or null. A request-derived arg (`knex(req.params.table)`,
|
|
1078
|
+
// `.from(collection)` where `const collection = req.params.collection`) resolves to a
|
|
1079
|
+
// SYMBOLIC table (`:table`) — a precise rule, not an unknown — so generic CRUD
|
|
1080
|
+
// endpoints stop reading as blind.
|
|
1081
|
+
function builderTableOf(node, reqDerived) {
|
|
968
1082
|
let cur = node;
|
|
1083
|
+
const lit = (t) => ({ table: t, symbolic: false });
|
|
969
1084
|
for (let hops = 0; hops < 8 && cur; hops++) {
|
|
970
1085
|
if (cur.type === 'CallExpression') {
|
|
971
1086
|
const c = cur.callee;
|
|
972
|
-
|
|
1087
|
+
const arg0 = cur.arguments[0];
|
|
1088
|
+
const isFrom =
|
|
973
1089
|
c.type === 'MemberExpression' &&
|
|
974
1090
|
c.property.type === 'Identifier' &&
|
|
975
|
-
c.property.name === 'from'
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
if (
|
|
980
|
-
|
|
1091
|
+
c.property.name === 'from';
|
|
1092
|
+
const isBaseCall = c.type === 'Identifier'; // knex('users')
|
|
1093
|
+
const isThisCall =
|
|
1094
|
+
c.type === 'MemberExpression' && c.object.type === 'ThisExpression';
|
|
1095
|
+
if (isFrom || isBaseCall || isThisCall) {
|
|
1096
|
+
if (arg0?.type === 'StringLiteral') return lit(arg0.value);
|
|
1097
|
+
const sym = reqParamName(arg0, reqDerived);
|
|
1098
|
+
if (sym) return { table: sym, symbolic: true };
|
|
1099
|
+
}
|
|
981
1100
|
cur = c.type === 'MemberExpression' ? c.object : null;
|
|
982
1101
|
} else if (cur.type === 'MemberExpression') {
|
|
983
1102
|
cur = cur.object;
|
package/src/ubg/nestjs.js
CHANGED
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import traverseModule from '@babel/traverse';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
parseModule,
|
|
19
|
+
scanFunction,
|
|
20
|
+
classInModule,
|
|
21
|
+
baseClassOf,
|
|
22
|
+
methodInClassChain,
|
|
23
|
+
} from './extract.js';
|
|
18
24
|
|
|
19
25
|
const traverse = traverseModule.default ?? traverseModule;
|
|
20
26
|
const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
|
@@ -289,11 +295,11 @@ function resolveMethod(typeName, methodName, ownerMod, cwd, scannedFiles, helper
|
|
|
289
295
|
const rel = relOf(cwd, file);
|
|
290
296
|
if (!scannedFiles.includes(rel)) scannedFiles.push(rel);
|
|
291
297
|
|
|
292
|
-
const m = methodInClassChain(cls, mod, methodName
|
|
298
|
+
const m = methodInClassChain(cls, mod, methodName);
|
|
293
299
|
if (!m) return null;
|
|
294
300
|
helpers.push({
|
|
295
301
|
name: `${typeName}.${methodName}`,
|
|
296
|
-
sourceFile: m.
|
|
302
|
+
sourceFile: relOf(cwd, moduleFileOf(m.mod)) || rel,
|
|
297
303
|
sourceLine: m.fn.loc?.start.line ?? 0,
|
|
298
304
|
fn: m.fn,
|
|
299
305
|
});
|
|
@@ -325,47 +331,8 @@ function diMapWithMod(cls, clsMod) {
|
|
|
325
331
|
return out;
|
|
326
332
|
}
|
|
327
333
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
for (const node of mod.ast.program.body) {
|
|
331
|
-
const cls =
|
|
332
|
-
node.type === 'ClassDeclaration'
|
|
333
|
-
? node
|
|
334
|
-
: node.type === 'ExportNamedDeclaration' &&
|
|
335
|
-
node.declaration?.type === 'ClassDeclaration'
|
|
336
|
-
? node.declaration
|
|
337
|
-
: null;
|
|
338
|
-
if (cls && cls.id?.name === typeName) return cls;
|
|
339
|
-
}
|
|
340
|
-
return null;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// locate `methodName` on the class or, failing that, up the `extends` chain →
|
|
344
|
-
// { fn, mod, rel }. Base-class methods live in the base module, so carry it along.
|
|
345
|
-
function methodInClassChain(cls, mod, methodName, cwd, depth) {
|
|
346
|
-
for (const m of cls.body.body) {
|
|
347
|
-
if (
|
|
348
|
-
m.type === 'ClassMethod' &&
|
|
349
|
-
m.key.type === 'Identifier' &&
|
|
350
|
-
m.key.name === methodName
|
|
351
|
-
)
|
|
352
|
-
return { fn: m, mod, rel: relOf(cwd, moduleFileOf(mod)) };
|
|
353
|
-
}
|
|
354
|
-
if (depth >= MAX_DI_DEPTH) return null;
|
|
355
|
-
const base = baseClassOf(cls, mod);
|
|
356
|
-
return base ? methodInClassChain(base.cls, base.mod, methodName, cwd, depth + 1) : null;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// resolve `class X extends Base` → the Base class node + its module, via the import.
|
|
360
|
-
function baseClassOf(cls, mod) {
|
|
361
|
-
if (cls.superClass?.type !== 'Identifier') return null;
|
|
362
|
-
const file = mod.imports.get(cls.superClass.name);
|
|
363
|
-
if (!file || !fs.existsSync(file)) return null;
|
|
364
|
-
const baseMod = parseModule(file);
|
|
365
|
-
if (baseMod.error) return null;
|
|
366
|
-
const baseCls = classInModule(baseMod, cls.superClass.name);
|
|
367
|
-
return baseCls ? { cls: baseCls, mod: baseMod } : null;
|
|
368
|
-
}
|
|
334
|
+
// classInModule / methodInClassChain / baseClassOf live in extract.js — shared
|
|
335
|
+
// with the Express instantiated-service follower (`new Service().method()`).
|
|
369
336
|
|
|
370
337
|
const moduleFileOf = (mod) => mod.ast?.loc?.filename ?? mod._file ?? '';
|
|
371
338
|
|
package/src/ubg/translate.js
CHANGED
|
@@ -218,6 +218,10 @@ function ensureChainNode(graph, step, scanCache) {
|
|
|
218
218
|
existing.meta.returnShapes = cachedScan.returnShapes;
|
|
219
219
|
return { id, scan: cachedScan };
|
|
220
220
|
}
|
|
221
|
+
// opaque = SPARDA holds NO body for this step (fn:null and no precomputed scan) —
|
|
222
|
+
// a handler/middleware it registered by name but could not read. The blindspot
|
|
223
|
+
// ledger uses this to tell "read and empty" apart from "couldn't read".
|
|
224
|
+
const opaque = !step.fn && !step.scan;
|
|
221
225
|
addNode(
|
|
222
226
|
graph,
|
|
223
227
|
makeNode(
|
|
@@ -228,6 +232,7 @@ function ensureChainNode(graph, step, scanCache) {
|
|
|
228
232
|
{
|
|
229
233
|
role: step.role,
|
|
230
234
|
async: scan.async,
|
|
235
|
+
...(opaque ? { opaque: true } : {}),
|
|
231
236
|
...(kind === 'guard'
|
|
232
237
|
? { guardType: guardTypeOf(step.name, scan), verified: gf.verified }
|
|
233
238
|
: {}),
|
|
@@ -263,6 +268,9 @@ function attachBody(graph, ownerId, scan, helperByName, scanCache, expanded) {
|
|
|
263
268
|
effectType: eff.effectType,
|
|
264
269
|
...(eff.op ? { op: eff.op } : {}),
|
|
265
270
|
...(eff.table ? { table: eff.table } : {}),
|
|
271
|
+
// symbolic table (`:collection`): a request-derived target, resolved as a
|
|
272
|
+
// rule, not a literal — carried so the blindspot ledger doesn't flag it opaque
|
|
273
|
+
...(eff.symbolic ? { symbolic: true } : {}),
|
|
266
274
|
...(eff.target ? { target: eff.target } : {}),
|
|
267
275
|
...(eff.driver ? { driver: eff.driver } : {}),
|
|
268
276
|
...(eff.httpMethod ? { httpMethod: eff.httpMethod } : {}),
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"v":"imm1","proven":false,"surfaceOnly":true,"routes":[{"behaviorHash":"bh1_13969839d8a18d54aa0342618e56db6a","pol":121,"exposed":[]},{"behaviorHash":"bh1_2daf8a2b1b0ed393d443991a2b0700f6","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_cc6788289c612af85b8d215b5bdf9b28","pol":121,"exposed":[]}],"posture":{"auth":{"protected":0,"exposed":0,"na":5},"atomicity":{"protected":0,"exposed":0,"na":5},"reversibility":{"protected":0,"exposed":0,"na":5},"validation":{"protected":0,"exposed":0,"na":5},"aggregate":{"protected":0,"exposed":0,"na":5}},"bytes":5}
|