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.
+
+ {c.unresolvedAdvisoryIds.length > 0 && (
+
+ {c.unresolvedAdvisoryIds.length}{' '}
+ {c.unresolvedAdvisoryIds.length === 1 ? 'advisory' : 'advisories'} could not be
+ resolved: {c.unresolvedAdvisoryIds.join(', ')}
+
+ )}
+ {c.skippedCount > 0 && (
+
+ {c.skippedCount} {c.skippedCount === 1 ? 'dependency was' : 'dependencies were'}{' '}
+ skipped
+
+ )}
+ {c.warnings.map((w) => {w} )}
+
+
+ );
+}
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 (
+ <>
+
+
+
+ Rule
+ Severity
+ Package
+ Location
+ Finding
+ Fix
+
+
+
+ {findings.map((f) => (
+
+ {f.ruleId}
+
+ {f.package?.name ?? '—'}
+
+ {f.location?.file}
+ {f.location?.jsonPath ? ` > ${f.location.jsonPath}` : ''}
+
+ {f.message}
+
+ {f.fix?.runnableCommand
+ ? {f.fix.runnableCommand}
+ : — }
+
+
+ ))}
+
+
+
+ void load(true)}
+ className="px-2 py-1 text-xs rounded border border-zinc-700 hover:bg-zinc-800"
+ >
+ Re-audit
+
+ {OVERRIDE_HYGIENE_FIX_ENABLED && (
+ setConfirmRule('ALL')}
+ className="px-2 py-1 text-xs rounded border border-zinc-700 hover:bg-zinc-800 disabled:opacity-50"
+ >
+ Fix all
+
+ )}
+
+ {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
+
>
)}
{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 {
+ 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 {
+ async scan(project: ProjectConfig): Promise {
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;
- scan(project: ProjectConfig): Promise;
+ scan(project: ProjectConfig): Promise;
}