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