diff --git a/src/app/api/projects/[id]/git-commit/route.ts b/src/app/api/projects/[id]/git-commit/route.ts index 03b606c..fe14350 100644 --- a/src/app/api/projects/[id]/git-commit/route.ts +++ b/src/app/api/projects/[id]/git-commit/route.ts @@ -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); @@ -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( @@ -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({ @@ -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( diff --git a/src/app/api/projects/[id]/git-push/route.ts b/src/app/api/projects/[id]/git-push/route.ts index 50691e2..297ca12 100644 --- a/src/app/api/projects/[id]/git-push/route.ts +++ b/src/app/api/projects/[id]/git-push/route.ts @@ -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; + 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); @@ -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) { @@ -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({ @@ -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( diff --git a/src/app/api/projects/[id]/update/route.ts b/src/app/api/projects/[id]/update/route.ts index 37e4098..9ca5015 100644 --- a/src/app/api/projects/[id]/update/route.ts +++ b/src/app/api/projects/[id]/update/route.ts @@ -32,6 +32,7 @@ interface UpdateRequestBody { fixByParent?: { name: string; version: string }; }>; lockfileResolution?: LockfileResolutionMode; + auditContext?: { source?: string; advisories?: string[]; severity?: string }; } export async function POST( @@ -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'); diff --git a/src/app/api/security/cve-lite/[id]/fix/route.ts b/src/app/api/security/cve-lite/[id]/fix/route.ts index 2c26d0a..28fc455 100644 --- a/src/app/api/security/cve-lite/[id]/fix/route.ts +++ b/src/app/api/security/cve-lite/[id]/fix/route.ts @@ -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 { @@ -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 }); } diff --git a/src/app/security/page.tsx b/src/app/security/page.tsx index 53f5640..ac605d0 100644 --- a/src/app/security/page.tsx +++ b/src/app/security/page.tsx @@ -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 } +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(); @@ -35,6 +47,11 @@ function SecurityHubInner() { const [dbStatus, setDbStatus] = useState(null); const [confirm, setConfirm] = useState(null); const [busy, setBusy] = useState(false); + const [pendingCommit, setPendingCommit] = useState(null); + const [gitStatus, setGitStatus] = useState(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') @@ -100,6 +117,85 @@ function SecurityHubInner() { useEffect(() => { load(false); }, [load]); + const fetchGitStatus = useCallback(async (projectId: string): Promise => { + 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); @@ -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); }, }); @@ -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({ @@ -149,6 +250,7 @@ function SecurityHubInner() { toVersion: row.validatedFixVersion, fixViaOverride: row.relationship === 'transitive', }], + auditContext: { source: 'cve-lite', advisories: rc.advisories, severity: rc.severity }, }), }); if (!res.ok) { @@ -156,6 +258,7 @@ function SecurityHubInner() { throw new Error(e.error ?? `update failed (HTTP ${res.status})`); } await load(true); + await beginPendingCommit(rc); }, }); @@ -215,6 +318,22 @@ function SecurityHubInner() { onRescan={() => load(true)} /> + {pendingCommit && AUTO_APPLY_ENABLED && ( + 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 &&
Scanning…
} {error &&
{error}
} {!loading && !error && visibleReport && ( diff --git a/src/components/security/cve-lite/pending-commit-banner.tsx b/src/components/security/cve-lite/pending-commit-banner.tsx new file mode 100644 index 0000000..7bfb5e8 --- /dev/null +++ b/src/components/security/cve-lite/pending-commit-banner.tsx @@ -0,0 +1,96 @@ +'use client'; +import type { UpdatedPackage } from '@/lib/patch-commit-message'; + +export interface PendingCommitBannerProps { + packages: UpdatedPackage[]; + message: string; + isEditing: boolean; + ahead: number; + committed: boolean; + isCommitting: boolean; + isPushing: boolean; + onMessageChange: (msg: string) => void; + onToggleEdit: () => void; + onCommit: () => void; + onPush: () => void; + onDismiss: () => void; +} + +export function PendingCommitBanner({ + packages, + message, + isEditing, + ahead, + committed, + isCommitting, + isPushing, + onMessageChange, + onToggleEdit, + onCommit, + onPush, + onDismiss, +}: PendingCommitBannerProps) { + const securityCount = packages.filter((p) => p.isSecurityFix).length; + + return ( +
+
+ + {committed ? 'Committed — ready to push' : 'Fix applied — ready to commit'} + {securityCount > 0 && ( + + ({securityCount} security fix{securityCount !== 1 ? 'es' : ''}) + + )} + + +
+ + {isEditing ? ( +