|
| 1 | +/** |
| 2 | + * Dependency audit on dependency change. |
| 3 | + * |
| 4 | + * Activity-triggered, not wall-clock (expansion plan §4.3): a repo is audited |
| 5 | + * only when its dependency manifest (package.json / package-lock.json) |
| 6 | + * actually changed since the last audited commit. The sweep itself rides the |
| 7 | + * engine tick every 6 hours, but a repo whose manifests are untouched costs |
| 8 | + * one `git rev-parse` — `npm audit` runs only on real dependency movement. |
| 9 | + * |
| 10 | + * Alerts flow as durable `dependency-audit` signals when high/critical |
| 11 | + * counts change; remediation stays operator/child-run (`npm audit fix` is |
| 12 | + * proposed, never executed here). |
| 13 | + */ |
| 14 | + |
| 15 | +import { execFile, execFileSync } from 'child_process' |
| 16 | +import { promisify } from 'util' |
| 17 | +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs' |
| 18 | +import { homedir } from 'os' |
| 19 | +import { join } from 'path' |
| 20 | +import { loadWorkflowConfig } from './workflow-config.js' |
| 21 | + |
| 22 | +const execFileAsync = promisify(execFile) |
| 23 | + |
| 24 | +// --------------------------------------------------------------------------- |
| 25 | +// Types |
| 26 | +// --------------------------------------------------------------------------- |
| 27 | + |
| 28 | +export interface AuditCounts { |
| 29 | + info: number |
| 30 | + low: number |
| 31 | + moderate: number |
| 32 | + high: number |
| 33 | + critical: number |
| 34 | +} |
| 35 | + |
| 36 | +interface RepoAuditState { |
| 37 | + /** Last audited (or examined-and-skipped) HEAD sha. */ |
| 38 | + sha: string |
| 39 | + at: string |
| 40 | + counts?: AuditCounts |
| 41 | +} |
| 42 | + |
| 43 | +type AuditStateFile = Record<string, RepoAuditState> |
| 44 | + |
| 45 | +/** IO seams — real git/npm in production, injected in tests. */ |
| 46 | +export interface DependencyAuditIo { |
| 47 | + headSha: (repoPath: string) => string | null |
| 48 | + /** Files changed between two commits; `null` when the diff fails (rewritten history) → treat as changed. */ |
| 49 | + changedFiles: (repoPath: string, fromSha: string) => string[] | null |
| 50 | + hasLockfile: (repoPath: string) => boolean |
| 51 | + /** Vulnerability counts from `npm audit`; `null` when the audit itself fails. */ |
| 52 | + runNpmAudit: (repoPath: string) => Promise<AuditCounts | null> |
| 53 | +} |
| 54 | + |
| 55 | +export type AuditSignalPublisher = (input: { kind: string; payload?: Record<string, unknown>; dedupeKey?: string; ttlMs?: number }) => void |
| 56 | + |
| 57 | +const STATE_PATH = join(homedir(), '.codekin', 'dependency-audit.json') |
| 58 | +const MANIFEST_RE = /(^|\/)package(-lock)?\.json$/ |
| 59 | +const SIGNAL_TTL_MS = 24 * 60 * 60 * 1000 |
| 60 | + |
| 61 | +// --------------------------------------------------------------------------- |
| 62 | +// Default IO |
| 63 | +// --------------------------------------------------------------------------- |
| 64 | + |
| 65 | +function gitOut(repoPath: string, args: string[]): string | null { |
| 66 | + try { |
| 67 | + return execFileSync('git', args, { cwd: repoPath, timeout: 10_000 }).toString().trim() |
| 68 | + } catch { |
| 69 | + return null |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +export function parseAuditCounts(stdout: string): AuditCounts | null { |
| 74 | + try { |
| 75 | + const parsed = JSON.parse(stdout) as { metadata?: { vulnerabilities?: Partial<AuditCounts> } } |
| 76 | + const v = parsed.metadata?.vulnerabilities |
| 77 | + if (!v) return null |
| 78 | + return { info: v.info ?? 0, low: v.low ?? 0, moderate: v.moderate ?? 0, high: v.high ?? 0, critical: v.critical ?? 0 } |
| 79 | + } catch { |
| 80 | + return null |
| 81 | + } |
| 82 | +} |
| 83 | + |
| 84 | +const DEFAULT_IO: DependencyAuditIo = { |
| 85 | + headSha: (repoPath) => gitOut(repoPath, ['rev-parse', 'HEAD']), |
| 86 | + changedFiles: (repoPath, fromSha) => { |
| 87 | + const out = gitOut(repoPath, ['diff', '--name-only', `${fromSha}..HEAD`]) |
| 88 | + return out === null ? null : out.split('\n').filter(Boolean) |
| 89 | + }, |
| 90 | + hasLockfile: (repoPath) => existsSync(join(repoPath, 'package-lock.json')), |
| 91 | + runNpmAudit: async (repoPath) => { |
| 92 | + try { |
| 93 | + const { stdout } = await execFileAsync('npm', ['audit', '--omit=dev', '--json'], { |
| 94 | + cwd: repoPath, timeout: 120_000, maxBuffer: 20 * 1024 * 1024, |
| 95 | + }) |
| 96 | + return parseAuditCounts(stdout) |
| 97 | + } catch (err) { |
| 98 | + // npm audit exits non-zero when vulnerabilities exist — the JSON is still on stdout. |
| 99 | + const stdout = (err as { stdout?: string }).stdout |
| 100 | + return stdout ? parseAuditCounts(stdout) : null |
| 101 | + } |
| 102 | + }, |
| 103 | +} |
| 104 | + |
| 105 | +// --------------------------------------------------------------------------- |
| 106 | +// State |
| 107 | +// --------------------------------------------------------------------------- |
| 108 | + |
| 109 | +function loadState(statePath: string): AuditStateFile { |
| 110 | + try { |
| 111 | + if (existsSync(statePath)) return JSON.parse(readFileSync(statePath, 'utf-8')) as AuditStateFile |
| 112 | + } catch (err) { |
| 113 | + console.error('[dependency-audit] Failed to load state:', err) |
| 114 | + } |
| 115 | + return {} |
| 116 | +} |
| 117 | + |
| 118 | +function saveState(statePath: string, state: AuditStateFile): void { |
| 119 | + const dir = join(statePath, '..') |
| 120 | + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) |
| 121 | + writeFileSync(statePath, JSON.stringify(state, null, 2), { encoding: 'utf-8', mode: 0o600 }) |
| 122 | +} |
| 123 | + |
| 124 | +/** Alert when actionable (high+critical) exposure exists and has changed. */ |
| 125 | +export function shouldAlert(previous: AuditCounts | undefined, current: AuditCounts): boolean { |
| 126 | + const actionable = current.high + current.critical |
| 127 | + if (actionable === 0) return false |
| 128 | + const prevActionable = previous ? previous.high + previous.critical : -1 |
| 129 | + return actionable !== prevActionable |
| 130 | +} |
| 131 | + |
| 132 | +// --------------------------------------------------------------------------- |
| 133 | +// Sweep |
| 134 | +// --------------------------------------------------------------------------- |
| 135 | + |
| 136 | +export async function runDependencyAuditSweep(opts: { |
| 137 | + publish: AuditSignalPublisher |
| 138 | + io?: Partial<DependencyAuditIo> |
| 139 | + statePath?: string |
| 140 | + repoPaths?: string[] |
| 141 | +}): Promise<void> { |
| 142 | + const io: DependencyAuditIo = { ...DEFAULT_IO, ...opts.io } |
| 143 | + const statePath = opts.statePath ?? STATE_PATH |
| 144 | + const repoPaths = opts.repoPaths ?? [...new Set(loadWorkflowConfig().reviewRepos.map(r => r.repoPath))] |
| 145 | + const state = loadState(statePath) |
| 146 | + let dirty = false |
| 147 | + |
| 148 | + for (const repoPath of repoPaths) { |
| 149 | + try { |
| 150 | + const sha = io.headSha(repoPath) |
| 151 | + if (!sha) continue |
| 152 | + |
| 153 | + const prior = state[repoPath] |
| 154 | + if (prior?.sha === sha) continue // nothing moved since last look |
| 155 | + |
| 156 | + // Only audit when the dependency manifests actually changed. A failed |
| 157 | + // diff (rewritten history) counts as changed — err toward auditing. |
| 158 | + if (prior) { |
| 159 | + const changed = io.changedFiles(repoPath, prior.sha) |
| 160 | + if (changed !== null && !changed.some(f => MANIFEST_RE.test(f))) { |
| 161 | + state[repoPath] = { ...prior, sha, at: new Date().toISOString() } |
| 162 | + dirty = true |
| 163 | + continue |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + if (!io.hasLockfile(repoPath)) { |
| 168 | + state[repoPath] = { sha, at: new Date().toISOString() } |
| 169 | + dirty = true |
| 170 | + continue |
| 171 | + } |
| 172 | + |
| 173 | + const counts = await io.runNpmAudit(repoPath) |
| 174 | + if (!counts) { |
| 175 | + console.error(`[dependency-audit] npm audit failed for ${repoPath} — will retry next sweep`) |
| 176 | + continue // state untouched: retried on the next sweep |
| 177 | + } |
| 178 | + |
| 179 | + if (shouldAlert(prior?.counts, counts)) { |
| 180 | + const repoName = repoPath.split('/').pop() ?? repoPath |
| 181 | + opts.publish({ |
| 182 | + kind: 'dependency-audit', |
| 183 | + payload: { repoPath, repoName, sha, counts, previous: prior?.counts ?? null }, |
| 184 | + dedupeKey: `dependency-audit::${repoPath}::${sha}`, |
| 185 | + ttlMs: SIGNAL_TTL_MS, |
| 186 | + }) |
| 187 | + } |
| 188 | + |
| 189 | + state[repoPath] = { sha, at: new Date().toISOString(), counts } |
| 190 | + dirty = true |
| 191 | + } catch (err) { |
| 192 | + console.error(`[dependency-audit] Sweep error for ${repoPath}:`, err) |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + if (dirty) saveState(statePath, state) |
| 197 | +} |
0 commit comments