Files
omp-security/index.js
T

304 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @local/omp-security — oh-my-pi's `security_scan` for DSH.
*
* preflight → start → status / cancel → validate, like oh-my-pi's native scans:
* - preflight pins an immutable scope (repository, scoped_path, working_tree changes, or a
* base..head ref_diff), detects languages/lockfiles and which scanners apply, returns a plan id;
* - start runs the scanners in the background (semgrep, bandit, gitleaks, osv-scanner, npm audit)
* and, when `ai_review` is on, a reviewer model triages the findings (true/false positive,
* exploitability, fix) and adds issues the scanners missed in the scoped code;
* - status returns phase and the prioritized report; findings persist under ~/.dsh/security-scans;
* - validate records a human/agent verdict on one finding.
*/
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SCANNERS, SEV_ORDER, run, which } from './lib/scanners.js'
export const name = 'omp-security'
export const inject = ['tools', 'llm']
const STORE = path.join(os.homedir(), '.dsh', 'security-scans')
const ACTIONS = ['preflight', 'start', 'status', 'cancel', 'validate', 'list', 'scanners']
export function apply(ctx, config = {}) {
const cfg = {
aiReview: true,
reviewProvider: '',
reviewModel: '',
maxReviewFindings: 60,
maxReviewChars: 60000,
semgrepConfig: 'p/default',
...config,
}
fs.mkdirSync(STORE, { recursive: true })
const plans = new Map()
const ops = new Map()
ctx.effect(() => () => { for (const o of ops.values()) o.abort?.abort() }, 'omp-security: operations')
const cwdOf = (exec) => exec?.agent?.session?.header?.cwd ?? process.cwd()
const gitRoot = async (dir) => { const r = await run('git', ['rev-parse', '--show-toplevel'], { cwd: dir }); return r.code === 0 ? r.stdout.trim() : null }
const save = (scan) => fs.writeFileSync(path.join(STORE, `${scan.scanId}.json`), JSON.stringify(scan, null, 1))
const load = (id) => { try { return JSON.parse(fs.readFileSync(path.join(STORE, `${id}.json`), 'utf8')) } catch { return null } }
async function preflight(args, exec) {
const cwd = cwdOf(exec)
const root = (await gitRoot(cwd)) ?? cwd
const kind = args.target_kind ?? 'repository'
let files = null
let diff = null
let base = null; let head = null
if (kind === 'scoped_path') {
const inc = (args.include_paths ?? []).filter((p) => String(p).trim())
if (!inc.length) throw new Error('scoped_path needs include_paths')
files = inc
} else if (kind === 'working_tree') {
const r = await run('git', ['status', '--porcelain', '-uall'], { cwd: root })
if (r.code !== 0) throw new Error('working_tree needs a git repository')
files = r.stdout.split('\n').filter(Boolean).map((l) => l.slice(3).split(' -> ').pop()).filter((f) => fs.existsSync(path.join(root, f)))
diff = (await run('git', ['diff', 'HEAD'], { cwd: root })).stdout
} else if (kind === 'ref_diff') {
if (!args.base_revision || !args.head_revision) throw new Error('ref_diff needs base_revision and head_revision')
const rb = await run('git', ['rev-parse', '--verify', `${args.base_revision}^{commit}`], { cwd: root })
const rh = await run('git', ['rev-parse', '--verify', `${args.head_revision}^{commit}`], { cwd: root })
if (rb.code || rh.code) throw new Error('could not resolve base/head revision')
base = rb.stdout.trim(); head = rh.stdout.trim()
files = (await run('git', ['diff', '--name-only', '--diff-filter=ACMR', base, head], { cwd: root })).stdout.split('\n').filter(Boolean)
diff = (await run('git', ['diff', base, head], { cwd: root })).stdout
} else if (kind !== 'repository') throw new Error(`unknown target_kind ${kind}`)
const exclude = (args.exclude_paths ?? []).map(String)
if (files) files = files.filter((f) => !exclude.some((x) => f === x || f.startsWith(x.replace(/\/?$/, '/'))))
if (files && !files.length) throw new Error('scope contains no files')
const scanners = Object.entries(SCANNERS).map(([id, s]) => ({
id, what: s.what, available: s.available(),
applies: (kind === 'repository' || !['osv-scanner', 'npm-audit'].includes(id) || files?.some((f) => /(lock|requirements|go\.sum|Cargo|pom\.xml|Gemfile)/i.test(f))) && s.applies({ root, files }),
install: s.install,
}))
const wanted = args.scanners?.length ? new Set(args.scanners) : null
const selected = scanners.filter((s) => s.available && s.applies && (!wanted || wanted.has(s.id))).map((s) => s.id)
const planId = `plan-${crypto.randomBytes(3).toString('hex')}`
const plan = { planId, root, kind, files, exclude, base, head, diff, selected, aiReview: args.ai_review ?? cfg.aiReview, createdAt: Date.now(), owner: exec?.agent }
plans.set(planId, plan)
const scope = files ? `${files.length} file(s)${files.length <= 15 ? `: ${files.join(', ')}` : ''}` : 'whole repository'
return [
`Plan ${planId} — ${kind} at ${root}`,
`Scope: ${scope}${exclude.length ? ` (excluding ${exclude.join(', ')})` : ''}${base ? `\nRange: ${base.slice(0, 8)}..${head.slice(0, 8)}` : ''}`,
'Scanners:',
...scanners.map((s) => ` ${selected.includes(s.id) ? '✓' : s.available ? '–' : '✗'} ${s.id}: ${s.what}${s.available ? (s.applies ? '' : ' (not applicable)') : ` — not installed (${s.install})`}`),
`AI review: ${plan.aiReview ? 'on (reviewer model triages findings and inspects the scoped code)' : 'off'}`,
`Start with security_scan {"action":"start","plan_id":"${planId}"}`,
].join('\n')
}
async function start(args, exec) {
const plan = plans.get(args.plan_id)
if (!plan) throw new Error(`unknown plan ${args.plan_id}; run preflight first`)
const scanId = `scan-${new Date().toISOString().slice(0, 10)}-${crypto.randomBytes(3).toString('hex')}`
const operationId = `op-${crypto.randomBytes(3).toString('hex')}`
const scan = { scanId, operationId, planId: plan.planId, root: plan.root, kind: plan.kind, files: plan.files, base: plan.base, head: plan.head, phase: 'queued', startedAt: Date.now(), scanners: {}, findings: [], review: null }
const abort = new AbortController()
const op = { scan, abort }
ops.set(operationId, op)
save(scan)
const header = exec?.agent?.session?.requestHeader?.()
const route = { provider: cfg.reviewProvider || header?.config?.provider, model: cfg.reviewModel || header?.config?.model }
op.promise = (async () => {
try {
scan.phase = 'scanning'
await Promise.all(plan.selected.map(async (id) => {
const t0 = Date.now()
try {
const found = await SCANNERS[id].scan({ root: plan.root, files: plan.files, signal: abort.signal, target: plan.kind, config: { semgrepConfig: cfg.semgrepConfig, exclude: plan.exclude } })
scan.findings.push(...found)
scan.scanners[id] = { status: 'ok', findings: found.length, ms: Date.now() - t0 }
} catch (e) {
scan.scanners[id] = { status: 'error', error: String(e?.message ?? e).slice(0, 500), ms: Date.now() - t0 }
}
save(scan)
}))
if (abort.signal.aborted) { scan.phase = 'cancelled'; return }
dedupe(scan)
if (plan.aiReview && route.provider && route.model) {
scan.phase = 'reviewing'
save(scan)
try { await aiReview(scan, plan, route, abort.signal) } catch (e) { scan.review = { status: 'error', error: String(e?.message ?? e) } }
}
scan.phase = Object.values(scan.scanners).some((s) => s.status === 'error') || scan.review?.status === 'error' ? 'partial' : 'completed'
} catch (e) {
scan.phase = abort.signal.aborted ? 'cancelled' : 'failed'
scan.error = String(e?.message ?? e)
} finally {
scan.finishedAt = Date.now()
save(scan)
}
})()
// Small scans finish quickly: wait briefly so the first status is useful.
await Promise.race([op.promise, new Promise((r) => setTimeout(r, Math.min(args.wait_seconds ?? 20, 240) * 1000))])
return statusText(scan, args)
}
function dedupe(scan) {
const seen = new Map()
for (const f of scan.findings) {
const key = `${f.file}:${f.line}:${(f.cwe ?? f.rule).toString().slice(0, 40)}`
if (!seen.has(key)) seen.set(key, f)
else { const g = seen.get(key); g.alsoReportedBy = [...(g.alsoReportedBy ?? []), f.scanner] }
}
scan.findings = [...seen.values()].sort((a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity))
}
async function aiReview(scan, plan, route, signal) {
const items = scan.findings.slice(0, cfg.maxReviewFindings)
const ctxSnips = items.map((f) => {
let code = ''
try {
const lines = fs.readFileSync(path.join(plan.root, f.file), 'utf8').split('\n')
const a = Math.max(1, (f.line ?? 1) - 4); const b = Math.min(lines.length, (f.endLine ?? f.line ?? 1) + 4)
for (let n = a; n <= b; n++) code += `${n}:${lines[n - 1]}\n`
} catch {}
return `[${f.id}] ${f.severity} ${f.scanner} ${f.rule}\n${f.title}\n${f.file}:${f.line ?? ''}\n${code}`
})
let extra = ''
if (plan.diff) extra = `\nCHANGED CODE (diff):\n${plan.diff.slice(0, Math.floor(cfg.maxReviewChars / 2))}`
else if (plan.files?.length) {
for (const f of plan.files.slice(0, 20)) {
try { extra += `\n=== ${f} ===\n${fs.readFileSync(path.join(plan.root, f), 'utf8').slice(0, 8000)}` } catch {}
if (extra.length > cfg.maxReviewChars / 2) break
}
}
const prompt = `You are a security reviewer. Triage the scanner findings below and inspect the code for real vulnerabilities.
For each finding decide: "tp" (true positive), "fp" (false positive) or "uncertain", rate exploitability, and give a concrete fix.
Also report up to 10 additional real issues you see in the provided code that scanners missed (injection, authz, path traversal, SSRF, deserialization, secrets, unsafe crypto, race conditions).
Reply with ONLY JSON: {"triage":[{"id":"…","verdict":"tp|fp|uncertain","severity":"critical|high|medium|low","reason":"…","fix":"…"}],"new":[{"file":"…","line":0,"severity":"…","title":"…","reason":"…","fix":"…","cwe":"CWE-…"}],"summary":"2-4 sentences: overall risk and what to fix first"}
FINDINGS (${items.length}${scan.findings.length > items.length ? ` of ${scan.findings.length}` : ''}):
${ctxSnips.join('\n').slice(0, cfg.maxReviewChars / 2)}
${extra}`
let text = ''
for await (const ch of ctx.llm.stream({ provider: route.provider, model: route.model, messages: [createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'omp-security' } })], maxTokens: 6000, signal })) {
if (ch.type === 'text-delta') text += ch.text
if (ch.type === 'finish' && ch.reason?.kind === 'error') throw new Error(ch.reason.error?.message ?? 'review model error')
}
const m = /\{[\s\S]*\}/.exec(text)
if (!m) throw new Error('reviewer returned no JSON')
const j = JSON.parse(m[0])
const byId = new Map(scan.findings.map((f) => [f.id, f]))
for (const t of j.triage ?? []) {
const f = byId.get(t.id)
if (!f) continue
f.ai = { verdict: t.verdict, reason: t.reason, fix: t.fix }
if (t.severity && SEV_ORDER.includes(t.severity) && t.verdict === 'tp') f.severity = t.severity
}
for (const n of j.new ?? []) {
scan.findings.push({ id: crypto.createHash('sha1').update(`ai|${n.file}|${n.line}|${n.title}`).digest('hex').slice(0, 10), scanner: 'ai-review', rule: n.cwe ?? 'ai', severity: SEV_ORDER.includes(n.severity) ? n.severity : 'medium', title: n.title, file: n.file, line: n.line, cwe: n.cwe, ai: { verdict: 'tp', reason: n.reason, fix: n.fix } })
}
scan.review = { status: 'ok', model: `${route.provider}/${route.model}`, summary: j.summary }
scan.findings.sort((a, b) => (a.ai?.verdict === 'fp') - (b.ai?.verdict === 'fp') || SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity))
}
function statusText(scan, args = {}) {
const p = scan.phase
const running = !['completed', 'partial', 'failed', 'cancelled'].includes(p)
const live = scan.findings.filter((f) => f.ai?.verdict !== 'fp')
const counts = SEV_ORDER.map((s) => `${live.filter((f) => f.severity === s).length} ${s}`).join(', ')
const minSev = SEV_ORDER.indexOf(args.min_severity ?? 'low')
const shown = live.filter((f) => SEV_ORDER.indexOf(f.severity) <= (minSev < 0 ? 3 : minSev))
const max = args.max_findings ?? 40
const lines = [
`Security scan ${scan.scanId} (operation ${scan.operationId}): ${p}; ${live.length} finding(s) — ${counts}${scan.findings.length - live.length ? `; ${scan.findings.length - live.length} dismissed as false positive by AI review` : ''}.`,
`Scanners: ${Object.entries(scan.scanners).map(([k, v]) => `${k} ${v.status === 'ok' ? `${v.findings}` : `ERROR (${v.error})`}`).join(' · ') || '(running)'}`,
...(scan.review?.summary ? [`AI review (${scan.review.model}): ${scan.review.summary}`] : scan.review?.error ? [`AI review failed: ${scan.review.error}`] : []),
...(running ? [`Still ${p}; call security_scan {"action":"status","operation_id":"${scan.operationId}"} again.`] : []),
'',
]
for (const [i, f] of shown.slice(0, max).entries()) {
const pr = { critical: 'P0', high: 'P1', medium: 'P2', low: 'P3', info: 'P4' }[f.severity]
lines.push(`${i + 1}. [${pr} ${f.severity}] ${f.title}`)
lines.push(` ${f.file}${f.line ? `:${f.line}` : ''} · ${f.scanner}${f.alsoReportedBy ? `+${f.alsoReportedBy.join('+')}` : ''} ${f.rule}${f.cwe ? ` · ${f.cwe}` : ''} · id ${f.id}`)
if (f.ai) lines.push(` AI: ${f.ai.verdict}${f.ai.reason ? ` — ${f.ai.reason}` : ''}`)
const fix = f.ai?.fix ?? f.fix
if (fix) lines.push(` Fix: ${String(fix).split('\n')[0].slice(0, 300)}`)
if (f.validation) lines.push(` Validation: ${f.validation.status} — ${f.validation.summary}`)
}
if (shown.length > max) lines.push(`… ${shown.length - max} more (raise max_findings or min_severity). Full report: ${path.join(STORE, `${scan.scanId}.json`)}`)
return lines.join('\n')
}
async function runAction(args, exec) {
switch (args.action) {
case 'scanners':
return Object.entries(SCANNERS).map(([id, s]) => `${s.available() ? '✓' : '✗'} ${id}: ${s.what}${s.available() ? '' : ` — install: ${s.install}`}`).join('\n')
case 'preflight': return preflight(args, exec)
case 'start': return start(args, exec)
case 'status': {
const op = ops.get(args.operation_id)
const scan = op?.scan ?? load(args.scan_id ?? '') ?? [...ops.values()].findLast((o) => o.scan.scanId === args.scan_id)?.scan
if (!scan) throw new Error('unknown operation_id/scan_id')
if (op && args.wait_seconds) await Promise.race([op.promise, new Promise((r) => setTimeout(r, Math.min(args.wait_seconds, 240) * 1000))])
return statusText(scan, args)
}
case 'cancel': {
const op = ops.get(args.operation_id)
if (!op || ['completed', 'partial', 'failed', 'cancelled'].includes(op.scan.phase)) return 'Nothing to cancel.'
op.abort.abort()
return `Cancelling ${args.operation_id}.`
}
case 'validate': {
const scan = load(args.scan_id ?? '')
if (!scan) throw new Error(`unknown scan ${args.scan_id}`)
const f = scan.findings.find((x) => x.id === args.finding_id)
if (!f) throw new Error(`unknown finding ${args.finding_id}`)
if (!['unvalidated', 'validated', 'rejected', 'partial', 'error'].includes(args.validation_status)) throw new Error('validation_status must be unvalidated|validated|rejected|partial|error')
if (!String(args.validation_summary ?? '').trim()) throw new Error('validation_summary is required')
f.validation = { status: args.validation_status, summary: args.validation_summary, evidence: args.validation_evidence ?? [], at: new Date().toISOString() }
save(scan)
const live = ops.get(scan.operationId); if (live) Object.assign(live.scan, scan)
return `Finding ${f.id} validation is now ${args.validation_status}.`
}
case 'list': {
const files = fs.readdirSync(STORE).filter((f) => f.endsWith('.json')).sort().reverse().slice(0, 20)
return files.map((f) => { const s = load(f.slice(0, -5)); return `${s.scanId} ${s.phase} ${s.kind} ${s.root} — ${s.findings.length} finding(s)` }).join('\n') || 'No scans yet.'
}
default: throw new Error(`unknown action ${args.action}`)
}
}
ctx.tools.register({
name: 'security_scan',
description: 'Plan and run security reviews of the workspace. 1) preflight: pin the scope — target_kind repository (default), scoped_path (+include_paths), working_tree (uncommitted changes) or ref_diff (base_revision..head_revision); exclude_paths; optional scanners subset and ai_review — returns a plan_id and which scanners apply (semgrep SAST, bandit for Python, gitleaks secrets, osv-scanner and npm audit for vulnerable dependencies). 2) start {plan_id}: runs scanners in the background, then a reviewer model triages findings (true/false positive, exploitability, fix) and adds missed issues; returns early results and an operation_id. 3) status {operation_id or scan_id, wait_seconds, min_severity, max_findings}: P0–P3 prioritized report. Also cancel, validate {scan_id, finding_id, validation_status, validation_summary} to record a verdict, list (past scans), scanners (installed tools).',
parameters: {
type: 'object',
additionalProperties: false,
required: ['action'],
properties: {
action: { type: 'string', enum: ACTIONS },
target_kind: { type: 'string', enum: ['repository', 'scoped_path', 'working_tree', 'ref_diff'] },
include_paths: { type: 'array', items: { type: 'string' } },
exclude_paths: { type: 'array', items: { type: 'string' } },
base_revision: { type: 'string' },
head_revision: { type: 'string' },
scanners: { type: 'array', items: { type: 'string', enum: Object.keys(SCANNERS) }, description: 'Subset of scanners to run.' },
ai_review: { type: 'boolean', description: 'Triage with a reviewer model (default from config).' },
plan_id: { type: 'string' },
operation_id: { type: 'string' },
scan_id: { type: 'string' },
wait_seconds: { type: 'integer', description: 'start/status: wait up to this long for completion (default 20 for start).' },
min_severity: { type: 'string', enum: SEV_ORDER },
max_findings: { type: 'integer' },
finding_id: { type: 'string' },
validation_status: { type: 'string', enum: ['unvalidated', 'validated', 'rejected', 'partial', 'error'] },
validation_summary: { type: 'string' },
validation_evidence: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['label', 'explanation'], properties: { label: { type: 'string' }, explanation: { type: 'string' } } } },
},
},
output: { schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } }, render: (_a, v) => [{ type: 'text', text: v.text }] },
timeoutMs: 300_000,
async execute(args, exec) {
return { text: await runAction(args ?? {}, exec) }
},
})
void which
}