use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::{ExportName, ModuleInfo};
use fallow_types::output::{FixAction, FixActionType, IssueAction};
use fallow_types::output_dead_code::{UnusedExportFinding, UnusedFileFinding};
use fallow_types::results::{
SecurityAttackSurfaceEntry, SecurityCandidateBoundary, SecurityDeadCodeContext,
SecurityDeadCodeKind, SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding,
SecurityFindingKind, SecurityReachability, SecurityRuntimeState, SecuritySeverity,
SecurityTaintFlow, SecurityZoneCrossing, TaintConfidence, TaintEndpoint, TaintPath, TraceHop,
TraceHopRole,
};
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use super::{LineOffsetsMap, byte_offset_to_line_col, catalogue::catalogue};
const UNUSED_FILE_GUIDANCE: &str = "This sink sits in a file fallow also reports as unused. Verify the dead-code finding, then delete the file instead of hardening the sink.";
const UNUSED_EXPORT_GUIDANCE: &str = "This sink sits on an export fallow also reports as unused. Verify the dead-code finding, then remove the export instead of hardening the sink.";
const ZERO_CONTROL_PROMPT: &str = "No known control library was detected on this path. Should validation, sanitization, or auth be required before this sink?";
const CONTROL_PRESENT_PROMPT: &str = "Known defensive controls were detected on this path. Are they sufficient for this sink and untrusted input?";
pub fn annotate_dead_code_cross_links(
graph: &ModuleGraph,
modules: &[ModuleInfo],
line_offsets_by_file: &LineOffsetsMap<'_>,
unused_files: &[UnusedFileFinding],
unused_exports: &[UnusedExportFinding],
findings: &mut [SecurityFinding],
) {
if findings.is_empty() || (unused_files.is_empty() && unused_exports.is_empty()) {
return;
}
let unused_file_paths: FxHashSet<&Path> =
unused_files.iter().map(|f| f.file.path.as_path()).collect();
let modules_by_id: FxHashMap<FileId, &ModuleInfo> = modules
.iter()
.map(|module| (module.file_id, module))
.collect();
let module_by_path: FxHashMap<&Path, &ModuleInfo> = graph
.modules
.iter()
.filter_map(|node| {
modules_by_id
.get(&node.file_id)
.map(|module| (node.path.as_path(), *module))
})
.collect();
for finding in findings {
if !matches!(finding.kind, SecurityFindingKind::TaintedSink) {
continue;
}
if unused_file_paths.contains(finding.path.as_path()) {
finding.dead_code = Some(SecurityDeadCodeContext {
kind: SecurityDeadCodeKind::UnusedFile,
export_name: None,
line: None,
guidance: UNUSED_FILE_GUIDANCE.to_string(),
});
prepend_dead_code_action(finding);
continue;
}
if let Some(export) = matching_unused_export(
module_by_path.get(finding.path.as_path()).copied(),
line_offsets_by_file,
unused_exports,
finding,
) {
finding.dead_code = Some(SecurityDeadCodeContext {
kind: SecurityDeadCodeKind::UnusedExport,
export_name: Some(export.export.export_name.clone()),
line: Some(export.export.line),
guidance: UNUSED_EXPORT_GUIDANCE.to_string(),
});
prepend_dead_code_action(finding);
}
}
}
fn matching_unused_export<'a>(
module: Option<&ModuleInfo>,
line_offsets_by_file: &LineOffsetsMap<'_>,
unused_exports: &'a [UnusedExportFinding],
finding: &SecurityFinding,
) -> Option<&'a UnusedExportFinding> {
let same_file = unused_exports
.iter()
.filter(|export| export.export.path == finding.path);
if let Some(module) = module {
for export in same_file.clone() {
let Some(info) = module
.exports
.iter()
.find(|info| export_name_matches(&info.name, &export.export.export_name))
else {
continue;
};
let (start_line, _) =
byte_offset_to_line_col(line_offsets_by_file, module.file_id, info.span.start);
let (end_line, _) =
byte_offset_to_line_col(line_offsets_by_file, module.file_id, info.span.end);
if start_line <= finding.line && finding.line <= end_line.max(start_line) {
return Some(export);
}
}
}
same_file
.into_iter()
.find(|export| export.export.line == finding.line)
}
fn export_name_matches(name: &ExportName, candidate: &str) -> bool {
match name {
ExportName::Named(name) => name == candidate,
ExportName::Default => candidate == "default",
}
}
fn prepend_dead_code_action(finding: &mut SecurityFinding) {
let Some(context) = &finding.dead_code else {
return;
};
let action = match context.kind {
SecurityDeadCodeKind::UnusedFile => IssueAction::Fix(FixAction {
kind: FixActionType::DeleteFile,
auto_fixable: false,
description: "Delete this unused file instead of hardening the sink".to_string(),
note: Some(
"Verify the unused-file finding before deleting production code".to_string(),
),
available_in_catalogs: None,
suggested_target: None,
}),
SecurityDeadCodeKind::UnusedExport => IssueAction::Fix(FixAction {
kind: FixActionType::RemoveExport,
auto_fixable: false,
description: "Remove the unused export instead of hardening the sink".to_string(),
note: context
.export_name
.as_ref()
.map(|name| format!("Verify that export `{name}` is unused before removing it")),
available_in_catalogs: None,
suggested_target: None,
}),
};
finding.actions.insert(0, action);
}
pub fn rank_security_findings(
graph: &ModuleGraph,
modules: &[ModuleInfo],
line_offsets_by_file: &LineOffsetsMap<'_>,
declared_deps: &FxHashSet<String>,
boundary_crossings: &FxHashMap<PathBuf, (String, String)>,
findings: &mut [SecurityFinding],
) {
if findings.is_empty() {
return;
}
let path_to_id: FxHashMap<&Path, FileId> = graph
.modules
.iter()
.map(|node| (node.path.as_path(), node.file_id))
.collect();
let source_index = UntrustedSourceIndex::build(graph, modules, declared_deps);
let modules_by_id: FxHashMap<FileId, &ModuleInfo> = modules
.iter()
.map(|module| (module.file_id, module))
.collect();
let modules_by_path: FxHashMap<&Path, &ModuleInfo> = graph
.modules
.iter()
.filter_map(|node| {
modules_by_id
.get(&node.file_id)
.map(|module| (node.path.as_path(), *module))
})
.collect();
for finding in findings.iter_mut() {
let reachability = path_to_id.get(finding.path.as_path()).map(|&file_id| {
compute_reachability(
graph,
file_id,
finding,
boundary_crossings,
&source_index,
line_offsets_by_file,
)
});
finding.reachability = reachability;
enrich_candidate(finding, boundary_crossings.get(&finding.path));
finding.attack_surface =
build_attack_surface(finding, &modules_by_path, line_offsets_by_file);
finding.severity = derive_security_severity(finding);
}
findings.sort_by(|a, b| {
let (ra, rb) = (a.reachability.as_ref(), b.reachability.as_ref());
let reach_a = ra.is_some_and(|r| r.reachable_from_entry);
let reach_b = rb.is_some_and(|r| r.reachable_from_entry);
reach_b
.cmp(&reach_a)
.then_with(|| b.source_backed.cmp(&a.source_backed))
.then_with(|| {
let source_a = ra.is_some_and(|r| r.reachable_from_untrusted_source);
let source_b = rb.is_some_and(|r| r.reachable_from_untrusted_source);
source_b.cmp(&source_a)
})
.then_with(|| {
let ba = ra.map_or(0, |r| r.blast_radius);
let bb = rb.map_or(0, |r| r.blast_radius);
bb.cmp(&ba)
})
.then_with(|| {
let ca = ra.is_some_and(|r| r.crosses_boundary);
let cb = rb.is_some_and(|r| r.crosses_boundary);
cb.cmp(&ca)
})
.then_with(|| a.dead_code.is_some().cmp(&b.dead_code.is_some()))
.then_with(|| a.path.cmp(&b.path))
.then_with(|| a.line.cmp(&b.line))
.then_with(|| a.col.cmp(&b.col))
.then_with(|| a.category.cmp(&b.category))
});
}
#[must_use]
pub fn derive_security_severity(finding: &SecurityFinding) -> SecuritySeverity {
if finding
.runtime
.as_ref()
.is_some_and(|runtime| runtime.state == SecurityRuntimeState::RuntimeHot)
|| finding.candidate.boundary.client_server
|| finding
.candidate
.boundary
.architecture_zone
.as_ref()
.is_some()
|| finding
.reachability
.as_ref()
.is_some_and(|reach| reach.crosses_boundary)
|| finding
.reachability
.as_ref()
.is_some_and(|reach| reach.reachable_from_entry && finding.source_backed)
{
return SecuritySeverity::High;
}
if finding.source_backed
|| finding
.reachability
.as_ref()
.is_some_and(|reach| reach.reachable_from_untrusted_source)
{
return SecuritySeverity::Medium;
}
SecuritySeverity::Low
}
fn compute_reachability(
graph: &ModuleGraph,
file_id: FileId,
finding: &SecurityFinding,
boundary_crossings: &FxHashMap<PathBuf, (String, String)>,
source_index: &UntrustedSourceIndex,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> SecurityReachability {
let reachable_from_entry = graph
.modules
.get(file_id.0 as usize)
.is_some_and(|node| node.is_runtime_reachable());
let source_trace = source_index.trace_for(graph, file_id, finding, line_offsets_by_file);
SecurityReachability {
reachable_from_entry,
reachable_from_untrusted_source: source_trace.is_some(),
taint_confidence: source_trace.as_ref().map(|_| {
if finding.source_backed {
TaintConfidence::ArgLevel
} else {
TaintConfidence::ModuleLevel
}
}),
untrusted_source_hop_count: source_trace.as_ref().map(|source| source.hop_count),
untrusted_source_trace: source_trace.map_or_else(Vec::new, |source| source.trace),
blast_radius: transitive_dependent_count(graph, file_id),
crosses_boundary: boundary_crossings.contains_key(&finding.path),
}
}
fn enrich_candidate(finding: &mut SecurityFinding, zone: Option<&(String, String)>) {
let client_server = finding
.trace
.iter()
.any(|hop| hop.role == TraceHopRole::ClientBoundary);
let hop_count = finding
.reachability
.as_ref()
.and_then(|reach| reach.untrusted_source_hop_count);
finding.candidate.boundary = SecurityCandidateBoundary {
client_server,
cross_module: hop_count.is_some_and(|count| count > 0),
architecture_zone: zone.map(|(from, to)| SecurityZoneCrossing {
from: from.clone(),
to: to.clone(),
}),
};
finding.taint_flow = build_taint_flow(finding);
}
fn build_taint_flow(finding: &SecurityFinding) -> Option<SecurityTaintFlow> {
let reach = finding.reachability.as_ref()?;
if !reach.reachable_from_untrusted_source {
return None;
}
let first = reach.untrusted_source_trace.first()?;
let last = reach.untrusted_source_trace.last()?;
let hop_count = reach.untrusted_source_hop_count.unwrap_or(0);
Some(SecurityTaintFlow {
source: TaintEndpoint {
path: first.path.clone(),
line: first.line,
col: first.col,
},
sink: TaintEndpoint {
path: last.path.clone(),
line: last.line,
col: last.col,
},
path: TaintPath {
intra_module: hop_count == 0,
cross_module_hops: hop_count,
},
})
}
fn build_attack_surface(
finding: &SecurityFinding,
modules_by_path: &FxHashMap<&Path, &ModuleInfo>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Option<SecurityAttackSurfaceEntry> {
let flow = finding.taint_flow.as_ref()?;
let reach = finding.reachability.as_ref()?;
let path = reach.untrusted_source_trace.clone();
if path.is_empty() {
return None;
}
let controls = defensive_controls_for_path(&path, modules_by_path, line_offsets_by_file);
let verification_prompt = if controls.is_empty() {
ZERO_CONTROL_PROMPT
} else {
CONTROL_PRESENT_PROMPT
}
.to_string();
Some(SecurityAttackSurfaceEntry {
source: flow.source.clone(),
sink: finding.candidate.sink.clone(),
path,
defensive_boundary: SecurityDefensiveBoundary {
controls,
verification_prompt,
},
})
}
fn defensive_controls_for_path(
path: &[TraceHop],
modules_by_path: &FxHashMap<&Path, &ModuleInfo>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<SecurityDefensiveControl> {
let mut controls = Vec::new();
let mut seen_files = FxHashSet::default();
for hop in path {
if !seen_files.insert(hop.path.as_path()) {
continue;
}
let Some(module) = modules_by_path.get(hop.path.as_path()).copied() else {
continue;
};
for control in &module.security_control_sites {
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, module.file_id, control.span_start);
controls.push(SecurityDefensiveControl {
kind: control.kind,
path: hop.path.clone(),
line,
col,
callee: control.callee_path.clone(),
});
}
}
controls.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.line.cmp(&b.line))
.then_with(|| a.col.cmp(&b.col))
.then_with(|| a.callee.cmp(&b.callee))
.then_with(|| a.kind.cmp(&b.kind))
});
controls.dedup_by(|a, b| {
a.path == b.path
&& a.line == b.line
&& a.col == b.col
&& a.kind == b.kind
&& a.callee == b.callee
});
controls
}
#[derive(Debug, Clone, Copy)]
struct SourceParent {
previous: FileId,
import_span_start: Option<u32>,
}
struct UntrustedSourceIndex {
source_for: Vec<Option<FileId>>,
parent: Vec<Option<SourceParent>>,
}
struct UntrustedSourceTrace {
hop_count: u32,
trace: Vec<TraceHop>,
}
impl UntrustedSourceIndex {
fn build(
graph: &ModuleGraph,
modules: &[ModuleInfo],
declared_deps: &FxHashSet<String>,
) -> Self {
let modules_by_id: FxHashMap<FileId, &ModuleInfo> = modules
.iter()
.map(|module| (module.file_id, module))
.collect();
let mut source_for = vec![None; graph.modules.len()];
let mut parent = vec![None; graph.modules.len()];
let mut queue: VecDeque<FileId> = VecDeque::new();
for node in &graph.modules {
let Some(module) = modules_by_id.get(&node.file_id) else {
continue;
};
if !module_contains_untrusted_source(module, declared_deps) {
continue;
}
let idx = node.file_id.0 as usize;
if idx >= source_for.len() || source_for[idx].is_some() {
continue;
}
source_for[idx] = Some(node.file_id);
queue.push_back(node.file_id);
}
while let Some(current) = queue.pop_front() {
let Some(source_id) = source_for.get(current.0 as usize).copied().flatten() else {
continue;
};
for (target, all_type_only, span) in graph.outgoing_edge_summaries(current) {
if all_type_only {
continue;
}
let idx = target.0 as usize;
if idx >= source_for.len() || source_for[idx].is_some() {
continue;
}
source_for[idx] = Some(source_id);
parent[idx] = Some(SourceParent {
previous: current,
import_span_start: span,
});
queue.push_back(target);
}
}
Self { source_for, parent }
}
fn trace_for(
&self,
graph: &ModuleGraph,
sink_id: FileId,
finding: &SecurityFinding,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Option<UntrustedSourceTrace> {
if !is_source_reachability_candidate(finding) {
return None;
}
let source_id = self.source_for.get(sink_id.0 as usize).copied().flatten()?;
let mut ids = vec![sink_id];
let mut current = sink_id;
while current != source_id {
let parent = self.parent.get(current.0 as usize).copied().flatten()?;
current = parent.previous;
ids.push(current);
}
ids.reverse();
let hop_count = u32::try_from(ids.len().saturating_sub(1)).unwrap_or(u32::MAX);
if source_id == sink_id {
let (source_line, source_col, source_role) = finding
.source_read
.map_or((1, 0, TraceHopRole::ModuleSource), |(line, col)| {
(line, col, TraceHopRole::UntrustedSource)
});
return Some(UntrustedSourceTrace {
hop_count,
trace: vec![
TraceHop {
path: finding.path.clone(),
line: source_line,
col: source_col,
role: source_role,
},
TraceHop {
path: finding.path.clone(),
line: finding.line,
col: finding.col,
role: TraceHopRole::Sink,
},
],
});
}
let mut trace = Vec::with_capacity(ids.len().saturating_add(1));
for (idx, &file_id) in ids.iter().enumerate() {
let path = graph.modules.get(file_id.0 as usize)?.path.clone();
if idx == ids.len() - 1 {
trace.push(TraceHop {
path,
line: finding.line,
col: finding.col,
role: TraceHopRole::Sink,
});
continue;
}
let Some(&next_id) = ids.get(idx + 1) else {
continue;
};
let next_parent = self.parent.get(next_id.0 as usize).copied().flatten();
let (line, col) = next_parent
.and_then(|p| p.import_span_start)
.map_or((1, 0), |span| {
byte_offset_to_line_col(line_offsets_by_file, file_id, span)
});
trace.push(TraceHop {
path,
line,
col,
role: if idx == 0 {
TraceHopRole::ModuleSource
} else {
TraceHopRole::Intermediate
},
});
}
Some(UntrustedSourceTrace { hop_count, trace })
}
}
fn is_source_reachability_candidate(finding: &SecurityFinding) -> bool {
matches!(finding.kind, SecurityFindingKind::TaintedSink)
&& finding.category.as_deref() != Some(super::hardcoded_secret::CATEGORY_ID)
}
fn module_contains_untrusted_source(
module: &ModuleInfo,
declared_deps: &FxHashSet<String>,
) -> bool {
let cat = catalogue();
module.tainted_bindings.iter().any(|binding| {
cat.matching_source_for_deps(&binding.source_path, declared_deps)
.is_some()
}) || module.security_sinks.iter().any(|sink| {
sink.arg_source_paths
.iter()
.any(|path| cat.matching_source_for_deps(path, declared_deps).is_some())
}) || module.member_accesses.iter().any(|access| {
let full_path = format!("{}.{}", access.object, access.member);
cat.matching_source_for_deps(&full_path, declared_deps)
.is_some()
|| cat
.matching_source_for_deps(&access.object, declared_deps)
.is_some()
})
}
fn transitive_dependent_count(graph: &ModuleGraph, target: FileId) -> u32 {
let mut visited: FxHashSet<FileId> = FxHashSet::default();
let mut queue: VecDeque<FileId> = VecDeque::new();
queue.push_back(target);
visited.insert(target);
while let Some(current) = queue.pop_front() {
let Some(dependents) = graph.reverse_deps.get(current.0 as usize) else {
continue;
};
for &dep in dependents {
if visited.insert(dep) {
queue.push_back(dep);
}
}
}
u32::try_from(visited.len().saturating_sub(1)).unwrap_or(u32::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
use fallow_types::extract::{
MemberAccess, SecurityControlKind, SecurityControlSite, TaintedBinding,
};
use fallow_types::output::{FixActionType, IssueAction};
use fallow_types::output_dead_code::{UnusedExportFinding, UnusedFileFinding};
use fallow_types::results::{
SecurityDeadCodeKind, SecurityFindingKind, SecurityRuntimeContext, TraceHop, TraceHopRole,
UnusedExport, UnusedFile,
};
use crate::graph::ModuleGraph;
use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
const ROOT: &str = "/proj";
fn build_graph(file_names: &[&str], edges: &[(usize, usize)], entry: &[usize]) -> ModuleGraph {
let edges: Vec<(usize, usize, bool)> =
edges.iter().map(|&(from, to)| (from, to, false)).collect();
build_graph_with_type_edges(file_names, &edges, entry)
}
fn build_graph_with_type_edges(
file_names: &[&str],
edges: &[(usize, usize, bool)],
entry: &[usize],
) -> ModuleGraph {
let files: Vec<DiscoveredFile> = file_names
.iter()
.enumerate()
.map(|(i, name)| DiscoveredFile {
id: FileId(i as u32),
path: PathBuf::from(ROOT).join(name),
size_bytes: 100,
})
.collect();
let resolved: Vec<ResolvedModule> = files
.iter()
.map(|f| {
let imports: Vec<ResolvedImport> = edges
.iter()
.filter(|(from, _, _)| *from == f.id.0 as usize)
.map(|&(_, to, is_type_only)| ResolvedImport {
target: ResolveResult::InternalModule(FileId(to as u32)),
info: fallow_types::extract::ImportInfo {
source: format!("./{}", file_names[to]),
imported_name: fallow_types::extract::ImportedName::Default,
local_name: "x".to_string(),
is_type_only,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::new(0, 10),
},
})
.collect();
ResolvedModule {
file_id: f.id,
path: f.path.clone(),
exports: vec![],
re_exports: vec![],
resolved_imports: imports,
resolved_dynamic_imports: vec![],
resolved_dynamic_patterns: vec![],
member_accesses: vec![],
whole_object_uses: vec![],
has_cjs_exports: false,
has_angular_component_template_url: false,
unused_import_bindings: FxHashSet::default(),
type_referenced_import_bindings: vec![],
value_referenced_import_bindings: vec![],
namespace_object_aliases: vec![],
}
})
.collect();
let entry_points: Vec<EntryPoint> = entry
.iter()
.map(|&i| EntryPoint {
path: files[i].path.clone(),
source: EntryPointSource::ManualEntry,
})
.collect();
ModuleGraph::build(&resolved, &entry_points, &files)
}
fn rank(
graph: &ModuleGraph,
boundary_anchor_paths: &FxHashSet<PathBuf>,
findings: &mut [SecurityFinding],
) {
let modules = Vec::new();
let line_offsets = FxHashMap::default();
let declared_deps = FxHashSet::default();
let boundary_crossings: FxHashMap<PathBuf, (String, String)> = boundary_anchor_paths
.iter()
.map(|path| (path.clone(), ("from".to_string(), "to".to_string())))
.collect();
rank_security_findings(
graph,
&modules,
&line_offsets,
&declared_deps,
&boundary_crossings,
findings,
);
}
fn rank_with_modules(
graph: &ModuleGraph,
modules: &[ModuleInfo],
findings: &mut [SecurityFinding],
) {
let line_offsets = FxHashMap::default();
let declared_deps = FxHashSet::default();
let boundary_crossings = FxHashMap::default();
rank_security_findings(
graph,
modules,
&line_offsets,
&declared_deps,
&boundary_crossings,
findings,
);
}
fn module(file_id: u32) -> ModuleInfo {
ModuleInfo {
file_id: FileId(file_id),
exports: vec![],
imports: vec![],
re_exports: vec![],
dynamic_imports: vec![],
dynamic_import_patterns: vec![],
require_calls: vec![],
package_path_references: vec![],
member_accesses: vec![],
whole_object_uses: vec![],
has_cjs_exports: false,
has_angular_component_template_url: false,
content_hash: 0,
suppressions: vec![],
unknown_suppression_kinds: vec![],
unused_import_bindings: vec![],
type_referenced_import_bindings: vec![],
value_referenced_import_bindings: vec![],
line_offsets: vec![],
complexity: vec![],
flag_uses: vec![],
class_heritage: vec![],
injection_tokens: vec![],
local_type_declarations: vec![],
public_signature_type_references: vec![],
namespace_object_aliases: vec![],
iconify_prefixes: vec![],
iconify_icon_names: vec![],
auto_import_candidates: vec![],
directives: vec![],
security_sinks: vec![],
security_sinks_skipped: 0,
tainted_bindings: vec![],
sanitized_sink_args: vec![],
security_control_sites: vec![],
}
}
fn member_source_module(file_id: u32) -> ModuleInfo {
let mut module = module(file_id);
module.member_accesses.push(MemberAccess {
object: "req".to_string(),
member: "body".to_string(),
});
module
}
fn tainted_binding_source_module(file_id: u32) -> ModuleInfo {
let mut module = module(file_id);
module.tainted_bindings.push(TaintedBinding {
local: "body".to_string(),
source_path: "req.body".to_string(),
source_span_start: 0,
});
module
}
fn validation_control_module(file_id: u32) -> ModuleInfo {
let mut module = module(file_id);
module.security_control_sites.push(SecurityControlSite {
kind: SecurityControlKind::Validation,
callee_path: "schema.parse".to_string(),
span_start: 0,
span_end: 12,
});
module
}
fn finding(name: &str) -> SecurityFinding {
use fallow_types::results::{SecurityCandidate, SecurityCandidateSink};
let path = PathBuf::from(ROOT).join(name);
SecurityFinding {
finding_id: String::new(),
kind: SecurityFindingKind::TaintedSink,
category: Some("dangerous-html".to_string()),
cwe: Some(79),
path: path.clone(),
line: 1,
col: 0,
evidence: "candidate".to_string(),
trace: vec![TraceHop {
path: path.clone(),
line: 1,
col: 0,
role: TraceHopRole::Sink,
}],
actions: Vec::<IssueAction>::new(),
dead_code: None,
reachability: None,
source_backed: false,
source_read: None,
severity: SecuritySeverity::Low,
candidate: SecurityCandidate {
source_kind: None,
sink: SecurityCandidateSink {
path,
line: 1,
col: 0,
category: Some("dangerous-html".to_string()),
cwe: Some(79),
callee: None,
},
boundary: SecurityCandidateBoundary::default(),
network: None,
},
taint_flow: None,
runtime: None,
attack_surface: None,
}
}
fn reachability(
reachable_from_entry: bool,
reachable_from_untrusted_source: bool,
crosses_boundary: bool,
) -> SecurityReachability {
SecurityReachability {
reachable_from_entry,
reachable_from_untrusted_source,
taint_confidence: None,
untrusted_source_hop_count: None,
untrusted_source_trace: vec![],
blast_radius: 1,
crosses_boundary,
}
}
#[test]
fn derives_low_severity_for_baseline_candidate() {
assert_eq!(
derive_security_severity(&finding("sink.ts")),
SecuritySeverity::Low
);
}
#[test]
fn derives_medium_severity_for_source_signals() {
let mut source_backed = finding("source-backed.ts");
source_backed.source_backed = true;
let mut source_reachable = finding("source-reachable.ts");
source_reachable.reachability = Some(reachability(false, true, false));
assert_eq!(
derive_security_severity(&source_backed),
SecuritySeverity::Medium
);
assert_eq!(
derive_security_severity(&source_reachable),
SecuritySeverity::Medium
);
}
#[test]
fn derives_high_severity_for_boundary_entry_and_runtime_signals() {
let mut client_boundary = finding("client-boundary.ts");
client_boundary.candidate.boundary.client_server = true;
let mut architecture_boundary = finding("architecture-boundary.ts");
architecture_boundary.candidate.boundary.architecture_zone = Some(SecurityZoneCrossing {
from: "web".to_string(),
to: "server".to_string(),
});
let mut crossed_boundary = finding("crossed-boundary.ts");
crossed_boundary.reachability = Some(reachability(false, false, true));
let mut source_backed_entry = finding("source-backed-entry.ts");
source_backed_entry.source_backed = true;
source_backed_entry.reachability = Some(reachability(true, false, false));
let mut runtime_hot = finding("runtime-hot.ts");
runtime_hot.runtime = Some(SecurityRuntimeContext {
state: SecurityRuntimeState::RuntimeHot,
function: "handler".to_string(),
line: 1,
invocations: Some(500),
stable_id: Some("fallow:fn:test".to_string()),
evidence: Some("runtime hot path".to_string()),
});
for finding in [
client_boundary,
architecture_boundary,
crossed_boundary,
source_backed_entry,
runtime_hot,
] {
assert_eq!(derive_security_severity(&finding), SecuritySeverity::High);
}
}
#[test]
fn reachable_from_entry_sorts_first() {
let graph = build_graph(&["entry.ts", "reachable.ts", "orphan.ts"], &[(0, 1)], &[0]);
let empty = FxHashSet::default();
let mut findings = vec![finding("orphan.ts"), finding("reachable.ts")];
rank(&graph, &empty, &mut findings);
assert!(findings[0].path.ends_with("reachable.ts"));
assert!(
findings[0]
.reachability
.as_ref()
.expect("ranked")
.reachable_from_entry
);
assert!(findings[1].path.ends_with("orphan.ts"));
assert!(
!findings[1]
.reachability
.as_ref()
.expect("ranked")
.reachable_from_entry
);
}
#[test]
fn attack_surface_records_detected_controls_on_source_to_sink_path() {
let graph = build_graph(&["source.ts", "sink.ts"], &[(0, 1)], &[0]);
let modules = vec![
tainted_binding_source_module(0),
validation_control_module(1),
];
let mut findings = vec![finding("sink.ts")];
rank_with_modules(&graph, &modules, &mut findings);
let surface = findings[0].attack_surface.as_ref().expect("surface entry");
assert_eq!(surface.source.path, PathBuf::from(ROOT).join("source.ts"));
assert_eq!(surface.sink.path, PathBuf::from(ROOT).join("sink.ts"));
assert_eq!(surface.defensive_boundary.controls.len(), 1);
assert_eq!(
surface.defensive_boundary.controls[0].kind,
SecurityControlKind::Validation
);
assert!(
surface
.defensive_boundary
.verification_prompt
.contains("Are they sufficient")
);
}
#[test]
fn attack_surface_zero_control_prompt_is_a_question() {
let graph = build_graph(&["source.ts", "sink.ts"], &[(0, 1)], &[0]);
let modules = vec![tainted_binding_source_module(0), module(1)];
let mut findings = vec![finding("sink.ts")];
rank_with_modules(&graph, &modules, &mut findings);
let prompt = &findings[0]
.attack_surface
.as_ref()
.expect("surface entry")
.defensive_boundary
.verification_prompt;
assert!(prompt.ends_with('?'));
assert!(prompt.contains("No known control library"));
}
#[test]
fn higher_blast_radius_wins_among_reachable() {
let graph = build_graph(
&["entry.ts", "hub.ts", "leaf.ts", "extra.ts"],
&[(0, 1), (0, 2), (3, 1)],
&[0, 3],
);
let empty = FxHashSet::default();
let mut findings = vec![finding("leaf.ts"), finding("hub.ts")];
rank(&graph, &empty, &mut findings);
assert!(findings[0].path.ends_with("hub.ts"));
let hub = findings[0].reachability.as_ref().expect("ranked");
let leaf = findings[1].reachability.as_ref().expect("ranked");
assert!(hub.reachable_from_entry && leaf.reachable_from_entry);
assert!(
hub.blast_radius > leaf.blast_radius,
"hub {} should exceed leaf {}",
hub.blast_radius,
leaf.blast_radius
);
}
#[test]
fn boundary_crossing_breaks_tie() {
let graph = build_graph(&["entry.ts", "a.ts", "b.ts"], &[(0, 1), (0, 2)], &[0]);
let mut boundary = FxHashSet::default();
boundary.insert(PathBuf::from(ROOT).join("b.ts"));
let mut findings = vec![finding("a.ts"), finding("b.ts")];
rank(&graph, &boundary, &mut findings);
assert!(findings[0].path.ends_with("b.ts"));
assert!(
findings[0]
.reachability
.as_ref()
.expect("ranked")
.crosses_boundary
);
assert!(
!findings[1]
.reachability
.as_ref()
.expect("ranked")
.crosses_boundary
);
}
#[test]
fn full_tie_is_deterministic_by_path() {
let graph = build_graph(&["entry.ts", "a.ts", "b.ts"], &[(0, 1), (0, 2)], &[0]);
let empty = FxHashSet::default();
let mut findings = vec![finding("b.ts"), finding("a.ts")];
rank(&graph, &empty, &mut findings);
assert!(findings[0].path.ends_with("a.ts"));
assert!(findings[1].path.ends_with("b.ts"));
}
#[test]
fn dead_code_cross_link_marks_unused_file_sink() {
let graph = build_graph(&["dead.ts"], &[], &[]);
let mut findings = vec![finding("dead.ts")];
let unused_files = vec![UnusedFileFinding::with_actions(UnusedFile {
path: PathBuf::from(ROOT).join("dead.ts"),
})];
let line_offsets = FxHashMap::default();
annotate_dead_code_cross_links(
&graph,
&[],
&line_offsets,
&unused_files,
&[],
&mut findings,
);
let context = findings[0].dead_code.as_ref().expect("dead-code context");
assert_eq!(context.kind, SecurityDeadCodeKind::UnusedFile);
assert_eq!(context.export_name, None);
match &findings[0].actions[0] {
IssueAction::Fix(action) => assert_eq!(action.kind, FixActionType::DeleteFile),
other => panic!("expected delete-file action, got {other:?}"),
}
}
#[test]
fn dead_code_cross_link_marks_same_line_unused_export_sink() {
let graph = build_graph(&["sink.ts"], &[], &[]);
let mut findings = vec![finding("sink.ts")];
let unused_exports = vec![UnusedExportFinding::with_actions(UnusedExport {
path: PathBuf::from(ROOT).join("sink.ts"),
export_name: "dangerous".to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
})];
let line_offsets = FxHashMap::default();
annotate_dead_code_cross_links(
&graph,
&[],
&line_offsets,
&[],
&unused_exports,
&mut findings,
);
let context = findings[0].dead_code.as_ref().expect("dead-code context");
assert_eq!(context.kind, SecurityDeadCodeKind::UnusedExport);
assert_eq!(context.export_name.as_deref(), Some("dangerous"));
assert_eq!(context.line, Some(1));
match &findings[0].actions[0] {
IssueAction::Fix(action) => assert_eq!(action.kind, FixActionType::RemoveExport),
other => panic!("expected remove-export action, got {other:?}"),
}
}
#[test]
fn dead_code_cross_link_skips_client_server_leak_findings() {
let graph = build_graph(&["dead.ts"], &[], &[]);
let mut findings = vec![finding("dead.ts")];
findings[0].kind = SecurityFindingKind::ClientServerLeak;
let unused_files = vec![UnusedFileFinding::with_actions(UnusedFile {
path: PathBuf::from(ROOT).join("dead.ts"),
})];
let line_offsets = FxHashMap::default();
annotate_dead_code_cross_links(
&graph,
&[],
&line_offsets,
&unused_files,
&[],
&mut findings,
);
assert!(findings[0].dead_code.is_none());
assert!(findings[0].actions.is_empty());
}
#[test]
fn active_code_sorts_ahead_of_dead_code_when_rank_signals_tie() {
let graph = build_graph(
&["entry.ts", "active.ts", "dead.ts"],
&[(0, 1), (0, 2)],
&[0],
);
let empty = FxHashSet::default();
let mut dead = finding("dead.ts");
dead.dead_code = Some(SecurityDeadCodeContext {
kind: SecurityDeadCodeKind::UnusedFile,
export_name: None,
line: None,
guidance: UNUSED_FILE_GUIDANCE.to_string(),
});
let mut findings = vec![dead, finding("active.ts")];
rank(&graph, &empty, &mut findings);
assert!(findings[0].path.ends_with("active.ts"));
assert!(findings[0].dead_code.is_none());
assert!(findings[1].path.ends_with("dead.ts"));
assert!(findings[1].dead_code.is_some());
}
#[test]
fn empty_findings_is_noop() {
let graph = build_graph(&["entry.ts"], &[], &[0]);
let empty = FxHashSet::default();
let mut findings: Vec<SecurityFinding> = vec![];
rank(&graph, &empty, &mut findings);
assert!(findings.is_empty());
}
#[test]
fn untrusted_source_reachability_uses_value_import_path() {
let graph = build_graph(&["handler.ts", "helper.ts"], &[(0, 1)], &[]);
let modules = vec![member_source_module(0), module(1)];
let mut findings = vec![finding("helper.ts")];
rank_with_modules(&graph, &modules, &mut findings);
let reach = findings[0].reachability.as_ref().expect("ranked");
assert!(reach.reachable_from_untrusted_source);
assert_eq!(reach.untrusted_source_hop_count, Some(1));
assert_eq!(reach.taint_confidence, Some(TaintConfidence::ModuleLevel));
assert_eq!(
reach
.untrusted_source_trace
.iter()
.map(|hop| hop.role)
.collect::<Vec<_>>(),
vec![TraceHopRole::ModuleSource, TraceHopRole::Sink]
);
}
#[test]
fn arg_level_same_file_finding_anchors_source_node_at_read_line() {
let graph = build_graph(&["handler.ts"], &[], &[]);
let modules = vec![tainted_binding_source_module(0)];
let mut arg_level = finding("handler.ts");
arg_level.source_backed = true;
arg_level.source_read = Some((7, 4));
let mut findings = vec![arg_level];
rank_with_modules(&graph, &modules, &mut findings);
let reach = findings[0].reachability.as_ref().expect("ranked");
assert!(reach.reachable_from_untrusted_source);
assert_eq!(reach.taint_confidence, Some(TaintConfidence::ArgLevel));
let source_hop = reach.untrusted_source_trace.first().expect("source node");
assert_eq!(source_hop.role, TraceHopRole::UntrustedSource);
assert_eq!((source_hop.line, source_hop.col), (7, 4));
}
#[test]
fn untrusted_source_reachability_skips_type_only_import_path() {
let graph = build_graph_with_type_edges(&["handler.ts", "helper.ts"], &[(0, 1, true)], &[]);
let modules = vec![member_source_module(0), module(1)];
let mut findings = vec![finding("helper.ts")];
rank_with_modules(&graph, &modules, &mut findings);
let reach = findings[0].reachability.as_ref().expect("ranked");
assert!(!reach.reachable_from_untrusted_source);
assert_eq!(reach.untrusted_source_hop_count, None);
assert!(reach.untrusted_source_trace.is_empty());
}
#[test]
fn same_file_untrusted_source_and_sink_has_zero_hop_trace() {
let graph = build_graph(&["handler.ts"], &[], &[]);
let modules = vec![tainted_binding_source_module(0)];
let mut findings = vec![finding("handler.ts")];
rank_with_modules(&graph, &modules, &mut findings);
let reach = findings[0].reachability.as_ref().expect("ranked");
assert!(reach.reachable_from_untrusted_source);
assert_eq!(reach.untrusted_source_hop_count, Some(0));
assert_eq!(reach.taint_confidence, Some(TaintConfidence::ModuleLevel));
assert_eq!(
reach
.untrusted_source_trace
.iter()
.map(|hop| hop.role)
.collect::<Vec<_>>(),
vec![TraceHopRole::ModuleSource, TraceHopRole::Sink]
);
}
#[test]
fn source_backed_sorts_ahead_of_module_level_source_when_entry_ties() {
let graph = build_graph(
&["entry.ts", "source.ts", "module.ts", "direct.ts"],
&[(0, 1), (0, 2), (0, 3), (1, 2)],
&[0],
);
let modules = vec![
module(0),
member_source_module(1),
module(2),
tainted_binding_source_module(3),
];
let mut direct = finding("direct.ts");
direct.source_backed = true;
let mut findings = vec![finding("module.ts"), direct];
rank_with_modules(&graph, &modules, &mut findings);
assert!(findings[0].path.ends_with("direct.ts"));
assert!(findings[0].source_backed);
assert!(findings[1].path.ends_with("module.ts"));
assert!(
findings[1]
.reachability
.as_ref()
.expect("ranked")
.reachable_from_untrusted_source
);
}
#[test]
fn runtime_entry_reachability_sorts_before_module_source_reachability() {
let graph = build_graph(
&["entry.ts", "reachable.ts", "source.ts", "module.ts"],
&[(0, 1), (2, 3)],
&[0],
);
let modules = vec![module(0), module(1), member_source_module(2), module(3)];
let mut findings = vec![finding("module.ts"), finding("reachable.ts")];
rank_with_modules(&graph, &modules, &mut findings);
assert!(findings[0].path.ends_with("reachable.ts"));
assert!(
findings[0]
.reachability
.as_ref()
.expect("ranked")
.reachable_from_entry
);
assert!(findings[1].path.ends_with("module.ts"));
assert!(
findings[1]
.reachability
.as_ref()
.expect("ranked")
.reachable_from_untrusted_source
);
}
#[test]
fn hardcoded_secret_and_client_server_leak_are_not_source_annotated() {
let graph = build_graph(&["source.ts", "candidate.ts"], &[(0, 1)], &[]);
let modules = vec![member_source_module(0), module(1)];
let mut hardcoded = finding("candidate.ts");
hardcoded.category = Some(super::super::hardcoded_secret::CATEGORY_ID.to_string());
let mut leak = finding("candidate.ts");
leak.kind = SecurityFindingKind::ClientServerLeak;
leak.category = None;
let mut findings = vec![hardcoded, leak];
rank_with_modules(&graph, &modules, &mut findings);
for finding in findings {
let reach = finding.reachability.as_ref().expect("ranked");
assert!(!reach.reachable_from_untrusted_source);
assert!(reach.untrusted_source_trace.is_empty());
}
}
}