Skip to main content

fallow_core/analyze/security/
rank.rs

1//! Reachability-weighted ranking of security candidates (issues #860 and #885).
2//!
3//! Reuses the existing module graph to rank `fallow security` candidates by
4//! runtime reachability, source-backed evidence, module-level untrusted-source
5//! reachability, reverse-dependency fan-in, boundary crossings, and dead-code
6//! context. This is graph-side glue plus output ordering only: it touches
7//! neither the extract cache nor detector semantics.
8//!
9//! The ranking is a relative-ordering signal, NOT proof of exploitability:
10//! candidates remain CANDIDATES for downstream agent verification.
11
12use std::collections::VecDeque;
13use std::path::{Path, PathBuf};
14
15use rustc_hash::{FxHashMap, FxHashSet};
16
17use fallow_types::extract::{ExportName, ModuleInfo};
18use fallow_types::output::{FixAction, FixActionType, IssueAction};
19use fallow_types::output_dead_code::{UnusedExportFinding, UnusedFileFinding};
20use fallow_types::results::{
21    SecurityAttackSurfaceEntry, SecurityCandidateBoundary, SecurityDeadCodeContext,
22    SecurityDeadCodeKind, SecurityDefensiveBoundary, SecurityDefensiveControl, SecurityFinding,
23    SecurityFindingKind, SecurityReachability, SecurityRuntimeState, SecuritySeverity,
24    SecurityTaintFlow, SecurityZoneCrossing, TaintConfidence, TaintEndpoint, TaintPath, TraceHop,
25    TraceHopRole,
26};
27
28use crate::discover::FileId;
29use crate::graph::ModuleGraph;
30
31use super::{LineOffsetsMap, byte_offset_to_line_col, catalogue::catalogue};
32
33const 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.";
34const 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.";
35const ZERO_CONTROL_PROMPT: &str = "No known control library was detected on this path. Should validation, sanitization, or auth be required before this sink?";
36const CONTROL_PRESENT_PROMPT: &str = "Known defensive controls were detected on this path. Are they sufficient for this sink and untrusted input?";
37
38/// Annotate tainted-sink candidates that overlap dead-code findings from the same
39/// analysis run. Client-server leak findings stay unchanged because #884 was
40/// narrowed to sink candidates.
41pub fn annotate_dead_code_cross_links(
42    graph: &ModuleGraph,
43    modules: &[ModuleInfo],
44    line_offsets_by_file: &LineOffsetsMap<'_>,
45    unused_files: &[UnusedFileFinding],
46    unused_exports: &[UnusedExportFinding],
47    findings: &mut [SecurityFinding],
48) {
49    if findings.is_empty() || (unused_files.is_empty() && unused_exports.is_empty()) {
50        return;
51    }
52
53    let unused_file_paths: FxHashSet<&Path> =
54        unused_files.iter().map(|f| f.file.path.as_path()).collect();
55    let modules_by_id: FxHashMap<FileId, &ModuleInfo> = modules
56        .iter()
57        .map(|module| (module.file_id, module))
58        .collect();
59    let module_by_path: FxHashMap<&Path, &ModuleInfo> = graph
60        .modules
61        .iter()
62        .filter_map(|node| {
63            modules_by_id
64                .get(&node.file_id)
65                .map(|module| (node.path.as_path(), *module))
66        })
67        .collect();
68
69    for finding in findings {
70        if !matches!(finding.kind, SecurityFindingKind::TaintedSink) {
71            continue;
72        }
73        annotate_finding_dead_code(
74            finding,
75            &unused_file_paths,
76            &module_by_path,
77            line_offsets_by_file,
78            unused_exports,
79        );
80    }
81}
82
83fn annotate_finding_dead_code(
84    finding: &mut SecurityFinding,
85    unused_file_paths: &FxHashSet<&Path>,
86    module_by_path: &FxHashMap<&Path, &ModuleInfo>,
87    line_offsets_by_file: &LineOffsetsMap<'_>,
88    unused_exports: &[UnusedExportFinding],
89) {
90    if unused_file_paths.contains(finding.path.as_path()) {
91        finding.dead_code = Some(SecurityDeadCodeContext {
92            kind: SecurityDeadCodeKind::UnusedFile,
93            export_name: None,
94            line: None,
95            guidance: UNUSED_FILE_GUIDANCE.to_string(),
96        });
97        prepend_dead_code_action(finding);
98        return;
99    }
100
101    if let Some(export) = matching_unused_export(
102        module_by_path.get(finding.path.as_path()).copied(),
103        line_offsets_by_file,
104        unused_exports,
105        finding,
106    ) {
107        finding.dead_code = Some(SecurityDeadCodeContext {
108            kind: SecurityDeadCodeKind::UnusedExport,
109            export_name: Some(export.export.export_name.clone()),
110            line: Some(export.export.line),
111            guidance: UNUSED_EXPORT_GUIDANCE.to_string(),
112        });
113        prepend_dead_code_action(finding);
114    }
115}
116
117fn matching_unused_export<'a>(
118    module: Option<&ModuleInfo>,
119    line_offsets_by_file: &LineOffsetsMap<'_>,
120    unused_exports: &'a [UnusedExportFinding],
121    finding: &SecurityFinding,
122) -> Option<&'a UnusedExportFinding> {
123    let same_file = unused_exports
124        .iter()
125        .filter(|export| export.export.path == finding.path);
126
127    if let Some(module) = module {
128        for export in same_file.clone() {
129            let Some(info) = module
130                .exports
131                .iter()
132                .find(|info| export_name_matches(&info.name, &export.export.export_name))
133            else {
134                continue;
135            };
136            let (start_line, _) =
137                byte_offset_to_line_col(line_offsets_by_file, module.file_id, info.span.start);
138            let (end_line, _) =
139                byte_offset_to_line_col(line_offsets_by_file, module.file_id, info.span.end);
140            if start_line <= finding.line && finding.line <= end_line.max(start_line) {
141                return Some(export);
142            }
143        }
144    }
145
146    same_file
147        .into_iter()
148        .find(|export| export.export.line == finding.line)
149}
150
151fn export_name_matches(name: &ExportName, candidate: &str) -> bool {
152    match name {
153        ExportName::Named(name) => name == candidate,
154        ExportName::Default => candidate == "default",
155    }
156}
157
158fn prepend_dead_code_action(finding: &mut SecurityFinding) {
159    let Some(context) = &finding.dead_code else {
160        return;
161    };
162    let action = match context.kind {
163        SecurityDeadCodeKind::UnusedFile => IssueAction::Fix(FixAction {
164            kind: FixActionType::DeleteFile,
165            auto_fixable: false,
166            description: "Delete this unused file instead of hardening the sink".to_string(),
167            note: Some(
168                "Verify the unused-file finding before deleting production code".to_string(),
169            ),
170            available_in_catalogs: None,
171            suggested_target: None,
172        }),
173        SecurityDeadCodeKind::UnusedExport => IssueAction::Fix(FixAction {
174            kind: FixActionType::RemoveExport,
175            auto_fixable: false,
176            description: "Remove the unused export instead of hardening the sink".to_string(),
177            note: context
178                .export_name
179                .as_ref()
180                .map(|name| format!("Verify that export `{name}` is unused before removing it")),
181            available_in_catalogs: None,
182            suggested_target: None,
183        }),
184    };
185    finding.actions.insert(0, action);
186}
187
188/// Rank security findings in place: fill each finding's [`SecurityReachability`]
189/// from the graph, then re-sort so the highest-priority candidates sort first.
190///
191/// `boundary_crossings` maps each file path that participates in an
192/// architecture-boundary violation found in the same run (importing or imported
193/// side) to the `(from_zone, to_zone)` names of that crossing; a finding whose
194/// anchor is a key is flagged `crosses_boundary` and its candidate records the
195/// zone names (issue #900).
196///
197/// Sort order (descending priority): reachable-from-entry first, same-module
198/// source-backed sinks, module-level source-reachable sinks, larger blast
199/// radius, crosses-boundary, active-code over dead-code candidates, then the
200/// existing deterministic `(path, line, col, category)` tiebreak so output stays
201/// stable across runs.
202/// Graph and run inputs that drive security-finding ranking.
203pub struct SecurityRankingInput<'a> {
204    pub graph: &'a ModuleGraph,
205    pub modules: &'a [ModuleInfo],
206    pub line_offsets_by_file: &'a LineOffsetsMap<'a>,
207    pub declared_deps: &'a FxHashSet<String>,
208    pub request_receivers: &'a FxHashSet<String>,
209    pub boundary_crossings: &'a FxHashMap<PathBuf, (String, String)>,
210}
211
212pub fn rank_security_findings(input: &SecurityRankingInput<'_>, findings: &mut [SecurityFinding]) {
213    if findings.is_empty() {
214        return;
215    }
216
217    let context = SecurityRankingContext::build(input);
218
219    for finding in findings.iter_mut() {
220        enrich_ranked_security_finding(finding, &context);
221    }
222
223    findings.sort_by(compare_ranked_findings);
224}
225
226struct SecurityRankingContext<'a> {
227    graph: &'a ModuleGraph,
228    line_offsets_by_file: &'a LineOffsetsMap<'a>,
229    boundary_crossings: &'a FxHashMap<PathBuf, (String, String)>,
230    path_to_id: FxHashMap<&'a Path, FileId>,
231    source_index: UntrustedSourceIndex,
232    modules_by_path: FxHashMap<&'a Path, &'a ModuleInfo>,
233}
234
235impl<'a> SecurityRankingContext<'a> {
236    fn build(input: &SecurityRankingInput<'a>) -> Self {
237        let graph = input.graph;
238        let modules = input.modules;
239        let path_to_id = graph
240            .modules
241            .iter()
242            .map(|node| (node.path.as_path(), node.file_id))
243            .collect();
244        let source_index = UntrustedSourceIndex::build(
245            graph,
246            modules,
247            input.declared_deps,
248            input.request_receivers,
249        );
250        let modules_by_id: FxHashMap<FileId, &ModuleInfo> = modules
251            .iter()
252            .map(|module| (module.file_id, module))
253            .collect();
254        let modules_by_path = graph
255            .modules
256            .iter()
257            .filter_map(|node| {
258                modules_by_id
259                    .get(&node.file_id)
260                    .map(|module| (node.path.as_path(), *module))
261            })
262            .collect();
263
264        Self {
265            graph,
266            line_offsets_by_file: input.line_offsets_by_file,
267            boundary_crossings: input.boundary_crossings,
268            path_to_id,
269            source_index,
270            modules_by_path,
271        }
272    }
273}
274
275fn enrich_ranked_security_finding(
276    finding: &mut SecurityFinding,
277    context: &SecurityRankingContext<'_>,
278) {
279    finding.reachability = context
280        .path_to_id
281        .get(finding.path.as_path())
282        .map(|&file_id| {
283            compute_reachability(
284                context.graph,
285                file_id,
286                finding,
287                context.boundary_crossings,
288                &context.source_index,
289                context.line_offsets_by_file,
290            )
291        });
292
293    enrich_candidate(finding, context.boundary_crossings.get(&finding.path));
294    finding.attack_surface = build_attack_surface(
295        finding,
296        &context.modules_by_path,
297        context.line_offsets_by_file,
298    );
299    finding.severity = derive_security_severity(finding);
300}
301
302/// Rank ordering for two enriched security findings: entry-reachable, then
303/// source-backed, then source-reachable, blast radius, boundary crossing, active
304/// before dead, then a deterministic path/line/col/category tiebreak.
305fn compare_ranked_findings(a: &SecurityFinding, b: &SecurityFinding) -> std::cmp::Ordering {
306    let (ra, rb) = (a.reachability.as_ref(), b.reachability.as_ref());
307    // Reachable-from-entry findings sort first.
308    let reach_a = ra.is_some_and(|r| r.reachable_from_entry);
309    let reach_b = rb.is_some_and(|r| r.reachable_from_entry);
310    reach_b
311        .cmp(&reach_a)
312        // Then same-module source-backed sinks first.
313        .then_with(|| b.source_backed.cmp(&a.source_backed))
314        // Then module-level source-reachable sinks first.
315        .then_with(|| {
316            let source_a = ra.is_some_and(|r| r.reachable_from_untrusted_source);
317            let source_b = rb.is_some_and(|r| r.reachable_from_untrusted_source);
318            source_b.cmp(&source_a)
319        })
320        // Then larger blast radius first.
321        .then_with(|| {
322            let ba = ra.map_or(0, |r| r.blast_radius);
323            let bb = rb.map_or(0, |r| r.blast_radius);
324            bb.cmp(&ba)
325        })
326        // Then boundary-crossing candidates first.
327        .then_with(|| {
328            let ca = ra.is_some_and(|r| r.crosses_boundary);
329            let cb = rb.is_some_and(|r| r.crosses_boundary);
330            cb.cmp(&ca)
331        })
332        // Then active-code candidates before dead-code candidates.
333        .then_with(|| a.dead_code.is_some().cmp(&b.dead_code.is_some()))
334        // Deterministic tiebreak (matches the detectors' own ordering).
335        .then_with(|| a.path.cmp(&b.path))
336        .then_with(|| a.line.cmp(&b.line))
337        .then_with(|| a.col.cmp(&b.col))
338        .then_with(|| a.category.cmp(&b.category))
339}
340
341/// Derive the verification-priority tier from existing security signals. This is
342/// ranking only, not a vulnerability verdict.
343#[must_use]
344pub fn derive_security_severity(finding: &SecurityFinding) -> SecuritySeverity {
345    if finding
346        .runtime
347        .as_ref()
348        .is_some_and(|runtime| runtime.state == SecurityRuntimeState::RuntimeHot)
349        || finding.candidate.boundary.client_server
350        || finding
351            .candidate
352            .boundary
353            .architecture_zone
354            .as_ref()
355            .is_some()
356        || finding
357            .reachability
358            .as_ref()
359            .is_some_and(|reach| reach.crosses_boundary)
360        || finding
361            .reachability
362            .as_ref()
363            .is_some_and(|reach| reach.reachable_from_entry && finding.source_backed)
364    {
365        return SecuritySeverity::High;
366    }
367
368    if finding.source_backed
369        || finding
370            .reachability
371            .as_ref()
372            .is_some_and(|reach| reach.reachable_from_untrusted_source)
373    {
374        return SecuritySeverity::Medium;
375    }
376
377    SecuritySeverity::Low
378}
379
380/// Compute the reachability signal for a single anchor module.
381fn compute_reachability(
382    graph: &ModuleGraph,
383    file_id: FileId,
384    finding: &SecurityFinding,
385    boundary_crossings: &FxHashMap<PathBuf, (String, String)>,
386    source_index: &UntrustedSourceIndex,
387    line_offsets_by_file: &LineOffsetsMap<'_>,
388) -> SecurityReachability {
389    let reachable_from_entry = graph
390        .modules
391        .get(file_id.0 as usize)
392        .is_some_and(|node| node.is_runtime_reachable());
393    let source_trace = source_index.trace_for(graph, file_id, finding, line_offsets_by_file);
394
395    SecurityReachability {
396        reachable_from_entry,
397        reachable_from_untrusted_source: source_trace.is_some(),
398        // Tier the source association (issue #1093): arg-level when the sink
399        // argument traces to a same-module source read (`source_backed`),
400        // module-level when only the import graph connects a source module.
401        // Present exactly when reachable_from_untrusted_source is true.
402        taint_confidence: source_trace.as_ref().map(|_| {
403            if finding.source_backed {
404                TaintConfidence::ArgLevel
405            } else {
406                TaintConfidence::ModuleLevel
407            }
408        }),
409        untrusted_source_hop_count: source_trace.as_ref().map(|source| source.hop_count),
410        untrusted_source_trace: source_trace.map_or_else(Vec::new, |source| source.trace),
411        blast_radius: transitive_dependent_count(graph, file_id),
412        crosses_boundary: boundary_crossings.contains_key(&finding.path),
413    }
414}
415
416/// Fill the candidate's boundary slot and the taint-flow triple from the
417/// finding's trace and the reachability just computed (issue #900). Re-projection
418/// only: client/server from a `ClientBoundary` trace hop, cross-module from the
419/// untrusted-source hop count, and the architecture zone from the run's
420/// boundary-crossing map. The `source_kind` and `sink` slots are set by the
421/// detectors and left untouched here.
422fn enrich_candidate(finding: &mut SecurityFinding, zone: Option<&(String, String)>) {
423    let client_server = finding
424        .trace
425        .iter()
426        .any(|hop| hop.role == TraceHopRole::ClientBoundary);
427    let hop_count = finding
428        .reachability
429        .as_ref()
430        .and_then(|reach| reach.untrusted_source_hop_count);
431    finding.candidate.boundary = SecurityCandidateBoundary {
432        client_server,
433        cross_module: hop_count.is_some_and(|count| count > 0),
434        architecture_zone: zone.map(|(from, to)| SecurityZoneCrossing {
435            from: from.clone(),
436            to: to.clone(),
437        }),
438    };
439    finding.taint_flow = build_taint_flow(finding);
440}
441
442/// Build the `{ source, sink, path }` taint-flow triple when an untrusted source
443/// is import-reachable to the sink. The full ordered hops are NOT duplicated:
444/// `path` is the compact shape, the hops stay on `reachability.untrusted_source_trace`.
445fn build_taint_flow(finding: &SecurityFinding) -> Option<SecurityTaintFlow> {
446    let reach = finding.reachability.as_ref()?;
447    if !reach.reachable_from_untrusted_source {
448        return None;
449    }
450    let first = reach.untrusted_source_trace.first()?;
451    let last = reach.untrusted_source_trace.last()?;
452    let hop_count = reach.untrusted_source_hop_count.unwrap_or(0);
453    Some(SecurityTaintFlow {
454        source: TaintEndpoint {
455            path: first.path.clone(),
456            line: first.line,
457            col: first.col,
458        },
459        sink: TaintEndpoint {
460            path: last.path.clone(),
461            line: last.line,
462            col: last.col,
463        },
464        path: TaintPath {
465            intra_module: hop_count == 0,
466            cross_module_hops: hop_count,
467        },
468    })
469}
470
471fn build_attack_surface(
472    finding: &SecurityFinding,
473    modules_by_path: &FxHashMap<&Path, &ModuleInfo>,
474    line_offsets_by_file: &LineOffsetsMap<'_>,
475) -> Option<SecurityAttackSurfaceEntry> {
476    let flow = finding.taint_flow.as_ref()?;
477    let reach = finding.reachability.as_ref()?;
478    let path = reach.untrusted_source_trace.clone();
479    if path.is_empty() {
480        return None;
481    }
482    let controls = defensive_controls_for_path(&path, modules_by_path, line_offsets_by_file);
483    let verification_prompt = if controls.is_empty() {
484        ZERO_CONTROL_PROMPT
485    } else {
486        CONTROL_PRESENT_PROMPT
487    }
488    .to_string();
489
490    Some(SecurityAttackSurfaceEntry {
491        source: flow.source.clone(),
492        sink: finding.candidate.sink.clone(),
493        path,
494        defensive_boundary: SecurityDefensiveBoundary {
495            controls,
496            verification_prompt,
497        },
498    })
499}
500
501fn defensive_controls_for_path(
502    path: &[TraceHop],
503    modules_by_path: &FxHashMap<&Path, &ModuleInfo>,
504    line_offsets_by_file: &LineOffsetsMap<'_>,
505) -> Vec<SecurityDefensiveControl> {
506    let mut controls = Vec::new();
507    let mut seen_files = FxHashSet::default();
508    for hop in path {
509        if !seen_files.insert(hop.path.as_path()) {
510            continue;
511        }
512        let Some(module) = modules_by_path.get(hop.path.as_path()).copied() else {
513            continue;
514        };
515        for control in &module.security_control_sites {
516            let (line, col) =
517                byte_offset_to_line_col(line_offsets_by_file, module.file_id, control.span_start);
518            controls.push(SecurityDefensiveControl {
519                kind: control.kind,
520                path: hop.path.clone(),
521                line,
522                col,
523                callee: control.callee_path.clone(),
524            });
525        }
526    }
527    controls.sort_by(|a, b| {
528        a.path
529            .cmp(&b.path)
530            .then_with(|| a.line.cmp(&b.line))
531            .then_with(|| a.col.cmp(&b.col))
532            .then_with(|| a.callee.cmp(&b.callee))
533            .then_with(|| a.kind.cmp(&b.kind))
534    });
535    controls.dedup_by(|a, b| {
536        a.path == b.path
537            && a.line == b.line
538            && a.col == b.col
539            && a.kind == b.kind
540            && a.callee == b.callee
541    });
542    controls
543}
544
545#[derive(Debug, Clone, Copy)]
546struct SourceParent {
547    previous: FileId,
548    import_span_start: Option<u32>,
549}
550
551struct UntrustedSourceIndex {
552    source_for: Vec<Option<FileId>>,
553    parent: Vec<Option<SourceParent>>,
554}
555
556struct UntrustedSourceTrace {
557    hop_count: u32,
558    trace: Vec<TraceHop>,
559}
560
561impl UntrustedSourceIndex {
562    fn build(
563        graph: &ModuleGraph,
564        modules: &[ModuleInfo],
565        declared_deps: &FxHashSet<String>,
566        request_receivers: &FxHashSet<String>,
567    ) -> Self {
568        let modules_by_id: FxHashMap<FileId, &ModuleInfo> = modules
569            .iter()
570            .map(|module| (module.file_id, module))
571            .collect();
572        let mut source_for = vec![None; graph.modules.len()];
573        let mut parent = vec![None; graph.modules.len()];
574        let mut queue: VecDeque<FileId> = VecDeque::new();
575
576        for node in &graph.modules {
577            let Some(module) = modules_by_id.get(&node.file_id) else {
578                continue;
579            };
580            if !module_contains_untrusted_source(module, declared_deps, request_receivers) {
581                continue;
582            }
583            let idx = node.file_id.0 as usize;
584            if idx >= source_for.len() || source_for[idx].is_some() {
585                continue;
586            }
587            source_for[idx] = Some(node.file_id);
588            queue.push_back(node.file_id);
589        }
590
591        while let Some(current) = queue.pop_front() {
592            let Some(source_id) = source_for.get(current.0 as usize).copied().flatten() else {
593                continue;
594            };
595            for (target, all_type_only, span) in graph.outgoing_edge_summaries(current) {
596                if all_type_only {
597                    continue;
598                }
599                let idx = target.0 as usize;
600                if idx >= source_for.len() || source_for[idx].is_some() {
601                    continue;
602                }
603                source_for[idx] = Some(source_id);
604                parent[idx] = Some(SourceParent {
605                    previous: current,
606                    import_span_start: span,
607                });
608                queue.push_back(target);
609            }
610        }
611
612        Self { source_for, parent }
613    }
614
615    fn trace_for(
616        &self,
617        graph: &ModuleGraph,
618        sink_id: FileId,
619        finding: &SecurityFinding,
620        line_offsets_by_file: &LineOffsetsMap<'_>,
621    ) -> Option<UntrustedSourceTrace> {
622        if !is_source_reachability_candidate(finding) {
623            return None;
624        }
625        let source_id = self.source_for.get(sink_id.0 as usize).copied().flatten()?;
626        let mut ids = vec![sink_id];
627        let mut current = sink_id;
628        while current != source_id {
629            let parent = self.parent.get(current.0 as usize).copied().flatten()?;
630            current = parent.previous;
631            ids.push(current);
632        }
633        ids.reverse();
634        let hop_count = u32::try_from(ids.len().saturating_sub(1)).unwrap_or(u32::MAX);
635
636        if source_id == sink_id {
637            return Some(Self::same_module_trace(finding, hop_count));
638        }
639
640        let trace = self.multi_hop_trace(graph, &ids, finding, line_offsets_by_file)?;
641        Some(UntrustedSourceTrace { hop_count, trace })
642    }
643
644    /// Build the two-hop arg-level / module-level trace for a sink whose source
645    /// read is in the same module (issue #1093).
646    fn same_module_trace(finding: &SecurityFinding, hop_count: u32) -> UntrustedSourceTrace {
647        // Arg-level (source_backed): anchor the source node at the real
648        // source read and label it `UntrustedSource` (a specific read is
649        // implicated). Module-level (source elsewhere in the same file, no
650        // arg trace): keep line 1 and label `ModuleSource` so the node is
651        // never read as a proven value path (issue #1093). `source_read` is
652        // Some exactly when `source_backed`.
653        let (source_line, source_col, source_role) = finding
654            .source_read
655            .map_or((1, 0, TraceHopRole::ModuleSource), |(line, col)| {
656                (line, col, TraceHopRole::UntrustedSource)
657            });
658        UntrustedSourceTrace {
659            hop_count,
660            trace: vec![
661                TraceHop {
662                    path: finding.path.clone(),
663                    line: source_line,
664                    col: source_col,
665                    role: source_role,
666                },
667                TraceHop {
668                    path: finding.path.clone(),
669                    line: finding.line,
670                    col: finding.col,
671                    role: TraceHopRole::Sink,
672                },
673            ],
674        }
675    }
676
677    /// Build the cross-module trace from the resolved source -> sink id chain.
678    fn multi_hop_trace(
679        &self,
680        graph: &ModuleGraph,
681        ids: &[FileId],
682        finding: &SecurityFinding,
683        line_offsets_by_file: &LineOffsetsMap<'_>,
684    ) -> Option<Vec<TraceHop>> {
685        let mut trace = Vec::with_capacity(ids.len().saturating_add(1));
686        for (idx, &file_id) in ids.iter().enumerate() {
687            let path = graph.modules.get(file_id.0 as usize)?.path.clone();
688            if idx == ids.len() - 1 {
689                trace.push(TraceHop {
690                    path,
691                    line: finding.line,
692                    col: finding.col,
693                    role: TraceHopRole::Sink,
694                });
695                continue;
696            }
697
698            let Some(&next_id) = ids.get(idx + 1) else {
699                continue;
700            };
701            let next_parent = self.parent.get(next_id.0 as usize).copied().flatten();
702            let (line, col) = next_parent
703                .and_then(|p| p.import_span_start)
704                .map_or((1, 0), |span| {
705                    byte_offset_to_line_col(line_offsets_by_file, file_id, span)
706                });
707            trace.push(TraceHop {
708                path,
709                line,
710                col,
711                // Cross-module reachability is module-level by construction (the
712                // specific source value is not shown to reach the sink), so the
713                // origin hop is `ModuleSource`, not `UntrustedSource` (#1093).
714                role: if idx == 0 {
715                    TraceHopRole::ModuleSource
716                } else {
717                    TraceHopRole::Intermediate
718                },
719            });
720        }
721        Some(trace)
722    }
723}
724
725fn is_source_reachability_candidate(finding: &SecurityFinding) -> bool {
726    matches!(finding.kind, SecurityFindingKind::TaintedSink)
727        && finding.category.as_deref() != Some(super::hardcoded_secret::CATEGORY_ID)
728}
729
730fn module_contains_untrusted_source(
731    module: &ModuleInfo,
732    declared_deps: &FxHashSet<String>,
733    request_receivers: &FxHashSet<String>,
734) -> bool {
735    let cat = catalogue();
736    module.tainted_bindings.iter().any(|binding| {
737        cat.matching_source_for_deps_with_receivers(
738            &binding.source_path,
739            declared_deps,
740            request_receivers,
741        )
742        .is_some()
743    }) || module.security_sinks.iter().any(|sink| {
744        sink.arg_source_paths.iter().any(|path| {
745            cat.matching_source_for_deps_with_receivers(path, declared_deps, request_receivers)
746                .is_some()
747        })
748    }) || module.member_accesses.iter().any(|access| {
749        let full_path = format!("{}.{}", access.object, access.member);
750        cat.matching_source_for_deps_with_receivers(&full_path, declared_deps, request_receivers)
751            .is_some()
752            || cat
753                .matching_source_for_deps_with_receivers(
754                    &access.object,
755                    declared_deps,
756                    request_receivers,
757                )
758                .is_some()
759    })
760}
761
762/// Count the distinct modules that transitively depend on `target` (fan-in) via
763/// the graph's reverse-dependency index. Bounded BFS with a visited set; the
764/// target itself is excluded from the count.
765fn transitive_dependent_count(graph: &ModuleGraph, target: FileId) -> u32 {
766    let mut visited: FxHashSet<FileId> = FxHashSet::default();
767    let mut queue: VecDeque<FileId> = VecDeque::new();
768    queue.push_back(target);
769    visited.insert(target);
770
771    while let Some(current) = queue.pop_front() {
772        let Some(dependents) = graph.reverse_deps.get(current.0 as usize) else {
773            continue;
774        };
775        for &dep in dependents {
776            if visited.insert(dep) {
777                queue.push_back(dep);
778            }
779        }
780    }
781
782    // Exclude the target itself; saturate into u32 for the wire type.
783    u32::try_from(visited.len().saturating_sub(1)).unwrap_or(u32::MAX)
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
791    use fallow_types::extract::{
792        MemberAccess, SecurityControlKind, SecurityControlSite, TaintedBinding,
793    };
794    use fallow_types::output::{FixActionType, IssueAction};
795    use fallow_types::output_dead_code::{UnusedExportFinding, UnusedFileFinding};
796    use fallow_types::results::{
797        SecurityDeadCodeKind, SecurityFindingKind, SecurityRuntimeContext, TraceHop, TraceHopRole,
798        UnusedExport, UnusedFile,
799    };
800
801    use crate::graph::ModuleGraph;
802    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
803
804    const ROOT: &str = "/proj";
805
806    /// Build a graph from `file_names` and `(from, to)` import edges. `entry`
807    /// indices become runtime entry points (so their cones are runtime-reachable).
808    fn build_graph(file_names: &[&str], edges: &[(usize, usize)], entry: &[usize]) -> ModuleGraph {
809        let edges: Vec<(usize, usize, bool)> =
810            edges.iter().map(|&(from, to)| (from, to, false)).collect();
811        build_graph_with_type_edges(file_names, &edges, entry)
812    }
813
814    fn build_graph_with_type_edges(
815        file_names: &[&str],
816        edges: &[(usize, usize, bool)],
817        entry: &[usize],
818    ) -> ModuleGraph {
819        let files: Vec<DiscoveredFile> = file_names
820            .iter()
821            .enumerate()
822            .map(|(i, name)| DiscoveredFile {
823                id: FileId(i as u32),
824                path: PathBuf::from(ROOT).join(name),
825                size_bytes: 100,
826            })
827            .collect();
828
829        let resolved: Vec<ResolvedModule> = files
830            .iter()
831            .map(|f| {
832                let imports: Vec<ResolvedImport> = edges
833                    .iter()
834                    .filter(|(from, _, _)| *from == f.id.0 as usize)
835                    .map(|&(_, to, is_type_only)| ResolvedImport {
836                        target: ResolveResult::InternalModule(FileId(to as u32)),
837                        info: fallow_types::extract::ImportInfo {
838                            source: format!("./{}", file_names[to]),
839                            imported_name: fallow_types::extract::ImportedName::Default,
840                            local_name: "x".to_string(),
841                            is_type_only,
842                            from_style: false,
843                            span: oxc_span::Span::new(0, 10),
844                            source_span: oxc_span::Span::new(0, 10),
845                        },
846                    })
847                    .collect();
848                ResolvedModule {
849                    file_id: f.id,
850                    path: f.path.clone(),
851                    exports: vec![],
852                    re_exports: vec![],
853                    resolved_imports: imports,
854                    resolved_dynamic_imports: vec![],
855                    resolved_dynamic_patterns: vec![],
856                    member_accesses: vec![],
857                    semantic_facts: Box::default(),
858                    whole_object_uses: Box::default(),
859                    has_cjs_exports: false,
860                    has_angular_component_template_url: false,
861                    unused_import_bindings: FxHashSet::default(),
862                    type_referenced_import_bindings: vec![],
863                    value_referenced_import_bindings: vec![],
864                    namespace_object_aliases: vec![],
865                    exported_factory_returns: Box::default(),
866                }
867            })
868            .collect();
869
870        let entry_points: Vec<EntryPoint> = entry
871            .iter()
872            .map(|&i| EntryPoint {
873                path: files[i].path.clone(),
874                source: EntryPointSource::ManualEntry,
875            })
876            .collect();
877
878        ModuleGraph::build(&resolved, &entry_points, &files)
879    }
880
881    /// Test wrapper: accepts the boundary-anchor path set (the pre-#900 shape)
882    /// and lifts each path into the `(from_zone, to_zone)` map the production
883    /// signature now takes, with placeholder zone names. Existing call sites that
884    /// only assert on `crosses_boundary` stay unchanged.
885    fn rank(
886        graph: &ModuleGraph,
887        boundary_anchor_paths: &FxHashSet<PathBuf>,
888        findings: &mut [SecurityFinding],
889    ) {
890        let modules = Vec::new();
891        let line_offsets = FxHashMap::default();
892        let declared_deps = FxHashSet::default();
893        let request_receivers = FxHashSet::default();
894        let boundary_crossings: FxHashMap<PathBuf, (String, String)> = boundary_anchor_paths
895            .iter()
896            .map(|path| (path.clone(), ("from".to_string(), "to".to_string())))
897            .collect();
898        rank_security_findings(
899            &SecurityRankingInput {
900                graph,
901                modules: &modules,
902                line_offsets_by_file: &line_offsets,
903                declared_deps: &declared_deps,
904                request_receivers: &request_receivers,
905                boundary_crossings: &boundary_crossings,
906            },
907            findings,
908        );
909    }
910
911    fn rank_with_modules(
912        graph: &ModuleGraph,
913        modules: &[ModuleInfo],
914        findings: &mut [SecurityFinding],
915    ) {
916        let line_offsets = FxHashMap::default();
917        let declared_deps = FxHashSet::default();
918        let request_receivers = FxHashSet::default();
919        let boundary_crossings = FxHashMap::default();
920        rank_security_findings(
921            &SecurityRankingInput {
922                graph,
923                modules,
924                line_offsets_by_file: &line_offsets,
925                declared_deps: &declared_deps,
926                request_receivers: &request_receivers,
927                boundary_crossings: &boundary_crossings,
928            },
929            findings,
930        );
931    }
932
933    fn module(file_id: u32) -> ModuleInfo {
934        ModuleInfo {
935            file_id: FileId(file_id),
936            exports: vec![],
937            imports: vec![],
938            re_exports: vec![],
939            dynamic_imports: vec![],
940            dynamic_import_patterns: vec![],
941            require_calls: vec![],
942            package_path_references: Box::default(),
943            member_accesses: vec![],
944            semantic_facts: Box::default(),
945            whole_object_uses: Box::default(),
946            has_cjs_exports: false,
947            has_angular_component_template_url: false,
948            content_hash: 0,
949            suppressions: vec![],
950            unknown_suppression_kinds: vec![],
951            unused_import_bindings: vec![],
952            type_referenced_import_bindings: vec![],
953            value_referenced_import_bindings: vec![],
954            line_offsets: vec![],
955            complexity: vec![],
956            flag_uses: vec![],
957            class_heritage: vec![],
958            exported_factory_returns: Box::default(),
959            injection_tokens: vec![],
960            local_type_declarations: vec![],
961            public_signature_type_references: vec![],
962            namespace_object_aliases: vec![],
963            iconify_prefixes: vec![],
964            iconify_icon_names: vec![],
965            auto_import_candidates: vec![],
966            directives: vec![],
967            client_only_dynamic_import_spans: vec![],
968            security_sinks: vec![],
969            security_sinks_skipped: 0,
970            security_unresolved_callee_sites: Vec::new(),
971            tainted_bindings: vec![],
972            sanitized_sink_args: vec![],
973            security_control_sites: vec![],
974            callee_uses: vec![],
975            misplaced_directives: vec![],
976            inline_server_action_exports: Vec::new(),
977            di_key_sites: Vec::new(),
978            has_dynamic_provide: false,
979            referenced_import_bindings: Vec::new(),
980            component_props: Vec::new(),
981            has_props_attrs_fallthrough: false,
982            has_define_expose: false,
983            has_define_model: false,
984            has_unharvestable_props: false,
985            component_emits: Vec::new(),
986            angular_inputs: Vec::new(),
987            angular_outputs: Vec::new(),
988            has_unharvestable_emits: false,
989            has_dynamic_emit: false,
990            has_emit_whole_object_use: false,
991            load_return_keys: Vec::new(),
992            has_unharvestable_load: false,
993            has_load_data_whole_use: false,
994            has_page_data_store_whole_use: false,
995            component_functions: Vec::new(),
996            react_props: Vec::new(),
997            hook_uses: Vec::new(),
998            render_edges: Vec::new(),
999            svelte_dispatched_events: Vec::new(),
1000            svelte_listened_events: Vec::new(),
1001            angular_component_selectors: Vec::new(),
1002            registered_custom_elements: Vec::new(),
1003            used_custom_element_tags: Vec::new(),
1004            angular_used_selectors: Vec::new(),
1005            angular_entry_component_refs: Vec::new(),
1006            has_dynamic_component_render: false,
1007            has_dynamic_dispatch: false,
1008        }
1009    }
1010
1011    fn member_source_module(file_id: u32) -> ModuleInfo {
1012        let mut module = module(file_id);
1013        module.member_accesses.push(MemberAccess {
1014            object: "req".to_string(),
1015            member: "body".to_string(),
1016        });
1017        module
1018    }
1019
1020    fn tainted_binding_source_module(file_id: u32) -> ModuleInfo {
1021        let mut module = module(file_id);
1022        module.tainted_bindings.push(TaintedBinding {
1023            local: "body".to_string(),
1024            source_path: "req.body".to_string(),
1025            source_span_start: 0,
1026        });
1027        module
1028    }
1029
1030    fn validation_control_module(file_id: u32) -> ModuleInfo {
1031        let mut module = module(file_id);
1032        module.security_control_sites.push(SecurityControlSite {
1033            kind: SecurityControlKind::Validation,
1034            callee_path: "schema.parse".to_string(),
1035            span_start: 0,
1036            span_end: 12,
1037        });
1038        module
1039    }
1040
1041    fn finding(name: &str) -> SecurityFinding {
1042        use fallow_types::results::{SecurityCandidate, SecurityCandidateSink};
1043        let path = PathBuf::from(ROOT).join(name);
1044        SecurityFinding {
1045            finding_id: String::new(),
1046            kind: SecurityFindingKind::TaintedSink,
1047            category: Some("dangerous-html".to_string()),
1048            cwe: Some(79),
1049            path: path.clone(),
1050            line: 1,
1051            col: 0,
1052            evidence: "candidate".to_string(),
1053            trace: vec![TraceHop {
1054                path: path.clone(),
1055                line: 1,
1056                col: 0,
1057                role: TraceHopRole::Sink,
1058            }],
1059            actions: Vec::<IssueAction>::new(),
1060            dead_code: None,
1061            reachability: None,
1062            source_backed: false,
1063            source_read: None,
1064            severity: SecuritySeverity::Low,
1065            candidate: SecurityCandidate {
1066                source_kind: None,
1067                sink: SecurityCandidateSink {
1068                    path,
1069                    line: 1,
1070                    col: 0,
1071                    category: Some("dangerous-html".to_string()),
1072                    cwe: Some(79),
1073                    callee: None,
1074                    url_shape: None,
1075                },
1076                boundary: SecurityCandidateBoundary::default(),
1077                network: None,
1078            },
1079            taint_flow: None,
1080            runtime: None,
1081            attack_surface: None,
1082        }
1083    }
1084
1085    fn reachability(
1086        reachable_from_entry: bool,
1087        reachable_from_untrusted_source: bool,
1088        crosses_boundary: bool,
1089    ) -> SecurityReachability {
1090        SecurityReachability {
1091            reachable_from_entry,
1092            reachable_from_untrusted_source,
1093            taint_confidence: None,
1094            untrusted_source_hop_count: None,
1095            untrusted_source_trace: vec![],
1096            blast_radius: 1,
1097            crosses_boundary,
1098        }
1099    }
1100
1101    #[test]
1102    fn derives_low_severity_for_baseline_candidate() {
1103        assert_eq!(
1104            derive_security_severity(&finding("sink.ts")),
1105            SecuritySeverity::Low
1106        );
1107    }
1108
1109    #[test]
1110    fn derives_medium_severity_for_source_signals() {
1111        let mut source_backed = finding("source-backed.ts");
1112        source_backed.source_backed = true;
1113
1114        let mut source_reachable = finding("source-reachable.ts");
1115        source_reachable.reachability = Some(reachability(false, true, false));
1116
1117        assert_eq!(
1118            derive_security_severity(&source_backed),
1119            SecuritySeverity::Medium
1120        );
1121        assert_eq!(
1122            derive_security_severity(&source_reachable),
1123            SecuritySeverity::Medium
1124        );
1125    }
1126
1127    #[test]
1128    fn derives_high_severity_for_boundary_entry_and_runtime_signals() {
1129        let mut client_boundary = finding("client-boundary.ts");
1130        client_boundary.candidate.boundary.client_server = true;
1131
1132        let mut architecture_boundary = finding("architecture-boundary.ts");
1133        architecture_boundary.candidate.boundary.architecture_zone = Some(SecurityZoneCrossing {
1134            from: "web".to_string(),
1135            to: "server".to_string(),
1136        });
1137
1138        let mut crossed_boundary = finding("crossed-boundary.ts");
1139        crossed_boundary.reachability = Some(reachability(false, false, true));
1140
1141        let mut source_backed_entry = finding("source-backed-entry.ts");
1142        source_backed_entry.source_backed = true;
1143        source_backed_entry.reachability = Some(reachability(true, false, false));
1144
1145        let mut runtime_hot = finding("runtime-hot.ts");
1146        runtime_hot.runtime = Some(SecurityRuntimeContext {
1147            state: SecurityRuntimeState::RuntimeHot,
1148            function: "handler".to_string(),
1149            line: 1,
1150            invocations: Some(500),
1151            stable_id: Some("fallow:fn:test".to_string()),
1152            evidence: Some("runtime hot path".to_string()),
1153        });
1154
1155        for finding in [
1156            client_boundary,
1157            architecture_boundary,
1158            crossed_boundary,
1159            source_backed_entry,
1160            runtime_hot,
1161        ] {
1162            assert_eq!(derive_security_severity(&finding), SecuritySeverity::High);
1163        }
1164    }
1165
1166    #[test]
1167    fn reachable_from_entry_sorts_first() {
1168        // entry(0) -> reachable(1); orphan(2) is not in any entry cone.
1169        let graph = build_graph(&["entry.ts", "reachable.ts", "orphan.ts"], &[(0, 1)], &[0]);
1170        let empty = FxHashSet::default();
1171        // Seed in the "wrong" order to prove the sort moves the reachable one up.
1172        let mut findings = vec![finding("orphan.ts"), finding("reachable.ts")];
1173        rank(&graph, &empty, &mut findings);
1174
1175        assert!(findings[0].path.ends_with("reachable.ts"));
1176        assert!(
1177            findings[0]
1178                .reachability
1179                .as_ref()
1180                .expect("ranked")
1181                .reachable_from_entry
1182        );
1183        assert!(findings[1].path.ends_with("orphan.ts"));
1184        assert!(
1185            !findings[1]
1186                .reachability
1187                .as_ref()
1188                .expect("ranked")
1189                .reachable_from_entry
1190        );
1191    }
1192
1193    #[test]
1194    fn attack_surface_records_detected_controls_on_source_to_sink_path() {
1195        let graph = build_graph(&["source.ts", "sink.ts"], &[(0, 1)], &[0]);
1196        let modules = vec![
1197            tainted_binding_source_module(0),
1198            validation_control_module(1),
1199        ];
1200        let mut findings = vec![finding("sink.ts")];
1201
1202        rank_with_modules(&graph, &modules, &mut findings);
1203
1204        let surface = findings[0].attack_surface.as_ref().expect("surface entry");
1205        assert_eq!(surface.source.path, PathBuf::from(ROOT).join("source.ts"));
1206        assert_eq!(surface.sink.path, PathBuf::from(ROOT).join("sink.ts"));
1207        assert_eq!(surface.defensive_boundary.controls.len(), 1);
1208        assert_eq!(
1209            surface.defensive_boundary.controls[0].kind,
1210            SecurityControlKind::Validation
1211        );
1212        assert!(
1213            surface
1214                .defensive_boundary
1215                .verification_prompt
1216                .contains("Are they sufficient")
1217        );
1218    }
1219
1220    #[test]
1221    fn attack_surface_zero_control_prompt_is_a_question() {
1222        let graph = build_graph(&["source.ts", "sink.ts"], &[(0, 1)], &[0]);
1223        let modules = vec![tainted_binding_source_module(0), module(1)];
1224        let mut findings = vec![finding("sink.ts")];
1225
1226        rank_with_modules(&graph, &modules, &mut findings);
1227
1228        let prompt = &findings[0]
1229            .attack_surface
1230            .as_ref()
1231            .expect("surface entry")
1232            .defensive_boundary
1233            .verification_prompt;
1234        assert!(prompt.ends_with('?'));
1235        assert!(prompt.contains("No known control library"));
1236    }
1237
1238    #[test]
1239    fn higher_blast_radius_wins_among_reachable() {
1240        // entry(0) imports both hub(1) and leaf(2); extra(3) also imports hub(1).
1241        // hub has fan-in {entry, extra} = 2; leaf has fan-in {entry} = 1.
1242        let graph = build_graph(
1243            &["entry.ts", "hub.ts", "leaf.ts", "extra.ts"],
1244            &[(0, 1), (0, 2), (3, 1)],
1245            &[0, 3],
1246        );
1247        let empty = FxHashSet::default();
1248        let mut findings = vec![finding("leaf.ts"), finding("hub.ts")];
1249        rank(&graph, &empty, &mut findings);
1250
1251        assert!(findings[0].path.ends_with("hub.ts"));
1252        let hub = findings[0].reachability.as_ref().expect("ranked");
1253        let leaf = findings[1].reachability.as_ref().expect("ranked");
1254        assert!(hub.reachable_from_entry && leaf.reachable_from_entry);
1255        assert!(
1256            hub.blast_radius > leaf.blast_radius,
1257            "hub {} should exceed leaf {}",
1258            hub.blast_radius,
1259            leaf.blast_radius
1260        );
1261    }
1262
1263    #[test]
1264    fn boundary_crossing_breaks_tie() {
1265        // Two equally-reachable, equal-fan-in siblings imported by entry(0).
1266        let graph = build_graph(&["entry.ts", "a.ts", "b.ts"], &[(0, 1), (0, 2)], &[0]);
1267        let mut boundary = FxHashSet::default();
1268        boundary.insert(PathBuf::from(ROOT).join("b.ts"));
1269        // Seed a-first; b crosses a boundary so it should sort ahead.
1270        let mut findings = vec![finding("a.ts"), finding("b.ts")];
1271        rank(&graph, &boundary, &mut findings);
1272
1273        assert!(findings[0].path.ends_with("b.ts"));
1274        assert!(
1275            findings[0]
1276                .reachability
1277                .as_ref()
1278                .expect("ranked")
1279                .crosses_boundary
1280        );
1281        assert!(
1282            !findings[1]
1283                .reachability
1284                .as_ref()
1285                .expect("ranked")
1286                .crosses_boundary
1287        );
1288    }
1289
1290    #[test]
1291    fn full_tie_is_deterministic_by_path() {
1292        let graph = build_graph(&["entry.ts", "a.ts", "b.ts"], &[(0, 1), (0, 2)], &[0]);
1293        let empty = FxHashSet::default();
1294        let mut findings = vec![finding("b.ts"), finding("a.ts")];
1295        rank(&graph, &empty, &mut findings);
1296        assert!(findings[0].path.ends_with("a.ts"));
1297        assert!(findings[1].path.ends_with("b.ts"));
1298    }
1299
1300    #[test]
1301    fn dead_code_cross_link_marks_unused_file_sink() {
1302        let graph = build_graph(&["dead.ts"], &[], &[]);
1303        let mut findings = vec![finding("dead.ts")];
1304        let unused_files = vec![UnusedFileFinding::with_actions(UnusedFile {
1305            path: PathBuf::from(ROOT).join("dead.ts"),
1306        })];
1307        let line_offsets = FxHashMap::default();
1308
1309        annotate_dead_code_cross_links(
1310            &graph,
1311            &[],
1312            &line_offsets,
1313            &unused_files,
1314            &[],
1315            &mut findings,
1316        );
1317
1318        let context = findings[0].dead_code.as_ref().expect("dead-code context");
1319        assert_eq!(context.kind, SecurityDeadCodeKind::UnusedFile);
1320        assert_eq!(context.export_name, None);
1321        match &findings[0].actions[0] {
1322            IssueAction::Fix(action) => assert_eq!(action.kind, FixActionType::DeleteFile),
1323            other => panic!("expected delete-file action, got {other:?}"),
1324        }
1325    }
1326
1327    #[test]
1328    fn dead_code_cross_link_marks_same_line_unused_export_sink() {
1329        let graph = build_graph(&["sink.ts"], &[], &[]);
1330        let mut findings = vec![finding("sink.ts")];
1331        let unused_exports = vec![UnusedExportFinding::with_actions(UnusedExport {
1332            path: PathBuf::from(ROOT).join("sink.ts"),
1333            export_name: "dangerous".to_string(),
1334            is_type_only: false,
1335            line: 1,
1336            col: 0,
1337            span_start: 0,
1338            is_re_export: false,
1339        })];
1340        let line_offsets = FxHashMap::default();
1341
1342        annotate_dead_code_cross_links(
1343            &graph,
1344            &[],
1345            &line_offsets,
1346            &[],
1347            &unused_exports,
1348            &mut findings,
1349        );
1350
1351        let context = findings[0].dead_code.as_ref().expect("dead-code context");
1352        assert_eq!(context.kind, SecurityDeadCodeKind::UnusedExport);
1353        assert_eq!(context.export_name.as_deref(), Some("dangerous"));
1354        assert_eq!(context.line, Some(1));
1355        match &findings[0].actions[0] {
1356            IssueAction::Fix(action) => assert_eq!(action.kind, FixActionType::RemoveExport),
1357            other => panic!("expected remove-export action, got {other:?}"),
1358        }
1359    }
1360
1361    #[test]
1362    fn dead_code_cross_link_skips_client_server_leak_findings() {
1363        let graph = build_graph(&["dead.ts"], &[], &[]);
1364        let mut findings = vec![finding("dead.ts")];
1365        findings[0].kind = SecurityFindingKind::ClientServerLeak;
1366        let unused_files = vec![UnusedFileFinding::with_actions(UnusedFile {
1367            path: PathBuf::from(ROOT).join("dead.ts"),
1368        })];
1369        let line_offsets = FxHashMap::default();
1370
1371        annotate_dead_code_cross_links(
1372            &graph,
1373            &[],
1374            &line_offsets,
1375            &unused_files,
1376            &[],
1377            &mut findings,
1378        );
1379
1380        assert!(findings[0].dead_code.is_none());
1381        assert!(findings[0].actions.is_empty());
1382    }
1383
1384    #[test]
1385    fn active_code_sorts_ahead_of_dead_code_when_rank_signals_tie() {
1386        let graph = build_graph(
1387            &["entry.ts", "active.ts", "dead.ts"],
1388            &[(0, 1), (0, 2)],
1389            &[0],
1390        );
1391        let empty = FxHashSet::default();
1392        let mut dead = finding("dead.ts");
1393        dead.dead_code = Some(SecurityDeadCodeContext {
1394            kind: SecurityDeadCodeKind::UnusedFile,
1395            export_name: None,
1396            line: None,
1397            guidance: UNUSED_FILE_GUIDANCE.to_string(),
1398        });
1399        let mut findings = vec![dead, finding("active.ts")];
1400
1401        rank(&graph, &empty, &mut findings);
1402
1403        assert!(findings[0].path.ends_with("active.ts"));
1404        assert!(findings[0].dead_code.is_none());
1405        assert!(findings[1].path.ends_with("dead.ts"));
1406        assert!(findings[1].dead_code.is_some());
1407    }
1408
1409    #[test]
1410    fn empty_findings_is_noop() {
1411        let graph = build_graph(&["entry.ts"], &[], &[0]);
1412        let empty = FxHashSet::default();
1413        let mut findings: Vec<SecurityFinding> = vec![];
1414        rank(&graph, &empty, &mut findings);
1415        assert!(findings.is_empty());
1416    }
1417
1418    #[test]
1419    fn untrusted_source_reachability_uses_value_import_path() {
1420        let graph = build_graph(&["handler.ts", "helper.ts"], &[(0, 1)], &[]);
1421        let modules = vec![member_source_module(0), module(1)];
1422        let mut findings = vec![finding("helper.ts")];
1423
1424        rank_with_modules(&graph, &modules, &mut findings);
1425
1426        let reach = findings[0].reachability.as_ref().expect("ranked");
1427        assert!(reach.reachable_from_untrusted_source);
1428        assert_eq!(reach.untrusted_source_hop_count, Some(1));
1429        // Cross-module reachability is module-level: the source node is labeled
1430        // `ModuleSource`, and the tier says `module-level` (issue #1093).
1431        assert_eq!(reach.taint_confidence, Some(TaintConfidence::ModuleLevel));
1432        assert_eq!(
1433            reach
1434                .untrusted_source_trace
1435                .iter()
1436                .map(|hop| hop.role)
1437                .collect::<Vec<_>>(),
1438            vec![TraceHopRole::ModuleSource, TraceHopRole::Sink]
1439        );
1440    }
1441
1442    #[test]
1443    fn arg_level_same_file_finding_anchors_source_node_at_read_line() {
1444        // A source-backed finding with a resolved source-read line: the trace
1445        // source node points at that read and is labeled `UntrustedSource`, and
1446        // the tier is `arg-level` (issue #1093).
1447        let graph = build_graph(&["handler.ts"], &[], &[]);
1448        let modules = vec![tainted_binding_source_module(0)];
1449        let mut arg_level = finding("handler.ts");
1450        arg_level.source_backed = true;
1451        arg_level.source_read = Some((7, 4));
1452        let mut findings = vec![arg_level];
1453
1454        rank_with_modules(&graph, &modules, &mut findings);
1455
1456        let reach = findings[0].reachability.as_ref().expect("ranked");
1457        assert!(reach.reachable_from_untrusted_source);
1458        assert_eq!(reach.taint_confidence, Some(TaintConfidence::ArgLevel));
1459        let source_hop = reach.untrusted_source_trace.first().expect("source node");
1460        assert_eq!(source_hop.role, TraceHopRole::UntrustedSource);
1461        assert_eq!((source_hop.line, source_hop.col), (7, 4));
1462    }
1463
1464    #[test]
1465    fn untrusted_source_reachability_skips_type_only_import_path() {
1466        let graph = build_graph_with_type_edges(&["handler.ts", "helper.ts"], &[(0, 1, true)], &[]);
1467        let modules = vec![member_source_module(0), module(1)];
1468        let mut findings = vec![finding("helper.ts")];
1469
1470        rank_with_modules(&graph, &modules, &mut findings);
1471
1472        let reach = findings[0].reachability.as_ref().expect("ranked");
1473        assert!(!reach.reachable_from_untrusted_source);
1474        assert_eq!(reach.untrusted_source_hop_count, None);
1475        assert!(reach.untrusted_source_trace.is_empty());
1476    }
1477
1478    #[test]
1479    fn same_file_untrusted_source_and_sink_has_zero_hop_trace() {
1480        let graph = build_graph(&["handler.ts"], &[], &[]);
1481        let modules = vec![tainted_binding_source_module(0)];
1482        let mut findings = vec![finding("handler.ts")];
1483
1484        rank_with_modules(&graph, &modules, &mut findings);
1485
1486        let reach = findings[0].reachability.as_ref().expect("ranked");
1487        assert!(reach.reachable_from_untrusted_source);
1488        assert_eq!(reach.untrusted_source_hop_count, Some(0));
1489        // The finding is not source-backed (the module merely contains a source),
1490        // so the same-file source node is module-level: `ModuleSource`, never
1491        // `UntrustedSource` (issue #1093).
1492        assert_eq!(reach.taint_confidence, Some(TaintConfidence::ModuleLevel));
1493        assert_eq!(
1494            reach
1495                .untrusted_source_trace
1496                .iter()
1497                .map(|hop| hop.role)
1498                .collect::<Vec<_>>(),
1499            vec![TraceHopRole::ModuleSource, TraceHopRole::Sink]
1500        );
1501    }
1502
1503    #[test]
1504    fn source_backed_sorts_ahead_of_module_level_source_when_entry_ties() {
1505        let graph = build_graph(
1506            &["entry.ts", "source.ts", "module.ts", "direct.ts"],
1507            &[(0, 1), (0, 2), (0, 3), (1, 2)],
1508            &[0],
1509        );
1510        let modules = vec![
1511            module(0),
1512            member_source_module(1),
1513            module(2),
1514            tainted_binding_source_module(3),
1515        ];
1516        let mut direct = finding("direct.ts");
1517        direct.source_backed = true;
1518        let mut findings = vec![finding("module.ts"), direct];
1519
1520        rank_with_modules(&graph, &modules, &mut findings);
1521
1522        assert!(findings[0].path.ends_with("direct.ts"));
1523        assert!(findings[0].source_backed);
1524        assert!(findings[1].path.ends_with("module.ts"));
1525        assert!(
1526            findings[1]
1527                .reachability
1528                .as_ref()
1529                .expect("ranked")
1530                .reachable_from_untrusted_source
1531        );
1532    }
1533
1534    #[test]
1535    fn runtime_entry_reachability_sorts_before_module_source_reachability() {
1536        let graph = build_graph(
1537            &["entry.ts", "reachable.ts", "source.ts", "module.ts"],
1538            &[(0, 1), (2, 3)],
1539            &[0],
1540        );
1541        let modules = vec![module(0), module(1), member_source_module(2), module(3)];
1542        let mut findings = vec![finding("module.ts"), finding("reachable.ts")];
1543
1544        rank_with_modules(&graph, &modules, &mut findings);
1545
1546        assert!(findings[0].path.ends_with("reachable.ts"));
1547        assert!(
1548            findings[0]
1549                .reachability
1550                .as_ref()
1551                .expect("ranked")
1552                .reachable_from_entry
1553        );
1554        assert!(findings[1].path.ends_with("module.ts"));
1555        assert!(
1556            findings[1]
1557                .reachability
1558                .as_ref()
1559                .expect("ranked")
1560                .reachable_from_untrusted_source
1561        );
1562    }
1563
1564    #[test]
1565    fn hardcoded_secret_and_client_server_leak_are_not_source_annotated() {
1566        let graph = build_graph(&["source.ts", "candidate.ts"], &[(0, 1)], &[]);
1567        let modules = vec![member_source_module(0), module(1)];
1568        let mut hardcoded = finding("candidate.ts");
1569        hardcoded.category = Some(super::super::hardcoded_secret::CATEGORY_ID.to_string());
1570        let mut leak = finding("candidate.ts");
1571        leak.kind = SecurityFindingKind::ClientServerLeak;
1572        leak.category = None;
1573        let mut findings = vec![hardcoded, leak];
1574
1575        rank_with_modules(&graph, &modules, &mut findings);
1576
1577        for finding in findings {
1578            let reach = finding.reachability.as_ref().expect("ranked");
1579            assert!(!reach.reachable_from_untrusted_source);
1580            assert!(reach.untrusted_source_trace.is_empty());
1581        }
1582    }
1583}