feat(ingest): multi-language white-box ingest (web-tree-sitter) (#75)

* chore(ingest): pin web-tree-sitter@0.25.10 + tree-sitter-wasms@0.1.13; add multilang-ingest design docs

* feat(ingest): multi-language grammar registry (web-tree-sitter, 8 langs, fail-open); 100% cov

* feat(ingest): language-aware param-name splitter (py/go/ts/java/c); 100% cov

* feat(ingest): tree-sitter CodeBlock extractor with wall-clock parse budget + fail-open

* feat(ingest): cross-lang sinks + multilang config + wire tree-sitter into ingestRepository (sync)

* feat(ingest): repoint white-box production callers to multilang config + prod regression test

* server-init-wiring: load multi-language grammars once at bootstrap

initGrammars() runs after LLM init, before app.listen. Internally
fail-open (never rejects): on failure the grammar registry stays empty
and ingest falls back to the Python regex parser.

* adversarial-tests: hostile-input hardening for untrusted target source

Malformed per-language source, wrong-language content, binary/non-UTF8,
empty, unicode identifiers, 20k-func large file (wall-clock budget),
deeply nested input, and a symlink-loop crawl — all must fail-open, never
throw or spin. Drives parseFileMultiLang + ingestRepository/crawl.

* readme-status + docs: flip white-box status to multi-language ingest

README source-code rows now state web-tree-sitter multi-language ingest
(fail-open to Python regex). whitebox.ts scope caveat + no-source error
message updated off the stale Python-only wording.

* coverage-setup: enforce 100% coverage gate on the three new ingest files

vitest.config.ts adds a coverage-only block (v8, perFile thresholds=100)
scoped to ts-grammars.ts, param-split.ts, ts-parse.ts. Test discovery and
env stay on defaults. Edited files gate on new-branch coverage via the
regression suites; the server.ts initGrammars bootstrap line is the
documented smoke-covered carve-out.

* review: fix WASM heap leak, language-scoped fail-open, sink recall/precision

Adversarial security + strict code review (two independent subagents)
returned one blocking bug and several correctness findings; remediated:

BLOCKING #1 — native WASM memory leak: web-tree-sitter Tree/Parser objects
were never .delete()'d. Both reviewers reproduced a process-global wedge:
after ~7 large parses the shared WASM heap exhausts, every subsequent parse
aborts (caught by fail-open), and non-Python ingest silently returns []
forever. Fix: free the tree in a finally; delete the parser in resetParser.
Regression test parses ~8MB through the real shared parser 12x.

regex, which mis-parses brace/class syntax into corrupt blocks (wrong line
spans, lost methods). Now non-.py fail-open returns [] (honest absence); the
Python regex only ever runs on .py. Isolated ts-parse-fallback test.

http(s).request, axios(config), C exec-family); negative lookbehind stops
bare system(/popen(/fetch( from matching Python receivers (os.system etc.
still matched, corpus rankings unchanged). Recall+precision tests added.

operator sees non-Python degradation.

(one-line call-graph edges, no decorators, C++ member methods).

* review round 2: fix sink-regex regressions, real timeout test, deterministic leak guard

Third review round (correctness + security re-attack + test-quality) against
the round-1 fixes surfaced issues in the fixes themselves; remediated:

- Sink-regex regressions I introduced: (1) my fetch lookbehind dropped
  window.fetch/this.fetch (top JS SSRF idiom) — reverted to \bfetch\(;
  (2) bare .Get/.Post/.Do flagged db.Get/cache.Get/queue.Do as SSRF —
  receiver-qualified to [Cc]lient. Recall+precision tests cover both;
  regex is ReDoS-free (timed).
- Placebo leak test: the 12x function-dense loop did NOT wedge the parser
  on machines with heap headroom (proven: passed with tree.delete removed).
  Replaced with a deterministic vi.spyOn(Tree.prototype,'delete') assertion,
  verified RED without the fix.
- Real (non-mocked) parse-timeout test: parseFileMultiLang gains an injectable
  budgetMs; a 0ms budget drives the real progressCallback cancellation to [].
- runWhiteboxAnalysis swap now has coverage (spy on orchestrator.run) so the
  2nd production caller can't silently revert to the Python-only config.
- Un-vacuoused weak tests: unicode now asserts the extracted block; nested/
  wrong-language wrapped in invariants() + timing bounds.
- CI now runs npm run test:coverage (the 100% gate was defined but unenforced).
- Security: documented maxFileBytes as the control bounding a single parse's
  peak WASM memory below the 2GB heap-abort ceiling.
- Stale 'fail-open to Python regex' docs fixed (ts-parse, whitebox, server,
  adversarial); circular-import note added; temp-dir cleanup; honest coverage
  wording in spec + config.

* docs(ingest): align fallback-contract text with the shipped language-scoped fail-open

Review feedback (#75, @jmagly): the design docs + one ts-grammars comment
still described the pre-implementation 'fall back to the Python regex'
model for non-Python files. The shipped contract is language-scoped: .py
uses parseFile; any non-.py file with a missing/unsupported/failed grammar
or a parse timeout returns [] (never the Python regex, which would mis-parse
brace/class syntax into corrupt blocks). Updated every stale reference in
multilang-ingest-spec.md, multilang-ingest-plan.md, and ts-grammars.ts so
the docs match the security boundary. Docs/comment only — no code change.

* fix(test): make the ReDoS wall-clock guard robust under coverage instrumentation

Review feedback (#75, @jmagly): the CI step this PR adds (npm run
test:coverage = vitest run --coverage) runs the full suite under V8
coverage, and that overhead pushes the pathological-brace DoS timing guard
in local-agent-tool-calling.test.ts:69 over its <300ms bound (320 < 300
fails on CI). Raise the bound to 1000ms — still 2x below the ~2100ms
quadratic-ReDoS regression it guards against, so it fails hard if the
regression returns, but no longer false-fails under coverage/slow CI.

Chose the reviewer's option (a) (robust bound) over scoping test:coverage,
so the gate stays a simple full-suite run and no test-file enumeration can
rot. Verified: npm run test:coverage is green and the 3 new files stay at
100%.

* fix(ingest): cover pointer-returning C functions

---------

Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
This commit is contained in:
Lyubomir BozhinovandJoseph Magly authored and GitHub committed 2026-07-20 11:41:21 -04:00
1 parent 440dedd9c1
commit 151d3555c6
24 files changed
+1681 -25

No files matched your search

@@ -66,7 +66,10 @@ describe('parseTextToolCalls — happy path + drift tolerance', () => {
it('is DoS-safe on pathological brace input (was: quadratic ReDoS)', () => {
const t0 = Date.now();
expect(parseTextToolCalls('{'.repeat(80000))).toBeUndefined();
expect(Date.now() - t0).toBeLessThan(300); // bounded — the old greedy regex took ~2100ms
// Bounded — the old greedy regex took ~2100ms. Generous ceiling (2x below that
// regression target) so V8 coverage instrumentation / slow CI can't false-fail
// this wall-clock guard; it still fails hard if the quadratic ReDoS returns.
expect(Date.now() - t0).toBeLessThan(1000);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { crawl, createMultiLangIngestConfig } from '../recon/code-ingest.js';
describe('createMultiLangIngestConfig', () => {
const EXTS = ['py', 'js', 'ts', 'tsx', 'go', 'java', 'c', 'cpp'];
it('crawls every supported extension and excludes non-source', () => {
const root = mkdtempSync(join(tmpdir(), 'mling-'));
for (const e of EXTS) writeFileSync(join(root, `f.${e}`), 'x\n');
writeFileSync(join(root, 'd.md'), '# ignore');
writeFileSync(join(root, 'e.txt'), 'ignore');
const files = crawl(createMultiLangIngestConfig(root));
for (const e of EXTS) {
expect(files.some((f) => f.endsWith(`f.${e}`)), `includes .${e}`).toBe(true);
}
expect(files.some((f) => f.endsWith('d.md'))).toBe(false);
expect(files.some((f) => f.endsWith('e.txt'))).toBe(false);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { splitParamList } from '../recon/param-split.js';
describe('splitParamList', () => {
it('python: drops self, strips annotations/defaults, drops *args', () =>
expect(splitParamList('self, url: str, timeout=5, *args', 'py')).toEqual(['url', 'timeout']));
it('go name-first, drops types', () =>
expect(splitParamList('ctx context.Context, id string', 'go')).toEqual(['ctx', 'id']));
it('java type-first, keeps trailing name', () =>
expect(splitParamList('String url, int max', 'java')).toEqual(['url', 'max']));
it('ts name: type, strips optional marker', () =>
expect(splitParamList('url: string, opts?: Opts', 'ts')).toEqual(['url', 'opts']));
it('empty', () => expect(splitParamList('', 'ts')).toEqual([]));
it('SAME raw, different lang → forces real dispatch', () => {
expect(splitParamList('a b', 'go')).toEqual(['a']); // name-first
expect(splitParamList('a b', 'java')).toEqual(['b']); // type-first
});
it('strips surrounding parens (tree-sitter params node text)', () =>
expect(splitParamList('(url string)', 'go')).toEqual(['url']));
it('c pointers: type-first, strips *', () =>
expect(splitParamList('char *url, int n', 'c')).toEqual(['url', 'n']));
it('generics do not split params', () =>
expect(splitParamList('m Map<int, string>, n int', 'go')).toEqual(['m', 'n']));
it('python drops bare * and / markers', () =>
expect(splitParamList('a, *, b, /', 'py')).toEqual(['a', 'b']));
it('python drops cls', () =>
expect(splitParamList('cls, x', 'py')).toEqual(['x']));
it('ts rest param + array type', () =>
expect(splitParamList('...args: number[]', 'ts')).toEqual(['args']));
it('cpp references and pointers stripped', () =>
expect(splitParamList('const std::string &ref, int* p', 'cpp')).toEqual(['ref', 'p']));
it('c array-suffix name stripped', () =>
expect(splitParamList('char buf[]', 'c')).toEqual(['buf']));
it('bracket + brace nesting does not split', () => {
expect(splitParamList('a int[2], b T{}', 'go')).toEqual(['a', 'b']);
});
it('unbalanced close bracket is tolerated', () =>
expect(splitParamList('a)x, b', 'go')).toEqual(['a', 'b']));
it('non-identifier params are dropped', () =>
expect(splitParamList('*, ---', 'go')).toEqual([]));
it('trailing comma tolerated', () =>
expect(splitParamList('a,', 'go')).toEqual(['a']));
it('empty segment (double comma) dropped', () =>
expect(splitParamList('a,,b', 'go')).toEqual(['a', 'b']));
it('ts name-less param dropped', () =>
expect(splitParamList(': int, x: str', 'ts')).toEqual(['x']));
it('java name-less param dropped', () =>
expect(splitParamList('=v, int x', 'java')).toEqual(['x']));
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { DANGEROUS_SINK_RE, OUTBOUND_REQUEST_RE } from '../recon/code-ingest.js';
describe('cross-language sinks', () => {
it('matches new cross-language sinks', () => {
for (const s of [
'Runtime.getRuntime().exec(cmd)',
'exec.Command("sh")',
'child_process.exec(x)',
'system(buf)',
'popen(cmd)',
'new ProcessBuilder(cmd)',
]) {
expect(DANGEROUS_SINK_RE.test(s), s).toBe(true);
}
});
it('regression: existing python sinks still match', () => {
for (const s of ['os.system(x)', 'eval(y)', 'subprocess.run(z)', 'yaml.load(f)']) {
expect(DANGEROUS_SINK_RE.test(s), s).toBe(true);
}
});
it('no false positive on benign / System.out / filesystem identifiers', () => {
for (const s of ['const total = sum(a, b)', 'System.out.println("debug")', 'fileSystem.readFile(x)']) {
expect(DANGEROUS_SINK_RE.test(s), s).toBe(false);
}
});
it('outbound covers go net/http and js fetch/axios', () => {
for (const s of ['http.Get(u)', 'http.Post(u, b)', 'fetch(u)', 'axios.get(u)']) {
expect(OUTBOUND_REQUEST_RE.test(s), s).toBe(true);
}
});
it('recall: idiomatic outbound/exec forms missed by the first-pass patterns', () => {
// Go http.NewRequest+client.Do, Go client.Get, Node http(s).request, axios(config),
// C exec-family, and receiver-qualified fetch — all real attack-surface idioms.
for (const s of [
'req, _ := http.NewRequest("GET", u, nil)',
'resp, _ := client.Do(req)',
'exec.CommandContext(ctx, cmd)',
'execve(path, argv, envp)',
'execvp(cmd, args)',
'https.request(options, cb)',
'axios({ url: u })',
'return window.fetch(url)', // qualified fetch must still match (JS SSRF idiom)
'this.fetch(userUrl)',
]) {
expect(DANGEROUS_SINK_RE.test(s), s).toBe(true);
}
for (const s of ['client.Get(u)', 'client.Do(req)', 'http.request(u, cb)', 'axios({url:u})', 'window.fetch(u)']) {
expect(OUTBOUND_REQUEST_RE.test(s), s).toBe(true);
}
});
it('precision: bare-call sinks and generic .Get/.Do methods on unrelated receivers are not flagged', () => {
// `.system(`/`.popen(` on an unrelated object must not be flagged as the
// C/OS bare call. (os.system / os.popen keep matching via their own patterns.)
expect(DANGEROUS_SINK_RE.test('solarSystem.system(planets)'), 'receiver .system(').toBe(false);
expect(DANGEROUS_SINK_RE.test('os.system(cmd)'), 'os.system still').toBe(true);
// Generic keyed-store methods must NOT be mistaken for outbound HTTP — the
// .Get/.Post/.Do alternatives are receiver-qualified to `client`.
for (const s of ['return db.Get(id)', 'cache.Get(userID)', 'queue.Do(task)', 'session.Post(data)', 'config.get(key)']) {
expect(OUTBOUND_REQUEST_RE.test(s), s).toBe(false);
expect(DANGEROUS_SINK_RE.test(s), s).toBe(false);
}
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Parser } from 'web-tree-sitter';
import {
initGrammars,
getGrammar,
supportedExts,
__resetGrammarsForTest,
} from '../recon/ts-grammars.js';
describe('ts-grammars registry', () => {
beforeEach(() => __resetGrammarsForTest());
it('advertises the multi-language extension set', () => {
expect(supportedExts()).toEqual(
expect.arrayContaining(['.py', '.js', '.ts', '.tsx', '.go', '.java', '.c', '.cpp']),
);
});
it('loads every grammar with a working def-query (multi-language, real parse)', async () => {
await initGrammars();
const samples: Record<string, string> = {
'.py': 'def fetch(url):\n return get(url)\n',
'.js': 'function fetch(url){return get(url)}\n',
'.ts': 'export function fetch(url: string){return get(url)}\n',
'.tsx': 'export function F(p: {a:number}){ return null; }\n',
'.go': 'package m\nfunc Fetch(url string) error { return nil }\n',
'.java': 'class A { void run(String u){} }\n',
'.c': 'int fetch(char *url){ return 0; }\n',
'.cpp': 'int fetch(char *url){ return 0; }\n',
};
for (const ext of supportedExts()) {
const g = getGrammar(ext);
expect(g, `grammar for ${ext}`).toBeDefined();
const p = new Parser();
p.setLanguage(g!.language);
const tree = p.parse(samples[ext]);
const names = g!.query
.matches(tree!.rootNode)
.flatMap((m) => m.captures.filter((c) => c.name === 'name').map((c) => c.node.text));
expect(names.length, `captures for ${ext}`).toBeGreaterThan(0);
}
});
it('is idempotent — a second init keeps the loaded registry', async () => {
await initGrammars(['.go']);
await initGrammars(['.py']); // second call is a no-op (already initialized)
expect(getGrammar('.go')).toBeDefined();
expect(getGrammar('.py')).toBeUndefined();
});
it('fail-open: per-grammar load failure skips that ext, no throw', async () => {
await expect(
initGrammars(['.go'], () => { throw new Error('bad wasm path'); }),
).resolves.toBeUndefined();
expect(getGrammar('.go')).toBeUndefined();
});
it('fail-open: global init failure leaves the registry empty, no throw', async () => {
await expect(
initGrammars(supportedExts(), (n) => n, () => Promise.reject(new Error('runtime init failed'))),
).resolves.toBeUndefined();
for (const ext of supportedExts()) expect(getGrammar(ext)).toBeUndefined();
});
it('ignores unknown extensions', async () => {
await initGrammars(['.zzz']);
expect(getGrammar('.zzz')).toBeUndefined();
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Adversarial hardening — ingest parses UNTRUSTED target source. A crafted file
* must never throw, hang, or crash a mission; worst case is honest absence ([])
* — a non-.py file is NEVER routed to the Python regex. Drives the full wired
* path (parseFileMultiLang + ingestRepository/crawl).
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Tree } from 'web-tree-sitter';
import { initGrammars, __resetGrammarsForTest } from '../recon/ts-grammars.js';
import { parseFileMultiLang } from '../recon/ts-parse.js';
import { ingestRepository, createMultiLangIngestConfig, type CodeBlock } from '../recon/code-ingest.js';
const tmpRoots: string[] = [];
beforeAll(async () => {
__resetGrammarsForTest();
await initGrammars();
});
afterAll(() => {
for (const r of tmpRoots) rmSync(r, { recursive: true, force: true });
});
const invariants = (blocks: CodeBlock[]) => {
expect(Array.isArray(blocks)).toBe(true);
for (const b of blocks) {
expect(b.lineStart).toBeLessThanOrEqual(b.lineEnd);
expect(Array.isArray(b.params)).toBe(true);
if (b.name) expect(b.body.length).toBeGreaterThan(0);
}
};
describe('adversarial parseFileMultiLang', () => {
it('malformed source per language does not throw', () => {
for (const [ext, src] of [
['.go', 'func ('],
['.ts', 'class {{{ function ('],
['.java', 'class A { void ('],
['.c', 'int f( { { {'],
] as const) {
expect(() => invariants(parseFileMultiLang(`x${ext}`, src, ext))).not.toThrow();
}
});
it('wrong-language content (Python in a .go file) yields structurally valid output', () => {
expect(() =>
invariants(parseFileMultiLang('x.go', 'def fetch(url):\n return get(url)\n', '.go')),
).not.toThrow();
});
it('binary / non-UTF8 bytes do not throw', () => {
const bin = Buffer.from([0x00, 0xff, 0xfe, 0x10, 0x80, 0x00, 0x41]).toString('binary');
expect(() => invariants(parseFileMultiLang('x.go', bin, '.go'))).not.toThrow();
});
it('empty file yields no blocks, no throw', () => {
expect(parseFileMultiLang('e.go', '', '.go')).toEqual([]);
});
it('unicode identifiers are extracted correctly', () => {
const blocks = parseFileMultiLang('u.go', 'package m\nfunc Ünïçødé(x int) int { return x }\n', '.go');
const fn = blocks.find((b) => b.name === 'Ünïçødé');
expect(fn, 'unicode function name captured').toBeDefined();
expect(fn!.params).toEqual(['x']);
});
it('large file parses within the wall-clock budget and still extracts', () => {
let src = 'package m\n';
for (let i = 0; i < 20000; i++) src += `func F${i}(url string) error { return http.Get(url) }\n`;
const t0 = Date.now();
const blocks = parseFileMultiLang('big.go', src, '.go');
expect(Date.now() - t0).toBeLessThan(10000); // bounded — never spins
expect(blocks.length).toBeGreaterThan(0);
});
it('a real parse tripping the wall-clock budget fails open to [] (not mocked)', () => {
// Drive the REAL tree-sitter progressCallback path with a 0ms budget: the
// callback returns true on its first invocation, tree-sitter cancels, parse
// returns null, and the fail-open returns []. Proves the actual cancellation
// wiring — not a mock that hard-returns null.
let src = 'package m\n';
for (let i = 0; i < 5000; i++) src += `func F${i}(a int) int { return a }\n`;
const t0 = Date.now();
const blocks = parseFileMultiLang('slow.go', src, '.go', undefined, 0);
expect(blocks).toEqual([]); // cancellation path taken
expect(Date.now() - t0).toBeLessThan(5000); // cancelled early, not run to completion
});
it('deeply nested input does not blow the stack, hang, or corrupt output', () => {
const deep = 'package m\nfunc F() { ' + '{'.repeat(2000) + '}'.repeat(2000) + ' }\n';
const t0 = Date.now();
expect(() => invariants(parseFileMultiLang('d.go', deep, '.go'))).not.toThrow();
expect(Date.now() - t0).toBeLessThan(10000);
});
it('frees the parsed tree exactly once per parse that produced one (leak-fix mechanism)', () => {
// Deterministic, machine-independent guard for the WASM Tree/Parser leak:
// assert tree.delete() is actually invoked. (A resource-exhaustion repro is
// hardware/heap-ceiling dependent and green-only on machines with headroom.)
const del = vi.spyOn(Tree.prototype, 'delete');
try {
del.mockClear();
parseFileMultiLang('ok.go', 'package m\nfunc F(){ return }\n', '.go'); // success path
expect(del, 'success path frees its tree').toHaveBeenCalledTimes(1);
del.mockClear();
parseFileMultiLang('bad.ts', 'class C { m( { { {', '.ts'); // error-recovery still yields a tree
expect(del, 'error-recovery path frees its tree').toHaveBeenCalledTimes(1);
} finally {
del.mockRestore();
}
});
});
describe('adversarial crawl / ingest', () => {
it('symlink loop under the repo root terminates (crawl does not follow symlink dirs)', () => {
const root = mkdtempSync(join(tmpdir(), 'advcrawl-'));
tmpRoots.push(root);
const sub = join(root, 'sub');
mkdirSync(sub);
writeFileSync(join(sub, 'a.go'), 'package m\nfunc A() {}\n');
try {
symlinkSync(root, join(sub, 'loop'), 'dir'); // self-referential loop
} catch {
console.warn('[skip] symlinks unavailable in this environment — loop guard unverified');
return;
}
const t0 = Date.now();
const result = ingestRepository(createMultiLangIngestConfig(root));
expect(Date.now() - t0).toBeLessThan(10000); // did not spin on the loop
expect(result.analysisUnits.some((u) => u.block.name === 'A')).toBe(true);
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Language-scoped fail-open: the Python regex parser must ONLY ever run on `.py`.
* This file deliberately never calls initGrammars(), so the registry is empty and
* every getGrammar() returns undefined — the "grammars failed to load / not yet
* loaded" production scenario. A brace/`class` language routed to the Python regex
* in that state would emit a corrupt block (wrong line span, methods lost); the
* contract is honest absence ([]) instead.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { __resetGrammarsForTest } from '../recon/ts-grammars.js';
import { parseFileMultiLang } from '../recon/ts-parse.js';
import { parseFile } from '../recon/code-ingest.js';
beforeAll(() => __resetGrammarsForTest()); // ensure the registry is empty; do NOT init
describe('parseFileMultiLang — language-scoped fallback (no grammars loaded)', () => {
it('a Java `class` file returns [] — never a mangled Python-regex block', () => {
// If this routed to parseFile, the Python CLASS_RE would match `class A` and
// emit exactly one (wrong) block; assert it does not.
expect(parseFileMultiLang('A.java', 'class A {\n void run(String c){}\n}\n', '.java')).toEqual([]);
});
it('a JS `class` file returns [] with no grammar (would false-match the Python regex)', () => {
expect(parseFileMultiLang('a.js', 'class Svc {\n run(cmd){ exec(cmd); }\n}\n', '.js')).toEqual([]);
});
it('.py still routes to the Python regex even with no grammars loaded (byte-identical)', () => {
const src = 'def fetch(url):\n return get(url)\n';
expect(parseFileMultiLang('a.py', src, '.py')).toEqual(parseFile('a.py', src));
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { initGrammars, __resetGrammarsForTest } from '../recon/ts-grammars.js';
import { ingestRepository, createMultiLangIngestConfig } from '../recon/code-ingest.js';
beforeAll(async () => {
__resetGrammarsForTest();
await initGrammars();
});
describe('multi-language ingest → security ranking (headline regression)', () => {
function repo(): string {
const root = mkdtempSync(join(tmpdir(), 'mlrank-'));
// Go: Handler calls Fetch; Fetch is an SSRF surface (identifier param + outbound).
writeFileSync(
join(root, 'svc.go'),
'package m\n' +
'func Handler(url string) error {\n\treturn Fetch(url)\n}\n' +
'func Fetch(url string) error {\n\treturn http.Get(url)\n}\n' +
'func Add(a int, b int) int {\n\treturn a + b\n}\n',
);
// TS: command-exec sink.
writeFileSync(join(root, 'run.ts'), 'export function runCmd(cmd: string) { return exec(cmd); }\n');
// Java: Runtime.exec sink.
writeFileSync(join(root, 'A.java'), 'class A { void run(String cmd){ Runtime.getRuntime().exec(cmd); } }\n');
// Python still works alongside (regex path).
writeFileSync(join(root, 'p.py'), 'def load(path):\n return open(path)\n');
return root;
}
it('extracts non-.py functions and security-ranks them as attack_surface', () => {
const { analysisUnits } = ingestRepository(createMultiLangIngestConfig(repo()));
const by = (name: string) => analysisUnits.find((u) => u.block.name === name);
// multi-language extraction reached the pipeline (Go/TS/Java + Python)
for (const name of ['Fetch', 'runCmd', 'run', 'load']) {
expect(by(name), `block ${name} extracted`).toBeDefined();
}
// sink-bearing functions are attack_surface, out-ranking a neutral block
for (const name of ['Fetch', 'runCmd', 'run']) {
expect(by(name)!.exposure, `${name} exposure`).toBe('attack_surface');
}
expect(by('Fetch')!.priority).toBeGreaterThan(by('Add')!.priority);
expect(by('Add')!.exposure).toBe('neutral');
});
it('call graph links non-Python identifiers (cross-language reachability guard)', () => {
const { analysisUnits } = ingestRepository(createMultiLangIngestConfig(repo()));
const handler = analysisUnits.find((u) => u.block.name === 'Handler')!;
const fetch = analysisUnits.find((u) => u.block.name === 'Fetch')!;
// buildCallGraph matched the Go call "Fetch(url)" inside Handler by name.
expect(handler.callees).toContain(fetch.block.id);
expect(fetch.callers).toContain(handler.block.id);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeAll } from 'vitest';
import type { Parser } from 'web-tree-sitter';
import { initGrammars, __resetGrammarsForTest } from '../recon/ts-grammars.js';
import { parseFileMultiLang, nodeToCodeBlock } from '../recon/ts-parse.js';
import { parseFile } from '../recon/code-ingest.js';
beforeAll(async () => {
__resetGrammarsForTest(); // clear any singleton state leaked from another test file
await initGrammars();
});
describe('parseFileMultiLang', () => {
it('extracts a Go function as a CodeBlock', () => {
const src = 'package m\nfunc Fetch(url string) error {\n return http.Get(url)\n}\n';
const fn = parseFileMultiLang('a.go', src, '.go').find((b) => b.name === 'Fetch');
expect(fn).toBeDefined();
expect(fn!.params).toEqual(['url']);
expect(fn!.kind).toBe('function');
expect(fn!.lineStart).toBe(2);
expect(fn!.body).toContain('http.Get(url)');
expect(fn!.id).toBe('a.go::Fetch@2');
expect(fn!.decorators).toEqual([]);
});
it('extracts a TS class + method', () => {
const src = 'class Svc {\n run(cmd: string) { exec(cmd); }\n}\n';
const blocks = parseFileMultiLang('s.ts', src, '.ts');
expect(blocks.find((b) => b.name === 'Svc')?.kind).toBe('class');
const m = blocks.find((b) => b.name === 'run');
expect(m?.kind).toBe('method');
expect(m?.params).toEqual(['cmd']);
});
it('extracts a Java constructor as a method', () => {
const blocks = parseFileMultiLang('A.java', 'class A {\n A(int x){}\n}\n', '.java');
expect(blocks.find((b) => b.name === 'A' && b.kind === 'method')).toBeDefined();
expect(blocks.find((b) => b.name === 'A' && b.kind === 'class')).toBeDefined();
});
it('extracts C functions with pointer return types', () => {
const blocks = parseFileMultiLang(
'fetch.c',
'const char *fetch_url(const char *url) { return url; }\n',
'.c',
);
expect(blocks).toEqual([
expect.objectContaining({
name: 'fetch_url',
kind: 'function',
params: ['url'],
}),
]);
});
it('.py routes to the regex parser (byte-identical)', () => {
const src = 'def fetch(url):\n return get(url)\n';
expect(parseFileMultiLang('a.py', src, '.py')).toEqual(parseFile('a.py', src));
});
it('unsupported ext returns [] (no throw, no Python-regex on non-.py)', () => {
expect(parseFileMultiLang('a.zzz', 'func F(){}', '.zzz')).toEqual([]);
});
it('fail-open on parse timeout (parse returns null) → []', () => {
const nullParser = () =>
({ setLanguage() {}, parse: () => null, delete() {} }) as unknown as Parser;
expect(parseFileMultiLang('a.go', 'package m\nfunc F(){}\n', '.go', nullParser)).toEqual([]);
});
it('fail-open on parser error (parse throws) → []', () => {
const throwParser = () =>
({
setLanguage() {},
parse() {
throw new Error('boom');
},
delete() {},
}) as unknown as Parser;
expect(parseFileMultiLang('a.go', 'package m\nfunc F(){}\n', '.go', throwParser)).toEqual([]);
});
});
describe('nodeToCodeBlock', () => {
it('maps a class node (no params) to kind=class with empty params', () => {
const fake = {
type: 'class_declaration',
startPosition: { row: 4 },
endPosition: { row: 9 },
text: 'class C {}',
} as never;
const name = { text: 'C' } as never;
const cb = nodeToCodeBlock(fake, name, undefined, 'x.ts', 'ts');
expect(cb).toMatchObject({ name: 'C', kind: 'class', lineStart: 5, lineEnd: 10, params: [], id: 'x.ts::C@5' });
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, realpathSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { initGrammars, __resetGrammarsForTest } from '../recon/ts-grammars.js';
import { ingestRepoToSourceContext, runWhiteboxAnalysis } from '../recon/whitebox.js';
import { DecompositionOrchestrator } from '../orchestration/index.js';
let base: string;
let repo: string;
let prevRoot: string | undefined;
beforeAll(async () => {
__resetGrammarsForTest();
await initGrammars();
base = realpathSync(mkdtempSync(join(tmpdir(), 't3mp3st-wbml-')));
prevRoot = process.env.T3MP3ST_REPO_ROOT;
process.env.T3MP3ST_REPO_ROOT = base;
repo = join(base, 'repo');
mkdirSync(repo);
writeFileSync(join(repo, 'svc.go'), 'package m\nfunc Fetch(url string) error {\n\treturn http.Get(url)\n}\n');
writeFileSync(join(repo, 'run.ts'), 'export function runCmd(cmd: string) {\n\treturn exec(cmd);\n}\n');
writeFileSync(join(repo, 'p.py'), 'def load(path):\n return open(path)\n');
});
afterAll(() => {
if (prevRoot === undefined) delete process.env.T3MP3ST_REPO_ROOT;
else process.env.T3MP3ST_REPO_ROOT = prevRoot;
});
describe('ingestRepoToSourceContext (production white-box entry) — multi-language', () => {
it('non-.py source reaches the packed sourceContext (feature is not a no-op in prod)', () => {
const { sourceContext } = ingestRepoToSourceContext(repo);
// Go and TS blocks — proof the multilang config swap actually took effect on the
// real production path, not just via a direct createMultiLangIngestConfig call.
expect(sourceContext).toContain('Fetch');
expect(sourceContext).toContain('runCmd');
// Python still ingested alongside.
expect(sourceContext).toContain('load');
});
it('runWhiteboxAnalysis (2nd production caller) also feeds multilang source to the orchestrator', async () => {
// Guards the second createMultiLangIngestConfig swap site — a one-token
// revert to createPythonIngestConfig would drop non-.py content silently.
// Stub orch.run so no real LLM calls fire; capture the sourceContext it gets.
const runSpy = vi
.spyOn(DecompositionOrchestrator.prototype, 'run')
.mockResolvedValue({} as never);
try {
await runWhiteboxAnalysis({ repoPath: repo, objective: 'x' });
expect(runSpy).toHaveBeenCalledTimes(1);
const sourceContext = runSpy.mock.calls[0][1] as string;
expect(sourceContext).toContain('Fetch'); // Go reached the orchestrator
expect(sourceContext).toContain('runCmd'); // TS reached the orchestrator
} finally {
runSpy.mockRestore();
}
});
});
+45 -8
View File
@@ -37,7 +37,8 @@
*/
import { readdirSync, readFileSync, statSync } from 'fs';
import { join, sep } from 'path';
import { join, sep, extname } from 'path';
import { parseFileMultiLang } from './ts-parse.js';
import {
estimateTokens,
type SourceFile,
@@ -169,14 +170,23 @@ const ENTRY_NAME_RES: RegExp[] = [
const SECURITY_CONTROL_NAME_RE =
/valid|verif|auth|authoriz|authentic|permission|sanitiz|escap|csrf|check_|require_|is_allowed/i;
// Dangerous sinks that make a block part of the attack surface.
const DANGEROUS_SINK_RE =
/requests\.(get|post|put)|urllib|urlopen|httpx|socket\.|subprocess|os\.system|\beval\(|\bexec\(|pickle\.loads|yaml\.load|cursor\.execute|\.raw\(|open\(/;
// Dangerous sinks that make a block part of the attack surface. Python patterns
// plus cross-language sinks (Java/Go/C/JS) for the multi-language ingest.
// Bare-call patterns (system/popen/exec-family/fetch) use a `(?<![\w.])` guard so
// they match a C/JS free call `system(x)` but NOT a Python/JS method call on a
// receiver `foo.system(x)` — that keeps the existing Python corpus's rankings
// stable (its `os.system`/`os.popen` are matched by the qualified patterns).
export const DANGEROUS_SINK_RE =
/requests\.(get|post|put)|urllib|urlopen|httpx|socket\.|subprocess|os\.system|\beval\(|\bexec\(|pickle\.loads|yaml\.load|cursor\.execute|\.raw\(|open\(|exec\.Command(?:Context)?|Runtime\.getRuntime|ProcessBuilder|(?<![\w.])system\(|(?<![\w.])popen\(|(?<![\w.])exec(?:l|v)[pe]?\(|http\.(Get|Post|NewRequest)|https?\.request\(|[Cc]lient\.(Do|Get|Post)\(|\bfetch\(|axios[.(]/;
// Outbound-request sinks specifically (subset of the above) — used for the
// SSRF/IDOR "identifier param + outbound request" signal.
const OUTBOUND_REQUEST_RE =
/requests\.(get|post|put)|urllib|urlopen|httpx|socket\./;
// SSRF/IDOR "identifier param + outbound request" signal. Cross-language: Go
// net/http (http.Get/Post/NewRequest and *client*.Do/Get/Post), Node http(s).request,
// JS fetch/axios. `fetch` uses \b (matches window.fetch/this.fetch too); the Go
// client methods are receiver-qualified to `client` so a mundane db.Get/cache.Get
// is NOT mistaken for an outbound request.
export const OUTBOUND_REQUEST_RE =
/requests\.(get|post|put)|urllib|urlopen|httpx|socket\.|http\.(Get|Post|get|post|NewRequest)|https?\.request\(|[Cc]lient\.(Do|Get|Post)\(|\bfetch\(|axios[.(]/;
// URL/identifier-shaped param names.
const RISKY_PARAM_RE = /url|uri|endpoint|host|addr|id$|_id|path|file|name/i;
@@ -764,7 +774,10 @@ export function ingestRepository(config: IngestConfig): IngestResult {
}
processedFiles += 1;
totalBytes += content.length;
allBlocks.push(...parseFile(path, content));
// Multi-language dispatch: .py uses the legacy regex parser; supported
// non-Python languages use tree-sitter and yield [] when their grammar is
// unavailable. Stays sync — parseFileMultiLang does not await.
allBlocks.push(...parseFileMultiLang(path, content, extname(path).toLowerCase()));
if (maxTotalBytes !== undefined && totalBytes >= maxTotalBytes) {
truncated = true;
console.warn(`[code-ingest] ingest reached maxTotalBytes ceiling (${maxTotalBytes}) after ${totalBytes} bytes; remaining files skipped — raise IngestConfig.maxTotalBytes to analyze more.`);
@@ -841,6 +854,30 @@ export function createPythonIngestConfig(repoRoot: string): IngestConfig {
};
}
/**
* Multi-language ingest config: same ceilings as the Python config, but crawls
* the languages the tree-sitter extractor supports. Non-source files are
* excluded by `includeExts`. Used by the white-box analysis entrypoints.
*
* SECURITY: `maxFileBytes` (1 MB) is a security control here, not just a perf
* knob — it caps the peak WASM linear-memory a single tree-sitter parse can
* demand. A dense/obfuscated file large enough to approach web-tree-sitter's
* 2 GB heap ceiling makes the native allocator abort() (caught + fail-open, but
* the WASM high-water-mark cannot be reclaimed for the process's life). The 1 MB
* cap keeps that unreachable (worst measured blow-up ~370x → ~5-6x margin). Do
* NOT raise it without re-testing that margin.
*/
export function createMultiLangIngestConfig(repoRoot: string): IngestConfig {
return {
repoRoot,
includeExts: ['.py', '.js', '.ts', '.tsx', '.go', '.java', '.c', '.cpp'],
excludeGlobs: [...DEFAULT_EXCLUDES],
maxFileBytes: 1_000_000,
maxFiles: 50_000,
maxTotalBytes: 1_000_000_000,
};
}
// =============================================================================
// COMPOSITION WITH context-pack — rendering & security-ordered packing
// =============================================================================
+77
View File
@@ -0,0 +1,77 @@
/**
* Language-aware parameter-name extractor for the multi-language ingest.
*
* tree-sitter hands us a parameter-list as raw source text; different languages
* order name and type differently (Python/Go/TS are name-first, Java/C/C++ are
* type-first). This returns the bare identifier names so the security-ranking
* layer's `RISKY_PARAM_RE` can match on them exactly as it did for Python.
*/
/** Split on top-level commas only — commas inside (), [], {}, <> are nested. */
function splitTopLevel(s: string): string[] {
const parts: string[] = [];
let depth = 0;
let cur = '';
for (const ch of s) {
if (ch === '(' || ch === '[' || ch === '{' || ch === '<') depth++;
else if (ch === ')' || ch === ']' || ch === '}' || ch === '>') depth = Math.max(0, depth - 1);
if (ch === ',' && depth === 0) {
parts.push(cur);
cur = '';
} else {
cur += ch;
}
}
if (cur.trim()) parts.push(cur);
return parts;
}
/** First identifier-shaped token in a string, or null. */
function firstId(s: string): string | null {
const m = s.match(/[A-Za-z_$][A-Za-z0-9_$]*/);
return m ? m[0] : null;
}
function paramName(partRaw: string, lang: string): string | null {
const part = partRaw.trim();
if (!part) return null;
if (lang === 'py') {
if (part.startsWith('*')) return null; // *args / **kwargs / bare *
const p = part.split('=')[0].split(':')[0].trim();
if (!p || p === '/' || p === 'self' || p === 'cls') return null;
return firstId(p);
}
if (lang === 'ts' || lang === 'js') {
// name-first: name is the token before ':' or '=', minus a trailing '?'.
let p = part.replace(/^\.\.\./, '').split(':')[0].split('=')[0].trim();
p = p.replace(/\?$/, '').trim();
const toks = p.split(/\s+/).filter(Boolean); // drop TS modifiers (public/readonly)
return firstId(toks[toks.length - 1] ?? '');
}
if (lang === 'go') {
// name-first: first token of the group ("a, b int" already split → "a", "b int").
return firstId(part);
}
// java / c / c++ / unknown: type-first → name is the last token; strip * & [].
const p = part.split('=')[0].trim();
const toks = p.split(/\s+/).filter(Boolean);
const last = (toks[toks.length - 1] ?? '').replace(/^[*&]+/, '').replace(/\[\]$/, '');
return firstId(last);
}
/** Bare parameter identifiers from a raw parameter list, per language dialect. */
export function splitParamList(raw: string, lang: string): string[] {
let s = raw.trim();
if (s.startsWith('(') && s.endsWith(')')) s = s.slice(1, -1).trim();
if (!s) return [];
const out: string[] = [];
for (const part of splitTopLevel(s)) {
const name = paramName(part, lang);
if (name) out.push(name);
}
return out;
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Multi-language grammar registry for white-box ingest.
*
* Wraps web-tree-sitter (WASM) so `parseFileMultiLang` can extract CodeBlocks
* from ~8 languages, replacing the Python-only regex parser. Grammars are
* loaded ONCE at bootstrap (`initGrammars`) — `Parser.init()`/`Language.load()`
* are async, but `parser.parse()` is sync, so the ingest path stays sync.
*
* Fail-open by design: if init throws (bad/missing wasm, version skew), the
* registry is left empty. Python still routes to `parseFile`; non-Python files
* yield no blocks — ingest never crashes a mission.
*/
import { Parser, Language, Query } from 'web-tree-sitter';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
/** A loaded grammar: the tree-sitter Language plus the compiled def-query. */
export interface GrammarEntry {
language: Language;
query: Query;
/** Language id, for param-splitting dialect (py/js/ts/go/java/c/cpp). */
lang: string;
}
/**
* Per-extension spec: which prebuilt wasm (from tree-sitter-wasms) and the
* tree-sitter query capturing function/method/class definitions. Every pattern
* binds `@name` (the identifier) and `@def` (the whole definition node);
* `@params` is bound where the grammar exposes a parameter list.
*/
const SPECS: Record<string, { wasm: string; lang: string; query: string }> = {
'.py': {
wasm: 'tree-sitter-python', lang: 'py',
query: `
(function_definition name: (identifier) @name parameters: (parameters) @params) @def
(class_definition name: (identifier) @name) @def
`,
},
'.js': {
wasm: 'tree-sitter-javascript', lang: 'js',
query: `
(function_declaration name: (identifier) @name parameters: (formal_parameters) @params) @def
(method_definition name: (property_identifier) @name parameters: (formal_parameters) @params) @def
(class_declaration name: (identifier) @name) @def
`,
},
'.ts': {
wasm: 'tree-sitter-typescript', lang: 'ts',
query: `
(function_declaration name: (identifier) @name parameters: (formal_parameters) @params) @def
(method_definition name: (property_identifier) @name parameters: (formal_parameters) @params) @def
(class_declaration name: (type_identifier) @name) @def
`,
},
'.tsx': {
wasm: 'tree-sitter-tsx', lang: 'ts',
query: `
(function_declaration name: (identifier) @name parameters: (formal_parameters) @params) @def
(method_definition name: (property_identifier) @name parameters: (formal_parameters) @params) @def
(class_declaration name: (type_identifier) @name) @def
`,
},
'.go': {
wasm: 'tree-sitter-go', lang: 'go',
query: `
(function_declaration name: (identifier) @name parameters: (parameter_list) @params) @def
(method_declaration name: (field_identifier) @name parameters: (parameter_list) @params) @def
`,
},
'.java': {
wasm: 'tree-sitter-java', lang: 'java',
query: `
(method_declaration name: (identifier) @name parameters: (formal_parameters) @params) @def
(constructor_declaration name: (identifier) @name parameters: (formal_parameters) @params) @def
(class_declaration name: (identifier) @name) @def
`,
},
'.c': {
wasm: 'tree-sitter-c', lang: 'c',
query: `
(function_definition declarator: (function_declarator declarator: (identifier) @name parameters: (parameter_list) @params)) @def
(function_definition declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name parameters: (parameter_list) @params))) @def
`,
},
'.cpp': {
wasm: 'tree-sitter-cpp', lang: 'cpp',
query: `
(function_definition declarator: (function_declarator declarator: (identifier) @name parameters: (parameter_list) @params)) @def
`,
},
};
const registry = new Map<string, GrammarEntry>();
let initialized = false;
/** Extensions (with leading dot) the registry can parse. */
export function supportedExts(): string[] {
return Object.keys(SPECS);
}
/**
* Load grammars once. Idempotent. Fail-open: any error leaves the (possibly
* partial) registry in place and marks init done — callers extract nothing for
* exts that never loaded (non-.py fail-open is empty, not the Python regex).
* `wasmResolve` lets a test force a bad path to exercise the failure branch.
*/
export async function initGrammars(
exts: string[] = supportedExts(),
wasmResolve: (name: string) => string = (name) => require.resolve(`tree-sitter-wasms/out/${name}.wasm`),
init: () => Promise<void> = () => Parser.init(),
): Promise<void> {
if (initialized) return;
try {
await init();
for (const ext of exts) {
const spec = SPECS[ext];
if (!spec) continue;
try {
const language = await Language.load(wasmResolve(spec.wasm));
const query = new Query(language, spec.query);
registry.set(ext, { language, query, lang: spec.lang });
} catch {
// per-ext fail-open: skip this grammar, keep the rest
}
}
} catch {
// global fail-open: registry stays empty → non-.py ingest extracts nothing.
// Log once so an operator scanning a non-Python repo sees the degradation
// (the `initialized` guard means this never retries for the process).
console.warn(
'[ts-grammars] grammar init failed; multi-language ingest disabled for this process ' +
'(non-Python files will yield no blocks). Python ingest is unaffected.',
);
} finally {
initialized = true;
}
}
/** A loaded grammar for `ext`, or undefined (→ caller returns [] for non-.py; .py uses parseFile). */
export function getGrammar(ext: string): GrammarEntry | undefined {
return registry.get(ext);
}
/** Test-only: reset init state + registry so a fresh `initGrammars` runs. */
export function __resetGrammarsForTest(): void {
registry.clear();
initialized = false;
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Multi-language CodeBlock extractor — the tree-sitter replacement for the
* Python-only `parseFile`. Emits the SAME `CodeBlock` shape so the downstream
* security-ranking pipeline (classify/prioritize/reachability) is reused
* verbatim.
*
* Fail-open on every path: `.py` uses the Python regex parser; any other
* language with a missing grammar, parse timeout, or parser error yields `[]`
* (honest absence) — never the Python regex, which would mis-parse brace/`class`
* syntax into corrupt blocks. A hostile input cannot crash or hang a mission —
* the parse is wall-clock time-bounded and every error is caught.
*/
import { Parser, type Node } from 'web-tree-sitter';
import { getGrammar } from './ts-grammars.js';
import { splitParamList } from './param-split.js';
// Deliberate circular import with code-ingest.ts (it imports parseFileMultiLang).
// Safe because both cross-references are used only inside function bodies, never
// at module-evaluation time — do NOT hoist parseFile/CodeBlock to top-level use.
import { parseFile, type CodeBlock } from './code-ingest.js';
/** Hard per-file parse bound (wall-clock ms). A pathological input hits this and fail-opens. */
const PARSE_BUDGET_MS = 5000;
let sharedParser: Parser | null = null;
function getParser(): Parser {
if (!sharedParser) sharedParser = new Parser();
return sharedParser;
}
/**
* Drop the parser after a timeout/error so the next parse starts clean. MUST
* free the native (WASM-backed) parser first: web-tree-sitter has no GC-based
* finalizer, so a dropped-but-not-deleted parser leaks its linear-memory
* allocation. Left unfreed, repeated failures exhaust the shared WASM heap and
* permanently wedge every subsequent parse (they abort, get caught, and yield
* [] forever). Frees the module singleton, which is correct because production
* always uses the default `getParser` (`makeParser` is a test-only seam).
*/
function resetParser(): void {
sharedParser?.delete();
sharedParser = null;
}
function kindOf(nodeType: string): CodeBlock['kind'] {
if (nodeType.includes('class')) return 'class';
if (nodeType.includes('method') || nodeType.includes('constructor')) return 'method';
return 'function';
}
/** Map a captured definition node (+ its name/params captures) to a CodeBlock. */
export function nodeToCodeBlock(
def: Node,
nameNode: Node,
paramsNode: Node | undefined,
path: string,
lang: string,
): CodeBlock {
const name = nameNode.text;
const lineStart = def.startPosition.row + 1; // tree-sitter rows are 0-indexed
const lineEnd = def.endPosition.row + 1;
return {
id: `${path}::${name}@${lineStart}`,
path,
name,
kind: kindOf(def.type),
lineStart,
lineEnd,
params: paramsNode ? splitParamList(paramsNode.text, lang) : [],
decorators: [],
body: def.text,
};
}
/**
* Extract CodeBlocks from one file. `.py` keeps the regex parser (benchmark
* stability); other languages use tree-sitter. Fail-open is language-scoped: the
* Python regex parser only ever runs on `.py`. For any other language a missing
* grammar, parse timeout, or parser error returns `[]` (nothing extracted) —
* never the Python regex, which would mis-parse brace/`class` syntax and emit
* corrupt blocks (wrong line spans, lost methods) rather than honest absence.
*
* Time-bounding note: only the `parse()` call is wall-clock bounded (via the
* `budgetMs` arg, injectable so a test can drive the real cancellation path).
* When called through `ingestRepository` the content is capped at `maxFileBytes`
* (1 MB) upstream, which also bounds the near-linear match/conversion loop and
* the WASM peak-memory demand (see createMultiLangIngestConfig).
*
* ponytail: unify Python onto tree-sitter only if CVE-Zero is re-baselined.
*/
export function parseFileMultiLang(
path: string,
content: string,
ext: string,
makeParser: () => Parser = getParser,
budgetMs: number = PARSE_BUDGET_MS,
): CodeBlock[] {
if (ext === '.py') return parseFile(path, content);
const g = getGrammar(ext);
if (!g) return []; // unsupported / grammar not loaded — honest absence, not Python-regex garbage
let tree: ReturnType<Parser['parse']> | undefined;
try {
const p = makeParser();
p.setLanguage(g.language);
// Bound parse wall-time: progressCallback returning true cancels the parse
// (→ parse returns null). setTimeoutMicros is deprecated/broken in 0.25.
const start = Date.now();
tree = p.parse(content, undefined, {
progressCallback: () => Date.now() - start > budgetMs,
});
if (!tree) {
resetParser(); // parse cancelled (budget exceeded)
return [];
}
const blocks: CodeBlock[] = [];
for (const match of g.query.matches(tree.rootNode)) {
const caps: Record<string, Node> = {};
for (const c of match.captures) caps[c.name] = c.node;
blocks.push(nodeToCodeBlock(caps.def, caps.name, caps.params, path, g.lang));
}
return blocks;
} catch {
// Fail-open. A native WASM OOM abort (huge/dense input near the 2GB heap
// ceiling) surfaces here too; it is caught, not fatal, but leaves the shared
// heap pinned high — the maxFileBytes cap keeps that unreachable in prod.
resetParser();
return []; // any parser error → fail-open (empty, not Python-regex garbage)
} finally {
// Free the native WASM-backed tree. nodeToCodeBlock already copied every
// field out as a plain JS string, so nothing dangles after delete().
// Without this the linear-memory heap grows unbounded and eventually
// poisons the shared parser for the rest of the process.
tree?.delete();
}
}
+32 -12
View File
@@ -8,16 +8,36 @@
* itself makes (worker + orchestrator models). It is otherwise pure of network
* side effects.
*
* SCOPE CAVEAT (inherited from code-ingest): the ingest layer is a PYTHON-ONLY,
* regex "AST-lite" prototype — see recon/code-ingest.ts. It selects which Python
* blocks to analyze first by security exposure; anything non-`.py` or imported
* from outside the scan scope is invisible. Do NOT describe this as "any repo".
* SCOPE CAVEAT (inherited from code-ingest): the ingest layer extracts blocks via
* web-tree-sitter for Python/JS/TS/Go/Java/C/C++ (Python uses the regex "AST-lite"
* path; other languages fail-open to [] — never the Python regex — on a grammar
* miss or parse error) — see recon/code-ingest.ts + recon/ts-parse.ts. It selects which
* blocks to analyze first by security exposure; a language without a bundled grammar,
* or code imported from outside the scan scope, is invisible. Do NOT describe this as
* "any repo" or "any language".
*
* KNOWN v1 EXTRACTION LIMITS (fail-safe — these shapes are silently NOT extracted or
* under-linked, never mis-extracted; all strictly better than the prior Python-only
* ingest, and tracked as follow-ups):
* - JS/TS: only `function`/`method`/`class` declarations are captured. Arrow-function
* and function-expression definitions (`const f = () => …`) are not — idiomatic in
* modern TS, so their sinks may go unranked.
* - C++: only free functions are captured. In-class and out-of-line (`Class::method`)
* member methods are not — a materially narrower C++ story than "full support".
* - Entry-point elevation for non-Python code relies on name heuristics only; no
* language emits `decorators`, so annotation-based routes (Spring `@GetMapping`,
* TS `@Get()`, Express `app.get(...)`) are not elevated to `exposed_externally`.
* - Call-graph/reachability: buildCallGraph strips a block's signature line before
* scanning for callees (kept unchanged for Python-benchmark stability), so a
* SINGLE-LINE non-Python definition (`func F(u) { return G(u) }`) misses the call
* on that line — the block is still extracted and sink-classified, only the edge
* is lost.
*/
import { config } from '../config/index.js';
import {
ingestRepository,
createPythonIngestConfig,
createMultiLangIngestConfig,
packAnalysisUnits,
} from './code-ingest.js';
import { DecompositionOrchestrator } from '../orchestration/index.js';
@@ -218,7 +238,7 @@ export function ingestRepoToSourceContext(
): RepoSourceContext {
const source = resolveRepoSourceForAnalysis(repoPath);
try {
const result = ingestRepository(createPythonIngestConfig(source.repoPath));
const result = ingestRepository(createMultiLangIngestConfig(source.repoPath));
const packed = packAnalysisUnits(result.analysisUnits, tokenBudget);
return {
sourceContext: packed.text,
@@ -302,21 +322,21 @@ export async function runWhiteboxAnalysis(
// 1) ingest source, security-rank the blocks
try {
const result = ingestRepository(createPythonIngestConfig(source.repoPath));
const result = ingestRepository(createMultiLangIngestConfig(source.repoPath));
// 2) pack into a generous source view (orchestrator re-packs per-worker itself)
const packed = packAnalysisUnits(result.analysisUnits, DEFAULT_ANALYSIS_SOURCE_BUDGET);
const sourceContext = packed.text;
// Fail loud ONLY when there is genuinely NO source to analyze: 0 ingestable source files
// (non-Python or empty repo). A valid Python repo that has files but no def/class (a
// module-level-only script) yields 0 blocks -> empty packed text; that used to run the
// orchestrator, so we do NOT regress it into a 500 — gate the throw on the file count, not
// on empty packed text, and let a files>0 repo proceed as before.
// (no supported-language files, or empty repo). A valid repo that has files but no
// def/class/func (a module-level-only script) yields 0 blocks -> empty packed text; that
// used to run the orchestrator, so we do NOT regress it into a 500 — gate the throw on the
// file count, not on empty packed text, and let a files>0 repo proceed as before.
if (result.stats.files === 0) {
throw new Error(
'white-box analysis has no analyzable source: the repo has 0 ingestable source files ' +
'(non-Python, or empty). Point repoPath at a Python codebase.',
'(no supported-language source, or empty). Point repoPath at a code repository.',
);
}
+7
View File
@@ -32,6 +32,7 @@ import { createTargetFromUrl, createTargetFromIP } from './target/index.js';
import type { OperatorArchetype, LLMProvider } from './types/index.js';
import { listOperatorPrompts, setOperatorOverride, resetOperatorOverride, type OperatorOverride } from './operators/index.js';
import { ingestRepoToSourceContext, runWhiteboxAnalysis, resolveRepoSourceForAnalysis, RepoCloneError, RepoPathError } from './recon/whitebox.js';
import { initGrammars } from './recon/ts-grammars.js';
import { redactCredential } from './evidence/index.js';
const execFileAsync = promisify(execFile);
@@ -7789,6 +7790,12 @@ async function startServer() {
await loadPersistedState();
llm = await initLLM();
// Load multi-language grammars once, before any white-box ingest request.
// initGrammars is internally fail-open (never rejects): on failure the
// registry stays empty, Python still ingests via the regex parser, and other
// languages yield no blocks (it logs a warning).
await initGrammars();
// Graceful-shutdown flush: on SIGTERM/SIGINT (Ctrl-C, docker stop, systemctl restart) write
// any pending debounced snapshot before exiting, so the persistState debounce can't lose the
// last <1s of state. Registered here (the server-start path) so it never affects test imports.