1use crate::report::sink::outln;
13use std::cmp::Ordering;
14use std::collections::BTreeMap;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18use std::time::Instant;
19
20use fallow_config::{OutputFormat, ProductionAnalysis, Severity};
21use fallow_core::analyze::derive_security_severity;
22use fallow_core::results::{
23 AnalysisResults, SecurityAttackSurfaceEntry, SecurityDeadCodeKind, SecurityFinding,
24 SecurityFindingKind, TraceHop, TraceHopRole,
25};
26use fallow_types::discover::DiscoveredFile;
27use fallow_types::envelope::{ElapsedMs, Meta, ToolVersion};
28use fallow_types::extract::ModuleInfo;
29use fallow_types::results::{
30 SecurityRuntimeContext, SecurityRuntimeState, SecuritySeverity,
31 SecurityUnresolvedCalleeDiagnostic, TaintConfidence,
32};
33use rustc_hash::FxHashSet;
34use serde::Serialize;
35use xxhash_rust::xxh3::xxh3_64;
36
37use crate::base_worktree::{BaseWorktree, git_rev_parse};
38use crate::error::emit_error;
39use crate::health::{HealthOptions, SharedParseData, SortBy};
40use crate::health_types::{
41 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport, RuntimeCoverageVerdict,
42};
43use crate::load_config_for_analysis;
44
45const UNRESOLVED_CALLEE_SAMPLE_LIMIT: usize = 25;
46const UNRESOLVED_CALLEE_TOP_FILES_LIMIT: usize = 10;
47
48#[derive(Debug, Clone, Copy, Serialize)]
51#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
52pub enum SecuritySchemaVersion {
53 #[allow(
55 dead_code,
56 reason = "kept so the generated schema documents historical v1"
57 )]
58 #[serde(rename = "1")]
59 V1,
60 #[allow(
62 dead_code,
63 reason = "kept so the generated schema documents historical v2"
64 )]
65 #[serde(rename = "2")]
66 V2,
67 #[allow(
69 dead_code,
70 reason = "kept so the generated schema documents historical v3"
71 )]
72 #[serde(rename = "3")]
73 V3,
74 #[allow(
76 dead_code,
77 reason = "kept so the generated schema documents historical v4"
78 )]
79 #[serde(rename = "4")]
80 V4,
81 #[allow(
83 dead_code,
84 reason = "kept so the generated schema documents historical v5"
85 )]
86 #[serde(rename = "5")]
87 V5,
88 #[serde(rename = "6")]
90 V6,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
95#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
96#[serde(rename_all = "kebab-case")]
97pub enum SecurityGateMode {
98 New,
103 NewlyReachable,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113#[serde(rename_all = "kebab-case")]
114pub enum SecurityGateVerdict {
115 Pass,
117 Fail,
119}
120
121#[derive(Debug, Clone, Copy, Serialize)]
124#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
125pub struct SecurityGate {
126 pub mode: SecurityGateMode,
128 pub verdict: SecurityGateVerdict,
130 pub new_count: usize,
132}
133
134#[derive(Debug, Clone, Serialize)]
136#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
137#[cfg_attr(
138 feature = "schema",
139 schemars(extend("required" = ["rules", "categories_include", "categories_exclude"]))
140)]
141pub struct SecurityOutputConfig {
142 pub rules: SecurityOutputRulesConfig,
145 pub categories_include: Option<Vec<String>>,
148 pub categories_exclude: Option<Vec<String>>,
151}
152
153#[derive(Debug, Clone, Copy, Serialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155pub struct SecurityOutputRulesConfig {
156 pub security_client_server_leak: SecurityRuleSeverityConfig,
157 pub security_sink: SecurityRuleSeverityConfig,
158}
159
160#[derive(Debug, Clone, Copy, Serialize)]
161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
162pub struct SecurityRuleSeverityConfig {
163 pub configured: Severity,
166 pub effective: Severity,
168}
169
170#[derive(Debug, Clone, Serialize)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175pub struct SecurityOutput {
176 pub schema_version: SecuritySchemaVersion,
178 pub version: ToolVersion,
180 pub elapsed_ms: ElapsedMs,
182 pub config: SecurityOutputConfig,
184 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
186 pub meta: Option<Meta>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub gate: Option<SecurityGate>,
192 pub security_findings: Vec<SecurityFinding>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
198 pub unresolved_edge_files: usize,
203 pub unresolved_callee_sites: usize,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
211}
212
213#[derive(Debug, Clone, Serialize)]
215#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
216pub struct SecurityUnresolvedCalleeDiagnostics {
217 pub sampled: Vec<SecurityUnresolvedCalleeSample>,
219 pub top_files: Vec<SecurityUnresolvedCalleeTopFile>,
221 pub by_reason: Vec<SecurityUnresolvedCalleeReasonCount>,
223 pub sample_limit: usize,
225 pub top_files_limit: usize,
227}
228
229#[derive(Debug, Clone, Serialize)]
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
232pub struct SecurityUnresolvedCalleeSample {
233 pub path: String,
235 pub line: u32,
237 pub col: u32,
239 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
241 pub expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
243}
244
245#[derive(Debug, Clone, Serialize)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248pub struct SecurityUnresolvedCalleeTopFile {
249 pub path: String,
251 pub count: usize,
253}
254
255#[derive(Debug, Clone, Serialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258pub struct SecurityUnresolvedCalleeReasonCount {
259 pub reason: fallow_types::extract::SkippedSecurityCalleeReason,
261 pub count: usize,
263}
264
265#[derive(Debug, Clone, Serialize)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270pub struct SecuritySummaryOutput {
271 pub schema_version: SecuritySchemaVersion,
273 pub version: ToolVersion,
275 pub elapsed_ms: ElapsedMs,
277 pub config: SecurityOutputConfig,
279 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
281 pub meta: Option<Meta>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub gate: Option<SecurityGate>,
285 pub summary: SecuritySummary,
287}
288
289#[derive(Debug, Clone, Serialize)]
291#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
292pub struct SecuritySummary {
293 pub security_findings: usize,
295 pub by_severity: SecuritySeverityCounts,
297 pub by_category: BTreeMap<String, usize>,
300 pub by_reachability: SecurityReachabilityCounts,
302 pub by_runtime_state: SecurityRuntimeStateCounts,
304 pub unresolved_edge_files: usize,
306 pub unresolved_callee_sites: usize,
308 pub attack_surface_entries: usize,
310}
311
312#[derive(Debug, Clone, Copy, Default, Serialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315pub struct SecuritySeverityCounts {
316 pub high: usize,
317 pub medium: usize,
318 pub low: usize,
319}
320
321#[derive(Debug, Clone, Copy, Default, Serialize)]
323#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
324pub struct SecurityReachabilityCounts {
325 pub entry_reachable: usize,
326 pub untrusted_source_reachable: usize,
327 pub arg_level: usize,
328 pub module_level: usize,
329 pub crosses_boundary: usize,
330 pub source_backed: usize,
331}
332
333#[derive(Debug, Clone, Copy, Default, Serialize)]
335#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
336pub struct SecurityRuntimeStateCounts {
337 pub runtime_hot: usize,
338 pub runtime_cold: usize,
339 pub never_executed: usize,
340 pub low_traffic: usize,
341 pub coverage_unavailable: usize,
342 pub runtime_unknown: usize,
343 pub not_collected: usize,
344}
345
346pub struct SecurityOptions<'a> {
348 pub root: &'a Path,
350 pub config_path: &'a Option<PathBuf>,
352 pub output: OutputFormat,
354 pub no_cache: bool,
356 pub threads: usize,
358 pub quiet: bool,
360 pub fail_on_issues: bool,
362 pub sarif_file: Option<&'a Path>,
364 pub summary: bool,
366 pub changed_since: Option<&'a str>,
368 pub use_shared_diff_index: bool,
370 pub workspace: Option<&'a [String]>,
372 pub changed_workspaces: Option<&'a str>,
374 pub file: &'a [PathBuf],
376 pub surface: bool,
378 pub gate: Option<SecurityGateMode>,
383 pub runtime_coverage: Option<&'a Path>,
385 pub min_invocations_hot: u64,
387 pub explain: bool,
389}
390
391pub fn run(opts: &SecurityOptions<'_>) -> ExitCode {
396 let started = Instant::now();
397 if !matches!(
398 opts.output,
399 OutputFormat::Human | OutputFormat::Json | OutputFormat::Sarif
400 ) {
401 return emit_error(
402 "fallow security supports --format human, json, or sarif only.",
403 2,
404 opts.output,
405 );
406 }
407
408 let mut config = match load_config_for_analysis(
409 opts.root,
410 opts.config_path,
411 opts.output,
412 opts.no_cache,
413 opts.threads,
414 None,
415 opts.quiet,
416 ProductionAnalysis::DeadCode,
417 ) {
418 Ok(config) => config,
419 Err(code) => return code,
420 };
421
422 let configured_severity = config.rules.security_client_server_leak;
423 let configured_sink_severity = config.rules.security_sink;
424 force_security_rules(&mut config);
425 let effective_severity = config.rules.security_client_server_leak;
426 let effective_sink_severity = config.rules.security_sink;
427
428 let mut analysis = match analyze_security_candidates(opts, &config) {
429 Ok(analysis) => analysis,
430 Err(code) => return code,
431 };
432
433 let ws_roots = match crate::check::filtering::resolve_workspace_scope(
435 opts.root,
436 opts.workspace,
437 opts.changed_workspaces,
438 opts.output,
439 ) {
440 Ok(roots) => roots,
441 Err(code) => return code,
442 };
443 if let Some(ref roots) = ws_roots {
444 crate::check::filtering::filter_to_workspaces(&mut analysis.results, roots);
445 }
446
447 if !matches!(opts.gate, Some(SecurityGateMode::NewlyReachable)) {
448 apply_changed_scope(opts, &mut analysis.results);
449 }
450 filter_to_files(&mut analysis.results, opts.root, opts.file, opts.quiet);
451
452 let gate_mode = match apply_security_gate(opts, &config, &mut analysis.results) {
453 Ok(mode) => mode,
454 Err(code) => return code,
455 };
456
457 let unresolved_edge_files = analysis.results.security_unresolved_edge_files;
458 let unresolved_callee_sites = analysis.results.security_unresolved_callee_sites;
459 let unresolved_callee_diagnostics = unresolved_callee_diagnostics(
460 &analysis.results.security_unresolved_callee_diagnostics,
461 &config.root,
462 );
463 let runtime_report = match security_runtime_report(opts, &mut analysis) {
464 Ok(report) => report,
465 Err(code) => return code,
466 };
467 let mut findings: Vec<SecurityFinding> =
468 std::mem::take(&mut analysis.results.security_findings)
469 .into_iter()
470 .map(|f| relativize_finding(f, &config.root))
471 .collect();
472 if let (Some(report), Some(modules), Some(files)) = (
473 runtime_report.as_ref(),
474 analysis.modules.as_ref(),
475 analysis.files.as_ref(),
476 ) {
477 apply_runtime_context(&mut findings, modules, files, &config.root, report);
478 }
479 apply_security_severity(&mut findings);
480 sort_by_security_severity(&mut findings);
481 for finding in &mut findings {
482 finding.finding_id = security_finding_id(finding);
485 }
486 let (findings, attack_surface) = prepare_findings(findings, &config.root, opts.surface);
487
488 let gate = gate_mode.map(|mode| {
493 let new_count = findings.len();
494 SecurityGate {
495 mode,
496 verdict: if new_count > 0 {
497 SecurityGateVerdict::Fail
498 } else {
499 SecurityGateVerdict::Pass
500 },
501 new_count,
502 }
503 });
504
505 let advisory_fail = (opts.fail_on_issues
506 || effective_severity == Severity::Error
507 || effective_sink_severity == Severity::Error)
508 && !findings.is_empty();
509
510 let output = SecurityOutput {
511 schema_version: SecuritySchemaVersion::V6,
512 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
513 elapsed_ms: ElapsedMs(started.elapsed().as_millis() as u64),
514 config: security_output_config(
515 &config,
516 configured_severity,
517 effective_severity,
518 configured_sink_severity,
519 effective_sink_severity,
520 ),
521 meta: opts.explain.then(crate::explain::security_meta),
522 gate,
523 security_findings: findings,
524 attack_surface,
525 unresolved_edge_files,
526 unresolved_callee_sites,
527 unresolved_callee_diagnostics,
528 };
529 crate::telemetry::note_result_count(output.security_findings.len());
530
531 if let Some(path) = opts.sarif_file
532 && let Err(message) = write_sarif_file(&output, path)
533 {
534 return emit_error(&message, 2, opts.output);
535 }
536
537 let rendered = match opts.output {
538 OutputFormat::Json if opts.summary => render_json_summary(&output),
539 OutputFormat::Json => render_json(&output),
540 OutputFormat::Sarif => render_sarif(&output),
541 _ if opts.summary => render_human_summary(&output),
542 _ => render_human(&output),
543 };
544 outln!("{rendered}");
545
546 if let Some(gate) = &output.gate {
552 if gate.verdict == SecurityGateVerdict::Fail {
553 ExitCode::from(8)
554 } else {
555 ExitCode::SUCCESS
556 }
557 } else if advisory_fail {
558 ExitCode::from(1)
559 } else {
560 ExitCode::SUCCESS
561 }
562}
563
564fn force_security_rules(config: &mut fallow_config::ResolvedConfig) {
565 if config.rules.security_client_server_leak == Severity::Off {
568 config.rules.security_client_server_leak = Severity::Warn;
569 }
570 if config.rules.security_sink == Severity::Off {
571 config.rules.security_sink = Severity::Warn;
572 }
573}
574
575fn security_output_config(
576 config: &fallow_config::ResolvedConfig,
577 configured_severity: Severity,
578 effective_severity: Severity,
579 configured_sink_severity: Severity,
580 effective_sink_severity: Severity,
581) -> SecurityOutputConfig {
582 let categories = config.security.categories.as_ref();
583 SecurityOutputConfig {
584 rules: SecurityOutputRulesConfig {
585 security_client_server_leak: SecurityRuleSeverityConfig {
586 configured: configured_severity,
587 effective: effective_severity,
588 },
589 security_sink: SecurityRuleSeverityConfig {
590 configured: configured_sink_severity,
591 effective: effective_sink_severity,
592 },
593 },
594 categories_include: categories.and_then(|categories| categories.include.clone()),
595 categories_exclude: categories.and_then(|categories| categories.exclude.clone()),
596 }
597}
598
599fn apply_changed_scope(opts: &SecurityOptions<'_>, results: &mut AnalysisResults) {
600 if let Some(git_ref) = opts.changed_since
601 && let Some(changed) = fallow_core::changed_files::get_changed_files(opts.root, git_ref)
602 {
603 fallow_core::changed_files::filter_results_by_changed_files(results, &changed);
604 }
605 if opts.use_shared_diff_index
606 && let Some(diff_index) = crate::report::ci::diff_filter::shared_diff_index()
607 {
608 crate::check::filtering::filter_results_by_diff(results, diff_index, opts.root);
609 }
610}
611
612fn apply_security_gate(
613 opts: &SecurityOptions<'_>,
614 config: &fallow_config::ResolvedConfig,
615 results: &mut AnalysisResults,
616) -> Result<Option<SecurityGateMode>, ExitCode> {
617 let Some(mode) = opts.gate else {
618 return Ok(None);
619 };
620
621 if matches!(mode, SecurityGateMode::NewlyReachable) {
622 retain_gate_newly_reachable(opts, config, results)?;
623 return Ok(Some(mode));
624 }
625
626 let mut owned_gate_diff: Option<crate::report::ci::diff_filter::DiffIndex> = None;
631 let gate_diff: &crate::report::ci::diff_filter::DiffIndex =
632 if let Some(shared) = crate::report::ci::diff_filter::shared_diff_index() {
633 shared
634 } else if let Some(git_ref) = opts.changed_since {
635 match fallow_core::changed_files::try_get_changed_diff(opts.root, git_ref) {
636 Ok(text) => owned_gate_diff
637 .insert(crate::report::ci::diff_filter::DiffIndex::from_unified_diff(&text)),
638 Err(err) => {
639 return Err(emit_error(
640 &format!(
641 "fallow security --gate could not compute the diff for '{git_ref}': {}",
642 err.describe()
643 ),
644 2,
645 opts.output,
646 ));
647 }
648 }
649 } else {
650 return Err(emit_error(
651 "fallow security --gate requires a diff source: --changed-since <ref>, \
652 --diff-file <path>, or --diff-stdin.",
653 2,
654 opts.output,
655 ));
656 };
657 crate::check::filtering::retain_gate_new(results, gate_diff, opts.root);
658 Ok(Some(mode))
659}
660
661const SECURITY_BASE_SNAPSHOT_CACHE_VERSION: u8 = 1;
662const MAX_SECURITY_BASE_SNAPSHOT_CACHE_SIZE: usize = 8 * 1024 * 1024;
663
664#[derive(Debug, Clone)]
665struct SecurityKeySnapshot {
666 reachable: FxHashSet<String>,
667}
668
669struct SecurityBaseSnapshotCacheKey {
670 hash: u64,
671 base_sha: String,
672}
673
674#[derive(bitcode::Encode, bitcode::Decode)]
675struct CachedSecurityKeySnapshot {
676 version: u8,
677 cli_version: String,
678 key_hash: u64,
679 base_sha: String,
680 reachable: Vec<String>,
681}
682
683fn retain_gate_newly_reachable(
684 opts: &SecurityOptions<'_>,
685 config: &fallow_config::ResolvedConfig,
686 results: &mut AnalysisResults,
687) -> Result<(), ExitCode> {
688 let Some(base_ref) = opts.changed_since else {
689 return Err(emit_error(
690 "fallow security --gate newly-reachable requires --changed-since <ref>; \
691 --diff-file and --diff-stdin do not identify a base tree.",
692 2,
693 opts.output,
694 ));
695 };
696 let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
697 return Err(emit_error(
698 &format!(
699 "fallow security --gate newly-reachable could not resolve base ref '{base_ref}'."
700 ),
701 2,
702 opts.output,
703 ));
704 };
705 let cache_key = security_base_snapshot_cache_key(opts, config, &base_sha)?;
706 let base = if let Some(snapshot) = load_cached_security_base_snapshot(config, &cache_key) {
707 snapshot
708 } else {
709 let snapshot = compute_base_security_snapshot(opts, config, base_ref, &base_sha)?;
710 save_cached_security_base_snapshot(config, &cache_key, &snapshot);
711 snapshot
712 };
713 results.security_findings.retain(|finding| {
714 security_reachability_key(finding, opts.root)
715 .is_some_and(|key| !base.reachable.contains(&key))
716 });
717 Ok(())
718}
719
720fn compute_base_security_snapshot(
721 opts: &SecurityOptions<'_>,
722 config: &fallow_config::ResolvedConfig,
723 base_ref: &str,
724 base_sha: &str,
725) -> Result<SecurityKeySnapshot, ExitCode> {
726 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, Some(base_sha)) else {
727 return Err(emit_error(
728 &format!("could not create a temporary worktree for base ref '{base_ref}'"),
729 2,
730 opts.output,
731 ));
732 };
733 let base_root = base_analysis_root(opts.root, worktree.path());
734 let current_config_path = opts
735 .config_path
736 .clone()
737 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
738 let mut base_config = load_config_for_analysis(
739 &base_root,
740 ¤t_config_path,
741 opts.output,
742 opts.no_cache,
743 opts.threads,
744 None,
745 true,
746 ProductionAnalysis::DeadCode,
747 )?;
748 base_config.cache_dir =
749 remap_cache_dir_for_base_worktree(opts.root, &base_root, &config.cache_dir);
750 force_security_rules(&mut base_config);
751 let mut base_analysis = analyze_security_candidates(
752 &SecurityOptions {
753 root: &base_root,
754 config_path: ¤t_config_path,
755 output: opts.output,
756 no_cache: opts.no_cache,
757 threads: opts.threads,
758 quiet: true,
759 fail_on_issues: false,
760 sarif_file: None,
761 summary: false,
762 changed_since: None,
763 use_shared_diff_index: false,
764 workspace: opts.workspace,
765 changed_workspaces: None,
766 file: &[],
767 surface: false,
768 gate: None,
769 runtime_coverage: None,
770 min_invocations_hot: opts.min_invocations_hot,
771 explain: false,
772 },
773 &base_config,
774 )?;
775 if let Some(ref roots) = crate::check::filtering::resolve_workspace_scope(
776 &base_root,
777 opts.workspace,
778 None,
779 opts.output,
780 )? {
781 crate::check::filtering::filter_to_workspaces(&mut base_analysis.results, roots);
782 }
783 Ok(SecurityKeySnapshot {
784 reachable: security_reachable_keys(&base_analysis.results.security_findings, &base_root),
785 })
786}
787
788fn security_reachable_keys(findings: &[SecurityFinding], root: &Path) -> FxHashSet<String> {
789 findings
790 .iter()
791 .filter_map(|finding| security_reachability_key(finding, root))
792 .collect()
793}
794
795fn security_reachability_key(finding: &SecurityFinding, root: &Path) -> Option<String> {
796 if !finding
797 .reachability
798 .as_ref()
799 .is_some_and(|reachability| reachability.reachable_from_entry)
800 {
801 return None;
802 }
803 let category = finding.category.as_deref().unwrap_or("none");
804 Some(format!(
805 "security-reach:{}:{}:{}",
806 relative_key(&finding.path, root),
807 security_kind_key(finding.kind),
808 category,
809 ))
810}
811
812fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
813 match kind {
814 SecurityFindingKind::ClientServerLeak => "client-server-leak",
815 SecurityFindingKind::TaintedSink => "tainted-sink",
816 }
817}
818
819fn security_base_snapshot_cache_key(
820 opts: &SecurityOptions<'_>,
821 config: &fallow_config::ResolvedConfig,
822 base_sha: &str,
823) -> Result<SecurityBaseSnapshotCacheKey, ExitCode> {
824 let payload = serde_json::json!({
825 "cache_version": SECURITY_BASE_SNAPSHOT_CACHE_VERSION,
826 "cli_version": env!("CARGO_PKG_VERSION"),
827 "base_sha": base_sha,
828 "config_hash": format!("{:016x}", config.cache_config_hash),
829 "security_client_server_leak": format!("{:?}", config.rules.security_client_server_leak),
830 "security_sink": format!("{:?}", config.rules.security_sink),
831 "workspace": opts.workspace,
832 "changed_workspaces": opts.changed_workspaces,
833 });
834 let bytes = serde_json::to_vec(&payload).map_err(|err| {
835 emit_error(
836 &format!("failed to build security gate cache key: {err}"),
837 2,
838 opts.output,
839 )
840 })?;
841 Ok(SecurityBaseSnapshotCacheKey {
842 hash: xxh3_64(&bytes),
843 base_sha: base_sha.to_owned(),
844 })
845}
846
847fn security_base_snapshot_cache_dir(config: &fallow_config::ResolvedConfig) -> PathBuf {
848 config.cache_dir.join("cache").join(format!(
849 "security-base-v{SECURITY_BASE_SNAPSHOT_CACHE_VERSION}"
850 ))
851}
852
853fn security_base_snapshot_cache_file(
854 config: &fallow_config::ResolvedConfig,
855 key: &SecurityBaseSnapshotCacheKey,
856) -> PathBuf {
857 security_base_snapshot_cache_dir(config).join(format!("{:016x}.bin", key.hash))
858}
859
860fn ensure_security_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
861 std::fs::create_dir_all(dir)?;
862 let gitignore = dir.join(".gitignore");
863 if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
864 std::fs::write(gitignore, "*\n")?;
865 }
866 Ok(())
867}
868
869fn load_cached_security_base_snapshot(
870 config: &fallow_config::ResolvedConfig,
871 key: &SecurityBaseSnapshotCacheKey,
872) -> Option<SecurityKeySnapshot> {
873 if config.no_cache {
874 return None;
875 }
876 let path = security_base_snapshot_cache_file(config, key);
877 let data = std::fs::read(path).ok()?;
878 if data.len() > MAX_SECURITY_BASE_SNAPSHOT_CACHE_SIZE {
879 return None;
880 }
881 let cached: CachedSecurityKeySnapshot = bitcode::decode(&data).ok()?;
882 if cached.version != SECURITY_BASE_SNAPSHOT_CACHE_VERSION
883 || cached.cli_version != env!("CARGO_PKG_VERSION")
884 || cached.key_hash != key.hash
885 || cached.base_sha != key.base_sha
886 {
887 return None;
888 }
889 Some(SecurityKeySnapshot {
890 reachable: cached.reachable.into_iter().collect(),
891 })
892}
893
894fn save_cached_security_base_snapshot(
895 config: &fallow_config::ResolvedConfig,
896 key: &SecurityBaseSnapshotCacheKey,
897 snapshot: &SecurityKeySnapshot,
898) {
899 if config.no_cache {
900 return;
901 }
902 let dir = security_base_snapshot_cache_dir(config);
903 if ensure_security_base_snapshot_cache_dir(&dir).is_err() {
904 return;
905 }
906 let mut reachable = snapshot.reachable.iter().cloned().collect::<Vec<_>>();
907 reachable.sort_unstable();
908 let data = bitcode::encode(&CachedSecurityKeySnapshot {
909 version: SECURITY_BASE_SNAPSHOT_CACHE_VERSION,
910 cli_version: env!("CARGO_PKG_VERSION").to_owned(),
911 key_hash: key.hash,
912 base_sha: key.base_sha.clone(),
913 reachable,
914 });
915 let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
916 return;
917 };
918 if tmp.write_all(&data).is_err() {
919 return;
920 }
921 let _ = tmp.persist(security_base_snapshot_cache_file(config, key));
922}
923
924fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
925 if current_root.is_absolute()
926 && let Some(git_root) = crate::base_worktree::git_toplevel(current_root)
927 && let Ok(relative) = current_root.strip_prefix(git_root)
928 {
929 return base_worktree_root.join(relative);
930 }
931 base_worktree_root.to_path_buf()
932}
933
934fn remap_cache_dir_for_base_worktree(
935 current_root: &Path,
936 base_worktree_root: &Path,
937 cache_dir: &Path,
938) -> PathBuf {
939 if cache_dir.is_absolute()
940 && let Ok(relative) = cache_dir.strip_prefix(current_root)
941 {
942 return base_worktree_root.join(relative);
943 }
944 cache_dir.to_path_buf()
945}
946
947struct SecurityAnalysisState {
948 results: AnalysisResults,
949 modules: Option<Vec<ModuleInfo>>,
950 files: Option<Vec<DiscoveredFile>>,
951 analysis_output: Option<fallow_core::AnalysisOutput>,
952}
953
954#[expect(
955 deprecated,
956 reason = "ADR-008 deprecates fallow_core::analyze APIs externally; the CLI uses the workspace path dependency"
957)]
958fn analyze_security_candidates(
959 opts: &SecurityOptions<'_>,
960 config: &fallow_config::ResolvedConfig,
961) -> Result<SecurityAnalysisState, ExitCode> {
962 if opts.runtime_coverage.is_none() {
963 return fallow_core::analyze(config)
964 .map(|results| SecurityAnalysisState {
965 results,
966 modules: None,
967 files: None,
968 analysis_output: None,
969 })
970 .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output));
971 }
972
973 fallow_core::analyze_retaining_modules(config, true, true)
974 .map(|mut output| {
975 let modules = output.modules.take();
976 let files = output.files.take();
977 let results = output.results.clone();
978 SecurityAnalysisState {
979 results,
980 modules,
981 files,
982 analysis_output: Some(output),
983 }
984 })
985 .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output))
986}
987
988fn security_runtime_report(
989 opts: &SecurityOptions<'_>,
990 analysis: &mut SecurityAnalysisState,
991) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
992 let Some(path) = opts.runtime_coverage else {
993 return Ok(None);
994 };
995 let (Some(modules), Some(files), Some(analysis_output)) = (
996 analysis.modules.as_ref(),
997 analysis.files.as_ref(),
998 analysis.analysis_output.take(),
999 ) else {
1000 return Ok(None);
1001 };
1002 analyze_security_runtime(opts, path, modules.clone(), files.clone(), analysis_output)
1003}
1004
1005fn analyze_security_runtime(
1006 opts: &SecurityOptions<'_>,
1007 path: &Path,
1008 modules: Vec<ModuleInfo>,
1009 files: Vec<DiscoveredFile>,
1010 analysis_output: fallow_core::AnalysisOutput,
1011) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
1012 let runtime_coverage = crate::health::coverage::prepare_options(
1013 path,
1014 opts.min_invocations_hot,
1015 None,
1016 None,
1017 opts.output,
1018 )?;
1019 let result = crate::health::execute_health_with_shared_parse(
1020 &HealthOptions {
1021 root: opts.root,
1022 config_path: opts.config_path,
1023 output: opts.output,
1024 no_cache: opts.no_cache,
1025 threads: opts.threads,
1026 quiet: opts.quiet,
1027 max_cyclomatic: None,
1028 max_cognitive: None,
1029 max_crap: None,
1030 top: None,
1031 sort: SortBy::Cyclomatic,
1032 production: true,
1033 production_override: Some(true),
1034 changed_since: opts.changed_since,
1035 diff_index: None,
1036 use_shared_diff_index: opts.use_shared_diff_index,
1037 workspace: opts.workspace,
1038 changed_workspaces: opts.changed_workspaces,
1039 baseline: None,
1040 save_baseline: None,
1041 complexity: false,
1042 complexity_breakdown: false,
1043 file_scores: false,
1044 coverage_gaps: false,
1045 config_activates_coverage_gaps: false,
1046 hotspots: false,
1047 ownership: false,
1048 ownership_emails: None,
1049 targets: false,
1050 force_full: false,
1051 score_only_output: false,
1052 enforce_coverage_gap_gate: false,
1053 effort: None,
1054 score: false,
1055 min_score: None,
1056 since: None,
1057 min_commits: None,
1058 explain: false,
1059 summary: false,
1060 save_snapshot: None,
1061 trend: false,
1062 group_by: None,
1063 coverage: None,
1064 coverage_root: None,
1065 performance: false,
1066 min_severity: None,
1067 report_only: false,
1068 runtime_coverage: Some(runtime_coverage),
1069 churn_file: None,
1070 },
1071 SharedParseData {
1072 files,
1073 modules,
1074 analysis_output: Some(analysis_output),
1075 },
1076 )?;
1077 Ok(result.report.runtime_coverage)
1078}
1079
1080#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1081struct RuntimeFunctionKey {
1082 path: String,
1083 function: String,
1084 line: u32,
1085}
1086
1087#[derive(Debug, Clone)]
1088struct FunctionSpan {
1089 key: RuntimeFunctionKey,
1090 end_line: u32,
1091}
1092
1093fn apply_runtime_context(
1094 findings: &mut Vec<SecurityFinding>,
1095 modules: &[ModuleInfo],
1096 files: &[fallow_types::discover::DiscoveredFile],
1097 root: &Path,
1098 report: &RuntimeCoverageReport,
1099) {
1100 let spans = function_spans(modules, files, root);
1101 let runtime = SecurityRuntimeIndex::new(report);
1102 let mut indexed = findings.drain(..).enumerate().collect::<Vec<_>>();
1103 for (_, finding) in &mut indexed {
1104 if !matches!(finding.kind, SecurityFindingKind::TaintedSink) {
1105 continue;
1106 }
1107 finding.runtime = runtime_context_for_finding(finding, &spans, &runtime);
1108 }
1109 indexed.sort_by(|(left_index, left), (right_index, right)| {
1110 runtime_rank(left)
1111 .cmp(&runtime_rank(right))
1112 .then_with(|| left_index.cmp(right_index))
1113 });
1114 findings.extend(indexed.into_iter().map(|(_, finding)| finding));
1115}
1116
1117fn function_spans(
1118 modules: &[ModuleInfo],
1119 files: &[fallow_types::discover::DiscoveredFile],
1120 root: &Path,
1121) -> Vec<FunctionSpan> {
1122 let paths_by_id = files
1123 .iter()
1124 .map(|file| (file.id, &file.path))
1125 .collect::<rustc_hash::FxHashMap<_, _>>();
1126 let mut spans = Vec::new();
1127 for module in modules {
1128 let Some(path) = paths_by_id.get(&module.file_id) else {
1129 continue;
1130 };
1131 let path = relative_key(path, root);
1132 for function in &module.complexity {
1133 spans.push(FunctionSpan {
1134 key: RuntimeFunctionKey {
1135 path: path.clone(),
1136 function: function.name.clone(),
1137 line: function.line,
1138 },
1139 end_line: function.line.saturating_add(function.line_count),
1140 });
1141 }
1142 }
1143 spans
1144}
1145
1146struct SecurityRuntimeIndex {
1147 hot_paths: Vec<(RuntimeFunctionKey, u32, SecurityRuntimeContext)>,
1148 findings: rustc_hash::FxHashMap<RuntimeFunctionKey, SecurityRuntimeContext>,
1149}
1150
1151impl SecurityRuntimeIndex {
1152 fn new(report: &RuntimeCoverageReport) -> Self {
1153 let hot_paths = report
1154 .hot_paths
1155 .iter()
1156 .map(|hot| {
1157 (
1158 runtime_hot_key(hot),
1159 hot.end_line.max(hot.line),
1160 SecurityRuntimeContext {
1161 state: SecurityRuntimeState::RuntimeHot,
1162 function: hot.function.clone(),
1163 line: hot.line,
1164 invocations: Some(hot.invocations),
1165 stable_id: hot.stable_id.clone(),
1166 evidence: Some(format!(
1167 "production hot path observed with {} invocation{}",
1168 hot.invocations,
1169 crate::report::plural(hot.invocations as usize)
1170 )),
1171 },
1172 )
1173 })
1174 .collect();
1175 let findings = report
1176 .findings
1177 .iter()
1178 .map(runtime_finding_context)
1179 .collect();
1180 Self {
1181 hot_paths,
1182 findings,
1183 }
1184 }
1185}
1186
1187fn runtime_context_for_finding(
1188 finding: &SecurityFinding,
1189 spans: &[FunctionSpan],
1190 runtime: &SecurityRuntimeIndex,
1191) -> Option<SecurityRuntimeContext> {
1192 let path = path_key(&finding.path);
1193 let span = spans
1194 .iter()
1195 .filter(|span| {
1196 span.key.path == path && span.key.line <= finding.line && finding.line <= span.end_line
1197 })
1198 .min_by_key(|span| span.end_line.saturating_sub(span.key.line))?;
1199 if let Some((_, _, context)) = runtime.hot_paths.iter().find(|(key, end_line, _)| {
1200 key == &span.key && key.line <= finding.line && finding.line <= *end_line
1201 }) {
1202 return Some(context.clone());
1203 }
1204 runtime.findings.get(&span.key).cloned().or_else(|| {
1205 Some(SecurityRuntimeContext {
1206 state: SecurityRuntimeState::RuntimeUnknown,
1207 function: span.key.function.clone(),
1208 line: span.key.line,
1209 invocations: None,
1210 stable_id: None,
1211 evidence: Some("runtime coverage carried no matching function evidence".to_owned()),
1212 })
1213 })
1214}
1215
1216fn runtime_rank(finding: &SecurityFinding) -> u8 {
1217 match finding.runtime.as_ref().map(|runtime| runtime.state) {
1218 Some(SecurityRuntimeState::RuntimeHot) => 0,
1219 Some(SecurityRuntimeState::LowTraffic) => 1,
1220 None | Some(SecurityRuntimeState::RuntimeUnknown) => 2,
1221 Some(SecurityRuntimeState::CoverageUnavailable) => 3,
1222 Some(SecurityRuntimeState::RuntimeCold) => 4,
1223 Some(SecurityRuntimeState::NeverExecuted) => 5,
1224 }
1225}
1226
1227fn apply_security_severity(findings: &mut [SecurityFinding]) {
1228 for finding in findings {
1229 finding.severity = derive_security_severity(finding);
1230 }
1231}
1232
1233fn sort_by_security_severity(findings: &mut [SecurityFinding]) {
1234 findings.sort_by(compare_security_priority);
1235}
1236
1237fn compare_security_priority(left: &SecurityFinding, right: &SecurityFinding) -> Ordering {
1238 security_severity_rank(left.severity)
1239 .cmp(&security_severity_rank(right.severity))
1240 .then_with(|| runtime_rank(left).cmp(&runtime_rank(right)))
1241 .then_with(|| {
1242 right
1243 .reachability
1244 .as_ref()
1245 .is_some_and(|reach| reach.reachable_from_entry)
1246 .cmp(
1247 &left
1248 .reachability
1249 .as_ref()
1250 .is_some_and(|reach| reach.reachable_from_entry),
1251 )
1252 })
1253 .then_with(|| taint_rank(left).cmp(&taint_rank(right)))
1254 .then_with(|| security_blast_radius(right).cmp(&security_blast_radius(left)))
1255 .then_with(|| security_crosses_boundary(right).cmp(&security_crosses_boundary(left)))
1256 .then_with(|| left.dead_code.is_some().cmp(&right.dead_code.is_some()))
1257 .then_with(|| left.path.cmp(&right.path))
1258 .then_with(|| left.line.cmp(&right.line))
1259 .then_with(|| left.col.cmp(&right.col))
1260 .then_with(|| left.category.cmp(&right.category))
1261}
1262
1263fn taint_rank(finding: &SecurityFinding) -> u8 {
1264 match finding
1265 .reachability
1266 .as_ref()
1267 .and_then(|reach| reach.taint_confidence)
1268 {
1269 Some(TaintConfidence::ArgLevel) => 0,
1270 Some(TaintConfidence::ModuleLevel) => 1,
1271 None if finding.source_backed => 0,
1272 None if finding
1273 .reachability
1274 .as_ref()
1275 .is_some_and(|reach| reach.reachable_from_untrusted_source) =>
1276 {
1277 1
1278 }
1279 None => 2,
1280 }
1281}
1282
1283fn security_blast_radius(finding: &SecurityFinding) -> u32 {
1284 finding
1285 .reachability
1286 .as_ref()
1287 .map_or(0, |reach| reach.blast_radius)
1288}
1289
1290fn security_crosses_boundary(finding: &SecurityFinding) -> bool {
1291 finding
1292 .reachability
1293 .as_ref()
1294 .is_some_and(|reach| reach.crosses_boundary)
1295}
1296
1297const fn security_severity_rank(severity: SecuritySeverity) -> u8 {
1298 match severity {
1299 SecuritySeverity::High => 0,
1300 SecuritySeverity::Medium => 1,
1301 SecuritySeverity::Low => 2,
1302 }
1303}
1304
1305fn runtime_hot_key(hot: &RuntimeCoverageHotPath) -> RuntimeFunctionKey {
1306 RuntimeFunctionKey {
1307 path: path_key(&hot.path),
1308 function: hot.function.clone(),
1309 line: hot.line,
1310 }
1311}
1312
1313fn runtime_finding_context(
1314 finding: &RuntimeCoverageFinding,
1315) -> (RuntimeFunctionKey, SecurityRuntimeContext) {
1316 let state = match finding.verdict {
1317 RuntimeCoverageVerdict::SafeToDelete => SecurityRuntimeState::NeverExecuted,
1318 RuntimeCoverageVerdict::ReviewRequired if finding.invocations.unwrap_or(0) == 0 => {
1319 SecurityRuntimeState::RuntimeCold
1320 }
1321 RuntimeCoverageVerdict::LowTraffic => SecurityRuntimeState::LowTraffic,
1322 RuntimeCoverageVerdict::CoverageUnavailable | RuntimeCoverageVerdict::Unknown => {
1323 SecurityRuntimeState::CoverageUnavailable
1324 }
1325 RuntimeCoverageVerdict::ReviewRequired | RuntimeCoverageVerdict::Active => {
1326 SecurityRuntimeState::RuntimeUnknown
1327 }
1328 };
1329 (
1330 RuntimeFunctionKey {
1331 path: path_key(&finding.path),
1332 function: finding.function.clone(),
1333 line: finding.line,
1334 },
1335 SecurityRuntimeContext {
1336 state,
1337 function: finding.function.clone(),
1338 line: finding.line,
1339 invocations: finding.invocations,
1340 stable_id: finding.stable_id.clone(),
1341 evidence: Some(format!("runtime coverage verdict: {}", finding.verdict)),
1342 },
1343 )
1344}
1345
1346fn relative_key(path: &Path, root: &Path) -> String {
1347 path_key(path.strip_prefix(root).unwrap_or(path))
1348}
1349
1350fn path_key(path: &Path) -> String {
1351 path.to_string_lossy().replace('\\', "/")
1352}
1353
1354fn unresolved_callee_diagnostics(
1355 diagnostics: &[SecurityUnresolvedCalleeDiagnostic],
1356 root: &Path,
1357) -> Option<SecurityUnresolvedCalleeDiagnostics> {
1358 if diagnostics.is_empty() {
1359 return None;
1360 }
1361
1362 let mut sorted = diagnostics.to_vec();
1363 sorted.sort_by(|a, b| {
1364 a.path
1365 .cmp(&b.path)
1366 .then(a.line.cmp(&b.line))
1367 .then(a.col.cmp(&b.col))
1368 .then(a.reason.cmp(&b.reason))
1369 .then(a.expression_kind.cmp(&b.expression_kind))
1370 });
1371
1372 let sampled = sorted
1373 .iter()
1374 .take(UNRESOLVED_CALLEE_SAMPLE_LIMIT)
1375 .map(|diagnostic| SecurityUnresolvedCalleeSample {
1376 path: relative_key(&diagnostic.path, root),
1377 line: diagnostic.line,
1378 col: diagnostic.col,
1379 reason: diagnostic.reason,
1380 expression_kind: diagnostic.expression_kind,
1381 })
1382 .collect();
1383
1384 let mut by_file: BTreeMap<String, usize> = BTreeMap::new();
1385 let mut by_reason: BTreeMap<fallow_types::extract::SkippedSecurityCalleeReason, usize> =
1386 BTreeMap::new();
1387 for diagnostic in &sorted {
1388 *by_file
1389 .entry(relative_key(&diagnostic.path, root))
1390 .or_insert(0) += 1;
1391 *by_reason.entry(diagnostic.reason).or_insert(0) += 1;
1392 }
1393
1394 let mut top_files: Vec<_> = by_file
1395 .into_iter()
1396 .map(|(path, count)| SecurityUnresolvedCalleeTopFile { path, count })
1397 .collect();
1398 top_files.sort_by(|a, b| b.count.cmp(&a.count).then(a.path.cmp(&b.path)));
1399 top_files.truncate(UNRESOLVED_CALLEE_TOP_FILES_LIMIT);
1400
1401 let mut by_reason: Vec<_> = by_reason
1402 .into_iter()
1403 .map(|(reason, count)| SecurityUnresolvedCalleeReasonCount { reason, count })
1404 .collect();
1405 by_reason.sort_by(|a, b| b.count.cmp(&a.count).then(a.reason.cmp(&b.reason)));
1406
1407 Some(SecurityUnresolvedCalleeDiagnostics {
1408 sampled,
1409 top_files,
1410 by_reason,
1411 sample_limit: UNRESOLVED_CALLEE_SAMPLE_LIMIT,
1412 top_files_limit: UNRESOLVED_CALLEE_TOP_FILES_LIMIT,
1413 })
1414}
1415
1416fn filter_to_files(
1417 results: &mut fallow_core::results::AnalysisResults,
1418 root: &Path,
1419 files: &[PathBuf],
1420 quiet: bool,
1421) {
1422 if files.is_empty() {
1423 return;
1424 }
1425
1426 let resolved_files: Vec<PathBuf> = files
1427 .iter()
1428 .map(|path| {
1429 if crate::path_util::is_absolute_path_any_platform(path) {
1430 path.clone()
1431 } else {
1432 root.join(path)
1433 }
1434 })
1435 .collect();
1436
1437 if !quiet {
1438 for (original, resolved) in files.iter().zip(&resolved_files) {
1439 if !resolved.exists() {
1440 eprintln!(
1441 "Warning: --file '{}' (resolved to '{}') was not found in the project",
1442 original.display(),
1443 resolved.display()
1444 );
1445 }
1446 }
1447 }
1448
1449 let file_set: rustc_hash::FxHashSet<PathBuf> = resolved_files.into_iter().collect();
1450 fallow_core::changed_files::filter_results_by_changed_files(results, &file_set);
1451}
1452
1453fn prepare_findings(
1454 findings: Vec<SecurityFinding>,
1455 root: &Path,
1456 include_surface: bool,
1457) -> (
1458 Vec<SecurityFinding>,
1459 Option<Vec<SecurityAttackSurfaceEntry>>,
1460) {
1461 let mut findings: Vec<SecurityFinding> = findings
1462 .into_iter()
1463 .map(|f| {
1464 let mut f = relativize_finding(f, root);
1465 f.finding_id = security_finding_id(&f);
1466 f
1467 })
1468 .collect();
1469 let attack_surface = include_surface.then(|| {
1470 findings
1471 .iter()
1472 .filter_map(|finding| finding.attack_surface.clone())
1473 .collect()
1474 });
1475 for finding in &mut findings {
1476 finding.attack_surface = None;
1477 }
1478 (findings, attack_surface)
1479}
1480
1481fn relativize_finding(mut finding: SecurityFinding, root: &Path) -> SecurityFinding {
1484 finding.path = relativize(&finding.path, root);
1485 for hop in &mut finding.trace {
1486 hop.path = relativize(&hop.path, root);
1487 }
1488 if let Some(reachability) = &mut finding.reachability {
1489 for hop in &mut reachability.untrusted_source_trace {
1490 hop.path = relativize(&hop.path, root);
1491 }
1492 }
1493 finding.candidate.sink.path = relativize(&finding.candidate.sink.path, root);
1494 if let Some(flow) = &mut finding.taint_flow {
1495 flow.source.path = relativize(&flow.source.path, root);
1496 flow.sink.path = relativize(&flow.sink.path, root);
1497 }
1498 if let Some(surface) = &mut finding.attack_surface {
1499 surface.source.path = relativize(&surface.source.path, root);
1500 surface.sink.path = relativize(&surface.sink.path, root);
1501 for hop in &mut surface.path {
1502 hop.path = relativize(&hop.path, root);
1503 }
1504 for control in &mut surface.defensive_boundary.controls {
1505 control.path = relativize(&control.path, root);
1506 }
1507 }
1508 finding
1509}
1510
1511fn relativize(path: &Path, root: &Path) -> PathBuf {
1512 path.strip_prefix(root)
1513 .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
1514}
1515
1516#[must_use]
1518pub fn render_json(output: &SecurityOutput) -> String {
1519 let Ok(value) = crate::output_envelope::serialize_root_output(
1520 crate::output_envelope::FallowOutput::Security(output.clone()),
1521 ) else {
1522 return "{\"error\":\"failed to serialize security output\"}".to_owned();
1523 };
1524 serde_json::to_string_pretty(&value)
1525 .unwrap_or_else(|_| "{\"error\":\"failed to serialize security output\"}".to_owned())
1526}
1527
1528#[must_use]
1530pub fn render_json_summary(output: &SecurityOutput) -> String {
1531 let summary = SecuritySummaryOutput {
1532 schema_version: output.schema_version,
1533 version: output.version.clone(),
1534 elapsed_ms: output.elapsed_ms,
1535 config: output.config.clone(),
1536 meta: output.meta.clone(),
1537 gate: output.gate,
1538 summary: security_summary(output),
1539 };
1540 let Ok(value) = crate::output_envelope::serialize_root_output_without_telemetry(
1541 crate::output_envelope::FallowOutput::SecuritySummary(summary),
1542 ) else {
1543 return "{\"error\":\"failed to serialize security summary output\"}".to_owned();
1544 };
1545 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
1546 "{\"error\":\"failed to serialize security summary output\"}".to_owned()
1547 })
1548}
1549
1550fn security_summary(output: &SecurityOutput) -> SecuritySummary {
1551 let mut by_severity = SecuritySeverityCounts::default();
1552 let mut by_reachability = SecurityReachabilityCounts::default();
1553 let mut by_runtime_state = SecurityRuntimeStateCounts::default();
1554 let mut by_category = BTreeMap::new();
1555
1556 for finding in &output.security_findings {
1557 match finding.severity {
1558 SecuritySeverity::High => by_severity.high += 1,
1559 SecuritySeverity::Medium => by_severity.medium += 1,
1560 SecuritySeverity::Low => by_severity.low += 1,
1561 }
1562 let category = finding
1563 .category
1564 .clone()
1565 .unwrap_or_else(|| security_kind_key(finding.kind).to_owned());
1566 *by_category.entry(category).or_insert(0) += 1;
1567
1568 if finding.source_backed {
1569 by_reachability.source_backed += 1;
1570 }
1571 if let Some(reachability) = &finding.reachability {
1572 if reachability.reachable_from_entry {
1573 by_reachability.entry_reachable += 1;
1574 }
1575 if reachability.reachable_from_untrusted_source {
1576 by_reachability.untrusted_source_reachable += 1;
1577 }
1578 if reachability.crosses_boundary {
1579 by_reachability.crosses_boundary += 1;
1580 }
1581 match reachability.taint_confidence {
1582 Some(TaintConfidence::ArgLevel) => by_reachability.arg_level += 1,
1583 Some(TaintConfidence::ModuleLevel) => by_reachability.module_level += 1,
1584 None => {}
1585 }
1586 }
1587
1588 match finding.runtime.as_ref().map(|runtime| runtime.state) {
1589 Some(SecurityRuntimeState::RuntimeHot) => by_runtime_state.runtime_hot += 1,
1590 Some(SecurityRuntimeState::RuntimeCold) => by_runtime_state.runtime_cold += 1,
1591 Some(SecurityRuntimeState::NeverExecuted) => by_runtime_state.never_executed += 1,
1592 Some(SecurityRuntimeState::LowTraffic) => by_runtime_state.low_traffic += 1,
1593 Some(SecurityRuntimeState::CoverageUnavailable) => {
1594 by_runtime_state.coverage_unavailable += 1;
1595 }
1596 Some(SecurityRuntimeState::RuntimeUnknown) => by_runtime_state.runtime_unknown += 1,
1597 None => by_runtime_state.not_collected += 1,
1598 }
1599 }
1600
1601 SecuritySummary {
1602 security_findings: output.security_findings.len(),
1603 by_severity,
1604 by_category,
1605 by_reachability,
1606 by_runtime_state,
1607 unresolved_edge_files: output.unresolved_edge_files,
1608 unresolved_callee_sites: output.unresolved_callee_sites,
1609 attack_surface_entries: output.attack_surface.as_ref().map_or(0, Vec::len),
1610 }
1611}
1612
1613fn write_sarif_file(output: &SecurityOutput, path: &Path) -> Result<(), String> {
1614 if let Some(parent) = path.parent()
1615 && !parent.as_os_str().is_empty()
1616 {
1617 std::fs::create_dir_all(parent).map_err(|err| {
1618 format!(
1619 "Failed to create directory for SARIF file {}: {err}",
1620 path.display()
1621 )
1622 })?;
1623 }
1624 std::fs::write(path, render_sarif(output))
1625 .map_err(|err| format!("Failed to write SARIF file {}: {err}", path.display()))
1626}
1627
1628fn gate_human_header(gate: &SecurityGate) -> String {
1633 use crate::report::plural;
1634 let checked = match gate.mode {
1635 SecurityGateMode::New => "in changed lines",
1636 SecurityGateMode::NewlyReachable => "newly reachable from entry points",
1637 };
1638 match gate.verdict {
1639 SecurityGateVerdict::Fail => format!(
1640 "Gate: REVIEW REQUIRED, {} new security item{} {checked}. fallow has not confirmed a vulnerability.",
1641 gate.new_count,
1642 plural(gate.new_count),
1643 ),
1644 SecurityGateVerdict::Pass => {
1645 format!("Gate: PASS, no new security items {checked}.")
1646 }
1647 }
1648}
1649
1650fn unresolved_callee_human_hint(output: &SecurityOutput) -> Option<String> {
1651 let diagnostics = output.unresolved_callee_diagnostics.as_ref()?;
1652 let top_reason = diagnostics.by_reason.first()?;
1653 let top_file = diagnostics.top_files.first()?;
1654 Some(format!(
1655 "Most unresolved callees: {} in {}.",
1656 unresolved_callee_reason_label(top_reason.reason),
1657 top_file.path
1658 ))
1659}
1660
1661fn unresolved_callee_reason_label(
1662 reason: fallow_types::extract::SkippedSecurityCalleeReason,
1663) -> &'static str {
1664 match reason {
1665 fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember => "computed-member",
1666 fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch => "dynamic-dispatch",
1667 fallow_types::extract::SkippedSecurityCalleeReason::UnsupportedAssignmentObject => {
1668 "unsupported-assignment-object"
1669 }
1670 }
1671}
1672
1673#[must_use]
1674fn render_human_summary(output: &SecurityOutput) -> String {
1675 use crate::report::plural;
1676 use std::fmt::Write as _;
1677
1678 let mut out = String::new();
1679 if let Some(gate) = &output.gate {
1680 out.push_str(&gate_human_header(gate));
1681 out.push('\n');
1682 }
1683 let count = output.security_findings.len();
1684 if count == 0 {
1685 out.push_str("Security review: no items to check in the scanned code.\n");
1686 } else {
1687 let _ = writeln!(
1688 out,
1689 "Security review: {count} item{} to check. These are unverified security candidates, not confirmed vulnerabilities.",
1690 plural(count),
1691 );
1692 out.push_str(
1693 "Next: check whether the listed code can run with unsafe input, secrets, or settings, and whether anything blocks the risk.\n",
1694 );
1695 }
1696 if output.unresolved_edge_files > 0 {
1697 let n = output.unresolved_edge_files;
1698 let verb = if n == 1 { "uses" } else { "use" };
1699 let _ = writeln!(
1700 out,
1701 "Blind spot: {n} client file{} {verb} dynamic imports that fallow could not follow.",
1702 plural(n)
1703 );
1704 }
1705 if output.unresolved_callee_sites > 0 {
1706 let n = output.unresolved_callee_sites;
1707 let verb = if n == 1 { "uses" } else { "use" };
1708 let _ = writeln!(
1709 out,
1710 "Blind spot: {n} call site{} {verb} code patterns that fallow could not resolve.",
1711 plural(n)
1712 );
1713 if let Some(hint) = unresolved_callee_human_hint(output) {
1714 let _ = writeln!(out, "{hint}");
1715 }
1716 }
1717 out
1718}
1719
1720#[must_use]
1723#[expect(
1724 clippy::format_push_string,
1725 reason = "small report renderer; readability over avoiding the extra allocation"
1726)]
1727pub fn render_human(output: &SecurityOutput) -> String {
1728 use crate::report::plural;
1729
1730 let mut out = String::new();
1731 push_human_gate(&mut out, output);
1732 let count = output.security_findings.len();
1733 out.push_str(&format!("Security review: {count} item{}", plural(count)));
1734 if count == 0 {
1735 out.push_str(" to check in the scanned code.\n");
1736 } else {
1737 out.push_str(" to check.\n");
1738 out.push_str(
1739 "These are unverified security candidates, not confirmed vulnerabilities. Check whether the listed code can run with unsafe input, secrets, or settings, and whether anything blocks the risk.\n",
1740 );
1741 }
1742 out.push('\n');
1743
1744 if output.security_findings.is_empty() {
1745 out.push_str("No security details to show.\n");
1746 } else {
1747 push_human_findings(&mut out, &output.security_findings);
1748 }
1749
1750 push_human_blind_spots(&mut out, output);
1751
1752 out.push_str(&format!(
1753 "\nResult: {count} security item{} to check.",
1754 plural(count),
1755 ));
1756 if count > 0 {
1757 out.push_str(" Review the listed evidence and trace before changing code.");
1758 }
1759 out.push('\n');
1760 out
1761}
1762
1763fn push_human_gate(out: &mut String, output: &SecurityOutput) {
1764 if let Some(gate) = &output.gate {
1765 out.push_str(&gate_human_header(gate));
1766 out.push_str("\n\n");
1767 }
1768}
1769
1770fn push_human_findings(out: &mut String, findings: &[SecurityFinding]) {
1771 for finding in findings {
1772 push_human_finding(out, finding);
1773 }
1774}
1775
1776fn push_human_finding(out: &mut String, finding: &SecurityFinding) {
1777 use std::fmt::Write as _;
1778
1779 push_human_finding_header(out, finding);
1780 let _ = writeln!(out, " evidence: {}", finding.evidence);
1781 if let Some(hint) = dead_code_hint(finding) {
1782 let _ = writeln!(out, " dead-code: {hint}");
1783 }
1784 if let Some(runtime) = finding.runtime.as_ref() {
1785 let _ = writeln!(out, " runtime: {}", runtime_hint_text(runtime));
1786 }
1787 push_human_reachability(out, finding);
1788 push_human_import_trace(out, finding);
1789 push_human_next_step(out, finding);
1790 out.push('\n');
1791}
1792
1793fn push_human_finding_header(out: &mut String, finding: &SecurityFinding) {
1794 use colored::Colorize;
1795 use std::fmt::Write as _;
1796
1797 let kind = security_finding_label(finding);
1798 let (glyph, label) = human_severity_marker(finding.severity);
1799 let _ = writeln!(
1800 out,
1801 "{} {label} {kind} {}:{}",
1802 glyph,
1803 finding.path.to_string_lossy().replace('\\', "/").bold(),
1804 finding.line,
1805 );
1806}
1807
1808fn push_human_reachability(out: &mut String, finding: &SecurityFinding) {
1809 use std::fmt::Write as _;
1810
1811 let Some(reach) = finding.reachability.as_ref() else {
1812 return;
1813 };
1814 let entry = if reach.reachable_from_entry {
1815 "reachable from a runtime entry point"
1816 } else {
1817 "not reached from any runtime entry point"
1818 };
1819 let boundary = if reach.crosses_boundary {
1820 "; crosses an architecture boundary"
1821 } else {
1822 ""
1823 };
1824 let _ = writeln!(
1825 out,
1826 " code path: {entry} (blast radius {}){boundary}",
1827 reach.blast_radius,
1828 );
1829 if reach.reachable_from_untrusted_source {
1830 push_human_untrusted_trace(out, finding);
1831 }
1832}
1833
1834fn push_human_untrusted_trace(out: &mut String, finding: &SecurityFinding) {
1835 use std::fmt::Write as _;
1836
1837 let Some(reach) = finding.reachability.as_ref() else {
1838 return;
1839 };
1840 let hops = reach.untrusted_source_hop_count.unwrap_or(0);
1841 let _ = writeln!(
1842 out,
1843 " input path: this module is reachable from a module that receives \
1844 untrusted input via {hops} import hop{}",
1845 crate::report::plural(hops as usize),
1846 );
1847 if !reach.untrusted_source_trace.is_empty() {
1848 out.push_str(" input import trace:\n");
1849 for hop in &reach.untrusted_source_trace {
1850 let _ = writeln!(
1851 out,
1852 " {}:{} ({})",
1853 hop.path.to_string_lossy().replace('\\', "/"),
1854 hop.line,
1855 hop_role_label(hop.role),
1856 );
1857 }
1858 }
1859}
1860
1861fn push_human_import_trace(out: &mut String, finding: &SecurityFinding) {
1862 use std::fmt::Write as _;
1863
1864 if finding.trace.is_empty() {
1865 return;
1866 }
1867 out.push_str(" import trace:\n");
1868 for hop in &finding.trace {
1869 let _ = writeln!(
1870 out,
1871 " {}:{} ({})",
1872 hop.path.to_string_lossy().replace('\\', "/"),
1873 hop.line,
1874 hop_role_label(hop.role),
1875 );
1876 }
1877}
1878
1879fn push_human_next_step(out: &mut String, finding: &SecurityFinding) {
1880 if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
1881 out.push_str(
1882 " Next: check whether this import can ship a secret to the browser. If \
1883 it is type-only, server-only, or removed at build time, mark it as a false \
1884 positive.\n",
1885 );
1886 } else if finding.dead_code.is_some() {
1887 out.push_str(
1888 " Next: first verify the dead-code finding. If the code is safe to \
1889 remove, delete it. Otherwise check and harden the risky call.\n",
1890 );
1891 } else {
1892 out.push_str(
1893 " Next: check whether unsafe input, secrets, or settings can reach this \
1894 risky call without a safe guard. If not, mark it as a false positive.\n",
1895 );
1896 }
1897}
1898
1899fn push_human_blind_spots(out: &mut String, output: &SecurityOutput) {
1900 use crate::report::plural;
1901 use colored::Colorize;
1902 use std::fmt::Write as _;
1903
1904 if output.unresolved_edge_files > 0 {
1905 let n = output.unresolved_edge_files;
1906 let verb = if n == 1 { "uses" } else { "use" };
1907 let _ = writeln!(
1908 out,
1909 "{} Blind spot: {n} client file{} {verb} dynamic imports that fallow could not \
1910 follow. Code behind those imports may be missing from this report.",
1911 "[I]".blue().bold(),
1912 plural(n),
1913 );
1914 }
1915
1916 if output.unresolved_callee_sites > 0 {
1917 let n = output.unresolved_callee_sites;
1918 let verb = if n == 1 { "uses" } else { "use" };
1919 let _ = writeln!(
1920 out,
1921 "{} Blind spot: {n} call site{} {verb} code patterns that fallow could not resolve, \
1922 such as dynamic dispatch, computed members, or aliased bindings.",
1923 "[I]".blue().bold(),
1924 plural(n),
1925 );
1926 if let Some(hint) = unresolved_callee_human_hint(output) {
1927 let _ = writeln!(out, " {hint}");
1928 }
1929 }
1930}
1931
1932fn security_finding_label(finding: &SecurityFinding) -> String {
1936 match finding.kind {
1937 SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
1938 SecurityFindingKind::TaintedSink => {
1939 let title = finding
1940 .category
1941 .as_deref()
1942 .and_then(fallow_core::analyze::security_catalogue_title)
1943 .or(finding.category.as_deref())
1944 .unwrap_or("tainted-sink");
1945 match finding.cwe {
1946 Some(cwe) => format!("{title} (CWE-{cwe})"),
1947 None => title.to_string(),
1948 }
1949 }
1950 }
1951}
1952
1953fn human_severity_marker(severity: SecuritySeverity) -> (colored::ColoredString, &'static str) {
1954 use colored::Colorize;
1955 match severity {
1956 SecuritySeverity::High => ("[H]".red().bold(), "high"),
1957 SecuritySeverity::Medium => ("[M]".yellow().bold(), "medium"),
1958 SecuritySeverity::Low => ("[L]".blue().bold(), "low"),
1959 }
1960}
1961
1962fn dead_code_hint(finding: &SecurityFinding) -> Option<String> {
1963 let context = finding.dead_code.as_ref()?;
1964 match context.kind {
1965 SecurityDeadCodeKind::UnusedFile => Some(
1966 "also reported as unused-file; delete this file instead of hardening the sink"
1967 .to_string(),
1968 ),
1969 SecurityDeadCodeKind::UnusedExport => Some(format!(
1970 "also reported as unused-export{}; remove the export instead of hardening the sink",
1971 context
1972 .export_name
1973 .as_ref()
1974 .map_or(String::new(), |name| format!(" `{name}`"))
1975 )),
1976 }
1977}
1978
1979const fn hop_role_label(role: TraceHopRole) -> &'static str {
1980 match role {
1981 TraceHopRole::ClientBoundary => "client boundary",
1982 TraceHopRole::UntrustedSource => "untrusted source",
1983 TraceHopRole::ModuleSource => "source module",
1984 TraceHopRole::Intermediate => "intermediate",
1985 TraceHopRole::SecretSource => "secret source",
1986 TraceHopRole::Sink => "sink site",
1987 }
1988}
1989
1990fn source_reachability_hint(finding: &SecurityFinding) -> Option<&'static str> {
1991 finding
1992 .reachability
1993 .as_ref()
1994 .filter(|reach| reach.reachable_from_untrusted_source)
1995 .map(|_| {
1996 "Module-level context: the sink module is reachable from an untrusted-source module; fallow does not prove value flow."
1997 })
1998}
1999
2000fn runtime_hint_text(runtime: &SecurityRuntimeContext) -> String {
2001 use std::fmt::Write as _;
2002
2003 let mut text = format!(
2004 "{} in {}:{}",
2005 runtime_state_label(runtime.state),
2006 runtime.function,
2007 runtime.line
2008 );
2009 if let Some(invocations) = runtime.invocations {
2010 let _ = write!(
2011 text,
2012 " ({} invocation{})",
2013 invocations,
2014 crate::report::plural(invocations as usize)
2015 );
2016 }
2017 if let Some(evidence) = runtime.evidence.as_deref() {
2018 text.push_str("; ");
2019 text.push_str(evidence);
2020 }
2021 text
2022}
2023
2024const fn runtime_state_label(state: SecurityRuntimeState) -> &'static str {
2025 match state {
2026 SecurityRuntimeState::RuntimeHot => "runtime-hot",
2027 SecurityRuntimeState::RuntimeCold => "runtime-cold",
2028 SecurityRuntimeState::NeverExecuted => "never-executed",
2029 SecurityRuntimeState::LowTraffic => "low-traffic",
2030 SecurityRuntimeState::CoverageUnavailable => "coverage-unavailable",
2031 SecurityRuntimeState::RuntimeUnknown => "runtime-unknown",
2032 }
2033}
2034
2035fn sarif_rule_id(finding: &SecurityFinding) -> String {
2040 match finding.kind {
2041 SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
2042 SecurityFindingKind::TaintedSink => {
2043 format!(
2044 "security/{}",
2045 finding.category.as_deref().unwrap_or("tainted-sink")
2046 )
2047 }
2048 }
2049}
2050
2051fn security_help_text(title: &str) -> String {
2052 format!(
2053 "Verify this unverified {title} candidate before acting. Review the source, sink, \
2054 SARIF code flow, and any runtime or dead-code context. fallow does not prove \
2055 exploitability, attacker control, or missing sanitization."
2056 )
2057}
2058
2059fn security_help_markdown(title: &str) -> String {
2060 format!(
2061 "Verify this unverified **{title}** candidate before acting.\n\n\
2062 1. Review the source and sink in the SARIF code flow.\n\
2063 2. Confirm whether attacker-controlled data can reach the sink unsanitized.\n\
2064 3. Use runtime and dead-code context only as triage signals."
2065 )
2066}
2067
2068fn cwe_taxon_id(cwe: u32) -> String {
2069 format!("CWE-{cwe}")
2070}
2071
2072fn cwe_taxon(cwe: u32) -> serde_json::Value {
2073 let id = cwe_taxon_id(cwe);
2074 serde_json::json!({
2075 "id": id,
2076 "name": id,
2077 "shortDescription": { "text": format!("Common Weakness Enumeration {id}") },
2078 "fullDescription": { "text": format!("MITRE Common Weakness Enumeration {id}") },
2079 "helpUri": format!("https://cwe.mitre.org/data/definitions/{cwe}.html")
2080 })
2081}
2082
2083fn cwe_relationship(cwe: u32, taxon_index: usize) -> serde_json::Value {
2084 serde_json::json!({
2085 "target": {
2086 "id": cwe_taxon_id(cwe),
2087 "index": taxon_index,
2088 "toolComponent": {
2089 "name": "CWE",
2090 "index": 0
2091 }
2092 },
2093 "kinds": ["superset"]
2094 })
2095}
2096
2097fn collect_cwes(findings: &[SecurityFinding]) -> Vec<u32> {
2098 let mut cwes: Vec<u32> = findings.iter().filter_map(|finding| finding.cwe).collect();
2099 cwes.sort_unstable();
2100 cwes.dedup();
2101 cwes
2102}
2103
2104fn cwe_index(cwes: &[u32], cwe: u32) -> Option<usize> {
2105 cwes.iter().position(|existing| *existing == cwe)
2106}
2107
2108fn cwe_taxonomy(cwes: &[u32]) -> Option<serde_json::Value> {
2109 if cwes.is_empty() {
2110 return None;
2111 }
2112 let taxa = cwes.iter().map(|cwe| cwe_taxon(*cwe)).collect::<Vec<_>>();
2113 Some(serde_json::json!({
2114 "name": "CWE",
2115 "fullName": "Common Weakness Enumeration",
2116 "organization": "MITRE",
2117 "informationUri": "https://cwe.mitre.org/",
2118 "taxa": taxa
2119 }))
2120}
2121
2122fn sarif_rule_def(
2126 rule_id: &str,
2127 finding: &SecurityFinding,
2128 cwe_taxon_index: Option<usize>,
2129) -> serde_json::Value {
2130 match finding.kind {
2131 SecurityFindingKind::ClientServerLeak => {
2132 let title = "Client-server secret leak";
2133 serde_json::json!({
2134 "id": rule_id,
2135 "name": title,
2136 "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
2137 "fullDescription": { "text":
2138 "Unverified candidate, requires verification: a \"use client\" file \
2139 transitively imports a module that reads a non-public process.env \
2140 secret. fallow does not prove the secret reaches client-bundled code." },
2141 "help": {
2142 "text": security_help_text(title),
2143 "markdown": security_help_markdown(title)
2144 },
2145 "helpUri": "https://github.com/fallow-rs/fallow",
2146 "defaultConfiguration": { "level": "note" }
2147 })
2148 }
2149 SecurityFindingKind::TaintedSink => {
2150 let title = finding
2151 .category
2152 .as_deref()
2153 .and_then(fallow_core::analyze::security_catalogue_title)
2154 .or(finding.category.as_deref())
2155 .unwrap_or("tainted-sink");
2156 let mut rule = serde_json::json!({
2157 "id": rule_id,
2158 "name": title,
2159 "shortDescription": { "text": format!("{title} candidate (unverified)") },
2160 "fullDescription": { "text": format!(
2161 "Unverified candidate, requires verification: {title}. fallow flags a \
2162 syntactic sink reached by a non-literal argument; it does not prove the \
2163 value is attacker-controlled or reaches the sink unsanitized."
2164 ) },
2165 "help": {
2166 "text": security_help_text(title),
2167 "markdown": security_help_markdown(title)
2168 },
2169 "helpUri": "https://github.com/fallow-rs/fallow",
2170 "defaultConfiguration": { "level": "note" }
2171 });
2172 if let Some(cwe) = finding.cwe {
2173 rule["properties"] = serde_json::json!({
2174 "tags": [format!("external/cwe/cwe-{cwe}")]
2175 });
2176 if let Some(taxon_index) = cwe_taxon_index {
2177 rule["relationships"] = serde_json::json!([cwe_relationship(cwe, taxon_index)]);
2178 }
2179 }
2180 rule
2181 }
2182 }
2183}
2184
2185fn hop_role_token(role: TraceHopRole) -> &'static str {
2186 match role {
2187 TraceHopRole::ClientBoundary => "client-boundary",
2188 TraceHopRole::UntrustedSource => "untrusted-source",
2189 TraceHopRole::ModuleSource => "module-source",
2190 TraceHopRole::Intermediate => "intermediate",
2191 TraceHopRole::SecretSource => "secret-source",
2192 TraceHopRole::Sink => "sink",
2193 }
2194}
2195
2196fn sarif_thread_flow_location(hop: &TraceHop) -> serde_json::Value {
2197 let role = hop_role_token(hop.role);
2198 serde_json::json!({
2199 "location": sarif_location(&hop.path, hop.line, hop.col),
2200 "kinds": [role],
2201 "properties": { "fallowTraceRole": role }
2202 })
2203}
2204
2205fn primary_code_flow_hops(finding: &SecurityFinding) -> &[TraceHop] {
2206 if let Some(reachability) = finding.reachability.as_ref()
2207 && !reachability.untrusted_source_trace.is_empty()
2208 {
2209 return &reachability.untrusted_source_trace;
2210 }
2211 &finding.trace
2212}
2213
2214fn sarif_code_flows(finding: &SecurityFinding) -> Option<serde_json::Value> {
2215 let hops = primary_code_flow_hops(finding);
2216 if hops.is_empty() {
2217 return None;
2218 }
2219 let locations = hops
2220 .iter()
2221 .map(sarif_thread_flow_location)
2222 .collect::<Vec<_>>();
2223 Some(serde_json::json!([
2224 {
2225 "threadFlows": [
2226 { "locations": locations }
2227 ]
2228 }
2229 ]))
2230}
2231
2232fn push_related_location(related: &mut Vec<serde_json::Value>, hop: &TraceHop) {
2233 let location = sarif_location(&hop.path, hop.line, hop.col);
2234 if !related.iter().any(|existing| existing == &location) {
2235 related.push(location);
2236 }
2237}
2238
2239fn sarif_related_locations(finding: &SecurityFinding) -> Vec<serde_json::Value> {
2240 let mut related = Vec::new();
2241 for hop in &finding.trace {
2242 push_related_location(&mut related, hop);
2243 }
2244 if let Some(reachability) = finding.reachability.as_ref() {
2245 for hop in &reachability.untrusted_source_trace {
2246 push_related_location(&mut related, hop);
2247 }
2248 }
2249 related
2250}
2251
2252const fn sarif_level(severity: SecuritySeverity) -> &'static str {
2253 match severity {
2254 SecuritySeverity::High | SecuritySeverity::Medium => "warning",
2255 SecuritySeverity::Low => "note",
2256 }
2257}
2258
2259#[must_use]
2266fn render_sarif(output: &SecurityOutput) -> String {
2267 let cwes = collect_cwes(&output.security_findings);
2268 let results: Vec<serde_json::Value> = output
2269 .security_findings
2270 .iter()
2271 .map(|finding| {
2272 let rule_id = sarif_rule_id(finding);
2273 let mut message = dead_code_hint(finding).map_or_else(
2274 || finding.evidence.clone(),
2275 |hint| format!("{} Dead-code cross-link: {hint}.", finding.evidence),
2276 );
2277 if let Some(hint) = source_reachability_hint(finding) {
2278 message.push(' ');
2279 message.push_str(hint);
2280 }
2281 if let Some(runtime) = finding.runtime.as_ref() {
2282 message.push_str(" Runtime context: ");
2283 message.push_str(&runtime_hint_text(runtime));
2284 message.push('.');
2285 }
2286 let related = sarif_related_locations(finding);
2287 let mut result = serde_json::json!({
2292 "ruleId": rule_id,
2293 "level": sarif_level(finding.severity),
2294 "message": { "text": message },
2295 "locations": [sarif_location(&finding.path, finding.line, finding.col)],
2296 "relatedLocations": related,
2297 "partialFingerprints": { "fallowSecurity/v1": security_finding_id(finding) },
2298 });
2299 if let Some(code_flows) = sarif_code_flows(finding) {
2300 result["codeFlows"] = code_flows;
2301 }
2302 result
2303 })
2304 .collect();
2305
2306 let mut seen: Vec<String> = Vec::new();
2308 let mut rules: Vec<serde_json::Value> = Vec::new();
2309 for finding in &output.security_findings {
2310 let rule_id = sarif_rule_id(finding);
2311 if seen.iter().any(|s| s == &rule_id) {
2312 continue;
2313 }
2314 seen.push(rule_id.clone());
2315 let cwe_taxon_index = finding.cwe.and_then(|cwe| cwe_index(&cwes, cwe));
2316 rules.push(sarif_rule_def(&rule_id, finding, cwe_taxon_index));
2317 }
2318
2319 let mut run = serde_json::json!({
2320 "tool": { "driver": {
2321 "name": "fallow",
2322 "version": env!("CARGO_PKG_VERSION"),
2323 "informationUri": "https://github.com/fallow-rs/fallow",
2324 "rules": rules,
2325 }},
2326 "results": results,
2327 });
2328 if let Some(taxonomy) = cwe_taxonomy(&cwes) {
2329 run["taxonomies"] = serde_json::json!([taxonomy]);
2330 run["tool"]["driver"]["supportedTaxonomies"] = serde_json::json!([
2331 { "name": "CWE", "index": 0 }
2332 ]);
2333 }
2334 if let Some(gate) = &output.gate
2338 && let Ok(gate_value) = serde_json::to_value(gate)
2339 {
2340 run["properties"] = serde_json::json!({ "fallowGate": gate_value });
2341 }
2342
2343 let sarif = serde_json::json!({
2344 "version": "2.1.0",
2345 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2346 "runs": [run],
2347 });
2348 serde_json::to_string_pretty(&sarif)
2349 .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
2350}
2351
2352fn fnv_hex(input: &str) -> String {
2354 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2355 for byte in input.bytes() {
2356 hash ^= u64::from(byte);
2357 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2358 }
2359 format!("{hash:016x}")
2360}
2361
2362fn security_finding_id(finding: &SecurityFinding) -> String {
2368 let fp = format!(
2369 "{}:{}:{}",
2370 sarif_rule_id(finding),
2371 finding.path.to_string_lossy().replace('\\', "/"),
2372 finding.line,
2373 );
2374 fnv_hex(&fp)
2375}
2376
2377fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
2378 serde_json::json!({
2379 "physicalLocation": {
2380 "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
2381 "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
2382 }
2383 })
2384}
2385
2386#[cfg(test)]
2387mod tests {
2388 use super::*;
2389 use fallow_core::results::{
2390 SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink,
2391 SecurityDeadCodeContext, SecurityDeadCodeKind, SecurityFinding, SecurityFindingKind,
2392 TraceHop, TraceHopRole,
2393 };
2394 use fallow_types::results::{
2395 SecurityReachability, SecurityTaintFlow, TaintEndpoint, TaintPath,
2396 };
2397
2398 fn sample_finding(root: &Path) -> SecurityFinding {
2400 SecurityFinding {
2401 kind: SecurityFindingKind::ClientServerLeak,
2402 path: root.join("src/app.tsx"),
2403 line: 12,
2404 col: 3,
2405 evidence: "reaches process.env.SECRET_KEY".to_owned(),
2406 source_backed: false,
2407 source_read: None,
2408 severity: SecuritySeverity::High,
2409 trace: vec![
2410 TraceHop {
2411 path: root.join("src/app.tsx"),
2412 line: 12,
2413 col: 3,
2414 role: TraceHopRole::ClientBoundary,
2415 },
2416 TraceHop {
2417 path: root.join("src/lib/util.ts"),
2418 line: 4,
2419 col: 0,
2420 role: TraceHopRole::Intermediate,
2421 },
2422 TraceHop {
2423 path: root.join("src/lib/secret.ts"),
2424 line: 8,
2425 col: 2,
2426 role: TraceHopRole::SecretSource,
2427 },
2428 ],
2429 actions: vec![],
2430 category: None,
2431 cwe: None,
2432 dead_code: None,
2433 reachability: None,
2434 finding_id: String::new(),
2435 candidate: SecurityCandidate {
2436 source_kind: None,
2437 sink: SecurityCandidateSink {
2438 path: root.join("src/app.tsx"),
2439 line: 12,
2440 col: 3,
2441 category: None,
2442 cwe: None,
2443 callee: None,
2444 url_shape: None,
2445 },
2446 boundary: SecurityCandidateBoundary {
2447 client_server: true,
2448 cross_module: false,
2449 architecture_zone: None,
2450 },
2451 network: None,
2452 },
2453 taint_flow: None,
2454 runtime: None,
2455 attack_surface: None,
2456 }
2457 }
2458
2459 fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
2460 SecurityOutput {
2461 schema_version: SecuritySchemaVersion::V6,
2462 version: ToolVersion("test".to_string()),
2463 elapsed_ms: ElapsedMs(0),
2464 config: test_output_config(),
2465 meta: None,
2466 gate: None,
2467 security_findings: findings,
2468 attack_surface: None,
2469 unresolved_edge_files,
2470 unresolved_callee_sites: 0,
2471 unresolved_callee_diagnostics: None,
2472 }
2473 }
2474
2475 fn output_with_gate(verdict: SecurityGateVerdict, new_count: usize) -> SecurityOutput {
2476 SecurityOutput {
2477 schema_version: SecuritySchemaVersion::V6,
2478 version: ToolVersion("test".to_string()),
2479 elapsed_ms: ElapsedMs(0),
2480 config: test_output_config(),
2481 meta: None,
2482 gate: Some(SecurityGate {
2483 mode: SecurityGateMode::New,
2484 verdict,
2485 new_count,
2486 }),
2487 security_findings: vec![],
2488 attack_surface: None,
2489 unresolved_edge_files: 0,
2490 unresolved_callee_sites: 0,
2491 unresolved_callee_diagnostics: None,
2492 }
2493 }
2494
2495 fn sample_unresolved_callee_diagnostics(root: &Path) -> SecurityUnresolvedCalleeDiagnostics {
2496 unresolved_callee_diagnostics(
2497 &[
2498 SecurityUnresolvedCalleeDiagnostic {
2499 path: root.join("src/z.ts"),
2500 line: 9,
2501 col: 4,
2502 reason: fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember,
2503 expression_kind:
2504 fallow_types::extract::SkippedSecurityCalleeExpressionKind::ComputedMemberExpression,
2505 },
2506 SecurityUnresolvedCalleeDiagnostic {
2507 path: root.join("src/a.ts"),
2508 line: 3,
2509 col: 2,
2510 reason: fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch,
2511 expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other,
2512 },
2513 SecurityUnresolvedCalleeDiagnostic {
2514 path: root.join("src/a.ts"),
2515 line: 4,
2516 col: 2,
2517 reason: fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch,
2518 expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other,
2519 },
2520 ],
2521 root,
2522 )
2523 .expect("diagnostics summarized")
2524 }
2525
2526 fn test_output_config() -> SecurityOutputConfig {
2527 SecurityOutputConfig {
2528 rules: SecurityOutputRulesConfig {
2529 security_client_server_leak: SecurityRuleSeverityConfig {
2530 configured: Severity::Off,
2531 effective: Severity::Warn,
2532 },
2533 security_sink: SecurityRuleSeverityConfig {
2534 configured: Severity::Off,
2535 effective: Severity::Warn,
2536 },
2537 },
2538 categories_include: None,
2539 categories_exclude: None,
2540 }
2541 }
2542
2543 fn tainted_with_runtime(root: &Path, state: Option<SecurityRuntimeState>) -> SecurityFinding {
2544 let mut finding = sample_finding(root);
2545 finding.kind = SecurityFindingKind::TaintedSink;
2546 finding.category = Some("dangerous-html".to_owned());
2547 finding.cwe = Some(79);
2548 finding.runtime = state.map(|state| SecurityRuntimeContext {
2549 state,
2550 function: "render".to_owned(),
2551 line: 10,
2552 invocations: Some(123),
2553 stable_id: Some("fallow:fn:test".to_owned()),
2554 evidence: Some("production runtime evidence".to_owned()),
2555 });
2556 finding
2557 }
2558
2559 #[test]
2560 fn runtime_rank_promotes_hot_and_demotes_never_executed() {
2561 let root = Path::new("/proj/root");
2562 let mut findings = [
2563 tainted_with_runtime(root, Some(SecurityRuntimeState::NeverExecuted)),
2564 tainted_with_runtime(root, None),
2565 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
2566 tainted_with_runtime(root, Some(SecurityRuntimeState::CoverageUnavailable)),
2567 ];
2568
2569 findings.sort_by_key(runtime_rank);
2570
2571 assert_eq!(
2572 findings
2573 .iter()
2574 .map(|finding| finding.runtime.as_ref().map(|runtime| runtime.state))
2575 .collect::<Vec<_>>(),
2576 vec![
2577 Some(SecurityRuntimeState::RuntimeHot),
2578 None,
2579 Some(SecurityRuntimeState::CoverageUnavailable),
2580 Some(SecurityRuntimeState::NeverExecuted),
2581 ]
2582 );
2583 }
2584
2585 #[test]
2586 fn severity_sort_orders_tiers_then_location() {
2587 let root = Path::new("/proj/root");
2588 let mut high = sample_finding(root);
2589 high.path = root.join("z.ts");
2590 high.severity = SecuritySeverity::High;
2591 let mut low = sample_finding(root);
2592 low.path = root.join("a.ts");
2593 low.severity = SecuritySeverity::Low;
2594 let mut medium_a = sample_finding(root);
2595 medium_a.path = root.join("a.ts");
2596 medium_a.severity = SecuritySeverity::Medium;
2597 medium_a.reachability = Some(fallow_types::results::SecurityReachability {
2598 reachable_from_entry: false,
2599 reachable_from_untrusted_source: true,
2600 taint_confidence: Some(TaintConfidence::ModuleLevel),
2601 untrusted_source_hop_count: Some(1),
2602 untrusted_source_trace: vec![],
2603 blast_radius: 10,
2604 crosses_boundary: false,
2605 });
2606 let mut medium_b = sample_finding(root);
2607 medium_b.path = root.join("b.ts");
2608 medium_b.severity = SecuritySeverity::Medium;
2609 medium_b.source_backed = true;
2610 medium_b.reachability = Some(fallow_types::results::SecurityReachability {
2611 reachable_from_entry: false,
2612 reachable_from_untrusted_source: true,
2613 taint_confidence: Some(TaintConfidence::ArgLevel),
2614 untrusted_source_hop_count: Some(0),
2615 untrusted_source_trace: vec![],
2616 blast_radius: 1,
2617 crosses_boundary: false,
2618 });
2619 let mut findings = vec![low, medium_b, high, medium_a];
2620
2621 sort_by_security_severity(&mut findings);
2622
2623 assert_eq!(
2624 findings
2625 .iter()
2626 .map(|finding| (finding.severity, finding.path.file_name().unwrap()))
2627 .collect::<Vec<_>>(),
2628 vec![
2629 (SecuritySeverity::High, std::ffi::OsStr::new("z.ts")),
2630 (SecuritySeverity::Medium, std::ffi::OsStr::new("b.ts")),
2631 (SecuritySeverity::Medium, std::ffi::OsStr::new("a.ts")),
2632 (SecuritySeverity::Low, std::ffi::OsStr::new("a.ts")),
2633 ]
2634 );
2635 }
2636
2637 #[test]
2638 fn human_render_includes_runtime_context_line() {
2639 let root = Path::new("/proj/root");
2640 let finding = relativize_finding(
2641 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
2642 root,
2643 );
2644 let out = render_human(&output_with(vec![finding], 0));
2645
2646 assert!(
2647 out.contains("runtime: runtime-hot in render:10"),
2648 "got: {out}"
2649 );
2650 assert!(out.contains("production runtime evidence"), "got: {out}");
2651 }
2652
2653 #[test]
2654 fn sarif_render_includes_runtime_context_in_message() {
2655 let root = Path::new("/proj/root");
2656 let finding = relativize_finding(
2657 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
2658 root,
2659 );
2660 let rendered = render_sarif(&output_with(vec![finding], 0));
2661 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
2662 let message = sarif["runs"][0]["results"][0]["message"]["text"]
2663 .as_str()
2664 .expect("message text");
2665
2666 assert!(message.contains("Runtime context"), "got: {message}");
2667 assert!(
2668 message.contains("runtime-hot in render:10"),
2669 "got: {message}"
2670 );
2671 }
2672
2673 #[test]
2674 fn gate_human_header_fail_says_review_required_not_fail() {
2675 let gate = SecurityGate {
2676 mode: SecurityGateMode::New,
2677 verdict: SecurityGateVerdict::Fail,
2678 new_count: 2,
2679 };
2680 let header = gate_human_header(&gate);
2681 assert!(header.contains("REVIEW REQUIRED"));
2682 assert!(header.contains("2 new security items"));
2683 assert!(header.contains("not confirmed a vulnerability"));
2684 assert!(!header.to_uppercase().contains("GATE: FAIL"));
2685 }
2686
2687 #[test]
2688 fn gate_human_header_fail_singular_for_one_candidate() {
2689 let gate = SecurityGate {
2691 mode: SecurityGateMode::New,
2692 verdict: SecurityGateVerdict::Fail,
2693 new_count: 1,
2694 };
2695 let header = gate_human_header(&gate);
2696 assert!(header.contains("1 new security item in changed lines"));
2697 assert!(!header.contains("1 new security candidates"));
2698 }
2699
2700 #[test]
2701 fn gate_human_header_pass() {
2702 let gate = SecurityGate {
2703 mode: SecurityGateMode::New,
2704 verdict: SecurityGateVerdict::Pass,
2705 new_count: 0,
2706 };
2707 assert!(gate_human_header(&gate).contains("Gate: PASS"));
2708 }
2709
2710 #[test]
2711 fn gate_json_block_is_snake_case_and_present_on_pass() {
2712 let json = render_json(&output_with_gate(SecurityGateVerdict::Pass, 0));
2713 assert!(json.contains("\"gate\""));
2714 assert!(json.contains("\"mode\": \"new\""));
2715 assert!(json.contains("\"verdict\": \"pass\""));
2716 assert!(json.contains("\"new_count\": 0"));
2717 }
2718
2719 #[test]
2720 fn reachability_key_includes_path_kind_and_category() {
2721 let root = Path::new("/proj/root");
2722 let mut leak = sample_finding(root);
2723 leak.reachability = Some(SecurityReachability {
2724 reachable_from_entry: true,
2725 reachable_from_untrusted_source: false,
2726 taint_confidence: None,
2727 untrusted_source_hop_count: None,
2728 untrusted_source_trace: vec![],
2729 blast_radius: 0,
2730 crosses_boundary: false,
2731 });
2732 let mut sink = leak.clone();
2733 sink.kind = SecurityFindingKind::TaintedSink;
2734 sink.category = Some("dangerous-html".to_owned());
2735
2736 assert_eq!(
2737 security_reachability_key(&leak, root).as_deref(),
2738 Some("security-reach:src/app.tsx:client-server-leak:none")
2739 );
2740 assert_eq!(
2741 security_reachability_key(&sink, root).as_deref(),
2742 Some("security-reach:src/app.tsx:tainted-sink:dangerous-html")
2743 );
2744 }
2745
2746 #[test]
2747 fn reachability_key_ignores_unreachable_findings() {
2748 let root = Path::new("/proj/root");
2749 let finding = sample_finding(root);
2750
2751 assert!(security_reachability_key(&finding, root).is_none());
2752 }
2753
2754 #[test]
2755 fn gate_absent_from_json_when_no_gate_ran() {
2756 let json = render_json(&output_with(vec![], 0));
2757 assert!(!json.contains("\"gate\""));
2758 }
2759
2760 #[test]
2761 fn gate_sarif_is_a_run_property_not_result_severity() {
2762 let sarif = render_sarif(&output_with_gate(SecurityGateVerdict::Fail, 1));
2763 assert!(sarif.contains("fallowGate"));
2764 assert!(!sarif.contains("\"level\": \"error\""));
2766 assert!(!sarif.contains("\"level\": \"warning\""));
2767 }
2768
2769 fn add_untrusted_source_reachability(finding: &mut SecurityFinding, root: &Path) {
2770 finding.reachability = Some(SecurityReachability {
2771 reachable_from_entry: true,
2772 reachable_from_untrusted_source: true,
2773 taint_confidence: Some(fallow_core::results::TaintConfidence::ModuleLevel),
2775 untrusted_source_hop_count: Some(1),
2776 untrusted_source_trace: vec![
2777 TraceHop {
2778 path: root.join("src/routes/api.ts"),
2779 line: 3,
2780 col: 0,
2781 role: TraceHopRole::ModuleSource,
2782 },
2783 TraceHop {
2784 path: root.join("src/lib/sink.ts"),
2785 line: 9,
2786 col: 2,
2787 role: TraceHopRole::Sink,
2788 },
2789 ],
2790 blast_radius: 2,
2791 crosses_boundary: false,
2792 });
2793 }
2794
2795 fn add_taint_flow(finding: &mut SecurityFinding, root: &Path) {
2796 finding.taint_flow = Some(SecurityTaintFlow {
2797 source: TaintEndpoint {
2798 path: root.join("src/routes/api.ts"),
2799 line: 3,
2800 col: 0,
2801 },
2802 sink: TaintEndpoint {
2803 path: root.join("src/lib/sink.ts"),
2804 line: 9,
2805 col: 2,
2806 },
2807 path: TaintPath {
2808 intra_module: false,
2809 cross_module_hops: 1,
2810 },
2811 });
2812 }
2813
2814 #[test]
2815 fn relativize_strips_root_prefix() {
2816 let root = Path::new("/proj/root");
2817 let abs = root.join("src/app.tsx");
2818 let rel = relativize(&abs, root);
2819 assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
2820 }
2821
2822 #[test]
2823 fn relativize_keeps_path_when_outside_root() {
2824 let root = Path::new("/proj/root");
2825 let outside = Path::new("/elsewhere/file.ts");
2826 assert_eq!(relativize(outside, root), outside.to_path_buf());
2828 }
2829
2830 #[test]
2831 fn relativize_finding_relativizes_anchor_and_every_hop() {
2832 let root = Path::new("/proj/root");
2833 let finding = relativize_finding(sample_finding(root), root);
2834 assert_eq!(
2835 finding.path.to_string_lossy().replace('\\', "/"),
2836 "src/app.tsx"
2837 );
2838 let hop_paths: Vec<String> = finding
2839 .trace
2840 .iter()
2841 .map(|h| h.path.to_string_lossy().replace('\\', "/"))
2842 .collect();
2843 assert_eq!(
2844 hop_paths,
2845 vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
2846 );
2847 }
2848
2849 #[test]
2850 fn relativize_finding_relativizes_untrusted_source_trace() {
2851 let root = Path::new("/proj/root");
2852 let mut finding = sample_finding(root);
2853 add_untrusted_source_reachability(&mut finding, root);
2854 let finding = relativize_finding(finding, root);
2855 let reach = finding.reachability.as_ref().expect("reachability");
2856 let hop_paths: Vec<String> = reach
2857 .untrusted_source_trace
2858 .iter()
2859 .map(|h| h.path.to_string_lossy().replace('\\', "/"))
2860 .collect();
2861 assert_eq!(hop_paths, vec!["src/routes/api.ts", "src/lib/sink.ts"]);
2862 }
2863
2864 #[test]
2865 fn fnv_hex_is_deterministic_and_16_hex_digits() {
2866 let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
2867 let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
2868 assert_eq!(a, b, "same input must hash identically");
2869 assert_eq!(a.len(), 16);
2870 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
2871 assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
2873 }
2874
2875 #[test]
2876 fn hop_role_labels_cover_every_role() {
2877 assert_eq!(
2878 hop_role_label(TraceHopRole::ClientBoundary),
2879 "client boundary"
2880 );
2881 assert_eq!(
2882 hop_role_label(TraceHopRole::UntrustedSource),
2883 "untrusted source"
2884 );
2885 assert_eq!(hop_role_label(TraceHopRole::ModuleSource), "source module");
2886 assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
2887 assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
2888 assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
2889 }
2890
2891 #[test]
2892 fn sarif_location_clamps_line_and_offsets_column() {
2893 let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
2895 let region = &loc["physicalLocation"]["region"];
2896 assert_eq!(region["startLine"], 1);
2897 assert_eq!(region["startColumn"], 1);
2898 assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
2900 }
2901
2902 #[test]
2903 fn human_summary_reports_zero_without_edge_line() {
2904 let out = render_human_summary(&output_with(vec![], 0));
2905 assert!(
2906 out.contains("Security review: no items to check in the scanned code."),
2907 "got: {out}"
2908 );
2909 assert!(!out.contains("Blind spot"));
2910 }
2911
2912 #[test]
2913 fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
2914 let root = Path::new("/proj/root");
2915 let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
2916 assert!(
2917 out.contains("Security review: 1 item to check."),
2918 "got: {out}"
2919 );
2920 assert!(out.contains("not confirmed vulnerabilities"));
2921 assert!(out.contains("unsafe input, secrets, or settings"));
2922 assert!(out.contains("Blind spot: 2 client files use dynamic imports"));
2923 }
2924
2925 #[test]
2926 fn human_render_empty_states_no_candidates() {
2927 colored::control::set_override(false);
2928 let out = render_human(&output_with(vec![], 0));
2929 assert!(out.contains("Security review: 0 items to check"));
2930 assert!(out.contains("No security details to show."));
2931 assert!(out.contains("Result: 0 security items to check."));
2932 }
2933
2934 #[test]
2935 fn human_render_shows_finding_trace_and_next_action() {
2936 colored::control::set_override(false);
2937 let root = Path::new("/proj/root");
2938 let finding = relativize_finding(sample_finding(root), root);
2939 let out = render_human(&output_with(vec![finding], 0));
2940 assert!(out.contains("[H] high client-server-leak"));
2941 assert!(out.contains("client-server-leak"));
2942 assert!(out.contains("src/app.tsx:12"));
2943 assert!(out.contains("evidence: reaches process.env.SECRET_KEY"));
2944 assert!(out.contains("import trace:"));
2945 assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
2946 assert!(out.contains("src/app.tsx:12 (client boundary)"));
2947 assert!(out.contains("Next: check whether this import can ship a secret to the browser"));
2948 assert!(out.contains("Result: 1 security item to check."));
2949 }
2950
2951 #[test]
2952 fn human_render_shows_dead_code_hint_and_delete_next_step() {
2953 colored::control::set_override(false);
2954 let root = Path::new("/proj/root");
2955 let mut finding = relativize_finding(sample_finding(root), root);
2956 finding.kind = SecurityFindingKind::TaintedSink;
2957 finding.dead_code = Some(SecurityDeadCodeContext {
2958 kind: SecurityDeadCodeKind::UnusedFile,
2959 export_name: None,
2960 line: None,
2961 guidance: "delete instead of harden".to_string(),
2962 });
2963 let out = render_human(&output_with(vec![finding], 0));
2964 assert!(
2965 out.contains("dead-code: also reported as unused-file"),
2966 "got: {out}"
2967 );
2968 assert!(
2969 out.contains("If the code is safe to remove, delete it"),
2970 "got: {out}"
2971 );
2972 }
2973
2974 #[test]
2975 fn human_render_shows_untrusted_source_path_as_module_context() {
2976 colored::control::set_override(false);
2977 let root = Path::new("/proj/root");
2978 let mut finding = sample_finding(root);
2979 finding.kind = SecurityFindingKind::TaintedSink;
2980 finding.category = Some("command-injection".to_string());
2981 add_untrusted_source_reachability(&mut finding, root);
2982 let finding = relativize_finding(finding, root);
2983
2984 let out = render_human(&output_with(vec![finding], 0));
2985
2986 assert!(
2987 out.contains("reachable from a module that receives untrusted input via 1 import hop"),
2988 "got: {out}"
2989 );
2990 assert!(out.contains("input import trace:"), "got: {out}");
2991 assert!(
2992 out.contains("src/routes/api.ts:3 (source module)"),
2993 "got: {out}"
2994 );
2995 }
2996
2997 #[test]
2998 fn human_render_surfaces_unresolved_edge_blind_spot() {
2999 colored::control::set_override(false);
3000 let out = render_human(&output_with(vec![], 3));
3001 assert!(out.contains("Blind spot: 3 client files use dynamic imports"));
3002 assert!(out.contains("Code behind those imports may be missing from this report."));
3003 }
3004
3005 #[test]
3006 fn human_render_blind_spots_use_singular_verbs() {
3007 colored::control::set_override(false);
3008 let mut output = output_with(vec![], 1);
3009 output.unresolved_callee_sites = 1;
3010
3011 let out = render_human(&output);
3012
3013 assert!(out.contains("Blind spot: 1 client file uses dynamic imports"));
3014 assert!(out.contains("Blind spot: 1 call site uses code patterns"));
3015 }
3016
3017 #[test]
3018 fn human_render_mentions_top_unresolved_callee_reason_and_file() {
3019 colored::control::set_override(false);
3020 let root = Path::new("/proj/root");
3021 let mut output = output_with(vec![], 0);
3022 output.unresolved_callee_sites = 3;
3023 output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3024
3025 let out = render_human(&output);
3026
3027 assert!(
3028 out.contains("Most unresolved callees: dynamic-dispatch in src/a.ts."),
3029 "got: {out}"
3030 );
3031 }
3032
3033 #[test]
3034 fn json_render_carries_schema_version_and_findings() {
3035 let root = Path::new("/proj/root");
3036 let finding = relativize_finding(sample_finding(root), root);
3037 let rendered = render_json(&output_with(vec![finding], 1));
3038 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3039 assert_eq!(value["schema_version"], "6");
3040 assert_eq!(value["version"], "test");
3041 assert_eq!(value["elapsed_ms"], 0);
3042 assert_eq!(
3043 value["config"]["rules"]["security_client_server_leak"]["configured"],
3044 "off"
3045 );
3046 assert_eq!(
3047 value["config"]["rules"]["security_client_server_leak"]["effective"],
3048 "warn"
3049 );
3050 assert!(value["config"]["categories_include"].is_null());
3051 assert!(value["config"]["categories_exclude"].is_null());
3052 assert_eq!(value["unresolved_edge_files"], 1);
3053 let findings = value["security_findings"].as_array().expect("array");
3054 assert_eq!(findings.len(), 1);
3055 assert_eq!(findings[0]["kind"], "client-server-leak");
3056 assert_eq!(findings[0]["path"], "src/app.tsx");
3057 assert_eq!(findings[0]["severity"], "high");
3058 }
3059
3060 #[test]
3061 fn json_render_carries_bounded_unresolved_callee_diagnostics() {
3062 let root = Path::new("/proj/root");
3063 let mut output = output_with(vec![], 0);
3064 output.unresolved_callee_sites = 3;
3065 output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3066
3067 let rendered = render_json(&output);
3068 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3069 let diagnostics = &value["unresolved_callee_diagnostics"];
3070
3071 assert_eq!(diagnostics["sample_limit"], 25);
3072 assert_eq!(diagnostics["top_files_limit"], 10);
3073 assert_eq!(diagnostics["sampled"][0]["path"], "src/a.ts");
3074 assert_eq!(diagnostics["sampled"][0]["reason"], "dynamic-dispatch");
3075 assert_eq!(diagnostics["sampled"][0]["expression_kind"], "other");
3076 assert_eq!(diagnostics["top_files"][0]["path"], "src/a.ts");
3077 assert_eq!(diagnostics["top_files"][0]["count"], 2);
3078 assert_eq!(diagnostics["by_reason"][0]["reason"], "dynamic-dispatch");
3079 assert_eq!(diagnostics["by_reason"][0]["count"], 2);
3080 }
3081
3082 #[test]
3083 fn json_summary_omits_finding_arrays_and_counts_security_findings() {
3084 let root = Path::new("/proj/root");
3085 let mut leak = relativize_finding(sample_finding(root), root);
3086 leak.severity = SecuritySeverity::High;
3087
3088 let mut sink = relativize_finding(sample_finding(root), root);
3089 sink.kind = SecurityFindingKind::TaintedSink;
3090 sink.category = Some("dangerous-html".to_string());
3091 sink.severity = SecuritySeverity::Medium;
3092 sink.source_backed = true;
3093 sink.reachability = Some(SecurityReachability {
3094 reachable_from_entry: true,
3095 reachable_from_untrusted_source: true,
3096 taint_confidence: Some(TaintConfidence::ArgLevel),
3097 untrusted_source_hop_count: Some(0),
3098 untrusted_source_trace: vec![],
3099 blast_radius: 3,
3100 crosses_boundary: true,
3101 });
3102 sink.runtime = Some(SecurityRuntimeContext {
3103 state: SecurityRuntimeState::RuntimeHot,
3104 function: "render".to_owned(),
3105 line: 10,
3106 invocations: Some(120),
3107 stable_id: Some("src/app.tsx::render:10".to_owned()),
3108 evidence: Some("production hot path observed".to_owned()),
3109 });
3110
3111 let mut output = output_with(vec![leak, sink], 2);
3112 output.elapsed_ms = ElapsedMs(17);
3113 output.unresolved_callee_sites = 3;
3114
3115 let rendered = render_json_summary(&output);
3116 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3117
3118 assert_eq!(value["kind"], "security");
3119 assert_eq!(value["schema_version"], "6");
3120 assert_eq!(value["version"], "test");
3121 assert_eq!(value["elapsed_ms"], 17);
3122 assert!(value.get("config").is_some());
3123 assert!(value.get("security_findings").is_none());
3124 assert!(value.get("attack_surface").is_none());
3125 assert!(value.get("_meta").is_none());
3126 assert_eq!(value["summary"]["security_findings"], 2);
3127 assert_eq!(value["summary"]["by_severity"]["high"], 1);
3128 assert_eq!(value["summary"]["by_severity"]["medium"], 1);
3129 assert_eq!(value["summary"]["by_severity"]["low"], 0);
3130 assert_eq!(value["summary"]["by_category"]["client-server-leak"], 1);
3131 assert_eq!(value["summary"]["by_category"]["dangerous-html"], 1);
3132 assert_eq!(value["summary"]["by_reachability"]["entry_reachable"], 1);
3133 assert_eq!(
3134 value["summary"]["by_reachability"]["untrusted_source_reachable"],
3135 1
3136 );
3137 assert_eq!(value["summary"]["by_reachability"]["arg_level"], 1);
3138 assert_eq!(value["summary"]["by_reachability"]["module_level"], 0);
3139 assert_eq!(value["summary"]["by_reachability"]["crosses_boundary"], 1);
3140 assert_eq!(value["summary"]["by_reachability"]["source_backed"], 1);
3141 assert_eq!(value["summary"]["by_runtime_state"]["runtime_hot"], 1);
3142 assert_eq!(value["summary"]["by_runtime_state"]["runtime_cold"], 0);
3143 assert_eq!(value["summary"]["by_runtime_state"]["never_executed"], 0);
3144 assert_eq!(value["summary"]["by_runtime_state"]["low_traffic"], 0);
3145 assert_eq!(
3146 value["summary"]["by_runtime_state"]["coverage_unavailable"],
3147 0
3148 );
3149 assert_eq!(value["summary"]["by_runtime_state"]["runtime_unknown"], 0);
3150 assert_eq!(value["summary"]["by_runtime_state"]["not_collected"], 1);
3151 assert_eq!(value["summary"]["unresolved_edge_files"], 2);
3152 assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
3153 assert_eq!(value["summary"]["attack_surface_entries"], 0);
3154 }
3155
3156 #[test]
3157 fn json_summary_carries_security_meta_when_explain_requested() {
3158 let root = Path::new("/proj/root");
3159 let mut output = output_with(vec![relativize_finding(sample_finding(root), root)], 0);
3160 output.meta = Some(crate::explain::security_meta());
3161
3162 let rendered = render_json_summary(&output);
3163 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3164
3165 assert!(value.get("security_findings").is_none());
3166 assert!(value["_meta"]["field_definitions"]["security_findings[]"].is_string());
3167 assert!(value["_meta"]["field_definitions"]["summary.by_reachability"].is_string());
3168 assert!(value["_meta"]["field_definitions"]["summary.by_runtime_state"].is_string());
3169 assert!(value["_meta"]["field_definitions"]["unresolved_callee_sites"].is_string());
3170 }
3171
3172 #[test]
3173 fn json_summary_preserves_gate_block() {
3174 let output = output_with_gate(SecurityGateVerdict::Fail, 1);
3175 let rendered = render_json_summary(&output);
3176 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3177
3178 assert_eq!(value["kind"], "security");
3179 assert_eq!(value["gate"]["mode"], "new");
3180 assert_eq!(value["gate"]["verdict"], "fail");
3181 assert_eq!(value["gate"]["new_count"], 1);
3182 assert_eq!(value["summary"]["security_findings"], 0);
3183 }
3184
3185 #[test]
3186 fn json_render_carries_security_meta_when_explain_requested() {
3187 let mut output = output_with(vec![], 0);
3188 output.meta = Some(crate::explain::security_meta());
3189
3190 let rendered = render_json(&output);
3191 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3192
3193 assert_eq!(
3194 value["_meta"]["field_definitions"]["security_findings[]"],
3195 "Unverified security candidates for downstream human or agent verification."
3196 );
3197 assert!(value["_meta"]["rules"]["security/tainted-sink"].is_object());
3198 }
3199
3200 #[test]
3201 fn json_render_carries_candidate_record_and_omits_impact() {
3202 let root = Path::new("/proj/root");
3206 let finding = relativize_finding(sample_finding(root), root);
3207 let rendered = render_json(&output_with(vec![finding], 0));
3208 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3209 let finding = &value["security_findings"][0];
3210
3211 let candidate = &finding["candidate"];
3212 assert!(candidate.is_object(), "candidate record present");
3213 assert!(candidate["sink"].is_object(), "sink slot present");
3214 assert_eq!(candidate["boundary"]["client_server"], true);
3215 assert!(
3216 candidate.get("impact").is_none(),
3217 "impact must NOT be a wire field"
3218 );
3219 assert!(
3220 candidate.get("source_kind").is_none(),
3221 "client-server-leak has no source kind"
3222 );
3223 assert!(
3224 finding.get("taint_flow").is_none(),
3225 "no untrusted-source flow on a client-server-leak"
3226 );
3227 assert!(
3228 finding.get("finding_id").is_some(),
3229 "finding_id is on the wire"
3230 );
3231 }
3232
3233 #[test]
3234 fn finding_id_is_stable_and_matches_sarif_fingerprint() {
3235 let root = Path::new("/proj/root");
3238 let finding = relativize_finding(sample_finding(root), root);
3239 let id = security_finding_id(&finding);
3240 assert!(!id.is_empty());
3241 assert_eq!(
3242 id,
3243 security_finding_id(&finding),
3244 "deterministic across calls"
3245 );
3246
3247 let sarif: serde_json::Value =
3248 serde_json::from_str(&render_sarif(&output_with(vec![finding], 0)))
3249 .expect("valid SARIF");
3250 assert_eq!(
3251 sarif["runs"][0]["results"][0]["partialFingerprints"]["fallowSecurity/v1"],
3252 serde_json::Value::String(id)
3253 );
3254 }
3255
3256 #[test]
3257 fn json_render_carries_dead_code_context() {
3258 let root = Path::new("/proj/root");
3259 let mut finding = relativize_finding(sample_finding(root), root);
3260 finding.kind = SecurityFindingKind::TaintedSink;
3261 finding.dead_code = Some(SecurityDeadCodeContext {
3262 kind: SecurityDeadCodeKind::UnusedExport,
3263 export_name: Some("handler".to_string()),
3264 line: Some(12),
3265 guidance: "remove export instead of harden".to_string(),
3266 });
3267 let rendered = render_json(&output_with(vec![finding], 0));
3268 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3269 let context = &value["security_findings"][0]["dead_code"];
3270 assert_eq!(context["kind"], "unused-export");
3271 assert_eq!(context["export_name"], "handler");
3272 assert_eq!(context["line"], 12);
3273 }
3274
3275 #[test]
3276 fn sarif_render_emits_warning_level_with_fingerprint_and_related_locations() {
3277 let root = Path::new("/proj/root");
3278 let finding = relativize_finding(sample_finding(root), root);
3279 let rendered = render_sarif(&output_with(vec![finding], 0));
3280 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3281 assert_eq!(sarif["version"], "2.1.0");
3282 let run = &sarif["runs"][0];
3283 assert_eq!(run["tool"]["driver"]["name"], "fallow");
3284 let result = &run["results"][0];
3285 assert_eq!(result["level"], "warning");
3287 assert_eq!(result["ruleId"], "security/client-server-leak");
3288 assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
3289 assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
3291 let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
3292 .as_array()
3293 .expect("thread flow locations");
3294 assert_eq!(flow_locations.len(), 3);
3295 assert_eq!(
3296 flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3297 "src/app.tsx"
3298 );
3299 assert_eq!(
3300 flow_locations[2]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3301 "src/lib/secret.ts"
3302 );
3303 assert_eq!(
3304 flow_locations[2]["kinds"][0],
3305 serde_json::json!("secret-source")
3306 );
3307 assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
3309
3310 let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
3311 assert_eq!(rules[0]["name"], "Client-server secret leak");
3312 assert!(rules[0]["help"]["text"].is_string());
3313 assert!(rules[0].get("relationships").is_none());
3314 assert!(run.get("taxonomies").is_none());
3315 }
3316
3317 #[test]
3318 fn sarif_render_keeps_low_severity_as_note() {
3319 let root = Path::new("/proj/root");
3320 let mut finding = sample_finding(root);
3321 finding.severity = SecuritySeverity::Low;
3322 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3323 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3324
3325 assert_eq!(sarif["runs"][0]["results"][0]["level"], "note");
3326 }
3327
3328 #[test]
3329 fn sarif_render_includes_dead_code_hint_in_message() {
3330 let root = Path::new("/proj/root");
3331 let mut finding = relativize_finding(sample_finding(root), root);
3332 finding.kind = SecurityFindingKind::TaintedSink;
3333 finding.dead_code = Some(SecurityDeadCodeContext {
3334 kind: SecurityDeadCodeKind::UnusedFile,
3335 export_name: None,
3336 line: None,
3337 guidance: "delete instead of harden".to_string(),
3338 });
3339 let rendered = render_sarif(&output_with(vec![finding], 0));
3340 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3341 let message = sarif["runs"][0]["results"][0]["message"]["text"]
3342 .as_str()
3343 .expect("message text");
3344 assert!(message.contains("Dead-code cross-link"), "got: {message}");
3345 assert!(
3346 message.contains("delete this file instead of hardening"),
3347 "got: {message}"
3348 );
3349 }
3350
3351 #[test]
3352 fn sarif_render_includes_untrusted_source_context_and_related_locations() {
3353 let root = Path::new("/proj/root");
3354 let mut finding = sample_finding(root);
3355 finding.kind = SecurityFindingKind::TaintedSink;
3356 finding.category = Some("command-injection".to_string());
3357 add_untrusted_source_reachability(&mut finding, root);
3358 add_taint_flow(&mut finding, root);
3359 finding.trace.push(TraceHop {
3360 path: root.join("src/lib/sink.ts"),
3361 line: 9,
3362 col: 2,
3363 role: TraceHopRole::Sink,
3364 });
3365 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3366 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3367 let result = &sarif["runs"][0]["results"][0];
3368 let message = result["message"]["text"].as_str().expect("message text");
3369 assert!(message.contains("Module-level context"), "got: {message}");
3370 assert!(
3371 message.contains("does not prove value flow"),
3372 "got: {message}"
3373 );
3374 assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 5);
3376 let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
3377 .as_array()
3378 .expect("thread flow locations");
3379 assert_eq!(flow_locations.len(), 2);
3380 assert_eq!(
3381 flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3382 "src/routes/api.ts"
3383 );
3384 assert_eq!(
3385 flow_locations[1]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3386 "src/lib/sink.ts"
3387 );
3388 }
3389
3390 #[test]
3391 fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_metadata() {
3392 let root = Path::new("/proj/root");
3393 let mut finding = sample_finding(root);
3394 finding.kind = SecurityFindingKind::TaintedSink;
3395 finding.category = Some("dangerous-html".to_owned());
3396 finding.cwe = Some(79);
3397 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3398 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3399 let run = &sarif["runs"][0];
3400 let result = &run["results"][0];
3403 assert_eq!(result["level"], "warning");
3404 assert_eq!(result["ruleId"], "security/dangerous-html");
3405 let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
3407 assert_eq!(rules.len(), 1);
3408 assert_eq!(rules[0]["id"], "security/dangerous-html");
3409 assert_eq!(rules[0]["name"], "Dangerous HTML sink");
3410 assert!(
3411 rules[0]["help"]["text"]
3412 .as_str()
3413 .expect("help text")
3414 .contains("Verify this unverified")
3415 );
3416 assert!(
3417 rules[0]["help"]["markdown"]
3418 .as_str()
3419 .expect("help markdown")
3420 .contains("**Dangerous HTML sink**")
3421 );
3422 let tags = rules[0]["properties"]["tags"].as_array().unwrap();
3423 assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
3424 let relationship = &rules[0]["relationships"][0];
3425 assert_eq!(relationship["target"]["id"], "CWE-79");
3426 assert_eq!(relationship["target"]["index"], 0);
3427 assert_eq!(relationship["target"]["toolComponent"]["name"], "CWE");
3428 assert_eq!(relationship["kinds"][0], "superset");
3429
3430 let taxonomy = &run["taxonomies"][0];
3431 assert_eq!(taxonomy["name"], "CWE");
3432 assert_eq!(taxonomy["taxa"][0]["id"], "CWE-79");
3433 assert_eq!(
3434 run["tool"]["driver"]["supportedTaxonomies"][0]["name"],
3435 "CWE"
3436 );
3437 }
3438
3439 #[test]
3440 fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
3441 let root = Path::new("/proj/root");
3442 let finding = relativize_finding(sample_finding(root), root);
3443 let output = output_with(vec![finding], 0);
3444 let dir = tempfile::tempdir().expect("tempdir");
3445 let path = dir.path().join("nested/out.sarif");
3446 write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
3447 let written = std::fs::read_to_string(&path).expect("file exists");
3448 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
3449 assert_eq!(sarif["version"], "2.1.0");
3450 }
3451
3452 const NO_CONFIG: Option<PathBuf> = None;
3454
3455 fn leak_fixture_root() -> PathBuf {
3456 Path::new(env!("CARGO_MANIFEST_DIR"))
3457 .join("../../tests/fixtures/security-client-server-leak")
3458 }
3459
3460 fn source_reachability_fixture_root() -> PathBuf {
3461 Path::new(env!("CARGO_MANIFEST_DIR"))
3462 .join("../../tests/fixtures/security-source-reachability-885")
3463 }
3464
3465 fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
3466 SecurityOptions {
3467 root,
3468 config_path: &NO_CONFIG,
3469 output,
3470 no_cache: true,
3471 threads: 1,
3472 quiet: true,
3473 fail_on_issues,
3474 sarif_file: None,
3475 summary: false,
3476 changed_since: None,
3477 use_shared_diff_index: false,
3478 workspace: None,
3479 changed_workspaces: None,
3480 file: &[],
3481 surface: false,
3482 gate: None,
3483 runtime_coverage: None,
3484 min_invocations_hot: 100,
3485 explain: false,
3486 }
3487 }
3488
3489 #[test]
3490 #[expect(
3491 deprecated,
3492 reason = "CLI fixture test uses the same workspace path dependency boundary as `run`"
3493 )]
3494 fn source_reachability_fixture_marks_cross_module_sink() {
3495 let root = source_reachability_fixture_root();
3496 let mut config = load_config_for_analysis(
3497 &root,
3498 &NO_CONFIG,
3499 OutputFormat::Json,
3500 true,
3501 1,
3502 None,
3503 true,
3504 ProductionAnalysis::DeadCode,
3505 )
3506 .expect("fixture config loads");
3507 config.rules.security_sink = Severity::Warn;
3508
3509 let results = fallow_core::analyze(&config).expect("fixture analyzes");
3510 let finding = results
3511 .security_findings
3512 .iter()
3513 .find(|finding| finding.path.ends_with("src/runner.ts"))
3514 .expect("runner sink finding");
3515 let reach = finding.reachability.as_ref().expect("reachability");
3516
3517 assert!(reach.reachable_from_untrusted_source);
3518 assert_eq!(reach.untrusted_source_hop_count, Some(1));
3519 assert_eq!(
3523 reach.taint_confidence,
3524 Some(fallow_core::results::TaintConfidence::ModuleLevel)
3525 );
3526 assert_eq!(
3527 reach
3528 .untrusted_source_trace
3529 .iter()
3530 .map(|hop| hop.role)
3531 .collect::<Vec<_>>(),
3532 vec![TraceHopRole::ModuleSource, TraceHopRole::Sink]
3533 );
3534 assert!(
3535 reach.untrusted_source_trace[0]
3536 .path
3537 .ends_with("src/route.ts")
3538 );
3539
3540 assert!(
3544 finding.candidate.boundary.cross_module,
3545 "a sink reached across a module hop crosses a module boundary"
3546 );
3547 let flow = finding.taint_flow.as_ref().expect("taint_flow present");
3548 assert!(!flow.path.intra_module);
3549 assert_eq!(flow.path.cross_module_hops, 1);
3550 assert!(flow.source.path.ends_with("src/route.ts"));
3551 assert!(flow.sink.path.ends_with("src/runner.ts"));
3552 }
3553
3554 #[test]
3555 fn file_scope_keeps_security_finding_when_anchor_matches() {
3556 let root = Path::new("/proj/root");
3557 let mut results = fallow_core::results::AnalysisResults::default();
3558 results.security_findings.push(sample_finding(root));
3559
3560 filter_to_files(&mut results, root, &[PathBuf::from("src/app.tsx")], true);
3561
3562 assert_eq!(results.security_findings.len(), 1);
3563 }
3564
3565 #[test]
3566 fn file_scope_keeps_security_finding_when_trace_hop_matches() {
3567 let root = Path::new("/proj/root");
3568 let mut results = fallow_core::results::AnalysisResults::default();
3569 results.security_findings.push(sample_finding(root));
3570
3571 filter_to_files(
3572 &mut results,
3573 root,
3574 &[PathBuf::from("src/lib/secret.ts")],
3575 true,
3576 );
3577
3578 assert_eq!(results.security_findings.len(), 1);
3579 }
3580
3581 #[test]
3582 fn file_scope_drops_unrelated_security_finding() {
3583 let root = Path::new("/proj/root");
3584 let mut results = fallow_core::results::AnalysisResults::default();
3585 results.security_findings.push(sample_finding(root));
3586
3587 filter_to_files(&mut results, root, &[PathBuf::from("src/other.ts")], true);
3588
3589 assert!(results.security_findings.is_empty());
3590 }
3591
3592 #[test]
3593 fn run_is_advisory_and_exits_zero_even_with_candidates() {
3594 let root = leak_fixture_root();
3597 let code = run(&run_opts(&root, OutputFormat::Json, false));
3598 assert_eq!(code, ExitCode::SUCCESS);
3599 }
3600
3601 #[test]
3602 fn run_with_fail_on_issues_exits_one_when_candidates_found() {
3603 let root = leak_fixture_root();
3605 let code = run(&run_opts(&root, OutputFormat::Human, true));
3606 assert_eq!(code, ExitCode::from(1));
3607 }
3608
3609 #[test]
3610 fn run_rejects_unsupported_output_format() {
3611 let root = leak_fixture_root();
3613 let code = run(&run_opts(&root, OutputFormat::Compact, false));
3614 assert_eq!(code, ExitCode::from(2));
3615 }
3616
3617 #[test]
3618 fn run_summary_mode_dispatches_compact_human_renderer() {
3619 let root = leak_fixture_root();
3620 let opts = SecurityOptions {
3621 summary: true,
3622 ..run_opts(&root, OutputFormat::Human, false)
3623 };
3624 assert_eq!(run(&opts), ExitCode::SUCCESS);
3625 }
3626
3627 #[test]
3628 fn run_sarif_format_dispatches_sarif_renderer() {
3629 let root = leak_fixture_root();
3630 assert_eq!(
3631 run(&run_opts(&root, OutputFormat::Sarif, false)),
3632 ExitCode::SUCCESS
3633 );
3634 }
3635
3636 #[test]
3637 fn run_writes_sarif_sidecar_file_when_requested() {
3638 let root = leak_fixture_root();
3639 let dir = tempfile::tempdir().expect("tempdir");
3640 let sidecar = dir.path().join("security.sarif");
3641 let opts = SecurityOptions {
3642 sarif_file: Some(&sidecar),
3643 ..run_opts(&root, OutputFormat::Human, false)
3644 };
3645 assert_eq!(run(&opts), ExitCode::SUCCESS);
3646 let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
3647 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
3648 assert_eq!(sarif["version"], "2.1.0");
3649 }
3650}