// Scanner adapters: each returns normalized findings // {id, scanner, rule, severity: critical|high|medium|low|info, title, file, line, endLine, snippet, cwe, fix, url} import { execFile } from 'node:child_process' import crypto from 'node:crypto' import fs from 'node:fs' import path from 'node:path' const HOME = process.env.HOME || '' export function which(cmd) { for (const d of [...(process.env.PATH || '').split(path.delimiter), path.join(HOME, '.local/bin'), '/usr/local/bin']) { if (!d) continue const p = path.join(d, cmd) try { fs.accessSync(p, fs.constants.X_OK); return p } catch {} } return null } export function run(bin, args, { cwd, signal, timeoutMs = 600_000, env } = {}) { return new Promise((resolve) => { execFile(bin, args, { cwd, signal, timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8', env: { ...process.env, ...env } }, (error, stdout, stderr) => { resolve({ code: error ? (typeof error.code === 'number' ? error.code : -1) : 0, stdout: stdout ?? '', stderr: stderr ?? '', killed: !!error?.killed }) }) }) } const fid = (...parts) => crypto.createHash('sha1').update(parts.join('|')).digest('hex').slice(0, 10) const sev = (s) => { const t = String(s ?? '').toLowerCase() if (/crit/.test(t)) return 'critical' if (/high|error/.test(t)) return 'high' if (/med|warn|moderate/.test(t)) return 'medium' if (/low|note/.test(t)) return 'low' return 'info' } function hasFiles(root, exts, files) { if (files) return files.some((f) => exts.includes(path.extname(f))) try { const stack = [root]; let n = 0 while (stack.length && n < 20000) { const d = stack.pop() for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'venv' || e.name === 'dist') continue const p = path.join(d, e.name); n++ if (e.isDirectory()) stack.push(p) else if (exts.includes(path.extname(e.name))) return true } } } catch {} return false } export const SCANNERS = { semgrep: { what: 'SAST rules for 30+ languages (OWASP top 10, injection, crypto misuse, …)', install: 'pip install --user semgrep', available: () => !!which('semgrep'), applies: () => true, async scan({ root, files, signal, config }) { const args = ['scan', '--json', '--quiet', '--disable-version-check', '--metrics=off', '--timeout', '30', `--config=${config?.semgrepConfig ?? 'p/default'}`] for (const ex of config?.exclude ?? []) args.push('--exclude', ex) args.push(...(files?.length ? files : ['.'])) const r = await run(which('semgrep'), args, { cwd: root, signal, env: { SEMGREP_SEND_METRICS: 'off' } }) let j try { j = JSON.parse(r.stdout) } catch { throw new Error(`semgrep: ${r.stderr.split('\n').filter(Boolean).slice(-3).join(' ') || `exit ${r.code}`}`) } return (j.results ?? []).map((x) => ({ id: fid('semgrep', x.check_id, x.path, x.start?.line), scanner: 'semgrep', rule: x.check_id, severity: sev(x.extra?.metadata?.impact ?? x.extra?.severity), title: x.extra?.message?.split('\n')[0] ?? x.check_id, file: x.path, line: x.start?.line, endLine: x.end?.line, snippet: x.extra?.lines, cwe: [].concat(x.extra?.metadata?.cwe ?? []).join(', ') || undefined, fix: x.extra?.fix, url: x.extra?.metadata?.source ?? x.extra?.metadata?.references?.[0], })) }, }, bandit: { what: 'Python security linter', install: 'pip install --user bandit', available: () => !!which('bandit'), applies: ({ root, files }) => hasFiles(root, ['.py'], files), async scan({ root, files, signal }) { const args = ['-f', 'json', '-q', ...(files?.length ? files.filter((f) => f.endsWith('.py')) : ['-r', '.', '-x', './node_modules,./.venv,./venv,./.git'])] const r = await run(which('bandit'), args, { cwd: root, signal }) let j try { j = JSON.parse(r.stdout) } catch { throw new Error(`bandit: ${r.stderr.slice(-300) || `exit ${r.code}`}`) } return (j.results ?? []).map((x) => ({ id: fid('bandit', x.test_id, x.filename, x.line_number), scanner: 'bandit', rule: `${x.test_id} ${x.test_name}`, severity: sev(x.issue_severity), confidence: x.issue_confidence?.toLowerCase(), title: x.issue_text, file: path.relative(root, path.resolve(root, x.filename)), line: x.line_number, endLine: x.line_range?.at(-1), snippet: x.code, cwe: x.issue_cwe?.id ? `CWE-${x.issue_cwe.id}` : undefined, url: x.more_info, })) }, }, gitleaks: { what: 'hardcoded secrets (API keys, tokens, private keys)', install: 'download from github.com/gitleaks/gitleaks/releases into ~/.local/bin', available: () => !!which('gitleaks'), applies: () => true, async scan({ root, signal, target }) { const report = path.join(fs.mkdtempSync(path.join(process.env.TMPDIR || '/tmp', 'gl-')), 'r.json') const isGit = fs.existsSync(path.join(root, '.git')) const args = target === 'repository' && isGit ? ['git', '--no-banner', '--report-format', 'json', '--report-path', report, '--redact', '.'] : ['dir', '--no-banner', '--report-format', 'json', '--report-path', report, '--redact', '.'] await run(which('gitleaks'), args, { cwd: root, signal }) let j = [] try { j = JSON.parse(fs.readFileSync(report, 'utf8')) } catch {} fs.rmSync(path.dirname(report), { recursive: true, force: true }) return j.map((x) => ({ id: fid('gitleaks', x.RuleID, x.File, x.StartLine, x.Commit ?? ''), scanner: 'gitleaks', rule: x.RuleID, severity: 'high', title: `${x.Description}${x.Commit ? ` (commit ${x.Commit.slice(0, 8)})` : ''}`, file: x.File, line: x.StartLine, endLine: x.EndLine, snippet: x.Match, cwe: 'CWE-798', })) }, }, 'osv-scanner': { what: 'known vulnerabilities in dependencies (lockfiles: npm, pnpm, yarn, pip, poetry, go, cargo, maven, …)', install: 'download from github.com/google/osv-scanner/releases into ~/.local/bin', available: () => !!which('osv-scanner'), applies: () => true, async scan({ root, signal }) { const r = await run(which('osv-scanner'), ['scan', 'source', '--format', 'json', '-r', '.'], { cwd: root, signal }) let j try { j = JSON.parse(r.stdout) } catch { if (/no package sources found/i.test(r.stderr)) return []; throw new Error(`osv-scanner: ${r.stderr.split('\n').filter(Boolean).slice(-2).join(' ') || `exit ${r.code}`}`) } const out = [] for (const res of j.results ?? []) { for (const pkg of res.packages ?? []) { for (const v of pkg.vulnerabilities ?? []) { const group = (pkg.groups ?? []).find((g) => g.ids?.includes(v.id)) const score = Number(group?.max_severity) const severity = Number.isFinite(score) ? (score >= 9 ? 'critical' : score >= 7 ? 'high' : score >= 4 ? 'medium' : 'low') : sev(v.database_specific?.severity) const fixed = (v.affected ?? []).flatMap((a) => (a.ranges ?? []).flatMap((rg) => (rg.events ?? []).map((e) => e.fixed).filter(Boolean))) out.push({ id: fid('osv', v.id, pkg.package?.name, res.source?.path), scanner: 'osv-scanner', rule: v.id, severity, title: `${pkg.package?.name}@${pkg.package?.version}: ${v.summary ?? v.id}`, file: path.relative(root, res.source?.path ?? ''), fix: fixed.length ? `upgrade to ${fixed[0]}` : undefined, url: `https://osv.dev/vulnerability/${v.id}`, cwe: (v.database_specific?.cwe_ids ?? []).join(', ') || undefined, }) } } } return out }, }, 'npm-audit': { what: 'npm advisory database for package-lock.json projects', install: 'bundled with npm', available: () => !!which('npm'), applies: ({ root }) => fs.existsSync(path.join(root, 'package-lock.json')), async scan({ root, signal }) { const r = await run(which('npm'), ['audit', '--json'], { cwd: root, signal }) let j try { j = JSON.parse(r.stdout) } catch { return [] } return Object.values(j.vulnerabilities ?? {}).map((v) => ({ id: fid('npm', v.name, v.range), scanner: 'npm-audit', rule: (v.via ?? []).map((x) => (typeof x === 'object' ? x.source ?? x.url : x)).join(', '), severity: sev(v.severity), title: `${v.name} ${v.range}: ${(v.via ?? []).map((x) => (typeof x === 'object' ? x.title : `via ${x}`)).join('; ')}`, file: 'package-lock.json', fix: v.fixAvailable ? (typeof v.fixAvailable === 'object' ? `upgrade ${v.fixAvailable.name} to ${v.fixAvailable.version}` : 'npm audit fix') : undefined, })) }, }, } export const SEV_ORDER = ['critical', 'high', 'medium', 'low', 'info']