diff --git a/.gitignore b/.gitignore index ed6d763..46132b2 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ docs/dev-notes.md docs/plans/ docs/superpowers/ .superpowers/ + +# Playwright MCP session artifacts +.playwright-mcp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a1c55d..e96b032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to HexOps are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.21.0] - 2026-08-10 + +### Added +- **Override hygiene scanning** — `OverrideHygieneSource` runs cve-lite's `overrides` subcommand (rules OA001–OA009) as a fifth `ScanSource`, surfacing override-configuration defects as `config` findings on the fleet security view: orphaned targets, floating tags, wrong package-manager section, surpassed pins, nested ineffective overrides, and stale floors. +- **Override hygiene panel** on the per-project security view — rule id, severity, package, `file > jsonPath` location, and the runnable fix command for each finding, with a confirm-gated "Fix all" control. +- `GET /api/security/overrides/[id]` — cached override audit (1h TTL), `?force` bypasses. +- `POST /api/security/overrides/[id]/fix` — runs `cve-lite overrides --fix`, optionally scoped to a single OA rule. Gated by the new `OVERRIDE_HYGIENE_FIX_ENABLED` flag, which ships **disabled** and is enforced server-side with a 409 before any project lookup, so a stale browser tab cannot bypass it. The fix runs an install and is wrapped in the dev-server guard (#109). +- **Partial-scan reporting** — `scanCompleteness()` distinguishes a genuinely clean scan from one that could not check everything. An incomplete scan raises a warning banner naming the unresolved advisories and skipped dependencies, and marks the source degraded on the fleet cards, so it can no longer read as a green all-clear. +- `ScanSource.scan` now returns `{ findings, warning? }`, wiring the previously declared-but-unused `SourceResult.warning` end to end and rendering it on the source card. + +### Changed +- `cve-lite-cli` 1.24.0 → 1.28.0. +- Scan cache entries record the resolved `cve-lite-cli` version; a mismatch is treated as a miss, so a dependency bump invalidates stale reports immediately instead of letting them age out over the TTL. This also closes the stale-fallback path, which could otherwise resurrect a pre-bump report when a scan fails. +- `json-cache.ts` is now the single namespaced cache implementation; `cve-lite-cache.ts` is a thin wrapper over it, preserving its own public API and on-disk filenames. +- cve-lite's phantom-dependency rules (PD001/PD002) are excluded from override-hygiene findings — `DependencyHealthSource` (#125) remains the single phantom-dep authority, and it models workspace boundaries correctly where cve-lite currently does not. + +### Fixed +- Parent-upgrade confidence badges no longer render gray for every finding — cve-lite 1.28 renamed the `confidence` values from `exact-direct-child`/`best-effort` to `verified`/`unverified`, and the badge colour map still keyed on the old strings. +- Unverified parent-upgrade recommendations are now visually distinct from verified ones, rather than being presented as equally trustworthy. 1.28 only recommends a parent upgrade it has proven resolves the vulnerable package. +- A cache entry with a corrupt `cachedAt` is no longer served as fresh — a non-finite age is now a cache miss (previously `NaN > ttl` evaluated false). +- Override findings that share a rule id and message text no longer collapse into one in the merger; the `jsonPath` is folded into the finding path so each override entry stays distinct. + +### Security +- **next 16.2.10 → 16.3.0** — clears 9 advisories, 4 of them high: middleware/proxy bypass in App Router with Turbopack and a single locale (GHSA-6gpp-xcg3-4w24), SSRF in Server Actions on custom servers (GHSA-89xv-2m56-2m9x), SSRF via attacker-controlled rewrite destination hostname (GHSA-p9j2-gv94-2wf4), and denial of service in App Router Server Actions (GHSA-m99w-x7hq-7vfj). 16.3.0 was chosen over the 16.2.11 minimum because it also resolves the `next → postcss` path. +- **postcss override floor `^8.5.15` → `^8.5.23`** — the previous floor resolved to 8.5.16, still exposed to path traversal via `sourceMappingURL` auto-loading (GHSA-r28c-9q8g-f849) and GHSA-fxqj-rqcc-2cmp. Now resolves 8.5.26. The override is flat, so it also covers the `@tailwindcss/postcss → postcss` path the next upgrade alone does not reach. +- `nanoid` and `sharp` advisories cleared as a side effect of the next upgrade. + +--- + ## [0.20.1] - 2026-05-21 ### Performance diff --git a/package.json b/package.json index dd05430..cbf00cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hexops", - "version": "0.20.1", + "version": "0.21.0", "private": true, "author": "Hexaxia Technologies", "repository": { @@ -68,7 +68,7 @@ "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", "@vitest/ui": "^4.1.9", - "cve-lite-cli": "1.24.0", + "cve-lite-cli": "1.28.0", "tailwindcss": "4.3.2", "tsx": "^4.22.5", "tw-animate-css": "^1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f49b02..76c31ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,8 +112,8 @@ importers: specifier: ^4.1.9 version: 4.1.9(vitest@4.1.9) cve-lite-cli: - specifier: 1.24.0 - version: 1.24.0 + specifier: 1.28.0 + version: 1.28.0 tailwindcss: specifier: 4.3.2 version: 4.3.2 @@ -1756,8 +1756,8 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - cve-lite-cli@1.24.0: - resolution: {integrity: sha512-JrHIIp9ye8nYQ9EShLUTPtO0lJ03LfzoAiTO3rZjxeyAWunBE/XerOBWEhIGshDfBwNqDYgNkj7UjPpnHOeegQ==} + cve-lite-cli@1.28.0: + resolution: {integrity: sha512-SvccG7tAs4nrPzWU+JLRnzVmufm6c+l65J16+Y+VbM8h5+wqR4JLtWdlLwSm3OTcnarZ2ukEJriLb0glnpAHBw==} engines: {node: '>=18'} hasBin: true @@ -4239,7 +4239,7 @@ snapshots: csstype@3.2.3: {} - cve-lite-cli@1.24.0: + cve-lite-cli@1.28.0: dependencies: better-sqlite3: 12.11.1 fflate: 0.8.3 diff --git a/src/app/api/security/overrides/[id]/fix/route.rule-validation.test.ts b/src/app/api/security/overrides/[id]/fix/route.rule-validation.test.ts new file mode 100644 index 0000000..9e1cb10 --- /dev/null +++ b/src/app/api/security/overrides/[id]/fix/route.rule-validation.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// This file exists to exercise the rule-validation branch, which the flag=false +// test in route.test.ts can never reach (it 409s before validation runs). The +// flag must be mocked true here — via a separate test file rather than +// vi.resetModules()/vi.doMock() in the same file — to get an isolated module +// registry with OVERRIDE_HYGIENE_FIX_ENABLED=true without touching the shipped +// default in src/lib/auto-apply-flag.ts, which must stay false (F4). +vi.mock('@/lib/auto-apply-flag', () => ({ OVERRIDE_HYGIENE_FIX_ENABLED: true })); +vi.mock('@/lib/config', () => ({ + getProject: vi.fn(() => ({ id: 'p', name: 'p', path: '/tmp/p' })), +})); +vi.mock('@/lib/security/override-audit', () => ({ + runOverrideAudit: vi.fn(), + overrideAuditAvailable: vi.fn(() => true), +})); +vi.mock('@/lib/process-manager', () => ({ runWithDevServerGuard: vi.fn() })); +vi.mock('@/lib/security/runner', () => ({ scanProject: vi.fn().mockResolvedValue(undefined) })); + +import { POST } from './route'; +import { runWithDevServerGuard } from '@/lib/process-manager'; +import { runOverrideAudit } from '@/lib/security/override-audit'; +import { scanProject } from '@/lib/security/runner'; + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); +const req = (body: unknown) => + new Request('http://x/', { method: 'POST', body: JSON.stringify(body) }) as never; + +beforeEach(() => { vi.clearAllMocks(); }); + +describe('POST /api/security/overrides/[id]/fix — rule validation (flag enabled)', () => { + it('400s when rule is an array (would otherwise coerce to a matching string via toString())', async () => { + const res = await POST(req({ rule: ['OA009'] }), params('p')); + expect(res.status).toBe(400); + expect(vi.mocked(runWithDevServerGuard)).not.toHaveBeenCalled(); + }); + + it('400s when rule is a number', async () => { + const res = await POST(req({ rule: 123 }), params('p')); + expect(res.status).toBe(400); + expect(vi.mocked(runWithDevServerGuard)).not.toHaveBeenCalled(); + }); + + it('still accepts a valid string rule and proceeds past validation', async () => { + vi.mocked(runWithDevServerGuard).mockResolvedValue({ + decision: 'passthrough', + reason: 'no managed dev server', + blocked: false, + result: { ok: true, summary: 'done' }, + stopped: false, + restarted: false, + } as never); + vi.mocked(runOverrideAudit).mockResolvedValue({ findings: [] }); + const res = await POST(req({ rule: 'OA009' }), params('p')); + expect(res.status).toBe(200); + expect(vi.mocked(runWithDevServerGuard)).toHaveBeenCalled(); + const body = await res.json(); + expect(body.devServerGuard).toEqual({ action: 'passthrough', stopped: false, restarted: false }); + }); +}); diff --git a/src/app/api/security/overrides/[id]/fix/route.test.ts b/src/app/api/security/overrides/[id]/fix/route.test.ts new file mode 100644 index 0000000..d3b2dcb --- /dev/null +++ b/src/app/api/security/overrides/[id]/fix/route.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/lib/auto-apply-flag', () => ({ OVERRIDE_HYGIENE_FIX_ENABLED: false })); +vi.mock('@/lib/config', () => ({ getProject: vi.fn() })); +vi.mock('@/lib/security/override-audit', () => ({ + runOverrideAudit: vi.fn(), + overrideAuditAvailable: vi.fn(() => true), +})); +vi.mock('@/lib/process-manager', () => ({ runWithDevServerGuard: vi.fn() })); +vi.mock('@/lib/security/runner', () => ({ scanProject: vi.fn() })); + +import { POST } from './route'; +import { getProject } from '@/lib/config'; + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); +const req = (body: unknown) => + new Request('http://x/', { method: 'POST', body: JSON.stringify(body) }) as never; + +beforeEach(() => { vi.clearAllMocks(); }); + +describe('POST /api/security/overrides/[id]/fix', () => { + it('409s when OVERRIDE_HYGIENE_FIX_ENABLED is off, before touching the project', async () => { + const res = await POST(req({}), params('p')); + expect(res.status).toBe(409); + expect(vi.mocked(getProject)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/security/overrides/[id]/fix/route.ts b/src/app/api/security/overrides/[id]/fix/route.ts new file mode 100644 index 0000000..c60b6e6 --- /dev/null +++ b/src/app/api/security/overrides/[id]/fix/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { join } from 'path'; +import { getProject } from '@/lib/config'; +import { runOverrideAudit, overrideAuditAvailable } from '@/lib/security/override-audit'; +import { scanProject as runSecurityScan } from '@/lib/security/runner'; +import { OVERRIDE_HYGIENE_FIX_ENABLED } from '@/lib/auto-apply-flag'; +import { runWithDevServerGuard } from '@/lib/process-manager'; +import { logger } from '@/lib/logger'; + +const execAsync = promisify(exec); +const BIN = join(process.cwd(), 'node_modules', '.bin', 'cve-lite'); + +/** Only OA rule ids are accepted — PD rules are not fixable through this path. */ +const RULE_RE = /^OA\d{3}$/; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + // Checked first so a stale browser tab cannot bypass the kill switch (#96/#97). + if (!OVERRIDE_HYGIENE_FIX_ENABLED) { + return NextResponse.json( + { + ok: false, + error: + 'Override hygiene fixes are disabled in HexOps. Set OVERRIDE_HYGIENE_FIX_ENABLED to true in src/lib/auto-apply-flag.ts and rebuild to enable.', + }, + { status: 409 }, + ); + } + + const { id } = await params; + const project = getProject(id); + if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + if (!overrideAuditAvailable()) { + return NextResponse.json({ error: 'cve-lite not installed' }, { status: 503 }); + } + + const body = (await req.json().catch(() => ({}))) as { rule?: unknown }; + // typeof guard first: RULE_RE.test() coerces a non-string via toString(), so + // ["OA009"] would otherwise pass the anchored regex and reach JSON.stringify + // as an array, emitting the unquoted shell token [ "OA009" ] (F4). + if (body.rule !== undefined && (typeof body.rule !== 'string' || !RULE_RE.test(body.rule))) { + return NextResponse.json({ error: 'rule must match OA###' }, { status: 400 }); + } + const rule = body.rule as string | undefined; + const ruleFlags = rule ? ['--rule', rule] : []; + + // overrides --fix runs an install; guard the dev server (#109). + const guardOutcome = await runWithDevServerGuard( + project, + async () => { + const cmd = [BIN, project.path, 'overrides', '--fix', ...ruleFlags] + .map((p) => JSON.stringify(p)) + .join(' '); + try { + const { stdout } = await execAsync(cmd, { + cwd: project.path, + timeout: 300_000, + maxBuffer: 64 * 1024 * 1024, + }); + return { ok: true, summary: stdout.slice(-2000) }; + } catch (err) { + // Exit 1 = findings remain; exit 2 = fix ran but did not verify; + // exit 3 = tool error. Treat all as not-ok and surface the output. + const summary = err instanceof Error ? err.message.slice(-2000) : 'override fix failed'; + return { ok: false, summary }; + } + }, + { clearBuildDir: true }, + ); + + if (guardOutcome.blocked) { + logger.info('api', 'override_hygiene_fix_blocked', `overrides --fix on ${id} blocked by dev-server guard: ${guardOutcome.reason}`, { + projectId: id, + }); + return NextResponse.json( + { + ok: false, + error: guardOutcome.reason, + devServerGuard: { action: guardOutcome.decision, reason: guardOutcome.reason }, + }, + { status: 409 }, + ); + } + + const { ok, summary } = guardOutcome.result!; + // Surface stopped/restarted/restartError like the sibling fix endpoints do — + // otherwise a restart failure after a successful fix is silently swallowed + // and HexOps reports plain success while the dev server stays dead (F5). + const devServerGuard = { + action: guardOutcome.decision, + stopped: guardOutcome.stopped, + restarted: guardOutcome.restarted, + ...(guardOutcome.restartError ? { restartError: guardOutcome.restartError } : {}), + }; + if (ok) { + await runOverrideAudit(project, { force: true }).catch(() => {}); + await runSecurityScan(project).catch(() => {}); + } + logger.info('api', 'override_hygiene_fix', `overrides --fix on ${id} (ok=${ok})`, { + projectId: id, + }); + return NextResponse.json({ ok, summary, rescanned: ok, devServerGuard }); +} diff --git a/src/app/api/security/overrides/[id]/route.test.ts b/src/app/api/security/overrides/[id]/route.test.ts new file mode 100644 index 0000000..097fdff --- /dev/null +++ b/src/app/api/security/overrides/[id]/route.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/lib/config', () => ({ getProject: vi.fn() })); +vi.mock('@/lib/security/override-audit', () => ({ + runOverrideAudit: vi.fn(), + overrideAuditAvailable: vi.fn(() => true), +})); + +import { GET } from './route'; +import { getProject } from '@/lib/config'; +import { runOverrideAudit, overrideAuditAvailable } from '@/lib/security/override-audit'; + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +beforeEach(() => { vi.clearAllMocks(); }); + +describe('GET /api/security/overrides/[id]', () => { + it('404s for an unknown project', async () => { + vi.mocked(getProject).mockReturnValue(undefined as never); + const res = await GET(new Request('http://x/') as never, params('nope')); + expect(res.status).toBe(404); + }); + + it('503s when cve-lite is not installed', async () => { + vi.mocked(getProject).mockReturnValue({ id: 'p', name: 'p', path: '/tmp' } as never); + vi.mocked(overrideAuditAvailable).mockReturnValue(false); + const res = await GET(new Request('http://x/') as never, params('p')); + expect(res.status).toBe(503); + }); + + it('returns findings and mapped rows', async () => { + vi.mocked(getProject).mockReturnValue({ id: 'p', name: 'p', path: '/tmp' } as never); + vi.mocked(overrideAuditAvailable).mockReturnValue(true); + vi.mocked(runOverrideAudit).mockResolvedValue({ + findings: [{ ruleId: 'OA009', severity: 'low', package: { name: 'ws' }, message: 'stale floor' }], + }); + const res = await GET(new Request('http://x/') as never, params('p')); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.findings).toHaveLength(1); + expect(body.rows[0].title).toContain('OA009'); + }); + + it('excludes PD001/PD002 from the raw findings array, not just rows (F2)', async () => { + vi.mocked(getProject).mockReturnValue({ id: 'p', name: 'p', path: '/tmp' } as never); + vi.mocked(overrideAuditAvailable).mockReturnValue(true); + vi.mocked(runOverrideAudit).mockResolvedValue({ + findings: [ + { ruleId: 'PD001', severity: 'high', package: { name: 'js-yaml' }, message: 'phantom' }, + { ruleId: 'OA009', severity: 'low', package: { name: 'ws' }, message: 'stale floor' }, + ], + }); + const res = await GET(new Request('http://x/') as never, params('p')); + const body = await res.json(); + expect(body.findings).toHaveLength(1); + expect(body.findings[0].ruleId).toBe('OA009'); + }); + + it('passes force through when ?force is present', async () => { + vi.mocked(getProject).mockReturnValue({ id: 'p', name: 'p', path: '/tmp' } as never); + vi.mocked(overrideAuditAvailable).mockReturnValue(true); + vi.mocked(runOverrideAudit).mockResolvedValue({ findings: [] }); + await GET(new Request('http://x/?force=1') as never, params('p')); + expect(vi.mocked(runOverrideAudit).mock.calls[0][1]).toEqual({ force: true }); + }); +}); diff --git a/src/app/api/security/overrides/[id]/route.ts b/src/app/api/security/overrides/[id]/route.ts new file mode 100644 index 0000000..9173710 --- /dev/null +++ b/src/app/api/security/overrides/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getProject } from '@/lib/config'; +import { runOverrideAudit, overrideAuditAvailable } from '@/lib/security/override-audit'; +import { parseOverrideAuditJson, EXCLUDED_RULES } from '@/lib/security/sources/override-hygiene'; +import { logger } from '@/lib/logger'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const project = getProject(id); + if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + if (!overrideAuditAvailable()) { + return NextResponse.json({ error: 'cve-lite not installed' }, { status: 503 }); + } + const force = req.nextUrl?.searchParams.get('force') != null + || new URL(req.url).searchParams.get('force') != null; + try { + const report = await runOverrideAudit(project, { force }); + // The panel renders `findings` directly, so it must carry the same + // PD001/PD002 exclusion as `rows` — otherwise a real phantom dep shows up + // twice: once from DependencyHealthSource, once from this raw array (F2). + const findings = (report.findings ?? []).filter( + (f) => !EXCLUDED_RULES.has(f.ruleId ?? ''), + ); + return NextResponse.json({ + findings, + rows: parseOverrideAuditJson(report), + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'override audit failed'; + logger.error('api', 'override_audit_failed', message, { projectId: id }); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/components/security/cve-lite/completeness-banner.tsx b/src/components/security/cve-lite/completeness-banner.tsx new file mode 100644 index 0000000..095e83d --- /dev/null +++ b/src/components/security/cve-lite/completeness-banner.tsx @@ -0,0 +1,30 @@ +'use client'; +import { scanCompleteness } from '@/lib/security/cve-lite-view'; +import type { CveLiteOutput } from '@/lib/security/sources/cve-lite'; + +export function CompletenessBanner({ report }: { report: CveLiteOutput | null }) { + if (!report) return null; + const c = scanCompleteness(report); + if (c.complete) return null; + return ( +
+
Partial scan — this is not an all-clear.
+ +
+ ); +} diff --git a/src/components/security/cve-lite/cve-lite-findings.tsx b/src/components/security/cve-lite/cve-lite-findings.tsx index 2182721..1cab33a 100644 --- a/src/components/security/cve-lite/cve-lite-findings.tsx +++ b/src/components/security/cve-lite/cve-lite-findings.tsx @@ -4,8 +4,8 @@ import type { FindingRow } from '@/lib/security/cve-lite-view'; import { SeverityBadge } from '@/components/security/severity-badge'; const CONF = (c?: string) => - c === 'exact-direct-child' ? 'border-green-700 text-green-300 bg-green-900/20' - : c === 'best-effort' ? 'border-amber-700 text-amber-300 bg-amber-900/20' + c === 'verified' ? 'border-green-700 text-green-300 bg-green-900/20' + : c === 'unverified' ? 'border-amber-700 text-amber-300 bg-amber-900/20' : 'border-zinc-700 text-zinc-400'; export function CveLiteFindings({ rows, onApply }: { rows: FindingRow[]; onApply?: (row: FindingRow) => void }) { diff --git a/src/components/security/cve-lite/override-hygiene-panel.tsx b/src/components/security/cve-lite/override-hygiene-panel.tsx new file mode 100644 index 0000000..b8431ca --- /dev/null +++ b/src/components/security/cve-lite/override-hygiene-panel.tsx @@ -0,0 +1,140 @@ +'use client'; +import { useCallback, useEffect, useState } from 'react'; +import { SeverityBadge } from '@/components/security/severity-badge'; +import { ConfirmDialog } from '@/components/security/cve-lite/confirm-dialog'; +import { OVERRIDE_HYGIENE_FIX_ENABLED } from '@/lib/auto-apply-flag'; +import type { OverrideFinding } from '@/lib/security/override-audit'; +import type { Severity } from '@/lib/security/types'; + +const SEV = (s?: string): Severity => { + const v = (s ?? 'info').toLowerCase(); + if (v === 'critical' || v === 'high' || v === 'low' || v === 'info') return v; + if (v === 'medium' || v === 'moderate') return 'medium'; + return 'info'; +}; + +export function OverrideHygienePanel({ projectId }: { projectId: string }) { + const [findings, setFindings] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [confirmRule, setConfirmRule] = useState(null); + + const load = useCallback(async (force = false) => { + setError(null); + try { + const res = await fetch( + `/api/security/overrides/${encodeURIComponent(projectId)}${force ? '?force=1' : ''}`, + ); + const body = await res.json(); + if (!res.ok) { setError(body.error ?? 'Failed to load override audit'); return; } + setFindings(body.findings ?? []); + } catch { + setError('Failed to load override audit'); + } + }, [projectId]); + + useEffect(() => { void load(); }, [load]); + + const runFix = async () => { + setBusy(true); + try { + const res = await fetch(`/api/security/overrides/${encodeURIComponent(projectId)}/fix`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(confirmRule === 'ALL' ? {} : { rule: confirmRule }), + }); + const body = await res.json(); + if (!body.ok) setError(body.error ?? 'Override fix failed'); + await load(true); + } finally { + setBusy(false); + setConfirmRule(null); + } + }; + + if (error) return
{error}
; + if (findings === null) return
Auditing overrides…
; + if (findings.length === 0) { + return
No override hygiene findings.
; + } + + return ( + <> + + + + + + + + + + + + + {findings.map((f) => ( + + + + + + + + + ))} + +
RuleSeverityPackageLocationFindingFix
{f.ruleId}{f.package?.name ?? '—'} + {f.location?.file} + {f.location?.jsonPath ? ` > ${f.location.jsonPath}` : ''} + {f.message} + {f.fix?.runnableCommand + ? {f.fix.runnableCommand} + : } +
+
+ + {OVERRIDE_HYGIENE_FIX_ENABLED && ( + + )} +
+ {confirmRule && ( + +

+ Runs cve-lite overrides --fix against this project. It rewrites + override entries in package.json and runs an install. +

+

+ cve-lite can only remove, repin, move, or relocate an existing override — it + cannot introduce a new one. +

+ + } + onConfirm={() => void runFix()} + onCancel={() => setConfirmRule(null)} + /> + )} + + ); +} diff --git a/src/components/security/project-security-accordion.tsx b/src/components/security/project-security-accordion.tsx index 1e5cf6f..c812f2f 100644 --- a/src/components/security/project-security-accordion.tsx +++ b/src/components/security/project-security-accordion.tsx @@ -16,8 +16,10 @@ import { CveLiteFindings } from '@/components/security/cve-lite/cve-lite-finding import { CveLiteToolbar } from '@/components/security/cve-lite/cve-lite-toolbar'; import { CveLiteScanControls } from '@/components/security/cve-lite/cve-lite-scan-controls'; import { CveLiteManage } from '@/components/security/cve-lite/cve-lite-manage'; +import { OverrideHygienePanel } from '@/components/security/cve-lite/override-hygiene-panel'; import { ConfirmDialog } from '@/components/security/cve-lite/confirm-dialog'; import { PendingCommitBanner } from '@/components/security/cve-lite/pending-commit-banner'; +import { CompletenessBanner } from '@/components/security/cve-lite/completeness-banner'; import { SourcePluginCards } from '@/components/security/source-plugin-cards'; import type { PluginCardEntry } from '@/lib/security/plugins/types'; import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag'; @@ -1280,6 +1282,10 @@ export function ProjectSecurityAccordion({ {error &&
{error}
} {!loading && !error && visibleReport && ( <> + {/* Completeness is a property of the scan, not of the "imported only" view + filter — pass the unfiltered report so a filtered-out finding with an + unresolved advisory can't make a partial scan look clean (F1). */} +

Fix plan

@@ -1288,6 +1294,10 @@ export function ProjectSecurityAccordion({

Findings

+
+

Override hygiene

+ +
)} {confirm && ( diff --git a/src/components/security/source-card.tsx b/src/components/security/source-card.tsx index c2ead70..b68b53e 100644 --- a/src/components/security/source-card.tsx +++ b/src/components/security/source-card.tsx @@ -6,12 +6,16 @@ const SOURCE_DISPLAY_NAMES: Record = { 'pnpm-audit': 'pnpm-audit', grype: 'grype', 'cve-lite': 'cve-lite', + 'dependency-health': 'dependency-health', + 'override-hygiene': 'override-hygiene', }; const SOURCE_SCOPE: Record = { 'pnpm-audit': 'lockfile scanner', 'cve-lite': 'lockfile scanner', 'grype': 'filesystem/binary scanner', + 'dependency-health': 'manifest/source scanner', + 'override-hygiene': 'manifest override auditor', }; interface Tone { @@ -21,7 +25,7 @@ interface Tone { label: string; } -function pickTone(status: SourceResult['status'], findingCount: number): Tone { +function pickTone(status: SourceResult['status'], findingCount: number, warning?: string): Tone { if (status !== 'ok') { const map: Record, Tone> = { failed: { dot: 'bg-red-500', text: 'text-red-400', border: 'border-red-700/60', label: 'failed' }, @@ -30,6 +34,10 @@ function pickTone(status: SourceResult['status'], findingCount: number): Tone { }; return map[status]; } + if (warning) { + // Succeeded but did not cover everything — never show this as clean. + return { dot: 'bg-amber-500', text: 'text-amber-400', border: 'border-amber-700/60', label: 'partial' }; + } if (findingCount === 0) { return { dot: 'bg-green-500', text: 'text-green-400', border: 'border-zinc-800', label: 'clean' }; } @@ -48,7 +56,7 @@ export interface SourceCardProps { } export function SourceCard({ result, deepLinkHref }: SourceCardProps) { - const tone = pickTone(result.status, result.findingCount); + const tone = pickTone(result.status, result.findingCount, result.warning); const display = SOURCE_DISPLAY_NAMES[result.id] ?? result.id; return ( @@ -70,6 +78,9 @@ export function SourceCard({ result, deepLinkHref }: SourceCardProps) { {SOURCE_SCOPE[result.id]} )} + {result.warning && ( +
{result.warning}
+ )} {deepLinkHref && ( { const staleAt = new Date(Date.now() - 3_600_001).toISOString(); writeFileSync( join(dir, "cve-lite-p1.json"), - JSON.stringify({ cachedAt: staleAt, ttlMs: 3_600_000, report }), + // toolVersion must match cveLiteToolVersion() so the version gate lets + // execution reach the TTL comparison this test is meant to exercise — + // without it, the entry is rejected before the TTL check ever runs (F3). + JSON.stringify({ cachedAt: staleAt, ttlMs: 3_600_000, toolVersion: cveLiteToolVersion(), report }), ); expect(readCveLiteCache("p1")).toBeNull(); }); @@ -51,7 +55,7 @@ describe("cve-lite cache", () => { const recent = new Date(Date.now() - 60_000).toISOString(); writeFileSync( join(dir, "cve-lite-p1.json"), - JSON.stringify({ cachedAt: recent, ttlMs: 3_600_000, report }), + JSON.stringify({ cachedAt: recent, ttlMs: 3_600_000, toolVersion: cveLiteToolVersion(), report }), ); expect(readCveLiteCache("p1")?.report).toEqual(report); }); @@ -65,8 +69,50 @@ describe("cve-lite cache", () => { const staleAt = new Date(Date.now() - 7_200_000).toISOString(); writeFileSync( join(dir, "cve-lite-p1.json"), - JSON.stringify({ cachedAt: staleAt, ttlMs: 3_600_000, report }), + JSON.stringify({ cachedAt: staleAt, ttlMs: 3_600_000, toolVersion: cveLiteToolVersion(), report }), ); expect(readCveLiteCache("p1", { ignoreTtl: true })?.report).toEqual(report); }); + + it("treats a cache entry from a different tool version as a miss", () => { + const dir = mkdtempSync(join(tmpdir(), "cache-ver-")); + _setCveLiteCacheDirForTest(dir); + writeFileSync( + join(dir, "cve-lite-p1.json"), + JSON.stringify({ + cachedAt: new Date().toISOString(), + ttlMs: 3_600_000, + toolVersion: "1.24.0", + report: { findingCount: 99 }, + }), + ); + expect(readCveLiteCache("p1")).toBeNull(); + expect(readCveLiteCache("p1", { ignoreTtl: true })).toBeNull(); + rmSync(dir, { recursive: true, force: true }); + }); + + it("accepts a cache entry written by the current tool version", () => { + const dir = mkdtempSync(join(tmpdir(), "cache-ver-ok-")); + _setCveLiteCacheDirForTest(dir); + writeCveLiteCache("p2", { findingCount: 3 }); + expect(readCveLiteCache("p2")?.report.findingCount).toBe(3); + rmSync(dir, { recursive: true, force: true }); + }); + + it("treats a corrupt (non-finite) cachedAt as a cache miss, even with ignoreTtl", () => { + // A NaN age from `Date.now() - new Date(entry.cachedAt).getTime()` must + // never be treated as "fresh" (NaN > ttlMs is false) — this is a + // regression test for that bug, not just a TTL check. + writeFileSync( + join(dir, "cve-lite-p1.json"), + JSON.stringify({ cachedAt: "not-a-date", ttlMs: 3_600_000, toolVersion: cveLiteToolVersion(), report }), + ); + expect(readCveLiteCache("p1")).toBeNull(); + expect(readCveLiteCache("p1", { ignoreTtl: true })).toBeNull(); + }); + + it("writes the on-disk filename `cve-lite-.json` (compatibility with the pre-refactor cache)", () => { + writeCveLiteCache("compat-p1", report); + expect(readdirSync(dir)).toContain("cve-lite-compat-p1.json"); + }); }); diff --git a/src/lib/security/cve-lite-cache.ts b/src/lib/security/cve-lite-cache.ts index 6aa8d37..df4126d 100644 --- a/src/lib/security/cve-lite-cache.ts +++ b/src/lib/security/cve-lite-cache.ts @@ -1,72 +1,71 @@ -import { - existsSync, - mkdirSync, - readFileSync, - renameSync, - unlinkSync, - writeFileSync, -} from "fs"; -import { join } from "path"; +import { createRequire } from "module"; import type { CveLiteOutput } from "./sources/cve-lite"; +import { + _setJsonCacheDirForTest, + readJsonCacheEntry, + writeJsonCache, +} from "./json-cache"; export interface CveLiteCacheEntry { cachedAt: string; ttlMs: number; + toolVersion?: string; report: CveLiteOutput; } -const DEFAULT_TTL_MS = 3_600_000; // 1 hour -const DEFAULT_CACHE_DIR = join(process.cwd(), ".hexops", "cache"); -let cacheDir = DEFAULT_CACHE_DIR; +/** The namespace this wrapper occupies in the shared json-cache. */ +const NAMESPACE = "cve-lite" as const; -export function _setCveLiteCacheDirForTest(dir: string) { - cacheDir = dir; +let cachedToolVersion: string | null = null; + +/** + * Resolved version of the installed cve-lite-cli. Cache entries record it so a + * dependency bump invalidates every entry immediately instead of letting stale + * reports age out over the TTL — including via the ignoreTtl stale-fallback path. + */ +export function cveLiteToolVersion(): string { + if (cachedToolVersion !== null) return cachedToolVersion; + try { + const require = createRequire(import.meta.url); + const pkg = require("cve-lite-cli/package.json") as { version?: string }; + cachedToolVersion = pkg.version ?? "unknown"; + } catch { + cachedToolVersion = "unknown"; + } + return cachedToolVersion; } -function cachePath(projectId: string) { - return join(cacheDir, `cve-lite-${projectId}.json`); +/** + * Thin wrapper over json-cache's shared test-dir setter. There is only one + * on-disk cache directory; this and `_setJsonCacheDirForTest` both point at + * it, so tests may call either. + */ +export function _setCveLiteCacheDirForTest(dir: string) { + _setJsonCacheDirForTest(dir); } export function readCveLiteCache( projectId: string, opts: { ignoreTtl?: boolean } = {}, ): CveLiteCacheEntry | null { - const path = cachePath(projectId); - if (!existsSync(path)) return null; - try { - const entry = JSON.parse(readFileSync(path, "utf-8")) as CveLiteCacheEntry; - if (!entry || typeof entry.cachedAt !== "string") return null; - if (!opts.ignoreTtl) { - const age = Date.now() - new Date(entry.cachedAt).getTime(); - if (age > (entry.ttlMs ?? DEFAULT_TTL_MS)) return null; - } - return entry; - } catch { - return null; - } + const entry = readJsonCacheEntry( + NAMESPACE, + projectId, + cveLiteToolVersion(), + opts, + ); + if (!entry) return null; + return { + cachedAt: entry.cachedAt, + ttlMs: entry.ttlMs, + toolVersion: entry.toolVersion, + report: entry.payload, + }; } export function writeCveLiteCache( projectId: string, report: CveLiteOutput, ): void { - if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); - const finalPath = cachePath(projectId); - const tmp = `${finalPath}.${process.pid}.${Date.now()}.tmp`; - const entry: CveLiteCacheEntry = { - cachedAt: new Date().toISOString(), - ttlMs: DEFAULT_TTL_MS, - report, - }; - writeFileSync(tmp, JSON.stringify(entry, null, 2)); - try { - renameSync(tmp, finalPath); - } catch (err) { - try { - unlinkSync(tmp); - } catch { - /* ignore */ - } - throw err; - } + writeJsonCache(NAMESPACE, projectId, cveLiteToolVersion(), report); } diff --git a/src/lib/security/cve-lite-view.test.ts b/src/lib/security/cve-lite-view.test.ts index 37522dc..792f0d3 100644 --- a/src/lib/security/cve-lite-view.test.ts +++ b/src/lib/security/cve-lite-view.test.ts @@ -1,13 +1,17 @@ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { selectFixPlan, findingRows } from './cve-lite-view'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { selectFixPlan, findingRows, scanCompleteness } from './cve-lite-view'; import type { CveLiteOutput } from './sources/cve-lite'; const fixture = JSON.parse( readFileSync(join(__dirname, 'sources/__fixtures__/cve-lite-sample.json'), 'utf-8'), ) as CveLiteOutput; +const FIXTURE = JSON.parse( + readFileSync(join(__dirname, 'sources/__fixtures__/cve-lite-1.28-scan.json'), 'utf-8'), +); + describe('selectFixPlan', () => { const plan = selectFixPlan(fixture); @@ -52,3 +56,93 @@ describe('findingRows', () => { expect(axios?.confidence).toBeUndefined(); }); }); + +describe('findingRows — 1.28 parent-upgrade confidence', () => { + it('reads the verified confidence value emitted by 1.28', () => { + const rows = findingRows(FIXTURE); + const postcss = rows.find((r) => r.package === 'postcss'); + expect(postcss?.confidence).toBe('verified'); + expect(postcss?.parentVerified).toBe(true); + }); + + it('marks an unverified parent upgrade as not verified', () => { + const rows = findingRows({ + findings: [ + { + package: 'x', + severity: 'high', + recommendedParentUpgrade: { + package: 'p', + confidence: 'unverified', + reason: 'could not confirm', + }, + }, + ], + }); + expect(rows[0].confidence).toBe('unverified'); + expect(rows[0].parentVerified).toBe(false); + }); + + it('leaves parentVerified undefined when there is no parent upgrade', () => { + const rows = findingRows({ findings: [{ package: 'y', severity: 'low' }] }); + expect(rows[0].parentVerified).toBeUndefined(); + }); +}); + +describe('scanCompleteness', () => { + it('reports a complete scan for the recorded 1.28 fixture', () => { + const c = scanCompleteness(FIXTURE); + expect(c.complete).toBe(true); + expect(c.unresolvedAdvisoryIds).toEqual([]); + expect(c.skippedCount).toBe(0); + expect(c.warnings).toEqual([]); + }); + + it('aggregates per-finding unresolvedAdvisoryIds and dedupes them', () => { + const c = scanCompleteness({ + findings: [ + { package: 'a', unresolvedAdvisoryIds: ['GHSA-1', 'GHSA-2'] }, + { package: 'b', unresolvedAdvisoryIds: ['GHSA-2'] }, + ], + }); + expect(c.complete).toBe(false); + expect(c.unresolvedAdvisoryIds).toEqual(['GHSA-1', 'GHSA-2']); + }); + + it('counts skipped dependencies without assuming their shape', () => { + const c = scanCompleteness({ skippedDependencies: [{}, {}, {}] }); + expect(c.complete).toBe(false); + expect(c.skippedCount).toBe(3); + }); + + it('keeps only string warnings', () => { + const c = scanCompleteness({ warnings: ['bad thing', 42 as unknown as string] }); + expect(c.warnings).toEqual(['bad thing']); + expect(c.complete).toBe(false); + }); + + it('treats an empty report as complete', () => { + expect(scanCompleteness({}).complete).toBe(true); + }); + + it('stays incomplete even when the unresolved finding would be dropped by an "imported only" view filter (F1)', () => { + // Mirrors ProjectSecurityAccordion's "imported only" filter, which drops any + // finding whose deriveReachable(f.usage) !== true. A transitive finding with + // no usage data (usage undefined -> deriveReachable === null) is exactly the + // shape that filter removes. scanCompleteness must be computed from the full, + // unfiltered report so this can't be filtered into a false all-clear. + const report = { + findings: [ + { package: 'transitive-pkg', unresolvedAdvisoryIds: ['GHSA-unresolved'] }, + ], + }; + const filtered = { + ...report, + findings: report.findings.filter((f) => (f as { usage?: boolean }).usage === true), + }; + expect(filtered.findings).toHaveLength(0); + const c = scanCompleteness(report); + expect(c.complete).toBe(false); + expect(c.unresolvedAdvisoryIds).toEqual(['GHSA-unresolved']); + }); +}); diff --git a/src/lib/security/cve-lite-view.ts b/src/lib/security/cve-lite-view.ts index 16aac65..997e491 100644 --- a/src/lib/security/cve-lite-view.ts +++ b/src/lib/security/cve-lite-view.ts @@ -66,7 +66,8 @@ export interface FindingRow { severity: FixSeverity; relationship?: 'direct' | 'transitive'; path?: string[]; - confidence?: string; + confidence?: 'verified' | 'unverified'; + parentVerified?: boolean; validatedFixVersion?: string; command?: string; advisoryIds: string[]; @@ -86,6 +87,9 @@ export function findingRows(report: CveLiteOutput): FindingRow[] { relationship: f.relationship, path: f.dependencyPaths?.[0], confidence: f.recommendedParentUpgrade?.confidence ?? undefined, + parentVerified: f.recommendedParentUpgrade + ? f.recommendedParentUpgrade.confidence === 'verified' + : undefined, validatedFixVersion: f.validatedFirstFixedVersion ?? undefined, command: f.runnableFixCommand ?? undefined, advisoryIds: Array.from(new Set(advisoryIds)), @@ -93,3 +97,35 @@ export function findingRows(report: CveLiteOutput): FindingRow[] { }; }); } + +export interface ScanCompleteness { + complete: boolean; + unresolvedAdvisoryIds: string[]; + skippedCount: number; + warnings: string[]; +} + +/** + * Distinguishes "clean" from "couldn't check". 1.28 preserves evidence when an + * advisory lookup fails (#869) instead of caching the miss as a confirmed + * absence, so a scan can now legitimately be partial. unresolvedAdvisoryIds is + * emitted per-finding, so it is aggregated here. + */ +export function scanCompleteness(report: CveLiteOutput): ScanCompleteness { + const unresolved = new Set(); + for (const f of report.findings ?? []) { + for (const id of f.unresolvedAdvisoryIds ?? []) unresolved.add(id); + } + const warnings = (report.warnings ?? []).filter( + (w): w is string => typeof w === 'string', + ); + const skippedCount = (report.skippedDependencies ?? []).length; + const unresolvedAdvisoryIds = Array.from(unresolved); + return { + complete: + unresolvedAdvisoryIds.length === 0 && skippedCount === 0 && warnings.length === 0, + unresolvedAdvisoryIds, + skippedCount, + warnings, + }; +} diff --git a/src/lib/security/json-cache.test.ts b/src/lib/security/json-cache.test.ts new file mode 100644 index 0000000..d001e92 --- /dev/null +++ b/src/lib/security/json-cache.test.ts @@ -0,0 +1,152 @@ +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + _namespacesCollide, + _setJsonCacheDirForTest, + readJsonCache, + readJsonCacheEntry, + writeJsonCache, +} from "./json-cache"; +import { _setCveLiteCacheDirForTest, readCveLiteCache, writeCveLiteCache } from "./cve-lite-cache"; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "hexops-json-cache-")); + _setJsonCacheDirForTest(dir); + return () => rmSync(dir, { recursive: true, force: true }); +}); + +describe("json-cache", () => { + it("returns null when missing", () => { + expect(readJsonCache("override-audit", "p1", "v1")).toBeNull(); + }); + + it("round-trips a fresh write", () => { + writeJsonCache("override-audit", "p1", "v1", { findings: [] }); + expect(readJsonCache("override-audit", "p1", "v1")).toEqual({ findings: [] }); + }); + + it("writes the exact `-.json` filename", () => { + writeJsonCache("override-audit", "proj-123", "v1", { ok: true }); + expect(readdirSync(dir)).toEqual(["override-audit-proj-123.json"]); + }); + + it("atomic write leaves no .tmp file", () => { + writeJsonCache("override-audit", "p1", "v1", { ok: true }); + expect(readdirSync(dir).some((f) => f.endsWith(".tmp"))).toBe(false); + }); + + it("returns null on a toolVersion mismatch", () => { + writeJsonCache("override-audit", "p1", "v1", { ok: true }); + expect(readJsonCache("override-audit", "p1", "v2")).toBeNull(); + }); + + it("returns null past the TTL", () => { + const staleAt = new Date(Date.now() - 3_600_001).toISOString(); + writeFileSync( + join(dir, "override-audit-p1.json"), + JSON.stringify({ cachedAt: staleAt, ttlMs: 3_600_000, toolVersion: "v1", payload: { ok: true } }), + ); + expect(readJsonCache("override-audit", "p1", "v1")).toBeNull(); + }); + + it("returns the entry within the TTL", () => { + const recent = new Date(Date.now() - 60_000).toISOString(); + writeFileSync( + join(dir, "override-audit-p1.json"), + JSON.stringify({ cachedAt: recent, ttlMs: 3_600_000, toolVersion: "v1", payload: { ok: true } }), + ); + expect(readJsonCache("override-audit", "p1", "v1")).toEqual({ ok: true }); + }); + + it("treats malformed JSON as no cache", () => { + writeFileSync(join(dir, "override-audit-p1.json"), "not json"); + expect(readJsonCache("override-audit", "p1", "v1")).toBeNull(); + }); + + it("ignoreTtl returns an expired entry", () => { + const staleAt = new Date(Date.now() - 7_200_000).toISOString(); + writeFileSync( + join(dir, "override-audit-p1.json"), + JSON.stringify({ cachedAt: staleAt, ttlMs: 3_600_000, toolVersion: "v1", payload: { ok: true } }), + ); + expect(readJsonCacheEntry("override-audit", "p1", "v1", { ignoreTtl: true })?.payload).toEqual({ ok: true }); + }); + + it("ignoreTtl does not resurrect a toolVersion mismatch", () => { + const staleAt = new Date(Date.now() - 7_200_000).toISOString(); + writeFileSync( + join(dir, "override-audit-p1.json"), + JSON.stringify({ cachedAt: staleAt, ttlMs: 3_600_000, toolVersion: "v1", payload: { ok: true } }), + ); + expect(readJsonCacheEntry("override-audit", "p1", "v2", { ignoreTtl: true })).toBeNull(); + }); + + it("treats a non-finite cachedAt age as a cache miss, even with ignoreTtl", () => { + writeFileSync( + join(dir, "override-audit-p1.json"), + JSON.stringify({ cachedAt: "not-a-date", ttlMs: 3_600_000, toolVersion: "v1", payload: { ok: true } }), + ); + expect(readJsonCache("override-audit", "p1", "v1")).toBeNull(); + expect(readJsonCacheEntry("override-audit", "p1", "v1", { ignoreTtl: true })).toBeNull(); + }); + + it("reads a legacy entry written under the field name `report` (pre-refactor cve-lite-cache format)", () => { + const recent = new Date(Date.now() - 60_000).toISOString(); + writeFileSync( + join(dir, "cve-lite-p1.json"), + JSON.stringify({ cachedAt: recent, ttlMs: 3_600_000, toolVersion: "v1", report: { findingCount: 1 } }), + ); + expect(readJsonCache("cve-lite", "p1", "v1")).toEqual({ findingCount: 1 }); + }); + + it("rejects an unregistered namespace", () => { + // @ts-expect-error -- exercising the runtime guard for a namespace TypeScript wouldn't allow + expect(() => writeJsonCache("cve", "p1", "v1", {})).toThrow(/not registered/); + }); + + describe("_namespacesCollide", () => { + it("flags a namespace that is another plus a trailing separator", () => { + expect(_namespacesCollide("cve", "cve-lite")).toBe(true); + expect(_namespacesCollide("cve-lite", "cve")).toBe(true); + }); + + it("does not flag the real registered namespaces", () => { + expect(_namespacesCollide("cve-lite", "override-audit")).toBe(false); + }); + + it("does not flag a namespace against itself", () => { + expect(_namespacesCollide("cve-lite", "cve-lite")).toBe(false); + }); + + it("does not flag unrelated namespaces that merely share a prefix character run", () => { + // 'cve-liteX' is not 'cve-lite' + separator + anything, so no collision. + expect(_namespacesCollide("cve-lite", "cve-liteX")).toBe(false); + }); + }); + + describe("test-dir setters share one underlying directory", () => { + it("_setCveLiteCacheDirForTest also redirects json-cache's own dir", () => { + const dirA = mkdtempSync(join(tmpdir(), "hexops-shared-dir-a-")); + const dirB = mkdtempSync(join(tmpdir(), "hexops-shared-dir-b-")); + try { + _setCveLiteCacheDirForTest(dirA); + writeCveLiteCache("p1", { findingCount: 1, findings: [] }); + expect(readdirSync(dirA)).toContain("cve-lite-p1.json"); + + // Switching via the json-cache setter must move the *same* + // underlying variable that cve-lite-cache reads from. + _setJsonCacheDirForTest(dirB); + expect(readCveLiteCache("p1")).toBeNull(); // dirA's entry is not visible from dirB + writeCveLiteCache("p1", { findingCount: 2, findings: [] }); + expect(readdirSync(dirB)).toContain("cve-lite-p1.json"); + expect(readCveLiteCache("p1")?.report.findingCount).toBe(2); + } finally { + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/src/lib/security/json-cache.ts b/src/lib/security/json-cache.ts new file mode 100644 index 0000000..a437534 --- /dev/null +++ b/src/lib/security/json-cache.ts @@ -0,0 +1,116 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +export interface JsonCacheEntry { + cachedAt: string; + ttlMs: number; + toolVersion: string; + payload: T; +} + +const DEFAULT_TTL_MS = 3_600_000; // 1 hour +const SEPARATOR = '-'; + +/** + * Namespaces sharing this cache. Closed on purpose: `cachePath` joins + * `namespace` and `id` with a bare SEPARATOR, so a namespace that is another + * namespace plus a trailing separator (e.g. 'cve' vs 'cve-lite') could + * produce the same on-disk filename for two different logical entries — + * `cve` + '-' + 'lite-p1' === 'cve-lite' + '-' + 'p1'. Requiring every + * namespace to be registered here, and self-checking the registry below, + * makes that collision structurally impossible rather than merely unlikely. + */ +const REGISTERED_NAMESPACES = ['cve-lite', 'override-audit'] as const; +export type JsonCacheNamespace = (typeof REGISTERED_NAMESPACES)[number]; + +/** + * True when `a` and `b` could produce the same `-.json` + * filename for some choice of id, because one is the other plus a trailing + * separator. Exported only for tests. + */ +export function _namespacesCollide(a: string, b: string): boolean { + if (a === b) return false; + const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a]; + return longer.startsWith(shorter + SEPARATOR); +} + +function assertRegisteredNamespace(namespace: string): void { + if (!(REGISTERED_NAMESPACES as readonly string[]).includes(namespace)) { + throw new Error( + `json-cache: namespace "${namespace}" is not registered in REGISTERED_NAMESPACES (json-cache.ts). ` + + 'Add it there only after confirming — via _namespacesCollide — that it cannot collide with an existing namespace.', + ); + } +} + +let cacheDir = join(process.cwd(), '.hexops', 'cache'); + +export function _setJsonCacheDirForTest(dir: string) { + cacheDir = dir; +} + +function cachePath(namespace: JsonCacheNamespace, id: string) { + assertRegisteredNamespace(namespace); + return join(cacheDir, `${namespace}${SEPARATOR}${id}.json`); +} + +/** + * Reads a cache entry with its metadata intact (cachedAt/ttlMs/toolVersion). + * `readJsonCache` below is a convenience wrapper for callers that only want + * the payload; typed wrappers such as cve-lite-cache.ts use this directly so + * they can re-expose the metadata under their own field names. + */ +export function readJsonCacheEntry( + namespace: JsonCacheNamespace, + id: string, + toolVersion: string, + opts: { ignoreTtl?: boolean } = {}, +): JsonCacheEntry | null { + const path = cachePath(namespace, id); + if (!existsSync(path)) return null; + try { + // `report` is accepted as a synonym for `payload`: cve-lite-cache.ts + // wrote entries under that field name before this cache module was + // extracted and shared, so real on-disk caches from before this + // refactor still use it. Without this fallback they'd parse with + // `payload: undefined` instead of cleanly missing. + const raw = JSON.parse(readFileSync(path, 'utf-8')) as JsonCacheEntry & { report?: T }; + if (!raw || typeof raw.cachedAt !== 'string') return null; + // Version gate first: a stale entry from a different tool version must + // never be resurrected, even via ignoreTtl. + if (raw.toolVersion !== toolVersion) return null; + const age = Date.now() - new Date(raw.cachedAt).getTime(); + // A corrupt cachedAt yields NaN, and `NaN > ttlMs` is false — without + // this check a corrupt entry would be served as if it were fresh. + if (!Number.isFinite(age)) return null; + if (!opts.ignoreTtl && age > (raw.ttlMs ?? DEFAULT_TTL_MS)) return null; + const payload = raw.payload !== undefined ? raw.payload : raw.report; + if (payload === undefined) return null; + return { cachedAt: raw.cachedAt, ttlMs: raw.ttlMs ?? DEFAULT_TTL_MS, toolVersion: raw.toolVersion, payload }; + } catch { + return null; + } +} + +export function readJsonCache(namespace: JsonCacheNamespace, id: string, toolVersion: string): T | null { + return readJsonCacheEntry(namespace, id, toolVersion)?.payload ?? null; +} + +export function writeJsonCache( + namespace: JsonCacheNamespace, + id: string, + toolVersion: string, + payload: T, +): void { + if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); + const finalPath = cachePath(namespace, id); + const tmp = `${finalPath}.${process.pid}.${Date.now()}.tmp`; + const entry: JsonCacheEntry = { cachedAt: new Date().toISOString(), ttlMs: DEFAULT_TTL_MS, toolVersion, payload }; + writeFileSync(tmp, JSON.stringify(entry, null, 2)); + try { + renameSync(tmp, finalPath); + } catch (err) { + try { unlinkSync(tmp); } catch { /* ignore */ } + throw err; + } +} diff --git a/src/lib/security/override-audit.test.ts b/src/lib/security/override-audit.test.ts new file mode 100644 index 0000000..7720495 --- /dev/null +++ b/src/lib/security/override-audit.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildOverrideCommand } from './override-audit'; +import type { OverrideAuditOutput } from './override-audit'; + +describe('buildOverrideCommand', () => { + it('puts the path before the subcommand and quotes every part', () => { + expect(buildOverrideCommand('/bin/cve-lite', '/p/my proj')).toBe( + '"/bin/cve-lite" "/p/my proj" "overrides" "--json"', + ); + }); + + it('appends extra flags after --json', () => { + expect(buildOverrideCommand('/bin/cve-lite', '/p', ['--rule', 'OA009'])).toBe( + '"/bin/cve-lite" "/p" "overrides" "--json" "--rule" "OA009"', + ); + }); +}); + +describe('recorded 1.28 overrides fixture', () => { + it('has the shape the parser depends on', () => { + const out = JSON.parse( + readFileSync(join(__dirname, 'sources/__fixtures__/cve-lite-1.28-overrides.json'), 'utf-8'), + ) as OverrideAuditOutput; + expect(out.findings?.length).toBeGreaterThan(0); + const f = out.findings![0]; + expect(f.ruleId).toMatch(/^(OA|PD)\d{3}$/); + expect(f.location?.file).toBe('package.json'); + expect(f.location?.jsonPath).toContain('/overrides/'); + }); +}); diff --git a/src/lib/security/override-audit.ts b/src/lib/security/override-audit.ts new file mode 100644 index 0000000..dee0b2f --- /dev/null +++ b/src/lib/security/override-audit.ts @@ -0,0 +1,87 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import type { ProjectConfig } from '../types'; +import { readJsonCache, writeJsonCache } from './json-cache'; +import { cveLiteToolVersion } from './cve-lite-cache'; + +const execAsync = promisify(exec); + +export interface OverrideFinding { + ruleId?: string; + severity?: string; + package?: { name?: string }; + location?: { file?: string; jsonPath?: string }; + message?: string; + details?: string; + fix?: { type?: string; patch?: unknown[]; runnableCommand?: string }; + references?: string[]; +} + +export interface OverrideAuditOutput { + findings?: OverrideFinding[]; +} + +function binPath(): string { + return join(process.cwd(), 'node_modules', '.bin', 'cve-lite'); +} + +/** Path-first argument order (#770): `cve-lite overrides --json [flags]`. */ +export function buildOverrideCommand( + bin: string, + projectPath: string, + flags: string[] = [], +): string { + return [bin, projectPath, 'overrides', '--json', ...flags] + .map((p) => JSON.stringify(p)) + .join(' '); +} + +/** + * Runs one override audit. `overrides --json` writes a timestamped file into + * cwd rather than stdout, so this runs in a temp dir and reads the file back. + * A non-zero exit is expected when findings exist, so the file's presence — + * not the exit code — distinguishes success from failure. + */ +export async function runOverrideAuditRaw(project: ProjectConfig): Promise { + const tmp = mkdtempSync(join(tmpdir(), 'hexops-cve-lite-ovr-')); + try { + try { + await execAsync(buildOverrideCommand(binPath(), project.path), { + cwd: tmp, + timeout: 170_000, + maxBuffer: 64 * 1024 * 1024, + }); + } catch { + // findings present => non-zero exit, file still written. Disambiguated below. + } + const outFile = readdirSync(tmp).find( + (f) => f.startsWith('cve-lite-overrides-') && f.endsWith('.json'), + ); + if (!outFile) throw new Error('cve-lite overrides produced no output'); + return JSON.parse(readFileSync(join(tmp, outFile), 'utf-8')) as OverrideAuditOutput; + } finally { + try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } + } +} + +/** Cache-aware override audit (1h TTL), mirroring the cve-lite scan cache. */ +export async function runOverrideAudit( + project: ProjectConfig, + opts: { force?: boolean } = {}, +): Promise { + if (!opts.force) { + const cached = readJsonCache('override-audit', project.id, cveLiteToolVersion()); + if (cached) return cached; + } + const report = await runOverrideAuditRaw(project); + writeJsonCache('override-audit', project.id, cveLiteToolVersion(), report); + return report; +} + +/** True when cve-lite is installed. */ +export function overrideAuditAvailable(): boolean { + return existsSync(binPath()); +} diff --git a/src/lib/security/runner.test.ts b/src/lib/security/runner.test.ts index 5939552..25a02e7 100644 --- a/src/lib/security/runner.test.ts +++ b/src/lib/security/runner.test.ts @@ -4,7 +4,7 @@ import { join } from 'path'; import { tmpdir } from 'os'; import { _setCacheDirForTest } from './persistence'; import { _setFindingStatesDirForTest } from './finding-states'; -import { scanProjectWithSources } from './runner'; +import { scanProjectWithSources, _runOneForTest } from './runner'; import type { ScanSource, Finding } from './types'; import type { ProjectConfig } from '../types'; @@ -28,7 +28,7 @@ function source(id: string, behavior: Partial<{ scan: async () => { if (behavior.delayMs) await new Promise(r => setTimeout(r, behavior.delayMs)); if (behavior.throw) throw new Error(behavior.throw); - return behavior.findings ?? []; + return { findings: behavior.findings ?? [] }; }, }; } @@ -102,7 +102,7 @@ describe('runner.scanProjectWithSources', () => { scan: async () => { calls++; await new Promise(r => setTimeout(r, 50)); - return []; + return { findings: [] }; }, }; const [a, b] = await Promise.all([ @@ -112,4 +112,29 @@ describe('runner.scanProjectWithSources', () => { expect(calls).toBe(1); expect(a).toBe(b); }); + + it('propagates a source warning onto the SourceResult', async () => { + const source: ScanSource = { + id: 'warner', + displayName: 'Warner', + findingTypes: ['config'], + isAvailable: async () => true, + scan: async () => ({ findings: [], warning: 'partial scan: 2 advisories unresolved' }), + }; + const { result } = await _runOneForTest(source, { id: 'p', name: 'p', path: '/tmp' } as ProjectConfig); + expect(result.status).toBe('ok'); + expect(result.warning).toBe('partial scan: 2 advisories unresolved'); + }); + + it('leaves warning undefined when a source reports none', async () => { + const source: ScanSource = { + id: 'quiet', + displayName: 'Quiet', + findingTypes: ['config'], + isAvailable: async () => true, + scan: async () => ({ findings: [] }), + }; + const { result } = await _runOneForTest(source, { id: 'p', name: 'p', path: '/tmp' } as ProjectConfig); + expect(result.warning).toBeUndefined(); + }); }); diff --git a/src/lib/security/runner.ts b/src/lib/security/runner.ts index 9bf62a3..4202588 100644 --- a/src/lib/security/runner.ts +++ b/src/lib/security/runner.ts @@ -35,6 +35,7 @@ async function runOne(source: ScanSource, project: ProjectConfig): Promise<{ res const start = Date.now(); let status: SourceStatus = 'ok'; let error: string | undefined; + let warning: string | undefined; let findings: Finding[] = []; const available = await source.isAvailable().catch(() => false); @@ -48,7 +49,8 @@ async function runOne(source: ScanSource, project: ProjectConfig): Promise<{ res return { ok: false } as const; }); if (status === 'ok' && 'ok' in outcome && outcome.ok) { - findings = outcome.value; + findings = outcome.value.findings; + warning = outcome.value.warning; } else if (status === 'ok') { status = 'timeout'; } @@ -63,10 +65,13 @@ async function runOne(source: ScanSource, project: ProjectConfig): Promise<{ res durationMs: Date.now() - start, findingCount: findings.length, error, + warning, }, }; } +export const _runOneForTest = runOne; + export async function scanProjectWithSources(project: ProjectConfig, sources: ScanSource[]): Promise { const existing = inflight.get(project.id); if (existing) return existing; diff --git a/src/lib/security/sources/__fixtures__/cve-lite-1.28-overrides.json b/src/lib/security/sources/__fixtures__/cve-lite-1.28-overrides.json new file mode 100644 index 0000000..31bdc26 --- /dev/null +++ b/src/lib/security/sources/__fixtures__/cve-lite-1.28-overrides.json @@ -0,0 +1,56 @@ +{ + "findings": [ + { + "ruleId": "OA009", + "severity": "low", + "package": { + "name": "ip-address" + }, + "location": { + "file": "package.json", + "jsonPath": "/pnpm/overrides/ip-address" + }, + "message": "Override floor already met by all parent declarations", + "details": "ip-address is floored to ^10.2.0, but every package that depends on it already declares a minimum version that meets or exceeds the floor. Removing the override is safe.", + "fix": { + "type": "rfc6902", + "patch": [ + { + "op": "remove", + "path": "/pnpm/overrides/ip-address" + } + ], + "runnableCommand": "cve-lite overrides --fix --rule OA009" + }, + "references": [ + "https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/OA009.md" + ] + }, + { + "ruleId": "OA009", + "severity": "low", + "package": { + "name": "ws" + }, + "location": { + "file": "package.json", + "jsonPath": "/pnpm/overrides/ws" + }, + "message": "Override floor already met by all parent declarations", + "details": "ws is floored to >=8.21.0, but every package that depends on it already declares a minimum version that meets or exceeds the floor. Removing the override is safe.", + "fix": { + "type": "rfc6902", + "patch": [ + { + "op": "remove", + "path": "/pnpm/overrides/ws" + } + ], + "runnableCommand": "cve-lite overrides --fix --rule OA009" + }, + "references": [ + "https://github.com/OWASP/cve-lite-cli/blob/main/docs/rules/OA009.md" + ] + } + ] +} \ No newline at end of file diff --git a/src/lib/security/sources/__fixtures__/cve-lite-1.28-scan.json b/src/lib/security/sources/__fixtures__/cve-lite-1.28-scan.json new file mode 100644 index 0000000..99b8dc1 --- /dev/null +++ b/src/lib/security/sources/__fixtures__/cve-lite-1.28-scan.json @@ -0,0 +1,1278 @@ +{ + "projectPath": "/home/aaron/Projects/hexops", + "mode": "resolved-lockfile", + "source": "pnpm-lock", + "packageCount": 459, + "findingCount": 8, + "suggestedFixCommands": { + "packageManager": "pnpm", + "sourceLabel": "pnpm-lock.yaml", + "command": "pnpm update --no-save fast-uri && pnpm update --recursive --no-save ip-address && pnpm update --recursive --no-save nanoid && pnpm update --no-save hono && pnpm add next@16.3.0", + "sections": [ + { + "key": "urgent:high", + "kind": "urgent", + "severity": "high", + "title": "High severity fix commands", + "command": "pnpm add next@16.3.0", + "targets": [ + { + "package": "next", + "currentVersion": "16.2.10", + "targetVersion": "16.3.0", + "scannedVersions": 1, + "knownVulnerableVersions": 0, + "kind": "direct", + "urgent": true, + "severity": "high", + "adjusted": true, + "adjustmentNote": "Advisory fixed-version hint 15.5.21 is still known vulnerable for next; scanned 1 package version above current version (0 still known vulnerable); using lowest known non-vulnerable version 16.2.11.", + "reason": "Direct upgrade target for next@16.2.10", + "usage": { + "imported": true, + "files": [ + "next.config.ts", + "server.js", + "src/app/api/config/route.ts", + "src/app/api/deps/route.ts", + "src/app/api/health/route.ts", + "src/app/api/logs/route.ts", + "src/app/api/notifications/route.ts", + "src/app/api/patches/export/route.ts", + "src/app/api/patches/history/route.ts", + "src/app/api/patches/route.ts", + "src/app/api/patches/scan/route.ts", + "src/app/api/patches/stream/route.ts", + "src/app/api/patches/trends/route.ts", + "src/app/api/projects/[id]/audit/route.ts", + "src/app/api/projects/[id]/autostart/route.ts", + "src/app/api/projects/[id]/branch-sync/route.ts", + "src/app/api/projects/[id]/clear-cache/route.ts", + "src/app/api/projects/[id]/code-scan/route.ts", + "src/app/api/projects/[id]/delete-lock/route.ts", + "src/app/api/projects/[id]/dependabot/route.ts", + "src/app/api/projects/[id]/escalate/route.ts", + "src/app/api/projects/[id]/git/route.ts", + "src/app/api/projects/[id]/git-branch/route.ts", + "src/app/api/projects/[id]/git-commit/route.ts", + "src/app/api/projects/[id]/git-pull/route.ts", + "src/app/api/projects/[id]/git-push/route.ts", + "src/app/api/projects/[id]/git-stash/route.ts", + "src/app/api/projects/[id]/holds/route.ts", + "src/app/api/projects/[id]/info/route.ts", + "src/app/api/projects/[id]/logs/route.ts", + "src/app/api/projects/[id]/metrics/route.ts", + "src/app/api/projects/[id]/outdated/route.ts", + "src/app/api/projects/[id]/override-remove/route.ts", + "src/app/api/projects/[id]/package-health/route.ts", + "src/app/api/projects/[id]/plugins/[pluginId]/route.ts", + "src/app/api/projects/[id]/propagate-branches/route.ts", + "src/app/api/projects/[id]/resolve-lockfile/route.ts", + "src/app/api/projects/[id]/security/exceptions/[exceptionId]/revoke/route.ts", + "src/app/api/projects/[id]/security/exceptions/[exceptionId]/route.ts", + "src/app/api/projects/[id]/security/exceptions/route.ts", + "src/app/api/projects/[id]/security/remediation/[attemptId]/complete/route.ts", + "src/app/api/projects/[id]/security-scan/route.ts", + "src/app/api/projects/[id]/settings/route.ts", + "src/app/api/projects/[id]/start/route.ts", + "src/app/api/projects/[id]/stop/route.ts", + "src/app/api/projects/[id]/supply-scan/route.ts", + "src/app/api/projects/[id]/update/route.enabled.test.ts", + "src/app/api/projects/[id]/update/route.guard.test.ts", + "src/app/api/projects/[id]/update/route.test.ts", + "src/app/api/projects/[id]/update/route.ts", + "src/app/api/projects/[id]/validate/route.ts", + "src/app/api/projects/[id]/vercel/route.ts", + "src/app/api/projects/[id]/vercel/stream/route.ts", + "src/app/api/projects/route.ts", + "src/app/api/projects/save/route.ts", + "src/app/api/projects/scan-path/route.ts", + "src/app/api/scheduler/route.ts", + "src/app/api/security/cve-lite/[id]/artifact/route.ts", + "src/app/api/security/cve-lite/[id]/fix/route.ts", + "src/app/api/security/cve-lite/[id]/install-skill/route.ts", + "src/app/api/security/cve-lite/[id]/report/route.ts", + "src/app/api/security/cve-lite/[id]/route.ts", + "src/app/api/security/cve-lite/db-status/route.ts", + "src/app/api/security/cve-lite/summary/route.ts", + "src/app/api/security/cve-lite/sync/route.ts", + "src/app/api/security/findings/route.test.ts", + "src/app/api/security/findings/route.ts", + "src/app/api/security/plugins/[id]/status/route.ts", + "src/app/api/security/plugins/route.ts", + "src/app/api/settings/autostart/route.ts", + "src/app/api/settings/route.ts", + "src/app/api/settings/verify-vercel/route.ts", + "src/app/api/sidebar/route.ts", + "src/app/api/system/metrics/route.ts", + "src/app/deps/page.tsx", + "src/app/layout.tsx", + "src/app/page.tsx", + "src/app/patches/page.tsx", + "src/app/patches/trends/page.tsx", + "src/app/security/page.tsx", + "src/app/security/safe-chain/page.tsx", + "src/components/providers.tsx", + "src/components/radial-gauge.tsx", + "src/components/security/findings-table.tsx", + "src/components/sidebar.tsx", + "src/lib/security/sources/dependency-health.test.ts" + ] + }, + "isDev": false, + "fixVersionPublishedAt": "2026-07-21T16:00:01.566Z", + "coverage": "partial", + "coveredPaths": [ + [ + "project", + "next", + "postcss" + ] + ], + "remainingPaths": [ + [ + "project", + "@tailwindcss/postcss", + "postcss" + ], + [ + "project", + "vitest", + "vite", + "postcss" + ], + [ + "project", + "@vitest/ui", + "vitest", + "vite", + "postcss" + ], + [ + "project", + "vitest", + "@vitest/mocker", + "vite", + "postcss" + ] + ], + "chainProof": [], + "chainSafeVersion": "8.5.23", + "chainVulnerablePackage": "postcss", + "confidence": "verified" + } + ] + }, + { + "key": "parent-update:high", + "kind": "parent-update", + "severity": "high", + "title": "High severity parent updates within range", + "command": "pnpm update --no-save fast-uri && pnpm update --recursive --no-save ip-address && pnpm update --recursive --no-save nanoid", + "targets": [ + { + "package": "fast-uri", + "currentVersion": "3.1.2", + "targetVersion": "3.1.5", + "scannedVersions": null, + "knownVulnerableVersions": null, + "kind": "parent-update", + "urgent": true, + "severity": "high", + "adjusted": false, + "adjustmentNote": null, + "reason": "fast-uri@3.1.2 can be refreshed to 3.1.5+ — no parent upgrade needed.", + "command": "pnpm update --no-save fast-uri", + "usage": { + "imported": false, + "files": [] + } + }, + { + "package": "ip-address", + "currentVersion": "10.2.0", + "targetVersion": "10.5.0", + "scannedVersions": null, + "knownVulnerableVersions": null, + "kind": "parent-update", + "urgent": true, + "severity": "high", + "adjusted": false, + "adjustmentNote": null, + "reason": "ip-address@10.2.0 can be refreshed to 10.5.0+ — no parent upgrade needed.", + "command": "pnpm update --recursive --no-save ip-address", + "usage": { + "imported": false, + "files": [] + } + }, + { + "package": "nanoid", + "currentVersion": "3.3.15", + "targetVersion": "3.3.18", + "scannedVersions": null, + "knownVulnerableVersions": null, + "kind": "parent-update", + "urgent": true, + "severity": "high", + "adjusted": false, + "adjustmentNote": null, + "reason": "nanoid@3.3.15 can be refreshed to 3.3.18+ — no parent upgrade needed.", + "command": "pnpm update --recursive --no-save nanoid", + "usage": { + "imported": false, + "files": [] + } + } + ] + }, + { + "key": "parent-update:medium", + "kind": "parent-update", + "severity": "medium", + "title": "Medium severity parent updates within range", + "command": "pnpm update --no-save hono", + "targets": [ + { + "package": "hono", + "currentVersion": "4.12.26", + "targetVersion": "4.13.1", + "scannedVersions": null, + "knownVulnerableVersions": null, + "kind": "parent-update", + "urgent": false, + "severity": "medium", + "adjusted": false, + "adjustmentNote": null, + "reason": "hono@4.12.26 can be refreshed to 4.13.1+ — no parent upgrade needed.", + "command": "pnpm update --no-save hono", + "usage": { + "imported": false, + "files": [] + } + } + ] + } + ], + "targets": [ + { + "package": "next", + "currentVersion": "16.2.10", + "targetVersion": "16.3.0", + "scannedVersions": 1, + "knownVulnerableVersions": 0, + "kind": "direct", + "urgent": true, + "severity": "high", + "adjusted": true, + "adjustmentNote": "Advisory fixed-version hint 15.5.21 is still known vulnerable for next; scanned 1 package version above current version (0 still known vulnerable); using lowest known non-vulnerable version 16.2.11.", + "reason": "Direct upgrade target for next@16.2.10", + "usage": { + "imported": true, + "files": [ + "next.config.ts", + "server.js", + "src/app/api/config/route.ts", + "src/app/api/deps/route.ts", + "src/app/api/health/route.ts", + "src/app/api/logs/route.ts", + "src/app/api/notifications/route.ts", + "src/app/api/patches/export/route.ts", + "src/app/api/patches/history/route.ts", + "src/app/api/patches/route.ts", + "src/app/api/patches/scan/route.ts", + "src/app/api/patches/stream/route.ts", + "src/app/api/patches/trends/route.ts", + "src/app/api/projects/[id]/audit/route.ts", + "src/app/api/projects/[id]/autostart/route.ts", + "src/app/api/projects/[id]/branch-sync/route.ts", + "src/app/api/projects/[id]/clear-cache/route.ts", + "src/app/api/projects/[id]/code-scan/route.ts", + "src/app/api/projects/[id]/delete-lock/route.ts", + "src/app/api/projects/[id]/dependabot/route.ts", + "src/app/api/projects/[id]/escalate/route.ts", + "src/app/api/projects/[id]/git/route.ts", + "src/app/api/projects/[id]/git-branch/route.ts", + "src/app/api/projects/[id]/git-commit/route.ts", + "src/app/api/projects/[id]/git-pull/route.ts", + "src/app/api/projects/[id]/git-push/route.ts", + "src/app/api/projects/[id]/git-stash/route.ts", + "src/app/api/projects/[id]/holds/route.ts", + "src/app/api/projects/[id]/info/route.ts", + "src/app/api/projects/[id]/logs/route.ts", + "src/app/api/projects/[id]/metrics/route.ts", + "src/app/api/projects/[id]/outdated/route.ts", + "src/app/api/projects/[id]/override-remove/route.ts", + "src/app/api/projects/[id]/package-health/route.ts", + "src/app/api/projects/[id]/plugins/[pluginId]/route.ts", + "src/app/api/projects/[id]/propagate-branches/route.ts", + "src/app/api/projects/[id]/resolve-lockfile/route.ts", + "src/app/api/projects/[id]/security/exceptions/[exceptionId]/revoke/route.ts", + "src/app/api/projects/[id]/security/exceptions/[exceptionId]/route.ts", + "src/app/api/projects/[id]/security/exceptions/route.ts", + "src/app/api/projects/[id]/security/remediation/[attemptId]/complete/route.ts", + "src/app/api/projects/[id]/security-scan/route.ts", + "src/app/api/projects/[id]/settings/route.ts", + "src/app/api/projects/[id]/start/route.ts", + "src/app/api/projects/[id]/stop/route.ts", + "src/app/api/projects/[id]/supply-scan/route.ts", + "src/app/api/projects/[id]/update/route.enabled.test.ts", + "src/app/api/projects/[id]/update/route.guard.test.ts", + "src/app/api/projects/[id]/update/route.test.ts", + "src/app/api/projects/[id]/update/route.ts", + "src/app/api/projects/[id]/validate/route.ts", + "src/app/api/projects/[id]/vercel/route.ts", + "src/app/api/projects/[id]/vercel/stream/route.ts", + "src/app/api/projects/route.ts", + "src/app/api/projects/save/route.ts", + "src/app/api/projects/scan-path/route.ts", + "src/app/api/scheduler/route.ts", + "src/app/api/security/cve-lite/[id]/artifact/route.ts", + "src/app/api/security/cve-lite/[id]/fix/route.ts", + "src/app/api/security/cve-lite/[id]/install-skill/route.ts", + "src/app/api/security/cve-lite/[id]/report/route.ts", + "src/app/api/security/cve-lite/[id]/route.ts", + "src/app/api/security/cve-lite/db-status/route.ts", + "src/app/api/security/cve-lite/summary/route.ts", + "src/app/api/security/cve-lite/sync/route.ts", + "src/app/api/security/findings/route.test.ts", + "src/app/api/security/findings/route.ts", + "src/app/api/security/plugins/[id]/status/route.ts", + "src/app/api/security/plugins/route.ts", + "src/app/api/settings/autostart/route.ts", + "src/app/api/settings/route.ts", + "src/app/api/settings/verify-vercel/route.ts", + "src/app/api/sidebar/route.ts", + "src/app/api/system/metrics/route.ts", + "src/app/deps/page.tsx", + "src/app/layout.tsx", + "src/app/page.tsx", + "src/app/patches/page.tsx", + "src/app/patches/trends/page.tsx", + "src/app/security/page.tsx", + "src/app/security/safe-chain/page.tsx", + "src/components/providers.tsx", + "src/components/radial-gauge.tsx", + "src/components/security/findings-table.tsx", + "src/components/sidebar.tsx", + "src/lib/security/sources/dependency-health.test.ts" + ] + }, + "isDev": false, + "fixVersionPublishedAt": "2026-07-21T16:00:01.566Z", + "coverage": "partial", + "coveredPaths": [ + [ + "project", + "next", + "postcss" + ] + ], + "remainingPaths": [ + [ + "project", + "@tailwindcss/postcss", + "postcss" + ], + [ + "project", + "vitest", + "vite", + "postcss" + ], + [ + "project", + "@vitest/ui", + "vitest", + "vite", + "postcss" + ], + [ + "project", + "vitest", + "@vitest/mocker", + "vite", + "postcss" + ] + ], + "chainProof": [], + "chainSafeVersion": "8.5.23", + "chainVulnerablePackage": "postcss", + "confidence": "verified" + }, + { + "package": "fast-uri", + "currentVersion": "3.1.2", + "targetVersion": "3.1.5", + "scannedVersions": null, + "knownVulnerableVersions": null, + "kind": "parent-update", + "urgent": true, + "severity": "high", + "adjusted": false, + "adjustmentNote": null, + "reason": "fast-uri@3.1.2 can be refreshed to 3.1.5+ — no parent upgrade needed.", + "command": "pnpm update --no-save fast-uri", + "usage": { + "imported": false, + "files": [] + } + }, + { + "package": "ip-address", + "currentVersion": "10.2.0", + "targetVersion": "10.5.0", + "scannedVersions": null, + "knownVulnerableVersions": null, + "kind": "parent-update", + "urgent": true, + "severity": "high", + "adjusted": false, + "adjustmentNote": null, + "reason": "ip-address@10.2.0 can be refreshed to 10.5.0+ — no parent upgrade needed.", + "command": "pnpm update --recursive --no-save ip-address", + "usage": { + "imported": false, + "files": [] + } + }, + { + "package": "nanoid", + "currentVersion": "3.3.15", + "targetVersion": "3.3.18", + "scannedVersions": null, + "knownVulnerableVersions": null, + "kind": "parent-update", + "urgent": true, + "severity": "high", + "adjusted": false, + "adjustmentNote": null, + "reason": "nanoid@3.3.15 can be refreshed to 3.3.18+ — no parent upgrade needed.", + "command": "pnpm update --recursive --no-save nanoid", + "usage": { + "imported": false, + "files": [] + } + }, + { + "package": "hono", + "currentVersion": "4.12.26", + "targetVersion": "4.13.1", + "scannedVersions": null, + "knownVulnerableVersions": null, + "kind": "parent-update", + "urgent": false, + "severity": "medium", + "adjusted": false, + "adjustmentNote": null, + "reason": "hono@4.12.26 can be refreshed to 4.13.1+ — no parent upgrade needed.", + "command": "pnpm update --no-save hono", + "usage": { + "imported": false, + "files": [] + } + } + ], + "skipped": [ + { + "package": "@hono/node-server", + "version": "1.19.14", + "relationship": "transitive", + "reason": "@hono/node-server@1.19.14 is pulled in by @modelcontextprotocol/sdk. No safe upgrade version for @modelcontextprotocol/sdk was identified automatically - check for a release that resolves @hono/node-server to 2.0.5+." + }, + { + "package": "sharp", + "version": "0.34.5", + "relationship": "transitive", + "reason": "sharp@0.34.5 is pulled in by next. No safe upgrade version for next was identified automatically - check for a release that resolves sharp to 0.35.0+." + } + ], + "coveredFindingCount": 5, + "totalFindingCount": 8 + }, + "notes": [ + "Scanned resolved dependency versions from pnpm-lock.yaml.", + "Dependency paths are approximated from importer relationships and package snapshots.", + "CVE Lite CLI checks package versions against OSV advisories. It does not prove exploitability or runtime reachability.", + "Installed node_modules contents are not verified in this scan.", + "Container images, binaries, secrets, and IaC files are not scanned.", + "Monorepo workspace boundaries are only partially modeled in this version." + ], + "warnings": [], + "skippedDependencies": [], + "findings": [ + { + "package": "next", + "version": "16.2.10", + "severity": "high", + "relationship": "direct", + "dev": false, + "firstFixedVersion": "15.5.21", + "validatedFirstFixedVersion": "16.2.11", + "fixVersionValidationNote": "Advisory fixed-version hint 15.5.21 is still known vulnerable for next; scanned 1 package version above current version (0 still known vulnerable); using lowest known non-vulnerable version 16.2.11.", + "fixVersionPublishedAt": "2026-07-21T16:00:01.566Z", + "cooldownWarning": null, + "validatedTargetScannedVersions": 1, + "validatedTargetKnownVulnerableVersions": 0, + "recommendedAction": "Upgrade next to 16.2.11+ in this project.", + "runnableFixCommand": null, + "primaryParent": null, + "recommendedNpmTransitiveRemediation": null, + "cves": [ + "CVE-2026-64647", + "CVE-2026-64646", + "CVE-2026-64648", + "CVE-2026-64642", + "CVE-2026-64649", + "CVE-2026-64643", + "CVE-2026-64641", + "CVE-2026-64645", + "CVE-2026-64644" + ], + "dependencyPaths": [ + [ + "project", + "next" + ] + ], + "usage": { + "imported": true, + "files": [ + "next.config.ts", + "server.js", + "src/app/api/config/route.ts", + "src/app/api/deps/route.ts", + "src/app/api/health/route.ts", + "src/app/api/logs/route.ts", + "src/app/api/notifications/route.ts", + "src/app/api/patches/export/route.ts", + "src/app/api/patches/history/route.ts", + "src/app/api/patches/route.ts", + "src/app/api/patches/scan/route.ts", + "src/app/api/patches/stream/route.ts", + "src/app/api/patches/trends/route.ts", + "src/app/api/projects/[id]/audit/route.ts", + "src/app/api/projects/[id]/autostart/route.ts", + "src/app/api/projects/[id]/branch-sync/route.ts", + "src/app/api/projects/[id]/clear-cache/route.ts", + "src/app/api/projects/[id]/code-scan/route.ts", + "src/app/api/projects/[id]/delete-lock/route.ts", + "src/app/api/projects/[id]/dependabot/route.ts", + "src/app/api/projects/[id]/escalate/route.ts", + "src/app/api/projects/[id]/git/route.ts", + "src/app/api/projects/[id]/git-branch/route.ts", + "src/app/api/projects/[id]/git-commit/route.ts", + "src/app/api/projects/[id]/git-pull/route.ts", + "src/app/api/projects/[id]/git-push/route.ts", + "src/app/api/projects/[id]/git-stash/route.ts", + "src/app/api/projects/[id]/holds/route.ts", + "src/app/api/projects/[id]/info/route.ts", + "src/app/api/projects/[id]/logs/route.ts", + "src/app/api/projects/[id]/metrics/route.ts", + "src/app/api/projects/[id]/outdated/route.ts", + "src/app/api/projects/[id]/override-remove/route.ts", + "src/app/api/projects/[id]/package-health/route.ts", + "src/app/api/projects/[id]/plugins/[pluginId]/route.ts", + "src/app/api/projects/[id]/propagate-branches/route.ts", + "src/app/api/projects/[id]/resolve-lockfile/route.ts", + "src/app/api/projects/[id]/security/exceptions/[exceptionId]/revoke/route.ts", + "src/app/api/projects/[id]/security/exceptions/[exceptionId]/route.ts", + "src/app/api/projects/[id]/security/exceptions/route.ts", + "src/app/api/projects/[id]/security/remediation/[attemptId]/complete/route.ts", + "src/app/api/projects/[id]/security-scan/route.ts", + "src/app/api/projects/[id]/settings/route.ts", + "src/app/api/projects/[id]/start/route.ts", + "src/app/api/projects/[id]/stop/route.ts", + "src/app/api/projects/[id]/supply-scan/route.ts", + "src/app/api/projects/[id]/update/route.enabled.test.ts", + "src/app/api/projects/[id]/update/route.guard.test.ts", + "src/app/api/projects/[id]/update/route.test.ts", + "src/app/api/projects/[id]/update/route.ts", + "src/app/api/projects/[id]/validate/route.ts", + "src/app/api/projects/[id]/vercel/route.ts", + "src/app/api/projects/[id]/vercel/stream/route.ts", + "src/app/api/projects/route.ts", + "src/app/api/projects/save/route.ts", + "src/app/api/projects/scan-path/route.ts", + "src/app/api/scheduler/route.ts", + "src/app/api/security/cve-lite/[id]/artifact/route.ts", + "src/app/api/security/cve-lite/[id]/fix/route.ts", + "src/app/api/security/cve-lite/[id]/install-skill/route.ts", + "src/app/api/security/cve-lite/[id]/report/route.ts", + "src/app/api/security/cve-lite/[id]/route.ts", + "src/app/api/security/cve-lite/db-status/route.ts", + "src/app/api/security/cve-lite/summary/route.ts", + "src/app/api/security/cve-lite/sync/route.ts", + "src/app/api/security/findings/route.test.ts", + "src/app/api/security/findings/route.ts", + "src/app/api/security/plugins/[id]/status/route.ts", + "src/app/api/security/plugins/route.ts", + "src/app/api/settings/autostart/route.ts", + "src/app/api/settings/route.ts", + "src/app/api/settings/verify-vercel/route.ts", + "src/app/api/sidebar/route.ts", + "src/app/api/system/metrics/route.ts", + "src/app/deps/page.tsx", + "src/app/layout.tsx", + "src/app/page.tsx", + "src/app/patches/page.tsx", + "src/app/patches/trends/page.tsx", + "src/app/security/page.tsx", + "src/app/security/safe-chain/page.tsx", + "src/components/providers.tsx", + "src/components/radial-gauge.tsx", + "src/components/security/findings-table.tsx", + "src/components/sidebar.tsx", + "src/lib/security/sources/dependency-health.test.ts" + ] + }, + "maliciousUnverifiable": false, + "maliciousGitSource": false, + "maliciousGitSourcePinned": false, + "unresolvedAdvisoryIds": [], + "vulnerabilities": [ + { + "id": "GHSA-4633-3j49-mh5q", + "aliases": [ + "CVE-2026-64647" + ], + "summary": "Next.js: Cache confusion of response bodies for requests with bodies containing invalid UTF-8 byte sequences", + "severity": "medium" + }, + { + "id": "GHSA-4c39-4ccg-62r3", + "aliases": [ + "CVE-2026-64646" + ], + "summary": "Next.js: Unbounded Server Action payload in Edge runtime", + "severity": "medium" + }, + { + "id": "GHSA-68g3-v927-f742", + "aliases": [ + "CVE-2026-64648" + ], + "summary": "Next.js: Cache confusion of response bodies for requests with bodies", + "severity": "medium" + }, + { + "id": "GHSA-6gpp-xcg3-4w24", + "aliases": [ + "CVE-2026-64642" + ], + "summary": "Next.js: Middleware / Proxy bypass in App Router applications using Turbopack and single locale", + "severity": "high" + }, + { + "id": "GHSA-89xv-2m56-2m9x", + "aliases": [ + "CVE-2026-64649" + ], + "summary": "Next.js: Server-Side Request Forgery in Server Actions on custom servers", + "severity": "high" + }, + { + "id": "GHSA-955p-x3mx-jcvp", + "aliases": [ + "CVE-2026-64643" + ], + "summary": "Next.js: Unauthenticated disclosure of internal Server Function endpoints", + "severity": "medium" + }, + { + "id": "GHSA-m99w-x7hq-7vfj", + "aliases": [ + "CVE-2026-64641" + ], + "summary": "Next.js: Denial of Service in App Router using Server Actions", + "severity": "high" + }, + { + "id": "GHSA-p9j2-gv94-2wf4", + "aliases": [ + "CVE-2026-64645" + ], + "summary": "Next.js: Server-Side Request Forgery in rewrites via attacker-controlled destination hostname", + "severity": "high" + }, + { + "id": "GHSA-q8wf-6r8g-63ch", + "aliases": [ + "CVE-2026-64644" + ], + "summary": "Next.js: Denial of Service in the Image Optimization API using SVGs", + "severity": "medium" + } + ] + }, + { + "package": "fast-uri", + "version": "3.1.2", + "severity": "high", + "relationship": "transitive", + "dev": false, + "firstFixedVersion": "2.4.2", + "validatedFirstFixedVersion": "3.1.5", + "fixVersionValidationNote": "Advisory fixed-version hint 2.4.2 is still known vulnerable for fast-uri; scanned 3 package versions above current version (2 still known vulnerable); using lowest known non-vulnerable version 3.1.5.", + "fixVersionPublishedAt": "2026-07-31T09:16:56.212Z", + "cooldownWarning": null, + "validatedTargetScannedVersions": 3, + "validatedTargetKnownVulnerableVersions": 2, + "recommendedAction": "ajv already permits fast-uri@3.1.5 - run `npm install` to pick it up.", + "runnableFixCommand": "pnpm update --no-save fast-uri", + "primaryParent": "ajv", + "recommendedNpmTransitiveRemediation": { + "kind": "update-parent-within-range", + "package": "ajv", + "currentVersion": "8.20.0", + "targetChildVersion": "3.1.5", + "viaPath": [ + "project", + "ajv", + "fast-uri" + ], + "reason": "ajv@8.20.0 already allows fast-uri@3.1.5 within the current dependency range", + "workspaces": [ + "." + ] + }, + "cves": [ + "CVE-2026-13676", + "CVE-2026-18446", + "CVE-2026-16221" + ], + "dependencyPaths": [ + [ + "project", + "ajv", + "fast-uri" + ], + [ + "project", + "@modelcontextprotocol/sdk", + "ajv", + "fast-uri" + ], + [ + "project", + "ajv-formats", + "ajv", + "fast-uri" + ], + [ + "project", + "@modelcontextprotocol/sdk", + "ajv-formats", + "ajv", + "fast-uri" + ] + ], + "usage": { + "imported": false, + "files": [] + }, + "maliciousUnverifiable": false, + "maliciousGitSource": false, + "maliciousGitSourcePinned": false, + "unresolvedAdvisoryIds": [], + "vulnerabilities": [ + { + "id": "GHSA-4c8g-83qw-93j6", + "aliases": [ + "CVE-2026-13676" + ], + "summary": "fast-uri vulnerable to host confusion via failed IDN canonicalization", + "severity": "high" + }, + { + "id": "GHSA-7p8r-x3mc-p8w7", + "aliases": [ + "CVE-2026-18446" + ], + "summary": "fast-uri vulnerable to host confusion via backslash authority introducer", + "severity": "high" + }, + { + "id": "GHSA-v2hh-gcrm-f6hx", + "aliases": [ + "CVE-2026-16221" + ], + "summary": "fast-uri vulnerable to host confusion via literal backslash authority delimiter", + "severity": "high" + } + ] + }, + { + "package": "ip-address", + "version": "10.2.0", + "severity": "high", + "relationship": "transitive", + "dev": false, + "firstFixedVersion": "10.2.1", + "validatedFirstFixedVersion": "10.3.1", + "fixVersionValidationNote": "Advisory fixed-version hint 10.2.1 is still known vulnerable for ip-address; scanned 4 package versions above current version (3 still known vulnerable); using lowest known non-vulnerable version 10.3.1.", + "fixVersionPublishedAt": "2026-07-25T08:00:07.838Z", + "cooldownWarning": null, + "validatedTargetScannedVersions": 4, + "validatedTargetKnownVulnerableVersions": 3, + "recommendedAction": "ip-address already permits ip-address@10.5.0 - run `npm install` to pick it up.", + "runnableFixCommand": "pnpm update --recursive --no-save ip-address", + "primaryParent": "@modelcontextprotocol/sdk", + "recommendedNpmTransitiveRemediation": { + "kind": "update-parent-within-range", + "package": "ip-address", + "currentVersion": "10.2.0", + "targetChildVersion": "10.5.0", + "viaPath": [ + "project", + "@modelcontextprotocol/sdk", + "express-rate-limit", + "ip-address" + ], + "reason": "express-rate-limit@8.5.2 already allows ip-address@10.5.0 within the current dependency range" + }, + "cves": [ + "CVE-2026-54272", + "CVE-2026-69198", + "CVE-2026-69192" + ], + "dependencyPaths": [ + [ + "project", + "@modelcontextprotocol/sdk", + "express-rate-limit", + "ip-address" + ] + ], + "usage": { + "imported": false, + "files": [] + }, + "maliciousUnverifiable": false, + "maliciousGitSource": false, + "maliciousGitSourcePinned": false, + "unresolvedAdvisoryIds": [], + "vulnerabilities": [ + { + "id": "GHSA-22jq-vg5j-6vgg", + "aliases": [ + "CVE-2026-54272" + ], + "summary": "ip-address: misclassification of IPv4-mapped/NAT64 IPv6 addresses can bypass SSRF and trust-boundary checks", + "severity": "medium" + }, + { + "id": "GHSA-4xrf-jv44-h6hh", + "aliases": [ + "CVE-2026-69198" + ], + "summary": "ip-address: a CIDR suffix on the parsed address suppresses special-use classification and can bypass SSRF and trust-boundary checks", + "severity": "medium" + }, + { + "id": "GHSA-mwp4-54f8-5fhr", + "aliases": [ + "CVE-2026-69192" + ], + "summary": "ip-address: Address4 decodes leading-zero octets as decimal while resolvers decode them as octal, allowing SSRF and trust-boundary bypass", + "severity": "high" + } + ] + }, + { + "package": "nanoid", + "version": "3.3.15", + "severity": "high", + "relationship": "transitive", + "dev": false, + "firstFixedVersion": "3.3.16", + "validatedFirstFixedVersion": "3.3.17", + "fixVersionValidationNote": "Advisory fixed-version hint 3.3.16 is still known vulnerable for nanoid; scanned 2 package versions above current version (1 still known vulnerable); using lowest known non-vulnerable version 3.3.17.", + "fixVersionPublishedAt": "2026-08-03T10:39:22.487Z", + "cooldownWarning": null, + "validatedTargetScannedVersions": 2, + "validatedTargetKnownVulnerableVersions": 1, + "recommendedAction": "nanoid already permits nanoid@3.3.18 - run `npm install` to pick it up.", + "runnableFixCommand": "pnpm update --recursive --no-save nanoid", + "primaryParent": "next", + "recommendedNpmTransitiveRemediation": { + "kind": "update-parent-within-range", + "package": "nanoid", + "currentVersion": "3.3.15", + "targetChildVersion": "3.3.18", + "viaPath": [ + "project", + "next", + "postcss", + "nanoid" + ], + "reason": "postcss@8.5.16 already allows nanoid@3.3.18 within the current dependency range" + }, + "cves": [ + "CVE-2026-67214", + "CVE-2026-67213" + ], + "dependencyPaths": [ + [ + "project", + "next", + "postcss", + "nanoid" + ], + [ + "project", + "@tailwindcss/postcss", + "postcss", + "nanoid" + ], + [ + "project", + "vitest", + "vite", + "postcss", + "nanoid" + ], + [ + "project", + "@vitest/ui", + "vitest", + "vite", + "postcss", + "nanoid" + ], + [ + "project", + "vitest", + "@vitest/mocker", + "vite", + "postcss", + "nanoid" + ] + ], + "usage": { + "imported": false, + "files": [] + }, + "maliciousUnverifiable": false, + "maliciousGitSource": false, + "maliciousGitSourcePinned": false, + "unresolvedAdvisoryIds": [], + "vulnerabilities": [ + { + "id": "GHSA-28wg-ghj8-5hjv", + "aliases": [ + "CVE-2026-67214" + ], + "summary": "nanoid: non-secure generators can loop indefinitely with negative size", + "severity": "high" + }, + { + "id": "GHSA-2v37-7h3g-55p8", + "aliases": [ + "CVE-2026-67213" + ], + "summary": "nanoid: custom generators can loop indefinitely when size is zero", + "severity": "high" + } + ] + }, + { + "package": "postcss", + "version": "8.5.16", + "severity": "high", + "relationship": "transitive", + "dev": false, + "firstFixedVersion": "8.5.18", + "validatedFirstFixedVersion": "8.5.23", + "fixVersionValidationNote": "Advisory fixed-version hint 8.5.18 is still known vulnerable for postcss; scanned 7 package versions above current version (6 still known vulnerable); using lowest known non-vulnerable version 8.5.23.", + "fixVersionPublishedAt": "2026-07-24T17:05:13.876Z", + "cooldownWarning": null, + "validatedTargetScannedVersions": 7, + "validatedTargetKnownVulnerableVersions": 6, + "recommendedAction": "Upgrade next from 16.2.10 to 16.3.0 to resolve the postcss@8.5.16 path for project -> next -> postcss; run it, then rescan. 4 other known paths may still need separate parent upgrades.", + "runnableFixCommand": "pnpm add next@16.3.0", + "primaryParent": "next", + "recommendedParentUpgrade": { + "package": "next", + "currentVersion": "16.2.10", + "targetVersion": "16.3.0", + "viaPath": [ + "project", + "next", + "postcss" + ], + "vulnerablePackage": "postcss", + "confidence": "verified", + "reason": "next@16.3.0 no longer allows postcss@8.5.16 and allows 8.5.23+" + }, + "recommendedNpmTransitiveRemediation": null, + "cves": [ + "CVE-2026-69153" + ], + "dependencyPaths": [ + [ + "project", + "next", + "postcss" + ], + [ + "project", + "@tailwindcss/postcss", + "postcss" + ], + [ + "project", + "vitest", + "vite", + "postcss" + ], + [ + "project", + "@vitest/ui", + "vitest", + "vite", + "postcss" + ], + [ + "project", + "vitest", + "@vitest/mocker", + "vite", + "postcss" + ] + ], + "usage": { + "imported": false, + "files": [] + }, + "maliciousUnverifiable": false, + "maliciousGitSource": false, + "maliciousGitSourcePinned": false, + "unresolvedAdvisoryIds": [], + "vulnerabilities": [ + { + "id": "GHSA-fxqj-rqcc-2cmp", + "aliases": [ + "CVE-2026-69153" + ], + "summary": "PostCSS: incomplete fix of GHSA-6g55-p6wh-862q — attacker-controlled sourceMappingURL reads arbitrary .map files when `from` is unset", + "severity": "medium" + }, + { + "id": "GHSA-r28c-9q8g-f849", + "aliases": [], + "summary": "PostCSS: Path Traversal in Previous Source Map Auto-Loading (sourceMappingURL) leads to Arbitrary .map File Disclosure", + "severity": "high" + } + ] + }, + { + "package": "sharp", + "version": "0.34.5", + "severity": "high", + "relationship": "transitive", + "dev": false, + "firstFixedVersion": "0.35.0", + "validatedFirstFixedVersion": "0.35.0", + "fixVersionValidationNote": null, + "fixVersionPublishedAt": "2026-06-10T17:07:14.319Z", + "cooldownWarning": null, + "validatedTargetScannedVersions": 1, + "validatedTargetKnownVulnerableVersions": 0, + "recommendedAction": "Upgrade next - no safe version was identified automatically. Check for a release that resolves sharp to 0.35.0+.", + "runnableFixCommand": null, + "primaryParent": "next", + "recommendedParentUpgrade": null, + "recommendedNpmTransitiveRemediation": null, + "cves": [], + "dependencyPaths": [ + [ + "project", + "next", + "sharp" + ] + ], + "usage": { + "imported": false, + "files": [] + }, + "maliciousUnverifiable": false, + "maliciousGitSource": false, + "maliciousGitSourcePinned": false, + "unresolvedAdvisoryIds": [], + "vulnerabilities": [ + { + "id": "GHSA-f88m-g3jw-g9cj", + "aliases": [], + "summary": "sharp inherited vulnerabilities in libvips: CVE-2026-33327, CVE-2026-33328, CVE-2026-35590, CVE-2026-35591", + "severity": "high" + } + ] + }, + { + "package": "@hono/node-server", + "version": "1.19.14", + "severity": "medium", + "relationship": "transitive", + "dev": false, + "firstFixedVersion": "2.0.5", + "validatedFirstFixedVersion": "2.0.5", + "fixVersionValidationNote": null, + "fixVersionPublishedAt": "2026-06-15T12:55:56.300Z", + "cooldownWarning": null, + "validatedTargetScannedVersions": 8, + "validatedTargetKnownVulnerableVersions": 7, + "recommendedAction": "Upgrade @modelcontextprotocol/sdk - no safe version was identified automatically. Check for a release that resolves @hono/node-server to 2.0.5+.", + "runnableFixCommand": null, + "primaryParent": "@modelcontextprotocol/sdk", + "recommendedParentUpgrade": null, + "recommendedNpmTransitiveRemediation": null, + "cves": [], + "dependencyPaths": [ + [ + "project", + "@modelcontextprotocol/sdk", + "@hono/node-server" + ] + ], + "usage": { + "imported": false, + "files": [] + }, + "maliciousUnverifiable": false, + "maliciousGitSource": false, + "maliciousGitSourcePinned": false, + "unresolvedAdvisoryIds": [], + "vulnerabilities": [ + { + "id": "GHSA-frvp-7c67-39w9", + "aliases": [], + "summary": "Node.js Adapter for Hono: Path traversal in `serve-static` on Windows via encoded backslash (`%5C`)", + "severity": "medium" + } + ] + }, + { + "package": "hono", + "version": "4.12.26", + "severity": "medium", + "relationship": "transitive", + "dev": false, + "firstFixedVersion": "4.12.27", + "validatedFirstFixedVersion": "4.12.34", + "fixVersionValidationNote": "Advisory fixed-version hint 4.12.27 is still known vulnerable for hono; scanned 8 package versions above current version (7 still known vulnerable); using lowest known non-vulnerable version 4.12.34.", + "fixVersionPublishedAt": "2026-08-03T02:36:40.543Z", + "cooldownWarning": null, + "validatedTargetScannedVersions": 8, + "validatedTargetKnownVulnerableVersions": 7, + "recommendedAction": "@modelcontextprotocol/sdk already permits hono@4.13.1 - run `npm install` to pick it up.", + "runnableFixCommand": "pnpm update --no-save hono", + "primaryParent": "@modelcontextprotocol/sdk", + "recommendedNpmTransitiveRemediation": { + "kind": "update-parent-within-range", + "package": "@modelcontextprotocol/sdk", + "currentVersion": "1.29.0", + "targetChildVersion": "4.13.1", + "viaPath": [ + "project", + "@modelcontextprotocol/sdk", + "hono" + ], + "reason": "@modelcontextprotocol/sdk@1.29.0 already allows hono@4.13.1 within the current dependency range", + "workspaces": [ + "." + ] + }, + "cves": [ + "CVE-2026-71848", + "CVE-2026-71849", + "CVE-2026-69207", + "CVE-2026-71850", + "CVE-2026-59896", + "CVE-2026-59895", + "CVE-2026-59897" + ], + "dependencyPaths": [ + [ + "project", + "@modelcontextprotocol/sdk", + "hono" + ], + [ + "project", + "@modelcontextprotocol/sdk", + "@hono/node-server", + "hono" + ] + ], + "usage": { + "imported": false, + "files": [] + }, + "maliciousUnverifiable": false, + "maliciousGitSource": false, + "maliciousGitSourcePinned": false, + "unresolvedAdvisoryIds": [], + "vulnerabilities": [ + { + "id": "GHSA-54fx-42gc-7vw4", + "aliases": [ + "CVE-2026-71848" + ], + "summary": "Hono: Algorithmic Complexity DoS in Language Middleware", + "severity": "medium" + }, + { + "id": "GHSA-79qm-7rj5-m7r9", + "aliases": [ + "CVE-2026-71849" + ], + "summary": "Hono: Proxy Helper does not remove response headers listed in the `Connection` header", + "severity": "low" + }, + { + "id": "GHSA-8j4g-w8fx-2239", + "aliases": [ + "CVE-2026-69207" + ], + "summary": "Hono: ReDoS in CORS middleware via Access-Control-Request-Headers", + "severity": "medium" + }, + { + "id": "GHSA-f23p-vx2j-j53r", + "aliases": [ + "CVE-2026-71850" + ], + "summary": "Hono: `memo()` retains SSR output across requests, leading to cross-user data disclosure", + "severity": "medium" + }, + { + "id": "GHSA-hvrm-45r6-mjfj", + "aliases": [ + "CVE-2026-59896" + ], + "summary": "hono/jsx does not isolate context per request, leading to cross-request data disclosure", + "severity": "medium" + }, + { + "id": "GHSA-w62v-xxxg-mg59", + "aliases": [ + "CVE-2026-59895" + ], + "summary": "Hono: Server-Side XSS via JSX Escaping Bypass in cx() Utility", + "severity": "medium" + }, + { + "id": "GHSA-xgm2-5f3f-mvvc", + "aliases": [ + "CVE-2026-59897" + ], + "summary": "Hono: API Gateway v1 adapter can drop a distinct repeated request header value during de-duplication", + "severity": "medium" + } + ] + } + ], + "overrideFindings": [], + "maintenanceFindings": [] +} \ No newline at end of file diff --git a/src/lib/security/sources/cve-lite.test.ts b/src/lib/security/sources/cve-lite.test.ts index 5fc9e4e..de85116 100644 --- a/src/lib/security/sources/cve-lite.test.ts +++ b/src/lib/security/sources/cve-lite.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; import { join } from 'path'; -import { parseCveLiteJson, buildScanFlags, buildCveLiteCommand } from './cve-lite'; +import { parseCveLiteJson, buildScanFlags, buildCveLiteCommand, buildCompletenessWarning } from './cve-lite'; import { CVE_LITE_DB_PATH } from '../cve-lite-db'; const fixture = JSON.parse(readFileSync(join(__dirname, '__fixtures__/cve-lite-sample.json'), 'utf-8')); @@ -107,3 +107,38 @@ describe('buildCveLiteCommand', () => { expect(cmd).toContain('"/my proj"'); }); }); + +describe('CveLiteSource degraded reporting', () => { + it('returns a warning describing what could not be checked', () => { + const warning = buildCompletenessWarning({ + complete: false, + unresolvedAdvisoryIds: ['GHSA-1', 'GHSA-2'], + skippedCount: 1, + warnings: ['registry timeout'], + }); + expect(warning).toContain('2 advisories unresolved'); + expect(warning).toContain('1 dependency skipped'); + expect(warning).toContain('registry timeout'); + }); + + it('returns undefined for a complete scan', () => { + expect( + buildCompletenessWarning({ + complete: true, + unresolvedAdvisoryIds: [], + skippedCount: 0, + warnings: [], + }), + ).toBeUndefined(); + }); + + it('pluralises a single unresolved advisory', () => { + const warning = buildCompletenessWarning({ + complete: false, + unresolvedAdvisoryIds: ['GHSA-1'], + skippedCount: 0, + warnings: [], + }); + expect(warning).toContain('1 advisory unresolved'); + }); +}); diff --git a/src/lib/security/sources/cve-lite.ts b/src/lib/security/sources/cve-lite.ts index 9fd25de..1afacd4 100644 --- a/src/lib/security/sources/cve-lite.ts +++ b/src/lib/security/sources/cve-lite.ts @@ -3,10 +3,10 @@ import { promisify } from 'util'; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import type { ScanSource, Finding, Severity, Remediation } from '../types'; +import type { ScanSource, Finding, Severity, Remediation, ScanSourceResult } from '../types'; import type { ProjectConfig } from '../../types'; import { readCveLiteCache, writeCveLiteCache } from '../cve-lite-cache'; -import { deriveReachable } from '../cve-lite-view'; +import { deriveReachable, scanCompleteness, type ScanCompleteness } from '../cve-lite-view'; import { CVE_LITE_DB_PATH } from '../cve-lite-db'; const execAsync = promisify(exec); @@ -55,7 +55,8 @@ export interface CveLiteParentUpgrade { targetVersion?: string; viaPath?: string[]; vulnerablePackage?: string; - confidence?: string; + /** 1.28 replaced 1.24's 'exact-direct-child' | 'best-effort' with this pair. */ + confidence?: 'verified' | 'unverified'; reason?: string; } @@ -76,6 +77,15 @@ export interface CveLiteFinding { dependencyPaths?: string[][]; usage?: unknown; vulnerabilities?: CveLiteVuln[]; + dev?: boolean; + fixVersionPublishedAt?: string; + cooldownWarning?: unknown | null; + validatedTargetScannedVersions?: number; + validatedTargetKnownVulnerableVersions?: number; + maliciousUnverifiable?: boolean; + maliciousGitSource?: boolean; + maliciousGitSourcePinned?: boolean; + unresolvedAdvisoryIds?: string[]; } export interface CveLiteOutput { @@ -83,6 +93,13 @@ export interface CveLiteOutput { packageCount?: number; projectPath?: string; source?: string; + mode?: string; + notes?: string[]; + warnings?: string[]; + /** Element shape unverified (empty in the 1.28 capture) — count only, never destructure. */ + skippedDependencies?: unknown[]; + overrideFindings?: unknown[]; + maintenanceFindings?: unknown[]; suggestedFixCommands?: unknown; findings?: CveLiteFinding[]; } @@ -135,6 +152,21 @@ export function parseCveLiteJson(out: CveLiteOutput): Finding[] { return findings; } +/** Renders a completeness result as a one-line source warning, or undefined when the scan was complete. */ +export function buildCompletenessWarning(c: ScanCompleteness): string | undefined { + if (c.complete) return undefined; + const parts: string[] = []; + if (c.unresolvedAdvisoryIds.length > 0) { + const n = c.unresolvedAdvisoryIds.length; + parts.push(`${n} ${n === 1 ? 'advisory' : 'advisories'} unresolved`); + } + if (c.skippedCount > 0) { + parts.push(`${c.skippedCount} ${c.skippedCount === 1 ? 'dependency' : 'dependencies'} skipped`); + } + parts.push(...c.warnings); + return `Partial scan: ${parts.join('; ')}`; +} + function binPath(): string { return join(process.cwd(), 'node_modules', '.bin', 'cve-lite'); } @@ -256,8 +288,11 @@ export const CveLiteSource: ScanSource = { isAvailable: probe, - async scan(project: ProjectConfig): Promise { + async scan(project: ProjectConfig): Promise { const report = await runCveLite(project); - return parseCveLiteJson(report); + return { + findings: parseCveLiteJson(report), + warning: buildCompletenessWarning(scanCompleteness(report)), + }; }, }; diff --git a/src/lib/security/sources/dependency-health.test.ts b/src/lib/security/sources/dependency-health.test.ts index e9c4cc4..5877d1c 100644 --- a/src/lib/security/sources/dependency-health.test.ts +++ b/src/lib/security/sources/dependency-health.test.ts @@ -133,7 +133,7 @@ describe('DependencyHealthSource.scan', () => { mkdirSync(join(dir, 'src', 'lib'), { recursive: true }); writeFileSync(join(dir, 'src', 'lib', 'yaml-engine.ts'), "import yaml from 'js-yaml';\n"); - const findings = await DependencyHealthSource.scan({ id: 'p', name: 'p', path: dir } as never); + const { findings } = await DependencyHealthSource.scan({ id: 'p', name: 'p', path: dir } as never); expect(findings).toHaveLength(1); const f = findings[0]; expect(f).toMatchObject({ @@ -154,7 +154,7 @@ describe('DependencyHealthSource.scan', () => { it('returns [] when package.json is absent', async () => { const dir = mkdtempSync(join(tmpdir(), 'dh-empty-')); try { - expect(await DependencyHealthSource.scan({ id: 'p', name: 'p', path: dir } as never)).toEqual([]); + expect((await DependencyHealthSource.scan({ id: 'p', name: 'p', path: dir } as never)).findings).toEqual([]); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -185,7 +185,7 @@ describe('DependencyHealthSource.scan', () => { "import y from 'real-script-pkg';\n" ); - const findings = await DependencyHealthSource.scan({ id: 'p', name: 'p', path: tmpDir } as never); + const { findings } = await DependencyHealthSource.scan({ id: 'p', name: 'p', path: tmpDir } as never); const names = findings.map((f) => f.package as string); expect(names).not.toContain("fixture-only-pkg"); diff --git a/src/lib/security/sources/dependency-health.ts b/src/lib/security/sources/dependency-health.ts index 45bb73a..8685dcb 100644 --- a/src/lib/security/sources/dependency-health.ts +++ b/src/lib/security/sources/dependency-health.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { ScanSource, Finding } from '../types'; +import type { ScanSource, Finding, ScanSourceResult } from '../types'; import type { ProjectConfig } from '../../types'; import { logger } from '../../logger'; @@ -254,13 +254,13 @@ function toFinding(pf: PhantomFinding, version: string | undefined, pm: PackageM }; } -async function scan(project: ProjectConfig): Promise { +async function scan(project: ProjectConfig): Promise { const root = project.path; let pkg: PkgJson; try { pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')) as PkgJson; } catch { - return []; + return { findings: [] }; } const declared = new Set([ ...Object.keys(pkg.dependencies ?? {}), @@ -283,7 +283,7 @@ async function scan(project: ProjectConfig): Promise { const version = await readInstalledVersion(root, pf.pkg); findings.push(toFinding(pf, version, packageManager)); } - return findings; + return { findings }; } export const DependencyHealthSource: ScanSource = { diff --git a/src/lib/security/sources/grype.ts b/src/lib/security/sources/grype.ts index d95851b..8ca0390 100644 --- a/src/lib/security/sources/grype.ts +++ b/src/lib/security/sources/grype.ts @@ -1,6 +1,6 @@ import { exec } from 'child_process'; import { promisify } from 'util'; -import type { ScanSource, Finding, Severity } from '../types'; +import type { ScanSource, Finding, Severity, ScanSourceResult } from '../types'; import type { ProjectConfig } from '../../types'; const execAsync = promisify(exec); @@ -102,12 +102,12 @@ export const GrypeSource: ScanSource = { isAvailable: probe, - async scan(project: ProjectConfig): Promise { + async scan(project: ProjectConfig): Promise { await maybeUpdateDb(); const { stdout } = await execAsync( `grype dir:${JSON.stringify(project.path)} -o json --quiet`, { timeout: 110_000, maxBuffer: 50 * 1024 * 1024 }, ); - return parseGrypeJson(JSON.parse(stdout) as GrypeOutput); + return { findings: parseGrypeJson(JSON.parse(stdout) as GrypeOutput) }; }, }; diff --git a/src/lib/security/sources/index.ts b/src/lib/security/sources/index.ts index cc60003..e47b6d7 100644 --- a/src/lib/security/sources/index.ts +++ b/src/lib/security/sources/index.ts @@ -3,5 +3,12 @@ import { PnpmAuditSource } from './pnpm-audit'; import { GrypeSource } from './grype'; import { CveLiteSource } from './cve-lite'; import { DependencyHealthSource } from './dependency-health'; +import { OverrideHygieneSource } from './override-hygiene'; -export const SOURCES: ScanSource[] = [PnpmAuditSource, GrypeSource, CveLiteSource, DependencyHealthSource]; +export const SOURCES: ScanSource[] = [ + PnpmAuditSource, + GrypeSource, + CveLiteSource, + DependencyHealthSource, + OverrideHygieneSource, +]; diff --git a/src/lib/security/sources/override-hygiene.test.ts b/src/lib/security/sources/override-hygiene.test.ts new file mode 100644 index 0000000..9adc9a4 --- /dev/null +++ b/src/lib/security/sources/override-hygiene.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseOverrideAuditJson } from './override-hygiene'; +import { computeDedupKey } from '../merger'; +import type { OverrideAuditOutput } from '../override-audit'; + +const FIXTURE = JSON.parse( + readFileSync(join(__dirname, '__fixtures__/cve-lite-1.28-overrides.json'), 'utf-8'), +) as OverrideAuditOutput; + +describe('parseOverrideAuditJson', () => { + it('maps the recorded fixture to config findings', () => { + const findings = parseOverrideAuditJson(FIXTURE); + expect(findings).toHaveLength(2); + expect(findings.every((f) => f.type === 'config')).toBe(true); + expect(findings.every((f) => f.sources[0] === 'override-hygiene')).toBe(true); + expect(findings[0].severity).toBe('low'); + expect(findings[0].title).toContain('OA009'); + }); + + it('gives same-rule findings on different packages distinct dedup keys', () => { + const findings = parseOverrideAuditJson(FIXTURE); + const keys = findings.map((f) => computeDedupKey(f)); + expect(new Set(keys).size).toBe(findings.length); + }); + + it('filters out PD001 and PD002 so DependencyHealthSource stays authoritative', () => { + const findings = parseOverrideAuditJson({ + findings: [ + { ruleId: 'PD001', severity: 'high', package: { name: 'js-yaml' }, message: 'phantom' }, + { ruleId: 'PD002', severity: 'medium', package: { name: 'pg' }, message: 'phantom' }, + { ruleId: 'OA001', severity: 'high', package: { name: 'x' }, message: 'orphaned' }, + ], + }); + expect(findings.map((f) => f.package)).toEqual(['x']); + }); + + it('carries the runnable fix command into remediation', () => { + const findings = parseOverrideAuditJson(FIXTURE); + expect(findings[0].remediation?.runnableFixCommand).toContain('overrides --fix'); + expect(findings[0].remediation?.source).toBe('override-hygiene'); + }); + + it('falls back to info severity for an unrecognised value', () => { + const findings = parseOverrideAuditJson({ + findings: [{ ruleId: 'OA003', severity: 'weird', package: { name: 'z' }, message: 'm' }], + }); + expect(findings[0].severity).toBe('info'); + }); + + it('returns an empty array for an empty report', () => { + expect(parseOverrideAuditJson({})).toEqual([]); + }); + + it('drops a finding with a missing ruleId instead of defaulting it into scope (F7)', () => { + const findings = parseOverrideAuditJson({ + findings: [ + { severity: 'high', package: { name: 'x' }, message: 'no ruleId at all' }, + { ruleId: 'OA001', severity: 'high', package: { name: 'y' }, message: 'has a ruleId' }, + ], + }); + expect(findings.map((f) => f.package)).toEqual(['y']); + }); + + it('gives same-rule, same-message findings on different packages distinct dedup keys even without jsonPath (F6)', () => { + const findings = parseOverrideAuditJson({ + findings: [ + { ruleId: 'OA009', severity: 'low', package: { name: 'pkg-a' }, message: 'Override floor already met' }, + { ruleId: 'OA009', severity: 'low', package: { name: 'pkg-b' }, message: 'Override floor already met' }, + ], + }); + expect(findings).toHaveLength(2); + const keys = findings.map((f) => computeDedupKey(f)); + expect(new Set(keys).size).toBe(2); + }); +}); diff --git a/src/lib/security/sources/override-hygiene.ts b/src/lib/security/sources/override-hygiene.ts new file mode 100644 index 0000000..a4603b6 --- /dev/null +++ b/src/lib/security/sources/override-hygiene.ts @@ -0,0 +1,84 @@ +import type { Finding, ScanSource, ScanSourceResult, Severity } from '../types'; +import type { ProjectConfig } from '../../types'; +import { + overrideAuditAvailable, + runOverrideAudit, + type OverrideAuditOutput, + type OverrideFinding, +} from '../override-audit'; + +const SEVERITY_MAP: Record = { + critical: 'critical', + high: 'high', + medium: 'medium', + moderate: 'medium', + low: 'low', + info: 'info', +}; + +/** + * Phantom-dependency rules are filtered out: DependencyHealthSource (#125) is + * HexOps's phantom-dep authority, and config findings dedup on title+path, so + * letting both report would show the same phantom dep twice on /security. + */ +export const EXCLUDED_RULES = new Set(['PD001', 'PD002']); + +export function parseOverrideAuditJson(out: OverrideAuditOutput): Finding[] { + const findings: Finding[] = []; + for (const of of out.findings ?? []) { + // A missing/unrecognised ruleId used to default to 'OA000', which isn't in + // EXCLUDED_RULES — an unlabelled phantom-dep finding would slip past the + // PD filter and duplicate DependencyHealthSource. Rather than invent a + // taxonomy to classify it, drop anything we can't positively identify as + // a known, non-excluded rule (F7). + const ruleId = of.ruleId; + if (!ruleId) continue; + if (EXCLUDED_RULES.has(ruleId)) continue; + findings.push(toFinding(of, ruleId)); + } + return findings; +} + +function toFinding(of: OverrideFinding, ruleId: string): Finding { + const file = of.location?.file ?? 'package.json'; + const jsonPath = of.location?.jsonPath ?? ''; + const pkgName = of.package?.name; + return { + type: 'config', + dedupKey: '', + sources: ['override-hygiene'], + // jsonPath is part of the path so two findings of the same rule on + // different override entries get distinct dedup keys — the fixture + // contains two OA009 findings with identical message text. When jsonPath + // is absent, fall back to the package name (rather than bare `file`) so + // same-rule findings on different packages with identical message text + // don't collide on the merger's `config:|` dedup key (F6). + path: jsonPath ? `${file}#${jsonPath}` : pkgName ? `${file}#${pkgName}` : file, + title: `${ruleId}: ${of.message ?? 'Override hygiene finding'}`, + detail: of.details ?? '', + package: of.package?.name, + severity: SEVERITY_MAP[(of.severity ?? 'info').toLowerCase()] ?? 'info', + advisoryIds: [], + rawBySource: { 'override-hygiene': of }, + references: of.references ?? [], + remediation: { + source: 'override-hygiene', + runnableFixCommand: of.fix?.runnableCommand ?? undefined, + recommendedAction: of.details ?? undefined, + }, + }; +} + +export const OverrideHygieneSource: ScanSource = { + id: 'override-hygiene', + displayName: 'Override Hygiene (OA)', + findingTypes: ['config'], + timeoutMs: 180_000, + + isAvailable: async () => overrideAuditAvailable(), + + async scan(project: ProjectConfig): Promise<ScanSourceResult> { + const report = await runOverrideAudit(project); + return { findings: parseOverrideAuditJson(report) }; + }, +}; diff --git a/src/lib/security/sources/pnpm-audit.ts b/src/lib/security/sources/pnpm-audit.ts index ba36b97..a6bc4c3 100644 --- a/src/lib/security/sources/pnpm-audit.ts +++ b/src/lib/security/sources/pnpm-audit.ts @@ -1,4 +1,4 @@ -import type { ScanSource, Finding, Severity } from '../types'; +import type { ScanSource, Finding, Severity, ScanSourceResult } from '../types'; import type { ProjectConfig, VulnerabilityInfo } from '../../types'; import { runPnpmAudit } from '../../patch-scanner'; import { existsSync } from 'fs'; @@ -77,14 +77,16 @@ export const PnpmAuditSource: ScanSource = { return true; // always present — uses the project's package manager }, - async scan(project: ProjectConfig): Promise<Finding[]> { + async scan(project: ProjectConfig): Promise<ScanSourceResult> { const pm = detectPm(project.path); - if (!pm) return []; + if (!pm) return { findings: [] }; const { vulnerabilities, raw } = await runPnpmAudit(project.path, pm); - return vulnerabilities.map((v): Finding => ({ - ...vulnInfoToFinding(v), - rawBySource: { 'pnpm-audit': raw }, - })); + return { + findings: vulnerabilities.map((v): Finding => ({ + ...vulnInfoToFinding(v), + rawBySource: { 'pnpm-audit': raw }, + })), + }; }, }; diff --git a/src/lib/security/types.ts b/src/lib/security/types.ts index 9d97ccc..f1f48c2 100644 --- a/src/lib/security/types.ts +++ b/src/lib/security/types.ts @@ -67,11 +67,17 @@ export interface ScanResult { findings: Finding[]; } +export interface ScanSourceResult { + findings: Finding[]; + /** Set when the source succeeded but could not cover everything. Surfaces as SourceResult.warning. */ + warning?: string; +} + export interface ScanSource { id: string; displayName: string; findingTypes: FindingType[]; timeoutMs?: number; isAvailable(): Promise<boolean>; - scan(project: ProjectConfig): Promise<Finding[]>; + scan(project: ProjectConfig): Promise<ScanSourceResult>; }