1use crate::report::sink::outln;
13use std::path::{Path, PathBuf};
14use std::process::ExitCode;
15
16use fallow_config::{OutputFormat, ProductionAnalysis, Severity};
17use fallow_core::analyze::derive_security_severity;
18use fallow_core::results::{
19 AnalysisResults, SecurityAttackSurfaceEntry, SecurityDeadCodeKind, SecurityFinding,
20 SecurityFindingKind, TraceHop, TraceHopRole,
21};
22use fallow_types::discover::DiscoveredFile;
23use fallow_types::extract::ModuleInfo;
24use fallow_types::results::{SecurityRuntimeContext, SecurityRuntimeState, SecuritySeverity};
25use serde::Serialize;
26
27use crate::error::emit_error;
28use crate::health::{HealthOptions, SharedParseData, SortBy};
29use crate::health_types::{
30 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport, RuntimeCoverageVerdict,
31};
32use crate::load_config_for_analysis;
33
34#[derive(Debug, Clone, Copy, Serialize)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38pub enum SecuritySchemaVersion {
39 #[allow(
41 dead_code,
42 reason = "kept so the generated schema documents historical v1"
43 )]
44 #[serde(rename = "1")]
45 V1,
46 #[serde(rename = "2")]
48 V2,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55#[serde(rename_all = "kebab-case")]
56pub enum SecurityGateMode {
57 New,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69#[serde(rename_all = "kebab-case")]
70pub enum SecurityGateVerdict {
71 Pass,
73 Fail,
75}
76
77#[derive(Debug, Clone, Copy, Serialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct SecurityGate {
82 pub mode: SecurityGateMode,
84 pub verdict: SecurityGateVerdict,
86 pub new_count: usize,
88}
89
90#[derive(Debug, Clone, Serialize)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95pub struct SecurityOutput {
96 pub schema_version: SecuritySchemaVersion,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub gate: Option<SecurityGate>,
103 pub security_findings: Vec<SecurityFinding>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
109 pub unresolved_edge_files: usize,
114 pub unresolved_callee_sites: usize,
119}
120
121pub struct SecurityOptions<'a> {
123 pub root: &'a Path,
125 pub config_path: &'a Option<PathBuf>,
127 pub output: OutputFormat,
129 pub no_cache: bool,
131 pub threads: usize,
133 pub quiet: bool,
135 pub fail_on_issues: bool,
137 pub sarif_file: Option<&'a Path>,
139 pub summary: bool,
141 pub changed_since: Option<&'a str>,
143 pub use_shared_diff_index: bool,
145 pub workspace: Option<&'a [String]>,
147 pub changed_workspaces: Option<&'a str>,
149 pub file: &'a [PathBuf],
151 pub surface: bool,
153 pub gate: Option<SecurityGateMode>,
157 pub runtime_coverage: Option<&'a Path>,
159 pub min_invocations_hot: u64,
161}
162
163pub fn run(opts: &SecurityOptions<'_>) -> ExitCode {
168 if !matches!(
169 opts.output,
170 OutputFormat::Human | OutputFormat::Json | OutputFormat::Sarif
171 ) {
172 return emit_error(
173 "fallow security supports --format human, json, or sarif only.",
174 2,
175 opts.output,
176 );
177 }
178
179 let mut config = match load_config_for_analysis(
180 opts.root,
181 opts.config_path,
182 opts.output,
183 opts.no_cache,
184 opts.threads,
185 None,
186 opts.quiet,
187 ProductionAnalysis::DeadCode,
188 ) {
189 Ok(config) => config,
190 Err(code) => return code,
191 };
192
193 let effective_severity = config.rules.security_client_server_leak;
197 if effective_severity == Severity::Off {
198 config.rules.security_client_server_leak = Severity::Warn;
199 }
200 let effective_sink_severity = config.rules.security_sink;
201 if effective_sink_severity == Severity::Off {
202 config.rules.security_sink = Severity::Warn;
203 }
204
205 let mut analysis = match analyze_security_candidates(opts, &config) {
206 Ok(analysis) => analysis,
207 Err(code) => return code,
208 };
209
210 let ws_roots = match crate::check::filtering::resolve_workspace_scope(
212 opts.root,
213 opts.workspace,
214 opts.changed_workspaces,
215 opts.output,
216 ) {
217 Ok(roots) => roots,
218 Err(code) => return code,
219 };
220 if let Some(ref roots) = ws_roots {
221 crate::check::filtering::filter_to_workspaces(&mut analysis.results, roots);
222 }
223
224 if let Some(git_ref) = opts.changed_since
227 && let Some(changed) = fallow_core::changed_files::get_changed_files(opts.root, git_ref)
228 {
229 fallow_core::changed_files::filter_results_by_changed_files(
230 &mut analysis.results,
231 &changed,
232 );
233 }
234 if opts.use_shared_diff_index
235 && let Some(diff_index) = crate::report::ci::diff_filter::shared_diff_index()
236 {
237 crate::check::filtering::filter_results_by_diff(
238 &mut analysis.results,
239 diff_index,
240 opts.root,
241 );
242 }
243 filter_to_files(&mut analysis.results, opts.root, opts.file, opts.quiet);
244
245 let gate_mode = match apply_security_gate(opts, &mut analysis.results) {
246 Ok(mode) => mode,
247 Err(code) => return code,
248 };
249
250 let unresolved_edge_files = analysis.results.security_unresolved_edge_files;
251 let unresolved_callee_sites = analysis.results.security_unresolved_callee_sites;
252 let runtime_report = match security_runtime_report(opts, &mut analysis) {
253 Ok(report) => report,
254 Err(code) => return code,
255 };
256 let mut findings: Vec<SecurityFinding> =
257 std::mem::take(&mut analysis.results.security_findings)
258 .into_iter()
259 .map(|f| relativize_finding(f, &config.root))
260 .collect();
261 if let (Some(report), Some(modules), Some(files)) = (
262 runtime_report.as_ref(),
263 analysis.modules.as_ref(),
264 analysis.files.as_ref(),
265 ) {
266 apply_runtime_context(&mut findings, modules, files, &config.root, report);
267 }
268 apply_security_severity(&mut findings);
269 sort_by_security_severity(&mut findings);
270 for finding in &mut findings {
271 finding.finding_id = security_finding_id(finding);
274 }
275 let (findings, attack_surface) = prepare_findings(findings, &config.root, opts.surface);
276
277 let gate = gate_mode.map(|mode| {
282 let new_count = findings.len();
283 SecurityGate {
284 mode,
285 verdict: if new_count > 0 {
286 SecurityGateVerdict::Fail
287 } else {
288 SecurityGateVerdict::Pass
289 },
290 new_count,
291 }
292 });
293
294 let advisory_fail = (opts.fail_on_issues
295 || effective_severity == Severity::Error
296 || effective_sink_severity == Severity::Error)
297 && !findings.is_empty();
298
299 let output = SecurityOutput {
300 schema_version: SecuritySchemaVersion::V2,
301 gate,
302 security_findings: findings,
303 attack_surface,
304 unresolved_edge_files,
305 unresolved_callee_sites,
306 };
307 crate::telemetry::note_result_count(output.security_findings.len());
308
309 if let Some(path) = opts.sarif_file
310 && let Err(message) = write_sarif_file(&output, path)
311 {
312 return emit_error(&message, 2, opts.output);
313 }
314
315 let rendered = match opts.output {
316 OutputFormat::Json => render_json(&output),
317 OutputFormat::Sarif => render_sarif(&output),
318 _ if opts.summary => render_human_summary(&output),
319 _ => render_human(&output),
320 };
321 outln!("{rendered}");
322
323 if let Some(gate) = &output.gate {
329 if gate.verdict == SecurityGateVerdict::Fail {
330 ExitCode::from(8)
331 } else {
332 ExitCode::SUCCESS
333 }
334 } else if advisory_fail {
335 ExitCode::from(1)
336 } else {
337 ExitCode::SUCCESS
338 }
339}
340
341fn apply_security_gate(
342 opts: &SecurityOptions<'_>,
343 results: &mut AnalysisResults,
344) -> Result<Option<SecurityGateMode>, ExitCode> {
345 let Some(mode) = opts.gate else {
346 return Ok(None);
347 };
348
349 let mut owned_gate_diff: Option<crate::report::ci::diff_filter::DiffIndex> = None;
354 let gate_diff: &crate::report::ci::diff_filter::DiffIndex =
355 if let Some(shared) = crate::report::ci::diff_filter::shared_diff_index() {
356 shared
357 } else if let Some(git_ref) = opts.changed_since {
358 match fallow_core::changed_files::try_get_changed_diff(opts.root, git_ref) {
359 Ok(text) => owned_gate_diff
360 .insert(crate::report::ci::diff_filter::DiffIndex::from_unified_diff(&text)),
361 Err(err) => {
362 return Err(emit_error(
363 &format!(
364 "fallow security --gate could not compute the diff for '{git_ref}': {}",
365 err.describe()
366 ),
367 2,
368 opts.output,
369 ));
370 }
371 }
372 } else {
373 return Err(emit_error(
374 "fallow security --gate requires a diff source: --changed-since <ref>, \
375 --diff-file <path>, or --diff-stdin.",
376 2,
377 opts.output,
378 ));
379 };
380 crate::check::filtering::retain_gate_new(results, gate_diff, opts.root);
381 Ok(Some(mode))
382}
383
384struct SecurityAnalysisState {
385 results: AnalysisResults,
386 modules: Option<Vec<ModuleInfo>>,
387 files: Option<Vec<DiscoveredFile>>,
388 analysis_output: Option<fallow_core::AnalysisOutput>,
389}
390
391#[expect(
392 deprecated,
393 reason = "ADR-008 deprecates fallow_core::analyze APIs externally; the CLI uses the workspace path dependency"
394)]
395fn analyze_security_candidates(
396 opts: &SecurityOptions<'_>,
397 config: &fallow_config::ResolvedConfig,
398) -> Result<SecurityAnalysisState, ExitCode> {
399 if opts.runtime_coverage.is_none() {
400 return fallow_core::analyze(config)
401 .map(|results| SecurityAnalysisState {
402 results,
403 modules: None,
404 files: None,
405 analysis_output: None,
406 })
407 .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output));
408 }
409
410 fallow_core::analyze_retaining_modules(config, true, true)
411 .map(|mut output| {
412 let modules = output.modules.take();
413 let files = output.files.take();
414 let results = output.results.clone();
415 SecurityAnalysisState {
416 results,
417 modules,
418 files,
419 analysis_output: Some(output),
420 }
421 })
422 .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output))
423}
424
425fn security_runtime_report(
426 opts: &SecurityOptions<'_>,
427 analysis: &mut SecurityAnalysisState,
428) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
429 let Some(path) = opts.runtime_coverage else {
430 return Ok(None);
431 };
432 let (Some(modules), Some(files), Some(analysis_output)) = (
433 analysis.modules.as_ref(),
434 analysis.files.as_ref(),
435 analysis.analysis_output.take(),
436 ) else {
437 return Ok(None);
438 };
439 analyze_security_runtime(opts, path, modules.clone(), files.clone(), analysis_output)
440}
441
442fn analyze_security_runtime(
443 opts: &SecurityOptions<'_>,
444 path: &Path,
445 modules: Vec<ModuleInfo>,
446 files: Vec<DiscoveredFile>,
447 analysis_output: fallow_core::AnalysisOutput,
448) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
449 let runtime_coverage = crate::health::coverage::prepare_options(
450 path,
451 opts.min_invocations_hot,
452 None,
453 None,
454 opts.output,
455 )?;
456 let result = crate::health::execute_health_with_shared_parse(
457 &HealthOptions {
458 root: opts.root,
459 config_path: opts.config_path,
460 output: opts.output,
461 no_cache: opts.no_cache,
462 threads: opts.threads,
463 quiet: opts.quiet,
464 max_cyclomatic: None,
465 max_cognitive: None,
466 max_crap: None,
467 top: None,
468 sort: SortBy::Cyclomatic,
469 production: true,
470 production_override: Some(true),
471 changed_since: opts.changed_since,
472 diff_index: None,
473 use_shared_diff_index: opts.use_shared_diff_index,
474 workspace: opts.workspace,
475 changed_workspaces: opts.changed_workspaces,
476 baseline: None,
477 save_baseline: None,
478 complexity: false,
479 complexity_breakdown: false,
480 file_scores: false,
481 coverage_gaps: false,
482 config_activates_coverage_gaps: false,
483 hotspots: false,
484 ownership: false,
485 ownership_emails: None,
486 targets: false,
487 force_full: false,
488 score_only_output: false,
489 enforce_coverage_gap_gate: false,
490 effort: None,
491 score: false,
492 min_score: None,
493 since: None,
494 min_commits: None,
495 explain: false,
496 summary: false,
497 save_snapshot: None,
498 trend: false,
499 group_by: None,
500 coverage: None,
501 coverage_root: None,
502 performance: false,
503 min_severity: None,
504 report_only: false,
505 runtime_coverage: Some(runtime_coverage),
506 churn_file: None,
507 },
508 SharedParseData {
509 files,
510 modules,
511 analysis_output: Some(analysis_output),
512 },
513 )?;
514 Ok(result.report.runtime_coverage)
515}
516
517#[derive(Debug, Clone, PartialEq, Eq, Hash)]
518struct RuntimeFunctionKey {
519 path: String,
520 function: String,
521 line: u32,
522}
523
524#[derive(Debug, Clone)]
525struct FunctionSpan {
526 key: RuntimeFunctionKey,
527 end_line: u32,
528}
529
530fn apply_runtime_context(
531 findings: &mut Vec<SecurityFinding>,
532 modules: &[ModuleInfo],
533 files: &[fallow_types::discover::DiscoveredFile],
534 root: &Path,
535 report: &RuntimeCoverageReport,
536) {
537 let spans = function_spans(modules, files, root);
538 let runtime = SecurityRuntimeIndex::new(report);
539 let mut indexed = findings.drain(..).enumerate().collect::<Vec<_>>();
540 for (_, finding) in &mut indexed {
541 if !matches!(finding.kind, SecurityFindingKind::TaintedSink) {
542 continue;
543 }
544 finding.runtime = runtime_context_for_finding(finding, &spans, &runtime);
545 }
546 indexed.sort_by(|(left_index, left), (right_index, right)| {
547 runtime_rank(left)
548 .cmp(&runtime_rank(right))
549 .then_with(|| left_index.cmp(right_index))
550 });
551 findings.extend(indexed.into_iter().map(|(_, finding)| finding));
552}
553
554fn function_spans(
555 modules: &[ModuleInfo],
556 files: &[fallow_types::discover::DiscoveredFile],
557 root: &Path,
558) -> Vec<FunctionSpan> {
559 let paths_by_id = files
560 .iter()
561 .map(|file| (file.id, &file.path))
562 .collect::<rustc_hash::FxHashMap<_, _>>();
563 let mut spans = Vec::new();
564 for module in modules {
565 let Some(path) = paths_by_id.get(&module.file_id) else {
566 continue;
567 };
568 let path = relative_key(path, root);
569 for function in &module.complexity {
570 spans.push(FunctionSpan {
571 key: RuntimeFunctionKey {
572 path: path.clone(),
573 function: function.name.clone(),
574 line: function.line,
575 },
576 end_line: function.line.saturating_add(function.line_count),
577 });
578 }
579 }
580 spans
581}
582
583struct SecurityRuntimeIndex {
584 hot_paths: Vec<(RuntimeFunctionKey, u32, SecurityRuntimeContext)>,
585 findings: rustc_hash::FxHashMap<RuntimeFunctionKey, SecurityRuntimeContext>,
586}
587
588impl SecurityRuntimeIndex {
589 fn new(report: &RuntimeCoverageReport) -> Self {
590 let hot_paths = report
591 .hot_paths
592 .iter()
593 .map(|hot| {
594 (
595 runtime_hot_key(hot),
596 hot.end_line.max(hot.line),
597 SecurityRuntimeContext {
598 state: SecurityRuntimeState::RuntimeHot,
599 function: hot.function.clone(),
600 line: hot.line,
601 invocations: Some(hot.invocations),
602 stable_id: hot.stable_id.clone(),
603 evidence: Some(format!(
604 "production hot path observed with {} invocation{}",
605 hot.invocations,
606 crate::report::plural(hot.invocations as usize)
607 )),
608 },
609 )
610 })
611 .collect();
612 let findings = report
613 .findings
614 .iter()
615 .map(runtime_finding_context)
616 .collect();
617 Self {
618 hot_paths,
619 findings,
620 }
621 }
622}
623
624fn runtime_context_for_finding(
625 finding: &SecurityFinding,
626 spans: &[FunctionSpan],
627 runtime: &SecurityRuntimeIndex,
628) -> Option<SecurityRuntimeContext> {
629 let path = path_key(&finding.path);
630 let span = spans
631 .iter()
632 .filter(|span| {
633 span.key.path == path && span.key.line <= finding.line && finding.line <= span.end_line
634 })
635 .min_by_key(|span| span.end_line.saturating_sub(span.key.line))?;
636 if let Some((_, _, context)) = runtime.hot_paths.iter().find(|(key, end_line, _)| {
637 key == &span.key && key.line <= finding.line && finding.line <= *end_line
638 }) {
639 return Some(context.clone());
640 }
641 runtime.findings.get(&span.key).cloned().or_else(|| {
642 Some(SecurityRuntimeContext {
643 state: SecurityRuntimeState::RuntimeUnknown,
644 function: span.key.function.clone(),
645 line: span.key.line,
646 invocations: None,
647 stable_id: None,
648 evidence: Some("runtime coverage carried no matching function evidence".to_owned()),
649 })
650 })
651}
652
653fn runtime_rank(finding: &SecurityFinding) -> u8 {
654 match finding.runtime.as_ref().map(|runtime| runtime.state) {
655 Some(SecurityRuntimeState::RuntimeHot) => 0,
656 Some(SecurityRuntimeState::LowTraffic) => 1,
657 None | Some(SecurityRuntimeState::RuntimeUnknown) => 2,
658 Some(SecurityRuntimeState::CoverageUnavailable) => 3,
659 Some(SecurityRuntimeState::RuntimeCold) => 4,
660 Some(SecurityRuntimeState::NeverExecuted) => 5,
661 }
662}
663
664fn apply_security_severity(findings: &mut [SecurityFinding]) {
665 for finding in findings {
666 finding.severity = derive_security_severity(finding);
667 }
668}
669
670fn sort_by_security_severity(findings: &mut [SecurityFinding]) {
671 findings.sort_by(|left, right| {
672 security_severity_rank(left.severity)
673 .cmp(&security_severity_rank(right.severity))
674 .then_with(|| left.path.cmp(&right.path))
675 .then_with(|| left.line.cmp(&right.line))
676 .then_with(|| left.col.cmp(&right.col))
677 .then_with(|| left.category.cmp(&right.category))
678 });
679}
680
681const fn security_severity_rank(severity: SecuritySeverity) -> u8 {
682 match severity {
683 SecuritySeverity::High => 0,
684 SecuritySeverity::Medium => 1,
685 SecuritySeverity::Low => 2,
686 }
687}
688
689fn runtime_hot_key(hot: &RuntimeCoverageHotPath) -> RuntimeFunctionKey {
690 RuntimeFunctionKey {
691 path: path_key(&hot.path),
692 function: hot.function.clone(),
693 line: hot.line,
694 }
695}
696
697fn runtime_finding_context(
698 finding: &RuntimeCoverageFinding,
699) -> (RuntimeFunctionKey, SecurityRuntimeContext) {
700 let state = match finding.verdict {
701 RuntimeCoverageVerdict::SafeToDelete => SecurityRuntimeState::NeverExecuted,
702 RuntimeCoverageVerdict::ReviewRequired if finding.invocations.unwrap_or(0) == 0 => {
703 SecurityRuntimeState::RuntimeCold
704 }
705 RuntimeCoverageVerdict::LowTraffic => SecurityRuntimeState::LowTraffic,
706 RuntimeCoverageVerdict::CoverageUnavailable | RuntimeCoverageVerdict::Unknown => {
707 SecurityRuntimeState::CoverageUnavailable
708 }
709 RuntimeCoverageVerdict::ReviewRequired | RuntimeCoverageVerdict::Active => {
710 SecurityRuntimeState::RuntimeUnknown
711 }
712 };
713 (
714 RuntimeFunctionKey {
715 path: path_key(&finding.path),
716 function: finding.function.clone(),
717 line: finding.line,
718 },
719 SecurityRuntimeContext {
720 state,
721 function: finding.function.clone(),
722 line: finding.line,
723 invocations: finding.invocations,
724 stable_id: finding.stable_id.clone(),
725 evidence: Some(format!("runtime coverage verdict: {}", finding.verdict)),
726 },
727 )
728}
729
730fn relative_key(path: &Path, root: &Path) -> String {
731 path_key(path.strip_prefix(root).unwrap_or(path))
732}
733
734fn path_key(path: &Path) -> String {
735 path.to_string_lossy().replace('\\', "/")
736}
737
738fn filter_to_files(
739 results: &mut fallow_core::results::AnalysisResults,
740 root: &Path,
741 files: &[PathBuf],
742 quiet: bool,
743) {
744 if files.is_empty() {
745 return;
746 }
747
748 let resolved_files: Vec<PathBuf> = files
749 .iter()
750 .map(|path| {
751 if crate::path_util::is_absolute_path_any_platform(path) {
752 path.clone()
753 } else {
754 root.join(path)
755 }
756 })
757 .collect();
758
759 if !quiet {
760 for (original, resolved) in files.iter().zip(&resolved_files) {
761 if !resolved.exists() {
762 eprintln!(
763 "Warning: --file '{}' (resolved to '{}') was not found in the project",
764 original.display(),
765 resolved.display()
766 );
767 }
768 }
769 }
770
771 let file_set: rustc_hash::FxHashSet<PathBuf> = resolved_files.into_iter().collect();
772 fallow_core::changed_files::filter_results_by_changed_files(results, &file_set);
773}
774
775fn prepare_findings(
776 findings: Vec<SecurityFinding>,
777 root: &Path,
778 include_surface: bool,
779) -> (
780 Vec<SecurityFinding>,
781 Option<Vec<SecurityAttackSurfaceEntry>>,
782) {
783 let mut findings: Vec<SecurityFinding> = findings
784 .into_iter()
785 .map(|f| {
786 let mut f = relativize_finding(f, root);
787 f.finding_id = security_finding_id(&f);
788 f
789 })
790 .collect();
791 let attack_surface = include_surface.then(|| {
792 findings
793 .iter()
794 .filter_map(|finding| finding.attack_surface.clone())
795 .collect()
796 });
797 for finding in &mut findings {
798 finding.attack_surface = None;
799 }
800 (findings, attack_surface)
801}
802
803fn relativize_finding(mut finding: SecurityFinding, root: &Path) -> SecurityFinding {
806 finding.path = relativize(&finding.path, root);
807 for hop in &mut finding.trace {
808 hop.path = relativize(&hop.path, root);
809 }
810 if let Some(reachability) = &mut finding.reachability {
811 for hop in &mut reachability.untrusted_source_trace {
812 hop.path = relativize(&hop.path, root);
813 }
814 }
815 finding.candidate.sink.path = relativize(&finding.candidate.sink.path, root);
816 if let Some(flow) = &mut finding.taint_flow {
817 flow.source.path = relativize(&flow.source.path, root);
818 flow.sink.path = relativize(&flow.sink.path, root);
819 }
820 if let Some(surface) = &mut finding.attack_surface {
821 surface.source.path = relativize(&surface.source.path, root);
822 surface.sink.path = relativize(&surface.sink.path, root);
823 for hop in &mut surface.path {
824 hop.path = relativize(&hop.path, root);
825 }
826 for control in &mut surface.defensive_boundary.controls {
827 control.path = relativize(&control.path, root);
828 }
829 }
830 finding
831}
832
833fn relativize(path: &Path, root: &Path) -> PathBuf {
834 path.strip_prefix(root)
835 .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
836}
837
838#[must_use]
840pub fn render_json(output: &SecurityOutput) -> String {
841 let Ok(value) = crate::output_envelope::serialize_root_output(
842 crate::output_envelope::FallowOutput::Security(output.clone()),
843 ) else {
844 return "{\"error\":\"failed to serialize security output\"}".to_owned();
845 };
846 serde_json::to_string_pretty(&value)
847 .unwrap_or_else(|_| "{\"error\":\"failed to serialize security output\"}".to_owned())
848}
849
850fn write_sarif_file(output: &SecurityOutput, path: &Path) -> Result<(), String> {
851 if let Some(parent) = path.parent()
852 && !parent.as_os_str().is_empty()
853 {
854 std::fs::create_dir_all(parent).map_err(|err| {
855 format!(
856 "Failed to create directory for SARIF file {}: {err}",
857 path.display()
858 )
859 })?;
860 }
861 std::fs::write(path, render_sarif(output))
862 .map_err(|err| format!("Failed to write SARIF file {}: {err}", path.display()))
863}
864
865fn gate_human_header(gate: &SecurityGate) -> String {
870 use crate::report::plural;
871 match gate.verdict {
872 SecurityGateVerdict::Fail => format!(
873 "Gate: REVIEW REQUIRED, {} new security candidate{} in changed lines (unverified; not confirmed vulnerabilities).",
874 gate.new_count,
875 plural(gate.new_count),
876 ),
877 SecurityGateVerdict::Pass => {
878 "Gate: PASS, no new security candidates in changed lines.".to_owned()
879 }
880 }
881}
882
883#[must_use]
884fn render_human_summary(output: &SecurityOutput) -> String {
885 use crate::report::plural;
886 use std::fmt::Write as _;
887
888 let mut out = String::new();
889 if let Some(gate) = &output.gate {
890 out.push_str(&gate_human_header(gate));
891 out.push('\n');
892 }
893 let count = output.security_findings.len();
894 let _ = writeln!(
895 out,
896 "Security candidates: {count} candidate{} found. These are NOT verified vulnerabilities; verify each before acting.",
897 plural(count),
898 );
899 if output.unresolved_edge_files > 0 {
900 let n = output.unresolved_edge_files;
901 let _ = writeln!(
902 out,
903 "Unresolved dynamic import cones: {n} client file{}.",
904 plural(n)
905 );
906 }
907 if output.unresolved_callee_sites > 0 {
908 let n = output.unresolved_callee_sites;
909 let _ = writeln!(out, "Unresolved sink callees: {n} site{}.", plural(n));
910 }
911 out
912}
913
914#[must_use]
917#[expect(
918 clippy::format_push_string,
919 reason = "small report renderer; readability over avoiding the extra allocation"
920)]
921pub fn render_human(output: &SecurityOutput) -> String {
922 use crate::report::plural;
923 use colored::Colorize;
924
925 let mut out = String::new();
926 if let Some(gate) = &output.gate {
927 out.push_str(&gate_human_header(gate));
928 out.push_str("\n\n");
929 }
930 out.push_str("Security candidates (unverified; for agent or human verification)\n\n");
931
932 if output.security_findings.is_empty() {
933 out.push_str("No security candidates found.\n");
934 } else {
935 for finding in &output.security_findings {
936 let kind = security_finding_label(finding);
937 let (glyph, label) = human_severity_marker(finding.severity);
938 out.push_str(&format!(
939 "{} {label} {kind} {}:{}\n",
940 glyph,
941 finding.path.to_string_lossy().replace('\\', "/").bold(),
942 finding.line,
943 ));
944 out.push_str(&format!(" {}\n", finding.evidence));
945 if let Some(hint) = dead_code_hint(finding) {
946 out.push_str(&format!(" dead-code: {hint}\n"));
947 }
948 if let Some(runtime) = finding.runtime.as_ref() {
949 out.push_str(&format!(" runtime: {}\n", runtime_hint_text(runtime)));
950 }
951 if let Some(reach) = finding.reachability.as_ref() {
952 let entry = if reach.reachable_from_entry {
953 "reachable from a runtime entry point"
954 } else {
955 "not reached from any runtime entry point"
956 };
957 let boundary = if reach.crosses_boundary {
958 "; crosses an architecture boundary"
959 } else {
960 ""
961 };
962 out.push_str(&format!(
963 " reach: {entry} (blast radius {}){boundary}\n",
964 reach.blast_radius,
965 ));
966 if reach.reachable_from_untrusted_source {
967 let hops = reach.untrusted_source_hop_count.unwrap_or(0);
968 out.push_str(&format!(
969 " untrusted-source path: module reachable from an untrusted-source \
970 module via {hops} import hop{}\n",
971 crate::report::plural(hops as usize),
972 ));
973 if !reach.untrusted_source_trace.is_empty() {
974 out.push_str(" untrusted-source trace:\n");
975 for hop in &reach.untrusted_source_trace {
976 out.push_str(&format!(
977 " {}:{} ({})\n",
978 hop.path.to_string_lossy().replace('\\', "/"),
979 hop.line,
980 hop_role_label(hop.role),
981 ));
982 }
983 }
984 }
985 }
986 if !finding.trace.is_empty() {
987 out.push_str(" trace:\n");
988 for hop in &finding.trace {
989 out.push_str(&format!(
990 " {}:{} ({})\n",
991 hop.path.to_string_lossy().replace('\\', "/"),
992 hop.line,
993 hop_role_label(hop.role),
994 ));
995 }
996 }
997 if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
998 out.push_str(
999 " Next: check whether the import is type-only, server-only, or behind a \
1000 build-time guard; if the value never ships to the client bundle, this \
1001 candidate is a false positive.\n",
1002 );
1003 } else if finding.dead_code.is_some() {
1004 out.push_str(
1005 " Next: verify the dead-code finding and delete the code if safe; \
1006 otherwise verify and harden the sink.\n",
1007 );
1008 }
1009 out.push('\n');
1010 }
1011 }
1012
1013 if output.unresolved_edge_files > 0 {
1014 let n = output.unresolved_edge_files;
1015 out.push_str(&format!(
1016 "{} {n} client file{} reached a dynamic import the reachability scan could not \
1017 follow; a leak behind those edges would not be reported, so an empty result is \
1018 not a clean bill.\n",
1019 "[I]".blue().bold(),
1020 plural(n),
1021 ));
1022 }
1023
1024 if output.unresolved_callee_sites > 0 {
1025 let n = output.unresolved_callee_sites;
1026 out.push_str(&format!(
1027 "{} {n} sink site{} had a callee the catalogue scan could not resolve to a static \
1028 path (dynamic dispatch, computed members, aliased bindings); an empty result is \
1029 not a clean bill.\n",
1030 "[I]".blue().bold(),
1031 plural(n),
1032 ));
1033 }
1034
1035 let count = output.security_findings.len();
1036 out.push_str(&format!(
1037 "\nFound {count} security candidate{}. These are NOT verified vulnerabilities; verify \
1038 each before acting.\n",
1039 plural(count),
1040 ));
1041 out
1042}
1043
1044fn security_finding_label(finding: &SecurityFinding) -> String {
1048 match finding.kind {
1049 SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
1050 SecurityFindingKind::TaintedSink => {
1051 let title = finding
1052 .category
1053 .as_deref()
1054 .and_then(fallow_core::analyze::security_catalogue_title)
1055 .or(finding.category.as_deref())
1056 .unwrap_or("tainted-sink");
1057 match finding.cwe {
1058 Some(cwe) => format!("{title} (CWE-{cwe})"),
1059 None => title.to_string(),
1060 }
1061 }
1062 }
1063}
1064
1065fn human_severity_marker(severity: SecuritySeverity) -> (colored::ColoredString, &'static str) {
1066 use colored::Colorize;
1067 match severity {
1068 SecuritySeverity::High => ("[H]".red().bold(), "high"),
1069 SecuritySeverity::Medium => ("[M]".yellow().bold(), "medium"),
1070 SecuritySeverity::Low => ("[L]".blue().bold(), "low"),
1071 }
1072}
1073
1074fn dead_code_hint(finding: &SecurityFinding) -> Option<String> {
1075 let context = finding.dead_code.as_ref()?;
1076 match context.kind {
1077 SecurityDeadCodeKind::UnusedFile => Some(
1078 "also reported as unused-file; delete this file instead of hardening the sink"
1079 .to_string(),
1080 ),
1081 SecurityDeadCodeKind::UnusedExport => Some(format!(
1082 "also reported as unused-export{}; remove the export instead of hardening the sink",
1083 context
1084 .export_name
1085 .as_ref()
1086 .map_or(String::new(), |name| format!(" `{name}`"))
1087 )),
1088 }
1089}
1090
1091const fn hop_role_label(role: TraceHopRole) -> &'static str {
1092 match role {
1093 TraceHopRole::ClientBoundary => "client boundary",
1094 TraceHopRole::UntrustedSource => "untrusted source",
1095 TraceHopRole::ModuleSource => "source module",
1096 TraceHopRole::Intermediate => "intermediate",
1097 TraceHopRole::SecretSource => "secret source",
1098 TraceHopRole::Sink => "sink site",
1099 }
1100}
1101
1102fn source_reachability_hint(finding: &SecurityFinding) -> Option<&'static str> {
1103 finding
1104 .reachability
1105 .as_ref()
1106 .filter(|reach| reach.reachable_from_untrusted_source)
1107 .map(|_| {
1108 "Module-level context: the sink module is reachable from an untrusted-source module; fallow does not prove value flow."
1109 })
1110}
1111
1112fn runtime_hint_text(runtime: &SecurityRuntimeContext) -> String {
1113 use std::fmt::Write as _;
1114
1115 let mut text = format!(
1116 "{} in {}:{}",
1117 runtime_state_label(runtime.state),
1118 runtime.function,
1119 runtime.line
1120 );
1121 if let Some(invocations) = runtime.invocations {
1122 let _ = write!(
1123 text,
1124 " ({} invocation{})",
1125 invocations,
1126 crate::report::plural(invocations as usize)
1127 );
1128 }
1129 if let Some(evidence) = runtime.evidence.as_deref() {
1130 text.push_str("; ");
1131 text.push_str(evidence);
1132 }
1133 text
1134}
1135
1136const fn runtime_state_label(state: SecurityRuntimeState) -> &'static str {
1137 match state {
1138 SecurityRuntimeState::RuntimeHot => "runtime-hot",
1139 SecurityRuntimeState::RuntimeCold => "runtime-cold",
1140 SecurityRuntimeState::NeverExecuted => "never-executed",
1141 SecurityRuntimeState::LowTraffic => "low-traffic",
1142 SecurityRuntimeState::CoverageUnavailable => "coverage-unavailable",
1143 SecurityRuntimeState::RuntimeUnknown => "runtime-unknown",
1144 }
1145}
1146
1147fn sarif_rule_id(finding: &SecurityFinding) -> String {
1152 match finding.kind {
1153 SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
1154 SecurityFindingKind::TaintedSink => {
1155 format!(
1156 "security/{}",
1157 finding.category.as_deref().unwrap_or("tainted-sink")
1158 )
1159 }
1160 }
1161}
1162
1163fn security_help_text(title: &str) -> String {
1164 format!(
1165 "Verify this unverified {title} candidate before acting. Review the source, sink, \
1166 SARIF code flow, and any runtime or dead-code context. fallow does not prove \
1167 exploitability, attacker control, or missing sanitization."
1168 )
1169}
1170
1171fn security_help_markdown(title: &str) -> String {
1172 format!(
1173 "Verify this unverified **{title}** candidate before acting.\n\n\
1174 1. Review the source and sink in the SARIF code flow.\n\
1175 2. Confirm whether attacker-controlled data can reach the sink unsanitized.\n\
1176 3. Use runtime and dead-code context only as triage signals."
1177 )
1178}
1179
1180fn cwe_taxon_id(cwe: u32) -> String {
1181 format!("CWE-{cwe}")
1182}
1183
1184fn cwe_taxon(cwe: u32) -> serde_json::Value {
1185 let id = cwe_taxon_id(cwe);
1186 serde_json::json!({
1187 "id": id,
1188 "name": id,
1189 "shortDescription": { "text": format!("Common Weakness Enumeration {id}") },
1190 "fullDescription": { "text": format!("MITRE Common Weakness Enumeration {id}") },
1191 "helpUri": format!("https://cwe.mitre.org/data/definitions/{cwe}.html")
1192 })
1193}
1194
1195fn cwe_relationship(cwe: u32, taxon_index: usize) -> serde_json::Value {
1196 serde_json::json!({
1197 "target": {
1198 "id": cwe_taxon_id(cwe),
1199 "index": taxon_index,
1200 "toolComponent": {
1201 "name": "CWE",
1202 "index": 0
1203 }
1204 },
1205 "kinds": ["superset"]
1206 })
1207}
1208
1209fn collect_cwes(findings: &[SecurityFinding]) -> Vec<u32> {
1210 let mut cwes: Vec<u32> = findings.iter().filter_map(|finding| finding.cwe).collect();
1211 cwes.sort_unstable();
1212 cwes.dedup();
1213 cwes
1214}
1215
1216fn cwe_index(cwes: &[u32], cwe: u32) -> Option<usize> {
1217 cwes.iter().position(|existing| *existing == cwe)
1218}
1219
1220fn cwe_taxonomy(cwes: &[u32]) -> Option<serde_json::Value> {
1221 if cwes.is_empty() {
1222 return None;
1223 }
1224 let taxa = cwes.iter().map(|cwe| cwe_taxon(*cwe)).collect::<Vec<_>>();
1225 Some(serde_json::json!({
1226 "name": "CWE",
1227 "fullName": "Common Weakness Enumeration",
1228 "organization": "MITRE",
1229 "informationUri": "https://cwe.mitre.org/",
1230 "taxa": taxa
1231 }))
1232}
1233
1234fn sarif_rule_def(
1238 rule_id: &str,
1239 finding: &SecurityFinding,
1240 cwe_taxon_index: Option<usize>,
1241) -> serde_json::Value {
1242 match finding.kind {
1243 SecurityFindingKind::ClientServerLeak => {
1244 let title = "Client-server secret leak";
1245 serde_json::json!({
1246 "id": rule_id,
1247 "name": title,
1248 "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
1249 "fullDescription": { "text":
1250 "Unverified candidate, requires verification: a \"use client\" file \
1251 transitively imports a module that reads a non-public process.env \
1252 secret. fallow does not prove the secret reaches client-bundled code." },
1253 "help": {
1254 "text": security_help_text(title),
1255 "markdown": security_help_markdown(title)
1256 },
1257 "helpUri": "https://github.com/fallow-rs/fallow",
1258 "defaultConfiguration": { "level": "note" }
1259 })
1260 }
1261 SecurityFindingKind::TaintedSink => {
1262 let title = finding
1263 .category
1264 .as_deref()
1265 .and_then(fallow_core::analyze::security_catalogue_title)
1266 .or(finding.category.as_deref())
1267 .unwrap_or("tainted-sink");
1268 let mut rule = serde_json::json!({
1269 "id": rule_id,
1270 "name": title,
1271 "shortDescription": { "text": format!("{title} candidate (unverified)") },
1272 "fullDescription": { "text": format!(
1273 "Unverified candidate, requires verification: {title}. fallow flags a \
1274 syntactic sink reached by a non-literal argument; it does not prove the \
1275 value is attacker-controlled or reaches the sink unsanitized."
1276 ) },
1277 "help": {
1278 "text": security_help_text(title),
1279 "markdown": security_help_markdown(title)
1280 },
1281 "helpUri": "https://github.com/fallow-rs/fallow",
1282 "defaultConfiguration": { "level": "note" }
1283 });
1284 if let Some(cwe) = finding.cwe {
1285 rule["properties"] = serde_json::json!({
1286 "tags": [format!("external/cwe/cwe-{cwe}")]
1287 });
1288 if let Some(taxon_index) = cwe_taxon_index {
1289 rule["relationships"] = serde_json::json!([cwe_relationship(cwe, taxon_index)]);
1290 }
1291 }
1292 rule
1293 }
1294 }
1295}
1296
1297fn hop_role_token(role: TraceHopRole) -> &'static str {
1298 match role {
1299 TraceHopRole::ClientBoundary => "client-boundary",
1300 TraceHopRole::UntrustedSource => "untrusted-source",
1301 TraceHopRole::ModuleSource => "module-source",
1302 TraceHopRole::Intermediate => "intermediate",
1303 TraceHopRole::SecretSource => "secret-source",
1304 TraceHopRole::Sink => "sink",
1305 }
1306}
1307
1308fn sarif_thread_flow_location(hop: &TraceHop) -> serde_json::Value {
1309 let role = hop_role_token(hop.role);
1310 serde_json::json!({
1311 "location": sarif_location(&hop.path, hop.line, hop.col),
1312 "kinds": [role],
1313 "properties": { "fallowTraceRole": role }
1314 })
1315}
1316
1317fn primary_code_flow_hops(finding: &SecurityFinding) -> &[TraceHop] {
1318 if let Some(reachability) = finding.reachability.as_ref()
1319 && !reachability.untrusted_source_trace.is_empty()
1320 {
1321 return &reachability.untrusted_source_trace;
1322 }
1323 &finding.trace
1324}
1325
1326fn sarif_code_flows(finding: &SecurityFinding) -> Option<serde_json::Value> {
1327 let hops = primary_code_flow_hops(finding);
1328 if hops.is_empty() {
1329 return None;
1330 }
1331 let locations = hops
1332 .iter()
1333 .map(sarif_thread_flow_location)
1334 .collect::<Vec<_>>();
1335 Some(serde_json::json!([
1336 {
1337 "threadFlows": [
1338 { "locations": locations }
1339 ]
1340 }
1341 ]))
1342}
1343
1344fn push_related_location(related: &mut Vec<serde_json::Value>, hop: &TraceHop) {
1345 let location = sarif_location(&hop.path, hop.line, hop.col);
1346 if !related.iter().any(|existing| existing == &location) {
1347 related.push(location);
1348 }
1349}
1350
1351fn sarif_related_locations(finding: &SecurityFinding) -> Vec<serde_json::Value> {
1352 let mut related = Vec::new();
1353 for hop in &finding.trace {
1354 push_related_location(&mut related, hop);
1355 }
1356 if let Some(reachability) = finding.reachability.as_ref() {
1357 for hop in &reachability.untrusted_source_trace {
1358 push_related_location(&mut related, hop);
1359 }
1360 }
1361 related
1362}
1363
1364const fn sarif_level(severity: SecuritySeverity) -> &'static str {
1365 match severity {
1366 SecuritySeverity::High | SecuritySeverity::Medium => "warning",
1367 SecuritySeverity::Low => "note",
1368 }
1369}
1370
1371#[must_use]
1378fn render_sarif(output: &SecurityOutput) -> String {
1379 let cwes = collect_cwes(&output.security_findings);
1380 let results: Vec<serde_json::Value> = output
1381 .security_findings
1382 .iter()
1383 .map(|finding| {
1384 let rule_id = sarif_rule_id(finding);
1385 let mut message = dead_code_hint(finding).map_or_else(
1386 || finding.evidence.clone(),
1387 |hint| format!("{} Dead-code cross-link: {hint}.", finding.evidence),
1388 );
1389 if let Some(hint) = source_reachability_hint(finding) {
1390 message.push(' ');
1391 message.push_str(hint);
1392 }
1393 if let Some(runtime) = finding.runtime.as_ref() {
1394 message.push_str(" Runtime context: ");
1395 message.push_str(&runtime_hint_text(runtime));
1396 message.push('.');
1397 }
1398 let related = sarif_related_locations(finding);
1399 let mut result = serde_json::json!({
1404 "ruleId": rule_id,
1405 "level": sarif_level(finding.severity),
1406 "message": { "text": message },
1407 "locations": [sarif_location(&finding.path, finding.line, finding.col)],
1408 "relatedLocations": related,
1409 "partialFingerprints": { "fallowSecurity/v1": security_finding_id(finding) },
1410 });
1411 if let Some(code_flows) = sarif_code_flows(finding) {
1412 result["codeFlows"] = code_flows;
1413 }
1414 result
1415 })
1416 .collect();
1417
1418 let mut seen: Vec<String> = Vec::new();
1420 let mut rules: Vec<serde_json::Value> = Vec::new();
1421 for finding in &output.security_findings {
1422 let rule_id = sarif_rule_id(finding);
1423 if seen.iter().any(|s| s == &rule_id) {
1424 continue;
1425 }
1426 seen.push(rule_id.clone());
1427 let cwe_taxon_index = finding.cwe.and_then(|cwe| cwe_index(&cwes, cwe));
1428 rules.push(sarif_rule_def(&rule_id, finding, cwe_taxon_index));
1429 }
1430
1431 let mut run = serde_json::json!({
1432 "tool": { "driver": {
1433 "name": "fallow",
1434 "version": env!("CARGO_PKG_VERSION"),
1435 "informationUri": "https://github.com/fallow-rs/fallow",
1436 "rules": rules,
1437 }},
1438 "results": results,
1439 });
1440 if let Some(taxonomy) = cwe_taxonomy(&cwes) {
1441 run["taxonomies"] = serde_json::json!([taxonomy]);
1442 run["tool"]["driver"]["supportedTaxonomies"] = serde_json::json!([
1443 { "name": "CWE", "index": 0 }
1444 ]);
1445 }
1446 if let Some(gate) = &output.gate
1450 && let Ok(gate_value) = serde_json::to_value(gate)
1451 {
1452 run["properties"] = serde_json::json!({ "fallowGate": gate_value });
1453 }
1454
1455 let sarif = serde_json::json!({
1456 "version": "2.1.0",
1457 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1458 "runs": [run],
1459 });
1460 serde_json::to_string_pretty(&sarif)
1461 .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
1462}
1463
1464fn fnv_hex(input: &str) -> String {
1466 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1467 for byte in input.bytes() {
1468 hash ^= u64::from(byte);
1469 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1470 }
1471 format!("{hash:016x}")
1472}
1473
1474fn security_finding_id(finding: &SecurityFinding) -> String {
1480 let fp = format!(
1481 "{}:{}:{}",
1482 sarif_rule_id(finding),
1483 finding.path.to_string_lossy().replace('\\', "/"),
1484 finding.line,
1485 );
1486 fnv_hex(&fp)
1487}
1488
1489fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
1490 serde_json::json!({
1491 "physicalLocation": {
1492 "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
1493 "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
1494 }
1495 })
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500 use super::*;
1501 use fallow_core::results::{
1502 SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink,
1503 SecurityDeadCodeContext, SecurityDeadCodeKind, SecurityFinding, SecurityFindingKind,
1504 TraceHop, TraceHopRole,
1505 };
1506 use fallow_types::results::{
1507 SecurityReachability, SecurityTaintFlow, TaintEndpoint, TaintPath,
1508 };
1509
1510 fn sample_finding(root: &Path) -> SecurityFinding {
1512 SecurityFinding {
1513 kind: SecurityFindingKind::ClientServerLeak,
1514 path: root.join("src/app.tsx"),
1515 line: 12,
1516 col: 3,
1517 evidence: "reaches process.env.SECRET_KEY".to_owned(),
1518 source_backed: false,
1519 source_read: None,
1520 severity: SecuritySeverity::High,
1521 trace: vec![
1522 TraceHop {
1523 path: root.join("src/app.tsx"),
1524 line: 12,
1525 col: 3,
1526 role: TraceHopRole::ClientBoundary,
1527 },
1528 TraceHop {
1529 path: root.join("src/lib/util.ts"),
1530 line: 4,
1531 col: 0,
1532 role: TraceHopRole::Intermediate,
1533 },
1534 TraceHop {
1535 path: root.join("src/lib/secret.ts"),
1536 line: 8,
1537 col: 2,
1538 role: TraceHopRole::SecretSource,
1539 },
1540 ],
1541 actions: vec![],
1542 category: None,
1543 cwe: None,
1544 dead_code: None,
1545 reachability: None,
1546 finding_id: String::new(),
1547 candidate: SecurityCandidate {
1548 source_kind: None,
1549 sink: SecurityCandidateSink {
1550 path: root.join("src/app.tsx"),
1551 line: 12,
1552 col: 3,
1553 category: None,
1554 cwe: None,
1555 callee: None,
1556 },
1557 boundary: SecurityCandidateBoundary {
1558 client_server: true,
1559 cross_module: false,
1560 architecture_zone: None,
1561 },
1562 network: None,
1563 },
1564 taint_flow: None,
1565 runtime: None,
1566 attack_surface: None,
1567 }
1568 }
1569
1570 fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
1571 SecurityOutput {
1572 schema_version: SecuritySchemaVersion::V2,
1573 gate: None,
1574 security_findings: findings,
1575 attack_surface: None,
1576 unresolved_edge_files,
1577 unresolved_callee_sites: 0,
1578 }
1579 }
1580
1581 fn output_with_gate(verdict: SecurityGateVerdict, new_count: usize) -> SecurityOutput {
1582 SecurityOutput {
1583 schema_version: SecuritySchemaVersion::V2,
1584 gate: Some(SecurityGate {
1585 mode: SecurityGateMode::New,
1586 verdict,
1587 new_count,
1588 }),
1589 security_findings: vec![],
1590 attack_surface: None,
1591 unresolved_edge_files: 0,
1592 unresolved_callee_sites: 0,
1593 }
1594 }
1595
1596 fn tainted_with_runtime(root: &Path, state: Option<SecurityRuntimeState>) -> SecurityFinding {
1597 let mut finding = sample_finding(root);
1598 finding.kind = SecurityFindingKind::TaintedSink;
1599 finding.category = Some("dangerous-html".to_owned());
1600 finding.cwe = Some(79);
1601 finding.runtime = state.map(|state| SecurityRuntimeContext {
1602 state,
1603 function: "render".to_owned(),
1604 line: 10,
1605 invocations: Some(123),
1606 stable_id: Some("fallow:fn:test".to_owned()),
1607 evidence: Some("production runtime evidence".to_owned()),
1608 });
1609 finding
1610 }
1611
1612 #[test]
1613 fn runtime_rank_promotes_hot_and_demotes_never_executed() {
1614 let root = Path::new("/proj/root");
1615 let mut findings = [
1616 tainted_with_runtime(root, Some(SecurityRuntimeState::NeverExecuted)),
1617 tainted_with_runtime(root, None),
1618 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
1619 tainted_with_runtime(root, Some(SecurityRuntimeState::CoverageUnavailable)),
1620 ];
1621
1622 findings.sort_by_key(runtime_rank);
1623
1624 assert_eq!(
1625 findings
1626 .iter()
1627 .map(|finding| finding.runtime.as_ref().map(|runtime| runtime.state))
1628 .collect::<Vec<_>>(),
1629 vec![
1630 Some(SecurityRuntimeState::RuntimeHot),
1631 None,
1632 Some(SecurityRuntimeState::CoverageUnavailable),
1633 Some(SecurityRuntimeState::NeverExecuted),
1634 ]
1635 );
1636 }
1637
1638 #[test]
1639 fn severity_sort_orders_tiers_then_location() {
1640 let root = Path::new("/proj/root");
1641 let mut high = sample_finding(root);
1642 high.path = root.join("z.ts");
1643 high.severity = SecuritySeverity::High;
1644 let mut low = sample_finding(root);
1645 low.path = root.join("a.ts");
1646 low.severity = SecuritySeverity::Low;
1647 let mut medium_a = sample_finding(root);
1648 medium_a.path = root.join("a.ts");
1649 medium_a.severity = SecuritySeverity::Medium;
1650 let mut medium_b = sample_finding(root);
1651 medium_b.path = root.join("b.ts");
1652 medium_b.severity = SecuritySeverity::Medium;
1653 let mut findings = vec![low, medium_b, high, medium_a];
1654
1655 sort_by_security_severity(&mut findings);
1656
1657 assert_eq!(
1658 findings
1659 .iter()
1660 .map(|finding| (finding.severity, finding.path.file_name().unwrap()))
1661 .collect::<Vec<_>>(),
1662 vec![
1663 (SecuritySeverity::High, std::ffi::OsStr::new("z.ts")),
1664 (SecuritySeverity::Medium, std::ffi::OsStr::new("a.ts")),
1665 (SecuritySeverity::Medium, std::ffi::OsStr::new("b.ts")),
1666 (SecuritySeverity::Low, std::ffi::OsStr::new("a.ts")),
1667 ]
1668 );
1669 }
1670
1671 #[test]
1672 fn human_render_includes_runtime_context_line() {
1673 let root = Path::new("/proj/root");
1674 let finding = relativize_finding(
1675 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
1676 root,
1677 );
1678 let out = render_human(&output_with(vec![finding], 0));
1679
1680 assert!(
1681 out.contains("runtime: runtime-hot in render:10"),
1682 "got: {out}"
1683 );
1684 assert!(out.contains("production runtime evidence"), "got: {out}");
1685 }
1686
1687 #[test]
1688 fn sarif_render_includes_runtime_context_in_message() {
1689 let root = Path::new("/proj/root");
1690 let finding = relativize_finding(
1691 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
1692 root,
1693 );
1694 let rendered = render_sarif(&output_with(vec![finding], 0));
1695 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
1696 let message = sarif["runs"][0]["results"][0]["message"]["text"]
1697 .as_str()
1698 .expect("message text");
1699
1700 assert!(message.contains("Runtime context"), "got: {message}");
1701 assert!(
1702 message.contains("runtime-hot in render:10"),
1703 "got: {message}"
1704 );
1705 }
1706
1707 #[test]
1708 fn gate_human_header_fail_says_review_required_not_fail() {
1709 let gate = SecurityGate {
1710 mode: SecurityGateMode::New,
1711 verdict: SecurityGateVerdict::Fail,
1712 new_count: 2,
1713 };
1714 let header = gate_human_header(&gate);
1715 assert!(header.contains("REVIEW REQUIRED"));
1716 assert!(header.contains("2 new security candidate"));
1717 assert!(header.contains("not confirmed vulnerabilities"));
1718 assert!(!header.to_uppercase().contains("GATE: FAIL"));
1719 }
1720
1721 #[test]
1722 fn gate_human_header_fail_singular_for_one_candidate() {
1723 let gate = SecurityGate {
1725 mode: SecurityGateMode::New,
1726 verdict: SecurityGateVerdict::Fail,
1727 new_count: 1,
1728 };
1729 let header = gate_human_header(&gate);
1730 assert!(header.contains("1 new security candidate in changed lines"));
1731 assert!(!header.contains("1 new security candidates"));
1732 }
1733
1734 #[test]
1735 fn gate_human_header_pass() {
1736 let gate = SecurityGate {
1737 mode: SecurityGateMode::New,
1738 verdict: SecurityGateVerdict::Pass,
1739 new_count: 0,
1740 };
1741 assert!(gate_human_header(&gate).contains("Gate: PASS"));
1742 }
1743
1744 #[test]
1745 fn gate_json_block_is_snake_case_and_present_on_pass() {
1746 let json = render_json(&output_with_gate(SecurityGateVerdict::Pass, 0));
1747 assert!(json.contains("\"gate\""));
1748 assert!(json.contains("\"mode\": \"new\""));
1749 assert!(json.contains("\"verdict\": \"pass\""));
1750 assert!(json.contains("\"new_count\": 0"));
1751 }
1752
1753 #[test]
1754 fn gate_absent_from_json_when_no_gate_ran() {
1755 let json = render_json(&output_with(vec![], 0));
1756 assert!(!json.contains("\"gate\""));
1757 }
1758
1759 #[test]
1760 fn gate_sarif_is_a_run_property_not_result_severity() {
1761 let sarif = render_sarif(&output_with_gate(SecurityGateVerdict::Fail, 1));
1762 assert!(sarif.contains("fallowGate"));
1763 assert!(!sarif.contains("\"level\": \"error\""));
1765 assert!(!sarif.contains("\"level\": \"warning\""));
1766 }
1767
1768 fn add_untrusted_source_reachability(finding: &mut SecurityFinding, root: &Path) {
1769 finding.reachability = Some(SecurityReachability {
1770 reachable_from_entry: true,
1771 reachable_from_untrusted_source: true,
1772 taint_confidence: Some(fallow_core::results::TaintConfidence::ModuleLevel),
1774 untrusted_source_hop_count: Some(1),
1775 untrusted_source_trace: vec![
1776 TraceHop {
1777 path: root.join("src/routes/api.ts"),
1778 line: 3,
1779 col: 0,
1780 role: TraceHopRole::ModuleSource,
1781 },
1782 TraceHop {
1783 path: root.join("src/lib/sink.ts"),
1784 line: 9,
1785 col: 2,
1786 role: TraceHopRole::Sink,
1787 },
1788 ],
1789 blast_radius: 2,
1790 crosses_boundary: false,
1791 });
1792 }
1793
1794 fn add_taint_flow(finding: &mut SecurityFinding, root: &Path) {
1795 finding.taint_flow = Some(SecurityTaintFlow {
1796 source: TaintEndpoint {
1797 path: root.join("src/routes/api.ts"),
1798 line: 3,
1799 col: 0,
1800 },
1801 sink: TaintEndpoint {
1802 path: root.join("src/lib/sink.ts"),
1803 line: 9,
1804 col: 2,
1805 },
1806 path: TaintPath {
1807 intra_module: false,
1808 cross_module_hops: 1,
1809 },
1810 });
1811 }
1812
1813 #[test]
1814 fn relativize_strips_root_prefix() {
1815 let root = Path::new("/proj/root");
1816 let abs = root.join("src/app.tsx");
1817 let rel = relativize(&abs, root);
1818 assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
1819 }
1820
1821 #[test]
1822 fn relativize_keeps_path_when_outside_root() {
1823 let root = Path::new("/proj/root");
1824 let outside = Path::new("/elsewhere/file.ts");
1825 assert_eq!(relativize(outside, root), outside.to_path_buf());
1827 }
1828
1829 #[test]
1830 fn relativize_finding_relativizes_anchor_and_every_hop() {
1831 let root = Path::new("/proj/root");
1832 let finding = relativize_finding(sample_finding(root), root);
1833 assert_eq!(
1834 finding.path.to_string_lossy().replace('\\', "/"),
1835 "src/app.tsx"
1836 );
1837 let hop_paths: Vec<String> = finding
1838 .trace
1839 .iter()
1840 .map(|h| h.path.to_string_lossy().replace('\\', "/"))
1841 .collect();
1842 assert_eq!(
1843 hop_paths,
1844 vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
1845 );
1846 }
1847
1848 #[test]
1849 fn relativize_finding_relativizes_untrusted_source_trace() {
1850 let root = Path::new("/proj/root");
1851 let mut finding = sample_finding(root);
1852 add_untrusted_source_reachability(&mut finding, root);
1853 let finding = relativize_finding(finding, root);
1854 let reach = finding.reachability.as_ref().expect("reachability");
1855 let hop_paths: Vec<String> = reach
1856 .untrusted_source_trace
1857 .iter()
1858 .map(|h| h.path.to_string_lossy().replace('\\', "/"))
1859 .collect();
1860 assert_eq!(hop_paths, vec!["src/routes/api.ts", "src/lib/sink.ts"]);
1861 }
1862
1863 #[test]
1864 fn fnv_hex_is_deterministic_and_16_hex_digits() {
1865 let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
1866 let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
1867 assert_eq!(a, b, "same input must hash identically");
1868 assert_eq!(a.len(), 16);
1869 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
1870 assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
1872 }
1873
1874 #[test]
1875 fn hop_role_labels_cover_every_role() {
1876 assert_eq!(
1877 hop_role_label(TraceHopRole::ClientBoundary),
1878 "client boundary"
1879 );
1880 assert_eq!(
1881 hop_role_label(TraceHopRole::UntrustedSource),
1882 "untrusted source"
1883 );
1884 assert_eq!(hop_role_label(TraceHopRole::ModuleSource), "source module");
1885 assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
1886 assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
1887 assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
1888 }
1889
1890 #[test]
1891 fn sarif_location_clamps_line_and_offsets_column() {
1892 let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
1894 let region = &loc["physicalLocation"]["region"];
1895 assert_eq!(region["startLine"], 1);
1896 assert_eq!(region["startColumn"], 1);
1897 assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
1899 }
1900
1901 #[test]
1902 fn human_summary_reports_zero_without_edge_line() {
1903 let out = render_human_summary(&output_with(vec![], 0));
1904 assert!(out.contains("0 candidates found"), "got: {out}");
1905 assert!(!out.contains("Unresolved dynamic import cones"));
1906 }
1907
1908 #[test]
1909 fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
1910 let root = Path::new("/proj/root");
1911 let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
1912 assert!(out.contains("1 candidate found"), "got: {out}");
1913 assert!(out.contains("Unresolved dynamic import cones: 2 client files."));
1914 }
1915
1916 #[test]
1917 fn human_render_empty_states_no_candidates() {
1918 colored::control::set_override(false);
1919 let out = render_human(&output_with(vec![], 0));
1920 assert!(out.contains("No security candidates found."));
1921 assert!(out.contains("Found 0 security candidates"));
1922 }
1923
1924 #[test]
1925 fn human_render_shows_finding_trace_and_next_action() {
1926 colored::control::set_override(false);
1927 let root = Path::new("/proj/root");
1928 let finding = relativize_finding(sample_finding(root), root);
1929 let out = render_human(&output_with(vec![finding], 0));
1930 assert!(out.contains("[H] high client-server-leak"));
1931 assert!(out.contains("client-server-leak"));
1932 assert!(out.contains("src/app.tsx:12"));
1933 assert!(out.contains("reaches process.env.SECRET_KEY"));
1934 assert!(out.contains("trace:"));
1935 assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
1936 assert!(out.contains("src/app.tsx:12 (client boundary)"));
1937 assert!(out.contains("Next:"));
1938 assert!(out.contains("Found 1 security candidate."));
1939 }
1940
1941 #[test]
1942 fn human_render_shows_dead_code_hint_and_delete_next_step() {
1943 colored::control::set_override(false);
1944 let root = Path::new("/proj/root");
1945 let mut finding = relativize_finding(sample_finding(root), root);
1946 finding.kind = SecurityFindingKind::TaintedSink;
1947 finding.dead_code = Some(SecurityDeadCodeContext {
1948 kind: SecurityDeadCodeKind::UnusedFile,
1949 export_name: None,
1950 line: None,
1951 guidance: "delete instead of harden".to_string(),
1952 });
1953 let out = render_human(&output_with(vec![finding], 0));
1954 assert!(
1955 out.contains("dead-code: also reported as unused-file"),
1956 "got: {out}"
1957 );
1958 assert!(out.contains("delete the code if safe"), "got: {out}");
1959 }
1960
1961 #[test]
1962 fn human_render_shows_untrusted_source_path_as_module_context() {
1963 colored::control::set_override(false);
1964 let root = Path::new("/proj/root");
1965 let mut finding = sample_finding(root);
1966 finding.kind = SecurityFindingKind::TaintedSink;
1967 finding.category = Some("command-injection".to_string());
1968 add_untrusted_source_reachability(&mut finding, root);
1969 let finding = relativize_finding(finding, root);
1970
1971 let out = render_human(&output_with(vec![finding], 0));
1972
1973 assert!(
1974 out.contains("module reachable from an untrusted-source module via 1 import hop"),
1975 "got: {out}"
1976 );
1977 assert!(out.contains("untrusted-source trace:"), "got: {out}");
1978 assert!(
1979 out.contains("src/routes/api.ts:3 (source module)"),
1980 "got: {out}"
1981 );
1982 }
1983
1984 #[test]
1985 fn human_render_surfaces_unresolved_edge_blind_spot() {
1986 colored::control::set_override(false);
1987 let out = render_human(&output_with(vec![], 3));
1988 assert!(out.contains("3 client files reached a dynamic import"));
1989 assert!(out.contains("not a clean bill"));
1990 }
1991
1992 #[test]
1993 fn json_render_carries_schema_version_and_findings() {
1994 let root = Path::new("/proj/root");
1995 let finding = relativize_finding(sample_finding(root), root);
1996 let rendered = render_json(&output_with(vec![finding], 1));
1997 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
1998 assert_eq!(value["schema_version"], "2");
1999 assert_eq!(value["unresolved_edge_files"], 1);
2000 let findings = value["security_findings"].as_array().expect("array");
2001 assert_eq!(findings.len(), 1);
2002 assert_eq!(findings[0]["kind"], "client-server-leak");
2003 assert_eq!(findings[0]["path"], "src/app.tsx");
2004 assert_eq!(findings[0]["severity"], "high");
2005 }
2006
2007 #[test]
2008 fn json_render_carries_candidate_record_and_omits_impact() {
2009 let root = Path::new("/proj/root");
2013 let finding = relativize_finding(sample_finding(root), root);
2014 let rendered = render_json(&output_with(vec![finding], 0));
2015 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
2016 let finding = &value["security_findings"][0];
2017
2018 let candidate = &finding["candidate"];
2019 assert!(candidate.is_object(), "candidate record present");
2020 assert!(candidate["sink"].is_object(), "sink slot present");
2021 assert_eq!(candidate["boundary"]["client_server"], true);
2022 assert!(
2023 candidate.get("impact").is_none(),
2024 "impact must NOT be a wire field"
2025 );
2026 assert!(
2027 candidate.get("source_kind").is_none(),
2028 "client-server-leak has no source kind"
2029 );
2030 assert!(
2031 finding.get("taint_flow").is_none(),
2032 "no untrusted-source flow on a client-server-leak"
2033 );
2034 assert!(
2035 finding.get("finding_id").is_some(),
2036 "finding_id is on the wire"
2037 );
2038 }
2039
2040 #[test]
2041 fn finding_id_is_stable_and_matches_sarif_fingerprint() {
2042 let root = Path::new("/proj/root");
2045 let finding = relativize_finding(sample_finding(root), root);
2046 let id = security_finding_id(&finding);
2047 assert!(!id.is_empty());
2048 assert_eq!(
2049 id,
2050 security_finding_id(&finding),
2051 "deterministic across calls"
2052 );
2053
2054 let sarif: serde_json::Value =
2055 serde_json::from_str(&render_sarif(&output_with(vec![finding], 0)))
2056 .expect("valid SARIF");
2057 assert_eq!(
2058 sarif["runs"][0]["results"][0]["partialFingerprints"]["fallowSecurity/v1"],
2059 serde_json::Value::String(id)
2060 );
2061 }
2062
2063 #[test]
2064 fn json_render_carries_dead_code_context() {
2065 let root = Path::new("/proj/root");
2066 let mut finding = relativize_finding(sample_finding(root), root);
2067 finding.kind = SecurityFindingKind::TaintedSink;
2068 finding.dead_code = Some(SecurityDeadCodeContext {
2069 kind: SecurityDeadCodeKind::UnusedExport,
2070 export_name: Some("handler".to_string()),
2071 line: Some(12),
2072 guidance: "remove export instead of harden".to_string(),
2073 });
2074 let rendered = render_json(&output_with(vec![finding], 0));
2075 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
2076 let context = &value["security_findings"][0]["dead_code"];
2077 assert_eq!(context["kind"], "unused-export");
2078 assert_eq!(context["export_name"], "handler");
2079 assert_eq!(context["line"], 12);
2080 }
2081
2082 #[test]
2083 fn sarif_render_emits_warning_level_with_fingerprint_and_related_locations() {
2084 let root = Path::new("/proj/root");
2085 let finding = relativize_finding(sample_finding(root), root);
2086 let rendered = render_sarif(&output_with(vec![finding], 0));
2087 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
2088 assert_eq!(sarif["version"], "2.1.0");
2089 let run = &sarif["runs"][0];
2090 assert_eq!(run["tool"]["driver"]["name"], "fallow");
2091 let result = &run["results"][0];
2092 assert_eq!(result["level"], "warning");
2094 assert_eq!(result["ruleId"], "security/client-server-leak");
2095 assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
2096 assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
2098 let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
2099 .as_array()
2100 .expect("thread flow locations");
2101 assert_eq!(flow_locations.len(), 3);
2102 assert_eq!(
2103 flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
2104 "src/app.tsx"
2105 );
2106 assert_eq!(
2107 flow_locations[2]["location"]["physicalLocation"]["artifactLocation"]["uri"],
2108 "src/lib/secret.ts"
2109 );
2110 assert_eq!(
2111 flow_locations[2]["kinds"][0],
2112 serde_json::json!("secret-source")
2113 );
2114 assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
2116
2117 let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
2118 assert_eq!(rules[0]["name"], "Client-server secret leak");
2119 assert!(rules[0]["help"]["text"].is_string());
2120 assert!(rules[0].get("relationships").is_none());
2121 assert!(run.get("taxonomies").is_none());
2122 }
2123
2124 #[test]
2125 fn sarif_render_keeps_low_severity_as_note() {
2126 let root = Path::new("/proj/root");
2127 let mut finding = sample_finding(root);
2128 finding.severity = SecuritySeverity::Low;
2129 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
2130 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
2131
2132 assert_eq!(sarif["runs"][0]["results"][0]["level"], "note");
2133 }
2134
2135 #[test]
2136 fn sarif_render_includes_dead_code_hint_in_message() {
2137 let root = Path::new("/proj/root");
2138 let mut finding = relativize_finding(sample_finding(root), root);
2139 finding.kind = SecurityFindingKind::TaintedSink;
2140 finding.dead_code = Some(SecurityDeadCodeContext {
2141 kind: SecurityDeadCodeKind::UnusedFile,
2142 export_name: None,
2143 line: None,
2144 guidance: "delete instead of harden".to_string(),
2145 });
2146 let rendered = render_sarif(&output_with(vec![finding], 0));
2147 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
2148 let message = sarif["runs"][0]["results"][0]["message"]["text"]
2149 .as_str()
2150 .expect("message text");
2151 assert!(message.contains("Dead-code cross-link"), "got: {message}");
2152 assert!(
2153 message.contains("delete this file instead of hardening"),
2154 "got: {message}"
2155 );
2156 }
2157
2158 #[test]
2159 fn sarif_render_includes_untrusted_source_context_and_related_locations() {
2160 let root = Path::new("/proj/root");
2161 let mut finding = sample_finding(root);
2162 finding.kind = SecurityFindingKind::TaintedSink;
2163 finding.category = Some("command-injection".to_string());
2164 add_untrusted_source_reachability(&mut finding, root);
2165 add_taint_flow(&mut finding, root);
2166 finding.trace.push(TraceHop {
2167 path: root.join("src/lib/sink.ts"),
2168 line: 9,
2169 col: 2,
2170 role: TraceHopRole::Sink,
2171 });
2172 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
2173 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
2174 let result = &sarif["runs"][0]["results"][0];
2175 let message = result["message"]["text"].as_str().expect("message text");
2176 assert!(message.contains("Module-level context"), "got: {message}");
2177 assert!(
2178 message.contains("does not prove value flow"),
2179 "got: {message}"
2180 );
2181 assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 5);
2183 let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
2184 .as_array()
2185 .expect("thread flow locations");
2186 assert_eq!(flow_locations.len(), 2);
2187 assert_eq!(
2188 flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
2189 "src/routes/api.ts"
2190 );
2191 assert_eq!(
2192 flow_locations[1]["location"]["physicalLocation"]["artifactLocation"]["uri"],
2193 "src/lib/sink.ts"
2194 );
2195 }
2196
2197 #[test]
2198 fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_metadata() {
2199 let root = Path::new("/proj/root");
2200 let mut finding = sample_finding(root);
2201 finding.kind = SecurityFindingKind::TaintedSink;
2202 finding.category = Some("dangerous-html".to_owned());
2203 finding.cwe = Some(79);
2204 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
2205 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
2206 let run = &sarif["runs"][0];
2207 let result = &run["results"][0];
2210 assert_eq!(result["level"], "warning");
2211 assert_eq!(result["ruleId"], "security/dangerous-html");
2212 let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
2214 assert_eq!(rules.len(), 1);
2215 assert_eq!(rules[0]["id"], "security/dangerous-html");
2216 assert_eq!(rules[0]["name"], "Dangerous HTML sink");
2217 assert!(
2218 rules[0]["help"]["text"]
2219 .as_str()
2220 .expect("help text")
2221 .contains("Verify this unverified")
2222 );
2223 assert!(
2224 rules[0]["help"]["markdown"]
2225 .as_str()
2226 .expect("help markdown")
2227 .contains("**Dangerous HTML sink**")
2228 );
2229 let tags = rules[0]["properties"]["tags"].as_array().unwrap();
2230 assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
2231 let relationship = &rules[0]["relationships"][0];
2232 assert_eq!(relationship["target"]["id"], "CWE-79");
2233 assert_eq!(relationship["target"]["index"], 0);
2234 assert_eq!(relationship["target"]["toolComponent"]["name"], "CWE");
2235 assert_eq!(relationship["kinds"][0], "superset");
2236
2237 let taxonomy = &run["taxonomies"][0];
2238 assert_eq!(taxonomy["name"], "CWE");
2239 assert_eq!(taxonomy["taxa"][0]["id"], "CWE-79");
2240 assert_eq!(
2241 run["tool"]["driver"]["supportedTaxonomies"][0]["name"],
2242 "CWE"
2243 );
2244 }
2245
2246 #[test]
2247 fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
2248 let root = Path::new("/proj/root");
2249 let finding = relativize_finding(sample_finding(root), root);
2250 let output = output_with(vec![finding], 0);
2251 let dir = tempfile::tempdir().expect("tempdir");
2252 let path = dir.path().join("nested/out.sarif");
2253 write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
2254 let written = std::fs::read_to_string(&path).expect("file exists");
2255 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
2256 assert_eq!(sarif["version"], "2.1.0");
2257 }
2258
2259 const NO_CONFIG: Option<PathBuf> = None;
2261
2262 fn leak_fixture_root() -> PathBuf {
2263 Path::new(env!("CARGO_MANIFEST_DIR"))
2264 .join("../../tests/fixtures/security-client-server-leak")
2265 }
2266
2267 fn source_reachability_fixture_root() -> PathBuf {
2268 Path::new(env!("CARGO_MANIFEST_DIR"))
2269 .join("../../tests/fixtures/security-source-reachability-885")
2270 }
2271
2272 fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
2273 SecurityOptions {
2274 root,
2275 config_path: &NO_CONFIG,
2276 output,
2277 no_cache: true,
2278 threads: 1,
2279 quiet: true,
2280 fail_on_issues,
2281 sarif_file: None,
2282 summary: false,
2283 changed_since: None,
2284 use_shared_diff_index: false,
2285 workspace: None,
2286 changed_workspaces: None,
2287 file: &[],
2288 surface: false,
2289 gate: None,
2290 runtime_coverage: None,
2291 min_invocations_hot: 100,
2292 }
2293 }
2294
2295 #[test]
2296 #[expect(
2297 deprecated,
2298 reason = "CLI fixture test uses the same workspace path dependency boundary as `run`"
2299 )]
2300 fn source_reachability_fixture_marks_cross_module_sink() {
2301 let root = source_reachability_fixture_root();
2302 let mut config = load_config_for_analysis(
2303 &root,
2304 &NO_CONFIG,
2305 OutputFormat::Json,
2306 true,
2307 1,
2308 None,
2309 true,
2310 ProductionAnalysis::DeadCode,
2311 )
2312 .expect("fixture config loads");
2313 config.rules.security_sink = Severity::Warn;
2314
2315 let results = fallow_core::analyze(&config).expect("fixture analyzes");
2316 let finding = results
2317 .security_findings
2318 .iter()
2319 .find(|finding| finding.path.ends_with("src/runner.ts"))
2320 .expect("runner sink finding");
2321 let reach = finding.reachability.as_ref().expect("reachability");
2322
2323 assert!(reach.reachable_from_untrusted_source);
2324 assert_eq!(reach.untrusted_source_hop_count, Some(1));
2325 assert_eq!(
2329 reach.taint_confidence,
2330 Some(fallow_core::results::TaintConfidence::ModuleLevel)
2331 );
2332 assert_eq!(
2333 reach
2334 .untrusted_source_trace
2335 .iter()
2336 .map(|hop| hop.role)
2337 .collect::<Vec<_>>(),
2338 vec![TraceHopRole::ModuleSource, TraceHopRole::Sink]
2339 );
2340 assert!(
2341 reach.untrusted_source_trace[0]
2342 .path
2343 .ends_with("src/route.ts")
2344 );
2345
2346 assert!(
2350 finding.candidate.boundary.cross_module,
2351 "a sink reached across a module hop crosses a module boundary"
2352 );
2353 let flow = finding.taint_flow.as_ref().expect("taint_flow present");
2354 assert!(!flow.path.intra_module);
2355 assert_eq!(flow.path.cross_module_hops, 1);
2356 assert!(flow.source.path.ends_with("src/route.ts"));
2357 assert!(flow.sink.path.ends_with("src/runner.ts"));
2358 }
2359
2360 #[test]
2361 fn file_scope_keeps_security_finding_when_anchor_matches() {
2362 let root = Path::new("/proj/root");
2363 let mut results = fallow_core::results::AnalysisResults::default();
2364 results.security_findings.push(sample_finding(root));
2365
2366 filter_to_files(&mut results, root, &[PathBuf::from("src/app.tsx")], true);
2367
2368 assert_eq!(results.security_findings.len(), 1);
2369 }
2370
2371 #[test]
2372 fn file_scope_keeps_security_finding_when_trace_hop_matches() {
2373 let root = Path::new("/proj/root");
2374 let mut results = fallow_core::results::AnalysisResults::default();
2375 results.security_findings.push(sample_finding(root));
2376
2377 filter_to_files(
2378 &mut results,
2379 root,
2380 &[PathBuf::from("src/lib/secret.ts")],
2381 true,
2382 );
2383
2384 assert_eq!(results.security_findings.len(), 1);
2385 }
2386
2387 #[test]
2388 fn file_scope_drops_unrelated_security_finding() {
2389 let root = Path::new("/proj/root");
2390 let mut results = fallow_core::results::AnalysisResults::default();
2391 results.security_findings.push(sample_finding(root));
2392
2393 filter_to_files(&mut results, root, &[PathBuf::from("src/other.ts")], true);
2394
2395 assert!(results.security_findings.is_empty());
2396 }
2397
2398 #[test]
2399 fn run_is_advisory_and_exits_zero_even_with_candidates() {
2400 let root = leak_fixture_root();
2403 let code = run(&run_opts(&root, OutputFormat::Json, false));
2404 assert_eq!(code, ExitCode::SUCCESS);
2405 }
2406
2407 #[test]
2408 fn run_with_fail_on_issues_exits_one_when_candidates_found() {
2409 let root = leak_fixture_root();
2411 let code = run(&run_opts(&root, OutputFormat::Human, true));
2412 assert_eq!(code, ExitCode::from(1));
2413 }
2414
2415 #[test]
2416 fn run_rejects_unsupported_output_format() {
2417 let root = leak_fixture_root();
2419 let code = run(&run_opts(&root, OutputFormat::Compact, false));
2420 assert_eq!(code, ExitCode::from(2));
2421 }
2422
2423 #[test]
2424 fn run_summary_mode_dispatches_compact_human_renderer() {
2425 let root = leak_fixture_root();
2426 let opts = SecurityOptions {
2427 summary: true,
2428 ..run_opts(&root, OutputFormat::Human, false)
2429 };
2430 assert_eq!(run(&opts), ExitCode::SUCCESS);
2431 }
2432
2433 #[test]
2434 fn run_sarif_format_dispatches_sarif_renderer() {
2435 let root = leak_fixture_root();
2436 assert_eq!(
2437 run(&run_opts(&root, OutputFormat::Sarif, false)),
2438 ExitCode::SUCCESS
2439 );
2440 }
2441
2442 #[test]
2443 fn run_writes_sarif_sidecar_file_when_requested() {
2444 let root = leak_fixture_root();
2445 let dir = tempfile::tempdir().expect("tempdir");
2446 let sidecar = dir.path().join("security.sarif");
2447 let opts = SecurityOptions {
2448 sarif_file: Some(&sidecar),
2449 ..run_opts(&root, OutputFormat::Human, false)
2450 };
2451 assert_eq!(run(&opts), ExitCode::SUCCESS);
2452 let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
2453 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
2454 assert_eq!(sarif["version"], "2.1.0");
2455 }
2456}