Initial commit: omp-security DSH plugin (oh-my-pi port) with docs and tests

This commit is contained in:
Agent committed 2026-10-10 21:30:02 +00:00
commit e37410e516
9 files changed
+627

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules/
*.bak
*.orig
*.log
.DS_Store
+73
View File
@@ -0,0 +1,73 @@
# omp-security
[oh-my-pi](https://github.com/can1357/oh-my-pi)'s `security_scan` tool for the DeepSeek Harness (DSH). It plans and runs security reviews of the workspace with real scanners plus an **AI triage pass**, produces a **P0–P3 prioritized report**, and keeps findings so they can be validated later.
## Flow
```
security_scan {"action":"preflight","target_kind":"working_tree"}
→ Plan plan-3fa1c2 — working_tree at /repo
Scope: 4 file(s): api.py, db.py, …
Scanners: ✓ semgrep ✓ bandit ✓ gitleaks – osv-scanner (not applicable) …
security_scan {"action":"start","plan_id":"plan-3fa1c2"}
→ Security scan scan-2026-10-10-9b1e (operation op-77c0): completed; 3 finding(s) — 0 critical, 2 high, 1 medium …
AI review (omni/claude-sonnet): Fix the shell injection in api.py first …
1. [P1 high] subprocess call with shell=True identified, security issue.
api.py:41 · bandit B602 · CWE-78 · id 4be1c0a9d2
AI: tp — user input reaches the shell
Fix: pass an argv list and shell=False
```
| Action | Purpose |
| --- | --- |
| `preflight` | Pin the scope: `target_kind` = `repository` (default), `scoped_path` (+`include_paths`), `working_tree` (uncommitted changes), `ref_diff` (`base_revision`..`head_revision`). Optional `exclude_paths`, `scanners` subset, `ai_review`. Returns a `plan_id` and which scanners apply |
| `start` | Run the plan in the background. Waits up to `wait_seconds` (default 20) and returns the report or progress plus an `operation_id` |
| `status` | `operation_id` or `scan_id`; `wait_seconds`, `min_severity`, `max_findings` |
| `cancel` | Stop a running operation |
| `validate` | Record a verdict on a finding: `scan_id`, `finding_id`, `validation_status` (`validated`/`rejected`/`partial`/`unvalidated`/`error`), `validation_summary`, optional `validation_evidence` |
| `list` | Recent scans |
| `scanners` | Installed scanners and install hints |
**AI review:** a reviewer model (the agent's own model by default, or `reviewProvider`/`reviewModel`) gets:
- every finding with surrounding code;
- the diff (for `working_tree`/`ref_diff`) or the scoped files.
It marks each finding as true or false positive with a reason and a concrete fix, adjusts severity, and adds up to 10 issues the scanners missed. False positives are hidden from the report but kept in the stored scan.
Scans are stored as JSON in `~/.dsh/security-scans/`.
## Scanners
| Scanner | What it finds | Install |
| --- | --- | --- |
| semgrep | SAST rules for 30+ languages (`p/default`) | `pip install --user semgrep` |
| bandit | Python security issues | `pip install --user bandit` |
| gitleaks | Hard-coded secrets (git history for `repository` scans) | [releases](https://github.com/gitleaks/gitleaks/releases) → `~/.local/bin` |
| osv-scanner | Vulnerable dependencies from lockfiles (npm, pnpm, yarn, pip, poetry, go, cargo, maven, …) | [releases](https://github.com/google/osv-scanner/releases) → `~/.local/bin` |
| npm audit | npm advisories for `package-lock.json` projects | bundled with npm |
Missing scanners are skipped and listed in the preflight with install hints.
## Installation
```bash
dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omp-security.git
```
Or in the Web UI, go to **Settings → Plugins → Install**. For development, use `link:` to a clone. Restart DSH afterwards.
## Configuration
```yaml
- id: omp-security
config:
aiReview: true
reviewProvider: '' # empty = the calling agent's provider
reviewModel: '' # empty = the calling agent's model
semgrepConfig: p/default # any semgrep --config value (registry pack, file, dir)
maxReviewFindings: 60
```
## Tests
`npm test` runs a scan of a deliberately vulnerable sample with bandit and gitleaks (stubbed reviewer), plus osv-scanner against a vulnerable `requirements.txt`.
+9
View File
@@ -0,0 +1,9 @@
- insert:
- id: omp-security
name: '@local/omp-security'
config:
aiReview: true
# empty = use the calling agent's provider/model
reviewProvider: ''
reviewModel: ''
semgrepConfig: p/default
+303
View File
@@ -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
}
+164
View File
@@ -0,0 +1,164 @@
// 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']
+1
View File
@@ -0,0 +1 @@
{ "meta": { "title": "OMP Security Scan", "description": "security_scan: preflight/start/status scans with semgrep, bandit, gitleaks, osv-scanner, npm audit and AI triage; P0-P3 report." } }
+1
View File
@@ -0,0 +1 @@
{ "meta": { "title": "OMP 安全扫描", "description": "security_scan:使用 semgrep、bandit、gitleaks、osv-scanner、npm audit 和 AI 分诊进行扫描,输出 P0-P3 报告。" } }
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@local/omp-security",
"version": "1.0.0",
"description": "oh-my-pi style security_scan tool for DSH: semgrep, bandit, gitleaks, osv-scanner and npm audit plus AI triage, with prioritized reports and finding validation.",
"private": true,
"type": "module",
"license": "MIT",
"exports": { ".": "./index.js", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" },
"files": ["index.js", "lib/*.js", "cordis.patch.yml", "locale/*.json"],
"scripts": { "test": "node --test test/*.test.mjs" },
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import * as plugin from '../index.js'
import { SCANNERS } from '../lib/scanners.js'
function harness({ llmText } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'omp-sec-'))
const tools = new Map()
const llm = {
async *stream() { yield { type: 'text-delta', text: llmText ?? '{"triage":[],"new":[],"summary":"stub"}' }; yield { type: 'finish', reason: { kind: 'stop' } } },
}
plugin.apply({ tools: { register(d) { tools.set(d.name, d) } }, effect() {}, llm }, {})
const exec = { agent: { session: { header: { cwd: dir }, requestHeader: () => ({ config: { provider: 'p', model: 'm' } }) } } }
const call = async (args) => (await tools.get('security_scan').execute(args, exec)).text
return { dir, call, w: (f, s) => { fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true }); fs.writeFileSync(path.join(dir, f), s) } }
}
const vulnPy = `import subprocess, pickle, hashlib\n\ndef run(cmd):\n return subprocess.call(cmd, shell=True)\n\ndef load(b):\n return pickle.loads(b)\n\ndef h(x):\n return hashlib.md5(x).hexdigest()\n`
const secret = 'AWS_SECRET = "' + 'AKIA' + 'Z7QWERTYUIOPASDF' + '"\naws_secret_access_key = "' + 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + '"\n'
test('scanners action lists tools', async () => {
const h = harness()
assert.match(await h.call({ action: 'scanners' }), /semgrep/)
})
test('preflight → start → status finds python issues and AI triage applies', { skip: !SCANNERS.bandit.available(), timeout: 240000 }, async () => {
const llmText = JSON.stringify({ triage: [], new: [{ file: 'app.py', line: 4, severity: 'high', title: 'Command injection via shell=True', reason: 'cmd reaches a shell', fix: 'pass a list and shell=False', cwe: 'CWE-78' }], summary: 'Fix the shell injection first.' })
const h = harness({ llmText })
h.w('app.py', vulnPy)
h.w('creds.py', secret)
execFileSync('git', ['init', '-q'], { cwd: h.dir })
const pf = await h.call({ action: 'preflight', target_kind: 'scoped_path', include_paths: ['app.py', 'creds.py'], scanners: ['bandit', 'gitleaks'] })
const planId = /Plan (plan-\w+)/.exec(pf)[1]
assert.match(pf, /✓ bandit/)
const st = await h.call({ action: 'start', plan_id: planId, wait_seconds: 200 })
assert.match(st, /: completed;|: partial;/, st)
assert.match(st, /B602|subprocess.*shell/i, st)
assert.match(st, /pickle/i)
assert.match(st, /AI review \(p\/m\): Fix the shell injection first/)
assert.match(st, /ai-review/)
const scanId = /Security scan (scan-[\w-]+)/.exec(st)[1]
const fidm = /id (\w{10})/.exec(st)[1]
assert.match(await h.call({ action: 'validate', scan_id: scanId, finding_id: fidm, validation_status: 'validated', validation_summary: 'confirmed by test' }), /validated/)
assert.match(await h.call({ action: 'status', scan_id: scanId }), /Validation: validated/)
fs.rmSync(path.join(os.homedir(), '.dsh/security-scans', `${scanId}.json`), { force: true })
})
test('osv-scanner flags a vulnerable lockfile', { skip: !SCANNERS['osv-scanner'].available(), timeout: 240000 }, async () => {
const h = harness()
h.w('requirements.txt', 'django==2.2.0\n')
const pf = await h.call({ action: 'preflight', scanners: ['osv-scanner'], ai_review: false })
const st = await h.call({ action: 'start', plan_id: /Plan (plan-\w+)/.exec(pf)[1], wait_seconds: 200 })
if (/osv-scanner ERROR/.test(st)) return // offline: tolerated
assert.match(st, /django@2\.2\.0/i, st)
})