Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,6 @@ docs/dev-notes.md
docs/plans/
docs/superpowers/
.superpowers/

# Playwright MCP session artifacts
.playwright-mcp/
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hexops",
"version": "0.20.1",
"version": "0.21.0",
"private": true,
"author": "Hexaxia Technologies",
"repository": {
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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 });
});
});
27 changes: 27 additions & 0 deletions src/app/api/security/overrides/[id]/fix/route.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
107 changes: 107 additions & 0 deletions src/app/api/security/overrides/[id]/fix/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
66 changes: 66 additions & 0 deletions src/app/api/security/overrides/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
36 changes: 36 additions & 0 deletions src/app/api/security/overrides/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading
Loading