1use 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
38pub 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
188pub 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
302fn compare_ranked_findings(a: &SecurityFinding, b: &SecurityFinding) -> std::cmp::Ordering {
306 let (ra, rb) = (a.reachability.as_ref(), b.reachability.as_ref());
307 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_with(|| b.source_backed.cmp(&a.source_backed))
314 .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_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_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_with(|| a.dead_code.is_some().cmp(&b.dead_code.is_some()))
334 .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#[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
380fn 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 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
416fn 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
442fn 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 fn same_module_trace(finding: &SecurityFinding, hop_count: u32) -> UntrustedSourceTrace {
647 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 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 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
762fn 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 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 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 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 let graph = build_graph(&["entry.ts", "reachable.ts", "orphan.ts"], &[(0, 1)], &[0]);
1170 let empty = FxHashSet::default();
1171 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 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 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 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 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 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 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}