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:
elipeter 2026-05-29 13:14:29 -05:00
parent acdc71cd88
commit 635b213825
40 changed files with 1810 additions and 240 deletions

View file

@ -1 +0,0 @@
{"sessionId":"6c158e05-a83e-4808-acf4-12ad7b0fe983","pid":8358,"procStart":"Fri May 29 15:24:35 2026","acquiredAt":1780071990470}

View file

@ -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.

View file

@ -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

View file

@ -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 |

View file

@ -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
View 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
View 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;
}

View file

@ -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.

View 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'] });
},
});
}

View file

@ -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;

View file

@ -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 />

View file

@ -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', () => {

View file

@ -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);

View file

@ -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"}

View file

@ -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(

View file

@ -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 = &{

View file

@ -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

View file

@ -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)

View file

@ -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",

View file

@ -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");

View file

@ -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;

View file

@ -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");
}

View file

@ -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);
}
}

View file

@ -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"
);
}
}

View file

@ -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,

View file

@ -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)),
}
}

View file

@ -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(

View file

@ -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();

View file

@ -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();

View file

@ -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))
}

View file

@ -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(),
}))
}

View file

@ -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())
}

View file

@ -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;

View file

@ -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());
}
}

View file

@ -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();

View 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(),
}
}

View file

@ -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!({

View file

@ -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
View 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);
}
}

View file

@ -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!(