Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 17 additions & 2 deletions src/app/api/projects/[id]/git-commit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export async function POST(
) {
const { id } = await params;

let source: string | undefined;
let advisories: string[] | undefined;

try {
const project = getProject(id);

Expand All @@ -24,6 +27,10 @@ export async function POST(

const body = await request.json();
const message = body.message?.trim();
source = typeof body.source === 'string' ? body.source : undefined;
advisories = Array.isArray(body.advisories)
? body.advisories.filter((a: unknown): a is string => typeof a === 'string')
: undefined;

if (!message) {
return NextResponse.json(
Expand Down Expand Up @@ -65,7 +72,11 @@ export async function POST(
// Log success
logger.info('git', 'commit_created', `Committed changes: ${message.split('\n')[0]}`, {
projectId: id,
meta: { message: message.split('\n')[0] },
meta: {
message: message.split('\n')[0],
...(source ? { source } : {}),
...(advisories ? { advisories } : {}),
},
});

return NextResponse.json({
Expand All @@ -79,7 +90,11 @@ export async function POST(
// Log failure
logger.error('git', 'commit_failed', `Commit failed: ${errorMessage}`, {
projectId: id,
meta: { error: errorMessage },
meta: {
error: errorMessage,
...(source ? { source } : {}),
...(advisories ? { advisories } : {}),
},
});

return NextResponse.json(
Expand Down
19 changes: 16 additions & 3 deletions src/app/api/projects/[id]/git-push/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ import { logger } from '@/lib/logger';
const execFileAsync = promisify(execFile);

export async function POST(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const source = typeof body.source === 'string' ? body.source : undefined;
const advisories = Array.isArray(body.advisories)
? (body.advisories as unknown[]).filter((a): a is string => typeof a === 'string')
: undefined;

try {
const project = getProject(id);
Expand All @@ -36,7 +41,10 @@ export async function POST(

// Remote has commits we don't have (e.g. Dependabot merged between our commit and push).
// Rebase our patch commit on top of whatever landed remotely, then push again.
logger.info('git', 'push_rebase', 'Push rejected (non-fast-forward) — pulling with rebase', { projectId: id });
logger.info('git', 'push_rebase', 'Push rejected (non-fast-forward) — pulling with rebase', {
projectId: id,
meta: { ...(source ? { source } : {}), ...(advisories ? { advisories } : {}) },
});
try {
await execFileAsync('git', ['pull', '--rebase', '--autostash'], { cwd, timeout: 60000 });
} catch (pullErr) {
Expand All @@ -50,6 +58,7 @@ export async function POST(
// Log success
logger.info('git', 'push_completed', 'Pushed changes to remote', {
projectId: id,
meta: { ...(source ? { source } : {}), ...(advisories ? { advisories } : {}) },
});

return NextResponse.json({
Expand All @@ -64,7 +73,11 @@ export async function POST(
// Log failure
logger.error('git', 'push_failed', `Push failed: ${errorMessage}`, {
projectId: id,
meta: { error: errorMessage },
meta: {
error: errorMessage,
...(source ? { source } : {}),
...(advisories ? { advisories } : {}),
},
});

return NextResponse.json(
Expand Down
17 changes: 17 additions & 0 deletions src/app/api/projects/[id]/update/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface UpdateRequestBody {
fixByParent?: { name: string; version: string };
}>;
lockfileResolution?: LockfileResolutionMode;
auditContext?: { source?: string; advisories?: string[]; severity?: string };
}

export async function POST(
Expand Down Expand Up @@ -324,6 +325,22 @@ export async function POST(
} catch { /* non-fatal */ }
}

// Origin-tagged remediation audit trail (e.g. applied from the CVE Lite dashboard).
if (anySucceeded && body.auditContext?.source) {
const successfulNames = results.filter(r => r.success).map(r => r.package).filter(n => n !== '*');
logger.info('patches', 'security_remediation_applied',
`Applied security fix in ${id}: ${successfulNames.join(', ') || '(reconcile)'}`,
{
projectId: id,
meta: {
source: body.auditContext.source,
advisories: body.auditContext.advisories ?? [],
severity: body.auditContext.severity,
packages: successfulNames,
},
});
}

const allSucceeded = results.every(r => r.success);
const output = results.map(r => r.output).join('\n\n');

Expand Down
11 changes: 11 additions & 0 deletions src/app/api/security/cve-lite/[id]/fix/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export async function POST(
if (body.mode !== 'all') {
return NextResponse.json({ error: "mode must be 'all'" }, { status: 400 });
}
const auditContext = body.auditContext as { source?: string; advisories?: string[]; severity?: string } | undefined;
let summary = '';
let ok = true;
try {
Expand All @@ -48,5 +49,15 @@ export async function POST(
await runCveLite(project, { force: true }).catch(() => {});
await runSecurityScan(project).catch(() => {});
logger.info('api', 'cve_lite_fix', `cve-lite --fix on ${id} (ok=${ok})`, { projectId: id });
if (ok && auditContext?.source) {
logger.info('patches', 'security_remediation_applied', `Applied cve-lite --fix in ${id}`, {
projectId: id,
meta: {
source: auditContext.source,
advisories: auditContext.advisories ?? [],
severity: auditContext.severity,
},
});
}
return NextResponse.json({ ok, summary, rescanned: true });
}
121 changes: 120 additions & 1 deletion src/app/security/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,20 @@ import { CveLiteManage } from '@/components/security/cve-lite/cve-lite-manage';
import { SourceStrip } from '@/components/security/source-strip';
import { ConfirmDialog } from '@/components/security/cve-lite/confirm-dialog';
import { AUTO_APPLY_ENABLED } from '@/lib/auto-apply-flag';
import type { UpdatedPackage } from '@/lib/patch-commit-message';
import { generatePatchCommitMessage } from '@/lib/patch-commit-message';
import { remediationFromRow, remediationFromRows } from '@/lib/security/remediation-commit';
import { PendingCommitBanner } from '@/components/security/cve-lite/pending-commit-banner';

interface ConfirmState { title: string; body: ReactNode; run: () => Promise<void> }
interface GitStatus { branch: string; ahead: number; behind: number; dirty: boolean }
interface PendingCommit {
packages: UpdatedPackage[];
advisories: string[];
severity?: string;
message: string;
isEditing: boolean;
}

function SecurityHubInner() {
const searchParams = useSearchParams();
Expand All @@ -35,6 +47,11 @@ function SecurityHubInner() {
const [dbStatus, setDbStatus] = useState<DbStatus | null>(null);
const [confirm, setConfirm] = useState<ConfirmState | null>(null);
const [busy, setBusy] = useState(false);
const [pendingCommit, setPendingCommit] = useState<PendingCommit | null>(null);
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
const [isCommitting, setIsCommitting] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [committed, setCommitted] = useState(false);

const refreshRail = useCallback(() => {
fetch('/api/security/cve-lite/summary')
Expand Down Expand Up @@ -100,6 +117,85 @@ function SecurityHubInner() {

useEffect(() => { load(false); }, [load]);

const fetchGitStatus = useCallback(async (projectId: string): Promise<GitStatus | null> => {
try {
const res = await fetch(`/api/projects/${projectId}/git`);
if (!res.ok) return null;
const d = await res.json();
return { branch: d.branch ?? '', ahead: d.aheadCount ?? 0, behind: d.behindCount ?? 0, dirty: !!d.isDirty };
} catch {
return null;
}
}, []);

const beginPendingCommit = useCallback(
async (rc: { packages: UpdatedPackage[]; advisories: string[]; severity?: string }) => {
const status = await fetchGitStatus(selected);
setGitStatus(status);
// An apply can produce nothing to commit: "Fix all direct" (cve-lite --fix) on a
// transitive-only advisory is a no-op, and some fixes only touch gitignored node_modules.
// Don't open a commit banner that would dead-end on "No changes to commit".
if (!status?.dirty) {
setPendingCommit(null);
setCommitted(false);
setError('Fix ran, but there were no file changes to commit. A transitive advisory cannot be fixed by "Fix all direct" — use the per-finding Apply, which adds a package override.');
return;
}
setError(null);
const generated = generatePatchCommitMessage(rc.packages).full;
const message = generated || `chore(deps): apply cve-lite fixes in ${selected}`;
setPendingCommit({ ...rc, message, isEditing: false });
setCommitted(false);
},
[selected, fetchGitStatus],
);

const handleCommit = useCallback(async () => {
if (!pendingCommit) return;
setIsCommitting(true); setError(null);
try {
const res = await fetch(`/api/projects/${selected}/git-commit`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: pendingCommit.message, source: 'cve-lite', advisories: pendingCommit.advisories }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.success === false) throw new Error(data.error ?? `commit failed (HTTP ${res.status})`);
setCommitted(true);
setPendingCommit((pc) => (pc ? { ...pc, isEditing: false } : pc));
setGitStatus(await fetchGitStatus(selected));
} catch (e) {
setError(e instanceof Error ? e.message : 'commit failed');
} finally {
setIsCommitting(false);
}
}, [pendingCommit, selected, fetchGitStatus]);

const handlePush = useCallback(async () => {
if (!pendingCommit) return;
setIsPushing(true); setError(null);
try {
const res = await fetch(`/api/projects/${selected}/git-push`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'cve-lite', advisories: pendingCommit.advisories }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.success === false) throw new Error(data.error ?? `push failed (HTTP ${res.status})`);
setPendingCommit(null); setCommitted(false);
setGitStatus(await fetchGitStatus(selected));
} catch (e) {
setError(e instanceof Error ? e.message : 'push failed');
} finally {
setIsPushing(false);
}
}, [pendingCommit, selected, fetchGitStatus]);

// Clear any pending commit when the user switches projects.
useEffect(() => {
setPendingCommit(null);
setCommitted(false);
setGitStatus(null);
}, [selected]);

const runConfirmed = async () => {
if (!confirm) return;
setBusy(true); setError(null);
Expand All @@ -121,13 +217,17 @@ function SecurityHubInner() {
</>
),
run: async () => {
// Audit context covers all direct fixable findings — `cve-lite --fix` ignores the
// importedOnly/reachability filter, so derive from the unfiltered report, not `rows`.
const rc = remediationFromRows(report ? findingRows(report) : []);
const res = await fetch(`/api/security/cve-lite/${selected}/fix`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'all' }),
body: JSON.stringify({ mode: 'all', auditContext: { source: 'cve-lite', advisories: rc.advisories, severity: rc.severity } }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) throw new Error(data.error ?? data.summary ?? `fix failed (HTTP ${res.status})`);
await load(true);
await beginPendingCommit(rc);
},
});

Expand All @@ -140,6 +240,7 @@ function SecurityHubInner() {
</>
),
run: async () => {
const rc = remediationFromRow(row);
const res = await fetch(`/api/projects/${selected}/update`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand All @@ -149,13 +250,15 @@ function SecurityHubInner() {
toVersion: row.validatedFixVersion,
fixViaOverride: row.relationship === 'transitive',
}],
auditContext: { source: 'cve-lite', advisories: rc.advisories, severity: rc.severity },
}),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e.error ?? `update failed (HTTP ${res.status})`);
}
await load(true);
await beginPendingCommit(rc);
},
});

Expand Down Expand Up @@ -215,6 +318,22 @@ function SecurityHubInner() {
onRescan={() => load(true)}
/>
</div>
{pendingCommit && AUTO_APPLY_ENABLED && (
<PendingCommitBanner
packages={pendingCommit.packages}
message={pendingCommit.message}
isEditing={pendingCommit.isEditing}
ahead={gitStatus?.ahead ?? 0}
committed={committed}
isCommitting={isCommitting}
isPushing={isPushing}
onMessageChange={(msg) => setPendingCommit((pc) => (pc ? { ...pc, message: msg } : pc))}
onToggleEdit={() => setPendingCommit((pc) => (pc ? { ...pc, isEditing: !pc.isEditing } : pc))}
onCommit={handleCommit}
onPush={handlePush}
onDismiss={() => { setPendingCommit(null); setCommitted(false); }}
/>
)}
{loading && <div className="text-sm text-zinc-500">Scanning…</div>}
{error && <div className="text-sm text-red-400">{error}</div>}
{!loading && !error && visibleReport && (
Expand Down
Loading
Loading