Initial commit: omp-security DSH plugin (oh-my-pi port) with docs and tests
This commit is contained in:
commit
e37410e516
9 files changed
+627
No files matched your search
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
Reference in new issue
Block a user