mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
refactor(server, scan): introduce target management with active target switching, enhance DB pool handling, and integrate target-aware task routes for improved modularity
This commit is contained in:
parent
acdc71cd88
commit
635b213825
40 changed files with 1810 additions and 240 deletions
|
|
@ -1 +0,0 @@
|
|||
{"sessionId":"6c158e05-a83e-4808-acf4-12ad7b0fe983","pid":8358,"procStart":"Fri May 29 15:24:35 2026","acquiredAt":1780071990470}
|
||||
10
README.md
10
README.md
|
|
@ -183,6 +183,16 @@ Fixtures live under [`tests/benchmark/cve_corpus/`](tests/benchmark/cve_corpus/)
|
|||
|
||||
Two passes over the filesystem, with an optional SQLite index to skip unchanged files:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Repo["Repository files"] --> Pass1["Pass 1 per file<br/>tree-sitter, CFG, SSA"]
|
||||
Pass1 --> Summaries["Function summaries<br/>sources, sinks, sanitizers, points-to"]
|
||||
Summaries --> Index["SQLite index<br/>optional incremental cache"]
|
||||
Index --> Pass2["Pass 2 cross-file<br/>global summaries, k=1 inline, SCC fixpoint"]
|
||||
Pass2 --> Rank["Rank and dedupe<br/>severity, evidence, exploitability"]
|
||||
Rank --> Output["Console, JSON, SARIF<br/>and browser UI"]
|
||||
```
|
||||
|
||||
1. **Pass 1**: parse each file via tree-sitter, build an intra-procedural CFG (petgraph), lower to pruned SSA (Cytron phi insertion over dominance frontiers), and export per-function summaries (source/sanitizer/sink caps, taint transforms, points-to, callees).
|
||||
2. **Summary merge**: union all per-file summaries into a `GlobalSummaries` map.
|
||||
3. **Pass 2**: re-analyze each file with cross-file context under bounded context sensitivity (k=1 inlining for intra-file callees, SCC fixpoint capped at 64 iterations, and summary fallback for callees above the inline body-size cap). A forward dataflow worklist propagates taint through the SSA lattice with guaranteed convergence. Call-graph SCCs iterate to fixed-point (within the cap) so mutually recursive functions get accurate summaries.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ preferred-dark-theme = "navy"
|
|||
git-repository-url = "https://github.com/elicpeter/nyx"
|
||||
edit-url-template = "https://github.com/elicpeter/nyx/edit/master/{path}"
|
||||
site-url = "/nyx/"
|
||||
additional-css = ["docs/mermaid.css"]
|
||||
additional-js = ["docs/mermaid-init.js"]
|
||||
|
||||
[output.html.fold]
|
||||
enable = true
|
||||
|
|
|
|||
|
|
@ -9,6 +9,17 @@ Nyx ships four independent detector families. They run together in `--mode full`
|
|||
| [State model](detectors/state.md) | `state-*` | Per-function state lattice | Use-after-close, double-close, leaks, unauthenticated access |
|
||||
| [AST patterns](detectors/patterns.md) | `<lang>.<cat>.<name>` | Tree-sitter structural match | Banned APIs, weak crypto, dangerous constructs |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Taint["Taint analysis<br/>cross-file source-to-sink"] --> Normalize["Normalize findings"]
|
||||
Cfg["CFG structural<br/>guards, exits, resource paths"] --> Normalize
|
||||
State["State model<br/>resource and auth lattice"] --> Normalize
|
||||
Ast["AST patterns<br/>tree-sitter structural match"] --> Normalize
|
||||
Normalize --> Dedupe["Deduplicate<br/>same site, rule, severity"]
|
||||
Dedupe --> Rank["Rank<br/>severity, evidence, context"]
|
||||
Rank --> Output["Console, JSON, SARIF, UI"]
|
||||
```
|
||||
|
||||
The taint family is split into cap-specific rule classes when a sink callee carries multiple vulnerability classes:
|
||||
|
||||
| Rule id | Cap | Surface |
|
||||
|
|
|
|||
|
|
@ -6,6 +6,21 @@ If you're going to act on a finding, it helps to know how the scanner got there.
|
|||
|
||||
A scan runs in two passes over the file tree, with an optional SQLite index that lets the second scan skip files whose content hash hasn't changed.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Walk["Walk file tree"] --> Pass1["Pass 1 per file<br/>tree-sitter parse, CFG, SSA"]
|
||||
Pass1 --> Summaries["Per-function summaries<br/>sources, sinks, sanitizers, returns, points-to"]
|
||||
Pass1 --> Hierarchy["Type hierarchy index<br/>extends, implements, impl-for, includes"]
|
||||
Summaries --> Global["GlobalSummaries map<br/>plus optional SQLite cache"]
|
||||
Hierarchy --> Global
|
||||
Global --> Pass2["Pass 2 per file<br/>cross-file context"]
|
||||
Pass2 --> Taint["Forward SSA taint worklist<br/>finite lattice, guaranteed convergence"]
|
||||
Pass2 --> Calls["Call precision<br/>k=1 inline, summaries, SCC fixed-point"]
|
||||
Taint --> Findings["Findings with evidence<br/>source, path, sink, engine notes"]
|
||||
Calls --> Findings
|
||||
Findings --> Emit["Rank, dedupe, emit<br/>console, JSON, SARIF, UI"]
|
||||
```
|
||||
|
||||
**Pass 1, per file.** Tree-sitter parses the file. Nyx builds an intra-procedural control-flow graph, lowers it to SSA, and extracts a summary per function describing what that function does at the boundary: which arguments flow to sinks, which sources it reads from, which sinks it calls, what taint it strips, what it returns. Summaries are persisted to SQLite ([`src/summary/`](https://github.com/elicpeter/nyx/tree/master/src/summary/), [`src/database.rs`](https://github.com/elicpeter/nyx/blob/master/src/database.rs)).
|
||||
|
||||
**Summary merge.** All per-file summaries get unioned into a global map keyed by qualified function name.
|
||||
|
|
|
|||
69
docs/mermaid-init.js
Normal file
69
docs/mermaid-init.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
(function () {
|
||||
const MERMAID_URL =
|
||||
"https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.esm.min.mjs";
|
||||
|
||||
async function renderMermaid() {
|
||||
const blocks = Array.from(
|
||||
document.querySelectorAll("pre > code.language-mermaid"),
|
||||
);
|
||||
if (blocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mermaidModule = await import(MERMAID_URL);
|
||||
const mermaid = mermaidModule.default;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
themeVariables: {
|
||||
background: "transparent",
|
||||
fontFamily:
|
||||
"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
|
||||
primaryColor: "#0f172a",
|
||||
primaryTextColor: "#e5e7eb",
|
||||
primaryBorderColor: "#22d3ee",
|
||||
secondaryColor: "#134e4a",
|
||||
secondaryTextColor: "#e5e7eb",
|
||||
secondaryBorderColor: "#2dd4bf",
|
||||
tertiaryColor: "#1e293b",
|
||||
tertiaryTextColor: "#e5e7eb",
|
||||
tertiaryBorderColor: "#64748b",
|
||||
lineColor: "#94a3b8",
|
||||
edgeLabelBackground: "#0f172a",
|
||||
clusterBkg: "#111827",
|
||||
clusterBorder: "#475569",
|
||||
},
|
||||
});
|
||||
|
||||
for (const block of blocks) {
|
||||
const pre = block.parentElement;
|
||||
if (!pre) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "nyx-mermaid";
|
||||
|
||||
const diagram = document.createElement("div");
|
||||
diagram.className = "mermaid";
|
||||
diagram.textContent = block.textContent.trim();
|
||||
|
||||
wrapper.appendChild(diagram);
|
||||
pre.replaceWith(wrapper);
|
||||
}
|
||||
|
||||
await mermaid.run({ querySelector: ".nyx-mermaid .mermaid" });
|
||||
} catch (error) {
|
||||
console.warn("Mermaid rendering failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", renderMermaid);
|
||||
} else {
|
||||
renderMermaid();
|
||||
}
|
||||
})();
|
||||
15
docs/mermaid.css
Normal file
15
docs/mermaid.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.nyx-mermaid {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.nyx-mermaid svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
|
@ -11,6 +11,16 @@ nyx serve --no-browser # don't auto-open
|
|||
|
||||
Persistent settings live under `[server]` in `nyx.conf` / `nyx.local`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Scan["nyx scan<br/>or UI-started scan"] --> Cache[".nyx findings<br/>plus SQLite project index"]
|
||||
Cache --> Serve["nyx serve<br/>loopback API and embedded React UI"]
|
||||
Serve --> Review["Review findings<br/>flow, evidence, history"]
|
||||
Review --> Triage["Update triage state<br/>investigate, suppress, accept, fix"]
|
||||
Triage --> Sync[".nyx/triage.json<br/>optional repo-synced state"]
|
||||
Sync --> Cache
|
||||
```
|
||||
|
||||
Starting a scan from the UI runs dynamic verification on `Confidence >= Medium`
|
||||
findings by default. Check "Skip dynamic verification" in the scan modal to get
|
||||
a fast static-only result. See [Dynamic verification](dynamic.md) for details.
|
||||
|
|
|
|||
42
frontend/src/api/queries/targets.ts
Normal file
42
frontend/src/api/queries/targets.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiDelete, apiGet, apiPost } from '../client';
|
||||
import type { TargetView } from '../types';
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryKey: ['targets'],
|
||||
queryFn: ({ signal }) => apiGet<TargetView[]>('/targets', signal),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTarget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { path: string }) => apiPost<TargetView>('/targets', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['targets'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSelectTarget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { id?: string; path?: string }) =>
|
||||
apiPost<TargetView>('/targets/select', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTarget() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<void>(`/targets/${encodeURIComponent(id)}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['targets'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -192,6 +192,17 @@ export interface ScanView {
|
|||
metrics?: ScanMetricsSnapshot;
|
||||
}
|
||||
|
||||
export interface TargetView {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
db_path: string;
|
||||
last_seen_at: string;
|
||||
last_scan_at?: string;
|
||||
active: boolean;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
// Scan Comparison types
|
||||
export interface CompareScanInfo {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@ import {
|
|||
ConfigIcon,
|
||||
ExplorerIcon,
|
||||
DebugIcon,
|
||||
FolderIcon,
|
||||
TagIcon,
|
||||
} from '../icons/Icons';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useRef, useState, type FC, type FormEvent } from 'react';
|
||||
import type { IconProps } from '../icons/Icons';
|
||||
import { useHealth } from '../../api/queries/health';
|
||||
import { useOverview } from '../../api/queries/overview';
|
||||
import {
|
||||
useAddTarget,
|
||||
useSelectTarget,
|
||||
useTargets,
|
||||
} from '../../api/queries/targets';
|
||||
import { useSSE } from '../../contexts/SSEContext';
|
||||
|
||||
interface NavItem {
|
||||
|
|
@ -95,6 +99,167 @@ function navLinkClass({ isActive }: { isActive: boolean }) {
|
|||
return `nav-link${isActive ? ' active' : ''}`;
|
||||
}
|
||||
|
||||
function targetNameFromPath(path: string) {
|
||||
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||
return parts[parts.length - 1] || path || 'Project';
|
||||
}
|
||||
|
||||
function targetInitial(name: string) {
|
||||
return name.trim().charAt(0).toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function compactPath(path: string) {
|
||||
return path.replace(/^\/Users\/[^/]+/, '~');
|
||||
}
|
||||
|
||||
function TargetSwitcher({ scanRoot }: { scanRoot?: string }) {
|
||||
const { data: targets = [] } = useTargets();
|
||||
const addTarget = useAddTarget();
|
||||
const selectTarget = useSelectTarget();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newPath, setNewPath] = useState('');
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const activeTarget =
|
||||
targets.find((target) => target.active) ??
|
||||
(scanRoot
|
||||
? {
|
||||
id: '__active__',
|
||||
name: targetNameFromPath(scanRoot),
|
||||
path: scanRoot,
|
||||
active: true,
|
||||
exists: true,
|
||||
}
|
||||
: undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (
|
||||
menuRef.current &&
|
||||
event.target instanceof Node &&
|
||||
!menuRef.current.contains(event.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function handleSelect(id: string) {
|
||||
selectTarget.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => setOpen(false),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleAddSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const path = newPath.trim();
|
||||
if (!path || addTarget.isPending) return;
|
||||
addTarget.mutate(
|
||||
{ path },
|
||||
{
|
||||
onSuccess: (target) => {
|
||||
setNewPath('');
|
||||
selectTarget.mutate(
|
||||
{ id: target.id },
|
||||
{
|
||||
onSuccess: () => setOpen(false),
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const isBusy = addTarget.isPending || selectTarget.isPending;
|
||||
const errorMessage =
|
||||
addTarget.error instanceof Error ? addTarget.error.message : null;
|
||||
|
||||
return (
|
||||
<div className="target-switcher" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="target-trigger"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-label="Select project target"
|
||||
title={activeTarget?.path}
|
||||
>
|
||||
<span className="target-avatar">
|
||||
{targetInitial(activeTarget?.name ?? 'Project')}
|
||||
</span>
|
||||
<span className="target-trigger-copy">
|
||||
<span className="target-name">
|
||||
{activeTarget?.name ?? 'Select target'}
|
||||
</span>
|
||||
<span className="target-path">
|
||||
{activeTarget?.path ? compactPath(activeTarget.path) : 'No target'}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`target-caret${open ? ' open' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="target-menu" role="menu">
|
||||
<div className="target-options">
|
||||
{targets.map((target) => (
|
||||
<button
|
||||
key={target.id}
|
||||
type="button"
|
||||
className={`target-option${target.active ? ' active' : ''}`}
|
||||
onClick={() => handleSelect(target.id)}
|
||||
disabled={target.active || !target.exists || isBusy}
|
||||
title={target.path}
|
||||
>
|
||||
<span className="target-option-avatar">
|
||||
{targetInitial(target.name)}
|
||||
</span>
|
||||
<span className="target-option-copy">
|
||||
<span className="target-option-name">{target.name}</span>
|
||||
<span className="target-option-path">
|
||||
{target.exists ? compactPath(target.path) : 'Missing path'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form className="target-add-form" onSubmit={handleAddSubmit}>
|
||||
<input
|
||||
value={newPath}
|
||||
onChange={(event) => setNewPath(event.target.value)}
|
||||
placeholder="/path/to/project"
|
||||
aria-label="Project path"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="target-add-button"
|
||||
disabled={!newPath.trim() || addTarget.isPending}
|
||||
title="Add target"
|
||||
aria-label="Add target"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
{errorMessage && <div className="target-error">{errorMessage}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { data: health } = useHealth();
|
||||
const { data: overview } = useOverview();
|
||||
|
|
@ -112,6 +277,8 @@ export function Sidebar() {
|
|||
<img src="/logo.png" alt="Nyx" className="sidebar-logo-img" />
|
||||
</div>
|
||||
|
||||
<TargetSwitcher scanRoot={health?.scan_root} />
|
||||
|
||||
<ul className="nav-list">
|
||||
{primary.map((item) => (
|
||||
<li key={item.id}>
|
||||
|
|
@ -161,12 +328,6 @@ export function Sidebar() {
|
|||
</div>
|
||||
|
||||
<div className="sidebar-meta">
|
||||
{health?.scan_root && (
|
||||
<div className="sidebar-meta-item" title={health.scan_root}>
|
||||
<FolderIcon />
|
||||
<span>{health.scan_root}</span>
|
||||
</div>
|
||||
)}
|
||||
{health?.version && (
|
||||
<div className="sidebar-meta-item">
|
||||
<TagIcon />
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export function SSEProvider({ children }: { children: ReactNode }) {
|
|||
es.addEventListener('scan_started', () => {
|
||||
setIsScanRunning(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['scans'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['targets'] });
|
||||
});
|
||||
|
||||
es.addEventListener('scan_progress', (e) => {
|
||||
|
|
@ -75,12 +76,14 @@ export function SSEProvider({ children }: { children: ReactNode }) {
|
|||
queryClient.invalidateQueries({ queryKey: ['scans'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['overview'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['findings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['targets'] });
|
||||
});
|
||||
|
||||
es.addEventListener('scan_failed', () => {
|
||||
setScanProgress(null);
|
||||
setIsScanRunning(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['scans'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['targets'] });
|
||||
});
|
||||
|
||||
es.addEventListener('config_changed', () => {
|
||||
|
|
|
|||
|
|
@ -177,6 +177,165 @@ a:hover {
|
|||
color: var(--text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.target-switcher {
|
||||
position: relative;
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
}
|
||||
.target-trigger,
|
||||
.target-option,
|
||||
.target-add-button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.target-trigger {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr) 12px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.target-trigger:hover,
|
||||
.target-trigger[aria-expanded='true'] {
|
||||
border-color: var(--line-strong);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.target-avatar,
|
||||
.target-option-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
font-weight: var(--weight-semibold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.target-trigger-copy,
|
||||
.target-option-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.target-name,
|
||||
.target-option-name {
|
||||
color: var(--text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.target-path,
|
||||
.target-option-path {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.7rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.target-caret {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 1.5px solid var(--text-tertiary);
|
||||
border-bottom: 1.5px solid var(--text-tertiary);
|
||||
transform: rotate(45deg) translateY(-2px);
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
.target-caret.open {
|
||||
transform: rotate(225deg) translateY(-2px);
|
||||
}
|
||||
.target-menu {
|
||||
position: absolute;
|
||||
left: var(--space-3);
|
||||
right: var(--space-3);
|
||||
top: calc(100% - var(--space-1));
|
||||
z-index: 30;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.target-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.target-option {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 5px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.target-option:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.target-option.active {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
.target-option:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.target-option-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.target-add-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 30px;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
.target-add-form input {
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 5px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.target-add-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-size: 1rem;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.target-add-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.target-error {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--sev-high);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
padding: var(--space-3) var(--space-3);
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/queryclient.ts","./src/api/types.ts","./src/api/mutations/baseline.ts","./src/api/mutations/config.ts","./src/api/mutations/rules.ts","./src/api/mutations/scans.ts","./src/api/mutations/triage.ts","./src/api/queries/config.ts","./src/api/queries/debug.ts","./src/api/queries/explorer.ts","./src/api/queries/findings.ts","./src/api/queries/health.ts","./src/api/queries/overview.ts","./src/api/queries/rules.ts","./src/api/queries/scans.ts","./src/api/queries/surface.ts","./src/api/queries/triage.ts","./src/components/copymarkdownbutton.tsx","./src/components/verdictbadge.tsx","./src/components/charts/horizontalbarchart.tsx","./src/components/charts/linechart.tsx","./src/components/data-display/codeviewer.tsx","./src/components/data-display/filetree.tsx","./src/components/explorer/analysisworkspace.tsx","./src/components/icons/icons.tsx","./src/components/layout/applayout.tsx","./src/components/layout/headerbar.tsx","./src/components/layout/sidebar.tsx","./src/components/overview/overviewwidgets.tsx","./src/components/ui/commandpalette.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/emptystate.tsx","./src/components/ui/errorstate.tsx","./src/components/ui/loadingstate.tsx","./src/components/ui/modal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/shortcutshelp.tsx","./src/components/ui/statcard.tsx","./src/components/ui/toaster.tsx","./src/contexts/ssecontext.tsx","./src/contexts/themecontext.tsx","./src/contexts/toastcontext.tsx","./src/graph/styles.ts","./src/graph/types.ts","./src/graph/adapters/callgraph.ts","./src/graph/adapters/cfg.ts","./src/graph/adapters/surface.ts","./src/graph/components/callgraphcanvas.tsx","./src/graph/components/cfggraphcanvas.tsx","./src/graph/components/graphtoolbar.tsx","./src/graph/components/surfacegraphcanvas.tsx","./src/graph/hooks/useelklayout.ts","./src/graph/layout/elk.ts","./src/graph/layout/text.ts","./src/graph/reduction/cfgcompaction.ts","./src/graph/reduction/neighborhood.ts","./src/graph/rendering/sigma/sigmagraph.tsx","./src/graph/rendering/sigma/buildgraph.ts","./src/graph/rendering/sigma/edgeoverlay.ts","./src/hooks/usechordnavigation.ts","./src/hooks/usedebounce.ts","./src/hooks/usefiletree.ts","./src/hooks/usefindingsurlstate.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/usepagetitle.ts","./src/hooks/usepersistedstate.ts","./src/modals/codeviewermodal.tsx","./src/modals/newscanmodal.tsx","./src/pages/configpage.tsx","./src/pages/explorerpage.tsx","./src/pages/findingdetailpage.tsx","./src/pages/findingspage.tsx","./src/pages/overviewpage.tsx","./src/pages/rulespage.tsx","./src/pages/scancomparepage.tsx","./src/pages/scandetailpage.tsx","./src/pages/scanspage.tsx","./src/pages/surfacepage.tsx","./src/pages/triagepage.tsx","./src/pages/debug/abstractinterppage.tsx","./src/pages/debug/authanalysispage.tsx","./src/pages/debug/callgraphpage.tsx","./src/pages/debug/cfgviewerpage.tsx","./src/pages/debug/debuglayout.tsx","./src/pages/debug/functionselector.tsx","./src/pages/debug/pointerviewerpage.tsx","./src/pages/debug/ssaviewerpage.tsx","./src/pages/debug/summaryexplorerpage.tsx","./src/pages/debug/symexpage.tsx","./src/pages/debug/taintviewerpage.tsx","./src/pages/debug/typefactspage.tsx","./src/test/setup.ts","./src/test/api/client.test.ts","./src/test/components/pagination.test.tsx","./src/test/components/statcard.test.tsx","./src/test/components/dynamicverdictsection.test.tsx","./src/test/components/statecomponents.test.tsx","./src/test/components/verdictbadge.test.tsx","./src/test/graph/cfgadapter.test.ts","./src/test/graph/compactgraph.test.ts","./src/test/graph/nodestyles.test.ts","./src/test/graph/surfaceadapter.test.ts","./src/test/hooks/usedebounce.test.ts","./src/test/modals/newscanmodal.test.tsx","./src/test/utils/findingmarkdown.test.ts","./src/test/utils/formatdate.test.ts","./src/test/utils/syntaxhighlight.test.ts","./src/test/utils/truncpath.test.ts","./src/utils/findingmarkdown.ts","./src/utils/formatdate.ts","./src/utils/parsenote.ts","./src/utils/syntaxhighlight.ts","./src/utils/truncpath.ts"],"version":"6.0.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/queryclient.ts","./src/api/types.ts","./src/api/mutations/baseline.ts","./src/api/mutations/config.ts","./src/api/mutations/rules.ts","./src/api/mutations/scans.ts","./src/api/mutations/triage.ts","./src/api/queries/config.ts","./src/api/queries/debug.ts","./src/api/queries/explorer.ts","./src/api/queries/findings.ts","./src/api/queries/health.ts","./src/api/queries/overview.ts","./src/api/queries/rules.ts","./src/api/queries/scans.ts","./src/api/queries/surface.ts","./src/api/queries/targets.ts","./src/api/queries/triage.ts","./src/components/copymarkdownbutton.tsx","./src/components/verdictbadge.tsx","./src/components/charts/horizontalbarchart.tsx","./src/components/charts/linechart.tsx","./src/components/data-display/codeviewer.tsx","./src/components/data-display/filetree.tsx","./src/components/explorer/analysisworkspace.tsx","./src/components/icons/icons.tsx","./src/components/layout/applayout.tsx","./src/components/layout/headerbar.tsx","./src/components/layout/sidebar.tsx","./src/components/overview/overviewwidgets.tsx","./src/components/ui/commandpalette.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/emptystate.tsx","./src/components/ui/errorstate.tsx","./src/components/ui/loadingstate.tsx","./src/components/ui/modal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/shortcutshelp.tsx","./src/components/ui/statcard.tsx","./src/components/ui/toaster.tsx","./src/contexts/ssecontext.tsx","./src/contexts/themecontext.tsx","./src/contexts/toastcontext.tsx","./src/graph/styles.ts","./src/graph/types.ts","./src/graph/adapters/callgraph.ts","./src/graph/adapters/cfg.ts","./src/graph/adapters/surface.ts","./src/graph/components/callgraphcanvas.tsx","./src/graph/components/cfggraphcanvas.tsx","./src/graph/components/graphtoolbar.tsx","./src/graph/components/surfacegraphcanvas.tsx","./src/graph/hooks/useelklayout.ts","./src/graph/layout/elk.ts","./src/graph/layout/text.ts","./src/graph/reduction/cfgcompaction.ts","./src/graph/reduction/neighborhood.ts","./src/graph/rendering/sigma/sigmagraph.tsx","./src/graph/rendering/sigma/buildgraph.ts","./src/graph/rendering/sigma/edgeoverlay.ts","./src/hooks/usechordnavigation.ts","./src/hooks/usedebounce.ts","./src/hooks/usefiletree.ts","./src/hooks/usefindingsurlstate.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/usepagetitle.ts","./src/hooks/usepersistedstate.ts","./src/modals/codeviewermodal.tsx","./src/modals/newscanmodal.tsx","./src/pages/configpage.tsx","./src/pages/explorerpage.tsx","./src/pages/findingdetailpage.tsx","./src/pages/findingspage.tsx","./src/pages/overviewpage.tsx","./src/pages/rulespage.tsx","./src/pages/scancomparepage.tsx","./src/pages/scandetailpage.tsx","./src/pages/scanspage.tsx","./src/pages/surfacepage.tsx","./src/pages/triagepage.tsx","./src/pages/debug/abstractinterppage.tsx","./src/pages/debug/authanalysispage.tsx","./src/pages/debug/callgraphpage.tsx","./src/pages/debug/cfgviewerpage.tsx","./src/pages/debug/debuglayout.tsx","./src/pages/debug/functionselector.tsx","./src/pages/debug/pointerviewerpage.tsx","./src/pages/debug/ssaviewerpage.tsx","./src/pages/debug/summaryexplorerpage.tsx","./src/pages/debug/symexpage.tsx","./src/pages/debug/taintviewerpage.tsx","./src/pages/debug/typefactspage.tsx","./src/test/setup.ts","./src/test/api/client.test.ts","./src/test/components/pagination.test.tsx","./src/test/components/statcard.test.tsx","./src/test/components/dynamicverdictsection.test.tsx","./src/test/components/statecomponents.test.tsx","./src/test/components/verdictbadge.test.tsx","./src/test/graph/cfgadapter.test.ts","./src/test/graph/compactgraph.test.ts","./src/test/graph/nodestyles.test.ts","./src/test/graph/surfaceadapter.test.ts","./src/test/hooks/usedebounce.test.ts","./src/test/modals/newscanmodal.test.tsx","./src/test/utils/findingmarkdown.test.ts","./src/test/utils/formatdate.test.ts","./src/test/utils/syntaxhighlight.test.ts","./src/test/utils/truncpath.test.ts","./src/utils/findingmarkdown.ts","./src/utils/formatdate.ts","./src/utils/parsenote.ts","./src/utils/syntaxhighlight.ts","./src/utils/truncpath.ts"],"version":"6.0.3"}
|
||||
|
|
@ -26,6 +26,11 @@ pub fn handle(
|
|||
IndexAction::Build { path, force } => {
|
||||
let build_path = std::path::Path::new(&path).canonicalize()?;
|
||||
let (project_name, db_path) = get_project_info(&build_path, database_dir)?;
|
||||
let _ = crate::utils::targets::remember_target(
|
||||
database_dir,
|
||||
&build_path,
|
||||
crate::utils::targets::TargetTouch::Seen,
|
||||
);
|
||||
|
||||
if force || !db_path.exists() {
|
||||
build_index(
|
||||
|
|
|
|||
|
|
@ -342,7 +342,8 @@ pub(crate) fn verify_findings_for_scan(
|
|||
.unwrap_or(true);
|
||||
|
||||
let results: Vec<crate::dynamic::report::VerifyResult> = if parallel && diags.len() > 1 {
|
||||
let lane_trace = verbose.then(|| std::sync::Arc::new(crate::dynamic::trace::VerifyTrace::new()));
|
||||
let lane_trace =
|
||||
verbose.then(|| std::sync::Arc::new(crate::dynamic::trace::VerifyTrace::new()));
|
||||
let out = crate::dynamic::runner::WorkerPool::run_in_lanes(
|
||||
&*diags,
|
||||
lane_trace.as_ref(),
|
||||
|
|
@ -554,6 +555,11 @@ pub fn handle(
|
|||
) -> NyxResult<()> {
|
||||
let scan_path = Path::new(path).canonicalize()?;
|
||||
let (project_name, db_path) = get_project_info(&scan_path, database_dir)?;
|
||||
let _ = crate::utils::targets::remember_target(
|
||||
database_dir,
|
||||
&scan_path,
|
||||
crate::utils::targets::TargetTouch::Scanned,
|
||||
);
|
||||
|
||||
// Detect frameworks from project manifests and enrich the config.
|
||||
let config = &{
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
use crate::database::index::Indexer;
|
||||
use crate::errors::NyxResult;
|
||||
use crate::server::app::{AppState, ServerEvent, build_router};
|
||||
use crate::server::jobs::JobManager;
|
||||
use crate::server::security::LocalServerSecurity;
|
||||
use crate::utils::config::Config;
|
||||
use crate::utils::project::get_project_info;
|
||||
use crate::utils::targets::{TargetTouch, remember_target};
|
||||
use console::style;
|
||||
use parking_lot::RwLock;
|
||||
use std::path::Path;
|
||||
|
|
@ -31,18 +30,7 @@ pub fn handle(
|
|||
let rayon_stack_size = config.performance.rayon_thread_stack_size;
|
||||
|
||||
let (event_tx, _) = tokio::sync::broadcast::channel(64);
|
||||
|
||||
// Initialize DB pool for scan persistence
|
||||
let db_pool = {
|
||||
let (_, db_path) = get_project_info(&scan_root, database_dir)?;
|
||||
match Indexer::init(&db_path) {
|
||||
Ok(pool) => Some(pool),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize scan DB: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = remember_target(database_dir, &scan_root, TargetTouch::Seen);
|
||||
|
||||
let addr = socket_addr(&host, port);
|
||||
|
||||
|
|
@ -75,16 +63,17 @@ pub fn handle(
|
|||
let security = LocalServerSecurity::new(local_addr.port());
|
||||
|
||||
let state = AppState {
|
||||
scan_root: scan_root.clone(),
|
||||
scan_root: Arc::new(RwLock::new(scan_root.clone())),
|
||||
config_dir: config_dir.to_path_buf(),
|
||||
database_dir: database_dir.to_path_buf(),
|
||||
security,
|
||||
config: Arc::new(RwLock::new(config.clone())),
|
||||
job_manager: Arc::new(JobManager::new(max_jobs, rayon_stack_size)),
|
||||
event_tx: event_tx.clone(),
|
||||
db_pool,
|
||||
db_pools: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
findings_cache: Arc::new(RwLock::new(None)),
|
||||
};
|
||||
let _ = state.db_pool_for(&scan_root);
|
||||
|
||||
// Invalidate the findings cache whenever a scan finishes so the next
|
||||
// request rebuilds against fresh diags. The next-request rebuild keeps
|
||||
|
|
|
|||
|
|
@ -108,14 +108,7 @@ impl BuildPool for GoPool {
|
|||
}
|
||||
|
||||
let output = base_command(&self.go_bin)
|
||||
.args([
|
||||
"build",
|
||||
"-trimpath",
|
||||
"-buildvcs=false",
|
||||
"-o",
|
||||
&dest,
|
||||
".",
|
||||
])
|
||||
.args(["build", "-trimpath", "-buildvcs=false", "-o", &dest, "."])
|
||||
.current_dir(workdir)
|
||||
.env("GOCACHE", &go_cache)
|
||||
.env("GOPATH", &go_path)
|
||||
|
|
|
|||
|
|
@ -84,8 +84,7 @@ impl BuildPool for RustPool {
|
|||
.current_dir(workdir)
|
||||
.env(
|
||||
"CARGO_HOME",
|
||||
std::env::var("CARGO_HOME")
|
||||
.unwrap_or_else(|_| default_cargo_home()),
|
||||
std::env::var("CARGO_HOME").unwrap_or_else(|_| default_cargo_home()),
|
||||
)
|
||||
.env(
|
||||
"RUSTUP_HOME",
|
||||
|
|
|
|||
|
|
@ -640,7 +640,10 @@ mod tests {
|
|||
let dst = tmp.path().join("clone");
|
||||
copy_workdir(&src, &dst).unwrap();
|
||||
assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"top");
|
||||
assert_eq!(fs::read(dst.join("nested").join("deep.txt")).unwrap(), b"deep");
|
||||
assert_eq!(
|
||||
fs::read(dst.join("nested").join("deep.txt")).unwrap(),
|
||||
b"deep"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
|
@ -655,7 +658,10 @@ mod tests {
|
|||
copy_workdir(&src, &dst).unwrap();
|
||||
let link = dst.join("link.txt");
|
||||
assert!(
|
||||
fs::symlink_metadata(&link).unwrap().file_type().is_symlink(),
|
||||
fs::symlink_metadata(&link)
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink(),
|
||||
"internal symlink must be preserved, not dereferenced"
|
||||
);
|
||||
assert_eq!(fs::read(&link).unwrap(), b"real");
|
||||
|
|
|
|||
|
|
@ -827,8 +827,7 @@ impl WorkerPool {
|
|||
.collect();
|
||||
}
|
||||
|
||||
let results: Vec<Mutex<Option<O>>> =
|
||||
(0..items.len()).map(|_| Mutex::new(None)).collect();
|
||||
let results: Vec<Mutex<Option<O>>> = (0..items.len()).map(|_| Mutex::new(None)).collect();
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
let results = &results;
|
||||
|
|
|
|||
|
|
@ -147,10 +147,10 @@ fn bind_mount_ro(src: &Path, dst: &Path) -> io::Result<()> {
|
|||
const MS_REC: u64 = 0x4000;
|
||||
|
||||
fs::create_dir_all(dst)?;
|
||||
let csrc =
|
||||
CString::new(src.as_os_str().as_bytes()).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
let cdst =
|
||||
CString::new(dst.as_os_str().as_bytes()).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
let csrc = CString::new(src.as_os_str().as_bytes())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
let cdst = CString::new(dst.as_os_str().as_bytes())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
|
||||
let bind = unsafe {
|
||||
mount(
|
||||
|
|
@ -240,7 +240,11 @@ mod tests {
|
|||
// Snapshot it into a fresh per-finding workdir.
|
||||
let workdir = tempfile::TempDir::new().unwrap();
|
||||
baseline.snapshot_into(workdir.path()).unwrap();
|
||||
let cloned = workdir.path().join("node_modules").join("left-pad").join("index.js");
|
||||
let cloned = workdir
|
||||
.path()
|
||||
.join("node_modules")
|
||||
.join("left-pad")
|
||||
.join("index.js");
|
||||
assert!(cloned.exists(), "snapshot must materialise node_modules");
|
||||
assert_eq!(fs::read(&cloned).unwrap(), b"module.exports = 1;\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,6 +200,84 @@ fn default_derivation_strategy() -> SpecDerivationStrategy {
|
|||
SpecDerivationStrategy::FromFlowSteps
|
||||
}
|
||||
|
||||
/// Phase 25 (Track K.0) — the optional cross-file context consulted by the
|
||||
/// multi-strategy scoring derivation.
|
||||
///
|
||||
/// Bundles the three inputs every scored strategy and the cross-file source
|
||||
/// seeding read, so the public [`HarnessSpec::derive_best`] /
|
||||
/// [`HarnessSpec::derive_all_strategies`] surface takes one borrowable
|
||||
/// context rather than three positional `Option`s. Cheap to copy (two
|
||||
/// references + a bool).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SpecDerivationCtx<'a> {
|
||||
/// When true, skip the `Confidence >= Medium` gate so low-confidence
|
||||
/// findings are still attempted.
|
||||
pub verify_all_confidence: bool,
|
||||
/// Cross-file function summaries (`FuncSummary` + `SsaFuncSummary`),
|
||||
/// shared by every finding in a scan.
|
||||
pub summaries: Option<&'a GlobalSummaries>,
|
||||
/// Whole-program call graph used for reverse-edge entry resolution and
|
||||
/// cross-file source seeding.
|
||||
pub callgraph: Option<&'a CallGraph>,
|
||||
}
|
||||
|
||||
impl<'a> SpecDerivationCtx<'a> {
|
||||
/// Construct a context from the three positional inputs the legacy
|
||||
/// `from_finding_*` constructors take.
|
||||
pub fn new(
|
||||
verify_all_confidence: bool,
|
||||
summaries: Option<&'a GlobalSummaries>,
|
||||
callgraph: Option<&'a CallGraph>,
|
||||
) -> Self {
|
||||
Self {
|
||||
verify_all_confidence,
|
||||
summaries,
|
||||
callgraph,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 25 (Track K.0) — one scored derivation candidate.
|
||||
///
|
||||
/// Produced by [`HarnessSpec::derive_all_strategies`]; carries both the
|
||||
/// built [`HarnessSpec`] and the [`SpecDerivationStrategy`] that produced
|
||||
/// it. The strategy tag is retained alongside `spec.derivation` (which
|
||||
/// holds the same value) so the loser-ranking telemetry can report the tag
|
||||
/// without unwrapping the spec.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpecCandidate {
|
||||
/// The derived harness recipe.
|
||||
pub spec: HarnessSpec,
|
||||
/// Which strategy produced [`Self::spec`].
|
||||
pub strategy: SpecDerivationStrategy,
|
||||
}
|
||||
|
||||
/// Phase 25 (Track K.0) — lexicographic score for a candidate spec.
|
||||
///
|
||||
/// Field declaration order *is* the comparison priority: the derived
|
||||
/// [`Ord`] compares `flow_depth` first, then `framework_bound`, then
|
||||
/// `cross_file_resolved`, then `payloads_available`. Higher is better, so
|
||||
/// [`HarnessSpec::derive_best`] picks the candidate whose score is the
|
||||
/// maximum. `bool` orders `false < true`, so a framework-bound /
|
||||
/// cross-file-resolved / payload-backed candidate outscores one that is
|
||||
/// not, all else equal.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct SpecScore {
|
||||
/// Flow-step depth the spec covers: `evidence.flow_steps.len()` plus a
|
||||
/// hop when the entry was rewritten to an ancestor function (the
|
||||
/// callgraph-walk strategies cover more of the call chain than the
|
||||
/// helper that physically contains the sink).
|
||||
pub flow_depth: u32,
|
||||
/// A [`FrameworkBinding`] was attached to the spec.
|
||||
pub framework_bound: bool,
|
||||
/// The spec's entry resolves to a different file than the sink — the
|
||||
/// source was recovered across a file boundary.
|
||||
pub cross_file_resolved: bool,
|
||||
/// The `(expected_cap, lang)` pair has at least one curated payload, so
|
||||
/// the verifier has something to fire.
|
||||
pub payloads_available: bool,
|
||||
}
|
||||
|
||||
impl HarnessSpec {
|
||||
/// Build a spec from a finding. Returns `Err` with a typed reason when
|
||||
/// the finding cannot be driven dynamically.
|
||||
|
|
@ -291,51 +369,15 @@ impl HarnessSpec {
|
|||
summaries: Option<&GlobalSummaries>,
|
||||
callgraph: Option<&CallGraph>,
|
||||
) -> Result<Self, UnsupportedReason> {
|
||||
if !verify_all_confidence {
|
||||
match diag.confidence {
|
||||
Some(c) if c >= Confidence::Medium => {}
|
||||
_ => return Err(UnsupportedReason::ConfidenceTooLow),
|
||||
}
|
||||
}
|
||||
|
||||
let evidence = diag
|
||||
.evidence
|
||||
.as_ref()
|
||||
.ok_or(UnsupportedReason::NoFlowSteps)?;
|
||||
|
||||
// Phase 04 pre-step: when both callgraph *and* summaries are
|
||||
// present, walk reverse edges to a framework-bound ancestor.
|
||||
// Takes precedence over the four-strategy ladder because a route
|
||||
// handler / CLI entry is always a stronger driving anchor than
|
||||
// the helper function that physically contains the sink.
|
||||
//
|
||||
// Strict variant: only the reverse-edge BFS (`find_entry_via_callgraph`)
|
||||
// counts here. The summary-entry-kind + rule-id substring fallbacks
|
||||
// that live in `derive_from_callgraph_entry_full` stay at strategy-4
|
||||
// priority — calling them here would short-circuit the more precise
|
||||
// strategies (FromFlowSteps / FromRuleNamespace / FromFuncSummaryAuto)
|
||||
// whenever the rule id happens to contain `.http.` / `.cli.`.
|
||||
if let (Some(s), Some(cg)) = (summaries, callgraph)
|
||||
&& let Some(spec) = derive_from_callgraph_walk_only(diag, evidence, s, cg)
|
||||
{
|
||||
return Ok(spec);
|
||||
}
|
||||
|
||||
// Try each strategy in priority order; first non-None wins.
|
||||
if let Some(spec) = derive_from_flow_steps(diag, evidence, summaries) {
|
||||
return Ok(spec);
|
||||
}
|
||||
if let Some(spec) = derive_from_rule_namespace_with(diag, evidence, summaries) {
|
||||
return Ok(spec);
|
||||
}
|
||||
if let Some(spec) = derive_from_func_summary_auto(diag, evidence, summaries) {
|
||||
return Ok(spec);
|
||||
}
|
||||
if let Some(spec) = derive_from_callgraph_entry_full(diag, evidence, summaries, callgraph) {
|
||||
return Ok(spec);
|
||||
}
|
||||
|
||||
Err(UnsupportedReason::SpecDerivationFailed)
|
||||
// Phase 25 (Track K.0): the legacy sequential first-match ladder is
|
||||
// now a thin wrapper over the multi-strategy scoring path. Every
|
||||
// strategy this method used to try in priority order is still run by
|
||||
// `derive_all_strategies`; `derive_best` scores them and the
|
||||
// ascending-precedence ordering reproduces the old tie-break
|
||||
// (strict callgraph walk > flow_steps > rule_namespace >
|
||||
// func_summary > callgraph fallback) when scores are equal.
|
||||
let ctx = SpecDerivationCtx::new(verify_all_confidence, summaries, callgraph);
|
||||
Self::derive_best(diag, &ctx)
|
||||
}
|
||||
|
||||
/// Convenience wrapper around [`HarnessSpec::from_finding_full`] that
|
||||
|
|
@ -388,6 +430,133 @@ impl HarnessSpec {
|
|||
SpecDerivationStrategy::FromCallgraphEntry,
|
||||
]
|
||||
}
|
||||
|
||||
/// Phase 25 (Track K.0) — run *every* derivation strategy and score each
|
||||
/// resulting candidate.
|
||||
///
|
||||
/// Unlike the legacy sequential first-match ladder, this evaluates all
|
||||
/// strategies that fire for the finding and returns each as a
|
||||
/// `(SpecCandidate, SpecScore)` pair. The caller
|
||||
/// ([`Self::derive_best_ranked`]) picks the maximum-scoring candidate.
|
||||
///
|
||||
/// Candidates are returned in *ascending precedence* order (lowest-priority
|
||||
/// strategy first). This is load-bearing: [`SpecScore`] is intentionally
|
||||
/// coarse and genuine ties are common (e.g. two strategies that both name
|
||||
/// the sink's own enclosing function as the entry). When scores tie, the
|
||||
/// winner-selection in [`Self::derive_best_ranked`] keeps the *last*
|
||||
/// maximal element, so ascending precedence here reproduces the legacy
|
||||
/// ladder's tie-break (flow-steps beats rule-namespace beats
|
||||
/// func-summary, and the strict callgraph walk beats every other
|
||||
/// strategy) without baking strategy rank into the score itself.
|
||||
///
|
||||
/// Returns an empty `Vec` when the finding carries no evidence or no
|
||||
/// strategy fires.
|
||||
pub fn derive_all_strategies(
|
||||
diag: &Diag,
|
||||
ctx: &SpecDerivationCtx,
|
||||
) -> Vec<(SpecCandidate, SpecScore)> {
|
||||
let Some(evidence) = diag.evidence.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let summaries = ctx.summaries;
|
||||
let callgraph = ctx.callgraph;
|
||||
|
||||
// Build raw candidates in ascending precedence (lowest first). The
|
||||
// two callgraph entries mirror the legacy two call sites: the
|
||||
// `*_full` variant carries the low-precedence summary-kind / rule-id
|
||||
// fallback, the `*_walk_only` and cross-file-seed variants are the
|
||||
// high-precedence reverse-edge walks.
|
||||
let mut raw: Vec<(HarnessSpec, SpecDerivationStrategy)> = Vec::new();
|
||||
if let Some(spec) = derive_from_callgraph_entry_full(diag, evidence, summaries, callgraph) {
|
||||
raw.push((spec, SpecDerivationStrategy::FromCallgraphEntry));
|
||||
}
|
||||
if let Some(spec) = derive_from_func_summary_auto(diag, evidence, summaries) {
|
||||
raw.push((spec, SpecDerivationStrategy::FromFuncSummaryWalk));
|
||||
}
|
||||
if let Some(spec) = derive_from_rule_namespace_with(diag, evidence, summaries) {
|
||||
raw.push((spec, SpecDerivationStrategy::FromRuleNamespace));
|
||||
}
|
||||
if let Some(spec) = derive_from_flow_steps(diag, evidence, summaries) {
|
||||
raw.push((spec, SpecDerivationStrategy::FromFlowSteps));
|
||||
}
|
||||
if let (Some(s), Some(cg)) = (summaries, callgraph) {
|
||||
if let Some(spec) = derive_from_callgraph_walk_only(diag, evidence, s, cg) {
|
||||
raw.push((spec, SpecDerivationStrategy::FromCallgraphEntry));
|
||||
}
|
||||
if let Some(spec) = derive_from_cross_file_seed(diag, evidence, s, cg) {
|
||||
raw.push((spec, SpecDerivationStrategy::FromCallgraphEntry));
|
||||
}
|
||||
}
|
||||
|
||||
let sink_file = sink_file_of(diag, evidence);
|
||||
raw.into_iter()
|
||||
.map(|(spec, strategy)| {
|
||||
let score = score_candidate(&spec, evidence, &sink_file);
|
||||
(SpecCandidate { spec, strategy }, score)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Phase 25 (Track K.0) — derive the single best spec for a finding.
|
||||
///
|
||||
/// Runs [`Self::derive_all_strategies`] and returns the maximum-scoring
|
||||
/// candidate's spec. The error contract matches the legacy
|
||||
/// [`Self::from_finding_full`]:
|
||||
/// - `Err(UnsupportedReason::ConfidenceTooLow)` when the confidence gate
|
||||
/// fails (and `ctx.verify_all_confidence` is false),
|
||||
/// - `Err(UnsupportedReason::NoFlowSteps)` when the finding carries no
|
||||
/// `Evidence` at all,
|
||||
/// - `Err(UnsupportedReason::SpecDerivationFailed)` when evidence is
|
||||
/// present but no strategy fired.
|
||||
pub fn derive_best(diag: &Diag, ctx: &SpecDerivationCtx) -> Result<Self, UnsupportedReason> {
|
||||
Self::derive_best_ranked(diag, ctx).map(|(spec, _runners_up)| spec)
|
||||
}
|
||||
|
||||
/// Phase 25 (Track K.0) — like [`Self::derive_best`] but also returns the
|
||||
/// loser ranking for telemetry.
|
||||
///
|
||||
/// The second tuple element lists every non-winning candidate's
|
||||
/// `(strategy, score)` in descending score order, so the verifier can
|
||||
/// emit a [`crate::dynamic::trace::TraceStage::SpecScoringResult`] event
|
||||
/// that makes engine gaps visible (which strategies fired, how they
|
||||
/// scored, and which one lost the tie-break).
|
||||
pub fn derive_best_ranked(
|
||||
diag: &Diag,
|
||||
ctx: &SpecDerivationCtx,
|
||||
) -> Result<(Self, Vec<(SpecDerivationStrategy, SpecScore)>), UnsupportedReason> {
|
||||
if !ctx.verify_all_confidence {
|
||||
match diag.confidence {
|
||||
Some(c) if c >= Confidence::Medium => {}
|
||||
_ => return Err(UnsupportedReason::ConfidenceTooLow),
|
||||
}
|
||||
}
|
||||
// Distinguish "no evidence at all" (NoFlowSteps) from "evidence
|
||||
// present but no strategy fired" (SpecDerivationFailed) — the
|
||||
// verifier lifts only the latter to `Inconclusive`.
|
||||
if diag.evidence.is_none() {
|
||||
return Err(UnsupportedReason::NoFlowSteps);
|
||||
}
|
||||
|
||||
let mut scored = Self::derive_all_strategies(diag, ctx);
|
||||
if scored.is_empty() {
|
||||
return Err(UnsupportedReason::SpecDerivationFailed);
|
||||
}
|
||||
|
||||
// Stable sort by score ascending. `derive_all_strategies` returns
|
||||
// candidates in ascending precedence, and a stable sort preserves
|
||||
// that order within equal scores — so the final element is the
|
||||
// highest-scoring candidate, and on a score tie it is the
|
||||
// highest-precedence one (legacy ladder tie-break).
|
||||
scored.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
let (winner, _winner_score) = scored.pop().expect("non-empty checked above");
|
||||
let mut runners_up: Vec<(SpecDerivationStrategy, SpecScore)> = scored
|
||||
.into_iter()
|
||||
.map(|(cand, score)| (cand.strategy, score))
|
||||
.collect();
|
||||
// Report losers best-first.
|
||||
runners_up.reverse();
|
||||
Ok((winner.spec, runners_up))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strategy 1: from flow_steps (original path) ──────────────────────────────
|
||||
|
|
@ -962,6 +1131,201 @@ fn entry_kind_from_summary(_kind: &crate::entry_points::EntryKind) -> EntryKind
|
|||
EntryKind::HttpRoute
|
||||
}
|
||||
|
||||
// ── Phase 25 (Track K.0): multi-strategy scoring + cross-file seeding ────────
|
||||
|
||||
/// Maximum reverse-edge hops the cross-file source seeding walks before
|
||||
/// giving up. Bounds the BFS so a deep call chain cannot stall derivation;
|
||||
/// the [`crate::dynamic::spec`] Phase 25 spec fixes this at 5.
|
||||
const CROSS_FILE_SEED_MAX_DEPTH: usize = 5;
|
||||
|
||||
/// The sink call-site's file: the last `Sink` flow step, falling back to the
|
||||
/// diag's own path. Used by [`score_candidate`] to decide whether a
|
||||
/// candidate's entry was resolved across a file boundary.
|
||||
fn sink_file_of(diag: &Diag, evidence: &crate::evidence::Evidence) -> String {
|
||||
evidence
|
||||
.flow_steps
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| matches!(s.kind, FlowStepKind::Sink))
|
||||
.map(|s| s.file.clone())
|
||||
.unwrap_or_else(|| diag.path.clone())
|
||||
}
|
||||
|
||||
/// Flow-step depth a candidate covers.
|
||||
///
|
||||
/// Base is `evidence.flow_steps.len()`. A candidate whose entry was
|
||||
/// rewritten to a *different* function than the sink's enclosing function
|
||||
/// (i.e. one of the callgraph-walk strategies climbed the call chain to a
|
||||
/// route handler / source ancestor) earns a `+1` hop bonus, so it scores
|
||||
/// strictly above the strategies that merely name the sink's own enclosing
|
||||
/// helper as the entry. This is what lets a successful reverse-edge walk
|
||||
/// win the [`SpecScore`] comparison without baking strategy rank into the
|
||||
/// score.
|
||||
fn candidate_flow_depth(spec: &HarnessSpec, evidence: &crate::evidence::Evidence) -> u32 {
|
||||
let base = evidence.flow_steps.len() as u32;
|
||||
let hop = match enclosing_function_from_flow_steps(evidence) {
|
||||
Some(ref f) if !f.is_empty() && *f != spec.entry_name => 1,
|
||||
_ => 0,
|
||||
};
|
||||
base + hop
|
||||
}
|
||||
|
||||
/// True when the `(cap, lang)` pair has at least one curated payload to fire.
|
||||
///
|
||||
/// `expected_cap` may carry several bits; a direct multi-bit lookup misses
|
||||
/// (the corpus is keyed by single caps), so on a miss we test each set bit
|
||||
/// individually.
|
||||
fn candidate_has_payloads(cap: Cap, lang: Lang) -> bool {
|
||||
use crate::dynamic::corpus::registry::payloads_for_lang;
|
||||
if !payloads_for_lang(cap, lang).is_empty() {
|
||||
return true;
|
||||
}
|
||||
cap.iter()
|
||||
.any(|bit| !payloads_for_lang(bit, lang).is_empty())
|
||||
}
|
||||
|
||||
/// Score a single candidate spec on the four Phase 25 axes.
|
||||
fn score_candidate(
|
||||
spec: &HarnessSpec,
|
||||
evidence: &crate::evidence::Evidence,
|
||||
sink_file: &str,
|
||||
) -> SpecScore {
|
||||
SpecScore {
|
||||
flow_depth: candidate_flow_depth(spec, evidence),
|
||||
framework_bound: spec.framework.is_some(),
|
||||
cross_file_resolved: !sink_file.is_empty()
|
||||
&& !spec.entry_file.is_empty()
|
||||
&& spec.entry_file != sink_file,
|
||||
payloads_available: candidate_has_payloads(spec.expected_cap, spec.lang),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 25 (Track K.0) deliverable 4 — cross-file source seeding.
|
||||
///
|
||||
/// Walks reverse call-graph edges from the sink's enclosing function,
|
||||
/// consulting [`GlobalSummaries::get_ssa`] (the `ssa_by_key` index) at each
|
||||
/// ancestor, until it finds either:
|
||||
/// * a **Source** — an ancestor whose [`crate::summary::ssa_summary::SsaFuncSummary::source_caps`]
|
||||
/// is non-empty, i.e. it introduces externally-controlled input, or
|
||||
/// * a **framework binding** — an ancestor that satisfies [`is_entry_point`].
|
||||
///
|
||||
/// Bounded at [`CROSS_FILE_SEED_MAX_DEPTH`] reverse hops. Unlike
|
||||
/// [`find_entry_via_callgraph`], which stops only at framework entry points,
|
||||
/// this also stops at SSA-confirmed sources — so it recovers a drivable
|
||||
/// entry for findings whose taint originates in a cross-file helper that
|
||||
/// reads input but is not itself a route handler. That additional reach is
|
||||
/// the lever Phase 25 pulls to cut the `Inconclusive(SpecDerivationFailed)`
|
||||
/// rate.
|
||||
fn seed_cross_file_source<'a>(
|
||||
diag: &Diag,
|
||||
evidence: &crate::evidence::Evidence,
|
||||
summaries: &'a GlobalSummaries,
|
||||
callgraph: &CallGraph,
|
||||
lang: Lang,
|
||||
) -> Option<EntryHit<'a>> {
|
||||
let enclosing = enclosing_function_from_flow_steps(evidence)
|
||||
.or_else(|| resolve_enclosing_function(diag, evidence, Some(summaries), lang))?;
|
||||
let sink_key = summaries
|
||||
.iter()
|
||||
.find(|(k, s)| {
|
||||
k.lang == lang && s.name == enclosing && paths_match(&s.file_path, &diag.path)
|
||||
})
|
||||
.map(|(k, _)| k.clone())?;
|
||||
let start = *callgraph.index.get(&sink_key)?;
|
||||
|
||||
let mut visited: HashSet<petgraph::graph::NodeIndex> = HashSet::new();
|
||||
visited.insert(start);
|
||||
let mut frontier: Vec<petgraph::graph::NodeIndex> = vec![start];
|
||||
for _ in 0..CROSS_FILE_SEED_MAX_DEPTH {
|
||||
let mut next: Vec<petgraph::graph::NodeIndex> = Vec::new();
|
||||
for node in frontier.drain(..) {
|
||||
for caller in callgraph
|
||||
.graph
|
||||
.neighbors_directed(node, petgraph::Direction::Incoming)
|
||||
{
|
||||
if !visited.insert(caller) {
|
||||
continue;
|
||||
}
|
||||
let caller_key = &callgraph.graph[caller];
|
||||
let summary = summaries.get(caller_key);
|
||||
let is_source = summaries
|
||||
.get_ssa(caller_key)
|
||||
.is_some_and(|ssa| !ssa.source_caps.is_empty());
|
||||
let is_framework = summary.is_some_and(|s| is_entry_point(s, callgraph));
|
||||
if (is_source || is_framework)
|
||||
&& let Some(s) = summary
|
||||
{
|
||||
return Some(EntryHit {
|
||||
key: caller_key.clone(),
|
||||
summary: s,
|
||||
});
|
||||
}
|
||||
next.push(caller);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
if frontier.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Strategy candidate built from [`seed_cross_file_source`].
|
||||
///
|
||||
/// Rewrites the spec's entry to the cross-file Source / framework ancestor
|
||||
/// the seed walk resolved, classifying its [`EntryKind`] from the ancestor's
|
||||
/// summary (HTTP-shaped static entry kinds → [`EntryKind::HttpRoute`], else
|
||||
/// name-based). Tagged [`SpecDerivationStrategy::FromCallgraphEntry`] — it
|
||||
/// is a reverse-edge call-graph walk, like the other two callgraph
|
||||
/// candidates — and emitted at the highest precedence in
|
||||
/// [`HarnessSpec::derive_all_strategies`].
|
||||
fn derive_from_cross_file_seed(
|
||||
diag: &Diag,
|
||||
evidence: &crate::evidence::Evidence,
|
||||
summaries: &GlobalSummaries,
|
||||
callgraph: &CallGraph,
|
||||
) -> Option<HarnessSpec> {
|
||||
let lang = lang_from_path(&diag.path)?;
|
||||
let expected_cap = Cap::from_bits_truncate(evidence.sink_caps);
|
||||
if expected_cap.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let found = seed_cross_file_source(diag, evidence, summaries, callgraph, lang)?;
|
||||
let entry_kind = found
|
||||
.summary
|
||||
.entry_kind
|
||||
.as_ref()
|
||||
.map(entry_kind_from_summary)
|
||||
.unwrap_or_else(|| name_to_entry_kind(&found.summary.name));
|
||||
let entry_file = if !found.summary.file_path.is_empty() {
|
||||
found.summary.file_path.clone()
|
||||
} else {
|
||||
diag.path.clone()
|
||||
};
|
||||
let (sink_file, sink_line) = evidence
|
||||
.flow_steps
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| matches!(s.kind, FlowStepKind::Sink))
|
||||
.map(|s| (s.file.clone(), s.line))
|
||||
.unwrap_or_else(|| (diag.path.clone(), diag.line as u32));
|
||||
let mut spec = finalize_spec(
|
||||
diag,
|
||||
entry_file,
|
||||
found.summary.name.clone(),
|
||||
lang,
|
||||
expected_cap,
|
||||
sink_file,
|
||||
sink_line,
|
||||
SpecDerivationStrategy::FromCallgraphEntry,
|
||||
Some(summaries),
|
||||
);
|
||||
spec.entry_kind = entry_kind;
|
||||
spec.spec_hash = compute_spec_hash(&spec);
|
||||
Some(spec)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the language for a finding path using extension first, then a
|
||||
|
|
@ -2573,4 +2937,250 @@ mod tests {
|
|||
assert_eq!(spec.spec_hash, pre_hash);
|
||||
assert!(spec.framework.is_some());
|
||||
}
|
||||
|
||||
// ── Phase 25 (Track K.0): multi-strategy scoring + cross-file seeding ────
|
||||
|
||||
#[test]
|
||||
fn spec_score_orders_lexicographically() {
|
||||
// `flow_depth` dominates every lower-priority axis.
|
||||
let deep = SpecScore {
|
||||
flow_depth: 3,
|
||||
framework_bound: false,
|
||||
cross_file_resolved: false,
|
||||
payloads_available: false,
|
||||
};
|
||||
let shallow_but_rich = SpecScore {
|
||||
flow_depth: 2,
|
||||
framework_bound: true,
|
||||
cross_file_resolved: true,
|
||||
payloads_available: true,
|
||||
};
|
||||
assert!(deep > shallow_but_rich);
|
||||
|
||||
// Equal `flow_depth`: `framework_bound` breaks the tie.
|
||||
let fw = SpecScore {
|
||||
flow_depth: 2,
|
||||
framework_bound: true,
|
||||
cross_file_resolved: false,
|
||||
payloads_available: false,
|
||||
};
|
||||
let no_fw = SpecScore {
|
||||
flow_depth: 2,
|
||||
framework_bound: false,
|
||||
cross_file_resolved: true,
|
||||
payloads_available: true,
|
||||
};
|
||||
assert!(fw > no_fw);
|
||||
|
||||
// Equal `flow_depth` + `framework_bound`: `cross_file_resolved` wins.
|
||||
let xfile = SpecScore {
|
||||
flow_depth: 1,
|
||||
framework_bound: false,
|
||||
cross_file_resolved: true,
|
||||
payloads_available: false,
|
||||
};
|
||||
let no_xfile = SpecScore {
|
||||
flow_depth: 1,
|
||||
framework_bound: false,
|
||||
cross_file_resolved: false,
|
||||
payloads_available: true,
|
||||
};
|
||||
assert!(xfile > no_xfile);
|
||||
|
||||
// Only `payloads_available` differs: it is the final tie-breaker.
|
||||
let with_payloads = SpecScore {
|
||||
flow_depth: 1,
|
||||
framework_bound: false,
|
||||
cross_file_resolved: false,
|
||||
payloads_available: true,
|
||||
};
|
||||
let without = SpecScore {
|
||||
flow_depth: 1,
|
||||
framework_bound: false,
|
||||
cross_file_resolved: false,
|
||||
payloads_available: false,
|
||||
};
|
||||
assert!(with_payloads > without);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_all_strategies_empty_without_evidence() {
|
||||
// No `Evidence` struct at all → no strategy has anything to derive
|
||||
// from, so the candidate set is empty (and `derive_best_ranked`
|
||||
// lifts this to `NoFlowSteps`, exercised separately).
|
||||
let diag = crate::commands::scan::Diag {
|
||||
confidence: Some(Confidence::High),
|
||||
evidence: None,
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = SpecDerivationCtx::new(false, None, None);
|
||||
assert!(HarnessSpec::derive_all_strategies(&diag, &ctx).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_best_ranked_reports_runner_up_strategies() {
|
||||
use crate::labels::Cap;
|
||||
// A finding both the flow-steps and rule-namespace strategies can
|
||||
// drive: identical entry → identical score → flow_steps wins the
|
||||
// precedence tie-break, and rule_namespace is reported as a loser.
|
||||
let evidence = Evidence {
|
||||
flow_steps: vec![
|
||||
source_step("src/handler.py", "handle_request"),
|
||||
sink_step("src/handler.py"),
|
||||
],
|
||||
sink_caps: Cap::SHELL_ESCAPE.bits(),
|
||||
..Default::default()
|
||||
};
|
||||
let diag = crate::commands::scan::Diag {
|
||||
id: "py.cmdi.os_system".into(),
|
||||
confidence: Some(Confidence::High),
|
||||
evidence: Some(evidence),
|
||||
path: "src/handler.py".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = SpecDerivationCtx::new(false, None, None);
|
||||
let (spec, runners_up) = HarnessSpec::derive_best_ranked(&diag, &ctx).unwrap();
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromFlowSteps);
|
||||
assert!(
|
||||
runners_up
|
||||
.iter()
|
||||
.any(|(s, _)| *s == SpecDerivationStrategy::FromRuleNamespace),
|
||||
"rule-namespace strategy must appear in the runner-up ranking, got {runners_up:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_cross_file_source_stops_at_cross_file_source() {
|
||||
use crate::labels::Cap;
|
||||
use crate::summary::CalleeSite;
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
use crate::symbol::FuncKey;
|
||||
|
||||
let mut gs = GlobalSummaries::new();
|
||||
|
||||
// Sink helper in db.rs — contains the dangerous call, no callees.
|
||||
let run_query = build_summary(
|
||||
"run_query",
|
||||
"src/db.rs",
|
||||
"rust",
|
||||
Cap::SHELL_ESCAPE.bits(),
|
||||
vec![0],
|
||||
None,
|
||||
);
|
||||
let run_query_key = FuncKey::new_function(Lang::Rust, "src/db.rs", "run_query", Some(1));
|
||||
gs.insert(run_query_key, run_query);
|
||||
|
||||
// Source ancestor in input.rs — reads external input, calls run_query.
|
||||
let mut read_input = build_summary("read_input", "src/input.rs", "rust", 0, vec![], None);
|
||||
read_input.callees = vec![CalleeSite::bare("run_query")];
|
||||
let read_input_key =
|
||||
FuncKey::new_function(Lang::Rust, "src/input.rs", "read_input", Some(1));
|
||||
gs.insert(read_input_key.clone(), read_input);
|
||||
// SSA summary marks read_input a Source (non-empty source_caps) —
|
||||
// the signal `seed_cross_file_source` stops on.
|
||||
gs.insert_ssa(
|
||||
read_input_key,
|
||||
SsaFuncSummary {
|
||||
source_caps: Cap::SHELL_ESCAPE,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
// A caller of read_input gives it in-degree 1, so the
|
||||
// `is_entry_point` zero-caller heuristic does NOT fire — proving the
|
||||
// walk stops because read_input is a SOURCE, not a framework entry.
|
||||
let mut dispatch = build_summary("dispatch", "src/main.rs", "rust", 0, vec![], None);
|
||||
dispatch.callees = vec![CalleeSite::bare("read_input")];
|
||||
let dispatch_key = FuncKey::new_function(Lang::Rust, "src/main.rs", "dispatch", Some(1));
|
||||
gs.insert(dispatch_key, dispatch);
|
||||
|
||||
let cg = crate::callgraph::build_call_graph(&gs, &[]);
|
||||
|
||||
let ev = Evidence {
|
||||
flow_steps: vec![sink_only_step_with_function("src/db.rs", "run_query")],
|
||||
sink_caps: Cap::SHELL_ESCAPE.bits(),
|
||||
..Default::default()
|
||||
};
|
||||
let diag = crate::commands::scan::Diag {
|
||||
id: "rust.cmdi.command".into(),
|
||||
path: "src/db.rs".into(),
|
||||
line: 6,
|
||||
confidence: Some(Confidence::High),
|
||||
evidence: Some(ev.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let hit = seed_cross_file_source(&diag, &ev, &gs, &cg, Lang::Rust)
|
||||
.expect("reverse walk must reach the cross-file source ancestor");
|
||||
assert_eq!(hit.summary.name, "read_input");
|
||||
assert_eq!(hit.summary.file_path, "src/input.rs");
|
||||
// read_input must not itself be a framework entry point — confirming
|
||||
// the stop was on the source condition.
|
||||
assert!(!is_entry_point(hit.summary, &cg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_from_cross_file_seed_rewrites_entry_across_file_boundary() {
|
||||
use crate::labels::Cap;
|
||||
use crate::summary::CalleeSite;
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
use crate::symbol::FuncKey;
|
||||
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let run_query = build_summary(
|
||||
"run_query",
|
||||
"src/db.rs",
|
||||
"rust",
|
||||
Cap::SHELL_ESCAPE.bits(),
|
||||
vec![0],
|
||||
None,
|
||||
);
|
||||
gs.insert(
|
||||
FuncKey::new_function(Lang::Rust, "src/db.rs", "run_query", Some(1)),
|
||||
run_query,
|
||||
);
|
||||
|
||||
let mut read_input = build_summary("read_input", "src/input.rs", "rust", 0, vec![], None);
|
||||
read_input.callees = vec![CalleeSite::bare("run_query")];
|
||||
let read_input_key =
|
||||
FuncKey::new_function(Lang::Rust, "src/input.rs", "read_input", Some(1));
|
||||
gs.insert(read_input_key.clone(), read_input);
|
||||
gs.insert_ssa(
|
||||
read_input_key,
|
||||
SsaFuncSummary {
|
||||
source_caps: Cap::SHELL_ESCAPE,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let cg = crate::callgraph::build_call_graph(&gs, &[]);
|
||||
|
||||
let ev = Evidence {
|
||||
flow_steps: vec![sink_only_step_with_function("src/db.rs", "run_query")],
|
||||
sink_caps: Cap::SHELL_ESCAPE.bits(),
|
||||
..Default::default()
|
||||
};
|
||||
let diag = crate::commands::scan::Diag {
|
||||
id: "rust.cmdi.command".into(),
|
||||
path: "src/db.rs".into(),
|
||||
line: 6,
|
||||
confidence: Some(Confidence::High),
|
||||
evidence: Some(ev.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let spec = derive_from_cross_file_seed(&diag, &ev, &gs, &cg)
|
||||
.expect("cross-file seed must derive a spec");
|
||||
assert_eq!(spec.entry_name, "read_input");
|
||||
assert_eq!(spec.entry_file, "src/input.rs");
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromCallgraphEntry);
|
||||
|
||||
// End-to-end: the scorer prefers the cross-file entry — deeper flow
|
||||
// (one reverse hop) plus cross-file resolution beats the sink-local
|
||||
// strategies that name `run_query` itself as the entry.
|
||||
let ctx = SpecDerivationCtx::new(false, Some(&gs), Some(&cg));
|
||||
let best = HarnessSpec::derive_best(&diag, &ctx).expect("derive_best must succeed");
|
||||
assert_eq!(best.entry_name, "read_input");
|
||||
assert_eq!(best.derivation, SpecDerivationStrategy::FromCallgraphEntry);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@ pub enum TraceStage {
|
|||
/// trace consumer can audit how a mixed-cap batch fanned out across
|
||||
/// lanes without head-of-line blocking.
|
||||
WorkerLaneAssigned,
|
||||
/// Track K.0 (Phase 25) — the multi-strategy spec-derivation scoring
|
||||
/// picked a winning candidate. `detail` carries
|
||||
/// `winner=<strategy> runners_up=<strategy,…>` so a trace consumer can
|
||||
/// audit which strategies fired and which lost the score / tie-break,
|
||||
/// making engine derivation gaps visible without re-running.
|
||||
SpecScoringResult,
|
||||
}
|
||||
|
||||
impl TraceStage {
|
||||
|
|
@ -79,6 +85,7 @@ impl TraceStage {
|
|||
Self::OracleObserved => "oracle_observed",
|
||||
Self::Verdict => "verdict",
|
||||
Self::WorkerLaneAssigned => "worker_lane_assigned",
|
||||
Self::SpecScoringResult => "spec_scoring_result",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -246,5 +253,9 @@ mod tests {
|
|||
TraceStage::WorkerLaneAssigned.as_str(),
|
||||
"worker_lane_assigned"
|
||||
);
|
||||
assert_eq!(
|
||||
TraceStage::SpecScoringResult.as_str(),
|
||||
"spec_scoring_result"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -437,6 +437,31 @@ fn spec_derivation_failed_verdict(
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase 25 (Track K.0): render the [`crate::dynamic::trace::TraceStage::SpecScoringResult`]
|
||||
/// detail string.
|
||||
///
|
||||
/// Deterministic and within the trace-detail budget: the winning strategy
|
||||
/// followed by the loser ranking in descending-score order, each tagged with
|
||||
/// its covered flow depth so a trace consumer sees *why* the winner won.
|
||||
fn format_spec_scoring_detail(
|
||||
winner: SpecDerivationStrategy,
|
||||
runners_up: &[(SpecDerivationStrategy, crate::dynamic::spec::SpecScore)],
|
||||
) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut detail = format!("winner={winner} runners_up=");
|
||||
if runners_up.is_empty() {
|
||||
detail.push_str("none");
|
||||
} else {
|
||||
for (i, (strat, score)) in runners_up.iter().enumerate() {
|
||||
if i > 0 {
|
||||
detail.push(',');
|
||||
}
|
||||
let _ = write!(detail, "{strat}:{}", score.flow_depth);
|
||||
}
|
||||
}
|
||||
detail
|
||||
}
|
||||
|
||||
/// True when the finding has *some* derivable signal (rule namespace, sink
|
||||
/// caps, or evidence) so a spec-derivation failure should be surfaced as
|
||||
/// `Inconclusive` rather than `Unsupported`.
|
||||
|
|
@ -550,13 +575,23 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
|
|||
};
|
||||
}
|
||||
|
||||
let spec = match HarnessSpec::from_finding_full(
|
||||
diag,
|
||||
// Phase 25 (Track K.0): derive the spec through the multi-strategy
|
||||
// scoring path. `derive_best_ranked` runs every strategy, scores each
|
||||
// candidate, and returns the winner plus the loser ranking for
|
||||
// telemetry.
|
||||
let ctx = crate::dynamic::spec::SpecDerivationCtx::new(
|
||||
opts.verify_all_confidence,
|
||||
opts.summaries.as_deref(),
|
||||
opts.callgraph.as_deref(),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
);
|
||||
let spec = match HarnessSpec::derive_best_ranked(diag, &ctx) {
|
||||
Ok((s, runners_up)) => {
|
||||
trace.record(
|
||||
crate::dynamic::trace::TraceStage::SpecScoringResult,
|
||||
Some(format_spec_scoring_detail(s.derivation, &runners_up)),
|
||||
);
|
||||
s
|
||||
}
|
||||
Err(reason) => {
|
||||
trace.record(
|
||||
crate::dynamic::trace::TraceStage::Verdict,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ use crate::server::progress::TimingBreakdown;
|
|||
use crate::server::routes;
|
||||
use crate::server::security::LocalServerSecurity;
|
||||
use crate::utils::config::Config;
|
||||
use crate::utils::project::get_project_info;
|
||||
use axum::Router;
|
||||
use parking_lot::RwLock;
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
|
@ -61,17 +63,62 @@ pub struct CachedFindings {
|
|||
/// Shared application state accessible to all route handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub scan_root: PathBuf,
|
||||
pub scan_root: Arc<RwLock<PathBuf>>,
|
||||
pub config_dir: PathBuf,
|
||||
pub database_dir: PathBuf,
|
||||
pub security: Arc<LocalServerSecurity>,
|
||||
pub config: Arc<RwLock<Config>>,
|
||||
pub job_manager: Arc<JobManager>,
|
||||
pub event_tx: broadcast::Sender<ServerEvent>,
|
||||
pub db_pool: Option<Arc<Pool<SqliteConnectionManager>>>,
|
||||
pub db_pools: Arc<RwLock<HashMap<PathBuf, Arc<Pool<SqliteConnectionManager>>>>>,
|
||||
pub findings_cache: Arc<RwLock<Option<CachedFindings>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn active_scan_root(&self) -> PathBuf {
|
||||
self.scan_root.read().clone()
|
||||
}
|
||||
|
||||
pub fn set_active_scan_root(&self, scan_root: PathBuf) {
|
||||
*self.scan_root.write() = scan_root;
|
||||
*self.findings_cache.write() = None;
|
||||
}
|
||||
|
||||
pub fn db_pool_for(
|
||||
&self,
|
||||
scan_root: &std::path::Path,
|
||||
) -> Option<Arc<Pool<SqliteConnectionManager>>> {
|
||||
let canonical = scan_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| scan_root.to_path_buf());
|
||||
if let Some(pool) = self.db_pools.read().get(&canonical).cloned() {
|
||||
return Some(pool);
|
||||
}
|
||||
|
||||
let (_, db_path) = match get_project_info(&canonical, &self.database_dir) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to resolve target DB path: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let pool = match crate::database::index::Indexer::init(&db_path) {
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize target DB {}: {e}", db_path.display());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
self.db_pools.write().insert(canonical, Arc::clone(&pool));
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
pub fn active_db_pool(&self) -> Option<Arc<Pool<SqliteConnectionManager>>> {
|
||||
self.db_pool_for(&self.active_scan_root())
|
||||
}
|
||||
}
|
||||
|
||||
/// 50 MiB cap on request bodies, generous for config uploads, tight
|
||||
/// enough to prevent OOM from a rogue client.
|
||||
const MAX_BODY_BYTES: usize = 50 * 1024 * 1024;
|
||||
|
|
@ -135,14 +182,14 @@ mod tests {
|
|||
fn test_state(scan_root: PathBuf, port: u16) -> AppState {
|
||||
let (event_tx, _) = broadcast::channel(8);
|
||||
AppState {
|
||||
scan_root: scan_root.clone(),
|
||||
scan_root: Arc::new(RwLock::new(scan_root.clone())),
|
||||
config_dir: scan_root.clone(),
|
||||
database_dir: scan_root,
|
||||
security: LocalServerSecurity::new(port),
|
||||
config: Arc::new(RwLock::new(Config::default())),
|
||||
job_manager: Arc::new(JobManager::new(4, 8 * 1024 * 1024)),
|
||||
event_tx,
|
||||
db_pool: None,
|
||||
db_pools: Arc::new(RwLock::new(HashMap::new())),
|
||||
findings_cache: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ async fn list_functions(
|
|||
State(state): State<AppState>,
|
||||
Query(q): Query<FileQuery>,
|
||||
) -> Result<Json<Vec<FunctionInfo>>, StatusCode> {
|
||||
let path = validate_and_resolve(&state.scan_root, &q.file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
|
||||
let config = state.config.read();
|
||||
let analysis = debug::analyse_file(&path, &config)?;
|
||||
Ok(Json(debug::function_list(&analysis)))
|
||||
|
|
@ -102,7 +102,7 @@ async fn get_cfg(
|
|||
State(state): State<AppState>,
|
||||
Query(q): Query<FileFunctionQuery>,
|
||||
) -> Result<Json<CfgGraphView>, StatusCode> {
|
||||
let path = validate_and_resolve(&state.scan_root, &q.file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
|
||||
let config = state.config.read();
|
||||
let analysis = debug::analyse_file(&path, &config)?;
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ async fn get_ssa(
|
|||
State(state): State<AppState>,
|
||||
Query(q): Query<FileFunctionQuery>,
|
||||
) -> Result<Json<SsaBodyView>, StatusCode> {
|
||||
let path = validate_and_resolve(&state.scan_root, &q.file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
|
||||
let config = state.config.read();
|
||||
let analysis = debug::analyse_file(&path, &config)?;
|
||||
let (ssa, _opt, _cfg) = debug::analyse_function_ssa(&analysis, &q.function)?;
|
||||
|
|
@ -130,7 +130,7 @@ async fn get_taint(
|
|||
State(state): State<AppState>,
|
||||
Query(q): Query<FileFunctionQuery>,
|
||||
) -> Result<Json<TaintAnalysisView>, StatusCode> {
|
||||
let path = validate_and_resolve(&state.scan_root, &q.file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
|
||||
let config = state.config.read();
|
||||
let analysis = debug::analyse_file(&path, &config)?;
|
||||
let (ssa, opt, body_cfg) = debug::analyse_function_ssa(&analysis, &q.function)?;
|
||||
|
|
@ -168,7 +168,7 @@ async fn get_abstract_interp(
|
|||
State(state): State<AppState>,
|
||||
Query(q): Query<FileFunctionQuery>,
|
||||
) -> Result<Json<AbstractInterpView>, StatusCode> {
|
||||
let path = validate_and_resolve(&state.scan_root, &q.file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
|
||||
let config = state.config.read();
|
||||
let analysis = debug::analyse_file(&path, &config)?;
|
||||
let (ssa, opt, body_cfg) = debug::analyse_function_ssa(&analysis, &q.function)?;
|
||||
|
|
@ -202,7 +202,7 @@ async fn get_summaries(
|
|||
Some(g) if !g.is_empty() => g,
|
||||
_ => {
|
||||
if let Some(ref file) = q.file {
|
||||
let path = validate_and_resolve(&state.scan_root, file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), file)?;
|
||||
let config = state.config.read();
|
||||
debug::analyse_file_summaries(&path, &config)?
|
||||
} else {
|
||||
|
|
@ -242,7 +242,7 @@ async fn get_call_graph(
|
|||
let global = if scope == "file" {
|
||||
// On-demand: parse the specified file and extract summaries
|
||||
let file = q.file.as_deref().ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let path = validate_and_resolve(&state.scan_root, file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), file)?;
|
||||
let config = state.config.read();
|
||||
debug::analyse_file_summaries(&path, &config)?
|
||||
} else {
|
||||
|
|
@ -262,7 +262,7 @@ async fn get_symex(
|
|||
State(state): State<AppState>,
|
||||
Query(q): Query<FileFunctionQuery>,
|
||||
) -> Result<Json<SymexView>, StatusCode> {
|
||||
let path = validate_and_resolve(&state.scan_root, &q.file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
|
||||
let config = state.config.read();
|
||||
let analysis = debug::analyse_file(&path, &config)?;
|
||||
let (ssa, opt, body_cfg) = debug::analyse_function_ssa(&analysis, &q.function)?;
|
||||
|
|
@ -281,7 +281,7 @@ async fn get_pointer(
|
|||
State(state): State<AppState>,
|
||||
Query(q): Query<FileFunctionQuery>,
|
||||
) -> Result<Json<PointerView>, StatusCode> {
|
||||
let path = validate_and_resolve(&state.scan_root, &q.file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
|
||||
let config = state.config.read();
|
||||
let analysis = debug::analyse_file(&path, &config)?;
|
||||
let (ssa, facts) = debug::analyse_function_pointer(&analysis, &q.function)?;
|
||||
|
|
@ -294,7 +294,7 @@ async fn get_type_facts(
|
|||
State(state): State<AppState>,
|
||||
Query(q): Query<FileFunctionQuery>,
|
||||
) -> Result<Json<TypeFactsView>, StatusCode> {
|
||||
let path = validate_and_resolve(&state.scan_root, &q.file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
|
||||
let config = state.config.read();
|
||||
let analysis = debug::analyse_file(&path, &config)?;
|
||||
let (ssa, opt, _cfg) = debug::analyse_function_ssa(&analysis, &q.function)?;
|
||||
|
|
@ -312,7 +312,7 @@ async fn get_auth(
|
|||
State(state): State<AppState>,
|
||||
Query(q): Query<FileQuery>,
|
||||
) -> Result<Json<AuthAnalysisView>, StatusCode> {
|
||||
let path = validate_and_resolve(&state.scan_root, &q.file)?;
|
||||
let path = validate_and_resolve(&state.active_scan_root(), &q.file)?;
|
||||
let config = state.config.read();
|
||||
let (model, bytes, enabled) = debug::analyse_file_auth(&path, &config)?;
|
||||
Ok(Json(AuthAnalysisView::from_model(&model, &bytes, enabled)))
|
||||
|
|
@ -322,8 +322,9 @@ async fn get_auth(
|
|||
|
||||
/// Load global summaries from DB if available.
|
||||
fn load_global_summaries(state: &AppState) -> Option<crate::summary::GlobalSummaries> {
|
||||
let pool = state.db_pool.as_ref()?;
|
||||
load_global_summaries_from_pool(&state.scan_root, pool)
|
||||
let scan_root = state.active_scan_root();
|
||||
let pool = state.active_db_pool()?;
|
||||
load_global_summaries_from_pool(&scan_root, &pool)
|
||||
}
|
||||
|
||||
fn load_global_summaries_from_pool(
|
||||
|
|
|
|||
|
|
@ -126,8 +126,8 @@ async fn get_tree(
|
|||
State(state): State<AppState>,
|
||||
Query(query): Query<TreeQuery>,
|
||||
) -> Result<Json<Vec<TreeEntry>>, StatusCode> {
|
||||
let resolved =
|
||||
resolve_repo_dir(&state.scan_root, query.path.as_deref()).map_err(map_path_error)?;
|
||||
let scan_root = state.active_scan_root();
|
||||
let resolved = resolve_repo_dir(&scan_root, query.path.as_deref()).map_err(map_path_error)?;
|
||||
let canonical = resolved.canonical;
|
||||
|
||||
// Load findings and pre-compute per-file and per-directory aggregates
|
||||
|
|
@ -245,14 +245,15 @@ async fn get_symbols(
|
|||
State(state): State<AppState>,
|
||||
Query(query): Query<SymbolsQuery>,
|
||||
) -> Result<Json<Vec<SymbolEntry>>, StatusCode> {
|
||||
let resolved = resolve_repo_path(&state.scan_root, &query.path).map_err(map_path_error)?;
|
||||
let scan_root = state.active_scan_root();
|
||||
let resolved = resolve_repo_path(&scan_root, &query.path).map_err(map_path_error)?;
|
||||
|
||||
let pool = match &state.db_pool {
|
||||
let pool = match state.active_db_pool() {
|
||||
Some(p) => p,
|
||||
None => return Ok(Json(vec![])),
|
||||
};
|
||||
|
||||
let idx = Indexer::from_pool("_scans", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let idx = Indexer::from_pool("_scans", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Build absolute path for DB lookup (DB stores absolute paths)
|
||||
let canonical_root = resolved.root;
|
||||
|
|
@ -330,7 +331,8 @@ async fn get_findings(
|
|||
State(state): State<AppState>,
|
||||
Query(query): Query<ExplorerFindingsQuery>,
|
||||
) -> Result<Json<Vec<ExplorerFinding>>, StatusCode> {
|
||||
let resolved = resolve_repo_path(&state.scan_root, &query.path).map_err(map_path_error)?;
|
||||
let scan_root = state.active_scan_root();
|
||||
let resolved = resolve_repo_path(&scan_root, &query.path).map_err(map_path_error)?;
|
||||
|
||||
let findings = load_latest_findings(&state);
|
||||
let root_str = resolved.root.to_string_lossy();
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ async fn get_file(
|
|||
State(state): State<AppState>,
|
||||
Query(query): Query<FileQuery>,
|
||||
) -> ApiResult<Json<FileResponse>> {
|
||||
let opened = open_repo_text_file(&state.scan_root, &query.path, DEFAULT_UI_MAX_FILE_BYTES)
|
||||
let scan_root = state.active_scan_root();
|
||||
let opened = open_repo_text_file(&scan_root, &query.path, DEFAULT_UI_MAX_FILE_BYTES)
|
||||
.map_err(|e| map_path_error(e, &query.path))?;
|
||||
let content = opened.content;
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use crate::commands::scan::Diag;
|
|||
use crate::database::index::Indexer;
|
||||
use crate::server::app::{AppState, CachedFindings};
|
||||
use crate::server::error::{ApiError, ApiResult};
|
||||
use crate::server::jobs::JobStatus;
|
||||
use crate::server::models::{
|
||||
FilterValues, FindingSummary, FindingView, collect_filter_values, dynamic_status_label,
|
||||
finding_from_diag, finding_from_diag_with_detail, overlay_triage_states, summarize_findings,
|
||||
|
|
@ -38,23 +39,30 @@ struct LoadedFindings {
|
|||
/// Load findings for the latest completed scan, falling back to DB if no
|
||||
/// in-memory completed scan exists (e.g. after a server restart).
|
||||
fn load_latest_findings_internal(state: &AppState) -> LoadedFindings {
|
||||
if let Some(job) = state.job_manager.get_latest_completed() {
|
||||
let scan_root = state.active_scan_root();
|
||||
let root_key = scan_root.display().to_string();
|
||||
if let Some(job) = state
|
||||
.job_manager
|
||||
.list_jobs()
|
||||
.into_iter()
|
||||
.find(|job| job.status == JobStatus::Completed && job.scan_root == scan_root)
|
||||
{
|
||||
if let Some(ref findings) = job.findings {
|
||||
return LoadedFindings {
|
||||
cache_key: job.id.clone(),
|
||||
cache_key: format!("{root_key}:{}", job.id),
|
||||
findings: Arc::clone(findings),
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
if let Ok(scans) = idx.list_scans(20) {
|
||||
for scan in scans {
|
||||
if scan.status == "completed" {
|
||||
if let Some(json) = scan.findings_json.as_deref() {
|
||||
if let Ok(diags) = serde_json::from_str::<Vec<Diag>>(json) {
|
||||
return LoadedFindings {
|
||||
cache_key: format!("{DB_FALLBACK_KEY}:{}", scan.id),
|
||||
cache_key: format!("{root_key}:{DB_FALLBACK_KEY}:{}", scan.id),
|
||||
findings: Arc::new(diags),
|
||||
};
|
||||
}
|
||||
|
|
@ -65,7 +73,7 @@ fn load_latest_findings_internal(state: &AppState) -> LoadedFindings {
|
|||
}
|
||||
}
|
||||
LoadedFindings {
|
||||
cache_key: DB_FALLBACK_KEY.to_string(),
|
||||
cache_key: format!("{root_key}:{DB_FALLBACK_KEY}"),
|
||||
findings: Arc::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
|
@ -120,8 +128,8 @@ fn cached_for_latest(state: &AppState) -> CachedFindings {
|
|||
/// the cached views so concurrent readers see consistent data and the cache
|
||||
/// stays valid across triage edits.
|
||||
fn apply_triage_overlay(state: &AppState, views: &mut [FindingView]) {
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_triage", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_triage", &pool) {
|
||||
let triage_map = idx.get_all_triage_states().unwrap_or_default();
|
||||
let rules = idx.get_suppression_rules().unwrap_or_default();
|
||||
overlay_triage_states(views, &triage_map, &rules);
|
||||
|
|
@ -270,7 +278,8 @@ async fn get_finding(
|
|||
let diag = findings
|
||||
.get(index)
|
||||
.ok_or_else(|| ApiError::not_found(format!("finding {index} not found")))?;
|
||||
let mut view = finding_from_diag_with_detail(index, diag, &state.scan_root, &findings);
|
||||
let scan_root = state.active_scan_root();
|
||||
let mut view = finding_from_diag_with_detail(index, diag, &scan_root, &findings);
|
||||
apply_triage_overlay(&state, std::slice::from_mut(&mut view));
|
||||
Ok(Json(view))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ async fn health_check(State(state): State<AppState>) -> Json<serde_json::Value>
|
|||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"scan_root": state.scan_root.display().to_string(),
|
||||
"scan_root": state.active_scan_root().display().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub mod overview;
|
|||
pub mod rules;
|
||||
pub mod scans;
|
||||
pub mod surface;
|
||||
pub mod targets;
|
||||
pub mod triage;
|
||||
|
||||
use crate::server::app::AppState;
|
||||
|
|
@ -28,5 +29,6 @@ pub fn api_routes() -> Router<AppState> {
|
|||
.merge(overview::routes())
|
||||
.merge(explorer::routes())
|
||||
.merge(surface::routes())
|
||||
.merge(targets::routes())
|
||||
.merge(debug::routes())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,8 +176,8 @@ async fn overview(State(state): State<AppState>) -> Json<OverviewResponse> {
|
|||
async fn overview_trends(State(state): State<AppState>) -> Json<Vec<TrendPoint>> {
|
||||
let mut points = Vec::new();
|
||||
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
if let Ok(scans) = idx.list_scans(20) {
|
||||
let completed: Vec<&ScanRecord> =
|
||||
scans.iter().filter(|s| s.status == "completed").collect();
|
||||
|
|
@ -238,10 +238,9 @@ fn set_baseline_inner(state: &AppState, scan_id: &str) -> Result<StatusCode, Sta
|
|||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let pool = state
|
||||
.db_pool
|
||||
.as_ref()
|
||||
.active_db_pool()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let idx = Indexer::from_pool("_scans", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let idx = Indexer::from_pool("_scans", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
idx.set_metadata(BASELINE_KEY, scan_id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
|
|
@ -250,10 +249,9 @@ fn set_baseline_inner(state: &AppState, scan_id: &str) -> Result<StatusCode, Sta
|
|||
/// DELETE /api/overview/baseline, clear the pinned baseline.
|
||||
async fn clear_baseline(State(state): State<AppState>) -> Result<StatusCode, StatusCode> {
|
||||
let pool = state
|
||||
.db_pool
|
||||
.as_ref()
|
||||
.active_db_pool()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let idx = Indexer::from_pool("_scans", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let idx = Indexer::from_pool("_scans", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
idx.delete_metadata(BASELINE_KEY)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
|
|
@ -284,10 +282,10 @@ impl ScanHistory {
|
|||
let mut scans = Vec::new();
|
||||
let mut first_seen: HashMap<String, String> = HashMap::new();
|
||||
|
||||
let Some(ref pool) = state.db_pool else {
|
||||
let Some(pool) = state.active_db_pool() else {
|
||||
return Self { scans, first_seen };
|
||||
};
|
||||
let Ok(idx) = Indexer::from_pool("_scans", pool) else {
|
||||
let Ok(idx) = Indexer::from_pool("_scans", &pool) else {
|
||||
return Self { scans, first_seen };
|
||||
};
|
||||
|
||||
|
|
@ -408,10 +406,11 @@ impl ScanHistory {
|
|||
fn collect_recent_scans(state: &AppState, limit: usize) -> Vec<ScanSummary> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut scans = Vec::new();
|
||||
let scan_root = state.active_scan_root();
|
||||
|
||||
// In-memory first
|
||||
for job in state.job_manager.list_jobs() {
|
||||
if seen.insert(job.id.clone()) {
|
||||
if job.scan_root == scan_root && seen.insert(job.id.clone()) {
|
||||
scans.push(ScanSummary {
|
||||
id: job.id.clone(),
|
||||
status: format!("{:?}", job.status).to_ascii_lowercase(),
|
||||
|
|
@ -423,8 +422,8 @@ fn collect_recent_scans(state: &AppState, limit: usize) -> Vec<ScanSummary> {
|
|||
}
|
||||
|
||||
// DB fallback
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
if let Ok(records) = idx.list_scans(limit as i64) {
|
||||
for r in records {
|
||||
if seen.insert(r.id.clone()) {
|
||||
|
|
@ -452,10 +451,10 @@ fn compute_triage_coverage(state: &AppState, findings: &[Diag]) -> f64 {
|
|||
return 0.0;
|
||||
}
|
||||
|
||||
let Some(ref pool) = state.db_pool else {
|
||||
let Some(pool) = state.active_db_pool() else {
|
||||
return 0.0;
|
||||
};
|
||||
let Ok(idx) = Indexer::from_pool("_scans", pool) else {
|
||||
let Ok(idx) = Indexer::from_pool("_scans", &pool) else {
|
||||
return 0.0;
|
||||
};
|
||||
|
||||
|
|
@ -497,10 +496,10 @@ fn compute_noisy_rules(
|
|||
findings: &[Diag],
|
||||
by_rule: &HashMap<String, usize>,
|
||||
) -> Vec<NoisyRule> {
|
||||
let Some(ref pool) = state.db_pool else {
|
||||
let Some(pool) = state.active_db_pool() else {
|
||||
return vec![];
|
||||
};
|
||||
let Ok(idx) = Indexer::from_pool("_scans", pool) else {
|
||||
let Ok(idx) = Indexer::from_pool("_scans", &pool) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
|
|
@ -766,8 +765,8 @@ fn compute_scanner_quality(
|
|||
findings: &[Diag],
|
||||
latest_scan_id: Option<&str>,
|
||||
) -> Option<ScannerQuality> {
|
||||
let pool = state.db_pool.as_ref()?;
|
||||
let idx = Indexer::from_pool("_scans", pool).ok()?;
|
||||
let pool = state.active_db_pool()?;
|
||||
let idx = Indexer::from_pool("_scans", &pool).ok()?;
|
||||
|
||||
let mut files_scanned = 0u64;
|
||||
let mut files_skipped = 0u64;
|
||||
|
|
@ -887,10 +886,10 @@ fn compute_suppression_hygiene(state: &AppState, findings: &[Diag]) -> Suppressi
|
|||
if findings.is_empty() {
|
||||
return hygiene;
|
||||
}
|
||||
let Some(ref pool) = state.db_pool else {
|
||||
let Some(pool) = state.active_db_pool() else {
|
||||
return hygiene;
|
||||
};
|
||||
let Ok(idx) = Indexer::from_pool("_scans", pool) else {
|
||||
let Ok(idx) = Indexer::from_pool("_scans", &pool) else {
|
||||
return hygiene;
|
||||
};
|
||||
let triage_map = idx.get_all_triage_states().unwrap_or_default();
|
||||
|
|
@ -950,8 +949,8 @@ fn compute_backlog(state: &AppState, findings: &[Diag], history: &ScanHistory) -
|
|||
// Pull DB-cached first_seen first; fall back to in-memory history map.
|
||||
let fingerprints: Vec<String> = findings.iter().map(compute_fingerprint).collect();
|
||||
let mut cached: HashMap<String, String> = HashMap::new();
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
cached = idx.get_first_seen_map(&fingerprints).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
|
@ -1013,8 +1012,8 @@ fn compute_backlog(state: &AppState, findings: &[Diag], history: &ScanHistory) -
|
|||
}
|
||||
|
||||
fn compute_baseline_info(state: &AppState, findings: &[Diag]) -> Option<BaselineInfo> {
|
||||
let pool = state.db_pool.as_ref()?;
|
||||
let idx = Indexer::from_pool("_scans", pool).ok()?;
|
||||
let pool = state.active_db_pool()?;
|
||||
let idx = Indexer::from_pool("_scans", &pool).ok()?;
|
||||
let scan_id = idx.get_metadata(BASELINE_KEY).ok().flatten()?;
|
||||
if scan_id.is_empty() {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::server::models::{
|
|||
};
|
||||
use crate::server::progress::ScanMetricsSnapshot;
|
||||
use crate::server::scan_log::ScanLogEntry;
|
||||
use crate::utils::targets::{TargetTouch, remember_target};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, post};
|
||||
|
|
@ -130,7 +131,10 @@ async fn start_scan(
|
|||
body: Option<Json<StartScanRequest>>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let req = body.map(|b| b.0).unwrap_or_default();
|
||||
let scan_root = resolve_requested_scan_root(req.scan_root.as_deref(), &state.scan_root)?;
|
||||
let active_root = state.active_scan_root();
|
||||
let scan_root = resolve_requested_scan_root(req.scan_root.as_deref(), &active_root)?;
|
||||
let _ = remember_target(&state.database_dir, &scan_root, TargetTouch::Scanned);
|
||||
state.set_active_scan_root(scan_root.clone());
|
||||
|
||||
let mut config = state.config.read().clone();
|
||||
if let Some(ref mode) = req.mode {
|
||||
|
|
@ -176,7 +180,7 @@ async fn start_scan(
|
|||
}
|
||||
|
||||
let event_tx = state.event_tx.clone();
|
||||
let db_pool = state.db_pool.clone();
|
||||
let db_pool = state.db_pool_for(&scan_root);
|
||||
let database_dir = state.database_dir.clone();
|
||||
|
||||
match state
|
||||
|
|
@ -196,22 +200,19 @@ async fn start_scan(
|
|||
|
||||
fn resolve_requested_scan_root(
|
||||
requested_root: Option<&str>,
|
||||
configured_root: &Path,
|
||||
active_root: &Path,
|
||||
) -> Result<PathBuf, (StatusCode, Json<serde_json::Value>)> {
|
||||
if let Some(root) = requested_root {
|
||||
let requested = Path::new(root)
|
||||
.canonicalize()
|
||||
.map_err(|_| bad_request("invalid scan_root"))?;
|
||||
if requested != configured_root {
|
||||
return Err(bad_request(
|
||||
"scan_root must match the repository passed to nyx serve",
|
||||
));
|
||||
if !requested.is_dir() {
|
||||
return Err(bad_request("scan_root must be a directory"));
|
||||
}
|
||||
return Ok(requested);
|
||||
}
|
||||
|
||||
// The request value is validation-only; scans always run against the
|
||||
// canonical root configured when the server started.
|
||||
Ok(configured_root.to_path_buf())
|
||||
Ok(active_root.to_path_buf())
|
||||
}
|
||||
|
||||
fn bad_request(message: &str) -> (StatusCode, Json<serde_json::Value>) {
|
||||
|
|
@ -222,16 +223,18 @@ fn bad_request(message: &str) -> (StatusCode, Json<serde_json::Value>) {
|
|||
}
|
||||
|
||||
async fn list_scans(State(state): State<AppState>) -> Json<Vec<ScanView>> {
|
||||
let scan_root = state.active_scan_root();
|
||||
let mut views: Vec<ScanView> = state
|
||||
.job_manager
|
||||
.list_jobs()
|
||||
.iter()
|
||||
.map(|j| job_to_view(j))
|
||||
.into_iter()
|
||||
.filter(|j| j.scan_root == scan_root)
|
||||
.map(|j| job_to_view(&j))
|
||||
.collect();
|
||||
|
||||
// Merge historical scans from DB (deduplicate by ID)
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
if let Ok(records) = idx.list_scans(100) {
|
||||
let in_memory_ids: HashSet<String> = views.iter().map(|v| v.id.clone()).collect();
|
||||
for record in records {
|
||||
|
|
@ -250,9 +253,11 @@ async fn list_scans(State(state): State<AppState>) -> Json<Vec<ScanView>> {
|
|||
}
|
||||
|
||||
async fn active_scan(State(state): State<AppState>) -> Result<Json<ScanView>, StatusCode> {
|
||||
let scan_root = state.active_scan_root();
|
||||
let job = state
|
||||
.job_manager
|
||||
.active_job()
|
||||
.filter(|job| job.scan_root == scan_root)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
Ok(Json(job_to_view(&job)))
|
||||
}
|
||||
|
|
@ -261,14 +266,17 @@ async fn get_scan(
|
|||
State(state): State<AppState>,
|
||||
axum::extract::Path(id): axum::extract::Path<String>,
|
||||
) -> Result<Json<ScanView>, StatusCode> {
|
||||
let scan_root = state.active_scan_root();
|
||||
// Check in-memory first
|
||||
if let Some(job) = state.job_manager.get_job(&id) {
|
||||
return Ok(Json(job_to_view(&job)));
|
||||
if job.scan_root == scan_root {
|
||||
return Ok(Json(job_to_view(&job)));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to DB
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
if let Ok(Some(record)) = idx.get_scan(&id) {
|
||||
let mut view = scan_record_to_view(&record);
|
||||
// Load metrics from DB
|
||||
|
|
@ -299,8 +307,8 @@ async fn delete_scan(
|
|||
}
|
||||
|
||||
// Delete from DB (CASCADE handles metrics + logs)
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
let _ = idx.delete_scan(&id);
|
||||
}
|
||||
}
|
||||
|
|
@ -319,11 +327,14 @@ struct FindingsQuery {
|
|||
|
||||
/// Load findings for a scan by ID (in-memory first, then DB fallback).
|
||||
fn load_scan_findings(state: &AppState, id: &str) -> Result<Vec<Diag>, StatusCode> {
|
||||
let scan_root = state.active_scan_root();
|
||||
if let Some(job) = state.job_manager.get_job(id) {
|
||||
return Ok(job.findings.map(|f| (*f).clone()).unwrap_or_default());
|
||||
if job.scan_root == scan_root {
|
||||
return Ok(job.findings.map(|f| (*f).clone()).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
if let Ok(Some(record)) = idx.get_scan(id) {
|
||||
return Ok(record
|
||||
.findings_json
|
||||
|
|
@ -338,15 +349,18 @@ fn load_scan_findings(state: &AppState, id: &str) -> Result<Vec<Diag>, StatusCod
|
|||
|
||||
/// Load minimal scan info for comparison headers.
|
||||
fn load_scan_info(state: &AppState, id: &str) -> Result<CompareScanInfo, StatusCode> {
|
||||
let scan_root = state.active_scan_root();
|
||||
if let Some(job) = state.job_manager.get_job(id) {
|
||||
return Ok(CompareScanInfo {
|
||||
id: job.id.clone(),
|
||||
started_at: job.started_at.map(|t| t.to_rfc3339()),
|
||||
finding_count: job.findings.as_ref().map(|f| f.len()).unwrap_or(0),
|
||||
});
|
||||
if job.scan_root == scan_root {
|
||||
return Ok(CompareScanInfo {
|
||||
id: job.id.clone(),
|
||||
started_at: job.started_at.map(|t| t.to_rfc3339()),
|
||||
finding_count: job.findings.as_ref().map(|f| f.len()).unwrap_or(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
if let Ok(Some(record)) = idx.get_scan(id) {
|
||||
return Ok(CompareScanInfo {
|
||||
id: record.id.clone(),
|
||||
|
|
@ -390,13 +404,14 @@ async fn get_scan_findings(
|
|||
let page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = query.per_page.unwrap_or(50).min(200);
|
||||
let start = (page - 1) * per_page;
|
||||
let scan_root = state.active_scan_root();
|
||||
|
||||
let page_findings: Vec<FindingView> = filtered
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.skip(start)
|
||||
.take(per_page)
|
||||
.map(|(i, d)| models::finding_from_diag_with_context(i, d, &state.scan_root))
|
||||
.map(|(i, d)| models::finding_from_diag_with_context(i, d, &scan_root))
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
|
|
@ -424,6 +439,7 @@ async fn compare_scans(
|
|||
|
||||
let left_findings = load_scan_findings(&state, &query.left)?;
|
||||
let right_findings = load_scan_findings(&state, &query.right)?;
|
||||
let scan_root = state.active_scan_root();
|
||||
|
||||
// Build fingerprint → Vec<(index, diag)> multi-maps so duplicate
|
||||
// fingerprints are preserved instead of silently dropped.
|
||||
|
|
@ -457,7 +473,7 @@ async fn compare_scans(
|
|||
for i in 0..matched {
|
||||
let (idx, diag) = right_group[i];
|
||||
let (_, left_diag) = left_group[i];
|
||||
let view = models::finding_from_diag_with_context(idx, diag, &state.scan_root);
|
||||
let view = models::finding_from_diag_with_context(idx, diag, &scan_root);
|
||||
let changes = compute_field_changes(left_diag, diag);
|
||||
if changes.is_empty() {
|
||||
unchanged_findings.push(ComparedFinding {
|
||||
|
|
@ -476,7 +492,7 @@ async fn compare_scans(
|
|||
for &(idx, diag) in &right_group[matched..] {
|
||||
new_findings.push(ComparedFinding {
|
||||
fingerprint: fp.clone(),
|
||||
finding: models::finding_from_diag_with_context(idx, diag, &state.scan_root),
|
||||
finding: models::finding_from_diag_with_context(idx, diag, &scan_root),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -484,7 +500,7 @@ async fn compare_scans(
|
|||
for &(idx, diag) in right_group {
|
||||
new_findings.push(ComparedFinding {
|
||||
fingerprint: fp.clone(),
|
||||
finding: models::finding_from_diag_with_context(idx, diag, &state.scan_root),
|
||||
finding: models::finding_from_diag_with_context(idx, diag, &scan_root),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -498,7 +514,7 @@ async fn compare_scans(
|
|||
for &(idx, diag) in &left_group[start..] {
|
||||
fixed_findings.push(ComparedFinding {
|
||||
fingerprint: fp.clone(),
|
||||
finding: models::finding_from_diag_with_context(idx, diag, &state.scan_root),
|
||||
finding: models::finding_from_diag_with_context(idx, diag, &scan_root),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -593,9 +609,12 @@ async fn get_scan_logs(
|
|||
axum::extract::Path(id): axum::extract::Path<String>,
|
||||
Query(query): Query<LogsQuery>,
|
||||
) -> Result<Json<Vec<ScanLogEntry>>, StatusCode> {
|
||||
let scan_root = state.active_scan_root();
|
||||
// Check in-memory (running scan)
|
||||
if let Some(job) = state.job_manager.get_job(&id) {
|
||||
if let Some(ref collector) = job.log_collector {
|
||||
if job.scan_root == scan_root
|
||||
&& let Some(ref collector) = job.log_collector
|
||||
{
|
||||
let mut logs = collector.snapshot();
|
||||
if let Some(ref level) = query.level {
|
||||
logs.retain(|l| l.level.to_string().eq_ignore_ascii_case(level));
|
||||
|
|
@ -605,8 +624,8 @@ async fn get_scan_logs(
|
|||
}
|
||||
|
||||
// Fall back to DB
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
if let Ok(logs) = idx.get_scan_logs(&id, query.level.as_deref()) {
|
||||
return Ok(Json(logs));
|
||||
}
|
||||
|
|
@ -620,16 +639,19 @@ async fn get_scan_metrics(
|
|||
State(state): State<AppState>,
|
||||
axum::extract::Path(id): axum::extract::Path<String>,
|
||||
) -> Result<Json<ScanMetricsSnapshot>, StatusCode> {
|
||||
let scan_root = state.active_scan_root();
|
||||
// Check in-memory (running scan)
|
||||
if let Some(job) = state.job_manager.get_job(&id) {
|
||||
if let Some(ref metrics) = job.metrics {
|
||||
if job.scan_root == scan_root
|
||||
&& let Some(ref metrics) = job.metrics
|
||||
{
|
||||
return Ok(Json(metrics.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to DB
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", pool) {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
if let Ok(idx) = Indexer::from_pool("_scans", &pool) {
|
||||
if let Ok(Some(metrics)) = idx.get_scan_metrics(&id) {
|
||||
return Ok(Json(metrics));
|
||||
}
|
||||
|
|
@ -710,7 +732,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_scan_root_accepts_matching_root_but_uses_configured_path() {
|
||||
fn resolve_requested_scan_root_accepts_matching_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let configured = dir.path().canonicalize().unwrap();
|
||||
let requested = dir.path().join(".");
|
||||
|
|
@ -723,21 +745,17 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_scan_root_rejects_different_root() {
|
||||
fn resolve_requested_scan_root_accepts_different_root() {
|
||||
let configured_dir = tempfile::tempdir().unwrap();
|
||||
let other_dir = tempfile::tempdir().unwrap();
|
||||
let configured = configured_dir.path().canonicalize().unwrap();
|
||||
|
||||
let err = resolve_requested_scan_root(
|
||||
let resolved = resolve_requested_scan_root(
|
||||
Some(other_dir.path().to_string_lossy().as_ref()),
|
||||
&configured,
|
||||
)
|
||||
.unwrap_err();
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
err.1.0["error"],
|
||||
"scan_root must match the repository passed to nyx serve"
|
||||
);
|
||||
assert_eq!(resolved, other_dir.path().canonicalize().unwrap());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub fn routes() -> Router<AppState> {
|
|||
}
|
||||
|
||||
async fn get_surface(State(state): State<AppState>) -> ApiResult<Json<Value>> {
|
||||
let scan_root = state.scan_root.clone();
|
||||
let scan_root = state.active_scan_root();
|
||||
let database_dir = state.database_dir.clone();
|
||||
let cfg = state.config.read().clone();
|
||||
|
||||
|
|
|
|||
159
src/server/routes/targets.rs
Normal file
159
src/server/routes/targets.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
use crate::server::app::AppState;
|
||||
use crate::server::error::{ApiError, ApiResult};
|
||||
use crate::utils::targets::{
|
||||
TargetRecord, TargetTouch, load_targets, remember_target, remove_target, target_id_for_path,
|
||||
};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/targets", get(list_targets).post(add_target))
|
||||
.route("/targets/select", post(select_target))
|
||||
.route("/targets/{id}", delete(delete_target))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TargetView {
|
||||
id: String,
|
||||
name: String,
|
||||
path: String,
|
||||
db_path: String,
|
||||
last_seen_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
last_scan_at: Option<String>,
|
||||
active: bool,
|
||||
exists: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TargetPathRequest {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SelectTargetRequest {
|
||||
id: Option<String>,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_targets(State(state): State<AppState>) -> ApiResult<Json<Vec<TargetView>>> {
|
||||
ensure_active_target_record(&state)?;
|
||||
let active = state.active_scan_root();
|
||||
let targets = load_targets(&state.database_dir)
|
||||
.map_err(|e| ApiError::internal(format!("failed to load targets: {e}")))?;
|
||||
Ok(Json(targets_to_views(&targets, &active)))
|
||||
}
|
||||
|
||||
async fn add_target(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<TargetPathRequest>,
|
||||
) -> ApiResult<Json<TargetView>> {
|
||||
let path = canonical_project_path(&body.path)?;
|
||||
let record = remember_target(&state.database_dir, &path, TargetTouch::Seen)
|
||||
.map_err(|e| ApiError::internal(format!("failed to remember target: {e}")))?;
|
||||
let _ = state.db_pool_for(&path);
|
||||
Ok(Json(record_to_view(&record, &state.active_scan_root())))
|
||||
}
|
||||
|
||||
async fn select_target(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<SelectTargetRequest>,
|
||||
) -> ApiResult<Json<TargetView>> {
|
||||
let path = if let Some(id) = body.id.as_deref() {
|
||||
target_path_by_id(&state, id)?
|
||||
} else if let Some(path) = body.path.as_deref() {
|
||||
canonical_project_path(path)?
|
||||
} else {
|
||||
return Err(ApiError::bad_request("target id or path is required"));
|
||||
};
|
||||
|
||||
let record = remember_target(&state.database_dir, &path, TargetTouch::Seen)
|
||||
.map_err(|e| ApiError::internal(format!("failed to remember target: {e}")))?;
|
||||
state.set_active_scan_root(path.clone());
|
||||
let _ = state.db_pool_for(&path);
|
||||
Ok(Json(record_to_view(&record, &path)))
|
||||
}
|
||||
|
||||
async fn delete_target(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<serde_json::Value>> {
|
||||
let removed = remove_target(&state.database_dir, &id)
|
||||
.map_err(|e| ApiError::internal(format!("failed to remove target: {e}")))?;
|
||||
if removed.is_none() {
|
||||
return Err(ApiError::not_found(format!("target {id} not found")));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "status": "deleted", "id": id })))
|
||||
}
|
||||
|
||||
fn ensure_active_target_record(state: &AppState) -> ApiResult<()> {
|
||||
let active = state.active_scan_root();
|
||||
let active_id = target_id_for_path(&active);
|
||||
let targets = load_targets(&state.database_dir)
|
||||
.map_err(|e| ApiError::internal(format!("failed to load targets: {e}")))?;
|
||||
if targets.iter().any(|target| target.id == active_id) {
|
||||
return Ok(());
|
||||
}
|
||||
remember_target(&state.database_dir, &active, TargetTouch::Seen)
|
||||
.map(|_| ())
|
||||
.map_err(|e| ApiError::internal(format!("failed to remember active target: {e}")))
|
||||
}
|
||||
|
||||
fn canonical_project_path(path: &str) -> ApiResult<PathBuf> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ApiError::bad_request("path is required"));
|
||||
}
|
||||
let path = FsPath::new(trimmed)
|
||||
.canonicalize()
|
||||
.map_err(|_| ApiError::bad_request("path does not exist"))?;
|
||||
if !path.is_dir() {
|
||||
return Err(ApiError::bad_request("path must be a directory"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn target_path_by_id(state: &AppState, id: &str) -> ApiResult<PathBuf> {
|
||||
let targets = load_targets(&state.database_dir)
|
||||
.map_err(|e| ApiError::internal(format!("failed to load targets: {e}")))?;
|
||||
let record = targets
|
||||
.iter()
|
||||
.find(|target| target.id == id)
|
||||
.ok_or_else(|| ApiError::not_found(format!("target {id} not found")))?;
|
||||
let path = canonical_project_path(&record.path)?;
|
||||
if target_id_for_path(&path) != id {
|
||||
return Err(ApiError::bad_request("target path no longer matches id"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn targets_to_views(targets: &[TargetRecord], active: &FsPath) -> Vec<TargetView> {
|
||||
targets
|
||||
.iter()
|
||||
.map(|record| record_to_view(record, active))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn record_to_view(record: &TargetRecord, active: &FsPath) -> TargetView {
|
||||
let target_path = FsPath::new(&record.path);
|
||||
let active = active
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| active.to_path_buf());
|
||||
let target_canonical = target_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| target_path.to_path_buf());
|
||||
TargetView {
|
||||
id: record.id.clone(),
|
||||
name: record.name.clone(),
|
||||
path: record.path.clone(),
|
||||
db_path: record.db_path.clone(),
|
||||
last_seen_at: record.last_seen_at.clone(),
|
||||
last_scan_at: record.last_scan_at.clone(),
|
||||
active: target_canonical == active,
|
||||
exists: target_path.is_dir(),
|
||||
}
|
||||
}
|
||||
|
|
@ -50,12 +50,12 @@ async fn set_triage(
|
|||
));
|
||||
}
|
||||
|
||||
let pool = state.db_pool.as_ref().ok_or((
|
||||
let pool = state.active_db_pool().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({ "error": "database not available" })),
|
||||
))?;
|
||||
|
||||
let idx = Indexer::from_pool("_triage", pool).map_err(|e| {
|
||||
let idx = Indexer::from_pool("_triage", &pool).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
|
|
@ -100,10 +100,10 @@ async fn list_triage(
|
|||
Query(query): Query<ListTriageQuery>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let pool = state
|
||||
.db_pool
|
||||
.as_ref()
|
||||
.active_db_pool()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let idx = Indexer::from_pool("_triage", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let idx =
|
||||
Indexer::from_pool("_triage", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = query.per_page.unwrap_or(50).clamp(1, 500);
|
||||
|
|
@ -167,10 +167,10 @@ async fn get_audit_log(
|
|||
Query(query): Query<AuditQuery>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let pool = state
|
||||
.db_pool
|
||||
.as_ref()
|
||||
.active_db_pool()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let idx = Indexer::from_pool("_triage", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let idx =
|
||||
Indexer::from_pool("_triage", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let per_page = query.per_page.unwrap_or(50).clamp(1, 500);
|
||||
|
|
@ -210,12 +210,12 @@ async fn add_suppression(
|
|||
));
|
||||
}
|
||||
|
||||
let pool = state.db_pool.as_ref().ok_or((
|
||||
let pool = state.active_db_pool().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({ "error": "database not available" })),
|
||||
))?;
|
||||
|
||||
let idx = Indexer::from_pool("_triage", pool).map_err(|e| {
|
||||
let idx = Indexer::from_pool("_triage", &pool).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
|
|
@ -277,10 +277,10 @@ async fn list_suppressions(
|
|||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let pool = state
|
||||
.db_pool
|
||||
.as_ref()
|
||||
.active_db_pool()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let idx = Indexer::from_pool("_triage", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let idx =
|
||||
Indexer::from_pool("_triage", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let rules = idx
|
||||
.get_suppression_rules()
|
||||
|
|
@ -301,10 +301,10 @@ async fn remove_suppression(
|
|||
Query(query): Query<DeleteSuppressionQuery>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let pool = state
|
||||
.db_pool
|
||||
.as_ref()
|
||||
.active_db_pool()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let idx = Indexer::from_pool("_triage", pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let idx =
|
||||
Indexer::from_pool("_triage", &pool).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let deleted = idx
|
||||
.delete_suppression_rule(query.id)
|
||||
|
|
@ -323,9 +323,10 @@ fn auto_sync_to_file(state: &AppState) {
|
|||
if !sync_enabled {
|
||||
return;
|
||||
}
|
||||
if let Some(ref pool) = state.db_pool {
|
||||
if let Some(pool) = state.active_db_pool() {
|
||||
let scan_root = state.active_scan_root();
|
||||
let findings = load_latest_findings(state);
|
||||
let _ = crate::server::triage_sync::sync_to_file(pool, &findings, &state.scan_root);
|
||||
let _ = crate::server::triage_sync::sync_to_file(&pool, &findings, &scan_root);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -334,28 +335,29 @@ fn auto_sync_to_file(state: &AppState) {
|
|||
async fn export_triage_file(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let pool = state.db_pool.as_ref().ok_or((
|
||||
let pool = state.active_db_pool().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({ "error": "database not available" })),
|
||||
))?;
|
||||
|
||||
let findings = load_latest_findings(&state);
|
||||
let file = crate::server::triage_sync::export_triage(pool, &findings, &state.scan_root)
|
||||
.map_err(|e| {
|
||||
let scan_root = state.active_scan_root();
|
||||
let file =
|
||||
crate::server::triage_sync::export_triage(&pool, &findings, &scan_root).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
)
|
||||
})?;
|
||||
|
||||
crate::server::triage_sync::save_triage_file(&state.scan_root, &file).map_err(|e| {
|
||||
crate::server::triage_sync::save_triage_file(&scan_root, &file).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let path = crate::server::triage_sync::triage_file_path(&state.scan_root).map_err(|e| {
|
||||
let path = crate::server::triage_sync::triage_file_path(&scan_root).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
|
|
@ -373,12 +375,13 @@ async fn export_triage_file(
|
|||
async fn import_triage_file(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let pool = state.db_pool.as_ref().ok_or((
|
||||
let pool = state.active_db_pool().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({ "error": "database not available" })),
|
||||
))?;
|
||||
|
||||
let file = crate::server::triage_sync::load_triage_file_checked(&state.scan_root)
|
||||
let scan_root = state.active_scan_root();
|
||||
let file = crate::server::triage_sync::load_triage_file_checked(&scan_root)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
|
@ -391,14 +394,13 @@ async fn import_triage_file(
|
|||
))?;
|
||||
|
||||
let findings = load_latest_findings(&state);
|
||||
let applied =
|
||||
crate::server::triage_sync::import_triage(pool, &findings, &state.scan_root, &file)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
)
|
||||
})?;
|
||||
let applied = crate::server::triage_sync::import_triage(&pool, &findings, &scan_root, &file)
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"imported": applied,
|
||||
|
|
@ -410,8 +412,9 @@ async fn import_triage_file(
|
|||
// ── GET /api/triage/sync-status ─────────────────────────────────────────────
|
||||
|
||||
async fn get_sync_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let path = crate::server::triage_sync::triage_file_path(&state.scan_root).ok();
|
||||
let file = crate::server::triage_sync::load_triage_file(&state.scan_root);
|
||||
let scan_root = state.active_scan_root();
|
||||
let path = crate::server::triage_sync::triage_file_path(&scan_root).ok();
|
||||
let file = crate::server::triage_sync::load_triage_file(&scan_root);
|
||||
let sync_enabled = state.config.read().server.triage_sync;
|
||||
|
||||
Json(serde_json::json!({
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub mod project;
|
|||
pub(crate) mod query_cache;
|
||||
pub mod redact;
|
||||
pub(crate) mod snippet;
|
||||
pub mod targets;
|
||||
|
||||
pub use analysis_options::{AnalysisOptions, SymexOptions};
|
||||
pub use config::Config;
|
||||
|
|
|
|||
161
src/utils/targets.rs
Normal file
161
src/utils/targets.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
use crate::errors::{NyxError, NyxResult};
|
||||
use crate::utils::project::{get_project_info, sanitize_project_name};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const TARGETS_FILE: &str = "targets.json";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TargetTouch {
|
||||
Seen,
|
||||
Scanned,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TargetRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub db_path: String,
|
||||
pub last_seen_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_scan_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct TargetFile {
|
||||
#[serde(default)]
|
||||
targets: Vec<TargetRecord>,
|
||||
}
|
||||
|
||||
pub fn targets_path(database_dir: &Path) -> PathBuf {
|
||||
database_dir.join(TARGETS_FILE)
|
||||
}
|
||||
|
||||
pub fn load_targets(database_dir: &Path) -> NyxResult<Vec<TargetRecord>> {
|
||||
let path = targets_path(database_dir);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let bytes = fs::read(path)?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let file: TargetFile =
|
||||
serde_json::from_slice(&bytes).map_err(|e| NyxError::Other(Box::new(e)))?;
|
||||
Ok(file.targets)
|
||||
}
|
||||
|
||||
pub fn save_targets(database_dir: &Path, targets: &[TargetRecord]) -> NyxResult<()> {
|
||||
fs::create_dir_all(database_dir)?;
|
||||
let path = targets_path(database_dir);
|
||||
let file = TargetFile {
|
||||
targets: targets.to_vec(),
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&file).map_err(|e| NyxError::Other(Box::new(e)))?;
|
||||
fs::write(path, bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remember_target(
|
||||
database_dir: &Path,
|
||||
project_path: &Path,
|
||||
touch: TargetTouch,
|
||||
) -> NyxResult<TargetRecord> {
|
||||
let canonical = project_path.canonicalize()?;
|
||||
let path_str = canonical.to_string_lossy().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let (_, db_path) = get_project_info(&canonical, database_dir)?;
|
||||
let mut targets = load_targets(database_dir)?;
|
||||
let id = target_id_for_path(&canonical);
|
||||
|
||||
let mut record = TargetRecord {
|
||||
id: id.clone(),
|
||||
name: display_name_for_path(&canonical),
|
||||
path: path_str.clone(),
|
||||
db_path: db_path.to_string_lossy().to_string(),
|
||||
last_seen_at: now.clone(),
|
||||
last_scan_at: (touch == TargetTouch::Scanned).then_some(now.clone()),
|
||||
};
|
||||
|
||||
if let Some(existing) = targets.iter_mut().find(|target| target.id == id) {
|
||||
existing.name = record.name.clone();
|
||||
existing.path = record.path.clone();
|
||||
existing.db_path = record.db_path.clone();
|
||||
existing.last_seen_at = now;
|
||||
if touch == TargetTouch::Scanned {
|
||||
existing.last_scan_at = record.last_scan_at.clone();
|
||||
} else {
|
||||
record.last_scan_at = existing.last_scan_at.clone();
|
||||
}
|
||||
record = existing.clone();
|
||||
} else {
|
||||
targets.push(record.clone());
|
||||
}
|
||||
|
||||
targets.sort_by(|a, b| {
|
||||
b.last_scan_at
|
||||
.as_deref()
|
||||
.unwrap_or(&b.last_seen_at)
|
||||
.cmp(a.last_scan_at.as_deref().unwrap_or(&a.last_seen_at))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
save_targets(database_dir, &targets)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn remove_target(database_dir: &Path, id: &str) -> NyxResult<Option<TargetRecord>> {
|
||||
let mut targets = load_targets(database_dir)?;
|
||||
let Some(pos) = targets.iter().position(|target| target.id == id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let removed = targets.remove(pos);
|
||||
save_targets(database_dir, &targets)?;
|
||||
Ok(Some(removed))
|
||||
}
|
||||
|
||||
pub fn target_id_for_path(path: &Path) -> String {
|
||||
let path_str = path.to_string_lossy();
|
||||
let hash = blake3::hash(path_str.as_bytes()).to_hex().to_string();
|
||||
let slug = display_name_for_path(path);
|
||||
format!("{}-{}", sanitize_project_name(&slug), &hash[..12])
|
||||
}
|
||||
|
||||
fn display_name_for_path(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| path.display().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remembers_and_updates_target() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
|
||||
let first = remember_target(data.path(), project.path(), TargetTouch::Seen).unwrap();
|
||||
assert!(first.last_scan_at.is_none());
|
||||
|
||||
let second = remember_target(data.path(), project.path(), TargetTouch::Scanned).unwrap();
|
||||
assert_eq!(first.id, second.id);
|
||||
assert!(second.last_scan_at.is_some());
|
||||
|
||||
let targets = load_targets(data.path()).unwrap();
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].id, first.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_id_is_stable_for_path() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let a = target_id_for_path(project.path());
|
||||
let b = target_id_for_path(project.path());
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +74,10 @@ fn cap_lanes_beat_single_lane_by_3x() {
|
|||
);
|
||||
let lanes = t1.elapsed();
|
||||
|
||||
assert_eq!(lane_out, baseline_out, "lanes must produce identical ordered results");
|
||||
assert_eq!(
|
||||
lane_out, baseline_out,
|
||||
"lanes must produce identical ordered results"
|
||||
);
|
||||
|
||||
let speedup = single_lane.as_secs_f64() / lanes.as_secs_f64();
|
||||
eprintln!(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue