mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-12 21:02:11 +02:00
[pitboss] sweep after phase 26: 4 deferred items resolved
This commit is contained in:
parent
8a801953e2
commit
ea722dc9ca
8 changed files with 320 additions and 130 deletions
|
|
@ -77,33 +77,18 @@ function NodeCard({
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`surface-node-card${selected ? ' selected' : ''}`}
|
className={`surface-node-card${selected ? ' selected' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 'var(--space-1)',
|
|
||||||
padding: 'var(--space-3)',
|
|
||||||
border: `1px solid ${selected ? color : 'var(--border)'}`,
|
border: `1px solid ${selected ? color : 'var(--border)'}`,
|
||||||
borderLeft: `4px solid ${color}`,
|
borderLeft: `4px solid ${color}`,
|
||||||
borderRadius: 'var(--radius-2)',
|
|
||||||
background: selected ? 'var(--surface-2)' : 'var(--surface-1)',
|
background: selected ? 'var(--surface-2)' : 'var(--surface-1)',
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'left',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 'var(--text-2xs)', color: 'var(--text-tertiary)' }}>
|
<span className="surface-node-card-meta">
|
||||||
#{index} · {node.node.replace('_', ' ')}
|
#{index} · {node.node.replace('_', ' ')}
|
||||||
{node.node === 'entry_point' && node.auth_required ? ' · auth' : ''}
|
{node.node === 'entry_point' && node.auth_required ? ' · auth' : ''}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontWeight: 600, fontSize: 'var(--text-sm)' }}>
|
<span className="surface-node-card-title">{nodeTitle(node)}</span>
|
||||||
{nodeTitle(node)}
|
<span className="surface-node-card-subtitle">{nodeSubtitle(node)}</span>
|
||||||
</span>
|
<code className="surface-node-card-loc">{nodeLocation(node)}</code>
|
||||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)' }}>
|
|
||||||
{nodeSubtitle(node)}
|
|
||||||
</span>
|
|
||||||
<code style={{ fontSize: 'var(--text-2xs)', color: 'var(--text-tertiary)' }}>
|
|
||||||
{nodeLocation(node)}
|
|
||||||
</code>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +126,7 @@ function NeighborList({
|
||||||
}) {
|
}) {
|
||||||
if (index === null) {
|
if (index === null) {
|
||||||
return (
|
return (
|
||||||
<p style={{ color: 'var(--text-tertiary)' }}>
|
<p className="surface-neighbor-empty">
|
||||||
Select a node on the left to see its neighbours.
|
Select a node on the left to see its neighbours.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
@ -155,54 +140,26 @@ function NeighborList({
|
||||||
const renderEdges = (edges: SurfaceEdge[], direction: 'in' | 'out') => {
|
const renderEdges = (edges: SurfaceEdge[], direction: 'in' | 'out') => {
|
||||||
if (edges.length === 0) {
|
if (edges.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p style={{ color: 'var(--text-tertiary)' }}>
|
<p className="surface-neighbor-empty">
|
||||||
(no {direction === 'in' ? 'inbound' : 'outbound'} edges)
|
(no {direction === 'in' ? 'inbound' : 'outbound'} edges)
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul className="surface-neighbor-edges">
|
||||||
style={{
|
|
||||||
listStyle: 'none',
|
|
||||||
padding: 0,
|
|
||||||
margin: 0,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 'var(--space-1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{edges.map((e, i) => {
|
{edges.map((e, i) => {
|
||||||
const otherIdx = direction === 'in' ? e.from : e.to;
|
const otherIdx = direction === 'in' ? e.from : e.to;
|
||||||
const other = map.nodes[otherIdx];
|
const other = map.nodes[otherIdx];
|
||||||
if (!other) return null;
|
if (!other) return null;
|
||||||
return (
|
return (
|
||||||
<li
|
<li key={`${direction}-${i}`} className="surface-neighbor-edge">
|
||||||
key={`${direction}-${i}`}
|
<span className="surface-neighbor-edge-kind">
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 'var(--space-2)',
|
|
||||||
fontSize: 'var(--text-xs)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: 'var(--radius-1)',
|
|
||||||
background: 'var(--surface-2)',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{EDGE_KIND_LABELS[e.kind]}
|
{EDGE_KIND_LABELS[e.kind]}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{direction === 'in' ? '←' : '→'} <strong>{nodeTitle(other)}</strong>
|
{direction === 'in' ? '←' : '→'} <strong>{nodeTitle(other)}</strong>
|
||||||
</span>
|
</span>
|
||||||
<code
|
<code className="surface-neighbor-edge-loc">{nodeLocation(other)}</code>
|
||||||
style={{ fontSize: 'var(--text-2xs)', color: 'var(--text-tertiary)' }}
|
|
||||||
>
|
|
||||||
{nodeLocation(other)}
|
|
||||||
</code>
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -212,8 +169,8 @@ function NeighborList({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ marginTop: 0 }}>{nodeTitle(node)}</h3>
|
<h3 className="surface-neighbor-title">{nodeTitle(node)}</h3>
|
||||||
<p style={{ color: 'var(--text-secondary)', marginTop: 0 }}>
|
<p className="surface-neighbor-subtitle">
|
||||||
{nodeSubtitle(node)} — <code>{nodeLocation(node)}</code>
|
{nodeSubtitle(node)} — <code>{nodeLocation(node)}</code>
|
||||||
</p>
|
</p>
|
||||||
<h4>Outbound</h4>
|
<h4>Outbound</h4>
|
||||||
|
|
@ -261,53 +218,26 @@ export function SurfacePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
<header
|
<header className="surface-header">
|
||||||
style={{
|
<h1>Attack surface</h1>
|
||||||
display: 'flex',
|
<span className="surface-header-summary">
|
||||||
alignItems: 'baseline',
|
|
||||||
gap: 'var(--space-4)',
|
|
||||||
marginBottom: 'var(--space-4)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 style={{ margin: 0 }}>Attack surface</h1>
|
|
||||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
|
||||||
{summary.entries} entry-points · {summary.stores} stores ·{' '}
|
{summary.entries} entry-points · {summary.stores} stores ·{' '}
|
||||||
{summary.externals} services · {summary.dangerous} dangerous locals ·{' '}
|
{summary.externals} services · {summary.dangerous} dangerous locals ·{' '}
|
||||||
{data.edges.length} edges
|
{data.edges.length} edges
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div className="surface-filter-row">
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 'var(--space-2)',
|
|
||||||
marginBottom: 'var(--space-3)',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
placeholder="Filter by name, label, or path"
|
placeholder="Filter by name, label, or path"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
style={{
|
className="surface-filter-input"
|
||||||
flex: '1 1 220px',
|
|
||||||
padding: 'var(--space-2)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 'var(--radius-1)',
|
|
||||||
background: 'var(--surface-1)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value as NodeKindFilter)}
|
onChange={(e) => setFilter(e.target.value as NodeKindFilter)}
|
||||||
style={{
|
className="surface-filter-select"
|
||||||
padding: 'var(--space-2)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 'var(--radius-1)',
|
|
||||||
background: 'var(--surface-1)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<option value="all">All node kinds</option>
|
<option value="all">All node kinds</option>
|
||||||
<option value="entry_point">Entry points</option>
|
<option value="entry_point">Entry points</option>
|
||||||
|
|
@ -316,25 +246,10 @@ export function SurfacePage() {
|
||||||
<option value="dangerous_local">Dangerous locals</option>
|
<option value="dangerous_local">Dangerous locals</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="surface-grid">
|
||||||
style={{
|
<div className="surface-node-list">
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'minmax(280px, 1fr) minmax(320px, 1.4fr)',
|
|
||||||
gap: 'var(--space-4)',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 'var(--space-2)',
|
|
||||||
maxHeight: '70vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text-tertiary)' }}>No nodes match.</p>
|
<p className="surface-node-list-empty">No nodes match.</p>
|
||||||
) : (
|
) : (
|
||||||
visible.map(({ node, index }) => (
|
visible.map(({ node, index }) => (
|
||||||
<NodeCard
|
<NodeCard
|
||||||
|
|
@ -347,14 +262,7 @@ export function SurfacePage() {
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<aside
|
<aside className="surface-sidebar">
|
||||||
style={{
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 'var(--radius-2)',
|
|
||||||
padding: 'var(--space-4)',
|
|
||||||
background: 'var(--surface-1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<NeighborList map={data} index={selected} />
|
<NeighborList map={data} index={selected} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8793,3 +8793,122 @@ input[type='checkbox'] {
|
||||||
[data-theme='light'] .code-modal-title {
|
[data-theme='light'] .code-modal-title {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SurfacePage */
|
||||||
|
.surface-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.surface-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.surface-header-summary {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.surface-filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.surface-filter-input {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.surface-filter-select {
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.surface-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.4fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.surface-node-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.surface-node-list-empty {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.surface-sidebar {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
.surface-node-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.surface-node-card-meta {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.surface-node-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.surface-node-card-subtitle {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.surface-node-card-loc {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.surface-neighbor-empty {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.surface-neighbor-title {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.surface-neighbor-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.surface-neighbor-edges {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.surface-neighbor-edge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.surface-neighbor-edge-kind {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.surface-neighbor-edge-loc {
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ impl ChainFinding {
|
||||||
if verdict.status == VerifyStatus::Inconclusive {
|
if verdict.status == VerifyStatus::Inconclusive {
|
||||||
self.severity = self.severity.downgraded();
|
self.severity = self.severity.downgraded();
|
||||||
let reason = match &verdict.inconclusive_reason {
|
let reason = match &verdict.inconclusive_reason {
|
||||||
Some(r) => format!("composite reverification inconclusive: {r:?}"),
|
Some(r) => format!("composite reverification inconclusive: {r}"),
|
||||||
None => match verdict.detail.as_deref() {
|
None => match verdict.detail.as_deref() {
|
||||||
Some(d) if !d.is_empty() => {
|
Some(d) if !d.is_empty() => {
|
||||||
format!("composite reverification inconclusive: {d}")
|
format!("composite reverification inconclusive: {d}")
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,37 @@ const _: () = assert!(
|
||||||
drop it from IMPACT_LATTICE_COVERED or add a rule that consumes it",
|
drop it from IMPACT_LATTICE_COVERED or add a rule that consumes it",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Precomputed standalone-rule table indexed by `Cap` bit position.
|
||||||
|
///
|
||||||
|
/// Built once at compile time from [`IMPACT_LATTICE`]. `Cap` is a
|
||||||
|
/// `bitflags!` u32, so each cap occupies one bit position 0..32; the
|
||||||
|
/// table stores the standalone [`ImpactCategory`] (if any) for that
|
||||||
|
/// position. [`lookup_impact`] uses this to short-circuit its
|
||||||
|
/// second-pass and third-pass walks in O(1).
|
||||||
|
static STANDALONE_BY_BIT: [Option<ImpactCategory>; 32] = build_standalone_table();
|
||||||
|
|
||||||
|
const fn build_standalone_table() -> [Option<ImpactCategory>; 32] {
|
||||||
|
let mut table = [None; 32];
|
||||||
|
let mut i = 0;
|
||||||
|
while i < IMPACT_LATTICE.len() {
|
||||||
|
let rule = IMPACT_LATTICE[i];
|
||||||
|
if rule.adjacent_cap.is_none() {
|
||||||
|
let bit = rule.source_cap.bits().trailing_zeros() as usize;
|
||||||
|
table[bit] = Some(rule.result);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
fn standalone_lookup(cap: Cap) -> Option<ImpactCategory> {
|
||||||
|
let bits = cap.bits();
|
||||||
|
if bits == 0 || bits.count_ones() != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
STANDALONE_BY_BIT[bits.trailing_zeros() as usize]
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up an [`ImpactCategory`] for a (source, adjacent) cap pair.
|
/// Look up an [`ImpactCategory`] for a (source, adjacent) cap pair.
|
||||||
///
|
///
|
||||||
/// `adjacent` is `None` when the caller has not yet found a partner
|
/// `adjacent` is `None` when the caller has not yet found a partner
|
||||||
|
|
@ -192,6 +223,12 @@ const _: () = assert!(
|
||||||
/// Phase 25's path search calls this once per candidate path with the
|
/// Phase 25's path search calls this once per candidate path with the
|
||||||
/// path's primary and secondary caps; multiple cap matches choose the
|
/// path's primary and secondary caps; multiple cap matches choose the
|
||||||
/// first rule in [`IMPACT_LATTICE`] order (specific before fallback).
|
/// first rule in [`IMPACT_LATTICE`] order (specific before fallback).
|
||||||
|
///
|
||||||
|
/// The standalone-rule walks (second + third pass) are O(1) via
|
||||||
|
/// [`STANDALONE_BY_BIT`]. The two-cap walk (first pass) stays linear
|
||||||
|
/// because the 2-cap subset is small (today: three rules); promote
|
||||||
|
/// to a sorted-pair binary search if the lattice grows past ~16
|
||||||
|
/// pair-rules.
|
||||||
pub fn lookup_impact(source: Cap, adjacent: Option<Cap>) -> Option<ImpactCategory> {
|
pub fn lookup_impact(source: Cap, adjacent: Option<Cap>) -> Option<ImpactCategory> {
|
||||||
// First pass: exact source + matching adjacency (or both ways).
|
// First pass: exact source + matching adjacency (or both ways).
|
||||||
if let Some(adj) = adjacent {
|
if let Some(adj) = adjacent {
|
||||||
|
|
@ -205,20 +242,16 @@ pub fn lookup_impact(source: Cap, adjacent: Option<Cap>) -> Option<ImpactCategor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Second pass: standalone rule on source_cap.
|
// Second pass: standalone rule on source_cap (O(1) table lookup).
|
||||||
for rule in IMPACT_LATTICE {
|
if let Some(cat) = standalone_lookup(source) {
|
||||||
if rule.adjacent_cap.is_none() && rule.source_cap == source {
|
return Some(cat);
|
||||||
return Some(rule.result);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Third pass: if `adjacent` is given but the pair didn't hit,
|
// Third pass: if `adjacent` is given but the pair didn't hit,
|
||||||
// try the standalone rule on adjacent_cap so a CODE_EXEC + UNRELATED
|
// try the standalone rule on adjacent_cap so a CODE_EXEC + UNRELATED
|
||||||
// pair still reaches `Rce`.
|
// pair still reaches `Rce`.
|
||||||
if let Some(adj) = adjacent {
|
if let Some(adj) = adjacent {
|
||||||
for rule in IMPACT_LATTICE {
|
if let Some(cat) = standalone_lookup(adj) {
|
||||||
if rule.adjacent_cap.is_none() && rule.source_cap == adj {
|
return Some(cat);
|
||||||
return Some(rule.result);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,5 @@ impl ChainGraph {
|
||||||
/// Phase 25's path-search code calls this as a fast-path before
|
/// Phase 25's path-search code calls this as a fast-path before
|
||||||
/// consulting the full [`IMPACT_LATTICE`].
|
/// consulting the full [`IMPACT_LATTICE`].
|
||||||
pub fn standalone_impact(cap: Cap) -> Option<ImpactCategory> {
|
pub fn standalone_impact(cap: Cap) -> Option<ImpactCategory> {
|
||||||
IMPACT_LATTICE
|
lookup_impact(cap, None)
|
||||||
.iter()
|
|
||||||
.find(|rule| rule.source_cap == cap && rule.adjacent_cap.is_none())
|
|
||||||
.map(|rule| rule.result)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,9 +217,25 @@ fn compose_chain(
|
||||||
let sink_cap = sole_cap(sink.cap_bits)?;
|
let sink_cap = sole_cap(sink.cap_bits)?;
|
||||||
let (impact, member_impacts) =
|
let (impact, member_impacts) =
|
||||||
resolve_impact(&path, sink_cap, entry, local_listener_present)?;
|
resolve_impact(&path, sink_cap, entry, local_listener_present)?;
|
||||||
Some(build_chain(entry, sink, &path, impact, &member_impacts))
|
let mut chain = build_chain(entry, sink, &path, impact, &member_impacts);
|
||||||
|
// SSRF + LocalListener refinement (Phase 24 deferred close): when
|
||||||
|
// the implied impact is `InternalNetworkAccess` AND the SurfaceMap
|
||||||
|
// exposes a loopback listener, the chain is more concrete than the
|
||||||
|
// bare lattice match — lift the score so it ranks above SSRF chains
|
||||||
|
// without a corroborating in-process target.
|
||||||
|
if impact == ImpactCategory::InternalNetworkAccess && local_listener_present {
|
||||||
|
chain.score *= LOCAL_LISTENER_BOOST;
|
||||||
|
}
|
||||||
|
Some(chain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Score multiplier applied when an `InternalNetworkAccess` chain has
|
||||||
|
/// a corroborating loopback listener in the SurfaceMap. Calibrated to
|
||||||
|
/// lift the chain above an otherwise-identical SSRF chain that lacks
|
||||||
|
/// the listener context, without overtaking strictly more severe
|
||||||
|
/// categories.
|
||||||
|
const LOCAL_LISTENER_BOOST: f64 = 1.5;
|
||||||
|
|
||||||
/// Pick the lowest-bit single [`Cap`] from `bits`, or `None` when no
|
/// Pick the lowest-bit single [`Cap`] from `bits`, or `None` when no
|
||||||
/// bit is set. Sinks in the SurfaceMap may carry multi-bit
|
/// bit is set. Sinks in the SurfaceMap may carry multi-bit
|
||||||
/// `cap_bits`; the DFS terminates against the lowest single bit so
|
/// `cap_bits`; the DFS terminates against the lowest single bit so
|
||||||
|
|
@ -557,6 +573,61 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssrf_with_local_listener_scores_higher_than_without() {
|
||||||
|
use crate::surface::{DataStore, DataStoreKind};
|
||||||
|
let edge = || -> ChainEdge {
|
||||||
|
edge_with(
|
||||||
|
"app.py",
|
||||||
|
10,
|
||||||
|
"taint-ssrf",
|
||||||
|
Cap::SSRF,
|
||||||
|
"/fetch",
|
||||||
|
HttpMethod::POST,
|
||||||
|
Feasibility::Confirmed,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut surface_no_listener = SurfaceMap::new();
|
||||||
|
surface_no_listener.nodes.push(entry("app.py", "/fetch", false));
|
||||||
|
surface_no_listener
|
||||||
|
.nodes
|
||||||
|
.push(sink("app.py", 20, "requests.get", Cap::SSRF));
|
||||||
|
let baseline = find_chains(
|
||||||
|
&[edge()],
|
||||||
|
&surface_no_listener,
|
||||||
|
ChainSearchConfig {
|
||||||
|
max_depth: 4,
|
||||||
|
min_score: 0.0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(baseline.len(), 1);
|
||||||
|
assert_eq!(baseline[0].implied_impact, ImpactCategory::InternalNetworkAccess);
|
||||||
|
|
||||||
|
let mut surface_with_listener = surface_no_listener.clone();
|
||||||
|
surface_with_listener
|
||||||
|
.nodes
|
||||||
|
.push(SurfaceNode::DataStore(DataStore {
|
||||||
|
location: loc("app.py", 5),
|
||||||
|
kind: DataStoreKind::KeyValue,
|
||||||
|
label: "redis://127.0.0.1:6379".into(),
|
||||||
|
}));
|
||||||
|
let boosted = find_chains(
|
||||||
|
&[edge()],
|
||||||
|
&surface_with_listener,
|
||||||
|
ChainSearchConfig {
|
||||||
|
max_depth: 4,
|
||||||
|
min_score: 0.0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(boosted.len(), 1);
|
||||||
|
assert_eq!(boosted[0].implied_impact, ImpactCategory::InternalNetworkAccess);
|
||||||
|
let ratio = boosted[0].score / baseline[0].score;
|
||||||
|
assert!(
|
||||||
|
(ratio - LOCAL_LISTENER_BOOST).abs() < 1e-9,
|
||||||
|
"expected ×{LOCAL_LISTENER_BOOST} boost, got ratio={ratio}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn score_threshold_drops_low_score_chains() {
|
fn score_threshold_drops_low_score_chains() {
|
||||||
let mut surface = SurfaceMap::new();
|
let mut surface = SurfaceMap::new();
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,68 @@ pub enum InconclusiveReason {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for InconclusiveReason {
|
||||||
|
/// Human-readable phrasing per variant. Used by callers that splice
|
||||||
|
/// the typed reason into a user-facing string (e.g. the
|
||||||
|
/// `reverify_reason` field on a chain finding). Consumers that need
|
||||||
|
/// structured access should read the enum variant directly via
|
||||||
|
/// `VerifyResult::inconclusive_reason`.
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::OracleCollisionSuspected => {
|
||||||
|
f.write_str("oracle collision suspected (marker matched without sink reach)")
|
||||||
|
}
|
||||||
|
Self::NonReproducible => f.write_str("repro artifact could not be written"),
|
||||||
|
Self::BuildFailed => f.write_str("harness build failed after retries"),
|
||||||
|
Self::SandboxError => f.write_str("sandbox error"),
|
||||||
|
Self::SpecDerivationFailed { tried, hint } => {
|
||||||
|
f.write_str("spec derivation failed (tried: ")?;
|
||||||
|
for (i, s) in tried.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
f.write_str(", ")?;
|
||||||
|
}
|
||||||
|
write!(f, "{s}")?;
|
||||||
|
}
|
||||||
|
write!(f, "; hint: {hint})")
|
||||||
|
}
|
||||||
|
Self::EntryKindUnsupported {
|
||||||
|
lang,
|
||||||
|
attempted,
|
||||||
|
supported,
|
||||||
|
hint,
|
||||||
|
} => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"entry kind {attempted:?} unsupported for {lang:?} (supported: "
|
||||||
|
)?;
|
||||||
|
for (i, k) in supported.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
f.write_str(", ")?;
|
||||||
|
}
|
||||||
|
write!(f, "{k:?}")?;
|
||||||
|
}
|
||||||
|
write!(f, "; hint: {hint})")
|
||||||
|
}
|
||||||
|
Self::NoBenignControl => {
|
||||||
|
f.write_str("no benign control payload available for differential confirmation")
|
||||||
|
}
|
||||||
|
Self::ReversedDifferential => f.write_str(
|
||||||
|
"reversed differential (benign payload fired, vulnerable payload did not)",
|
||||||
|
),
|
||||||
|
Self::UnrelatedCrash => {
|
||||||
|
f.write_str("harness crashed outside the instrumented sink")
|
||||||
|
}
|
||||||
|
Self::BackendInsufficient {
|
||||||
|
backend,
|
||||||
|
oracle_kind,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"{backend} backend cannot enforce isolation for {oracle_kind} oracle"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// High-level outcome of a dynamic verification attempt.
|
/// High-level outcome of a dynamic verification attempt.
|
||||||
///
|
///
|
||||||
/// Serializes as PascalCase (`"Confirmed"`, `"NotConfirmed"`, etc.).
|
/// Serializes as PascalCase (`"Confirmed"`, `"NotConfirmed"`, etc.).
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ fn composite_inconclusive_downgrades_one_bucket_and_records_reason() {
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.expect("reverify_reason recorded");
|
.expect("reverify_reason recorded");
|
||||||
assert!(
|
assert!(
|
||||||
reason.contains("BuildFailed"),
|
reason.contains("harness build failed"),
|
||||||
"reason carries typed inconclusive reason; got {reason:?}"
|
"reason carries typed inconclusive reason; got {reason:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue