Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
167ce28
chore(security): convert override pins to version floors
alamb-hex May 24, 2026
d020f56
feat(patches): dev-server-aware patching — stop→apply→restart, refuse…
alamb-hex May 24, 2026
be4c983
feat(security/plugins): define SecurityPlugin discriminated-union types
alamb-hex May 26, 2026
da1ba77
feat(security/plugins): empty static registry + getPlugin lookup
alamb-hex May 26, 2026
dfa9ad1
feat(security/plugins): per-project plugin config + helpers
alamb-hex May 26, 2026
d010260
feat(security/plugins): runAllPluginCards — parallel, isolated errors
alamb-hex May 26, 2026
de4a60c
feat(security/plugins/safe-chain): host detection + 30s cache
alamb-hex May 26, 2026
f7f5905
feat(security/plugins/safe-chain): pure command rewriter
alamb-hex May 26, 2026
dad3e14
feat(security/plugins/safe-chain): parser for block/clean output
alamb-hex May 26, 2026
5749048
feat(security/plugins): SafeChainPlugin (installGate) + register
alamb-hex May 26, 2026
a47871b
feat(security/plugins): applyInstallGate orchestrator
alamb-hex May 26, 2026
c69f400
feat(api): GET /api/security/plugins — registry + host availability
alamb-hex May 26, 2026
a09ed80
feat(api): GET /api/security/plugins/[id]/status?projectId
alamb-hex May 26, 2026
1dc16d4
feat(security/ui): SecurityHeader — Patches-style header chrome
alamb-hex May 26, 2026
2ad783c
feat(security/ui): SecuritySummaryBar — color-dot stats row
alamb-hex May 26, 2026
5f94da5
feat(security/ui): SourcePluginCards — sources + plugins row
alamb-hex May 26, 2026
bdbb927
feat(security): wire new chrome into /security page
alamb-hex May 26, 2026
af686d2
feat(api): POST /api/projects/[id]/plugins/[pluginId] — enable/disable
alamb-hex May 27, 2026
f384c33
feat(update): wire installGate plugins via binary override
alamb-hex May 27, 2026
0e41a18
feat(security): /security/safe-chain detail page
alamb-hex May 27, 2026
52ee3a3
feat(security): drop FleetProjectRail, Patches-style project accordion
alamb-hex May 27, 2026
4fe4c5b
feat(security/ui): Rescan now actually scans the fleet + better empty…
alamb-hex May 27, 2026
12507d5
feat(security/ui): Scan menu + OSV DB sync + per-source meters
alamb-hex May 27, 2026
c0a14f9
feat(security/ui): severity pills on accordion row headers
alamb-hex May 27, 2026
e25f0a1
feat(security/ui): merged findings list in accordion body
alamb-hex May 27, 2026
db485a0
feat(security/ui): group findings by package + CVE hover + clearer So…
alamb-hex May 27, 2026
ef4d76c
feat(security/ui): group findings by parent npm package
alamb-hex May 27, 2026
ceee343
feat(security): SecurityException tracking — classification + audit t…
alamb-hex May 27, 2026
d768e37
fix(security/ui): PackageRow outer wrapper is a div, not nested <button>
alamb-hex May 27, 2026
38fece3
feat(security/ui): exceptions get bright amber + view-details + edit
alamb-hex May 27, 2026
d6c0724
feat(security): finding lifecycle logging + scope labels
alamb-hex May 27, 2026
c9c4045
feat(security/ui): fix-version is now a cyan pill
alamb-hex May 27, 2026
87a995c
feat(security/ui): RemediationPanel — multi-option grype remediation
alamb-hex May 27, 2026
433c228
feat(security/ui): phase-aware Apply with auto-rescan + verify outcome
alamb-hex May 27, 2026
02b82a1
feat(security): change-control-style logging for remediation attempts
alamb-hex May 27, 2026
1ab55ed
feat(security): change-control attemptId on cve-lite Apply paths
alamb-hex May 27, 2026
6fa88c8
chore(security): strip dead plugin orchestrators (#116) + drop cve-li…
alamb-hex Jun 3, 2026
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
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@
"better-sqlite3"
],
"overrides": {
"postcss": "8.5.15",
"ip-address": "10.2.0",
"vite": "8.0.13",
"esbuild": "0.28.0",
"postcss": "^8.5.15",
"ip-address": "^10.2.0",
"vite": "^8.0.13",
"esbuild": ">=0.28.0",
"qs": ">=6.15.2"
}
},
Expand Down
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

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

12 changes: 12 additions & 0 deletions src/app/api/projects/[id]/escalate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { join } from 'path'
import { execFile, exec } from 'child_process'
import { promisify } from 'util'
import { logger } from '@/lib/logger'
import { decideDevServerGuard, isHexopsSelf, isTracked } from '@/lib/process-manager'

const execFileAsync = promisify(execFile)
const execAsync = promisify(exec)
Expand Down Expand Up @@ -58,6 +59,17 @@ export async function POST(
return NextResponse.json({ error: 'Project not found' }, { status: 404 })
}

// #109: an escalation rewrites overrides and reinstalls. Refuse if the target
// is hexops itself — churning node_modules would kill the server serving this
// request mid-install. (Orchestrated stop/restart for other projects: TODO.)
const guard = decideDevServerGuard({ isSelf: isHexopsSelf(project), isTracked: isTracked(id) })
if (guard.action === 'block-self') {
return NextResponse.json(
{ error: guard.reason, devServerGuard: { action: guard.action, reason: guard.reason } },
{ status: 409 },
)
}

const body: EscalateRequestBody = await request.json()
const { package: pkg, action, reason, overrideVersion, targetVersion, expiresAt, emergency } = body

Expand Down
40 changes: 31 additions & 9 deletions src/app/api/projects/[id]/override-remove/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { promisify } from 'util';
import { detectPackageManager } from '@/lib/patch-scanner';
import { invalidateProjectCache } from '@/lib/patch-storage';
import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag';
import { runWithDevServerGuard, decideDevServerGuard, isHexopsSelf, isTracked } from '@/lib/process-manager';

const execAsync = promisify(exec);

Expand Down Expand Up @@ -34,6 +35,15 @@ export async function POST(
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}

// #109: refuse to churn node_modules out from under hexops's own dev server.
const guard = decideDevServerGuard({ isSelf: isHexopsSelf(project), isTracked: isTracked(id) });
if (guard.action === 'block-self') {
return NextResponse.json(
{ success: false, error: guard.reason, devServerGuard: { action: guard.action, reason: guard.reason } },
{ status: 409 },
);
}

const pkgJsonPath = join(project.path, 'package.json');
if (!existsSync(pkgJsonPath)) {
return NextResponse.json({ error: 'package.json not found' }, { status: 400 });
Expand Down Expand Up @@ -92,18 +102,30 @@ export async function POST(
? 'yarn install'
: 'npm install --legacy-peer-deps';

let installOutput = '';
try {
const result = await execAsync(installCmd, { cwd: project.path, timeout: 120000 });
installOutput = (result.stdout || '') + (result.stderr || '');
} catch (err) {
const e = err as { stdout?: string; stderr?: string };
installOutput = (e.stdout || '') + (e.stderr || '');
}
// #109: stop a running dev server, reinstall, then restart it.
const guardOutcome = await runWithDevServerGuard(project, async () => {
try {
const result = await execAsync(installCmd, { cwd: project.path, timeout: 120000 });
return (result.stdout || '') + (result.stderr || '');
} catch (err) {
const e = err as { stdout?: string; stderr?: string };
return (e.stdout || '') + (e.stderr || '');
}
}, { clearBuildDir: true });

invalidateProjectCache(id);

return NextResponse.json({ success: true, removed: pkgName, output: installOutput });
return NextResponse.json({
success: true,
removed: pkgName,
output: guardOutcome.result ?? '',
devServerGuard: {
action: guardOutcome.decision,
stopped: guardOutcome.stopped,
restarted: guardOutcome.restarted,
...(guardOutcome.restartError ? { restartError: guardOutcome.restartError } : {}),
},
});
} catch (error) {
console.error('Error removing override:', error);
return NextResponse.json({ error: 'Failed to remove override' }, { status: 500 });
Expand Down
58 changes: 58 additions & 0 deletions src/app/api/projects/[id]/plugins/[pluginId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { getProjects, getCategories, saveConfig } from '@/lib/config';
import { getPlugin } from '@/lib/security/plugins';
import { setProjectPluginConfig } from '@/lib/security/plugins/config';
import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag';

interface ToggleRequest {
enabled: boolean;
}

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; pluginId: string }> }
) {
if (!AUTO_APPLY_ENABLED) {
return NextResponse.json(
{ success: false, error: 'Auto-apply is disabled in HexOps. Re-enable AUTO_APPLY_ENABLED to apply updates.' },
{ status: 409 },
);
}

const { id, pluginId } = await params;

const plugin = getPlugin(pluginId);
if (!plugin) {
return NextResponse.json({ error: `Unknown plugin: ${pluginId}` }, { status: 404 });
}

const projects = getProjects();
const projectIndex = projects.findIndex(p => p.id === id);
if (projectIndex === -1) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}

const body = (await request.json().catch(() => null)) as ToggleRequest | null;
if (!body || typeof body.enabled !== 'boolean') {
return NextResponse.json({ error: 'Body must be { enabled: boolean }' }, { status: 400 });
}

// Mutate via the existing setProjectPluginConfig helper (T3). Writer captures
// the index update + saves the whole config (mirrors the holds-route pattern).
await setProjectPluginConfig(
projects[projectIndex],
pluginId,
{ enabled: body.enabled },
(next) => {
projects[projectIndex] = next;
saveConfig({ projects, categories: getCategories() });
},
);

return NextResponse.json({
success: true,
project: id,
pluginId,
enabled: body.enabled,
});
}
17 changes: 14 additions & 3 deletions src/app/api/projects/[id]/security-scan/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { getProject } from '@/lib/config';
import { scanProject } from '@/lib/security/runner';
import { scanProject, scanProjectWithSources } from '@/lib/security/runner';
import { SOURCES } from '@/lib/security/sources';
import { logger } from '@/lib/logger';

export async function POST(
_req: NextRequest,
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 });

try {
const result = await scanProject(project);
const url = new URL(req.url);
const filter = url.searchParams.get('sources');
const subset = filter
? SOURCES.filter(s => filter.split(',').map(x => x.trim()).includes(s.id))
: SOURCES;
if (subset.length === 0) {
return NextResponse.json({ error: `No sources matched: ${filter}` }, { status: 400 });
}
const result = subset.length === SOURCES.length
? await scanProject(project)
: await scanProjectWithSources(project, subset);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'Security scan failed';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { getProject } from '@/lib/config';
import { revokeException } from '@/lib/security/exceptions';
import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag';
import { logger } from '@/lib/logger';

interface RevokeBody {
revokeReason?: string;
}

export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string; exceptionId: string }> },
) {
if (!AUTO_APPLY_ENABLED) {
return NextResponse.json(
{ success: false, error: 'Auto-apply is disabled in HexOps.' },
{ status: 409 },
);
}
const { id, exceptionId } = await params;
if (!getProject(id)) return NextResponse.json({ error: 'Project not found' }, { status: 404 });

const body = (await req.json().catch(() => ({}))) as RevokeBody;
const exc = revokeException({
projectId: id,
exceptionId,
revokeReason: body.revokeReason,
});
if (!exc) return NextResponse.json({ error: 'Exception not found' }, { status: 404 });

logger.info(
'security',
'exception_revoked',
`Exception ${exc.id} revoked`,
{
projectId: id,
meta: {
exceptionId: exc.id,
parentPackage: exc.parentPackage,
revokeReason: body.revokeReason,
},
},
);

return NextResponse.json({ success: true, exception: exc });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { getProject } from '@/lib/config';
import { updateException, type ExceptionClassification } from '@/lib/security/exceptions';
import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag';
import { logger } from '@/lib/logger';

const VALID_CLASSIFICATIONS: ExceptionClassification[] = [
'risk-accepted',
'false-positive',
'compensating-control',
'deferred',
'unfixable',
'deviation',
];

interface PatchBody {
classification?: ExceptionClassification;
reason?: string;
notes?: string | null; // null = clear
expiresAt?: string | null; // null = clear
}

export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string; exceptionId: string }> },
) {
if (!AUTO_APPLY_ENABLED) {
return NextResponse.json(
{ success: false, error: 'Auto-apply is disabled in HexOps.' },
{ status: 409 },
);
}
const { id, exceptionId } = await params;
if (!getProject(id)) return NextResponse.json({ error: 'Project not found' }, { status: 404 });

const body = (await req.json().catch(() => null)) as PatchBody | null;
if (!body) return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
if (body.classification && !VALID_CLASSIFICATIONS.includes(body.classification)) {
return NextResponse.json({ error: 'Invalid classification' }, { status: 400 });
}
if (body.reason !== undefined && typeof body.reason !== 'string') {
return NextResponse.json({ error: 'reason must be a string' }, { status: 400 });
}

// Translate nulls to undefined for storage layer
const updates: Parameters<typeof updateException>[0]['updates'] = {};
if (body.classification !== undefined) updates.classification = body.classification;
if (body.reason !== undefined) updates.reason = body.reason;
if ('notes' in body) updates.notes = body.notes ?? undefined;
if ('expiresAt' in body) updates.expiresAt = body.expiresAt ?? undefined;

const exc = updateException({ projectId: id, exceptionId, updates });
if (!exc) return NextResponse.json({ error: 'Exception not found' }, { status: 404 });

logger.info(
'security',
'exception_modified',
`Exception ${exc.id} modified`,
{
projectId: id,
meta: { exceptionId: exc.id, parentPackage: exc.parentPackage, changes: Object.keys(updates) },
},
);

return NextResponse.json({ success: true, exception: exc });
}
Loading
Loading