Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/code-scanner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ same directories, so the module is a thin host and each one is a **subsystem**:
| --- | --- | --- |
| `deps/` | Dependency advisories, malicious install scripts, lockfile drift | **implemented** ([PRD 0002](../../prd/0002-detect-vulnerable-and-malicious-dependencies-on-running-servers.md)) |
| `secrets/` | Hardcoded credentials, redacted by construction | **implemented** ([PRD 0003](../../prd/0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md)) |
| `sast/` | Source-level vulnerability analysis | not yet built |
| `sast/` | Dangerous source patterns, with stated confidence | **implemented** ([PRD 0004](../../prd/0004-find-dangerous-code-patterns-without-pretending-to-be-a-compiler.md)) |
| `config/` | Misconfiguration checks | not yet built |

```bash
Expand Down
20 changes: 20 additions & 0 deletions modules/code-scanner/mod.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ max_alerts = 25
# truth is "nothing was read". An unexamined project is not a clean one.
fail_on_unparseable = true

# --- sast subsystem: dangerous source patterns (PRD 0004) -----------------
sast_enabled = true

# Third-party, generated and minified code:
# rank_down demote one severity level (default)
# equal treat identically to first-party source
# Never hidden — a vulnerability in vendor/ still executes.
sast_vendored = "rank_down"

# Minimum confidence to report:
# pattern the dangerous construct exists (capped at medium severity)
# contextual the construct sits alongside apparent untrusted input
# This subsystem is line-oriented pattern matching, not data-flow analysis, and
# will not present a bare pattern match as a proven vulnerability. CodeQL and
# Semgrep do the real analysis in CI; this covers source on a running server
# that never passed through the repository.
sast_min_confidence = "pattern"

sast_max_file_bytes = 2000000

# --- secrets subsystem: hardcoded credential detection (PRD 0003) ---------
secrets_enabled = true

Expand Down
178 changes: 178 additions & 0 deletions modules/code-scanner/src/__tests__/sast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest';
import { SAST_RULES, isVendored, scanSource, severityFor } from '../sast/index.js';

const rule = (id: string) => SAST_RULES.find((r) => r.id === id)!;

/**
* THE OVERCLAIMING TEST (PRD 0004 R13).
*
* A regex is not data-flow analysis. The failure mode that would discredit this
* subsystem — and the accurate findings standing next to it — is presenting a
* bare pattern match with the confidence of a proven vulnerability. The cap is
* enforced in `severityFor` rather than per-rule, so no future rule can opt out
* of it by accident, and this test is what keeps that true.
*/
describe('confidence caps severity', () => {
it('never lets a pattern-only match exceed medium', () => {
for (const r of SAST_RULES) {
const capped = severityFor(r, 'pattern');
expect(['low', 'medium'], `${r.id} escalated on a bare pattern`).toContain(capped);
}
});

it('allows the rule severity once there is context', () => {
expect(severityFor(rule('js-eval-call'), 'contextual')).toBe('critical');
expect(severityFor(rule('js-eval-call'), 'pattern')).toBe('medium');
});

it('holds for every finding a real scan produces', () => {
const source = [
'eval(userInput);',
'exec(`convert ${req.body.file} out.png`);',
'const x = 1;',
].join('\n');

for (const finding of scanSource(source).findings) {
if (finding.confidence === 'pattern') {
expect(['low', 'medium'], `${finding.ruleId} overclaimed`).toContain(finding.severity);
}
}
});
});

describe('rule detection', () => {
it('finds dynamic code execution', () => {
expect(scanSource('eval(payload);').findings.map((f) => f.ruleId)).toContain('js-eval-call');
expect(scanSource('const f = new Function("return 1");').findings.map((f) => f.ruleId)).toContain(
'js-eval-call',
);
});

it('finds shell execution only when the command is interpolated', () => {
const interpolated = scanSource('exec(`rm -rf ${dir}`);');
expect(interpolated.findings.map((f) => f.ruleId)).toContain('js-exec-interpolation');

// A fixed command string is not a finding; flagging it is how a scanner
// earns its reputation for noise.
const literal = scanSource("exec('ls -la');");
expect(literal.findings.map((f) => f.ruleId)).not.toContain('js-exec-interpolation');
});

it('finds SQL built by interpolation', () => {
const found = scanSource('db.query(`SELECT * FROM users WHERE id = ${id}`);');
expect(found.findings.map((f) => f.ruleId)).toContain('js-sql-concatenation');
});

it('finds disabled TLS verification', () => {
expect(scanSource('const a = { rejectUnauthorized: false };').findings.map((f) => f.ruleId)).toContain(
'js-tls-verification-disabled',
);
});

it('finds Math.random used for a security value', () => {
const found = scanSource('const token = Math.random().toString(36);');
expect(found.findings.map((f) => f.ruleId)).toContain('js-insecure-randomness');

// Ordinary randomness is not a security bug.
expect(scanSource('const jitter = Math.random() * 100;').findings).toHaveLength(0);
});

it('finds unsafe HTML rendering', () => {
expect(
scanSource('el.innerHTML = userProvided;').findings.map((f) => f.ruleId),
).toContain('js-unsafe-html');
});

it('finds prototype pollution shapes', () => {
expect(scanSource('target["__proto__"] = source;').findings.map((f) => f.ruleId)).toContain(
'js-prototype-pollution',
);
});

it('every rule carries a CWE and a consequence, not just a name', () => {
for (const r of SAST_RULES) {
expect(r.cwe, r.id).toMatch(/^CWE-\d+$/);
expect(r.consequence.length, r.id).toBeGreaterThan(20);
}
});
});

describe('context gating', () => {
it('escalates when untrusted input is on the same line', () => {
const found = scanSource('exec(`convert ${req.body.file} out.png`);');
expect(found.findings[0]?.confidence).toBe('contextual');
expect(found.findings[0]?.severity).toBe('critical');
});

it('stays at pattern confidence without an untrusted source', () => {
const found = scanSource('exec(`convert ${localFile} out.png`);');
expect(found.findings[0]?.confidence).toBe('pattern');
expect(found.findings[0]?.severity).toBe('medium');
});

it('suppresses needsContext rules entirely when there is no context', () => {
// Reading a file from a computed path is just software.
expect(scanSource('readFile(`${dir}/config.json`, cb);').findings).toHaveLength(0);
// With a request field it becomes a traversal candidate.
expect(
scanSource('readFile(`${req.query.path}/config.json`, cb);').findings.map((f) => f.ruleId),
).toContain('js-path-traversal');
});
});

describe('noise control', () => {
it('reports nothing on ordinary application code', () => {
const ordinary = [
'import express from "express";',
'const app = express();',
'app.get("/health", (req, res) => res.json({ ok: true }));',
'const total = items.reduce((a, b) => a + b.price, 0);',
'export default app;',
].join('\n');
expect(scanSource(ordinary).findings).toHaveLength(0);
});

it('ignores comments, including rule documentation', () => {
// Without this the subsystem reports findings about its own rule file.
const commented = ['// eval(payload) is dangerous', ' * exec(`${x}`) runs a shell'].join('\n');
expect(scanSource(commented).findings).toHaveLength(0);
});
});

describe('suppressions', () => {
it('honours an inline disable for the named rule only', () => {
const source = [
'// threatcrush-disable-next-line js-eval-call sandboxed by design',
'eval(sandboxedExpression);',
].join('\n');
const result = scanSource(source);
expect(result.findings).toHaveLength(0);
expect(result.suppressions[0]).toMatchObject({
ruleId: 'js-eval-call',
reason: 'sandboxed by design',
});
});

it('does not suppress a different rule on the same line', () => {
const source = [
'// threatcrush-disable-next-line js-sql-concatenation',
'eval(payload);',
].join('\n');
expect(scanSource(source).findings.map((f) => f.ruleId)).toContain('js-eval-call');
});

it('records a placeholder when no reason is given', () => {
const source = ['// threatcrush-disable-next-line js-eval-call', 'eval(x);'].join('\n');
// Counted and reported: a quiet scan full of suppressions is not clean.
expect(scanSource(source).suppressions[0]?.reason).toBe('(no reason given)');
});
});

describe('vendored classification', () => {
it('recognises third-party and generated locations', () => {
expect(isVendored('/srv/app/vendor/lib.js')).toBe(true);
expect(isVendored('/srv/app/dist/bundle.js')).toBe(true);
expect(isVendored('/srv/app/static/app.min.js')).toBe(true);
expect(isVendored('/srv/app/src/routes/admin.ts')).toBe(false);
});
});
84 changes: 82 additions & 2 deletions modules/code-scanner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*
* deps/ dependency and supply-chain scanning — PRD 0002, implemented
* secrets/ hardcoded credential detection — PRD 0003, implemented
* sast/ source-level vulnerability analysis — not yet built
* sast/ source-level vulnerability analysis — PRD 0004, implemented
* config/ misconfiguration checks — not yet built
*
* PRD 0002 originally proposed dependency scanning as a standalone
Expand Down Expand Up @@ -38,6 +38,12 @@ import {
type DepsResult,
type Finding,
} from './deps/index.js';
import {
SAST_DEFAULTS,
scanSast,
type SastOptions,
type SastResult,
} from './sast/index.js';
import {
SECRETS_DEFAULTS,
scanSecrets,
Expand All @@ -56,6 +62,7 @@ const DEFAULTS = {
export interface ScanResult {
deps: DepsResult | null;
secrets: SecretsResult | null;
sast: SastResult | null;
/** True when any subsystem could not complete — never report "clean". */
incomplete: boolean;
}
Expand Down Expand Up @@ -122,7 +129,8 @@ export default class CodeScannerModule implements ThreatCrushModule {
async scan(): Promise<ScanResult> {
const deps = this.depsEnabled() ? await scanDependencies(this.depsOptions()) : null;
const secrets = this.secretsEnabled() ? await scanSecrets(this.secretsOptions()) : null;
return { deps, secrets, incomplete: Boolean(deps?.incomplete) };
const sast = this.sastEnabled() ? await scanSast(this.sastOptions()) : null;
return { deps, secrets, sast, incomplete: Boolean(deps?.incomplete) };
}

private depsEnabled(): boolean {
Expand All @@ -133,6 +141,23 @@ export default class CodeScannerModule implements ThreatCrushModule {
return this.ctx.config.secrets_enabled !== false;
}

private sastEnabled(): boolean {
return this.ctx.config.sast_enabled !== false;
}

private sastOptions(): SastOptions {
const cfg = this.ctx.config;
return {
paths: this.paths(),
maxDepth: (cfg.max_depth as number | undefined) ?? SAST_DEFAULTS.maxDepth,
maxFileBytes: (cfg.sast_max_file_bytes as number | undefined) ?? SAST_DEFAULTS.maxFileBytes,
vendored: (cfg.sast_vendored as SastOptions['vendored'] | undefined) ?? SAST_DEFAULTS.vendored,
minConfidence:
(cfg.sast_min_confidence as SastOptions['minConfidence'] | undefined) ??
SAST_DEFAULTS.minConfidence,
};
}

private secretsOptions(): SecretsOptions {
const cfg = this.ctx.config;
const allow = Array.isArray(cfg.secrets_allow) ? (cfg.secrets_allow as string[]) : [];
Expand Down Expand Up @@ -174,6 +199,7 @@ export default class CodeScannerModule implements ThreatCrushModule {
const reported = new Set(this.readState<string[]>(STATE_REPORTED, []));

if (result.secrets) this.reportSecrets(result.secrets, floor, maxAlerts, reported);
if (result.sast) this.reportSast(result.sast, floor, maxAlerts, reported);

const deps = result.deps;
if (!deps) {
Expand Down Expand Up @@ -330,6 +356,59 @@ export default class CodeScannerModule implements ThreatCrushModule {
);
}

/**
* Alert on dangerous constructs.
*
* The confidence is in the alert body, not implied by tone: a `pattern`
* finding says the construct exists, and the word "vulnerability" is
* reserved for `contextual` (PRD 0004 R13).
*/
private reportSast(
sast: SastResult,
floor: number,
maxAlerts: number,
reported: Set<string>,
): void {
const notable = sast.findings.filter((f) => severityRank(f.severity) >= floor);

for (const finding of notable
.filter((f) => !reported.has(`sast:${f.file}:${f.ruleId}:${f.line}`))
.slice(0, maxAlerts)) {
const headline = `code-scanner: ${finding.title} in ${finding.file}:${finding.line}`;
this.ctx.emit(
this.event('scan', finding.severity, headline, {
rule: finding.ruleId,
cwe: finding.cwe,
file: finding.file,
line: finding.line,
confidence: finding.confidence,
vendored: finding.vendored,
}),
);
this.ctx.alert({
title: headline,
severity: finding.severity,
body: [
`${finding.file}:${finding.line} ${finding.ruleId} (${finding.cwe})`,
finding.excerpt,
'',
`confidence: ${finding.confidence}`,
finding.consequence,
].join('\n'),
} satisfies Alert);
reported.add(`sast:${finding.file}:${finding.ruleId}:${finding.line}`);
}

// A quiet scan full of suppressions is not a clean one.
this.ctx.logger.info(
'[%s] sast: %d file(s) scanned, %d finding(s), %d suppression(s)',
this.name,
sast.filesScanned,
sast.findings.length,
sast.suppressions.length,
);
}

private paths(): string[] {
const configured = this.ctx.config.paths;
if (Array.isArray(configured) && configured.length > 0) return configured as string[];
Expand Down Expand Up @@ -385,4 +464,5 @@ export function summarize(result: ScanResult): string {
}

export * from './deps/index.js';
export * from './sast/index.js';
export * from './secrets/index.js';
Loading
Loading