Files
t3mp3st/src/recon/whitebox.ts
T
Lyubomir Bozhinov 9020e5fc08 feat(ingest): extract JS/TS arrow and function-expression definitions (#142)
* test(ingest): RED — JS/TS arrow and function-expression definitions are not extracted

Pins issue #141 in both directions: every named-value function form must be
extracted with its name, parameters and span, and the shapes carrying no
name-to-function binding (IIFE, destructured, HOC-wrapped) must stay unmatched.
Also covers the end-to-end consequence — an arrow-bound sink ranking
attack_surface, and an arrow-bound handler becoming an entry point.

Fails on main: the .js/.ts/.tsx def-queries bind declarations only.

Refs #141

* feat(ingest): extract JS/TS arrow and function-expression definitions

The .js/.ts/.tsx def-queries bound declarations only, so every definition bound
to a value produced no CodeBlock and its sinks never reached classification or
ranking. Adds the name-to-function-value shapes to all three queries:

  const f = (a) => {} / function (a) {}   variable_declarator
  const f = a => ...                      un-parenthesized single param
  obj.f = ... / module.exports.handler    assignment_expression
  onmessage = e => ...                    bare-identifier assignment
  { f: (a) => {} }                        pair
  class K { f = (a) => {} }               field_definition / public_field_definition

Query-only — no parser, pipeline or CodeBlock-shape change. nodeToCodeBlock
already handles any @def/@name/@params triple and kindOf maps these to
'function'. Property-assigned and object-literal forms are named by the
property, consistent with how method_definition is named: that is the name
buildCallGraph and the entry-point heuristic match on.

Not matched, by design: HOC-wrapped values (const h = withAuth(fn)) and
anonymous callbacks (app.get('/x', (req,res) => ...)) have no name-to-function
binding, and inventing one is worse than honest absence. whitebox.ts caveat
block updated to match.

Refs #141

* fix(ingest): capture #private class-field arrows; keep same-line block ids distinct

Two defects found by adversarial review of the arrow-extraction change:

- `class C { #handler = (x) => eval(x) }` yielded nothing: a private field's
  name is a `private_property_identifier`, not the `property_identifier` the
  class-field pattern required. A sink in a private handler is exactly as
  dangerous as one in a public handler, so it is captured (name keeps the `#`).

- A block id is `path::name@line`, so two same-named definitions starting on the
  same line collide, and buildCallGraph keys on that id — the two blocks silently
  share one callers/callees entry even when only one carries a sink. Rare for
  declarations, routine once value-bound functions are extracted (minified
  bundles pack many `{ handler: … }` onto one line). The later block is now
  disambiguated by column; the first keeps the plain id, so no existing id
  changes and the Python path is untouched.

Refs #141
2026-08-01 16:05:29 -04:00

365 lines
14 KiB
TypeScript

/**
* whitebox — glue that wires the large-repo analysis pipeline end to end:
*
* code-ingest (SELECT) -> context-pack (BUDGET) -> DecompositionOrchestrator (REASON)
*
* This is the WHITE-BOX path: it reads LOCAL source you own off disk (no network
* target, no probing). The only outbound calls are the LLM calls the orchestrator
* itself makes (worker + orchestrator models). It is otherwise pure of network
* side effects.
*
* 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 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: declarations plus name-bound function values (`const f = () => …`,
* `obj.f = …`, `{ f: … }`, class-field arrows incl. `#private`) are captured.
* Definitions with no name-to-function binding are not: HOC-wrapped values
* (`const h = withAuth(fn)`) and anonymous callbacks
* (`app.get('/x', (req, res) => …)`).
* - 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,
createMultiLangIngestConfig,
packAnalysisUnits,
} from './code-ingest.js';
import { DecompositionOrchestrator } from '../orchestration/index.js';
import type { DecompositionConfig } from '../orchestration/index.js';
import type { LLMProvider } from '../types/index.js';
import { mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { homedir, tmpdir } from 'node:os';
import { join as joinPath, isAbsolute, relative, resolve, sep } from 'node:path';
/**
* Default worker-source token budget when building a source blob directly from a
* repo for a one-off analysis. Generous so the orchestrator's own view is rich;
* it re-packs internally per-worker under its own tighter budget.
*/
const DEFAULT_ANALYSIS_SOURCE_BUDGET = 40000;
/** Result of turning a repo into a packed, security-ordered source blob. */
export interface RepoSourceContext {
/** The packed, LLM-facing source text (security-priority order preserved). */
sourceContext: string;
/** The raw ingest stats (files/blocks + per-exposure counts). */
stats: unknown;
/** How many analysis units made it into the budget. */
includedUnits: number;
/** How many analysis units were dropped for budget. */
droppedUnits: number;
}
/**
* Thrown when a caller-supplied repoPath fails the containment check. The API
* layer maps this to a 400 (bad request), never a 500 or a silent read.
*/
export class RepoPathError extends Error {
constructor(message: string) {
super(message);
this.name = 'RepoPathError';
}
}
export class RepoCloneError extends Error {
constructor(message: string) {
super(message);
this.name = 'RepoCloneError';
}
}
export interface ResolvedRepoSource {
repoPath: string;
source: 'local' | 'github';
cleanup(): void;
}
function isGitHubHttpsRepoUrl(input: string): boolean {
try {
const url = new URL(input);
return url.protocol === 'https:' &&
url.hostname.toLowerCase() === 'github.com' &&
/^\/[^/]+\/[^/]+(?:\.git)?\/?$/.test(url.pathname);
} catch {
return false;
}
}
function getGitHubRepoToken(): string | undefined {
return process.env.T3MP3ST_GITHUB_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim();
}
export function cloneGitHubRepoForAnalysis(repoUrl: string): ResolvedRepoSource {
if (!isGitHubHttpsRepoUrl(repoUrl)) {
throw new RepoCloneError('repoUrl must be an https://github.com/<owner>/<repo> URL');
}
const token = getGitHubRepoToken();
if (!token) {
throw new RepoCloneError('private GitHub repo scans require T3MP3ST_GITHUB_TOKEN or GITHUB_TOKEN');
}
const tempRoot = mkdtempSync(joinPath(tmpdir(), 't3mp3st-repo-'));
const cloneDir = joinPath(tempRoot, 'repo');
const askPassPath = joinPath(tempRoot, 'git-askpass.sh');
try {
writeFileSync(
askPassPath,
[
'#!/bin/sh',
'case "$1" in',
"*Username*) printf '%s\\n' 'x-access-token' ;;",
'*) printf "%s\\n" "$T3MP3ST_GIT_TOKEN" ;;',
'esac',
'',
].join('\n'),
{ mode: 0o700 },
);
execFileSync('git', [
'-c', 'credential.helper=',
'clone',
'--depth', '1',
'--filter=blob:none',
repoUrl,
cloneDir,
], {
stdio: ['ignore', 'ignore', 'pipe'],
env: {
...process.env,
GIT_ASKPASS: askPassPath,
GIT_TERMINAL_PROMPT: '0',
T3MP3ST_GIT_TOKEN: token,
},
});
return {
repoPath: realpathSync(cloneDir),
source: 'github',
cleanup: () => { rmSync(tempRoot, { recursive: true, force: true }); },
};
} catch (error) {
rmSync(tempRoot, { recursive: true, force: true });
const message = error instanceof Error ? error.message.replaceAll(token, '[REDACTED]') : 'git clone failed';
throw new RepoCloneError(`GitHub repo clone failed: ${message}`);
}
}
export function resolveRepoSourceForAnalysis(input: unknown): ResolvedRepoSource {
if (typeof input === 'string' && isGitHubHttpsRepoUrl(input.trim())) {
return cloneGitHubRepoForAnalysis(input.trim());
}
return {
repoPath: resolveContainedRepoPath(input),
source: 'local',
cleanup: () => { /* local repos are caller-owned; nothing to remove */ },
};
}
/**
* B-03 — path containment for the caller-supplied repoPath.
*
* The white-box path reads source off disk from an operator-supplied path. On a
* localhost tool the operator legitimately analyzes any repo THEY own, but an
* unbounded path is an arbitrary-directory read primitive (repoPath=/etc, another
* user's home, ~/.ssh) reachable by anything that can reach the API. We therefore:
* 1. canonicalize with realpath — collapses '..' AND resolves symlinks, so
* neither traversal nor a symlink planted inside the root can escape after
* the check (we validate the resolved target, not the string),
* 2. require it to exist AND be a directory,
* 3. confine it to an allowed ROOT — the operator's home by default, overridable
* with T3MP3ST_REPO_ROOT for repos kept elsewhere (/opt, /srv, a volume).
* Returns the canonical, contained absolute path to hand to the reader.
*/
export function resolveContainedRepoPath(input: unknown): string {
if (typeof input !== 'string' || !input.trim()) {
throw new RepoPathError('repoPath is required and must be a non-empty string');
}
let root: string;
try {
root = realpathSync(resolve(process.env.T3MP3ST_REPO_ROOT || homedir()));
} catch {
throw new RepoPathError('configured T3MP3ST_REPO_ROOT does not resolve to an existing path');
}
let real: string;
try {
real = realpathSync(resolve(input));
} catch {
throw new RepoPathError('repoPath does not resolve to an existing path');
}
try {
if (!statSync(real).isDirectory()) {
throw new RepoPathError('repoPath must be a directory');
}
} catch (e) {
if (e instanceof RepoPathError) throw e;
// statSync can throw (e.g. a TOCTOU deletion between realpath and here) — normalize
// to RepoPathError so the API layer returns a clean 400, never an unhandled rejection.
throw new RepoPathError('repoPath is not accessible');
}
// relative(root, real) is '' when equal, starts with '..' (as a whole segment)
// when real is outside root, or is absolute when they share no root (Windows).
const rel = relative(root, real);
if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
throw new RepoPathError(
`repoPath resolves outside the allowed root (${root}). Set T3MP3ST_REPO_ROOT to analyze a repo kept elsewhere.`,
);
}
return real;
}
/**
* Ingest a local repo and pack it into a single security-ordered source blob
* suitable for handing to `TempestCommand.setWhiteboxSource` (or any consumer
* that wants the highest-priority code first).
*
* @param repoPath Absolute path to a LOCAL repo you own, or a GitHub HTTPS repo URL with token auth.
* @param tokenBudget Packing budget in tokens (default 24000).
*/
export function ingestRepoToSourceContext(
repoPath: string,
tokenBudget = 24000,
): RepoSourceContext {
const source = resolveRepoSourceForAnalysis(repoPath);
try {
const result = ingestRepository(createMultiLangIngestConfig(source.repoPath));
const packed = packAnalysisUnits(result.analysisUnits, tokenBudget);
return {
sourceContext: packed.text,
stats: result.stats,
includedUnits: packed.includedUnits.length,
droppedUnits: packed.droppedUnits.length,
};
} finally {
source.cleanup();
}
}
/** Options for a full white-box decomposition analysis run. */
export interface WhiteboxAnalysisOptions {
/** Absolute path to a LOCAL repo you own, or a GitHub HTTPS repo URL with token auth. */
repoPath: string;
/** The offensive objective — only the orchestrator model sees this. */
objective: string;
/** Max decomposition rounds. Default 5 (orchestrator's own default). */
maxRounds?: number;
}
/** Result of a full white-box decomposition analysis run. */
export interface WhiteboxAnalysisResult {
/** Raw ingest stats (files/blocks + per-exposure counts). */
ingestStats: unknown;
/** ids of blocks detected as entry points. */
entryPoints: string[];
/** Total analysis units the ingest produced (before packing). */
unitCount: number;
/** The orchestrator's DecompositionResult. */
decomposition: unknown;
}
/**
* Build the DecompositionOrchestrator config the same way scripts/decompose.mjs
* does: an orchestrator model (opus) that holds the objective + a worker model
* (fable-5) that answers benign analytical sub-queries. Model/provider selection
* flows through the shared ConfigManager (env keys, fallback chain, etc.) exactly
* like the rest of the server.
*
* Env overrides (optional, mirroring the CLI flags):
* TEMPEST_ORCHESTRATOR_PROVIDER / TEMPEST_ORCHESTRATOR_MODEL
* TEMPEST_WORKER_PROVIDER / TEMPEST_WORKER_MODEL
*/
function buildDecompositionConfig(): DecompositionConfig {
const orchestratorProvider =
(process.env.TEMPEST_ORCHESTRATOR_PROVIDER as LLMProvider) || 'openrouter';
const workerProvider =
(process.env.TEMPEST_WORKER_PROVIDER as LLMProvider) || 'openrouter';
const orchestratorModel = config.getLLMConfig(
orchestratorProvider,
process.env.TEMPEST_ORCHESTRATOR_MODEL || 'anthropic/claude-opus-4.8',
);
const workerModel = config.getLLMConfig(
workerProvider,
process.env.TEMPEST_WORKER_MODEL || 'anthropic/claude-fable-5',
);
return {
orchestratorModel,
workerModel,
maxQueriesPerRound: 8,
parallelWorkers: true,
};
}
/**
* Full white-box analysis: ingest a LOCAL repo, build a generous security-ordered
* source view, then run the multi-model DecompositionOrchestrator against the
* objective. LLM-HEAVY — this may take a while (multiple orchestrator +
* worker round-trips). Reads LOCAL source only; the only network calls are the
* orchestrator's own LLM calls.
*
* @returns ingest telemetry + the orchestrator's DecompositionResult.
*/
export async function runWhiteboxAnalysis(
opts: WhiteboxAnalysisOptions,
): Promise<WhiteboxAnalysisResult> {
const { repoPath, objective, maxRounds } = opts;
const source = resolveRepoSourceForAnalysis(repoPath);
// 1) ingest source, security-rank the blocks
try {
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
// (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 ' +
'(no supported-language source, or empty). Point repoPath at a code repository.',
);
}
// 3) construct the orchestrator with a sane default config (mirrors decompose.mjs)
const decompConfig = buildDecompositionConfig();
if (typeof maxRounds === 'number' && maxRounds > 0) {
decompConfig.maxRounds = maxRounds;
}
const orch = new DecompositionOrchestrator(decompConfig);
// 4) run the decomposition loop (LLM calls happen HERE)
const decomposition = await orch.run(objective, sourceContext);
return {
ingestStats: result.stats,
entryPoints: result.entryPoints,
unitCount: result.analysisUnits.length,
decomposition,
};
} finally {
source.cleanup();
}
}