feat(plugins): admin review panel for scanner findings

The overrideWarnings escape hatch added alongside the bundle scan was
API-only: an admin uploading a crypto plugin through the web form hit a
400 with canOverride and had no way to act on it, which left S/MIME and
PGP bundles uninstallable through the UI.

Hold the rejected file client-side and show the findings — pattern per
file — with "Install anyway" and "Cancel". Proceeding re-posts the same
file with overrideWarnings, so the decision stays explicit and lands in
the audit log. The route now echoes accepted findings back on success so
the confirmation says how many were waved through rather than reporting a
bare install.

Also replaces a dead `data.warnings` read with the live `findings` field;
the route never returned `warnings` on success, so that branch never ran.

Completes B-01.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernd Rodler
2026-08-04 09:03:14 +02:00
co-authored by Claude Opus 4.8
parent d91db37b34
commit e9746fcf78
3 changed files with 78 additions and 8 deletions
+73 -6
View File
@@ -26,6 +26,10 @@ export function PluginsTab() {
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Bundle held back by the pattern scanner, awaiting an explicit admin decision.
const [pendingScan, setPendingScan] = useState<
{ file: File; findings: Array<{ file: string; patterns: string[] }> } | null
>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false);
@@ -104,15 +108,17 @@ export function PluginsTab() {
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
// Upload a bundle. The scanner may refuse it for containing patterns that are
// expected in a vendored crypto library (openpgp.js, pkijs); in that case the
// server returns `canOverride` and we hold the file so the admin can review
// the findings and decide. `override` re-posts the same file with consent.
async function uploadPlugin(file: File, override: boolean) {
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
if (override) formData.append('overrideWarnings', 'true');
try {
const res = await apiFetch('/api/admin/plugins', {
@@ -122,13 +128,22 @@ export function PluginsTab() {
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
setPendingScan(null);
const accepted = data.findings?.length
? `${data.findings.length} scanner finding(s) accepted and logged`
: '';
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${accepted}` });
await fetchPlugins();
} else if (data.canOverride && Array.isArray(data.findings) && !override) {
// Hold the file rather than the error: the admin needs to see WHAT
// tripped, in WHICH file, before deciding.
setPendingScan({ file, findings: data.findings });
} else {
setPendingScan(null);
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
} catch {
setPendingScan(null);
setMessage({ type: 'error', text: 'Upload failed' });
} finally {
setUploading(false);
@@ -136,6 +151,13 @@ export function PluginsTab() {
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setPendingScan(null);
await uploadPlugin(file, false);
}
async function togglePlugin(id: string, enabled: boolean) {
setMessage(null);
const res = await apiFetch('/api/admin/plugins', {
@@ -302,6 +324,51 @@ export function PluginsTab() {
</div>
)}
{pendingScan && (
<div className="border border-warning/40 bg-warning/5 rounded-lg p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-warning mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
Scanner flagged <span className="font-mono">{pendingScan.file.name}</span>
</p>
<p className="text-xs text-muted-foreground">
These patterns can indicate malicious code, but they also appear in legitimate
minified crypto libraries such as openpgp.js and pkijs. Review the findings before
proceeding installing anyway is recorded in the audit log.
</p>
</div>
</div>
<ul className="space-y-1">
{pendingScan.findings.map(f => (
<li key={f.file} className="text-xs font-mono bg-background/60 border border-border rounded px-2 py-1">
<span className="text-foreground">{f.file}</span>
<span className="text-muted-foreground"> {f.patterns.join(', ')}</span>
</li>
))}
</ul>
<div className="flex items-center gap-2">
<button
onClick={() => uploadPlugin(pendingScan.file, true)}
disabled={uploading}
className="inline-flex items-center gap-2 h-8 px-3 rounded-md bg-destructive text-destructive-foreground text-xs font-medium hover:bg-destructive/90 disabled:opacity-50 transition-all"
>
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <AlertTriangle className="w-3.5 h-3.5" />}
Install anyway
</button>
<button
onClick={() => { setPendingScan(null); setMessage(null); }}
disabled={uploading}
className="inline-flex items-center h-8 px-3 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50 transition-all"
>
Cancel
</button>
</div>
</div>
)}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
+3 -1
View File
@@ -255,7 +255,9 @@ export async function POST(request: NextRequest) {
logger.warn('Plugin installed with scanner override', { id: plugin.id, findings });
}
return NextResponse.json({ plugin });
// Echo accepted findings back so the admin UI can confirm exactly what was
// waved through, rather than reporting a bare success.
return NextResponse.json(findings.length > 0 ? { plugin, findings } : { plugin });
} catch (error) {
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });