165 lines
8.6 KiB
JavaScript
165 lines
8.6 KiB
JavaScript
// 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']
|