mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-21 21:31:03 +02:00
[pitboss/grind] deferred session-0002 (20260521T201327Z-3848)
This commit is contained in:
parent
159a779f31
commit
d99361cff6
18 changed files with 499 additions and 144 deletions
|
|
@ -4,6 +4,8 @@ import type { ScanView } from '../types';
|
||||||
|
|
||||||
export type ScanMode = 'full' | 'ast' | 'cfg' | 'taint';
|
export type ScanMode = 'full' | 'ast' | 'cfg' | 'taint';
|
||||||
export type EngineProfile = 'fast' | 'balanced' | 'deep';
|
export type EngineProfile = 'fast' | 'balanced' | 'deep';
|
||||||
|
export type VerifyBackend = 'auto' | 'docker' | 'process' | 'firecracker';
|
||||||
|
export type HardenProfile = 'standard' | 'strict';
|
||||||
|
|
||||||
export interface StartScanBody {
|
export interface StartScanBody {
|
||||||
scan_root?: string;
|
scan_root?: string;
|
||||||
|
|
@ -18,6 +20,10 @@ export interface StartScanBody {
|
||||||
verify?: boolean;
|
verify?: boolean;
|
||||||
/** Also verify Confidence < Medium findings. Default false. */
|
/** Also verify Confidence < Medium findings. Default false. */
|
||||||
verify_all_confidence?: boolean;
|
verify_all_confidence?: boolean;
|
||||||
|
/** Sandbox backend for dynamic verification. */
|
||||||
|
verify_backend?: VerifyBackend;
|
||||||
|
/** Process-backend hardening profile. */
|
||||||
|
harden_profile?: HardenProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStartScan() {
|
export function useStartScan() {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface FindingsParams {
|
||||||
language?: string;
|
language?: string;
|
||||||
rule_id?: string;
|
rule_id?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
verification?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_dir?: string;
|
sort_dir?: string;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export interface VerifyResult {
|
||||||
/** Typed InconclusiveReason (PascalCase string) */
|
/** Typed InconclusiveReason (PascalCase string) */
|
||||||
inconclusive_reason?: string;
|
inconclusive_reason?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
attempts: AttemptSummary[];
|
attempts?: AttemptSummary[];
|
||||||
toolchain_match?: string;
|
toolchain_match?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,6 +134,7 @@ export interface FindingView {
|
||||||
triage_note?: string;
|
triage_note?: string;
|
||||||
code_context?: CodeContextView;
|
code_context?: CodeContextView;
|
||||||
evidence?: Evidence;
|
evidence?: Evidence;
|
||||||
|
dynamic_verdict?: VerifyResult;
|
||||||
guard_kind?: string;
|
guard_kind?: string;
|
||||||
rank_reason?: [string, string][];
|
rank_reason?: [string, string][];
|
||||||
sanitizer_status?: string;
|
sanitizer_status?: string;
|
||||||
|
|
@ -155,6 +156,7 @@ export interface FilterValues {
|
||||||
languages: string[];
|
languages: string[];
|
||||||
rules: string[];
|
rules: string[];
|
||||||
statuses: string[];
|
statuses: string[];
|
||||||
|
verification_statuses: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan types
|
// Scan types
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ function verdictTooltip(verdict: VerifyResult): string {
|
||||||
? `Confirmed via payload: ${triggered_payload}`
|
? `Confirmed via payload: ${triggered_payload}`
|
||||||
: 'Dynamically confirmed exploitable';
|
: 'Dynamically confirmed exploitable';
|
||||||
case 'NotConfirmed':
|
case 'NotConfirmed':
|
||||||
return verdict.attempts.length > 0
|
return (verdict.attempts?.length ?? 0) > 0
|
||||||
? `Not confirmed after ${verdict.attempts.length} payload attempt(s)`
|
? `Not confirmed after ${verdict.attempts?.length ?? 0} payload attempt(s)`
|
||||||
: 'Not confirmed';
|
: 'Not confirmed';
|
||||||
case 'Unsupported':
|
case 'Unsupported':
|
||||||
return reason ? `Unsupported: ${reason}` : 'Dynamic verification not supported';
|
return reason ? `Unsupported: ${reason}` : 'Dynamic verification not supported';
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export interface FindingsURLState {
|
||||||
language: string;
|
language: string;
|
||||||
rule_id: string;
|
rule_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
verification: string;
|
||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ const FINDINGS_DEFAULTS: FindingsURLState = {
|
||||||
language: '',
|
language: '',
|
||||||
rule_id: '',
|
rule_id: '',
|
||||||
status: '',
|
status: '',
|
||||||
|
verification: '',
|
||||||
search: '',
|
search: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -52,6 +54,7 @@ const FILTER_KEYS: ReadonlySet<string> = new Set([
|
||||||
'language',
|
'language',
|
||||||
'rule_id',
|
'rule_id',
|
||||||
'status',
|
'status',
|
||||||
|
'verification',
|
||||||
'search',
|
'search',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
useStartScan,
|
useStartScan,
|
||||||
type ScanMode,
|
type ScanMode,
|
||||||
type EngineProfile,
|
type EngineProfile,
|
||||||
|
type VerifyBackend,
|
||||||
|
type HardenProfile,
|
||||||
type StartScanBody,
|
type StartScanBody,
|
||||||
} from '../api/mutations/scans';
|
} 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.',
|
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) {
|
export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
||||||
const { data: health } = useHealth();
|
const { data: health } = useHealth();
|
||||||
const startScan = useStartScan();
|
const startScan = useStartScan();
|
||||||
|
|
@ -39,6 +53,8 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
||||||
const [mode, setMode] = useState<ScanMode>('full');
|
const [mode, setMode] = useState<ScanMode>('full');
|
||||||
const [engineProfile, setEngineProfile] = useState<EngineProfile>('balanced');
|
const [engineProfile, setEngineProfile] = useState<EngineProfile>('balanced');
|
||||||
const [noVerify, setNoVerify] = useState(false);
|
const [noVerify, setNoVerify] = useState(false);
|
||||||
|
const [verifyBackend, setVerifyBackend] = useState<VerifyBackend>('auto');
|
||||||
|
const [hardenProfile, setHardenProfile] = useState<HardenProfile>('standard');
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
const root = scanRoot.trim();
|
const root = scanRoot.trim();
|
||||||
|
|
@ -46,7 +62,12 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
||||||
if (root && root !== defaultRoot) body.scan_root = root;
|
if (root && root !== defaultRoot) body.scan_root = root;
|
||||||
if (mode !== 'full') body.mode = mode;
|
if (mode !== 'full') body.mode = mode;
|
||||||
body.engine_profile = engineProfile;
|
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;
|
const payload = Object.keys(body).length ? body : undefined;
|
||||||
try {
|
try {
|
||||||
await startScan.mutateAsync(payload);
|
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.
|
findings. Check to skip and get a fast static-only result.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="scan-modal-actions">
|
||||||
<button className="btn btn-sm" onClick={onClose}>
|
<button className="btn btn-sm" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -707,16 +707,21 @@ function HowToFix({ finding }: { finding: FindingView }) {
|
||||||
|
|
||||||
export function DynamicVerdictSection({ verdict }: { verdict: VerifyResult }) {
|
export function DynamicVerdictSection({ verdict }: { verdict: VerifyResult }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const attempts = verdict.attempts ?? [];
|
||||||
// The repro bundle is keyed by spec_hash (not finding_id) inside the Nyx
|
// 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
|
// cache. Rather than showing a path that may not match, surface the CLI
|
||||||
// command that locates and opens the bundle regardless of the hash.
|
// command that locates and opens the bundle regardless of the hash.
|
||||||
const reproCmd = `nyx repro --finding ${verdict.finding_id}`;
|
const reproCmd = `nyx repro --finding ${verdict.finding_id}`;
|
||||||
|
|
||||||
const copyCmd = () => {
|
const copyCmd = () => {
|
||||||
navigator.clipboard.writeText(reproCmd).then(() => {
|
if (!navigator.clipboard) return;
|
||||||
setCopied(true);
|
navigator.clipboard.writeText(reproCmd).then(
|
||||||
setTimeout(() => setCopied(false), 2000);
|
() => {
|
||||||
});
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -767,11 +772,11 @@ export function DynamicVerdictSection({ verdict }: { verdict: VerifyResult }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{verdict.attempts.length > 0 && (
|
{attempts.length > 0 && (
|
||||||
<div className="dynamic-attempts">
|
<div className="dynamic-attempts">
|
||||||
<strong>Payload attempts:</strong>
|
<strong>Payload attempts:</strong>
|
||||||
<ul className="dynamic-attempt-list">
|
<ul className="dynamic-attempt-list">
|
||||||
{verdict.attempts.map((a, i) => (
|
{attempts.map((a, i) => (
|
||||||
<li key={i} className={`attempt-row ${a.triggered ? 'triggered' : ''}`}>
|
<li key={i} className={`attempt-row ${a.triggered ? 'triggered' : ''}`}>
|
||||||
<code>{a.payload_label}</code>
|
<code>{a.payload_label}</code>
|
||||||
<span className="attempt-outcome">
|
<span className="attempt-outcome">
|
||||||
|
|
@ -953,6 +958,7 @@ export function FindingDetailPage() {
|
||||||
|
|
||||||
const f = finding;
|
const f = finding;
|
||||||
const evidence = f.evidence;
|
const evidence = f.evidence;
|
||||||
|
const dynamicVerdict = evidence?.dynamic_verdict ?? f.dynamic_verdict;
|
||||||
const isState = isStateFinding(f);
|
const isState = isStateFinding(f);
|
||||||
const hasWhySection =
|
const hasWhySection =
|
||||||
f.message ||
|
f.message ||
|
||||||
|
|
@ -1110,9 +1116,9 @@ export function FindingDetailPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dynamic Verification */}
|
{/* Dynamic Verification */}
|
||||||
{evidence?.dynamic_verdict && (
|
{dynamicVerdict && (
|
||||||
<CollapsibleSection title="Dynamic Verification">
|
<CollapsibleSection title="Dynamic Verification">
|
||||||
<DynamicVerdictSection verdict={evidence.dynamic_verdict} />
|
<DynamicVerdictSection verdict={dynamicVerdict} />
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ function formatTriageState(state: string): string {
|
||||||
return (state || 'open').replace(/_/g, ' ');
|
return (state || 'open').replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatVerificationStatus(status: string): string {
|
||||||
|
if (status === 'NotConfirmed') return 'Not confirmed';
|
||||||
|
return status || 'Unverified';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Filter Bar ──────────────────────────────────────────────────────────────
|
// ── Filter Bar ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface FilterSelectProps {
|
interface FilterSelectProps {
|
||||||
|
|
@ -37,6 +42,7 @@ interface FilterSelectProps {
|
||||||
values: string[] | undefined;
|
values: string[] | undefined;
|
||||||
current: string;
|
current: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
formatValue?: (value: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterSelect({
|
function FilterSelect({
|
||||||
|
|
@ -45,6 +51,7 @@ function FilterSelect({
|
||||||
values,
|
values,
|
||||||
current,
|
current,
|
||||||
onChange,
|
onChange,
|
||||||
|
formatValue,
|
||||||
}: FilterSelectProps) {
|
}: FilterSelectProps) {
|
||||||
if (!values || values.length === 0) return null;
|
if (!values || values.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
|
|
@ -52,7 +59,7 @@ function FilterSelect({
|
||||||
<option value="">All {label}</option>
|
<option value="">All {label}</option>
|
||||||
{values.map((v) => (
|
{values.map((v) => (
|
||||||
<option key={v} value={v}>
|
<option key={v} value={v}>
|
||||||
{v}
|
{formatValue ? formatValue(v) : v}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -322,6 +329,7 @@ export function FindingsPage() {
|
||||||
language: state.language || undefined,
|
language: state.language || undefined,
|
||||||
rule_id: state.rule_id || undefined,
|
rule_id: state.rule_id || undefined,
|
||||||
status: state.status || undefined,
|
status: state.status || undefined,
|
||||||
|
verification: state.verification || undefined,
|
||||||
search: state.search || undefined,
|
search: state.search || undefined,
|
||||||
}),
|
}),
|
||||||
[state],
|
[state],
|
||||||
|
|
@ -621,6 +629,14 @@ export function FindingsPage() {
|
||||||
current={state.status}
|
current={state.status}
|
||||||
onChange={(v) => handleFilterChange('status', v)}
|
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 && (
|
{hasActiveFilters && (
|
||||||
<button className="btn btn-sm btn-clear" onClick={resetFilters}>
|
<button className="btn btn-sm btn-clear" onClick={resetFilters}>
|
||||||
Clear All
|
Clear All
|
||||||
|
|
@ -764,7 +780,7 @@ export function FindingsPage() {
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<VerdictBadge
|
<VerdictBadge
|
||||||
verdict={f.evidence?.dynamic_verdict}
|
verdict={f.dynamic_verdict ?? f.evidence?.dynamic_verdict}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,11 @@ describe('NewScanModal', () => {
|
||||||
await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce());
|
await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledOnce());
|
||||||
const payload = mockMutateAsync.mock.calls[0][0];
|
const payload = mockMutateAsync.mock.calls[0][0];
|
||||||
expect(payload).not.toHaveProperty('verify');
|
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 () => {
|
it('calls mutateAsync with verify: false when checkbox is checked', async () => {
|
||||||
|
|
@ -63,4 +67,17 @@ describe('NewScanModal', () => {
|
||||||
const payload = mockMutateAsync.mock.calls[0][0];
|
const payload = mockMutateAsync.mock.calls[0][0];
|
||||||
expect(payload).toEqual({ engine_profile: 'balanced', verify: false });
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
//! Fires when the surrounding source imports Django middleware base
|
//! Fires when the surrounding source imports Django middleware base
|
||||||
//! classes (`MiddlewareMixin`) or declares a callable middleware whose
|
//! classes (`MiddlewareMixin`) or declares a callable middleware whose
|
||||||
//! body defines `__call__(self, request)` / `process_request`.
|
//! body defines `__call__(self, request)` / `process_request`.
|
||||||
|
//!
|
||||||
|
//! Notably does NOT fire just because the file contains `MIDDLEWARE = [`
|
||||||
|
//! (typical of `settings.py`) — that needle stole every settings module
|
||||||
|
//! into Middleware bindings (Phase 21 binding-stealing audit).
|
||||||
|
|
||||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
|
|
@ -17,24 +21,42 @@ fn callee_is_django_middleware(name: &str) -> bool {
|
||||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
matches!(
|
matches!(
|
||||||
last,
|
last,
|
||||||
"process_request" | "process_response" | "process_view" | "process_exception" | "__call__"
|
"process_request" | "process_response" | "process_view" | "process_exception"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_imports_django_middleware(file_bytes: &[u8]) -> bool {
|
fn source_has_middleware_shape(file_bytes: &[u8]) -> bool {
|
||||||
const NEEDLES: &[&[u8]] = &[
|
const NEEDLES: &[&[u8]] = &[
|
||||||
b"django.utils.deprecation",
|
b"django.utils.deprecation",
|
||||||
b"MiddlewareMixin",
|
b"MiddlewareMixin",
|
||||||
b"def __call__(self, request",
|
b"def __call__(self, request",
|
||||||
b"def process_request",
|
b"def process_request",
|
||||||
b"django.middleware",
|
b"django.middleware",
|
||||||
b"MIDDLEWARE = [",
|
|
||||||
];
|
];
|
||||||
NEEDLES
|
NEEDLES
|
||||||
.iter()
|
.iter()
|
||||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn looks_like_settings_module(file_bytes: &[u8]) -> bool {
|
||||||
|
// Heuristic: settings.py declares MIDDLEWARE / INSTALLED_APPS / DATABASES at
|
||||||
|
// module scope. A real middleware module declares none of these (it carries
|
||||||
|
// a class with __call__ / process_*).
|
||||||
|
let has_middleware_list = file_bytes
|
||||||
|
.windows(b"MIDDLEWARE = [".len())
|
||||||
|
.any(|w| w == b"MIDDLEWARE = [");
|
||||||
|
let has_installed_apps = file_bytes
|
||||||
|
.windows(b"INSTALLED_APPS".len())
|
||||||
|
.any(|w| w == b"INSTALLED_APPS");
|
||||||
|
let declares_middleware_class = file_bytes
|
||||||
|
.windows(b"def __call__".len())
|
||||||
|
.any(|w| w == b"def __call__")
|
||||||
|
|| file_bytes
|
||||||
|
.windows(b"def process_request".len())
|
||||||
|
.any(|w| w == b"def process_request");
|
||||||
|
(has_middleware_list || has_installed_apps) && !declares_middleware_class
|
||||||
|
}
|
||||||
|
|
||||||
impl FrameworkAdapter for MiddlewareDjangoAdapter {
|
impl FrameworkAdapter for MiddlewareDjangoAdapter {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
ADAPTER_NAME
|
ADAPTER_NAME
|
||||||
|
|
@ -50,8 +72,11 @@ impl FrameworkAdapter for MiddlewareDjangoAdapter {
|
||||||
_ast: tree_sitter::Node<'_>,
|
_ast: tree_sitter::Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
|
if looks_like_settings_module(file_bytes) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let matches_call = super::any_callee_matches(summary, callee_is_django_middleware);
|
let matches_call = super::any_callee_matches(summary, callee_is_django_middleware);
|
||||||
let matches_source = source_imports_django_middleware(file_bytes);
|
let matches_source = source_has_middleware_shape(file_bytes);
|
||||||
if matches_call || matches_source {
|
if matches_call || matches_source {
|
||||||
Some(FrameworkBinding {
|
Some(FrameworkBinding {
|
||||||
adapter: ADAPTER_NAME.to_owned(),
|
adapter: ADAPTER_NAME.to_owned(),
|
||||||
|
|
@ -95,4 +120,20 @@ mod tests {
|
||||||
assert_eq!(binding.adapter, "middleware-django");
|
assert_eq!(binding.adapter, "middleware-django");
|
||||||
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_bind_settings_module() {
|
||||||
|
let src: &[u8] = b"INSTALLED_APPS = ['django.contrib.auth']\nMIDDLEWARE = [\n 'django.middleware.security.SecurityMiddleware',\n]\nDATABASES = {}\n";
|
||||||
|
let tree = parse_python(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "some_helper".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
MiddlewareDjangoAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none(),
|
||||||
|
"settings.py-shaped module must not bind as middleware",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
//! Phase 21 (Track M.3) — Express middleware adapter (JS).
|
//! Phase 21 (Track M.3) — Express middleware adapter (JS).
|
||||||
//!
|
//!
|
||||||
//! Fires when the surrounding source imports Express and declares a
|
//! Fires when the surrounding source imports Express and the function
|
||||||
//! middleware function — a `(req, res, next) => …` callable mounted
|
//! under analysis is mounted via `app.use(<this_fn>)` /
|
||||||
//! via `app.use(...)` / `router.use(...)`.
|
//! `router.use(<this_fn>)`. An anonymous-mount or callee-only signal
|
||||||
|
//! (`app.use(...)` with a non-matching function name) is no longer
|
||||||
|
//! enough on its own — that needle stole every Express setup file into
|
||||||
|
//! Middleware bindings regardless of which function the analyser was
|
||||||
|
//! looking at (Phase 21 binding-stealing audit).
|
||||||
|
|
||||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
|
|
@ -13,21 +17,36 @@ pub struct MiddlewareExpressAdapter;
|
||||||
|
|
||||||
const ADAPTER_NAME: &str = "middleware-express";
|
const ADAPTER_NAME: &str = "middleware-express";
|
||||||
|
|
||||||
fn callee_is_express(name: &str) -> bool {
|
fn callee_is_express_mount(name: &str) -> bool {
|
||||||
|
// `use` on Express's app/router registers middleware. Other Express
|
||||||
|
// helpers like `json`/`urlencoded`/`static` are body-parser
|
||||||
|
// factories that pair WITH `use` rather than identifying the
|
||||||
|
// function itself as middleware, so they no longer count.
|
||||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
matches!(last, "use" | "next" | "json" | "urlencoded" | "static")
|
last == "use"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_imports_express(file_bytes: &[u8]) -> bool {
|
fn function_is_mounted_as_middleware(file_bytes: &[u8], name: &str) -> bool {
|
||||||
// Phase 21 v1: require an explicit middleware-registration shape
|
if name.is_empty() {
|
||||||
// (`app.use(` / `router.use(`), not the bare `require('express')`
|
return false;
|
||||||
// import. Many non-middleware Express fixtures import the framework
|
}
|
||||||
// but never declare middleware; gating on the registration shape
|
let needles: [Vec<u8>; 2] = [
|
||||||
// keeps the adapter focused on the function the brief targets.
|
format!("app.use({name})").into_bytes(),
|
||||||
const NEEDLES: &[&[u8]] = &[b"app.use(", b"router.use(", b"express.Router()"];
|
format!("router.use({name})").into_bytes(),
|
||||||
NEEDLES
|
];
|
||||||
|
needles
|
||||||
.iter()
|
.iter()
|
||||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == n.as_slice()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn function_has_middleware_signature(summary: &FuncSummary) -> bool {
|
||||||
|
// Express middleware contract: (req, res, next). Adapters cannot
|
||||||
|
// rely on a generic mount-everything heuristic so the param shape
|
||||||
|
// becomes the secondary signal when no explicit `app.use(<name>)`
|
||||||
|
// line is present.
|
||||||
|
let names: Vec<&str> = summary.param_names.iter().map(|s| s.as_str()).collect();
|
||||||
|
matches!(names.as_slice(), ["req", "res", "next"])
|
||||||
|
|| matches!(names.as_slice(), ["request", "response", "next"])
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FrameworkAdapter for MiddlewareExpressAdapter {
|
impl FrameworkAdapter for MiddlewareExpressAdapter {
|
||||||
|
|
@ -45,22 +64,23 @@ impl FrameworkAdapter for MiddlewareExpressAdapter {
|
||||||
_ast: tree_sitter::Node<'_>,
|
_ast: tree_sitter::Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
let matches_call = super::any_callee_matches(summary, callee_is_express);
|
let mounted_by_name = function_is_mounted_as_middleware(file_bytes, &summary.name);
|
||||||
let matches_source = source_imports_express(file_bytes);
|
let has_mw_signature = function_has_middleware_signature(summary);
|
||||||
if matches_call || matches_source {
|
let body_mounts = super::any_callee_matches(summary, callee_is_express_mount);
|
||||||
Some(FrameworkBinding {
|
let binds = mounted_by_name || has_mw_signature || body_mounts;
|
||||||
adapter: ADAPTER_NAME.to_owned(),
|
if !binds {
|
||||||
kind: EntryKind::Middleware {
|
return None;
|
||||||
name: summary.name.clone(),
|
|
||||||
},
|
|
||||||
route: None,
|
|
||||||
request_params: Vec::new(),
|
|
||||||
response_writer: None,
|
|
||||||
middleware: Vec::new(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
Some(FrameworkBinding {
|
||||||
|
adapter: ADAPTER_NAME.to_owned(),
|
||||||
|
kind: EntryKind::Middleware {
|
||||||
|
name: summary.name.clone(),
|
||||||
|
},
|
||||||
|
route: None,
|
||||||
|
request_params: Vec::new(),
|
||||||
|
response_writer: None,
|
||||||
|
middleware: Vec::new(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,4 +114,26 @@ mod tests {
|
||||||
assert_eq!(name, "audit");
|
assert_eq!(name, "audit");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_bind_unrelated_helper_in_express_setup() {
|
||||||
|
// File mounts middleware `audit` but the analyser is asking
|
||||||
|
// about an unrelated helper `loadConfig` in the same file.
|
||||||
|
let src: &[u8] = b"const express = require('express');\n\
|
||||||
|
const app = express();\n\
|
||||||
|
function audit(req, res, next) { next(); }\n\
|
||||||
|
function loadConfig() { return { port: 3000 }; }\n\
|
||||||
|
app.use(audit);\n";
|
||||||
|
let tree = parse_js(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "loadConfig".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
MiddlewareExpressAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none(),
|
||||||
|
"unrelated helper in an Express setup file must not bind as middleware",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@
|
||||||
//! Fires when the surrounding source declares a class with a `handle`
|
//! Fires when the surrounding source declares a class with a `handle`
|
||||||
//! method whose signature matches Laravel's middleware contract
|
//! method whose signature matches Laravel's middleware contract
|
||||||
//! (`$request, Closure $next`).
|
//! (`$request, Closure $next`).
|
||||||
|
//!
|
||||||
|
//! Notably does NOT fire just because the file imports
|
||||||
|
//! `Illuminate\Http\Request` or mentions `$middleware` — every typical
|
||||||
|
//! Laravel controller imports the request facade, and `$middleware`
|
||||||
|
//! appears in routes / kernel files unrelated to middleware classes
|
||||||
|
//! (Phase 21 binding-stealing audit).
|
||||||
|
|
||||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
|
|
@ -15,23 +21,26 @@ const ADAPTER_NAME: &str = "middleware-laravel";
|
||||||
|
|
||||||
fn callee_is_laravel_middleware(name: &str) -> bool {
|
fn callee_is_laravel_middleware(name: &str) -> bool {
|
||||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
matches!(last, "handle" | "terminate" | "next" | "withMiddleware")
|
matches!(last, "terminate" | "withMiddleware")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_imports_laravel_middleware(file_bytes: &[u8]) -> bool {
|
fn source_has_middleware_shape(file_bytes: &[u8]) -> bool {
|
||||||
const NEEDLES: &[&[u8]] = &[
|
const NEEDLES: &[&[u8]] = &[
|
||||||
b"Illuminate\\Http\\Request",
|
|
||||||
b"Illuminate\\Foundation\\Http\\Middleware",
|
b"Illuminate\\Foundation\\Http\\Middleware",
|
||||||
b"function handle($request, Closure $next",
|
b"function handle($request, Closure $next",
|
||||||
b"function handle(Request $request, Closure $next",
|
b"function handle(Request $request, Closure $next",
|
||||||
|
b"function handle($request, $next",
|
||||||
b"app/Http/Middleware",
|
b"app/Http/Middleware",
|
||||||
b"$middleware",
|
|
||||||
];
|
];
|
||||||
NEEDLES
|
NEEDLES
|
||||||
.iter()
|
.iter()
|
||||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn name_is_middleware_entry(name: &str) -> bool {
|
||||||
|
matches!(name, "handle" | "terminate")
|
||||||
|
}
|
||||||
|
|
||||||
impl FrameworkAdapter for MiddlewareLaravelAdapter {
|
impl FrameworkAdapter for MiddlewareLaravelAdapter {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
ADAPTER_NAME
|
ADAPTER_NAME
|
||||||
|
|
@ -47,22 +56,24 @@ impl FrameworkAdapter for MiddlewareLaravelAdapter {
|
||||||
_ast: tree_sitter::Node<'_>,
|
_ast: tree_sitter::Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
let matches_call = super::any_callee_matches(summary, callee_is_laravel_middleware);
|
let has_shape = source_has_middleware_shape(file_bytes);
|
||||||
let matches_source = source_imports_laravel_middleware(file_bytes);
|
let name_matches = name_is_middleware_entry(&summary.name);
|
||||||
if matches_call || matches_source {
|
let body_mounts_middleware =
|
||||||
Some(FrameworkBinding {
|
super::any_callee_matches(summary, callee_is_laravel_middleware);
|
||||||
adapter: ADAPTER_NAME.to_owned(),
|
let binds = (name_matches && has_shape) || body_mounts_middleware;
|
||||||
kind: EntryKind::Middleware {
|
if !binds {
|
||||||
name: summary.name.clone(),
|
return None;
|
||||||
},
|
|
||||||
route: None,
|
|
||||||
request_params: Vec::new(),
|
|
||||||
response_writer: None,
|
|
||||||
middleware: Vec::new(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
Some(FrameworkBinding {
|
||||||
|
adapter: ADAPTER_NAME.to_owned(),
|
||||||
|
kind: EntryKind::Middleware {
|
||||||
|
name: summary.name.clone(),
|
||||||
|
},
|
||||||
|
route: None,
|
||||||
|
request_params: Vec::new(),
|
||||||
|
response_writer: None,
|
||||||
|
middleware: Vec::new(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,4 +102,20 @@ mod tests {
|
||||||
assert_eq!(binding.adapter, "middleware-laravel");
|
assert_eq!(binding.adapter, "middleware-laravel");
|
||||||
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_bind_laravel_controller_method() {
|
||||||
|
let src: &[u8] = b"<?php\nuse Illuminate\\Http\\Request;\nclass UserController {\n public function show(Request $request) { return $request->all(); }\n}\n";
|
||||||
|
let tree = parse_php(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "show".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
MiddlewareLaravelAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none(),
|
||||||
|
"controller method must not bind as middleware just because the file imports Request",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
//! Phase 21 (Track M.3) — Rack / Rails middleware adapter (Ruby).
|
//! Phase 21 (Track M.3) — Rack / Rails middleware adapter (Ruby).
|
||||||
//!
|
//!
|
||||||
//! Fires when the surrounding source defines a Rack-shaped middleware
|
//! Fires when the surrounding source defines a Rack-shaped middleware
|
||||||
//! (`def call(env)`) or registers a Rails before-action callback.
|
//! (`def call(env)`) or wires one into the Rails middleware stack.
|
||||||
|
//!
|
||||||
|
//! Notably does NOT fire for Rails controller actions even when the file
|
||||||
|
//! contains `before_action :name` / `after_action :name` callback
|
||||||
|
//! registrations — those are class-level controller DSL hooks, not Rack
|
||||||
|
//! middleware definitions. Older `before_action ` / `after_action ` /
|
||||||
|
//! `around_action ` source needles were dropped because every typical
|
||||||
|
//! Rails controller mentions them, which made the adapter bind every
|
||||||
|
//! controller action as middleware (Phase 21 binding-stealing audit).
|
||||||
|
|
||||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
|
|
@ -14,28 +22,39 @@ const ADAPTER_NAME: &str = "middleware-rails";
|
||||||
|
|
||||||
fn callee_is_rails_middleware(name: &str) -> bool {
|
fn callee_is_rails_middleware(name: &str) -> bool {
|
||||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
matches!(
|
matches!(last, "call" | "use")
|
||||||
last,
|
|
||||||
"call" | "before_action" | "around_action" | "after_action" | "use"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_imports_rails_middleware(file_bytes: &[u8]) -> bool {
|
fn source_has_rack_middleware_shape(file_bytes: &[u8]) -> bool {
|
||||||
const NEEDLES: &[&[u8]] = &[
|
const NEEDLES: &[&[u8]] = &[
|
||||||
b"def call(env)",
|
b"def call(env)",
|
||||||
b"def call (env",
|
b"def call (env",
|
||||||
b"before_action ",
|
|
||||||
b"after_action ",
|
|
||||||
b"around_action ",
|
|
||||||
b"Rails.application.config.middleware",
|
b"Rails.application.config.middleware",
|
||||||
b"Rack::Builder",
|
b"Rack::Builder",
|
||||||
b"@app = app",
|
|
||||||
];
|
];
|
||||||
NEEDLES
|
NEEDLES
|
||||||
.iter()
|
.iter()
|
||||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn looks_like_rails_controller(file_bytes: &[u8]) -> bool {
|
||||||
|
const NEEDLES: &[&[u8]] = &[
|
||||||
|
b"< ApplicationController",
|
||||||
|
b"<ApplicationController",
|
||||||
|
b"< ActionController::Base",
|
||||||
|
b"<ActionController::Base",
|
||||||
|
b"< ActionController::API",
|
||||||
|
b"<ActionController::API",
|
||||||
|
];
|
||||||
|
NEEDLES
|
||||||
|
.iter()
|
||||||
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name_is_rack_entry(name: &str) -> bool {
|
||||||
|
name == "call"
|
||||||
|
}
|
||||||
|
|
||||||
impl FrameworkAdapter for MiddlewareRailsAdapter {
|
impl FrameworkAdapter for MiddlewareRailsAdapter {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
ADAPTER_NAME
|
ADAPTER_NAME
|
||||||
|
|
@ -51,22 +70,27 @@ impl FrameworkAdapter for MiddlewareRailsAdapter {
|
||||||
_ast: tree_sitter::Node<'_>,
|
_ast: tree_sitter::Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
let matches_call = super::any_callee_matches(summary, callee_is_rails_middleware);
|
if looks_like_rails_controller(file_bytes) {
|
||||||
let matches_source = source_imports_rails_middleware(file_bytes);
|
return None;
|
||||||
if matches_call || matches_source {
|
|
||||||
Some(FrameworkBinding {
|
|
||||||
adapter: ADAPTER_NAME.to_owned(),
|
|
||||||
kind: EntryKind::Middleware {
|
|
||||||
name: summary.name.clone(),
|
|
||||||
},
|
|
||||||
route: None,
|
|
||||||
request_params: Vec::new(),
|
|
||||||
response_writer: None,
|
|
||||||
middleware: Vec::new(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
let has_middleware_shape = source_has_rack_middleware_shape(file_bytes);
|
||||||
|
let name_matches = name_is_rack_entry(&summary.name);
|
||||||
|
let body_mounts_middleware =
|
||||||
|
super::any_callee_matches(summary, callee_is_rails_middleware);
|
||||||
|
let binds = (name_matches && has_middleware_shape) || body_mounts_middleware;
|
||||||
|
if !binds {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(FrameworkBinding {
|
||||||
|
adapter: ADAPTER_NAME.to_owned(),
|
||||||
|
kind: EntryKind::Middleware {
|
||||||
|
name: summary.name.clone(),
|
||||||
|
},
|
||||||
|
route: None,
|
||||||
|
request_params: Vec::new(),
|
||||||
|
response_writer: None,
|
||||||
|
middleware: Vec::new(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,4 +119,20 @@ mod tests {
|
||||||
assert_eq!(binding.adapter, "middleware-rails");
|
assert_eq!(binding.adapter, "middleware-rails");
|
||||||
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_bind_rails_controller_action() {
|
||||||
|
let src: &[u8] = b"class UsersController < ApplicationController\n before_action :authenticate\n def index\n @users = User.all\n render :index\n end\nend\n";
|
||||||
|
let tree = parse_ruby(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "index".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
MiddlewareRailsAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none(),
|
||||||
|
"controller action must not bind as Rack middleware",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@
|
||||||
//! Fires when the surrounding source extends `Illuminate\\Database\\Migrations\\Migration`
|
//! Fires when the surrounding source extends `Illuminate\\Database\\Migrations\\Migration`
|
||||||
//! and declares an `up()` / `down()` method whose body invokes
|
//! and declares an `up()` / `down()` method whose body invokes
|
||||||
//! `Schema::create` / `Schema::table` / `DB::statement`.
|
//! `Schema::create` / `Schema::table` / `DB::statement`.
|
||||||
|
//!
|
||||||
|
//! Notably does NOT fire just because the file mentions `DB::statement`
|
||||||
|
//! or the bare `Illuminate\\Database\\Schema` namespace — those tokens
|
||||||
|
//! appear in plenty of model helpers, query objects, and database
|
||||||
|
//! drivers that are not themselves migration classes (Phase 21
|
||||||
|
//! binding-stealing audit).
|
||||||
|
|
||||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
|
|
@ -13,28 +19,26 @@ pub struct MigrationLaravelAdapter;
|
||||||
|
|
||||||
const ADAPTER_NAME: &str = "migration-laravel";
|
const ADAPTER_NAME: &str = "migration-laravel";
|
||||||
|
|
||||||
fn callee_is_laravel_migration(name: &str) -> bool {
|
fn callee_is_laravel_migration_ddl(name: &str) -> bool {
|
||||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
matches!(
|
matches!(last, "create" | "table" | "drop" | "statement" | "unprepared")
|
||||||
last,
|
|
||||||
"up" | "down" | "create" | "table" | "drop" | "statement" | "unprepared"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_imports_laravel_migration(file_bytes: &[u8]) -> bool {
|
fn source_has_migration_shape(file_bytes: &[u8]) -> bool {
|
||||||
const NEEDLES: &[&[u8]] = &[
|
const NEEDLES: &[&[u8]] = &[
|
||||||
b"Illuminate\\Database\\Migrations\\Migration",
|
b"Illuminate\\Database\\Migrations\\Migration",
|
||||||
b"Illuminate\\Database\\Schema",
|
|
||||||
b"Schema::create",
|
b"Schema::create",
|
||||||
b"Schema::table",
|
b"Schema::table",
|
||||||
b"DB::statement",
|
|
||||||
b"use Illuminate\\Database\\Schema",
|
|
||||||
];
|
];
|
||||||
NEEDLES
|
NEEDLES
|
||||||
.iter()
|
.iter()
|
||||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn name_is_migration_entry(name: &str) -> bool {
|
||||||
|
matches!(name, "up" | "down")
|
||||||
|
}
|
||||||
|
|
||||||
impl FrameworkAdapter for MigrationLaravelAdapter {
|
impl FrameworkAdapter for MigrationLaravelAdapter {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
ADAPTER_NAME
|
ADAPTER_NAME
|
||||||
|
|
@ -50,20 +54,21 @@ impl FrameworkAdapter for MigrationLaravelAdapter {
|
||||||
_ast: tree_sitter::Node<'_>,
|
_ast: tree_sitter::Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
let matches_call = super::any_callee_matches(summary, callee_is_laravel_migration);
|
let has_shape = source_has_migration_shape(file_bytes);
|
||||||
let matches_source = source_imports_laravel_migration(file_bytes);
|
let name_matches = name_is_migration_entry(&summary.name);
|
||||||
if matches_call || matches_source {
|
let body_runs_ddl = super::any_callee_matches(summary, callee_is_laravel_migration_ddl);
|
||||||
Some(FrameworkBinding {
|
let binds = (name_matches || body_runs_ddl) && has_shape;
|
||||||
adapter: ADAPTER_NAME.to_owned(),
|
if !binds {
|
||||||
kind: EntryKind::Migration { version: None },
|
return None;
|
||||||
route: None,
|
|
||||||
request_params: Vec::new(),
|
|
||||||
response_writer: None,
|
|
||||||
middleware: Vec::new(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
Some(FrameworkBinding {
|
||||||
|
adapter: ADAPTER_NAME.to_owned(),
|
||||||
|
kind: EntryKind::Migration { version: None },
|
||||||
|
route: None,
|
||||||
|
request_params: Vec::new(),
|
||||||
|
response_writer: None,
|
||||||
|
middleware: Vec::new(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
//! Phase 21 (Track M.3) — Rails ActiveRecord migration adapter (Ruby).
|
//! Phase 21 (Track M.3) — Rails ActiveRecord migration adapter (Ruby).
|
||||||
//!
|
//!
|
||||||
//! Fires when the surrounding source declares a class inheriting from
|
//! Fires when the surrounding source declares a class inheriting from
|
||||||
//! `ActiveRecord::Migration[...]` or invokes the canonical migration
|
//! `ActiveRecord::Migration[...]` or carries the canonical migration
|
||||||
//! DSL (`create_table`, `add_column`, `execute`).
|
//! marker the fixture uses (`# class Foo < ActiveRecord::Migration[…]`).
|
||||||
|
//!
|
||||||
|
//! Notably does NOT fire just because the file mentions `create_table` /
|
||||||
|
//! `add_column` / `drop_table` — those tokens also appear in
|
||||||
|
//! `db/schema.rb` snapshots, helper modules, and SQL ddl bodies that are
|
||||||
|
//! not themselves migration classes (Phase 21 binding-stealing audit).
|
||||||
|
|
||||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||||
use crate::evidence::EntryKind;
|
use crate::evidence::EntryKind;
|
||||||
|
|
@ -17,9 +22,7 @@ fn callee_is_rails_migration(name: &str) -> bool {
|
||||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||||
matches!(
|
matches!(
|
||||||
last,
|
last,
|
||||||
"up" | "down"
|
"create_table"
|
||||||
| "change"
|
|
||||||
| "create_table"
|
|
||||||
| "add_column"
|
| "add_column"
|
||||||
| "remove_column"
|
| "remove_column"
|
||||||
| "drop_table"
|
| "drop_table"
|
||||||
|
|
@ -28,19 +31,17 @@ fn callee_is_rails_migration(name: &str) -> bool {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_imports_rails_migration(file_bytes: &[u8]) -> bool {
|
fn source_has_migration_shape(file_bytes: &[u8]) -> bool {
|
||||||
const NEEDLES: &[&[u8]] = &[
|
const NEEDLES: &[&[u8]] = &[b"ActiveRecord::Migration", b"< ActiveRecord::Migration"];
|
||||||
b"ActiveRecord::Migration",
|
|
||||||
b"< ActiveRecord::Migration",
|
|
||||||
b"create_table ",
|
|
||||||
b"add_column ",
|
|
||||||
b"drop_table ",
|
|
||||||
];
|
|
||||||
NEEDLES
|
NEEDLES
|
||||||
.iter()
|
.iter()
|
||||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn name_is_migration_entry(name: &str) -> bool {
|
||||||
|
matches!(name, "up" | "down" | "change")
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_version(file_bytes: &[u8]) -> Option<String> {
|
fn extract_version(file_bytes: &[u8]) -> Option<String> {
|
||||||
let text = std::str::from_utf8(file_bytes).unwrap_or("");
|
let text = std::str::from_utf8(file_bytes).unwrap_or("");
|
||||||
let needle = "ActiveRecord::Migration[";
|
let needle = "ActiveRecord::Migration[";
|
||||||
|
|
@ -68,22 +69,23 @@ impl FrameworkAdapter for MigrationRailsAdapter {
|
||||||
_ast: tree_sitter::Node<'_>,
|
_ast: tree_sitter::Node<'_>,
|
||||||
file_bytes: &[u8],
|
file_bytes: &[u8],
|
||||||
) -> Option<FrameworkBinding> {
|
) -> Option<FrameworkBinding> {
|
||||||
let matches_call = super::any_callee_matches(summary, callee_is_rails_migration);
|
let has_shape = source_has_migration_shape(file_bytes);
|
||||||
let matches_source = source_imports_rails_migration(file_bytes);
|
let name_matches = name_is_migration_entry(&summary.name);
|
||||||
if matches_call || matches_source {
|
let body_runs_ddl = super::any_callee_matches(summary, callee_is_rails_migration);
|
||||||
Some(FrameworkBinding {
|
let binds = (name_matches || body_runs_ddl) && has_shape;
|
||||||
adapter: ADAPTER_NAME.to_owned(),
|
if !binds {
|
||||||
kind: EntryKind::Migration {
|
return None;
|
||||||
version: extract_version(file_bytes),
|
|
||||||
},
|
|
||||||
route: None,
|
|
||||||
request_params: Vec::new(),
|
|
||||||
response_writer: None,
|
|
||||||
middleware: Vec::new(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
Some(FrameworkBinding {
|
||||||
|
adapter: ADAPTER_NAME.to_owned(),
|
||||||
|
kind: EntryKind::Migration {
|
||||||
|
version: extract_version(file_bytes),
|
||||||
|
},
|
||||||
|
route: None,
|
||||||
|
request_params: Vec::new(),
|
||||||
|
response_writer: None,
|
||||||
|
middleware: Vec::new(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,4 +116,20 @@ mod tests {
|
||||||
assert_eq!(version.as_deref(), Some("7.0"));
|
assert_eq!(version.as_deref(), Some("7.0"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_bind_schema_dump() {
|
||||||
|
let src: &[u8] = b"ActiveRecord::Schema.define(version: 2024_01_01_000000) do\n create_table :users do |t|\n t.string :name\n end\nend\n";
|
||||||
|
let tree = parse_ruby(src);
|
||||||
|
let summary = FuncSummary {
|
||||||
|
name: "define".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
MigrationRailsAdapter
|
||||||
|
.detect(&summary, tree.root_node(), src)
|
||||||
|
.is_none(),
|
||||||
|
"db/schema.rb dump must not bind as migration",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ mod tests {
|
||||||
use crate::chain::finding::{ChainFinding, ChainSeverity, ChainSink};
|
use crate::chain::finding::{ChainFinding, ChainSeverity, ChainSink};
|
||||||
use crate::chain::impact::ImpactCategory;
|
use crate::chain::impact::ImpactCategory;
|
||||||
use crate::commands::scan::Diag;
|
use crate::commands::scan::Diag;
|
||||||
|
use crate::evidence::{Evidence, VerifyResult, VerifyStatus};
|
||||||
use crate::patterns::{FindingCategory, Severity};
|
use crate::patterns::{FindingCategory, Severity};
|
||||||
use crate::surface::SourceLocation;
|
use crate::surface::SourceLocation;
|
||||||
|
|
||||||
|
|
@ -157,4 +158,31 @@ mod tests {
|
||||||
let v = build_findings_json(&[], &[], Some(&json!({"new": []})));
|
let v = build_findings_json(&[], &[], Some(&json!({"new": []})));
|
||||||
assert!(v.get("verdict_diff").is_some());
|
assert!(v.get("verdict_diff").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dynamic_verification_summary_is_included() {
|
||||||
|
let mut d = diag(7);
|
||||||
|
d.evidence = Some(Evidence {
|
||||||
|
dynamic_verdict: Some(VerifyResult {
|
||||||
|
finding_id: "abc123".into(),
|
||||||
|
status: VerifyStatus::Confirmed,
|
||||||
|
triggered_payload: None,
|
||||||
|
reason: None,
|
||||||
|
inconclusive_reason: None,
|
||||||
|
detail: None,
|
||||||
|
attempts: vec![],
|
||||||
|
toolchain_match: None,
|
||||||
|
differential: None,
|
||||||
|
replay_stable: None,
|
||||||
|
wrong: None,
|
||||||
|
hardening_outcome: None,
|
||||||
|
}),
|
||||||
|
..Evidence::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let v = build_findings_json(&[d], &[], None);
|
||||||
|
|
||||||
|
assert_eq!(v["dynamic_verification"]["total"], json!(1));
|
||||||
|
assert_eq!(v["dynamic_verification"]["confirmed"], json!(1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::commands::scan::Diag;
|
use crate::commands::scan::Diag;
|
||||||
use crate::evidence::{Confidence, Evidence};
|
use crate::evidence::{Confidence, Evidence, VerifyResult, VerifyStatus};
|
||||||
use crate::patterns::{FindingCategory, Severity};
|
use crate::patterns::{FindingCategory, Severity};
|
||||||
use crate::utils::path::{DEFAULT_UI_MAX_FILE_BYTES, open_repo_text_file};
|
use crate::utils::path::{DEFAULT_UI_MAX_FILE_BYTES, open_repo_text_file};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
@ -26,6 +26,15 @@ pub const VALID_TRIAGE_STATES: &[&str] = &[
|
||||||
"fixed",
|
"fixed",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Valid dynamic verification states for findings.
|
||||||
|
pub const VALID_DYNAMIC_VERIFICATION_STATES: &[&str] = &[
|
||||||
|
"Confirmed",
|
||||||
|
"NotConfirmed",
|
||||||
|
"Inconclusive",
|
||||||
|
"Unsupported",
|
||||||
|
"Unverified",
|
||||||
|
];
|
||||||
|
|
||||||
/// Check if a string is a valid triage state.
|
/// Check if a string is a valid triage state.
|
||||||
pub fn is_valid_triage_state(s: &str) -> bool {
|
pub fn is_valid_triage_state(s: &str) -> bool {
|
||||||
VALID_TRIAGE_STATES.contains(&s)
|
VALID_TRIAGE_STATES.contains(&s)
|
||||||
|
|
@ -64,6 +73,8 @@ pub struct FindingView {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub evidence: Option<Evidence>,
|
pub evidence: Option<Evidence>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dynamic_verdict: Option<VerifyResult>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub guard_kind: Option<String>,
|
pub guard_kind: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub rank_reason: Option<Vec<(String, String)>>,
|
pub rank_reason: Option<Vec<(String, String)>>,
|
||||||
|
|
@ -199,6 +210,7 @@ pub struct FilterValues {
|
||||||
pub languages: Vec<String>,
|
pub languages: Vec<String>,
|
||||||
pub rules: Vec<String>,
|
pub rules: Vec<String>,
|
||||||
pub statuses: Vec<String>,
|
pub statuses: Vec<String>,
|
||||||
|
pub verification_statuses: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collect distinct filter values from a slice of diagnostics.
|
/// Collect distinct filter values from a slice of diagnostics.
|
||||||
|
|
@ -209,6 +221,7 @@ pub fn collect_filter_values(findings: &[Diag]) -> FilterValues {
|
||||||
let mut languages = BTreeSet::new();
|
let mut languages = BTreeSet::new();
|
||||||
let mut rules = BTreeSet::new();
|
let mut rules = BTreeSet::new();
|
||||||
let mut statuses = BTreeSet::new();
|
let mut statuses = BTreeSet::new();
|
||||||
|
let mut verification_statuses = BTreeSet::new();
|
||||||
|
|
||||||
for d in findings {
|
for d in findings {
|
||||||
severities.insert(d.severity.as_db_str().to_string());
|
severities.insert(d.severity.as_db_str().to_string());
|
||||||
|
|
@ -221,12 +234,16 @@ pub fn collect_filter_values(findings: &[Diag]) -> FilterValues {
|
||||||
}
|
}
|
||||||
rules.insert(d.id.clone());
|
rules.insert(d.id.clone());
|
||||||
statuses.insert(status_for_diag(d).to_string());
|
statuses.insert(status_for_diag(d).to_string());
|
||||||
|
verification_statuses.insert(dynamic_status_for_diag(d).unwrap_or("Unverified").to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always include all valid triage states so the filter dropdown is complete
|
// Always include all valid triage states so the filter dropdown is complete
|
||||||
for s in VALID_TRIAGE_STATES {
|
for s in VALID_TRIAGE_STATES {
|
||||||
statuses.insert(s.to_string());
|
statuses.insert(s.to_string());
|
||||||
}
|
}
|
||||||
|
for s in VALID_DYNAMIC_VERIFICATION_STATES {
|
||||||
|
verification_statuses.insert(s.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
FilterValues {
|
FilterValues {
|
||||||
severities: severities.into_iter().collect(),
|
severities: severities.into_iter().collect(),
|
||||||
|
|
@ -235,6 +252,7 @@ pub fn collect_filter_values(findings: &[Diag]) -> FilterValues {
|
||||||
languages: languages.into_iter().collect(),
|
languages: languages.into_iter().collect(),
|
||||||
rules: rules.into_iter().collect(),
|
rules: rules.into_iter().collect(),
|
||||||
statuses: statuses.into_iter().collect(),
|
statuses: statuses.into_iter().collect(),
|
||||||
|
verification_statuses: verification_statuses.into_iter().collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,6 +285,24 @@ fn status_for_diag(d: &Diag) -> &'static str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Human-readable dynamic status used by API filters and table rows.
|
||||||
|
pub fn dynamic_status_label(status: VerifyStatus) -> &'static str {
|
||||||
|
match status {
|
||||||
|
VerifyStatus::Confirmed => "Confirmed",
|
||||||
|
VerifyStatus::NotConfirmed => "NotConfirmed",
|
||||||
|
VerifyStatus::Inconclusive => "Inconclusive",
|
||||||
|
VerifyStatus::Unsupported => "Unsupported",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dynamic verification status for a diagnostic, when a verdict exists.
|
||||||
|
pub fn dynamic_status_for_diag(d: &Diag) -> Option<&'static str> {
|
||||||
|
d.evidence
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|ev| ev.dynamic_verdict.as_ref())
|
||||||
|
.map(|verdict| dynamic_status_label(verdict.status))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn is_zero_u64(v: &u64) -> bool {
|
pub(crate) fn is_zero_u64(v: &u64) -> bool {
|
||||||
*v == 0
|
*v == 0
|
||||||
}
|
}
|
||||||
|
|
@ -296,6 +332,10 @@ pub fn finding_from_diag(index: usize, d: &Diag) -> FindingView {
|
||||||
triage_note: String::new(),
|
triage_note: String::new(),
|
||||||
code_context: None,
|
code_context: None,
|
||||||
evidence: None,
|
evidence: None,
|
||||||
|
dynamic_verdict: d
|
||||||
|
.evidence
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|ev| ev.dynamic_verdict.clone()),
|
||||||
guard_kind: None,
|
guard_kind: None,
|
||||||
rank_reason: None,
|
rank_reason: None,
|
||||||
sanitizer_status: None,
|
sanitizer_status: None,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use crate::server::app::{AppState, CachedFindings};
|
||||||
use crate::server::error::{ApiError, ApiResult};
|
use crate::server::error::{ApiError, ApiResult};
|
||||||
use crate::server::models::{
|
use crate::server::models::{
|
||||||
FilterValues, FindingSummary, FindingView, collect_filter_values, finding_from_diag,
|
FilterValues, FindingSummary, FindingView, collect_filter_values, finding_from_diag,
|
||||||
finding_from_diag_with_detail, overlay_triage_states, summarize_findings,
|
finding_from_diag_with_detail, dynamic_status_label, overlay_triage_states, summarize_findings,
|
||||||
};
|
};
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
|
|
@ -139,6 +139,7 @@ struct FindingsQuery {
|
||||||
language: Option<String>,
|
language: Option<String>,
|
||||||
confidence: Option<String>,
|
confidence: Option<String>,
|
||||||
status: Option<String>,
|
status: Option<String>,
|
||||||
|
verification: Option<String>,
|
||||||
sort_by: Option<String>,
|
sort_by: Option<String>,
|
||||||
sort_dir: Option<String>,
|
sort_dir: Option<String>,
|
||||||
page: Option<usize>,
|
page: Option<usize>,
|
||||||
|
|
@ -187,6 +188,17 @@ async fn list_findings(
|
||||||
let status_lower = status.to_ascii_lowercase();
|
let status_lower = status.to_ascii_lowercase();
|
||||||
views.retain(|f| f.status.to_ascii_lowercase() == status_lower);
|
views.retain(|f| f.status.to_ascii_lowercase() == status_lower);
|
||||||
}
|
}
|
||||||
|
if let Some(ref verification) = query.verification {
|
||||||
|
let verification_lower = verification.to_ascii_lowercase();
|
||||||
|
views.retain(|f| {
|
||||||
|
let status = f
|
||||||
|
.dynamic_verdict
|
||||||
|
.as_ref()
|
||||||
|
.map(|verdict| dynamic_status_label(verdict.status))
|
||||||
|
.unwrap_or("Unverified");
|
||||||
|
status.to_ascii_lowercase() == verification_lower
|
||||||
|
});
|
||||||
|
}
|
||||||
if let Some(ref search) = query.search {
|
if let Some(ref search) = query.search {
|
||||||
let needle = search.to_ascii_lowercase();
|
let needle = search.to_ascii_lowercase();
|
||||||
views.retain(|f| {
|
views.retain(|f| {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue