[pitboss/grind] deferred session-0002 (20260521T201327Z-3848)

This commit is contained in:
pitboss 2026-05-21 15:48:29 -05:00
parent 159a779f31
commit d99361cff6
18 changed files with 499 additions and 144 deletions

View file

@ -4,6 +4,8 @@ import type { ScanView } from '../types';
export type ScanMode = 'full' | 'ast' | 'cfg' | 'taint';
export type EngineProfile = 'fast' | 'balanced' | 'deep';
export type VerifyBackend = 'auto' | 'docker' | 'process' | 'firecracker';
export type HardenProfile = 'standard' | 'strict';
export interface StartScanBody {
scan_root?: string;
@ -18,6 +20,10 @@ export interface StartScanBody {
verify?: boolean;
/** Also verify Confidence < Medium findings. Default false. */
verify_all_confidence?: boolean;
/** Sandbox backend for dynamic verification. */
verify_backend?: VerifyBackend;
/** Process-backend hardening profile. */
harden_profile?: HardenProfile;
}
export function useStartScan() {

View file

@ -11,6 +11,7 @@ export interface FindingsParams {
language?: string;
rule_id?: string;
status?: string;
verification?: string;
search?: string;
sort_by?: string;
sort_dir?: string;

View file

@ -22,7 +22,7 @@ export interface VerifyResult {
/** Typed InconclusiveReason (PascalCase string) */
inconclusive_reason?: string;
detail?: string;
attempts: AttemptSummary[];
attempts?: AttemptSummary[];
toolchain_match?: string;
}
@ -134,6 +134,7 @@ export interface FindingView {
triage_note?: string;
code_context?: CodeContextView;
evidence?: Evidence;
dynamic_verdict?: VerifyResult;
guard_kind?: string;
rank_reason?: [string, string][];
sanitizer_status?: string;
@ -155,6 +156,7 @@ export interface FilterValues {
languages: string[];
rules: string[];
statuses: string[];
verification_statuses: string[];
}
// Scan types

View file

@ -16,8 +16,8 @@ function verdictTooltip(verdict: VerifyResult): string {
? `Confirmed via payload: ${triggered_payload}`
: 'Dynamically confirmed exploitable';
case 'NotConfirmed':
return verdict.attempts.length > 0
? `Not confirmed after ${verdict.attempts.length} payload attempt(s)`
return (verdict.attempts?.length ?? 0) > 0
? `Not confirmed after ${verdict.attempts?.length ?? 0} payload attempt(s)`
: 'Not confirmed';
case 'Unsupported':
return reason ? `Unsupported: ${reason}` : 'Dynamic verification not supported';

View file

@ -13,6 +13,7 @@ export interface FindingsURLState {
language: string;
rule_id: string;
status: string;
verification: string;
search: string;
}
@ -27,6 +28,7 @@ const FINDINGS_DEFAULTS: FindingsURLState = {
language: '',
rule_id: '',
status: '',
verification: '',
search: '',
};
@ -52,6 +54,7 @@ const FILTER_KEYS: ReadonlySet<string> = new Set([
'language',
'rule_id',
'status',
'verification',
'search',
]);

View file

@ -8,6 +8,8 @@ import {
useStartScan,
type ScanMode,
type EngineProfile,
type VerifyBackend,
type HardenProfile,
type StartScanBody,
} from '../api/mutations/scans';
@ -29,6 +31,18 @@ const PROFILE_HINTS: Record<EngineProfile, string> = {
deep: 'Adds symex (cross-file + interproc) and demand-driven backwards taint. About 2 to 3x slower.',
};
const BACKEND_HINTS: Record<VerifyBackend, string> = {
auto: 'Use Docker when it fits, otherwise fall back to process.',
docker: 'Require Docker-backed harness execution.',
process: 'Unsafe local process backend for quick test runs.',
firecracker: 'Use the Firecracker backend when available.',
};
const HARDEN_HINTS: Record<HardenProfile, string> = {
standard: 'Baseline process limits.',
strict: 'Stricter process confinement when supported.',
};
export function NewScanModal({ open, onClose }: NewScanModalProps) {
const { data: health } = useHealth();
const startScan = useStartScan();
@ -39,6 +53,8 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
const [mode, setMode] = useState<ScanMode>('full');
const [engineProfile, setEngineProfile] = useState<EngineProfile>('balanced');
const [noVerify, setNoVerify] = useState(false);
const [verifyBackend, setVerifyBackend] = useState<VerifyBackend>('auto');
const [hardenProfile, setHardenProfile] = useState<HardenProfile>('standard');
const handleStart = async () => {
const root = scanRoot.trim();
@ -46,7 +62,12 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
if (root && root !== defaultRoot) body.scan_root = root;
if (mode !== 'full') body.mode = mode;
body.engine_profile = engineProfile;
if (noVerify) body.verify = false;
if (noVerify) {
body.verify = false;
} else {
body.verify_backend = verifyBackend;
body.harden_profile = hardenProfile;
}
const payload = Object.keys(body).length ? body : undefined;
try {
await startScan.mutateAsync(payload);
@ -125,6 +146,36 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
findings. Check to skip and get a fast static-only result.
</span>
</div>
<div className="form-group">
<label>Verification Backend</label>
<select
value={verifyBackend}
disabled={noVerify}
onChange={(e) =>
setVerifyBackend(e.target.value as VerifyBackend)
}
>
<option value="auto">Auto</option>
<option value="docker">Docker</option>
<option value="process">Process (unsafe)</option>
<option value="firecracker">Firecracker</option>
</select>
<span className="form-hint">{BACKEND_HINTS[verifyBackend]}</span>
</div>
<div className="form-group">
<label>Process Hardening</label>
<select
value={hardenProfile}
disabled={noVerify || verifyBackend !== 'process'}
onChange={(e) =>
setHardenProfile(e.target.value as HardenProfile)
}
>
<option value="standard">Standard</option>
<option value="strict">Strict</option>
</select>
<span className="form-hint">{HARDEN_HINTS[hardenProfile]}</span>
</div>
<div className="scan-modal-actions">
<button className="btn btn-sm" onClick={onClose}>
Cancel

View file

@ -707,16 +707,21 @@ function HowToFix({ finding }: { finding: FindingView }) {
export function DynamicVerdictSection({ verdict }: { verdict: VerifyResult }) {
const [copied, setCopied] = useState(false);
const attempts = verdict.attempts ?? [];
// The repro bundle is keyed by spec_hash (not finding_id) inside the Nyx
// cache. Rather than showing a path that may not match, surface the CLI
// command that locates and opens the bundle regardless of the hash.
const reproCmd = `nyx repro --finding ${verdict.finding_id}`;
const copyCmd = () => {
navigator.clipboard.writeText(reproCmd).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
if (!navigator.clipboard) return;
navigator.clipboard.writeText(reproCmd).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
},
() => {},
);
};
return (
@ -767,11 +772,11 @@ export function DynamicVerdictSection({ verdict }: { verdict: VerifyResult }) {
</div>
)}
{verdict.attempts.length > 0 && (
{attempts.length > 0 && (
<div className="dynamic-attempts">
<strong>Payload attempts:</strong>
<ul className="dynamic-attempt-list">
{verdict.attempts.map((a, i) => (
{attempts.map((a, i) => (
<li key={i} className={`attempt-row ${a.triggered ? 'triggered' : ''}`}>
<code>{a.payload_label}</code>
<span className="attempt-outcome">
@ -953,6 +958,7 @@ export function FindingDetailPage() {
const f = finding;
const evidence = f.evidence;
const dynamicVerdict = evidence?.dynamic_verdict ?? f.dynamic_verdict;
const isState = isStateFinding(f);
const hasWhySection =
f.message ||
@ -1110,9 +1116,9 @@ export function FindingDetailPage() {
)}
{/* Dynamic Verification */}
{evidence?.dynamic_verdict && (
{dynamicVerdict && (
<CollapsibleSection title="Dynamic Verification">
<DynamicVerdictSection verdict={evidence.dynamic_verdict} />
<DynamicVerdictSection verdict={dynamicVerdict} />
</CollapsibleSection>
)}

View file

@ -29,6 +29,11 @@ function formatTriageState(state: string): string {
return (state || 'open').replace(/_/g, ' ');
}
function formatVerificationStatus(status: string): string {
if (status === 'NotConfirmed') return 'Not confirmed';
return status || 'Unverified';
}
// ── Filter Bar ──────────────────────────────────────────────────────────────
interface FilterSelectProps {
@ -37,6 +42,7 @@ interface FilterSelectProps {
values: string[] | undefined;
current: string;
onChange: (value: string) => void;
formatValue?: (value: string) => string;
}
function FilterSelect({
@ -45,6 +51,7 @@ function FilterSelect({
values,
current,
onChange,
formatValue,
}: FilterSelectProps) {
if (!values || values.length === 0) return null;
return (
@ -52,7 +59,7 @@ function FilterSelect({
<option value="">All {label}</option>
{values.map((v) => (
<option key={v} value={v}>
{v}
{formatValue ? formatValue(v) : v}
</option>
))}
</select>
@ -322,6 +329,7 @@ export function FindingsPage() {
language: state.language || undefined,
rule_id: state.rule_id || undefined,
status: state.status || undefined,
verification: state.verification || undefined,
search: state.search || undefined,
}),
[state],
@ -621,6 +629,14 @@ export function FindingsPage() {
current={state.status}
onChange={(v) => handleFilterChange('status', v)}
/>
<FilterSelect
id="filter-verification"
label="Verification"
values={filters?.verification_statuses}
current={state.verification}
onChange={(v) => handleFilterChange('verification', v)}
formatValue={formatVerificationStatus}
/>
{hasActiveFilters && (
<button className="btn btn-sm btn-clear" onClick={resetFilters}>
Clear All
@ -764,7 +780,7 @@ export function FindingsPage() {
</td>
<td>
<VerdictBadge
verdict={f.evidence?.dynamic_verdict}
verdict={f.dynamic_verdict ?? f.evidence?.dynamic_verdict}
compact
/>
</td>

View file

@ -52,7 +52,11 @@ describe('NewScanModal', () => {
await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce());
const payload = mockMutateAsync.mock.calls[0][0];
expect(payload).not.toHaveProperty('verify');
expect(payload).toEqual({ engine_profile: 'balanced' });
expect(payload).toEqual({
engine_profile: 'balanced',
verify_backend: 'auto',
harden_profile: 'standard',
});
});
it('calls mutateAsync with verify: false when checkbox is checked', async () => {
@ -63,4 +67,17 @@ describe('NewScanModal', () => {
const payload = mockMutateAsync.mock.calls[0][0];
expect(payload).toEqual({ engine_profile: 'balanced', verify: false });
});
it('allows selecting the unsafe process verification backend', async () => {
render(<NewScanModal open={true} onClose={vi.fn()} />);
const selects = screen.getAllByRole('combobox');
fireEvent.change(selects[2], { target: { value: 'process' } });
fireEvent.click(screen.getByRole('button', { name: 'Start scan' }));
await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce());
const payload = mockMutateAsync.mock.calls[0][0];
expect(payload).toMatchObject({
verify_backend: 'process',
harden_profile: 'standard',
});
});
});