diff --git a/apps/cli/README.md b/apps/cli/README.md index 8c0e0ac..97f6a63 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -94,6 +94,7 @@ threatcrush # Get started threatcrush monitor # Real-time security monitoring (all ports) threatcrush tui # Interactive dashboard (htop for security) threatcrush scan ./src # Scan code for vulnerabilities & secrets +threatcrush scan . --format sarif --output out.sarif --fail-on critical,high threatcrush pentest URL # Penetration test a URL/API threatcrush init # Auto-detect services, generate config threatcrush status # Show daemon status & loaded modules diff --git a/apps/cli/package.json b/apps/cli/package.json index e569209..8916137 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@profullstack/threatcrush", "version": "0.2.2", - "description": "All-in-one security agent daemon — monitor, detect, scan, and protect servers in real-time", + "description": "All-in-one security agent daemon \u2014 monitor, detect, scan, and protect servers in real-time", "bin": { "threatcrush": "./dist/index.js" }, @@ -11,7 +11,9 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [ "security", @@ -52,7 +54,8 @@ "@types/nodemailer": "^6.4.17", "@types/react": "^18.3.12", "tsup": "^8.5.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.0.0" }, "repository": { "type": "git", diff --git a/apps/cli/src/commands/scan.ts b/apps/cli/src/commands/scan.ts index 8b22db7..f54241f 100644 --- a/apps/cli/src/commands/scan.ts +++ b/apps/cli/src/commands/scan.ts @@ -1,396 +1,346 @@ -import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'; -import { join, relative, extname } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import chalk from 'chalk'; import ora from 'ora'; import { banner, logger } from '../core/logger.js'; import type { RunResult, StructuredFinding } from '../core/run-result.js'; import { summarize } from '../core/run-result.js'; - -interface ScanFinding { - file: string; - line: number; - type: string; - severity: 'low' | 'medium' | 'high' | 'critical'; - message: string; - snippet: string; +import { scanDependencies } from '../scan/dependencies.js'; +import { meetsFailThreshold, scanPath } from '../scan/engine.js'; +import { buildSarif } from '../scan/sarif.js'; +import type { ScanFinding, Severity } from '../scan/types.js'; +import { SEVERITY_ORDER } from '../scan/types.js'; + +export type ScanFormat = 'text' | 'json' | 'sarif'; + +export interface ScanCommandOptions { + /** + * `text` for humans, `sarif` for the Security tab and coverage validators, + * `json` for anything else. Non-text formats put the payload on stdout (or + * `--output`) and every human line on stderr, so + * `threatcrush scan --format sarif > out.sarif` produces a valid file. + */ + format?: ScanFormat; + /** Write the machine-readable payload here instead of stdout. */ + output?: string; + /** Exit non-zero when a finding at or above one of these severities exists. */ + failOn?: readonly Severity[]; + /** Prefix prepended to SARIF URIs when the scan root is not the repo root. */ + pathPrefix?: string; + /** Print the paths that could not be read, not just the count. */ + verbose?: boolean; + /** + * Query OSV.dev for advisories against the resolved lockfile versions. + * Off by default in the CLI because it is the only part of a scan that + * needs the network — a CI job should opt in deliberately rather than + * discover the dependency mid-run. + */ + dependencies?: boolean; } -// Secret patterns -const SECRET_PATTERNS: Array<{ - name: string; - pattern: RegExp; - severity: 'medium' | 'high' | 'critical'; -}> = [ - { name: 'AWS Access Key', pattern: /(?:AKIA[0-9A-Z]{16})/g, severity: 'critical' }, - { name: 'AWS Secret Key', pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi, severity: 'critical' }, - { name: 'GitHub Token', pattern: /(?:ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82})/g, severity: 'critical' }, - { name: 'Generic API Key', pattern: /(?:api[_-]?key|apikey)\s*[=:]\s*['"]([A-Za-z0-9\-_]{20,})['"]?/gi, severity: 'high' }, - { name: 'Generic Secret', pattern: /(?:secret|password|passwd|pwd)\s*[=:]\s*['"]([^'"]{8,})['"]?/gi, severity: 'high' }, - { name: 'Private Key', pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/g, severity: 'critical' }, - { name: 'JWT Token', pattern: /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*/g, severity: 'high' }, - { name: 'Slack Token', pattern: /xox[bpors]-[A-Za-z0-9-]{10,}/g, severity: 'critical' }, - { name: 'Stripe Key', pattern: /(?:sk_live_|pk_live_|sk_test_|pk_test_)[A-Za-z0-9]{20,}/g, severity: 'critical' }, - { name: 'Database URL', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s'"]+/gi, severity: 'high' }, - { name: 'Bearer Token', pattern: /Bearer\s+[A-Za-z0-9\-_\.]{20,}/g, severity: 'medium' }, - { name: 'Hex Token (32+)', pattern: /(?:token|key|secret|auth)\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/gi, severity: 'medium' }, -]; - -// File permission / misconfig checks -const MISCONFIG_FILES = [ - { pattern: '.env', message: '.env file found — may contain secrets' }, - { pattern: '.env.local', message: '.env.local file found — may contain secrets' }, - { pattern: '.env.production', message: '.env.production file found — may contain secrets' }, - { pattern: 'id_rsa', message: 'Private SSH key found' }, - { pattern: 'id_ed25519', message: 'Private SSH key found' }, - { pattern: '.pem', message: 'PEM certificate/key file found' }, - { pattern: '.p12', message: 'PKCS#12 keystore found' }, - { pattern: '.keystore', message: 'Keystore file found' }, -]; - -const SKIP_DIRS = new Set([ - 'node_modules', '.git', '.next', 'dist', 'build', '__pycache__', - '.venv', 'vendor', '.terraform', 'coverage', '.cache', -]); +interface ScanOutcome { + result: RunResult; + findings: ScanFinding[]; + filesScanned: number; + unreadable: string[]; + suppressed: number; + root: string; +} -const SCAN_EXTENSIONS = new Set([ - '.ts', '.js', '.tsx', '.jsx', '.py', '.rb', '.go', '.java', - '.php', '.rs', '.c', '.cpp', '.h', '.yml', '.yaml', '.json', - '.toml', '.ini', '.cfg', '.conf', '.env', '.sh', '.bash', - '.tf', '.hcl', '.xml', '.properties', '.gradle', -]); +function readVersion(): string { + for (const candidate of [ + join(__dirname, '..', 'package.json'), + join(__dirname, '..', '..', 'package.json'), + ]) { + try { + return ( + (JSON.parse(readFileSync(candidate, 'utf-8')) as { version?: string }).version ?? '0.0.0' + ); + } catch { + /* try the next candidate */ + } + } + return '0.0.0'; +} -export async function runScan(targetPath: string): Promise { - const findings: ScanFinding[] = []; - try { - scanDirectory(targetPath, targetPath, findings, () => {}); - // Dependency CVE scan (PRD 06) - const depFindings = await scanDependencies(targetPath); - findings.push(...depFindings); - } catch (err) { - return { - type: 'scan', - target: targetPath, - findings: [], - severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, - summary: `Scan failed: ${(err as Error).message}`, - error: (err as Error).message, - }; +const PKG_VERSION = readVersion(); + +/** Parse `--fail-on critical,high` into severities, rejecting unknown names. */ +export function parseFailOn(raw: string | undefined): Severity[] { + if (!raw) return []; + const requested = raw + .split(',') + .map((part) => part.trim().toLowerCase()) + .filter(Boolean); + + const unknown = requested.filter((name) => !SEVERITY_ORDER.includes(name as Severity)); + if (unknown.length > 0) { + throw new Error( + `unknown severity in --fail-on: ${unknown.join(', ')} (expected ${SEVERITY_ORDER.join(', ')})`, + ); } + return requested as Severity[]; +} - const structured: StructuredFinding[] = findings.map((f) => ({ - type: f.type, - severity: f.severity, - message: f.message, - location: `${f.file}:${f.line}`, - details: { file: f.file, line: f.line, snippet: f.snippet }, +function toRunResult( + targetPath: string, + findings: readonly ScanFinding[], + filesScanned: number, +): RunResult { + const structured: StructuredFinding[] = findings.map((finding) => ({ + type: finding.title, + severity: finding.severity, + message: finding.message, + location: `${finding.file}:${finding.line}`, + details: { + file: finding.file, + line: finding.line, + snippet: finding.excerpt, + ruleId: finding.ruleId, + confidence: finding.confidence, + ...(finding.cwe ? { cwe: finding.cwe } : {}), + }, })); - const summary = summarize(structured); + const counts = summarize(structured); return { type: 'scan', target: targetPath, findings: structured, - severity_summary: summary, - summary: findings.length === 0 - ? 'No security issues found' - : `${findings.length} issue(s): ${summary.critical}C ${summary.high}H ${summary.medium}M ${summary.low}L`, + severity_summary: counts, + summary: + findings.length === 0 + ? `No issues found across ${filesScanned} files` + : `${findings.length} issue(s): ${counts.critical}C ${counts.high}H ${counts.medium}M ${counts.low}L`, }; } -export async function scanCommand(targetPath: string): Promise { - banner(); - logger.info(`Scanning ${chalk.white(targetPath)} for security issues...\n`); - - const spinner = ora({ text: 'Scanning files...', color: 'green' }).start(); - const findings: ScanFinding[] = []; - let filesScanned = 0; - - try { - scanDirectory(targetPath, targetPath, findings, () => { - filesScanned++; - spinner.text = `Scanning files... (${filesScanned} files)`; - }); - } catch (err) { - spinner.fail(`Scan failed: ${(err as Error).message}`); - return { - type: 'scan', - target: targetPath, - findings: [], - severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, - summary: `Scan failed: ${(err as Error).message}`, - error: (err as Error).message, - }; - } - - spinner.succeed(`Scanned ${filesScanned} files\n`); - - const structured: StructuredFinding[] = findings.map((f) => ({ - type: f.type, - severity: f.severity, - message: f.message, - location: `${f.file}:${f.line}`, - details: { file: f.file, line: f.line, snippet: f.snippet }, - })); - const sevCounts = summarize(structured); - - // Print results - if (findings.length === 0) { - console.log(chalk.green.bold(' ✓ No security issues found!')); - console.log(); - return { - type: 'scan', - target: targetPath, - findings: [], - severity_summary: sevCounts, - summary: `No issues found across ${filesScanned} files`, - }; - } - - // Group by severity - const critical = findings.filter((f) => f.severity === 'critical'); - const high = findings.filter((f) => f.severity === 'high'); - const medium = findings.filter((f) => f.severity === 'medium'); - const low = findings.filter((f) => f.severity === 'low'); - - console.log(chalk.white.bold(' Scan Results')); - console.log(chalk.gray(' ' + '─'.repeat(70))); - console.log( - ` ${chalk.red.bold(critical.length + ' critical')} ` + - `${chalk.red(high.length + ' high')} ` + - `${chalk.yellow(medium.length + ' medium')} ` + - `${chalk.gray(low.length + ' low')}`, - ); - console.log(chalk.gray(' ' + '─'.repeat(70))); - console.log(); - - const allFindings = [...critical, ...high, ...medium, ...low]; - for (const finding of allFindings) { - const sev = - finding.severity === 'critical' ? chalk.bgRed.white.bold(` ${finding.severity.toUpperCase()} `) : - finding.severity === 'high' ? chalk.red(`[${finding.severity.toUpperCase()}]`) : - finding.severity === 'medium' ? chalk.yellow(`[${finding.severity.toUpperCase()}]`) : - chalk.gray(`[${finding.severity.toUpperCase()}]`); - - console.log(` ${sev} ${chalk.white.bold(finding.type)}`); - console.log(` ${chalk.gray('File:')} ${chalk.cyan(finding.file)}:${chalk.yellow(String(finding.line))}`); - console.log(` ${chalk.gray('Info:')} ${finding.message}`); - if (finding.snippet) { - // Redact the actual secret value - const redacted = finding.snippet.replace( - /(['"]?)([A-Za-z0-9+/=\-_]{16,})(['"]?)/g, - '$1' + chalk.red('*'.repeat(16)) + '$3', - ); - console.log(` ${chalk.gray('Code:')} ${redacted.trim()}`); - } - console.log(); - } - - console.log(chalk.gray(' ' + '─'.repeat(70))); - console.log(` ${chalk.white.bold(`${findings.length} issue(s) found`)} across ${filesScanned} files`); - console.log(); - +function failedResult(targetPath: string, message: string): RunResult { return { type: 'scan', target: targetPath, - findings: structured, - severity_summary: sevCounts, - summary: `${findings.length} issue(s): ${sevCounts.critical}C ${sevCounts.high}H ${sevCounts.medium}M ${sevCounts.low}L`, + findings: [], + severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, + summary: `Scan failed: ${message}`, + error: message, }; } -function scanDirectory( - basePath: string, - currentPath: string, - findings: ScanFinding[], - onFile: () => void, -): void { - let entries; +/** + * Non-interactive scan used by the daemon and the runs worker. + * + * Includes dependency advisories, as it always has — the daemon runs on a + * schedule against a server it can reach the network from, and an advisory + * published since the last run is the main thing that changed. + */ +export async function runScan(targetPath: string): Promise { try { - entries = readdirSync(currentPath, { withFileTypes: true }); - } catch { - return; // Permission denied or similar + const report = scanPath(targetPath); + const findings = [...report.findings, ...(await scanDependencies(targetPath))]; + return toRunResult(targetPath, findings, report.filesScanned); + } catch (err) { + return failedResult(targetPath, (err as Error).message); } +} - for (const entry of entries) { - const fullPath = join(currentPath, entry.name); - - if (entry.isDirectory()) { - if (SKIP_DIRS.has(entry.name)) continue; - scanDirectory(basePath, fullPath, findings, onFile); - continue; - } - - if (!entry.isFile()) continue; - - // Check for misconfig files - for (const mc of MISCONFIG_FILES) { - if (entry.name === mc.pattern || entry.name.endsWith(mc.pattern)) { - findings.push({ - file: relative(basePath, fullPath), - line: 0, - type: 'Sensitive File', - severity: 'high', - message: mc.message, - snippet: '', - }); - } - } - - // Only scan text files with known extensions - const ext = extname(entry.name).toLowerCase(); - if (!SCAN_EXTENSIONS.has(ext) && !entry.name.startsWith('.env')) continue; +export async function scanCommand( + targetPath: string, + options: ScanCommandOptions = {}, +): Promise { + const format = options.format ?? 'text'; + const machineReadable = format !== 'text'; + // Human output goes to stderr whenever stdout is carrying a payload. A + // banner in the middle of a SARIF document is exactly how "the scan worked + // but the pipeline reports zero findings" happens. + const say = machineReadable + ? (line: string) => process.stderr.write(`${line}\n`) + : (line: string) => process.stdout.write(`${line}\n`); + + if (!existsSync(targetPath)) { + say(chalk.red(`Scan target does not exist: ${targetPath}`)); + process.exitCode = 2; + return failedResult(targetPath, `no such path: ${targetPath}`); + } - // Skip large files - try { - const stat = statSync(fullPath); - if (stat.size > 1024 * 1024) continue; // Skip >1MB - } catch { - continue; - } + if (!machineReadable) { + banner(); + logger.info(`Scanning ${chalk.white(targetPath)} for security issues...\n`); + } - onFile(); + const spinner = machineReadable ? null : ora({ text: 'Scanning files...', color: 'green' }).start(); - // Scan file content - let content: string; - try { - content = readFileSync(fullPath, 'utf-8'); - } catch { - continue; + let outcome: ScanOutcome; + try { + let seen = 0; + const report = scanPath(targetPath, { + onFile: () => { + seen += 1; + if (spinner) spinner.text = `Scanning files... (${seen} files)`; + }, + }); + if (options.dependencies) { + if (spinner) spinner.text = 'Querying OSV.dev for dependency advisories...'; + report.findings.push(...(await scanDependencies(targetPath))); } + outcome = { + result: toRunResult(targetPath, report.findings, report.filesScanned), + findings: report.findings, + filesScanned: report.filesScanned, + unreadable: report.unreadable, + suppressed: report.suppressed, + root: report.root, + }; + } catch (err) { + spinner?.fail(`Scan failed: ${(err as Error).message}`); + process.exitCode = 2; + return failedResult(targetPath, (err as Error).message); + } - const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - // Skip comments - if (line.trim().startsWith('//') && !line.includes('password') && !line.includes('secret')) continue; - - for (const pattern of SECRET_PATTERNS) { - pattern.pattern.lastIndex = 0; - if (pattern.pattern.test(line)) { - findings.push({ - file: relative(basePath, fullPath), - line: i + 1, - type: pattern.name, - severity: pattern.severity, - message: `Possible ${pattern.name} detected`, - snippet: line.length > 120 ? line.slice(0, 120) + '...' : line, - }); - } - } + spinner?.succeed(`Scanned ${outcome.filesScanned} files\n`); + + if (outcome.unreadable.length > 0) { + // Surfaced, never swallowed. An unexamined file is not a clean one, and a + // scanner that hides read failures reports silence as safety. + say( + chalk.yellow( + ` ! ${outcome.unreadable.length} path(s) could not be read and were NOT scanned`, + ), + ); + if (options.verbose) { + for (const path of outcome.unreadable) say(chalk.gray(` ${path}`)); } } -} - -// ─── Dependency CVE Scanner (PRD 06) ─── - -interface OsvVulnerability { - id: string; - summary: string; - details?: string; - severity?: Array<{ type: string; score: string }>; - affected?: Array<{ package: { name: string; ecosystem: string }; ranges?: Array<{ events: Array<{ introduced?: string; fixed?: string }> }> }>; -} -async function scanDependencies(targetPath: string): Promise { - const findings: ScanFinding[] = []; - - // Check for package-lock.json or package.json - const lockfiles = [ - { file: 'package-lock.json', ecosystem: 'npm' }, - { file: 'pnpm-lock.yaml', ecosystem: 'npm' }, - { file: 'yarn.lock', ecosystem: 'npm' }, - { file: 'requirements.txt', ecosystem: 'PyPI' }, - { file: 'Pipfile.lock', ecosystem: 'PyPI' }, - ]; - - for (const { file, ecosystem } of lockfiles) { - const lockPath = join(targetPath, file); - if (!existsSync(lockPath)) continue; + if (outcome.suppressed > 0) { + say( + chalk.gray( + ` · ${outcome.suppressed} finding(s) suppressed by inline threatcrush-disable comments`, + ), + ); + } - try { - const deps = parseDependencies(lockPath, file, ecosystem); - for (const dep of deps.slice(0, 50)) { // Limit to 50 deps to avoid API flooding - try { - const vulns = await queryOsv(dep.name, dep.version, ecosystem); - for (const vuln of vulns) { - const cvssScore = vuln.severity?.find(s => s.type === 'CVSS_V3')?.score; - const severity: ScanFinding['severity'] = cvssScore - ? (parseFloat(cvssScore) >= 9 ? 'critical' : parseFloat(cvssScore) >= 7 ? 'high' : parseFloat(cvssScore) >= 4 ? 'medium' : 'low') - : 'medium'; + if (machineReadable) { + emitMachineReadable(format, outcome, targetPath, options, say); + } else { + printHuman(outcome); + } - findings.push({ - file: file, - line: 0, - type: 'Dependency CVE', - severity, - message: `${dep.name}@${dep.version}: ${vuln.summary || vuln.id}`, - snippet: `${vuln.id}${cvssScore ? ` (CVSS: ${cvssScore})` : ''}`, - }); - } - } catch { - // Skip individual dep query failures - } - } - } catch { - // Skip lockfile parse failures - } + const failOn = options.failOn ?? []; + if (meetsFailThreshold(outcome.findings, failOn)) { + say( + chalk.red( + `\n ✗ findings at or above ${[...failOn].join('/')} — failing as requested by --fail-on`, + ), + ); + process.exitCode = 1; } - return findings; + return outcome.result; } -function parseDependencies(lockPath: string, filename: string, ecosystem: string): Array<{ name: string; version: string }> { - const deps: Array<{ name: string; version: string }> = []; - - if (filename === 'package-lock.json') { - try { - const lock = JSON.parse(readFileSync(lockPath, 'utf-8')); - const packages = lock.packages || lock.dependencies || {}; - for (const [key, value] of Object.entries(packages)) { - const name = key.replace(/^node_modules\//, ''); - const version = (value as any).version; - if (name && version && !name.startsWith('.')) { - deps.push({ name, version }); - } - } - } catch { /* skip */ } - } else if (filename === 'requirements.txt') { - try { - const content = readFileSync(lockPath, 'utf-8'); - for (const line of content.split('\n')) { - const match = line.match(/^([a-zA-Z0-9_.-]+)==([0-9.]+)/); - if (match) deps.push({ name: match[1], version: match[2] }); - } - } catch { /* skip */ } +function emitMachineReadable( + format: ScanFormat, + outcome: ScanOutcome, + targetPath: string, + options: ScanCommandOptions, + say: (line: string) => void, +): void { + const payload = + format === 'sarif' + ? buildSarif(outcome.findings, { + toolVersion: PKG_VERSION, + pathPrefix: options.pathPrefix, + // Relative to the working directory, NOT the scan root. `threatcrush + // scan vulns` from a repo root must emit `vulns/secrets/x.env`, not + // `secrets/x.env` — the second form matches nothing in the + // consumer's view of the repository, so every finding lands + // "outside" whatever it scoped to and a working scan reads as 0%. + // This is the single most expensive mistake in the whole pipeline + // and it fails silently. `--path-prefix` covers the remaining case: + // a scan run from inside the subdirectory it is scanning. + base: process.cwd(), + root: resolve(outcome.root), + }) + : { + tool: 'threatcrush', + version: PKG_VERSION, + target: targetPath, + filesScanned: outcome.filesScanned, + unreadable: outcome.unreadable, + suppressed: outcome.suppressed, + summary: outcome.result.severity_summary, + findings: outcome.findings, + }; + + const serialized = `${JSON.stringify(payload, null, 2)}\n`; + + if (options.output) { + mkdirSync(dirname(resolve(options.output)), { recursive: true }); + writeFileSync(options.output, serialized, 'utf-8'); + say( + chalk.gray( + ` ${format.toUpperCase()} written to ${options.output} (${outcome.findings.length} finding(s))`, + ), + ); + return; } - - return deps; + process.stdout.write(serialized); } -// Validate package name to prevent injection in OSV API queries -function isValidPackageName(name: string): boolean { - return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name); -} +function printHuman(outcome: ScanOutcome): void { + const { findings, filesScanned } = outcome; -function isValidVersion(version: string): boolean { - return /^[0-9a-zA-Z._\-+]{1,50}$/.test(version); -} + if (findings.length === 0) { + console.log(chalk.green.bold(' ✓ No security issues found!')); + console.log(); + return; + } -async function queryOsv(name: string, version: string, ecosystem: string): Promise { - // Sanitize inputs before sending to external API - if (!isValidPackageName(name) || !isValidVersion(version)) return []; + const counts = outcome.result.severity_summary; + console.log(chalk.white.bold(' Scan Results')); + console.log(chalk.gray(' ' + '─'.repeat(70))); + console.log( + ` ${chalk.red.bold(counts.critical + ' critical')} ` + + `${chalk.red(counts.high + ' high')} ` + + `${chalk.yellow(counts.medium + ' medium')} ` + + `${chalk.gray(counts.low + ' low')}`, + ); + console.log(chalk.gray(' ' + '─'.repeat(70))); + console.log(); - try { - const res = await fetch('https://api.osv.dev/v1/query', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ package: { name, ecosystem }, version }), - signal: AbortSignal.timeout(5000), - }); - if (!res.ok) return []; - const data = await res.json() as { vulns?: OsvVulnerability[] }; - return data.vulns || []; - } catch { - return []; + for (const finding of findings) { + const label = finding.severity.toUpperCase(); + const badge = + finding.severity === 'critical' + ? chalk.bgRed.white.bold(` ${label} `) + : finding.severity === 'high' + ? chalk.red(`[${label}]`) + : finding.severity === 'medium' + ? chalk.yellow(`[${label}]`) + : chalk.gray(`[${label}]`); + + console.log(` ${badge} ${chalk.white.bold(finding.title)}`); + console.log( + ` ${chalk.gray('File:')} ${chalk.cyan(finding.file)}:${chalk.yellow(String(finding.line))}`, + ); + console.log(` ${chalk.gray('Info:')} ${finding.message}`); + if (finding.consequence) { + console.log(` ${chalk.gray('Risk:')} ${chalk.dim(finding.consequence)}`); + } + if (finding.excerpt) { + console.log(` ${chalk.gray('Code:')} ${finding.excerpt}`); + } + console.log( + ` ${chalk.gray('Rule:')} ${chalk.dim(finding.ruleId)}` + + (finding.cwe ? chalk.dim(` · ${finding.cwe}`) : '') + + chalk.dim(` · confidence: ${finding.confidence}`), + ); + console.log(); } + + console.log(chalk.gray(' ' + '─'.repeat(70))); + console.log( + ` ${chalk.white.bold(`${findings.length} issue(s) found`)} across ${filesScanned} files`, + ); + console.log(); } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index fa8a13e..dc64921 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -8,7 +8,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { monitorCommand } from "./commands/monitor.js"; -import { scanCommand } from "./commands/scan.js"; +import { parseFailOn, scanCommand, type ScanFormat } from "./commands/scan.js"; import { initCommand } from "./commands/init.js"; import { statusCommand } from "./commands/status.js"; import { modulesCommand } from "./commands/modules.js"; @@ -243,8 +243,48 @@ program .command("scan") .description("Scan codebase for vulnerabilities and secrets") .argument("[path]", "Path to scan", ".") - .action(async (targetPath: string) => { - await scanCommand(targetPath); + .option("-f, --format ", "output format: text, json, or sarif", "text") + .option("-o, --output ", "write json/sarif output to a file instead of stdout") + .option( + "--fail-on ", + "exit 1 when a finding at or above any of these exists (comma-separated: critical,high,medium,low,info)", + ) + .option( + "--path-prefix ", + "prepend this to SARIF file URIs — use when the scan root is not the repository root", + ) + .option("--deps", "also query OSV.dev for advisories against lockfile versions (network)") + .option("-v, --verbose", "list the paths that could not be read") + .action(async (targetPath: string, opts: { + format?: string; + output?: string; + failOn?: string; + pathPrefix?: string; + deps?: boolean; + verbose?: boolean; + }) => { + const format = (opts.format ?? "text").toLowerCase(); + if (!["text", "json", "sarif"].includes(format)) { + console.error(chalk.red(`Unknown --format "${opts.format}" (expected text, json, or sarif)`)); + process.exit(2); + } + + let failOn; + try { + failOn = parseFailOn(opts.failOn); + } catch (err) { + console.error(chalk.red((err as Error).message)); + process.exit(2); + } + + await scanCommand(targetPath, { + format: format as ScanFormat, + output: opts.output, + failOn, + pathPrefix: opts.pathPrefix, + dependencies: opts.deps, + verbose: opts.verbose, + }); }); program diff --git a/apps/cli/src/scan/__tests__/code-rules.test.ts b/apps/cli/src/scan/__tests__/code-rules.test.ts new file mode 100644 index 0000000..36f7344 --- /dev/null +++ b/apps/cli/src/scan/__tests__/code-rules.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import { proseLines } from '../code-rules.js'; +import { scanText } from '../engine.js'; + +/** + * Every case here is a pair: the vulnerable shape and the *corrected* shape + * standing next to it, taken from `profullstack/malware-test-prs`. Testing + * only the first half measures nothing — a rule that flags everything passes + * it. The second half is the one that fails when a rule goes back to matching + * on syntax instead of on what the code does. + */ + +const ruleIds = (path: string, source: string): string[] => + scanText(path, source).map((finding) => finding.ruleId); + +describe('SQL injection', () => { + it('flags concatenation where the SQL string contains the other quote', () => { + // The inner `'` is what a naive `["'][^"']*` class chokes on, silently + // dropping the most common injection shape in every language at once. + const source = `const sql = "SELECT id, email FROM users WHERE id = '" + id + "'";`; + expect(ruleIds('a.js', source)).toContain('sql-string-concatenation'); + }); + + it('stays silent on a parameterised query', () => { + const source = `return db.query('SELECT id, email FROM users WHERE id = $1', [req.params.id]);`; + expect(ruleIds('a.js', source)).toHaveLength(0); + }); + + it('flags a template literal and an f-string', () => { + expect(ruleIds('a.js', 'return db.query(`SELECT * FROM p WHERE n LIKE \'%${term}%\'`);')).toContain( + 'sql-template-interpolation', + ); + expect( + ruleIds('a.py', `cursor.execute(f"SELECT * FROM products WHERE name LIKE '%{term}%'")`), + ).toContain('sql-template-interpolation'); + }); + + it('flags %-formatting and .format(), not a bound %s', () => { + expect(ruleIds('a.py', `cursor.execute("SELECT id FROM users WHERE id = '%s'" % user_id)`)).toContain( + 'sql-string-concatenation', + ); + expect( + ruleIds('a.py', `cursor.execute("SELECT id FROM users WHERE id = %s", (request.args["id"],))`), + ).toHaveLength(0); + }); + + it('flags Sprintf and String.format', () => { + expect(ruleIds('a.go', 'query := fmt.Sprintf("SELECT id FROM users WHERE id = \'%s\'", id)')).toContain( + 'sql-format-call', + ); + expect( + ruleIds('a.java', '.executeUpdate(String.format("DELETE FROM sessions WHERE token = \'%s\'", t));'), + ).toContain('sql-format-call'); + }); + + it('flags Ruby interpolation but not a bound placeholder', () => { + expect(ruleIds('a.rb', `User.where("id = '#{id}'")`)).toContain('rb-sql-interpolation'); + expect(ruleIds('a.rb', `User.where('id = ?', params[:id])`)).toHaveLength(0); + }); +}); + +describe('command injection', () => { + it('flags an interpolated shell string, not an argv array', () => { + expect(ruleIds('a.js', 'exec(`ping -c 1 ${host}`);')).toContain('js-shell-exec-interpolation'); + expect(ruleIds('a.js', "execFile('ping', ['-c', '1', '--', req.query.host], cb);")).toHaveLength(0); + }); + + it('flags shell=True and os.system concatenation', () => { + expect(ruleIds('a.py', 'os.system("ping -c 1 " + host)')).toContain('py-shell-command-string'); + expect( + ruleIds('a.py', 'subprocess.run(["ping", "-c", "1", "--", host], shell=False, check=False)'), + ).toHaveLength(0); + }); + + it('flags exec.Command with a shell, not with argv', () => { + expect(ruleIds('a.go', 'exec.Command("sh", "-c", "ping -c 1 "+host).Output()')).toContain( + 'go-shell-exec-command', + ); + expect(ruleIds('a.go', 'exec.Command("ping", "-c", "1", "--", host).Output()')).toHaveLength(0); + }); +}); + +describe('guard windows', () => { + it('exonerates an allow-list two lines above the sink', () => { + const guarded = [ + 'def fetch_safe(request):', + ' target_url = request.get("url")', + ' allowed_hosts = {"api.example.invalid"}', + ' if urlparse(target_url).hostname not in allowed_hosts:', + ' return None', + ' resp = requests.get(target_url, timeout=5)', + ].join('\n'); + expect(ruleIds('a.py', guarded)).toHaveLength(0); + }); + + it('still flags the same sink without the allow-list', () => { + const bare = [ + 'def fetch_vulnerable(request):', + ' target_url = request.get("url")', + ' resp = requests.get(target_url, timeout=5)', + ].join('\n'); + expect(ruleIds('a.py', bare)).toContain('py-ssrf-outbound-request'); + }); + + it('does not read a comment as evidence of a guard', () => { + // The corpus's vulnerable cases are commented "no allow-list validation". + // Treating prose as a guard silences exactly what we are measuring. + const source = [ + 'def fetch_vulnerable(request):', + ' # no allow-list, no scheme restriction, nothing sanitized', + ' target_url = request.get("url")', + ' resp = requests.get(target_url, timeout=5)', + ].join('\n'); + expect(ruleIds('a.py', source)).toContain('py-ssrf-outbound-request'); + }); + + it('does not read a function name as evidence of a guard', () => { + // `def sanitize_path_vulnerable` is a name, not a sanitiser. + const source = [ + 'def sanitize_path_vulnerable(path):', + " pattern = re.compile(r'^(/?[^/]+)+$')", + ].join('\n'); + expect(ruleIds('a.py', source)).toContain('redos-nested-quantifier'); + }); + + it('looks forward for XML hardening, which is configured after construction', () => { + const hardened = [ + 'DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();', + 'factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);', + 'factory.setExpandEntityReferences(false);', + 'DocumentBuilder builder = factory.newDocumentBuilder();', + ].join('\n'); + expect(ruleIds('a.java', hardened)).toHaveLength(0); + + const bare = [ + 'DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();', + 'DocumentBuilder builder = factory.newDocumentBuilder();', + ].join('\n'); + expect(ruleIds('a.java', bare)).toContain('java-xxe-parser-defaults'); + }); + + it('looks forward for an ObjectInputFilter installed after the stream', () => { + const filtered = [ + 'ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(blob));', + 'ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter("a.B;!*"));', + 'return ois.readObject();', + ].join('\n'); + expect(ruleIds('a.java', filtered)).toHaveLength(0); + }); +}); + +describe('prose is not code', () => { + it('identifies triple-quoted blocks', () => { + const lines = ['"""', 'pickle.loads is dangerous', '"""', 'x = 1']; + expect([...proseLines(lines)]).toEqual([0, 1, 2]); + }); + + it('does not treat a single-line docstring as an open block', () => { + expect([...proseLines(['"""one liner"""', 'x = 1'])]).toEqual([]); + }); + + it('does not report findings about a module docstring', () => { + // A Python file whose header describes the vulnerability it contains must + // not produce findings against that description. + const source = [ + '"""', + '@description Uses pickle.loads on request data and yaml.load with', + ' a full Loader, which is remote code execution.', + '"""', + 'import pickle', + ].join('\n'); + expect(ruleIds('a.py', source)).toHaveLength(0); + }); +}); + +describe('confidence', () => { + it('caps a bare construct at medium and escalates with untrusted input', () => { + const bare = scanText('a.js', 'const out = yaml.load(text);'); + expect(bare[0]?.confidence).toBe('pattern'); + expect(bare[0]?.severity).toBe('medium'); + + const contextual = scanText('a.js', 'const out = yaml.load(req.body.doc);'); + expect(contextual[0]?.confidence).toBe('contextual'); + expect(contextual[0]?.severity).toBe('high'); + }); +}); diff --git a/apps/cli/src/scan/__tests__/engine.test.ts b/apps/cli/src/scan/__tests__/engine.test.ts new file mode 100644 index 0000000..2d83360 --- /dev/null +++ b/apps/cli/src/scan/__tests__/engine.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { parseFailOn } from '../../commands/scan.js'; +import { collectSuppressions, languageOf, meetsFailThreshold, scanText } from '../engine.js'; +import { detectTyposquat, editDistance, scanPackageJson, scanRequirementsTxt } from '../manifest-rules.js'; +import { isKnownPlaceholder, redactSecret } from '../secret-rules.js'; +import type { ScanFinding } from '../types.js'; + +describe('language detection', () => { + it('maps extensions and treats dotted env files as config', () => { + expect(languageOf('a.ts')).toBe('typescript'); + expect(languageOf('a.rb')).toBe('ruby'); + expect(languageOf('.env.production')).toBe('config'); + expect(languageOf('aws-credentials.env')).toBe('config'); + }); +}); + +describe('secret redaction', () => { + it('never emits the matched credential', () => { + const redacted = redactSecret('AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE'); + expect(redacted).not.toContain('AKIAIOSFODNN7EXAMPLE'); + expect(redacted).toContain('*'); + }); + + it('is applied to findings, so a CI log never gains a secret', () => { + const findings = scanText('a.env', 'GITHUB_TOKEN=ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(findings).toHaveLength(1); + expect(findings[0]!.excerpt).not.toContain('ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + }); +}); + +describe('placeholders', () => { + it('exempts documented placeholders', () => { + expect(isKnownPlaceholder('YOUR_API_KEY')).toBe(true); + expect(isKnownPlaceholder('changeme')).toBe(true); + }); + + it('does not exempt AWS documentation keys', () => { + // They authenticate nothing, but they are in the file because someone + // pasted a credentials template — and the remediation is the same one. + expect(isKnownPlaceholder('AKIAIOSFODNN7EXAMPLE')).toBe(false); + expect(scanText('a.env', 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE')).toHaveLength(1); + }); + + it('does not flag an ARN or a parameter-store path', () => { + // Both sit under secret-shaped variable names in the testbed's control + // group. Neither is a credential. + expect(scanText('a.env', 'AWS_ROLE_ARN=arn:aws:iam::123456789012:role/app-runtime')).toHaveLength(0); + expect(scanText('a.env', 'DATABASE_URL_SSM_PARAMETER=/prod/app/database-url')).toHaveLength(0); + }); + + it('flags a database URL only when it carries a password', () => { + expect(scanText('a.env', 'DATABASE_URL=postgres://u:p@db.example.invalid:5432/app')).toHaveLength(1); + expect(scanText('a.env', 'DATABASE_URL=postgres://db.example.invalid:5432/app')).toHaveLength(0); + }); +}); + +describe('inline suppression', () => { + it('suppresses the next line for a named rule', () => { + const source = [ + '// threatcrush-disable-next-line secret-github-token test fixture', + "const t = 'ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';", + ].join('\n'); + expect(scanText('a.js', source)).toHaveLength(0); + }); + + it('does not suppress a different rule', () => { + const source = [ + '// threatcrush-disable-next-line sql-string-concatenation', + "const t = 'ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';", + ].join('\n'); + expect(scanText('a.js', source)).toHaveLength(1); + }); + + it('counts what it silenced, so a quiet scan is not mistaken for a clean one', () => { + const lines = ['// threatcrush-disable-next-line secret-github-token', 'const t = 1;']; + expect(collectSuppressions(lines).count).toBe(1); + }); +}); + +describe('--fail-on', () => { + const at = (severity: ScanFinding['severity']): ScanFinding => + ({ severity }) as ScanFinding; + + it('fires at or above the requested floor', () => { + expect(meetsFailThreshold([at('high')], ['critical', 'high'])).toBe(true); + expect(meetsFailThreshold([at('critical')], ['high'])).toBe(true); + expect(meetsFailThreshold([at('medium')], ['critical', 'high'])).toBe(false); + }); + + it('never fires when no threshold was requested', () => { + expect(meetsFailThreshold([at('critical')], [])).toBe(false); + }); + + it('rejects an unknown severity rather than silently ignoring it', () => { + // Silently accepting `--fail-on hihg` produces a gate that never fires, + // which looks exactly like a passing build. + expect(parseFailOn('critical,high')).toEqual(['critical', 'high']); + expect(() => parseFailOn('hihg')).toThrow(/unknown severity/); + }); +}); + +describe('typosquat detection', () => { + it('counts a transposition as one edit', () => { + expect(editDistance('lodahs', 'lodash')).toBe(1); + expect(editDistance('reqeust', 'request')).toBe(1); + }); + + it('catches transpositions, deletions and separator tricks', () => { + expect(detectTyposquat('lodahs', 'npm')?.impersonates).toBe('lodash'); + expect(detectTyposquat('expres', 'npm')?.impersonates).toBe('express'); + expect(detectTyposquat('urllib-3', 'pypi')?.impersonates).toBe('urllib3'); + expect(detectTyposquat('pythondateutil', 'pypi')?.impersonates).toBe('python-dateutil'); + }); + + it('never flags the popular package itself', () => { + expect(detectTyposquat('lodash', 'npm')).toBeNull(); + expect(detectTyposquat('requests', 'pypi')).toBeNull(); + expect(detectTyposquat('react-dom', 'npm')).toBeNull(); + }); +}); + +describe('manifest rules', () => { + it('flags dependency confusion and install-time lifecycle scripts', () => { + const manifest = JSON.stringify( + { + dependencies: { '@profullstack-internal/auth-client': '0.0.0' }, + scripts: { postinstall: "echo 'hi'" }, + }, + null, + 2, + ); + const ids = scanPackageJson(manifest).map((f) => f.ruleId); + expect(ids).toContain('manifest-dependency-confusion'); + expect(ids).toContain('manifest-install-lifecycle-script'); + }); + + it('reads requirements.txt and skips comments', () => { + const text = ['# requests==2.32.3 --hash=sha256:00', 'reqeusts==0.0.0'].join('\n'); + const findings = scanRequirementsTxt(text); + expect(findings).toHaveLength(1); + expect(findings[0]!.line).toBe(2); + }); +}); diff --git a/apps/cli/src/scan/__tests__/sarif.test.ts b/apps/cli/src/scan/__tests__/sarif.test.ts new file mode 100644 index 0000000..e60c123 --- /dev/null +++ b/apps/cli/src/scan/__tests__/sarif.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { buildSarif, sarifLevel, securitySeverity, toArtifactUri } from '../sarif.js'; +import type { ScanFinding } from '../types.js'; + +const finding = (overrides: Partial = {}): ScanFinding => ({ + ruleId: 'secret-aws-access-key', + title: 'AWS Access Key', + file: 'secrets/creds.env', + line: 23, + severity: 'critical', + confidence: 'evidence', + message: 'Possible AWS Access Key detected', + consequence: 'Grants the API access of the issuing IAM principal.', + cwe: 'CWE-798', + excerpt: 'AWS_ACCESS_KEY_ID=AKI**************', + category: 'secret', + ...overrides, +}); + +const firstResult = (log: unknown): any => (log as any).runs[0].results[0]; +const uriOf = (log: unknown): string => + firstResult(log).locations[0].physicalLocation.artifactLocation.uri; + +describe('artifact URIs', () => { + it('resolves finding paths against the scan root, not the working directory', () => { + // The bug this guards: findings carry paths relative to the scan root, so + // scanning `vulns/` from a repo root emitted `secrets/x.env`. Every + // consumer scoped to the repository then reported the finding as + // out-of-corpus, and a working scan read as 0% coverage. + expect(toArtifactUri('secrets/x.env', '/repo', '', '/repo/vulns')).toBe('vulns/secrets/x.env'); + }); + + it('applies an explicit prefix for scans run from inside the target', () => { + expect(toArtifactUri('secrets/x.env', '/repo/vulns', 'vulns', '/repo/vulns')).toBe( + 'vulns/secrets/x.env', + ); + }); + + it('emits POSIX separators and no leading ./', () => { + expect(toArtifactUri('a/b/c.js', '/repo', '', '/repo')).toBe('a/b/c.js'); + }); + + it('keeps an absolute path rather than a run of ../ segments', () => { + expect(toArtifactUri('/elsewhere/x.js', '/repo', 'vulns', '/repo')).toBe('/elsewhere/x.js'); + }); +}); + +describe('SARIF document', () => { + it('is a valid 2.1.0 run with a driver and rules', () => { + const log = buildSarif([finding()], { toolVersion: '1.2.3', base: '/repo', root: '/repo' }) as any; + expect(log.version).toBe('2.1.0'); + expect(log.runs).toHaveLength(1); + expect(log.runs[0].tool.driver.name).toBe('ThreatCrush'); + expect(log.runs[0].tool.driver.version).toBe('1.2.3'); + expect(log.runs[0].tool.driver.rules[0].id).toBe('secret-aws-access-key'); + }); + + it('clamps startLine to 1 — SARIF rejects 0', () => { + // Whole-file findings have no line. Emitting 0 fails schema validation and + // GitHub drops the whole upload rather than the one result. + const log = buildSarif([finding({ line: 0 })], { toolVersion: '1.0.0', base: '/repo', root: '/repo' }); + expect(firstResult(log).locations[0].physicalLocation.region.startLine).toBe(1); + }); + + it('tags the CWE so consumers can group by weakness', () => { + const log = buildSarif([finding()], { toolVersion: '1.0.0', base: '/repo', root: '/repo' }) as any; + expect(log.runs[0].tool.driver.rules[0].properties.tags).toContain('external/cwe/cwe-798'); + }); + + it('emits a valid empty run for a clean scan', () => { + // Distinguishable from a missing file, which is the point: a consumer must + // be able to tell "looked, found nothing" from "never ran". + const log = buildSarif([], { toolVersion: '1.0.0', base: '/repo', root: '/repo' }) as any; + expect(log.runs[0].results).toEqual([]); + expect(log.runs[0].tool.driver.rules).toEqual([]); + }); + + it('maps severity onto SARIF levels and GitHub security-severity', () => { + expect(sarifLevel('critical')).toBe('error'); + expect(sarifLevel('high')).toBe('error'); + expect(sarifLevel('medium')).toBe('warning'); + expect(sarifLevel('low')).toBe('note'); + expect(securitySeverity('critical')).toBe('9.0'); + expect(securitySeverity('medium')).toBe('5.0'); + }); + + it('reports confidence as SARIF precision', () => { + const log = buildSarif([finding({ confidence: 'pattern' })], { + toolVersion: '1.0.0', + base: '/repo', + root: '/repo', + }) as any; + expect(log.runs[0].tool.driver.rules[0].properties.precision).toBe('medium'); + }); + + it('does not carry raw credential material into the excerpt', () => { + const log = buildSarif([finding()], { toolVersion: '1.0.0', base: '/repo', root: '/repo' }); + expect(firstResult(log).locations[0].physicalLocation.region.snippet.text).not.toMatch( + /AKIA[0-9A-Z]{16}/, + ); + }); + + it('places the finding at the resolved URI', () => { + const log = buildSarif([finding()], { toolVersion: '1.0.0', base: '/repo', root: '/repo/vulns' }); + expect(uriOf(log)).toBe('vulns/secrets/creds.env'); + }); +}); diff --git a/apps/cli/src/scan/code-rules.ts b/apps/cli/src/scan/code-rules.ts new file mode 100644 index 0000000..ed95c1e --- /dev/null +++ b/apps/cli/src/scan/code-rules.ts @@ -0,0 +1,803 @@ +/** + * Code-level vulnerability rules for `threatcrush scan`. + * + * Why this file exists + * -------------------- + * Measured against the public testbed at `profullstack/malware-test-prs`, the + * CLI scored 15.6% true-positive rate with a 0.0% false-positive rate: it found + * every hardcoded credential and none of the code-level classes — no SQL + * injection, XSS, SSRF, command injection, deserialisation or template + * injection. ThreatCrush was a secrets scanner wearing a code scanner's name. + * + * These rules close that gap without giving up the number that was actually + * worth having. The false-positive denominator in that testbed is a control + * group of `SAFE:` lines — each one a *correct* implementation of the same + * pattern the neighbouring vulnerable code gets wrong. A scanner that flags one + * is pattern-matching on syntax instead of following the data. So every rule + * here is built against both halves: it must fire on the vulnerable shape and + * stay silent on the corrected shape standing next to it. + * + * Three mechanisms do that work: + * + * 1. **Shape, not keyword.** `db.query("SELECT … $1", [id])` and + * `db.query("SELECT … '" + id + "'")` both contain `SELECT`. Only the + * second concatenates, and only the second matches. + * 2. **Guard windows.** A construct is exonerated by the code around it — + * an allow-list two lines up, a `realpath` on the same line, an + * `ObjectInputFilter` installed before the `readObject()`. Comment lines + * are excluded from the window, because a comment saying "no allow-list + * here" is not an allow-list. + * 3. **Confidence.** A construct that merely exists is capped at medium. + * Escalation requires visible untrusted input. See `types.ts`. + * + * What this is not: data-flow analysis. It is line-oriented matching with a + * small amount of local context, and it says so. Classes that genuinely need + * whole-function reasoning — missing CSRF tokens, check-then-use races, + * integer overflow — are deliberately absent rather than approximated by a + * rule that would flag every session read in the codebase. See KNOWN_GAPS. + */ + +import type { Confidence, ScanLanguage, Severity } from './types.js'; +import { severityFor } from './types.js'; + +export interface CodeRule { + id: string; + title: string; + /** What happens if it is real. An operator triages on consequence. */ + consequence: string; + cwe: string; + severity: Severity; + /** Languages the rule applies to. `undefined` means every language. */ + languages?: readonly ScanLanguage[]; + pattern: RegExp; + /** + * The rule describes a construct that is ordinary on its own — reading a + * file from a computed path is just software. Only report it when untrusted + * input is visible nearby. + */ + needsContext?: boolean; + /** + * Extra evidence that must appear in the guard window for the rule to fire. + * Used where the dangerous part is the *combination* — a base64 blob is + * harmless until something executes it. + */ + requires?: RegExp; + /** + * Evidence that the construct is already handled. `false` opts the rule out + * of the generic guard entirely — the CWE-532 rules are *about* reading + * `process.env`, so the generic guard would veto every true positive. + */ + guard?: RegExp | false; + /** Lines of context searched backwards for guards and required evidence. */ + guardBack?: number; + /** + * Lines searched *forwards*. Zero for almost everything: code that fixes a + * problem generally runs before the problem. XML parser hardening is the + * exception — the factory is constructed, then configured, so the evidence + * is below the match. + */ + guardForward?: number; +} + +/** + * Things that look like attacker-controlled input, per language family. + * + * Deliberately shallow. It is a heuristic for *ranking*, not a taint-source + * model. A real source list would be framework-aware and interprocedural, + * which is exactly what this subsystem promises not to pretend to be. + */ +const UNTRUSTED_JS = + /\b(?:req|request|ctx|context)\s*\.\s*(?:body|query|params|param|headers|cookies|url|files)\b|\bprocess\.argv\b|\bwindow\.location\b|\bdocument\.location\b|\blocation\.(?:search|hash|href)\b|\bsearchParams\b|\bgetParameter\s*\(|\bgetQueryString\s*\(|\bgetInputStream\s*\(/; + +const UNTRUSTED_PY = /\brequest\b|\bparams\b|\bflask\b|\bsys\.argv\b|\bos\.environ\b\s*\[/; + +const UNTRUSTED_RB = /\bparams\s*\[|\brequest\b|\bcookies\s*\[/; + +const UNTRUSTED_GO = + /\br\s*\.\s*(?:URL|Form|Body|Header|PostForm)\b|\bFormValue\s*\(|\bQuery\s*\(\s*\)\s*\.\s*Get\s*\(|\bmux\.Vars\s*\(/; + +const UNTRUSTED_JAVA = + /\bgetParameter\s*\(|\bgetQueryString\s*\(|\bgetHeader\s*\(|\bgetInputStream\s*\(|\bgetCookies\s*\(|\b@RequestParam\b|\b@PathVariable\b/; + +export function untrustedPatternFor(language: ScanLanguage): RegExp { + switch (language) { + case 'python': + return UNTRUSTED_PY; + case 'ruby': + return UNTRUSTED_RB; + case 'go': + return UNTRUSTED_GO; + case 'java': + return UNTRUSTED_JAVA; + default: + return UNTRUSTED_JS; + } +} + +/** + * Evidence that the dangerous construct on this line is already handled. + * + * Every entry here was added because a *correct* implementation in the testbed + * corpus was otherwise flagged. They are named after what the safe code does, + * not after what the finding is: + * + * allow/whitelist an allow-list decides what reaches the sink + * escape/sanitize the value is encoded for its output context + * realpath/… the path is resolved and re-checked before use + * process.env/… the value comes from the environment, not the request + * ObjectInputFilter a class allow-list is installed on the stream + * + * A guard match suppresses the finding rather than downgrading it. Reporting + * "we saw an allow-list but flagged it anyway" is the behaviour that makes + * operators stop reading scanner output. + */ +export const GENERIC_GUARD = + /\ballow(?:ed|list|_list|ed_hosts)?\b|\bwhitelist\b|\bescape(?:Html|Html4|Xml|Sql)?\s*\(|\bhtml_escape\b|\bhtmlspecialchars\s*\(|\bsanitiz\w*\b|\bencoded\b|\brealpath\b|\bcommonpath\b|\bresolve\(\)\.startsWith\b|\bprocess\.env\b|\bos\.environ\b|\bgetenv\b|\bENV\s*\[|setObjectInputFilter|ObjectInputFilter/i; + +/** Evidence that an XML parser factory has been hardened against XXE. */ +const XXE_GUARD = + /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/; + +/** A sink that executes whatever string reaches it. */ +const CODE_SINK = + /\bglobalThis\s*\[|\bconstructor\b|\beval\b|\bFunction\b|\brun\s*\(|\bvm\s*\.\s*run/; + +/** A sink whose output is retained: a log, a console, an outbound request. */ +const EXFIL_SINK = /\bconsole\s*\.\s*(?:log|debug|info|warn|error)\s*\(|\bfetch\s*\(|\baxios\b|\brequest\s*\(|\.\s*send\s*\(/; + +/** + * SQL text that is being *assembled* rather than parameterised. + * + * The four tails are the four ways to build a string in the languages this + * covers: `+` concatenation, `${}` template interpolation, `%`/`.format()` + * substitution, and Ruby's `#{}`. A bound placeholder (`$1`, `?`, `%s` passed + * as an argument) leaves a comma after the closing quote and matches none of + * them — which is exactly how the safe counterparts stay unflagged. + */ +const SQL_KEYWORDS = 'SELECT|INSERT\\s+INTO|INSERT|UPDATE|DELETE\\s+FROM|DELETE|DROP|UNION\\s+SELECT'; + +/** + * A quoted string containing a SQL verb. + * + * Two variants, one per quote character, because the interesting strings + * contain the *other* quote: + * + * "SELECT id FROM users WHERE id = '" + id + "'" + * + * A single `["'][^"'\n]*` class stops dead at that inner `'` and matches + * nothing — which silently drops the most common SQL-injection shape in every + * language at once. Match a double-quoted string with a class that excludes + * only `"`, and vice versa. + */ +const SQL_IN_DOUBLE = `"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*"`; +const SQL_IN_SINGLE = `'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*'`; +const SQL_STRING = `(?:${SQL_IN_DOUBLE}|${SQL_IN_SINGLE})`; + +export const CODE_RULES: readonly CodeRule[] = [ + // ── Injection: SQL ─────────────────────────────────────────────────────── + { + id: 'sql-string-concatenation', + title: 'SQL assembled by concatenation or interpolation', + consequence: + 'A quote in the interpolated value changes the query’s meaning — the query runs as the attacker wrote it, not as you wrote it.', + cwe: 'CWE-89', + severity: 'critical', + // The tail is what distinguishes assembly from parameterisation. A bound + // query leaves a comma after the closing quote (`"… = $1", [id]`) and + // matches none of these. + pattern: new RegExp( + `${SQL_STRING}\\s*\\+|` + + `${SQL_STRING}\\s*%\\s*[\\w(]|` + + `${SQL_STRING}\\s*\\.\\s*format\\s*\\(|` + + '\\+\\s*(?:"[^"\\n]*|\'[^\'\\n]*)(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b', + 'i', + ), + }, + { + id: 'sql-template-interpolation', + title: 'SQL built from a template literal or f-string', + consequence: + 'Template interpolation is string concatenation with nicer syntax — it binds nothing and escapes nothing.', + cwe: 'CWE-89', + severity: 'critical', + pattern: new RegExp( + `\`[^\`\\n]*(?:${SQL_KEYWORDS})\\b[^\`\\n]*\\$\\{|` + + `\\bf"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*\\{|` + + `\\bf'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*\\{`, + 'i', + ), + }, + { + id: 'sql-format-call', + title: 'SQL text produced by a format helper', + consequence: + '`Sprintf`/`String.format` substitute without quoting; the resulting string is concatenated SQL by another name.', + cwe: 'CWE-89', + severity: 'critical', + languages: ['go', 'java'], + pattern: new RegExp( + `\\b(?:fmt\\.Sprintf|String\\.format)\\s*\\(\\s*"[^"\\n]*(?:${SQL_KEYWORDS})\\b`, + 'i', + ), + }, + { + id: 'rb-sql-interpolation', + title: 'ActiveRecord query built by string interpolation', + consequence: + '`where("… #{value}")` interpolates before the adapter sees it, so no binding ever happens.', + cwe: 'CWE-89', + severity: 'critical', + languages: ['ruby'], + pattern: + /\b(?:where|find_by_sql|execute|select_all|select_values|order|group|pluck)\s*[( ]\s*(?:"[^"\n]*|'[^'\n]*)#\{/, + }, + + // ── Injection: OS command ──────────────────────────────────────────────── + { + id: 'js-shell-exec-interpolation', + title: 'shell execution with an interpolated string', + consequence: 'A `;` or `$(…)` in the interpolated value runs as the server user.', + cwe: 'CWE-78', + severity: 'critical', + languages: ['javascript', 'typescript'], + pattern: + /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/, + }, + { + id: 'py-shell-command-string', + title: 'shell command built from a string', + consequence: + '`os.system` and `shell=True` hand the string to `/bin/sh`, which happily interprets metacharacters.', + cwe: 'CWE-78', + severity: 'critical', + languages: ['python'], + pattern: + /\bos\.(?:system|popen)\s*\(\s*(?:f?['"][^'"]*['"]\s*(?:\+|%|\.\s*format)|f['"]|[a-zA-Z_]\w*\s*[,)])|\bsubprocess\.(?:run|call|check_call|check_output|Popen)\s*\([^)]*\bshell\s*=\s*True/, + }, + { + id: 'go-shell-exec-command', + title: 'exec.Command invoking a shell', + consequence: + 'Passing `sh -c` re-introduces the shell that `exec.Command`’s argv interface exists to avoid.', + cwe: 'CWE-78', + severity: 'critical', + languages: ['go'], + pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/, + }, + { + id: 'rb-backtick-interpolation', + title: 'backtick command with interpolation', + consequence: 'Ruby backticks are a shell invocation; `#{}` inside one is command injection.', + cwe: 'CWE-78', + severity: 'critical', + languages: ['ruby'], + pattern: /`[^`\n]*#\{|\bsystem\s*\(\s*["'][^"'\n]*#\{|%x\[[^\]]*#\{/, + }, + + // ── Injection: dynamic code ────────────────────────────────────────────── + { + id: 'js-dynamic-code-execution', + title: 'dynamic code execution', + consequence: 'Any string reaching this call executes as code with the process’ privileges.', + cwe: 'CWE-95', + severity: 'critical', + languages: ['javascript', 'typescript'], + pattern: + /\beval\s*\(|\bnew\s+Function\s*\(|\bvm\s*\.\s*run(?:InThisContext|InNewContext|InContext)\s*\(|\bset(?:Timeout|Interval)\s*\(\s*(?:['"`]|(?:req|request|ctx|params|query|body)\b)/, + }, + { + id: 'js-indirect-code-sink', + title: 'code sink reached indirectly', + consequence: + 'Resolving `eval`/`Function` through `globalThis[…]` or `.constructor` hides the sink from literal matching. Legitimate code has no reason to.', + cwe: 'CWE-506', + severity: 'high', + languages: ['javascript', 'typescript'], + pattern: + /\bglobalThis\s*\[\s*[a-zA-Z_$][\w$]*\s*\]|\(\s*function\s*\(\s*\)\s*\{\s*\}\s*\)\s*\.\s*constructor/, + }, + { + id: 'js-encoded-payload-execution', + title: 'encoded blob decoded next to a code sink', + consequence: + 'A base64 literal that is decoded and executed is the standard shape of a planted backdoor; the encoding exists to defeat review.', + cwe: 'CWE-506', + severity: 'critical', + languages: ['javascript', 'typescript'], + pattern: /\bBuffer\.from\s*\(\s*[\w.$]+\s*,\s*['"]base64['"]\s*\)|\batob\s*\(\s*[\w.$]+\s*\)/, + requires: CODE_SINK, + guardBack: 6, + guardForward: 3, + }, + { + id: 'py-dynamic-code-execution', + title: 'dynamic code execution', + consequence: 'Any string reaching this call executes as Python with the process’ privileges.', + cwe: 'CWE-95', + severity: 'critical', + languages: ['python'], + pattern: /\b(?:eval|exec)\s*\(\s*(?!['"]\s*\))[a-zA-Z_(f'"]/, + needsContext: true, + }, + { + id: 'rb-dynamic-dispatch', + title: 'dynamic code execution or unrestricted #send', + consequence: + '`eval` runs arbitrary Ruby; unrestricted `#send` lets the caller invoke any method on the receiver, including private ones.', + cwe: 'CWE-95', + severity: 'critical', + languages: ['ruby'], + pattern: /\beval\s*\(|\binstance_eval\s*\(|\bclass_eval\s*\(|\.\s*send\s*\(\s*(?:params|request|args)\b/, + }, + + // ── Cross-site scripting ───────────────────────────────────────────────── + { + id: 'js-unescaped-html-sink', + title: 'unescaped HTML rendering', + consequence: 'A script tag in the value executes in the victim’s session — stored or reflected XSS.', + cwe: 'CWE-79', + severity: 'high', + languages: ['javascript', 'typescript'], + pattern: + /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=\s*(?!\s*['"`]\s*['"`]\s*;?\s*$)|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/, + }, + { + id: 'java-html-writer-concatenation', + title: 'HTML written to the response by concatenation', + consequence: + 'The servlet writer performs no encoding; a value concatenated into markup is rendered as markup.', + cwe: 'CWE-79', + severity: 'high', + languages: ['java'], + pattern: /\b(?:println|print|write)\s*\(\s*"[^"\n]*<[^"\n]*"\s*\+/, + }, + { + id: 'rb-unescaped-output', + title: 'Rails output escaping bypassed', + consequence: + '`html_safe` and `raw` tell Rails the string is already safe. If it came from a parameter, it is not.', + cwe: 'CWE-79', + severity: 'high', + languages: ['ruby'], + pattern: /\.\s*html_safe\b|\braw\s*\(\s*(?:params|request|@)|\blink_to\s+[^,\n]+,\s*params\s*\[/, + }, + { + id: 'py-template-autoescape-off', + title: 'template rendering with escaping disabled', + consequence: + 'With autoescape off — or a `|safe` filter — every interpolated value is rendered as markup.', + cwe: 'CWE-79', + severity: 'high', + languages: ['python'], + pattern: /\bEnvironment\s*\([^)]*\bautoescape\s*=\s*False|\|\s*safe\b|\bMarkup\s*\(\s*(?!['"])/, + }, + { + id: 'py-template-from-input', + title: 'template compiled from a non-literal source', + consequence: + 'Server-side template injection. In Jinja2 the sandbox is escapable, so this escalates from XSS to remote code execution.', + cwe: 'CWE-1336', + severity: 'critical', + languages: ['python'], + pattern: /\bTemplate\s*\(\s*(?!['"])[a-zA-Z_]/, + needsContext: true, + }, + + // ── Server-side request forgery ────────────────────────────────────────── + { + id: 'js-ssrf-outbound-request', + title: 'outbound request to a non-constant URL', + consequence: + 'An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.', + cwe: 'CWE-918', + severity: 'high', + languages: ['javascript', 'typescript'], + pattern: + /\bfetch\s*\(\s*[a-zA-Z_$][\w$]*\s*[,)]|\bhttps?\s*\.\s*(?:get|request)\s*\(\s*[a-zA-Z_$][\w$]*\s*[,)]|\baxios\s*\.\s*get\s*\(\s*[a-zA-Z_$][\w$]*\s*[,)]/, + needsContext: true, + }, + { + id: 'py-ssrf-outbound-request', + title: 'outbound request to a non-constant URL', + consequence: + 'An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.', + cwe: 'CWE-918', + severity: 'high', + languages: ['python'], + pattern: + /\brequests\.(?:get|request|head)\s*\(\s*[a-zA-Z_]\w*\s*[,)]|\burlopen\s*\(\s*[a-zA-Z_]\w*\s*[,)]|\bhttpx\.get\s*\(\s*[a-zA-Z_]\w*\s*[,)]/, + needsContext: true, + }, + { + id: 'go-ssrf-outbound-request', + title: 'outbound request to a non-constant URL', + consequence: + 'An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.', + cwe: 'CWE-918', + severity: 'high', + languages: ['go'], + pattern: /\bhttp\.(?:Get|Post|Head)\s*\(\s*(?:[a-zA-Z_]\w*\s*[,)]|"[^"]*"\s*\+)/, + needsContext: true, + }, + + // ── Open redirect ──────────────────────────────────────────────────────── + { + id: 'js-open-redirect', + title: 'redirect to a non-constant destination', + consequence: + 'Your domain becomes the credible first hop of a phishing chain; the victim sees your hostname in the link they clicked.', + cwe: 'CWE-601', + severity: 'medium', + languages: ['javascript', 'typescript'], + pattern: + /\b(?:res|response)\s*\.\s*redirect\s*\(\s*[a-zA-Z_$][\w$]*\s*\)|\bwindow\s*\.\s*location(?:\s*\.\s*(?:href|replace))?\s*(?:=\s*[a-zA-Z_$]|\(\s*[a-zA-Z_$][\w$]*\s*\))/, + needsContext: true, + }, + + // ── Deserialisation ────────────────────────────────────────────────────── + { + id: 'py-unsafe-deserialization', + title: 'deserialisation of untrusted data', + consequence: + '`pickle` and `yaml.load` instantiate arbitrary types during parsing — a crafted payload is remote code execution, not a parse error.', + cwe: 'CWE-502', + severity: 'critical', + languages: ['python'], + pattern: /\bpickle\.loads?\s*\(|\bcPickle\.loads?\s*\(|\bmarshal\.loads\s*\(|\byaml\.load\s*\(|\bjsonpickle\.decode\s*\(/, + }, + { + id: 'java-unsafe-deserialization', + title: 'Java deserialisation without a class filter', + consequence: + 'A gadget chain in the classpath turns `readObject()` on attacker bytes into remote code execution.', + cwe: 'CWE-502', + severity: 'critical', + languages: ['java'], + pattern: /\breadObject\s*\(\s*\)|\bnew\s+ObjectInputStream\s*\(/, + guardBack: 8, + // The stream is constructed, *then* filtered. Without a forward window the + // guarded case matches on its constructor line and reports a correct + // implementation as a finding. + guardForward: 6, + }, + { + id: 'js-unsafe-yaml-load', + title: 'YAML parsed with type resolution enabled', + consequence: 'A crafted document can instantiate arbitrary types during parsing.', + cwe: 'CWE-502', + severity: 'high', + languages: ['javascript', 'typescript'], + pattern: /\byaml\s*\.\s*load\s*\((?![^)]*safe)|\bloadAll\s*\([^)]*unsafe/i, + }, + + // ── XML external entities ──────────────────────────────────────────────── + { + id: 'java-xxe-parser-defaults', + title: 'XML parser left on its insecure defaults', + consequence: + 'External entity expansion reads local files and makes outbound requests on the parser’s behalf — file disclosure and SSRF from a document.', + cwe: 'CWE-611', + severity: 'high', + languages: ['java'], + pattern: + /\b(?:DocumentBuilderFactory|SAXParserFactory|XMLInputFactory|TransformerFactory|SchemaFactory)\s*\.\s*newInstance\s*\(\s*\)/, + guard: XXE_GUARD, + guardBack: 4, + guardForward: 8, + }, + { + id: 'java-xxe-parse-call', + title: 'XML parsed by a builder that was never hardened', + consequence: + 'The expansion happens at `parse()`. Flagging only the factory misses the line where the document is actually read.', + cwe: 'CWE-611', + severity: 'high', + languages: ['java'], + // Receiver-qualified so `LocalDate.parse(s)` and friends stay out of it. + pattern: /\b\w*(?:[Bb]uilder|[Pp]arser|[Rr]eader)\s*\.\s*parse\s*\(/, + guard: XXE_GUARD, + guardBack: 6, + guardForward: 4, + }, + + // ── Path traversal ─────────────────────────────────────────────────────── + { + id: 'py-path-traversal', + title: 'file opened at a path built from input', + consequence: 'A `../` sequence — or an absolute path — reads or writes outside the intended directory.', + cwe: 'CWE-22', + severity: 'high', + languages: ['python'], + pattern: /\bopen\s*\(\s*(?:os\.path\.join\s*\(|[a-zA-Z_]\w*\s*\+|f['"])/, + needsContext: true, + }, + { + id: 'js-path-traversal', + title: 'file path built from a variable', + consequence: 'A `../` sequence in the value reads or writes outside the intended directory.', + cwe: 'CWE-22', + severity: 'medium', + languages: ['javascript', 'typescript'], + pattern: + /\b(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|unlink|unlinkSync|sendFile)\s*\(\s*(?:`[^`]*\$\{|[a-zA-Z_$][\w$]*\s*\+|path\.join\s*\([^)]*(?:req|request)\b)/, + needsContext: true, + }, + + // ── Cryptography, tokens, randomness ───────────────────────────────────── + { + id: 'js-jwt-decode-without-verify', + title: 'JWT decoded without verifying the signature', + consequence: + '`decode` parses the claims and checks nothing. Anyone can mint a token with any `sub` and any `role`.', + cwe: 'CWE-347', + severity: 'critical', + languages: ['javascript', 'typescript'], + pattern: /\bjwt\s*\.\s*decode\s*\(|\bjsonwebtoken\s*\.\s*decode\s*\(|\bdecodeJwt\s*\(/, + }, + { + id: 'tls-verification-disabled', + title: 'TLS certificate verification disabled', + consequence: + 'Every connection made this way is trivially interceptable; the encryption is decorative.', + cwe: 'CWE-295', + severity: 'high', + pattern: + /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*[=:]\s*['"]?0|strictSSL\s*:\s*false|\bverify\s*=\s*False\b|InsecureSkipVerify\s*:\s*true/, + }, + { + id: 'weak-hash-on-credential', + title: 'broken hash used on a credential', + consequence: 'MD5 and SHA-1 are fast and collision-prone; hashed passwords are recoverable.', + cwe: 'CWE-327', + severity: 'high', + pattern: + /(?:createHash|hashlib|MessageDigest\.getInstance|Digest::)\s*[.(]?\s*['"]?(?:md5|MD5|sha1|SHA-?1)['"]?\s*\)?[\s\S]{0,80}(?:password|passwd|secret|token|credential)/i, + }, + { + id: 'insecure-randomness-for-secret', + title: 'predictable randomness used for a security value', + consequence: + '`Math.random`/`random.random` are predictable; tokens, session ids and reset codes built from them are guessable.', + cwe: 'CWE-338', + severity: 'high', + pattern: + /(?:token|secret|password|salt|nonce|session|otp|reset|apikey|api_key)[\w]*\s*[:=][^;\n]{0,60}(?:Math\s*\.\s*random\s*\(|\brandom\s*\.\s*(?:random|randint|choice)\s*\(|\brand\s*\()/i, + }, + { + id: 'redos-nested-quantifier', + title: 'regex with nested unbounded quantifiers', + consequence: + 'Catastrophic backtracking: a crafted input of a few dozen characters pins a CPU core for minutes.', + cwe: 'CWE-1333', + severity: 'medium', + pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/, + }, + + // ── Temporary files ────────────────────────────────────────────────────── + { + id: 'insecure-temp-file', + title: 'predictable temporary file path', + consequence: + 'A predictable name in a world-writable directory is a symlink attack: an attacker pre-creates the path and your process writes through it.', + cwe: 'CWE-377', + severity: 'medium', + // A hardcoded path under /tmp is the finding whether or not it is + // formatted: `"/tmp/application.log.tmp"` is worse than the PID-based one, + // because every process on the host can predict it exactly. + pattern: + /\btempfile\.mktemp\s*\(|\bos\.tmpnam\s*\(|['"]\/tmp\/[^'"\n]+['"]|['"]\/tmp\/[^'"\n]*\{|\bFile\.createTempFile\s*\(/, + }, + + // ── Information exposure ───────────────────────────────────────────────── + { + id: 'py-stack-trace-returned', + title: 'stack trace returned to the caller', + consequence: + 'Tracebacks leak absolute paths, dependency versions and source fragments — the reconnaissance an attacker would otherwise have to guess at.', + cwe: 'CWE-209', + severity: 'medium', + languages: ['python'], + pattern: /\breturn\b[^\n]*\btraceback\.(?:format_exc|format_exception|print_exc)\s*\(|\breturn\b[^\n]*\bstr\s*\(\s*e\s*\)/, + }, + { + id: 'js-environment-exfiltration', + title: 'process environment serialised into a payload', + consequence: + 'The environment is where every secret lives. Serialising it whole into a request body is credential exfiltration regardless of the endpoint.', + cwe: 'CWE-532', + severity: 'critical', + languages: ['javascript', 'typescript'], + pattern: /\bJSON\.stringify\s*\(\s*\{?[^)]*\bprocess\.env\b(?!\s*\.)/, + guard: false, + }, + { + id: 'js-credential-logged', + title: 'credential read from the environment into a log sink', + consequence: + 'CI retains job logs, and on public forks it publishes them. A token printed once is a token leaked permanently.', + cwe: 'CWE-532', + severity: 'high', + languages: ['javascript', 'typescript'], + pattern: /\b(?:token|apiKey|api_key|secret|password|credential|auth)\w*\s*:\s*process\.env\.\w+/i, + requires: EXFIL_SINK, + guard: false, + guardBack: 4, + guardForward: 1, + }, + + // ── Prototype pollution ────────────────────────────────────────────────── + { + id: 'js-prototype-pollution', + title: 'write to a prototype-reachable key', + consequence: + 'An attacker-supplied `__proto__` key changes behaviour for every object in the process, including ones it never touched.', + cwe: 'CWE-1321', + severity: 'high', + languages: ['javascript', 'typescript'], + pattern: + /\[\s*['"]__proto__['"]\s*\]|\bObject\s*\.\s*assign\s*\(\s*[\w.$]*\.prototype\b|\.\s*__proto__\s*=/, + }, +]; + +/** + * Classes deliberately not implemented, and why. + * + * Recorded in code rather than in a document because the reason is a design + * constraint, not a backlog item: each of these needs reasoning this scanner + * does not do, and the line-oriented approximation of each one flags ordinary + * software. A missing detection is a known number. A rule that fires on every + * session read is a scanner nobody runs twice. + */ +export const KNOWN_GAPS: readonly { cwe: string; name: string; why: string }[] = [ + { + cwe: 'CWE-352', + name: 'Missing CSRF token', + why: 'Requires knowing that a handler is state-changing and that no token check dominates the mutation. Line-locally, the vulnerable and the guarded handler are the same code.', + }, + { + cwe: 'CWE-362', + name: 'Check-then-use race (TOCTOU)', + why: 'Requires pairing a check with a later use of the same path across statements. A rule matching either half alone flags every `os.path.exists`.', + }, + { + cwe: 'CWE-190', + name: 'Integer overflow / unchecked narrowing', + why: 'Requires range reasoning about the operands. Flagging arithmetic on a request value would flag arithmetic.', + }, + { + cwe: 'CWE-1321', + name: 'Prototype pollution via generic dynamic assignment', + why: '`target[key] = source[key]` is both the vulnerable merge and the guarded one; only a denylist several lines away distinguishes them. The explicit `__proto__` shapes are covered.', + }, +]; + +export interface MatchContext { + /** All lines of the file, 0-indexed. */ + lines: readonly string[]; + /** 0-indexed position of the line being tested. */ + index: number; + language: ScanLanguage; + /** + * Indexes inside a multi-line string or block comment, from `proseLines`. + * Treated exactly like comment lines: not scanned, not guard evidence. + */ + prose?: ReadonlySet; +} + +const COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|