1use crate::report::sink::outln;
13use colored::Colorize;
14use std::cmp::Ordering;
15use std::collections::{BTreeMap, BTreeSet};
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use std::process::ExitCode;
19use std::time::Instant;
20
21use fallow_config::{OutputFormat, ProductionAnalysis, Severity};
22use fallow_engine::results::{
23 AnalysisResults, SecurityAttackSurfaceEntry, SecurityDeadCodeKind, SecurityFinding,
24 SecurityFindingKind, TraceHop, TraceHopRole,
25};
26use fallow_engine::security::{derive_security_severity, security_catalogue_title};
27pub use fallow_output::{
28 SecurityBlindSpotFile, SecurityBlindSpotGroup, SecurityBlindSpotsOutput,
29 SecurityBlindSpotsSchemaVersion, SecurityBlindSpotsSummary, SecurityGateVerdict,
30 SecuritySchemaVersion, SecuritySurvivor, SecuritySurvivorsOutput,
31 SecuritySurvivorsSchemaVersion, SecuritySurvivorsSummary, SecurityUnresolvedCalleeDiagnostics,
32 SecurityUnresolvedCalleeReasonCount, SecurityUnresolvedCalleeSample,
33 SecurityUnresolvedCalleeTopFile, SecurityVerifierVerdict, SecurityVerifierVerdictStatus,
34};
35use fallow_types::discover::DiscoveredFile;
36use fallow_types::envelope::{ElapsedMs, ToolVersion};
37use fallow_types::extract::ModuleInfo;
38use fallow_types::results::{
39 SecurityRuntimeContext, SecurityRuntimeState, SecuritySeverity,
40 SecurityUnresolvedCalleeDiagnostic, TaintConfidence,
41};
42use rustc_hash::FxHashSet;
43use xxhash_rust::xxh3::xxh3_64;
44
45use crate::base_worktree::{BaseWorktree, git_rev_parse};
46use crate::error::emit_error;
47use crate::health::HealthOptions;
48use crate::load_config_for_analysis;
49use fallow_output::{
50 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport, RuntimeCoverageVerdict,
51};
52
53pub use fallow_api::SecurityGateMode;
54
55const UNRESOLVED_CALLEE_SAMPLE_LIMIT: usize = 25;
56const UNRESOLVED_CALLEE_TOP_FILES_LIMIT: usize = 10;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
60pub enum SecurityGateArg {
61 New,
64 NewlyReachable,
67}
68
69impl SecurityGateArg {
70 #[must_use]
71 pub const fn into_mode(self) -> SecurityGateMode {
72 match self {
73 Self::New => SecurityGateMode::New,
74 Self::NewlyReachable => SecurityGateMode::NewlyReachable,
75 }
76 }
77}
78
79pub type SecurityGate = fallow_output::SecurityGate<SecurityGateMode>;
80
81pub type SecurityOutputConfig = fallow_output::SecurityOutputConfig<Severity>;
82
83pub type SecurityOutputRulesConfig = fallow_output::SecurityOutputRulesConfig<Severity>;
84
85pub type SecurityRuleSeverityConfig = fallow_output::SecurityRuleSeverityConfig<Severity>;
86
87pub type SecurityOutput = fallow_output::SecurityOutput<SecurityOutputConfig, SecurityGate>;
88
89pub struct SecurityOptions<'a> {
91 pub root: &'a Path,
93 pub config_path: &'a Option<PathBuf>,
95 pub output: OutputFormat,
97 pub no_cache: bool,
99 pub threads: usize,
101 pub quiet: bool,
103 pub fail_on_issues: bool,
105 pub sarif_file: Option<&'a Path>,
107 pub summary: bool,
109 pub changed_since: Option<&'a str>,
111 pub use_shared_diff_index: bool,
113 pub workspace: Option<&'a [String]>,
115 pub changed_workspaces: Option<&'a str>,
117 pub file: &'a [PathBuf],
119 pub surface: bool,
121 pub gate: Option<SecurityGateMode>,
126 pub runtime_coverage: Option<&'a Path>,
128 pub min_invocations_hot: u64,
130 pub explain: bool,
132}
133
134pub struct SecuritySurvivorsOptions<'a> {
136 pub output: OutputFormat,
138 pub candidates: &'a Path,
140 pub verdicts: &'a Path,
142 pub require_verdict_for_each_candidate: bool,
144}
145
146pub fn run_survivors(opts: &SecuritySurvivorsOptions<'_>) -> ExitCode {
148 let started = Instant::now();
149 if let Err(code) = validate_derived_security_output(opts.output, "survivors") {
150 return code;
151 }
152 let output = match build_survivors_output(opts, started) {
153 Ok(output) => output,
154 Err(message) => return emit_error(&message, 2, opts.output),
155 };
156 if opts.require_verdict_for_each_candidate && output.summary.unverdicted > 0 {
157 return emit_error(
158 &format!(
159 "Verifier verdict file is missing verdicts for {} candidate{}.",
160 output.summary.unverdicted,
161 crate::report::plural(output.summary.unverdicted)
162 ),
163 2,
164 opts.output,
165 );
166 }
167 outln!("{}", render_survivors_output(opts.output, &output));
168 ExitCode::SUCCESS
169}
170
171pub fn run_blind_spots(opts: &SecurityOptions<'_>) -> ExitCode {
173 let started = Instant::now();
174 if let Err(code) = validate_derived_security_output(opts.output, "blind-spots") {
175 return code;
176 }
177 let (security_output, _) = match build_security_command_output(opts, started) {
178 Ok(output) => output,
179 Err(code) => return code,
180 };
181 let output = build_blind_spots_output(&security_output);
182 outln!("{}", render_blind_spots_output(opts.output, &output));
183 ExitCode::SUCCESS
184}
185
186pub fn run(opts: &SecurityOptions<'_>) -> ExitCode {
191 let started = Instant::now();
192 let (output, effective_severities) = match build_security_command_output(opts, started) {
193 Ok(output) => output,
194 Err(code) => return code,
195 };
196 crate::telemetry::note_result_count(output.security_findings.len());
197
198 if let Err(code) = maybe_write_security_sarif(opts, &output) {
199 return code;
200 }
201
202 outln!("{}", render_security_output(opts, &output));
203 security_exit_code(opts, &output, effective_severities)
204}
205
206fn build_security_command_output(
207 opts: &SecurityOptions<'_>,
208 started: Instant,
209) -> Result<(SecurityOutput, SecurityRuleSeverities), ExitCode> {
210 validate_security_output(opts.output)?;
211
212 let mut config = load_config_for_analysis(
213 opts.root,
214 opts.config_path,
215 crate::ConfigLoadOptions {
216 output: opts.output,
217 no_cache: opts.no_cache,
218 threads: opts.threads,
219 production_override: None,
220 quiet: opts.quiet,
221 },
222 ProductionAnalysis::DeadCode,
223 )?;
224
225 let configured_severities = security_rule_severities(&config);
226 force_security_rules(&mut config);
227 let effective_severities = security_rule_severities(&config);
228
229 let mut analysis = analyze_security_candidates(opts, &config)?;
230
231 apply_security_scopes(opts, &mut analysis)?;
232
233 let gate_mode = apply_security_gate(opts, &config, &mut analysis.results)?;
234
235 let unresolved_edge_files = analysis.results.security_unresolved_edge_files;
236 let unresolved_callee_sites = analysis.results.security_unresolved_callee_sites;
237 let unresolved_callee_diagnostics = unresolved_callee_diagnostics(
238 &analysis.results.security_unresolved_callee_diagnostics,
239 &config.root,
240 );
241 let runtime_report = security_runtime_report(opts, &mut analysis)?;
242 let PreparedSecurityFindings {
243 findings,
244 attack_surface,
245 } = prepare_security_findings(
246 &mut analysis,
247 runtime_report.as_ref(),
248 &config.root,
249 opts.surface,
250 );
251
252 let output = build_security_output(SecurityOutputInput {
253 opts,
254 started,
255 config: &config,
256 configured_severities,
257 effective_severities,
258 gate_mode,
259 findings,
260 attack_surface,
261 unresolved_edge_files,
262 unresolved_callee_sites,
263 unresolved_callee_diagnostics,
264 });
265 Ok((output, effective_severities))
266}
267
268#[derive(Clone, Copy)]
269struct SecurityRuleSeverities {
270 leak: Severity,
271 sink: Severity,
272}
273
274struct SecurityOutputInput<'a, 'b> {
275 opts: &'a SecurityOptions<'b>,
276 started: Instant,
277 config: &'a fallow_config::ResolvedConfig,
278 configured_severities: SecurityRuleSeverities,
279 effective_severities: SecurityRuleSeverities,
280 gate_mode: Option<SecurityGateMode>,
281 findings: Vec<SecurityFinding>,
282 attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
283 unresolved_edge_files: usize,
284 unresolved_callee_sites: usize,
285 unresolved_callee_diagnostics: Option<SecurityUnresolvedCalleeDiagnostics>,
286}
287
288fn validate_security_output(output: OutputFormat) -> Result<(), ExitCode> {
289 if matches!(
290 output,
291 OutputFormat::Human | OutputFormat::Json | OutputFormat::Sarif
292 ) {
293 Ok(())
294 } else {
295 Err(emit_error(
296 "fallow security supports --format human, json, or sarif only.",
297 2,
298 output,
299 ))
300 }
301}
302
303fn validate_derived_security_output(
304 output: OutputFormat,
305 subcommand: &'static str,
306) -> Result<(), ExitCode> {
307 if matches!(output, OutputFormat::Human | OutputFormat::Json) {
308 Ok(())
309 } else {
310 Err(emit_error(
311 &format!("fallow security {subcommand} supports --format human or json only."),
312 2,
313 output,
314 ))
315 }
316}
317
318fn build_survivors_output(
319 opts: &SecuritySurvivorsOptions<'_>,
320 started: Instant,
321) -> Result<SecuritySurvivorsOutput, String> {
322 let candidates = load_candidate_map(opts.candidates)?;
323 let verdicts = load_verdicts(opts.verdicts)?;
324 let mut seen = BTreeSet::new();
325 let mut survivors = BTreeMap::new();
326 let mut needs_human_review = BTreeMap::new();
327 let mut dismissed = 0;
328
329 for verdict in &verdicts {
330 validate_verdict(verdict)?;
331 if !seen.insert(verdict.finding_id.clone()) {
332 return Err(format!(
333 "Verifier verdict file has duplicate verdict for finding_id `{}`.",
334 verdict.finding_id
335 ));
336 }
337 let Some(candidate) = candidates.get(&verdict.finding_id) else {
338 return Err(format!(
339 "Verifier verdict references unknown finding_id `{}`.",
340 verdict.finding_id
341 ));
342 };
343 match verdict.verdict {
344 SecurityVerifierVerdictStatus::Survivor => {
345 survivors.insert(
346 verdict.finding_id.clone(),
347 survivor_from_verdict(verdict, candidate),
348 );
349 }
350 SecurityVerifierVerdictStatus::Dismissed => dismissed += 1,
351 SecurityVerifierVerdictStatus::NeedsHumanReview => {
352 needs_human_review.insert(
353 verdict.finding_id.clone(),
354 survivor_from_verdict(verdict, candidate),
355 );
356 }
357 }
358 }
359
360 let unverdicted = candidates.len().saturating_sub(seen.len());
361
362 Ok(SecuritySurvivorsOutput {
363 schema_version: SecuritySurvivorsSchemaVersion::V2,
364 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
365 elapsed_ms: ElapsedMs(started.elapsed().as_millis() as u64),
366 summary: SecuritySurvivorsSummary {
367 candidates: candidates.len(),
368 verdicts: verdicts.len(),
369 survivors: survivors.len(),
370 dismissed,
371 needs_human_review: needs_human_review.len(),
372 unverdicted,
373 },
374 survivors,
375 needs_human_review,
376 })
377}
378
379fn load_candidate_map(path: &Path) -> Result<BTreeMap<String, SecurityFinding>, String> {
380 let value = load_json_file(path, "candidate")?;
381 let Some(findings) = value
382 .get("security_findings")
383 .and_then(serde_json::Value::as_array)
384 else {
385 return Err(format!(
386 "Candidate file {} must be raw `fallow security --format json` output with a security_findings array.",
387 path.display()
388 ));
389 };
390 let mut candidates = BTreeMap::new();
391 for finding in findings {
392 let finding: SecurityFinding = serde_json::from_value(finding.clone()).map_err(|err| {
393 format!(
394 "Candidate file {} contains a malformed security finding: {err}",
395 path.display()
396 )
397 })?;
398 if finding.finding_id.is_empty() {
399 return Err(format!(
400 "Candidate file {} contains a security finding with an empty finding_id.",
401 path.display()
402 ));
403 }
404 if candidates
405 .insert(finding.finding_id.clone(), finding.clone())
406 .is_some()
407 {
408 return Err(format!(
409 "Candidate file {} contains duplicate finding_id `{}`.",
410 path.display(),
411 finding.finding_id
412 ));
413 }
414 }
415 Ok(candidates)
416}
417
418fn load_verdicts(path: &Path) -> Result<Vec<SecurityVerifierVerdict>, String> {
419 let value = load_json_file(path, "verdict")?;
420 let verdicts_value = if let Some(items) = value.get("verdicts") {
421 if value
422 .get("schema_version")
423 .and_then(serde_json::Value::as_str)
424 != Some("fallow-security-verdicts/v1")
425 {
426 return Err(format!(
427 "Verifier verdict file {} must use schema_version `fallow-security-verdicts/v1`.",
428 path.display()
429 ));
430 }
431 if !items.is_array() {
432 return Err(format!(
433 "Verifier verdict file {} must contain a verdicts array.",
434 path.display()
435 ));
436 }
437 items.clone()
438 } else {
439 value
440 };
441 serde_json::from_value::<Vec<SecurityVerifierVerdict>>(verdicts_value).map_err(|err| {
442 format!(
443 "Failed to parse verifier verdict file {}: {err}",
444 path.display()
445 )
446 })
447}
448
449fn load_json_file(path: &Path, label: &str) -> Result<serde_json::Value, String> {
450 let src = std::fs::read_to_string(path)
451 .map_err(|err| format!("Failed to read {label} file {}: {err}", path.display()))?;
452 serde_json::from_str(&src)
453 .map_err(|err| format!("Failed to parse {label} file {}: {err}", path.display()))
454}
455
456fn validate_verdict(verdict: &SecurityVerifierVerdict) -> Result<(), String> {
457 if verdict.schema_version != "fallow-security-verdict/v1" {
458 return Err(format!(
459 "Verifier verdict for finding_id `{}` must use schema_version `fallow-security-verdict/v1`.",
460 verdict.finding_id
461 ));
462 }
463 if verdict.finding_id.is_empty() {
464 return Err("Verifier verdict contains an empty finding_id.".to_owned());
465 }
466 Ok(())
467}
468
469fn survivor_from_verdict(
470 verdict: &SecurityVerifierVerdict,
471 candidate: &SecurityFinding,
472) -> SecuritySurvivor {
473 SecuritySurvivor {
474 finding_id: verdict.finding_id.clone(),
475 verdict: verdict.verdict,
476 reason: verdict.reason.clone(),
477 rationale: verdict.rationale.clone(),
478 confidence: verdict.confidence.clone(),
479 impact: verdict.impact.clone(),
480 fix_direction: verdict.fix_direction.clone(),
481 candidate: candidate.clone(),
482 }
483}
484
485fn security_rule_severities(config: &fallow_config::ResolvedConfig) -> SecurityRuleSeverities {
486 SecurityRuleSeverities {
487 leak: config.rules.security_client_server_leak,
488 sink: config.rules.security_sink,
489 }
490}
491
492fn build_security_output(input: SecurityOutputInput<'_, '_>) -> SecurityOutput {
493 SecurityOutput {
494 schema_version: SecuritySchemaVersion::V7,
495 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
496 elapsed_ms: ElapsedMs(input.started.elapsed().as_millis() as u64),
497 config: security_output_config(
498 input.config,
499 input.configured_severities.leak,
500 input.effective_severities.leak,
501 input.configured_severities.sink,
502 input.effective_severities.sink,
503 ),
504 meta: input.opts.explain.then(crate::explain::security_meta),
505 gate: input
506 .gate_mode
507 .map(|mode| security_gate_output(mode, input.findings.len())),
508 security_findings: input.findings,
509 attack_surface: input.attack_surface,
510 unresolved_edge_files: input.unresolved_edge_files,
511 unresolved_callee_sites: input.unresolved_callee_sites,
512 unresolved_callee_diagnostics: input.unresolved_callee_diagnostics,
513 }
514}
515
516fn security_gate_output(mode: SecurityGateMode, finding_count: usize) -> SecurityGate {
517 SecurityGate {
521 mode,
522 verdict: if finding_count > 0 {
523 SecurityGateVerdict::Fail
524 } else {
525 SecurityGateVerdict::Pass
526 },
527 new_count: finding_count,
528 }
529}
530
531fn maybe_write_security_sarif(
532 opts: &SecurityOptions<'_>,
533 output: &SecurityOutput,
534) -> Result<(), ExitCode> {
535 if let Some(path) = opts.sarif_file
536 && let Err(message) = write_sarif_file(output, path)
537 {
538 return Err(emit_error(&message, 2, opts.output));
539 }
540 Ok(())
541}
542
543fn render_security_output(opts: &SecurityOptions<'_>, output: &SecurityOutput) -> String {
544 match opts.output {
545 OutputFormat::Json if opts.summary => render_json_summary(output),
546 OutputFormat::Json => render_json(output),
547 OutputFormat::Sarif => render_sarif(output),
548 _ if opts.summary => render_human_summary(output),
549 _ => render_human(output),
550 }
551}
552
553fn security_exit_code(
554 opts: &SecurityOptions<'_>,
555 output: &SecurityOutput,
556 effective_severities: SecurityRuleSeverities,
557) -> ExitCode {
558 if let Some(gate) = &output.gate {
559 if gate.verdict == SecurityGateVerdict::Fail {
560 ExitCode::from(8)
561 } else {
562 ExitCode::SUCCESS
563 }
564 } else if security_advisory_failed(opts, output, effective_severities) {
565 ExitCode::from(1)
566 } else {
567 ExitCode::SUCCESS
568 }
569}
570
571fn security_advisory_failed(
572 opts: &SecurityOptions<'_>,
573 output: &SecurityOutput,
574 effective_severities: SecurityRuleSeverities,
575) -> bool {
576 (opts.fail_on_issues
577 || effective_severities.leak == Severity::Error
578 || effective_severities.sink == Severity::Error)
579 && !output.security_findings.is_empty()
580}
581
582struct PreparedSecurityFindings {
583 findings: Vec<SecurityFinding>,
584 attack_surface: Option<Vec<SecurityAttackSurfaceEntry>>,
585}
586
587fn prepare_security_findings(
588 analysis: &mut SecurityAnalysisState,
589 runtime_report: Option<&RuntimeCoverageReport>,
590 root: &Path,
591 include_surface: bool,
592) -> PreparedSecurityFindings {
593 let mut findings: Vec<SecurityFinding> =
594 std::mem::take(&mut analysis.results.security_findings)
595 .into_iter()
596 .map(|f| relativize_finding(f, root))
597 .collect();
598 if let (Some(report), Some(modules), Some(files)) = (
599 runtime_report,
600 analysis.modules.as_ref(),
601 analysis.files.as_ref(),
602 ) {
603 apply_runtime_context(&mut findings, modules, files, root, report);
604 }
605 apply_security_severity(&mut findings);
606 sort_by_security_severity(&mut findings);
607 for finding in &mut findings {
608 finding.finding_id = security_finding_id(finding);
609 }
610 let (findings, attack_surface) = prepare_findings(findings, root, include_surface);
611 PreparedSecurityFindings {
612 findings,
613 attack_surface,
614 }
615}
616
617fn force_security_rules(config: &mut fallow_config::ResolvedConfig) {
618 if config.rules.security_client_server_leak == Severity::Off {
621 config.rules.security_client_server_leak = Severity::Warn;
622 }
623 if config.rules.security_sink == Severity::Off {
624 config.rules.security_sink = Severity::Warn;
625 }
626}
627
628fn security_output_config(
629 config: &fallow_config::ResolvedConfig,
630 configured_severity: Severity,
631 effective_severity: Severity,
632 configured_sink_severity: Severity,
633 effective_sink_severity: Severity,
634) -> SecurityOutputConfig {
635 let categories = config.security.categories.as_ref();
636 SecurityOutputConfig {
637 rules: SecurityOutputRulesConfig {
638 security_client_server_leak: SecurityRuleSeverityConfig {
639 configured: configured_severity,
640 effective: effective_severity,
641 },
642 security_sink: SecurityRuleSeverityConfig {
643 configured: configured_sink_severity,
644 effective: effective_sink_severity,
645 },
646 },
647 categories_include: categories.and_then(|categories| categories.include.clone()),
648 categories_exclude: categories.and_then(|categories| categories.exclude.clone()),
649 }
650}
651
652fn apply_changed_scope(opts: &SecurityOptions<'_>, results: &mut AnalysisResults) {
653 if let Some(git_ref) = opts.changed_since
654 && let Some(changed) = fallow_engine::changed_files::get_changed_files(opts.root, git_ref)
655 {
656 fallow_engine::changed_files::filter_results_by_changed_files(results, &changed);
657 }
658 if opts.use_shared_diff_index
659 && let Some(diff_index) = crate::report::ci::diff_filter::shared_diff_index()
660 {
661 crate::check::filtering::filter_results_by_diff(results, diff_index, opts.root);
662 }
663}
664
665fn apply_security_scopes(
666 opts: &SecurityOptions<'_>,
667 analysis: &mut SecurityAnalysisState,
668) -> Result<(), ExitCode> {
669 let ws_roots = crate::check::filtering::resolve_workspace_scope(
670 opts.root,
671 opts.workspace,
672 opts.changed_workspaces,
673 opts.output,
674 )?;
675 if let Some(ref roots) = ws_roots {
676 crate::check::filtering::filter_to_workspaces(&mut analysis.results, roots);
677 }
678
679 if !matches!(opts.gate, Some(SecurityGateMode::NewlyReachable)) {
680 apply_changed_scope(opts, &mut analysis.results);
681 }
682 filter_to_files(&mut analysis.results, opts.root, opts.file, opts.quiet);
683
684 Ok(())
685}
686
687fn apply_security_gate(
688 opts: &SecurityOptions<'_>,
689 config: &fallow_config::ResolvedConfig,
690 results: &mut AnalysisResults,
691) -> Result<Option<SecurityGateMode>, ExitCode> {
692 let Some(mode) = opts.gate else {
693 return Ok(None);
694 };
695
696 if matches!(mode, SecurityGateMode::NewlyReachable) {
697 retain_gate_newly_reachable(opts, config, results)?;
698 return Ok(Some(mode));
699 }
700
701 let mut owned_gate_diff: Option<crate::report::ci::diff_filter::DiffIndex> = None;
706 let gate_diff: &crate::report::ci::diff_filter::DiffIndex =
707 if let Some(shared) = crate::report::ci::diff_filter::shared_diff_index() {
708 shared
709 } else if let Some(git_ref) = opts.changed_since {
710 match fallow_engine::changed_files::try_get_changed_diff(opts.root, git_ref) {
711 Ok(text) => owned_gate_diff
712 .insert(crate::report::ci::diff_filter::DiffIndex::from_unified_diff(&text)),
713 Err(err) => {
714 return Err(emit_error(
715 &format!(
716 "fallow security --gate could not compute the diff for '{git_ref}': {}",
717 err.describe()
718 ),
719 2,
720 opts.output,
721 ));
722 }
723 }
724 } else {
725 return Err(emit_error(
726 "fallow security --gate requires a diff source: --changed-since <ref>, \
727 --diff-file <path>, or --diff-stdin.",
728 2,
729 opts.output,
730 ));
731 };
732 crate::check::filtering::retain_gate_new(results, gate_diff, opts.root);
733 Ok(Some(mode))
734}
735
736const SECURITY_BASE_SNAPSHOT_CACHE_VERSION: u8 = 1;
737const MAX_SECURITY_BASE_SNAPSHOT_CACHE_SIZE: usize = 8 * 1024 * 1024;
738
739#[derive(Debug, Clone)]
740struct SecurityKeySnapshot {
741 reachable: FxHashSet<String>,
742}
743
744struct SecurityBaseSnapshotCacheKey {
745 hash: u64,
746 base_sha: String,
747}
748
749#[derive(bitcode::Encode, bitcode::Decode)]
750struct CachedSecurityKeySnapshot {
751 version: u8,
752 cli_version: String,
753 key_hash: u64,
754 base_sha: String,
755 reachable: Vec<String>,
756}
757
758fn retain_gate_newly_reachable(
759 opts: &SecurityOptions<'_>,
760 config: &fallow_config::ResolvedConfig,
761 results: &mut AnalysisResults,
762) -> Result<(), ExitCode> {
763 let Some(base_ref) = opts.changed_since else {
764 return Err(emit_error(
765 "fallow security --gate newly-reachable requires --changed-since <ref>; \
766 --diff-file and --diff-stdin do not identify a base tree.",
767 2,
768 opts.output,
769 ));
770 };
771 let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
772 return Err(emit_error(
773 &format!(
774 "fallow security --gate newly-reachable could not resolve base ref '{base_ref}'."
775 ),
776 2,
777 opts.output,
778 ));
779 };
780 let cache_key = security_base_snapshot_cache_key(opts, config, &base_sha)?;
781 let base = if let Some(snapshot) = load_cached_security_base_snapshot(config, &cache_key) {
782 snapshot
783 } else {
784 let snapshot = compute_base_security_snapshot(opts, config, base_ref, &base_sha)?;
785 save_cached_security_base_snapshot(config, &cache_key, &snapshot);
786 snapshot
787 };
788 results.security_findings.retain(|finding| {
789 security_reachability_key(finding, opts.root)
790 .is_some_and(|key| !base.reachable.contains(&key))
791 });
792 Ok(())
793}
794
795fn compute_base_security_snapshot(
796 opts: &SecurityOptions<'_>,
797 config: &fallow_config::ResolvedConfig,
798 base_ref: &str,
799 base_sha: &str,
800) -> Result<SecurityKeySnapshot, ExitCode> {
801 let Some(worktree) = BaseWorktree::create(opts.root, base_ref, Some(base_sha)) else {
802 return Err(emit_error(
803 &format!("could not create a temporary worktree for base ref '{base_ref}'"),
804 2,
805 opts.output,
806 ));
807 };
808 let base_root = base_analysis_root(opts.root, worktree.path());
809 let current_config_path = opts
810 .config_path
811 .clone()
812 .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
813 let mut base_config = load_config_for_analysis(
814 &base_root,
815 ¤t_config_path,
816 crate::ConfigLoadOptions {
817 output: opts.output,
818 no_cache: opts.no_cache,
819 threads: opts.threads,
820 production_override: None,
821 quiet: true,
822 },
823 ProductionAnalysis::DeadCode,
824 )?;
825 base_config.cache_dir =
826 remap_cache_dir_for_base_worktree(opts.root, &base_root, &config.cache_dir);
827 force_security_rules(&mut base_config);
828 let mut base_analysis = analyze_security_candidates(
829 &base_snapshot_security_options(opts, &base_root, ¤t_config_path),
830 &base_config,
831 )?;
832 scope_base_snapshot_to_workspaces(opts, &base_root, &mut base_analysis.results)?;
833 Ok(SecurityKeySnapshot {
834 reachable: security_reachable_keys(&base_analysis.results.security_findings, &base_root),
835 })
836}
837
838#[expect(
841 clippy::ref_option,
842 reason = "config_path mirrors the SecurityOptions.config_path field which is &Option<PathBuf>"
843)]
844fn base_snapshot_security_options<'a>(
845 opts: &SecurityOptions<'a>,
846 base_root: &'a Path,
847 config_path: &'a Option<PathBuf>,
848) -> SecurityOptions<'a> {
849 SecurityOptions {
850 root: base_root,
851 config_path,
852 output: opts.output,
853 no_cache: opts.no_cache,
854 threads: opts.threads,
855 quiet: true,
856 fail_on_issues: false,
857 sarif_file: None,
858 summary: false,
859 changed_since: None,
860 use_shared_diff_index: false,
861 workspace: opts.workspace,
862 changed_workspaces: None,
863 file: &[],
864 surface: false,
865 gate: None,
866 runtime_coverage: None,
867 min_invocations_hot: opts.min_invocations_hot,
868 explain: false,
869 }
870}
871
872fn scope_base_snapshot_to_workspaces(
875 opts: &SecurityOptions<'_>,
876 base_root: &Path,
877 results: &mut AnalysisResults,
878) -> Result<(), ExitCode> {
879 if let Some(ref roots) = crate::check::filtering::resolve_workspace_scope(
880 base_root,
881 opts.workspace,
882 None,
883 opts.output,
884 )? {
885 crate::check::filtering::filter_to_workspaces(results, roots);
886 }
887 Ok(())
888}
889
890fn security_reachable_keys(findings: &[SecurityFinding], root: &Path) -> FxHashSet<String> {
891 findings
892 .iter()
893 .filter_map(|finding| security_reachability_key(finding, root))
894 .collect()
895}
896
897fn security_reachability_key(finding: &SecurityFinding, root: &Path) -> Option<String> {
898 if !finding
899 .reachability
900 .as_ref()
901 .is_some_and(|reachability| reachability.reachable_from_entry)
902 {
903 return None;
904 }
905 let category = finding.category.as_deref().unwrap_or("none");
906 Some(format!(
907 "security-reach:{}:{}:{}",
908 relative_key(&finding.path, root),
909 security_kind_key(finding.kind),
910 category,
911 ))
912}
913
914fn security_kind_key(kind: SecurityFindingKind) -> &'static str {
915 match kind {
916 SecurityFindingKind::ClientServerLeak => "client-server-leak",
917 SecurityFindingKind::TaintedSink => "tainted-sink",
918 }
919}
920
921fn security_base_snapshot_cache_key(
922 opts: &SecurityOptions<'_>,
923 config: &fallow_config::ResolvedConfig,
924 base_sha: &str,
925) -> Result<SecurityBaseSnapshotCacheKey, ExitCode> {
926 let payload = serde_json::json!({
927 "cache_version": SECURITY_BASE_SNAPSHOT_CACHE_VERSION,
928 "cli_version": env!("CARGO_PKG_VERSION"),
929 "base_sha": base_sha,
930 "config_hash": format!("{:016x}", config.cache_config_hash),
931 "security_client_server_leak": format!("{:?}", config.rules.security_client_server_leak),
932 "security_sink": format!("{:?}", config.rules.security_sink),
933 "workspace": opts.workspace,
934 "changed_workspaces": opts.changed_workspaces,
935 });
936 let bytes = serde_json::to_vec(&payload).map_err(|err| {
937 emit_error(
938 &format!("failed to build security gate cache key: {err}"),
939 2,
940 opts.output,
941 )
942 })?;
943 Ok(SecurityBaseSnapshotCacheKey {
944 hash: xxh3_64(&bytes),
945 base_sha: base_sha.to_owned(),
946 })
947}
948
949fn security_base_snapshot_cache_dir(config: &fallow_config::ResolvedConfig) -> PathBuf {
950 config.cache_dir.join("cache").join(format!(
951 "security-base-v{SECURITY_BASE_SNAPSHOT_CACHE_VERSION}"
952 ))
953}
954
955fn security_base_snapshot_cache_file(
956 config: &fallow_config::ResolvedConfig,
957 key: &SecurityBaseSnapshotCacheKey,
958) -> PathBuf {
959 security_base_snapshot_cache_dir(config).join(format!("{:016x}.bin", key.hash))
960}
961
962fn ensure_security_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
963 std::fs::create_dir_all(dir)?;
964 let gitignore = dir.join(".gitignore");
965 if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
966 std::fs::write(gitignore, "*\n")?;
967 }
968 Ok(())
969}
970
971fn load_cached_security_base_snapshot(
972 config: &fallow_config::ResolvedConfig,
973 key: &SecurityBaseSnapshotCacheKey,
974) -> Option<SecurityKeySnapshot> {
975 if config.no_cache {
976 return None;
977 }
978 let path = security_base_snapshot_cache_file(config, key);
979 let data = std::fs::read(path).ok()?;
980 if data.len() > MAX_SECURITY_BASE_SNAPSHOT_CACHE_SIZE {
981 return None;
982 }
983 let cached: CachedSecurityKeySnapshot = bitcode::decode(&data).ok()?;
984 if cached.version != SECURITY_BASE_SNAPSHOT_CACHE_VERSION
985 || cached.cli_version != env!("CARGO_PKG_VERSION")
986 || cached.key_hash != key.hash
987 || cached.base_sha != key.base_sha
988 {
989 return None;
990 }
991 Some(SecurityKeySnapshot {
992 reachable: cached.reachable.into_iter().collect(),
993 })
994}
995
996fn save_cached_security_base_snapshot(
997 config: &fallow_config::ResolvedConfig,
998 key: &SecurityBaseSnapshotCacheKey,
999 snapshot: &SecurityKeySnapshot,
1000) {
1001 if config.no_cache {
1002 return;
1003 }
1004 let dir = security_base_snapshot_cache_dir(config);
1005 if ensure_security_base_snapshot_cache_dir(&dir).is_err() {
1006 return;
1007 }
1008 let mut reachable = snapshot.reachable.iter().cloned().collect::<Vec<_>>();
1009 reachable.sort_unstable();
1010 let data = bitcode::encode(&CachedSecurityKeySnapshot {
1011 version: SECURITY_BASE_SNAPSHOT_CACHE_VERSION,
1012 cli_version: env!("CARGO_PKG_VERSION").to_owned(),
1013 key_hash: key.hash,
1014 base_sha: key.base_sha.clone(),
1015 reachable,
1016 });
1017 let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
1018 return;
1019 };
1020 if tmp.write_all(&data).is_err() {
1021 return;
1022 }
1023 let _ = tmp.persist(security_base_snapshot_cache_file(config, key));
1024}
1025
1026fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
1027 if current_root.is_absolute()
1028 && let Some(git_root) = crate::base_worktree::git_toplevel(current_root)
1029 && let Ok(relative) = current_root.strip_prefix(git_root)
1030 {
1031 return base_worktree_root.join(relative);
1032 }
1033 base_worktree_root.to_path_buf()
1034}
1035
1036fn remap_cache_dir_for_base_worktree(
1037 current_root: &Path,
1038 base_worktree_root: &Path,
1039 cache_dir: &Path,
1040) -> PathBuf {
1041 if cache_dir.is_absolute()
1042 && let Ok(relative) = cache_dir.strip_prefix(current_root)
1043 {
1044 return base_worktree_root.join(relative);
1045 }
1046 cache_dir.to_path_buf()
1047}
1048
1049struct SecurityAnalysisState {
1050 results: AnalysisResults,
1051 modules: Option<Vec<ModuleInfo>>,
1052 files: Option<Vec<DiscoveredFile>>,
1053 analysis_output: Option<fallow_engine::DeadCodeAnalysisArtifacts>,
1054}
1055
1056fn analyze_security_candidates(
1057 opts: &SecurityOptions<'_>,
1058 config: &fallow_config::ResolvedConfig,
1059) -> Result<SecurityAnalysisState, ExitCode> {
1060 if opts.runtime_coverage.is_none() {
1061 return fallow_engine::analyze(config)
1062 .map(|analysis| SecurityAnalysisState {
1063 results: analysis.results,
1064 modules: None,
1065 files: None,
1066 analysis_output: None,
1067 })
1068 .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output));
1069 }
1070
1071 fallow_engine::analyze_retaining_modules(config, true, true)
1072 .map(|mut output| {
1073 let modules = output.modules.take();
1074 let files = output.files.take();
1075 let results = output.results.clone();
1076 SecurityAnalysisState {
1077 results,
1078 modules,
1079 files,
1080 analysis_output: Some(output),
1081 }
1082 })
1083 .map_err(|err| emit_error(&format!("Analysis error: {err}"), 2, opts.output))
1084}
1085
1086fn security_runtime_report(
1087 opts: &SecurityOptions<'_>,
1088 analysis: &mut SecurityAnalysisState,
1089) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
1090 let Some(path) = opts.runtime_coverage else {
1091 return Ok(None);
1092 };
1093 let (Some(modules), Some(files), Some(analysis_output)) = (
1094 analysis.modules.as_ref(),
1095 analysis.files.as_ref(),
1096 analysis.analysis_output.take(),
1097 ) else {
1098 return Ok(None);
1099 };
1100 analyze_security_runtime(opts, path, modules.clone(), files.clone(), analysis_output)
1101}
1102
1103fn analyze_security_runtime(
1104 opts: &SecurityOptions<'_>,
1105 path: &Path,
1106 modules: Vec<ModuleInfo>,
1107 files: Vec<DiscoveredFile>,
1108 analysis_output: fallow_engine::DeadCodeAnalysisArtifacts,
1109) -> Result<Option<RuntimeCoverageReport>, ExitCode> {
1110 let runtime_coverage = crate::health::coverage::prepare_options(
1111 path,
1112 opts.min_invocations_hot,
1113 None,
1114 None,
1115 opts.output,
1116 )?;
1117 let result = crate::health::execute_health_with_shared_parse(
1118 &security_runtime_health_options(opts, runtime_coverage),
1119 fallow_engine::HealthSharedParseData {
1120 files,
1121 modules,
1122 analysis_output: Some(analysis_output),
1123 },
1124 )?;
1125 Ok(result.report.runtime_coverage)
1126}
1127
1128fn security_runtime_health_options<'a>(
1131 opts: &SecurityOptions<'a>,
1132 runtime_coverage: fallow_engine::RuntimeCoverageOptions,
1133) -> HealthOptions<'a> {
1134 HealthOptions {
1135 root: opts.root,
1136 config_path: opts.config_path,
1137 output: opts.output,
1138 no_cache: opts.no_cache,
1139 threads: opts.threads,
1140 quiet: opts.quiet,
1141 thresholds: fallow_engine::HealthThresholdOverrides::default(),
1142 top: None,
1143 sort: fallow_engine::HealthSort::Cyclomatic,
1144 production: true,
1145 production_override: Some(true),
1146 changed_since: opts.changed_since,
1147 diff_index: None,
1148 use_shared_diff_index: opts.use_shared_diff_index,
1149 workspace: opts.workspace,
1150 changed_workspaces: opts.changed_workspaces,
1151 baseline: None,
1152 save_baseline: None,
1153 complexity: false,
1154 file_scores: false,
1155 coverage_gaps: false,
1156 config_activates_coverage_gaps: false,
1157 hotspots: false,
1158 ownership: false,
1159 ownership_emails: None,
1160 targets: false,
1161 css: false,
1162 force_full: false,
1163 score_only_output: false,
1164 enforce_coverage_gap_gate: false,
1165 effort: None,
1166 score: false,
1167 gates: fallow_engine::HealthGateOptions::default(),
1168 since: None,
1169 min_commits: None,
1170 explain: false,
1171 summary: false,
1172 save_snapshot: None,
1173 trend: false,
1174 coverage_inputs: fallow_engine::HealthCoverageInputs::default(),
1175 performance: false,
1176 runtime_coverage: Some(runtime_coverage),
1177 churn_file: None,
1178 complexity_breakdown: false,
1179 group_by: None,
1180 }
1181}
1182
1183#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1184struct RuntimeFunctionKey {
1185 path: String,
1186 function: String,
1187 line: u32,
1188}
1189
1190#[derive(Debug, Clone)]
1191struct FunctionSpan {
1192 key: RuntimeFunctionKey,
1193 end_line: u32,
1194}
1195
1196fn apply_runtime_context(
1197 findings: &mut Vec<SecurityFinding>,
1198 modules: &[ModuleInfo],
1199 files: &[fallow_types::discover::DiscoveredFile],
1200 root: &Path,
1201 report: &RuntimeCoverageReport,
1202) {
1203 let spans = function_spans(modules, files, root);
1204 let runtime = SecurityRuntimeIndex::new(report);
1205 let mut indexed = findings.drain(..).enumerate().collect::<Vec<_>>();
1206 for (_, finding) in &mut indexed {
1207 if !matches!(finding.kind, SecurityFindingKind::TaintedSink) {
1208 continue;
1209 }
1210 finding.runtime = runtime_context_for_finding(finding, &spans, &runtime);
1211 }
1212 indexed.sort_by(|(left_index, left), (right_index, right)| {
1213 runtime_rank(left)
1214 .cmp(&runtime_rank(right))
1215 .then_with(|| left_index.cmp(right_index))
1216 });
1217 findings.extend(indexed.into_iter().map(|(_, finding)| finding));
1218}
1219
1220fn function_spans(
1221 modules: &[ModuleInfo],
1222 files: &[fallow_types::discover::DiscoveredFile],
1223 root: &Path,
1224) -> Vec<FunctionSpan> {
1225 let paths_by_id = files
1226 .iter()
1227 .map(|file| (file.id, &file.path))
1228 .collect::<rustc_hash::FxHashMap<_, _>>();
1229 let mut spans = Vec::new();
1230 for module in modules {
1231 let Some(path) = paths_by_id.get(&module.file_id) else {
1232 continue;
1233 };
1234 let path = relative_key(path, root);
1235 for function in &module.complexity {
1236 spans.push(FunctionSpan {
1237 key: RuntimeFunctionKey {
1238 path: path.clone(),
1239 function: function.name.clone(),
1240 line: function.line,
1241 },
1242 end_line: function.line.saturating_add(function.line_count),
1243 });
1244 }
1245 }
1246 spans
1247}
1248
1249struct SecurityRuntimeIndex {
1250 hot_paths: Vec<(RuntimeFunctionKey, u32, SecurityRuntimeContext)>,
1251 findings: rustc_hash::FxHashMap<RuntimeFunctionKey, SecurityRuntimeContext>,
1252}
1253
1254impl SecurityRuntimeIndex {
1255 fn new(report: &RuntimeCoverageReport) -> Self {
1256 let hot_paths = report
1257 .hot_paths
1258 .iter()
1259 .map(|hot| {
1260 (
1261 runtime_hot_key(hot),
1262 hot.end_line.max(hot.line),
1263 SecurityRuntimeContext {
1264 state: SecurityRuntimeState::RuntimeHot,
1265 function: hot.function.clone(),
1266 line: hot.line,
1267 invocations: Some(hot.invocations),
1268 stable_id: hot.stable_id.clone(),
1269 evidence: Some(format!(
1270 "production hot path observed with {} invocation{}",
1271 hot.invocations,
1272 crate::report::plural(hot.invocations as usize)
1273 )),
1274 },
1275 )
1276 })
1277 .collect();
1278 let findings = report
1279 .findings
1280 .iter()
1281 .map(runtime_finding_context)
1282 .collect();
1283 Self {
1284 hot_paths,
1285 findings,
1286 }
1287 }
1288}
1289
1290fn runtime_context_for_finding(
1291 finding: &SecurityFinding,
1292 spans: &[FunctionSpan],
1293 runtime: &SecurityRuntimeIndex,
1294) -> Option<SecurityRuntimeContext> {
1295 let path = path_key(&finding.path);
1296 let span = spans
1297 .iter()
1298 .filter(|span| {
1299 span.key.path == path && span.key.line <= finding.line && finding.line <= span.end_line
1300 })
1301 .min_by_key(|span| span.end_line.saturating_sub(span.key.line))?;
1302 if let Some((_, _, context)) = runtime.hot_paths.iter().find(|(key, end_line, _)| {
1303 key == &span.key && key.line <= finding.line && finding.line <= *end_line
1304 }) {
1305 return Some(context.clone());
1306 }
1307 runtime.findings.get(&span.key).cloned().or_else(|| {
1308 Some(SecurityRuntimeContext {
1309 state: SecurityRuntimeState::RuntimeUnknown,
1310 function: span.key.function.clone(),
1311 line: span.key.line,
1312 invocations: None,
1313 stable_id: None,
1314 evidence: Some("runtime coverage carried no matching function evidence".to_owned()),
1315 })
1316 })
1317}
1318
1319fn runtime_rank(finding: &SecurityFinding) -> u8 {
1320 match finding.runtime.as_ref().map(|runtime| runtime.state) {
1321 Some(SecurityRuntimeState::RuntimeHot) => 0,
1322 Some(SecurityRuntimeState::LowTraffic) => 1,
1323 None | Some(SecurityRuntimeState::RuntimeUnknown) => 2,
1324 Some(SecurityRuntimeState::CoverageUnavailable) => 3,
1325 Some(SecurityRuntimeState::RuntimeCold) => 4,
1326 Some(SecurityRuntimeState::NeverExecuted) => 5,
1327 }
1328}
1329
1330fn apply_security_severity(findings: &mut [SecurityFinding]) {
1331 for finding in findings {
1332 finding.severity = derive_security_severity(finding);
1333 }
1334}
1335
1336fn sort_by_security_severity(findings: &mut [SecurityFinding]) {
1337 findings.sort_by(compare_security_priority);
1338}
1339
1340fn compare_security_priority(left: &SecurityFinding, right: &SecurityFinding) -> Ordering {
1341 security_severity_rank(left.severity)
1342 .cmp(&security_severity_rank(right.severity))
1343 .then_with(|| runtime_rank(left).cmp(&runtime_rank(right)))
1344 .then_with(|| {
1345 right
1346 .reachability
1347 .as_ref()
1348 .is_some_and(|reach| reach.reachable_from_entry)
1349 .cmp(
1350 &left
1351 .reachability
1352 .as_ref()
1353 .is_some_and(|reach| reach.reachable_from_entry),
1354 )
1355 })
1356 .then_with(|| taint_rank(left).cmp(&taint_rank(right)))
1357 .then_with(|| security_blast_radius(right).cmp(&security_blast_radius(left)))
1358 .then_with(|| security_crosses_boundary(right).cmp(&security_crosses_boundary(left)))
1359 .then_with(|| left.dead_code.is_some().cmp(&right.dead_code.is_some()))
1360 .then_with(|| left.path.cmp(&right.path))
1361 .then_with(|| left.line.cmp(&right.line))
1362 .then_with(|| left.col.cmp(&right.col))
1363 .then_with(|| left.category.cmp(&right.category))
1364}
1365
1366fn taint_rank(finding: &SecurityFinding) -> u8 {
1367 match finding
1368 .reachability
1369 .as_ref()
1370 .and_then(|reach| reach.taint_confidence)
1371 {
1372 Some(TaintConfidence::ArgLevel) => 0,
1373 Some(TaintConfidence::ModuleLevel) => 1,
1374 None if finding.source_backed => 0,
1375 None if finding
1376 .reachability
1377 .as_ref()
1378 .is_some_and(|reach| reach.reachable_from_untrusted_source) =>
1379 {
1380 1
1381 }
1382 None => 2,
1383 }
1384}
1385
1386fn security_blast_radius(finding: &SecurityFinding) -> u32 {
1387 finding
1388 .reachability
1389 .as_ref()
1390 .map_or(0, |reach| reach.blast_radius)
1391}
1392
1393fn security_crosses_boundary(finding: &SecurityFinding) -> bool {
1394 finding
1395 .reachability
1396 .as_ref()
1397 .is_some_and(|reach| reach.crosses_boundary)
1398}
1399
1400const fn security_severity_rank(severity: SecuritySeverity) -> u8 {
1401 match severity {
1402 SecuritySeverity::High => 0,
1403 SecuritySeverity::Medium => 1,
1404 SecuritySeverity::Low => 2,
1405 }
1406}
1407
1408fn runtime_hot_key(hot: &RuntimeCoverageHotPath) -> RuntimeFunctionKey {
1409 RuntimeFunctionKey {
1410 path: path_key(&hot.path),
1411 function: hot.function.clone(),
1412 line: hot.line,
1413 }
1414}
1415
1416fn runtime_finding_context(
1417 finding: &RuntimeCoverageFinding,
1418) -> (RuntimeFunctionKey, SecurityRuntimeContext) {
1419 let state = match finding.verdict {
1420 RuntimeCoverageVerdict::SafeToDelete => SecurityRuntimeState::NeverExecuted,
1421 RuntimeCoverageVerdict::ReviewRequired if finding.invocations.unwrap_or(0) == 0 => {
1422 SecurityRuntimeState::RuntimeCold
1423 }
1424 RuntimeCoverageVerdict::LowTraffic => SecurityRuntimeState::LowTraffic,
1425 RuntimeCoverageVerdict::CoverageUnavailable | RuntimeCoverageVerdict::Unknown => {
1426 SecurityRuntimeState::CoverageUnavailable
1427 }
1428 RuntimeCoverageVerdict::ReviewRequired | RuntimeCoverageVerdict::Active => {
1429 SecurityRuntimeState::RuntimeUnknown
1430 }
1431 };
1432 (
1433 RuntimeFunctionKey {
1434 path: path_key(&finding.path),
1435 function: finding.function.clone(),
1436 line: finding.line,
1437 },
1438 SecurityRuntimeContext {
1439 state,
1440 function: finding.function.clone(),
1441 line: finding.line,
1442 invocations: finding.invocations,
1443 stable_id: finding.stable_id.clone(),
1444 evidence: Some(format!("runtime coverage verdict: {}", finding.verdict)),
1445 },
1446 )
1447}
1448
1449fn relative_key(path: &Path, root: &Path) -> String {
1450 path_key(path.strip_prefix(root).unwrap_or(path))
1451}
1452
1453fn path_key(path: &Path) -> String {
1454 path.to_string_lossy().replace('\\', "/")
1455}
1456
1457fn unresolved_callee_diagnostics(
1458 diagnostics: &[SecurityUnresolvedCalleeDiagnostic],
1459 root: &Path,
1460) -> Option<SecurityUnresolvedCalleeDiagnostics> {
1461 if diagnostics.is_empty() {
1462 return None;
1463 }
1464
1465 let mut sorted = diagnostics.to_vec();
1466 sorted.sort_by(|a, b| {
1467 a.path
1468 .cmp(&b.path)
1469 .then(a.line.cmp(&b.line))
1470 .then(a.col.cmp(&b.col))
1471 .then(a.reason.cmp(&b.reason))
1472 .then(a.expression_kind.cmp(&b.expression_kind))
1473 });
1474
1475 let sampled = sorted
1476 .iter()
1477 .take(UNRESOLVED_CALLEE_SAMPLE_LIMIT)
1478 .map(|diagnostic| SecurityUnresolvedCalleeSample {
1479 path: relative_key(&diagnostic.path, root),
1480 line: diagnostic.line,
1481 col: diagnostic.col,
1482 reason: diagnostic.reason,
1483 expression_kind: diagnostic.expression_kind,
1484 })
1485 .collect();
1486
1487 let mut by_file: BTreeMap<String, usize> = BTreeMap::new();
1488 let mut by_reason: BTreeMap<fallow_types::extract::SkippedSecurityCalleeReason, usize> =
1489 BTreeMap::new();
1490 for diagnostic in &sorted {
1491 *by_file
1492 .entry(relative_key(&diagnostic.path, root))
1493 .or_insert(0) += 1;
1494 *by_reason.entry(diagnostic.reason).or_insert(0) += 1;
1495 }
1496
1497 let mut top_files: Vec<_> = by_file
1498 .into_iter()
1499 .map(|(path, count)| SecurityUnresolvedCalleeTopFile { path, count })
1500 .collect();
1501 top_files.sort_by(|a, b| b.count.cmp(&a.count).then(a.path.cmp(&b.path)));
1502 top_files.truncate(UNRESOLVED_CALLEE_TOP_FILES_LIMIT);
1503
1504 let mut by_reason: Vec<_> = by_reason
1505 .into_iter()
1506 .map(|(reason, count)| SecurityUnresolvedCalleeReasonCount { reason, count })
1507 .collect();
1508 by_reason.sort_by(|a, b| b.count.cmp(&a.count).then(a.reason.cmp(&b.reason)));
1509
1510 Some(SecurityUnresolvedCalleeDiagnostics {
1511 sampled,
1512 top_files,
1513 by_reason,
1514 sample_limit: UNRESOLVED_CALLEE_SAMPLE_LIMIT,
1515 top_files_limit: UNRESOLVED_CALLEE_TOP_FILES_LIMIT,
1516 })
1517}
1518
1519fn filter_to_files(results: &mut AnalysisResults, root: &Path, files: &[PathBuf], quiet: bool) {
1520 if files.is_empty() {
1521 return;
1522 }
1523
1524 let resolved_files: Vec<PathBuf> = files
1525 .iter()
1526 .map(|path| {
1527 if crate::path_util::is_absolute_path_any_platform(path) {
1528 path.clone()
1529 } else {
1530 root.join(path)
1531 }
1532 })
1533 .collect();
1534
1535 if !quiet {
1536 for (original, resolved) in files.iter().zip(&resolved_files) {
1537 if !resolved.exists() {
1538 eprintln!(
1539 "Warning: --file '{}' (resolved to '{}') was not found in the project",
1540 original.display(),
1541 resolved.display()
1542 );
1543 }
1544 }
1545 }
1546
1547 let file_set: rustc_hash::FxHashSet<PathBuf> = resolved_files.into_iter().collect();
1548 fallow_engine::changed_files::filter_results_by_changed_files(results, &file_set);
1549}
1550
1551fn prepare_findings(
1552 findings: Vec<SecurityFinding>,
1553 root: &Path,
1554 include_surface: bool,
1555) -> (
1556 Vec<SecurityFinding>,
1557 Option<Vec<SecurityAttackSurfaceEntry>>,
1558) {
1559 let mut findings: Vec<SecurityFinding> = findings
1560 .into_iter()
1561 .map(|f| {
1562 let mut f = relativize_finding(f, root);
1563 f.finding_id = security_finding_id(&f);
1564 f
1565 })
1566 .collect();
1567 let attack_surface = include_surface.then(|| {
1568 findings
1569 .iter()
1570 .filter_map(|finding| finding.attack_surface.clone())
1571 .collect()
1572 });
1573 for finding in &mut findings {
1574 finding.attack_surface = None;
1575 }
1576 (findings, attack_surface)
1577}
1578
1579fn relativize_finding(mut finding: SecurityFinding, root: &Path) -> SecurityFinding {
1582 finding.path = relativize(&finding.path, root);
1583 for hop in &mut finding.trace {
1584 hop.path = relativize(&hop.path, root);
1585 }
1586 if let Some(reachability) = &mut finding.reachability {
1587 for hop in &mut reachability.untrusted_source_trace {
1588 hop.path = relativize(&hop.path, root);
1589 }
1590 }
1591 finding.candidate.sink.path = relativize(&finding.candidate.sink.path, root);
1592 if let Some(flow) = &mut finding.taint_flow {
1593 flow.source.path = relativize(&flow.source.path, root);
1594 flow.sink.path = relativize(&flow.sink.path, root);
1595 }
1596 if let Some(surface) = &mut finding.attack_surface {
1597 surface.source.path = relativize(&surface.source.path, root);
1598 surface.sink.path = relativize(&surface.sink.path, root);
1599 for hop in &mut surface.path {
1600 hop.path = relativize(&hop.path, root);
1601 }
1602 for control in &mut surface.defensive_boundary.controls {
1603 control.path = relativize(&control.path, root);
1604 }
1605 }
1606 finding
1607}
1608
1609fn relativize(path: &Path, root: &Path) -> PathBuf {
1610 path.strip_prefix(root)
1611 .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
1612}
1613
1614#[must_use]
1616pub fn render_json(output: &SecurityOutput) -> String {
1617 let Ok(value) = fallow_output::serialize_security_json_output(
1618 output.clone(),
1619 crate::output_runtime::current_root_envelope_mode(),
1620 crate::output_runtime::telemetry_analysis_run_id().as_deref(),
1621 ) else {
1622 return "{\"error\":\"failed to serialize security output\"}".to_owned();
1623 };
1624 serde_json::to_string_pretty(&value)
1625 .unwrap_or_else(|_| "{\"error\":\"failed to serialize security output\"}".to_owned())
1626}
1627
1628#[must_use]
1630pub fn render_json_summary(output: &SecurityOutput) -> String {
1631 let Ok(value) = fallow_output::serialize_security_summary_json_output(
1632 output,
1633 crate::output_runtime::current_root_envelope_mode(),
1634 None,
1635 ) else {
1636 return "{\"error\":\"failed to serialize security summary output\"}".to_owned();
1637 };
1638 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
1639 "{\"error\":\"failed to serialize security summary output\"}".to_owned()
1640 })
1641}
1642
1643fn render_survivors_output(
1644 output_format: OutputFormat,
1645 output: &SecuritySurvivorsOutput,
1646) -> String {
1647 match output_format {
1648 OutputFormat::Json => render_survivors_json(output),
1649 _ => render_survivors_human(output),
1650 }
1651}
1652
1653#[must_use]
1654pub fn render_survivors_json(output: &SecuritySurvivorsOutput) -> String {
1655 let Ok(value) = fallow_output::serialize_security_survivors_json_output(
1656 output.clone(),
1657 crate::output_runtime::current_root_envelope_mode(),
1658 ) else {
1659 return "{\"error\":\"failed to serialize security survivors output\"}".to_owned();
1660 };
1661 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
1662 "{\"error\":\"failed to serialize security survivors output\"}".to_owned()
1663 })
1664}
1665
1666#[must_use]
1667fn render_survivors_human(output: &SecuritySurvivorsOutput) -> String {
1668 use crate::report::plural;
1669 use std::fmt::Write as _;
1670
1671 let mut out = String::new();
1672 let _ = writeln!(
1673 out,
1674 "Security survivors: {} verifier-retained candidate{}.",
1675 output.summary.survivors,
1676 plural(output.summary.survivors)
1677 );
1678 let _ = writeln!(
1679 out,
1680 "Verdicts: {}/{} candidates covered, {} dismissed.",
1681 output.summary.verdicts, output.summary.candidates, output.summary.dismissed
1682 );
1683 if output.summary.needs_human_review > 0 {
1684 let _ = writeln!(
1685 out,
1686 "Needs human review: {} candidate{}.",
1687 output.summary.needs_human_review,
1688 plural(output.summary.needs_human_review)
1689 );
1690 }
1691 if output.summary.unverdicted > 0 {
1692 let _ = writeln!(
1693 out,
1694 "Unreviewed candidates: {} candidate{}.",
1695 output.summary.unverdicted,
1696 plural(output.summary.unverdicted)
1697 );
1698 }
1699 out.push_str(
1700 "Retained and human-review rows are verifier dispositions, not vulnerabilities proven by fallow.\n",
1701 );
1702 if output.summary.unverdicted > 0 {
1703 out.push_str("Unreviewed candidates have no verifier disposition yet.\n");
1704 }
1705
1706 if output.survivors.is_empty() && output.needs_human_review.is_empty() {
1707 if output.summary.unverdicted > 0 {
1708 out.push_str("\nNo retained or human-review details to show yet.\n");
1709 } else {
1710 out.push_str("\nNo retained candidate details to show.\n");
1711 }
1712 return out;
1713 }
1714
1715 push_survivor_group(&mut out, "Survivors", &output.survivors);
1716 push_survivor_group(&mut out, "Needs human review", &output.needs_human_review);
1717 out
1718}
1719
1720fn push_survivor_group(
1721 out: &mut String,
1722 title: &str,
1723 survivors: &BTreeMap<String, SecuritySurvivor>,
1724) {
1725 use std::fmt::Write as _;
1726
1727 if survivors.is_empty() {
1728 return;
1729 }
1730 let _ = writeln!(out, "\n{title}:");
1731 for survivor in survivors.values() {
1732 let path = survivor.candidate.path.to_string_lossy().replace('\\', "/");
1733 let line = survivor.candidate.line;
1734 let category = survivor
1735 .candidate
1736 .category
1737 .as_deref()
1738 .unwrap_or_else(|| security_kind_key(survivor.candidate.kind));
1739 let _ = writeln!(
1740 out,
1741 "- {}:{} ({}) [{}]",
1742 path, line, category, survivor.finding_id
1743 );
1744 if let Some(reason) = survivor.reason.as_ref().or(survivor.rationale.as_ref()) {
1745 let _ = writeln!(out, " reason: {reason}");
1746 }
1747 if let Some(impact) = &survivor.impact {
1748 let _ = writeln!(out, " impact: {impact}");
1749 }
1750 if let Some(fix_direction) = &survivor.fix_direction {
1751 let _ = writeln!(out, " fix direction: {fix_direction}");
1752 }
1753 out.push_str(" Next: review the original candidate evidence before editing code.\n");
1754 }
1755}
1756
1757fn build_blind_spots_output(output: &SecurityOutput) -> SecurityBlindSpotsOutput {
1758 let diagnostics = output.unresolved_callee_diagnostics.as_ref();
1759 let groups = diagnostics
1760 .map(group_blind_spot_samples)
1761 .unwrap_or_default();
1762 let sampled_callee_sites = diagnostics.map_or(0, |diagnostics| diagnostics.sampled.len());
1763 let unresolved_callee_sites =
1764 diagnostics.map_or(output.unresolved_callee_sites, |diagnostics| {
1765 diagnostics
1766 .by_reason
1767 .iter()
1768 .map(|reason| reason.count)
1769 .sum()
1770 });
1771
1772 SecurityBlindSpotsOutput {
1773 schema_version: SecurityBlindSpotsSchemaVersion::V1,
1774 version: output.version.clone(),
1775 elapsed_ms: output.elapsed_ms,
1776 summary: SecurityBlindSpotsSummary {
1777 unresolved_edge_files: output.unresolved_edge_files,
1778 unresolved_callee_sites,
1779 sampled_callee_sites,
1780 },
1781 groups,
1782 }
1783}
1784
1785fn group_blind_spot_samples(
1786 diagnostics: &SecurityUnresolvedCalleeDiagnostics,
1787) -> Vec<SecurityBlindSpotGroup> {
1788 let mut groups: BTreeMap<
1789 (
1790 fallow_types::extract::SkippedSecurityCalleeReason,
1791 fallow_types::extract::SkippedSecurityCalleeExpressionKind,
1792 ),
1793 BTreeMap<String, usize>,
1794 > = BTreeMap::new();
1795
1796 for sample in &diagnostics.sampled {
1797 let files = groups
1798 .entry((sample.reason, sample.expression_kind))
1799 .or_default();
1800 *files.entry(sample.path.clone()).or_insert(0) += 1;
1801 }
1802
1803 let mut groups: Vec<SecurityBlindSpotGroup> = groups
1804 .into_iter()
1805 .map(|((reason, expression_kind), files)| {
1806 let sampled_count = files.values().sum();
1807 let mut files: Vec<SecurityBlindSpotFile> = files
1808 .into_iter()
1809 .map(|(path, sampled_count)| SecurityBlindSpotFile {
1810 path,
1811 sampled_count,
1812 })
1813 .collect();
1814 files.sort_by(|a, b| {
1815 b.sampled_count
1816 .cmp(&a.sampled_count)
1817 .then_with(|| a.path.cmp(&b.path))
1818 });
1819 SecurityBlindSpotGroup {
1820 reason,
1821 expression_kind,
1822 sampled_count,
1823 files,
1824 suggestion: blind_spot_suggestion(reason).to_owned(),
1825 }
1826 })
1827 .collect();
1828
1829 groups.sort_by(|a, b| {
1830 b.sampled_count
1831 .cmp(&a.sampled_count)
1832 .then_with(|| {
1833 unresolved_callee_reason_label(a.reason)
1834 .cmp(unresolved_callee_reason_label(b.reason))
1835 })
1836 .then_with(|| {
1837 unresolved_callee_expression_label(a.expression_kind)
1838 .cmp(unresolved_callee_expression_label(b.expression_kind))
1839 })
1840 });
1841 groups
1842}
1843
1844fn render_blind_spots_output(
1845 output_format: OutputFormat,
1846 output: &SecurityBlindSpotsOutput,
1847) -> String {
1848 match output_format {
1849 OutputFormat::Json => render_blind_spots_json(output),
1850 _ => render_blind_spots_human(output),
1851 }
1852}
1853
1854#[must_use]
1855pub fn render_blind_spots_json(output: &SecurityBlindSpotsOutput) -> String {
1856 let Ok(value) = fallow_output::serialize_security_blind_spots_json_output(
1857 output.clone(),
1858 crate::output_runtime::current_root_envelope_mode(),
1859 ) else {
1860 return "{\"error\":\"failed to serialize security blind-spots output\"}".to_owned();
1861 };
1862 serde_json::to_string_pretty(&value).unwrap_or_else(|_| {
1863 "{\"error\":\"failed to serialize security blind-spots output\"}".to_owned()
1864 })
1865}
1866
1867#[must_use]
1868fn render_blind_spots_human(output: &SecurityBlindSpotsOutput) -> String {
1869 use crate::report::plural;
1870 use std::fmt::Write as _;
1871
1872 let mut out = String::new();
1873 let callee_count = output.summary.unresolved_callee_sites;
1874 let edge_count = output.summary.unresolved_edge_files;
1875 if callee_count == 0 && edge_count == 0 {
1876 out.push_str("Security blind spots: no unresolved security edges or callees found.\n");
1877 return out;
1878 }
1879
1880 let _ = writeln!(
1881 out,
1882 "Security blind spots: {callee_count} unresolved callee{} and {edge_count} unresolved client import edge{}.",
1883 plural(callee_count),
1884 plural(edge_count)
1885 );
1886 out.push_str("A non-zero blind-spot count means fallow may have missed security candidates behind dynamic code shapes.\n");
1887
1888 for group in &output.groups {
1889 let reason = unresolved_callee_reason_label(group.reason);
1890 let expression = unresolved_callee_expression_label(group.expression_kind);
1891 let _ = writeln!(
1892 out,
1893 "\n{} Blind spot: {reason} / {expression}, {} sampled site{}.",
1894 "[I]".blue().bold(),
1895 group.sampled_count,
1896 plural(group.sampled_count)
1897 );
1898 for file in group.files.iter().take(3) {
1899 let _ = writeln!(out, " {} ({})", file.path, file.sampled_count);
1900 }
1901 let _ = writeln!(out, " Next: {}", group.suggestion);
1902 }
1903
1904 out
1905}
1906
1907fn unresolved_callee_expression_label(
1908 expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind,
1909) -> &'static str {
1910 match expression_kind {
1911 fallow_types::extract::SkippedSecurityCalleeExpressionKind::ComputedMemberExpression => {
1912 "computed-member"
1913 }
1914 fallow_types::extract::SkippedSecurityCalleeExpressionKind::Identifier => "identifier",
1915 fallow_types::extract::SkippedSecurityCalleeExpressionKind::StaticMemberExpression => {
1916 "member-expression"
1917 }
1918 fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other => "other",
1919 }
1920}
1921
1922fn blind_spot_suggestion(
1923 reason: fallow_types::extract::SkippedSecurityCalleeReason,
1924) -> &'static str {
1925 match reason {
1926 fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember => {
1927 "inspect computed property names or convert hot sinks to explicit calls."
1928 }
1929 fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch => {
1930 "inspect dynamic dispatch targets and add a narrow wrapper or catalogue shape if the sink is real."
1931 }
1932 fallow_types::extract::SkippedSecurityCalleeReason::UnsupportedAssignmentObject => {
1933 "inspect assignment targets and simplify the object shape if security sink calls are hidden there."
1934 }
1935 }
1936}
1937
1938fn write_sarif_file(output: &SecurityOutput, path: &Path) -> Result<(), String> {
1939 if let Some(parent) = path.parent()
1940 && !parent.as_os_str().is_empty()
1941 {
1942 std::fs::create_dir_all(parent).map_err(|err| {
1943 format!(
1944 "Failed to create directory for SARIF file {}: {err}",
1945 path.display()
1946 )
1947 })?;
1948 }
1949 std::fs::write(path, render_sarif(output))
1950 .map_err(|err| format!("Failed to write SARIF file {}: {err}", path.display()))
1951}
1952
1953fn gate_human_header(gate: &SecurityGate) -> String {
1958 use crate::report::plural;
1959 let checked = match gate.mode {
1960 SecurityGateMode::New => "in changed lines",
1961 SecurityGateMode::NewlyReachable => "newly reachable from entry points",
1962 };
1963 match gate.verdict {
1964 SecurityGateVerdict::Fail => format!(
1965 "Gate: REVIEW REQUIRED, {} new security item{} {checked}. fallow has not confirmed a vulnerability.",
1966 gate.new_count,
1967 plural(gate.new_count),
1968 ),
1969 SecurityGateVerdict::Pass => {
1970 format!("Gate: PASS, no new security items {checked}.")
1971 }
1972 }
1973}
1974
1975fn unresolved_callee_human_hint(output: &SecurityOutput) -> Option<String> {
1976 let diagnostics = output.unresolved_callee_diagnostics.as_ref()?;
1977 let top_reason = diagnostics.by_reason.first()?;
1978 let top_file = diagnostics.top_files.first()?;
1979 Some(format!(
1980 "Most unresolved callees: {} in {}.",
1981 unresolved_callee_reason_label(top_reason.reason),
1982 top_file.path
1983 ))
1984}
1985
1986fn unresolved_callee_reason_label(
1987 reason: fallow_types::extract::SkippedSecurityCalleeReason,
1988) -> &'static str {
1989 match reason {
1990 fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember => "computed-member",
1991 fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch => "dynamic-dispatch",
1992 fallow_types::extract::SkippedSecurityCalleeReason::UnsupportedAssignmentObject => {
1993 "unsupported-assignment-object"
1994 }
1995 }
1996}
1997
1998#[must_use]
1999fn render_human_summary(output: &SecurityOutput) -> String {
2000 use crate::report::plural;
2001 use std::fmt::Write as _;
2002
2003 let mut out = String::new();
2004 if let Some(gate) = &output.gate {
2005 out.push_str(&gate_human_header(gate));
2006 out.push('\n');
2007 }
2008 let count = output.security_findings.len();
2009 if count == 0 {
2010 out.push_str("Security review: no items to check in the scanned code.\n");
2011 } else {
2012 let _ = writeln!(
2013 out,
2014 "Security review: {count} item{} to check. These are unverified security candidates, not confirmed vulnerabilities.",
2015 plural(count),
2016 );
2017 out.push_str(
2018 "Next: check whether the listed code can run with unsafe input, secrets, or settings, and whether anything blocks the risk.\n",
2019 );
2020 }
2021 if output.unresolved_edge_files > 0 {
2022 let n = output.unresolved_edge_files;
2023 let verb = if n == 1 { "uses" } else { "use" };
2024 let _ = writeln!(
2025 out,
2026 "Blind spot: {n} client file{} {verb} dynamic imports that fallow could not follow.",
2027 plural(n)
2028 );
2029 }
2030 if output.unresolved_callee_sites > 0 {
2031 let n = output.unresolved_callee_sites;
2032 let verb = if n == 1 { "uses" } else { "use" };
2033 let _ = writeln!(
2034 out,
2035 "Blind spot: {n} call site{} {verb} code patterns that fallow could not resolve.",
2036 plural(n)
2037 );
2038 if let Some(hint) = unresolved_callee_human_hint(output) {
2039 let _ = writeln!(out, "{hint}");
2040 }
2041 }
2042 out
2043}
2044
2045#[must_use]
2048#[expect(
2049 clippy::format_push_string,
2050 reason = "small report renderer; readability over avoiding the extra allocation"
2051)]
2052pub fn render_human(output: &SecurityOutput) -> String {
2053 use crate::report::plural;
2054
2055 let mut out = String::new();
2056 push_human_gate(&mut out, output);
2057 let count = output.security_findings.len();
2058 out.push_str(&format!("Security review: {count} item{}", plural(count)));
2059 if count == 0 {
2060 out.push_str(" to check in the scanned code.\n");
2061 } else {
2062 out.push_str(" to check.\n");
2063 out.push_str(
2064 "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",
2065 );
2066 }
2067 out.push('\n');
2068
2069 if output.security_findings.is_empty() {
2070 out.push_str("No security details to show.\n");
2071 } else {
2072 push_human_findings(&mut out, &output.security_findings);
2073 }
2074
2075 push_human_blind_spots(&mut out, output);
2076
2077 out.push_str(&format!(
2078 "\nResult: {count} security item{} to check.",
2079 plural(count),
2080 ));
2081 if count > 0 {
2082 out.push_str(" Review the listed evidence and trace before changing code.");
2083 }
2084 out.push('\n');
2085 out
2086}
2087
2088fn push_human_gate(out: &mut String, output: &SecurityOutput) {
2089 if let Some(gate) = &output.gate {
2090 out.push_str(&gate_human_header(gate));
2091 out.push_str("\n\n");
2092 }
2093}
2094
2095fn push_human_findings(out: &mut String, findings: &[SecurityFinding]) {
2096 for finding in findings {
2097 push_human_finding(out, finding);
2098 }
2099}
2100
2101fn push_human_finding(out: &mut String, finding: &SecurityFinding) {
2102 use std::fmt::Write as _;
2103
2104 push_human_finding_header(out, finding);
2105 let _ = writeln!(out, " evidence: {}", finding.evidence);
2106 if let Some(hint) = dead_code_hint(finding) {
2107 let _ = writeln!(out, " dead-code: {hint}");
2108 }
2109 if let Some(runtime) = finding.runtime.as_ref() {
2110 let _ = writeln!(out, " runtime: {}", runtime_hint_text(runtime));
2111 }
2112 push_human_reachability(out, finding);
2113 push_human_import_trace(out, finding);
2114 push_human_next_step(out, finding);
2115 out.push('\n');
2116}
2117
2118fn push_human_finding_header(out: &mut String, finding: &SecurityFinding) {
2119 use colored::Colorize;
2120 use std::fmt::Write as _;
2121
2122 let kind = security_finding_label(finding);
2123 let (glyph, label) = human_severity_marker(finding.severity);
2124 let _ = writeln!(
2125 out,
2126 "{} {label} {kind} {}:{}",
2127 glyph,
2128 finding.path.to_string_lossy().replace('\\', "/").bold(),
2129 finding.line,
2130 );
2131}
2132
2133fn push_human_reachability(out: &mut String, finding: &SecurityFinding) {
2134 use std::fmt::Write as _;
2135
2136 let Some(reach) = finding.reachability.as_ref() else {
2137 return;
2138 };
2139 let entry = if reach.reachable_from_entry {
2140 "reachable from a runtime entry point"
2141 } else {
2142 "not reached from any runtime entry point"
2143 };
2144 let boundary = if reach.crosses_boundary {
2145 "; crosses an architecture boundary"
2146 } else {
2147 ""
2148 };
2149 let _ = writeln!(
2150 out,
2151 " code path: {entry} (blast radius {}){boundary}",
2152 reach.blast_radius,
2153 );
2154 if reach.reachable_from_untrusted_source {
2155 push_human_untrusted_trace(out, finding);
2156 }
2157}
2158
2159fn push_human_untrusted_trace(out: &mut String, finding: &SecurityFinding) {
2160 use std::fmt::Write as _;
2161
2162 let Some(reach) = finding.reachability.as_ref() else {
2163 return;
2164 };
2165 let hops = reach.untrusted_source_hop_count.unwrap_or(0);
2166 let _ = writeln!(
2167 out,
2168 " input path: this module is reachable from a module that receives \
2169 untrusted input via {hops} import hop{}",
2170 crate::report::plural(hops as usize),
2171 );
2172 if !reach.untrusted_source_trace.is_empty() {
2173 out.push_str(" input import trace:\n");
2174 for hop in &reach.untrusted_source_trace {
2175 let _ = writeln!(
2176 out,
2177 " {}:{} ({})",
2178 hop.path.to_string_lossy().replace('\\', "/"),
2179 hop.line,
2180 hop_role_label(hop.role),
2181 );
2182 }
2183 }
2184}
2185
2186fn push_human_import_trace(out: &mut String, finding: &SecurityFinding) {
2187 use std::fmt::Write as _;
2188
2189 if finding.trace.is_empty() {
2190 return;
2191 }
2192 out.push_str(" import trace:\n");
2193 for hop in &finding.trace {
2194 let _ = writeln!(
2195 out,
2196 " {}:{} ({})",
2197 hop.path.to_string_lossy().replace('\\', "/"),
2198 hop.line,
2199 hop_role_label(hop.role),
2200 );
2201 }
2202}
2203
2204fn push_human_next_step(out: &mut String, finding: &SecurityFinding) {
2205 if is_server_only_leak(finding) {
2206 out.push_str(
2207 " Next: check whether this server-only code is meant to run on the client. \
2208 If it is pulled in only through next/dynamic(..., { ssr: false }), type-only, \
2209 or removed at build time, mark it as a false positive.\n",
2210 );
2211 } else if matches!(finding.kind, SecurityFindingKind::ClientServerLeak) {
2212 out.push_str(
2213 " Next: check whether this import can ship a secret to the browser. If \
2214 it is type-only, server-only, or removed at build time, mark it as a false \
2215 positive.\n",
2216 );
2217 } else if finding.dead_code.is_some() {
2218 out.push_str(
2219 " Next: first verify the dead-code finding. If the code is safe to \
2220 remove, delete it. Otherwise check and harden the risky call.\n",
2221 );
2222 } else {
2223 out.push_str(
2224 " Next: check whether unsafe input, secrets, or settings can reach this \
2225 risky call without a safe guard. If not, mark it as a false positive.\n",
2226 );
2227 }
2228}
2229
2230fn push_human_blind_spots(out: &mut String, output: &SecurityOutput) {
2231 use crate::report::plural;
2232 use std::fmt::Write as _;
2233
2234 if output.unresolved_edge_files > 0 {
2235 let n = output.unresolved_edge_files;
2236 let verb = if n == 1 { "uses" } else { "use" };
2237 let _ = writeln!(
2238 out,
2239 "{} Blind spot: {n} client file{} {verb} dynamic imports that fallow could not \
2240 follow. Code behind those imports may be missing from this report.",
2241 "[I]".blue().bold(),
2242 plural(n),
2243 );
2244 }
2245
2246 if output.unresolved_callee_sites > 0 {
2247 let n = output.unresolved_callee_sites;
2248 let verb = if n == 1 { "uses" } else { "use" };
2249 let _ = writeln!(
2250 out,
2251 "{} Blind spot: {n} call site{} {verb} code patterns that fallow could not resolve, \
2252 such as dynamic dispatch, computed members, or aliased bindings.",
2253 "[I]".blue().bold(),
2254 plural(n),
2255 );
2256 if let Some(hint) = unresolved_callee_human_hint(output) {
2257 let _ = writeln!(out, " {hint}");
2258 }
2259 }
2260}
2261
2262fn security_finding_label(finding: &SecurityFinding) -> String {
2267 match finding.kind {
2268 SecurityFindingKind::ClientServerLeak if is_server_only_leak(finding) => {
2269 "server-only-import".to_string()
2270 }
2271 SecurityFindingKind::ClientServerLeak => "client-server-leak".to_string(),
2272 SecurityFindingKind::TaintedSink => {
2273 let title = finding
2274 .category
2275 .as_deref()
2276 .and_then(security_catalogue_title)
2277 .or(finding.category.as_deref())
2278 .unwrap_or("tainted-sink");
2279 match finding.cwe {
2280 Some(cwe) => format!("{title} (CWE-{cwe})"),
2281 None => title.to_string(),
2282 }
2283 }
2284 }
2285}
2286
2287fn human_severity_marker(severity: SecuritySeverity) -> (colored::ColoredString, &'static str) {
2288 use colored::Colorize;
2289 match severity {
2290 SecuritySeverity::High => ("[H]".red().bold(), "high"),
2291 SecuritySeverity::Medium => ("[M]".yellow().bold(), "medium"),
2292 SecuritySeverity::Low => ("[L]".blue().bold(), "low"),
2293 }
2294}
2295
2296fn dead_code_hint(finding: &SecurityFinding) -> Option<String> {
2297 let context = finding.dead_code.as_ref()?;
2298 match context.kind {
2299 SecurityDeadCodeKind::UnusedFile => Some(
2300 "also reported as unused-file; delete this file instead of hardening the sink"
2301 .to_string(),
2302 ),
2303 SecurityDeadCodeKind::UnusedExport => Some(format!(
2304 "also reported as unused-export{}; remove the export instead of hardening the sink",
2305 context
2306 .export_name
2307 .as_ref()
2308 .map_or(String::new(), |name| format!(" `{name}`"))
2309 )),
2310 }
2311}
2312
2313const fn hop_role_label(role: TraceHopRole) -> &'static str {
2314 match role {
2315 TraceHopRole::ClientBoundary => "client boundary",
2316 TraceHopRole::UntrustedSource => "untrusted source",
2317 TraceHopRole::ModuleSource => "source module",
2318 TraceHopRole::Intermediate => "intermediate",
2319 TraceHopRole::SecretSource => "secret source",
2320 TraceHopRole::Sink => "sink site",
2321 }
2322}
2323
2324fn source_reachability_hint(finding: &SecurityFinding) -> Option<&'static str> {
2325 finding
2326 .reachability
2327 .as_ref()
2328 .filter(|reach| reach.reachable_from_untrusted_source)
2329 .map(|_| {
2330 "Module-level context: the sink module is reachable from an untrusted-source module; fallow does not prove value flow."
2331 })
2332}
2333
2334fn runtime_hint_text(runtime: &SecurityRuntimeContext) -> String {
2335 use std::fmt::Write as _;
2336
2337 let mut text = format!(
2338 "{} in {}:{}",
2339 runtime_state_label(runtime.state),
2340 runtime.function,
2341 runtime.line
2342 );
2343 if let Some(invocations) = runtime.invocations {
2344 let _ = write!(
2345 text,
2346 " ({} invocation{})",
2347 invocations,
2348 crate::report::plural(invocations as usize)
2349 );
2350 }
2351 if let Some(evidence) = runtime.evidence.as_deref() {
2352 text.push_str("; ");
2353 text.push_str(evidence);
2354 }
2355 text
2356}
2357
2358const fn runtime_state_label(state: SecurityRuntimeState) -> &'static str {
2359 match state {
2360 SecurityRuntimeState::RuntimeHot => "runtime-hot",
2361 SecurityRuntimeState::RuntimeCold => "runtime-cold",
2362 SecurityRuntimeState::NeverExecuted => "never-executed",
2363 SecurityRuntimeState::LowTraffic => "low-traffic",
2364 SecurityRuntimeState::CoverageUnavailable => "coverage-unavailable",
2365 SecurityRuntimeState::RuntimeUnknown => "runtime-unknown",
2366 }
2367}
2368
2369const SERVER_ONLY_CATEGORY: &str = "server-only-import";
2373
2374fn is_server_only_leak(finding: &SecurityFinding) -> bool {
2378 matches!(finding.kind, SecurityFindingKind::ClientServerLeak)
2379 && finding.category.as_deref() == Some(SERVER_ONLY_CATEGORY)
2380}
2381
2382fn sarif_rule_id(finding: &SecurityFinding) -> String {
2388 match finding.kind {
2389 SecurityFindingKind::ClientServerLeak if is_server_only_leak(finding) => {
2390 "security/server-only-import".to_owned()
2391 }
2392 SecurityFindingKind::ClientServerLeak => "security/client-server-leak".to_owned(),
2393 SecurityFindingKind::TaintedSink => {
2394 format!(
2395 "security/{}",
2396 finding.category.as_deref().unwrap_or("tainted-sink")
2397 )
2398 }
2399 }
2400}
2401
2402fn security_help_text(title: &str) -> String {
2403 format!(
2404 "Verify this unverified {title} candidate before acting. Review the source, sink, \
2405 SARIF code flow, and any runtime or dead-code context. fallow does not prove \
2406 exploitability, attacker control, or missing sanitization."
2407 )
2408}
2409
2410fn security_help_markdown(title: &str) -> String {
2411 format!(
2412 "Verify this unverified **{title}** candidate before acting.\n\n\
2413 1. Review the source and sink in the SARIF code flow.\n\
2414 2. Confirm whether attacker-controlled data can reach the sink unsanitized.\n\
2415 3. Use runtime and dead-code context only as triage signals."
2416 )
2417}
2418
2419fn cwe_taxon_id(cwe: u32) -> String {
2420 format!("CWE-{cwe}")
2421}
2422
2423fn cwe_taxon(cwe: u32) -> serde_json::Value {
2424 let id = cwe_taxon_id(cwe);
2425 serde_json::json!({
2426 "id": id,
2427 "name": id,
2428 "shortDescription": { "text": format!("Common Weakness Enumeration {id}") },
2429 "fullDescription": { "text": format!("MITRE Common Weakness Enumeration {id}") },
2430 "helpUri": format!("https://cwe.mitre.org/data/definitions/{cwe}.html")
2431 })
2432}
2433
2434fn cwe_relationship(cwe: u32, taxon_index: usize) -> serde_json::Value {
2435 serde_json::json!({
2436 "target": {
2437 "id": cwe_taxon_id(cwe),
2438 "index": taxon_index,
2439 "toolComponent": {
2440 "name": "CWE",
2441 "index": 0
2442 }
2443 },
2444 "kinds": ["superset"]
2445 })
2446}
2447
2448fn collect_cwes(findings: &[SecurityFinding]) -> Vec<u32> {
2449 let mut cwes: Vec<u32> = findings.iter().filter_map(|finding| finding.cwe).collect();
2450 cwes.sort_unstable();
2451 cwes.dedup();
2452 cwes
2453}
2454
2455fn cwe_index(cwes: &[u32], cwe: u32) -> Option<usize> {
2456 cwes.iter().position(|existing| *existing == cwe)
2457}
2458
2459fn cwe_taxonomy(cwes: &[u32]) -> Option<serde_json::Value> {
2460 if cwes.is_empty() {
2461 return None;
2462 }
2463 let taxa = cwes.iter().map(|cwe| cwe_taxon(*cwe)).collect::<Vec<_>>();
2464 Some(serde_json::json!({
2465 "name": "CWE",
2466 "fullName": "Common Weakness Enumeration",
2467 "organization": "MITRE",
2468 "informationUri": "https://cwe.mitre.org/",
2469 "taxa": taxa
2470 }))
2471}
2472
2473fn sarif_rule_def(
2477 rule_id: &str,
2478 finding: &SecurityFinding,
2479 cwe_taxon_index: Option<usize>,
2480) -> serde_json::Value {
2481 match finding.kind {
2482 SecurityFindingKind::ClientServerLeak if is_server_only_leak(finding) => {
2483 sarif_rule_def_server_only_leak(rule_id)
2484 }
2485 SecurityFindingKind::ClientServerLeak => sarif_rule_def_secret_leak(rule_id),
2486 SecurityFindingKind::TaintedSink => {
2487 sarif_rule_def_tainted_sink(rule_id, finding, cwe_taxon_index)
2488 }
2489 }
2490}
2491
2492fn sarif_rule_def_server_only_leak(rule_id: &str) -> serde_json::Value {
2494 let title = "Client imports server-only code";
2495 serde_json::json!({
2496 "id": rule_id,
2497 "name": title,
2498 "shortDescription": { "text": "Client imports server-only code candidate (unverified)" },
2499 "fullDescription": { "text":
2500 "Unverified candidate, requires verification: a \"use client\" file \
2501 transitively imports a server-only module (one carrying a \"use server\" \
2502 directive or importing server-only code such as server-only, next/headers, \
2503 next/server, or node:fs / node:child_process). fallow does not prove this \
2504 code runs on the client; a module pulled in only through \
2505 next/dynamic(..., { ssr: false }) is a false positive." },
2506 "help": {
2507 "text": security_help_text(title),
2508 "markdown": security_help_markdown(title)
2509 },
2510 "helpUri": "https://github.com/fallow-rs/fallow",
2511 "defaultConfiguration": { "level": "note" }
2512 })
2513}
2514
2515fn sarif_rule_def_secret_leak(rule_id: &str) -> serde_json::Value {
2517 let title = "Client-server secret leak";
2518 serde_json::json!({
2519 "id": rule_id,
2520 "name": title,
2521 "shortDescription": { "text": "Client-server secret leak candidate (unverified)" },
2522 "fullDescription": { "text":
2523 "Unverified candidate, requires verification: a \"use client\" file \
2524 transitively imports a module that reads a non-public process.env \
2525 secret. fallow does not prove the secret reaches client-bundled code." },
2526 "help": {
2527 "text": security_help_text(title),
2528 "markdown": security_help_markdown(title)
2529 },
2530 "helpUri": "https://github.com/fallow-rs/fallow",
2531 "defaultConfiguration": { "level": "note" }
2532 })
2533}
2534
2535fn sarif_rule_def_tainted_sink(
2538 rule_id: &str,
2539 finding: &SecurityFinding,
2540 cwe_taxon_index: Option<usize>,
2541) -> serde_json::Value {
2542 let title = finding
2543 .category
2544 .as_deref()
2545 .and_then(security_catalogue_title)
2546 .or(finding.category.as_deref())
2547 .unwrap_or("tainted-sink");
2548 let mut rule = serde_json::json!({
2549 "id": rule_id,
2550 "name": title,
2551 "shortDescription": { "text": format!("{title} candidate (unverified)") },
2552 "fullDescription": { "text": format!(
2553 "Unverified candidate, requires verification: {title}. fallow flags a \
2554 syntactic sink reached by a non-literal argument; it does not prove the \
2555 value is attacker-controlled or reaches the sink unsanitized."
2556 ) },
2557 "help": {
2558 "text": security_help_text(title),
2559 "markdown": security_help_markdown(title)
2560 },
2561 "helpUri": "https://github.com/fallow-rs/fallow",
2562 "defaultConfiguration": { "level": "note" }
2563 });
2564 if let Some(cwe) = finding.cwe {
2565 rule["properties"] = serde_json::json!({
2566 "tags": [format!("external/cwe/cwe-{cwe}")]
2567 });
2568 if let Some(taxon_index) = cwe_taxon_index {
2569 rule["relationships"] = serde_json::json!([cwe_relationship(cwe, taxon_index)]);
2570 }
2571 }
2572 rule
2573}
2574
2575fn hop_role_token(role: TraceHopRole) -> &'static str {
2576 match role {
2577 TraceHopRole::ClientBoundary => "client-boundary",
2578 TraceHopRole::UntrustedSource => "untrusted-source",
2579 TraceHopRole::ModuleSource => "module-source",
2580 TraceHopRole::Intermediate => "intermediate",
2581 TraceHopRole::SecretSource => "secret-source",
2582 TraceHopRole::Sink => "sink",
2583 }
2584}
2585
2586fn sarif_thread_flow_location(hop: &TraceHop) -> serde_json::Value {
2587 let role = hop_role_token(hop.role);
2588 serde_json::json!({
2589 "location": sarif_location(&hop.path, hop.line, hop.col),
2590 "kinds": [role],
2591 "properties": { "fallowTraceRole": role }
2592 })
2593}
2594
2595fn primary_code_flow_hops(finding: &SecurityFinding) -> &[TraceHop] {
2596 if let Some(reachability) = finding.reachability.as_ref()
2597 && !reachability.untrusted_source_trace.is_empty()
2598 {
2599 return &reachability.untrusted_source_trace;
2600 }
2601 &finding.trace
2602}
2603
2604fn sarif_code_flows(finding: &SecurityFinding) -> Option<serde_json::Value> {
2605 let hops = primary_code_flow_hops(finding);
2606 if hops.is_empty() {
2607 return None;
2608 }
2609 let locations = hops
2610 .iter()
2611 .map(sarif_thread_flow_location)
2612 .collect::<Vec<_>>();
2613 Some(serde_json::json!([
2614 {
2615 "threadFlows": [
2616 { "locations": locations }
2617 ]
2618 }
2619 ]))
2620}
2621
2622fn push_related_location(related: &mut Vec<serde_json::Value>, hop: &TraceHop) {
2623 let location = sarif_location(&hop.path, hop.line, hop.col);
2624 if !related.iter().any(|existing| existing == &location) {
2625 related.push(location);
2626 }
2627}
2628
2629fn sarif_related_locations(finding: &SecurityFinding) -> Vec<serde_json::Value> {
2630 let mut related = Vec::new();
2631 for hop in &finding.trace {
2632 push_related_location(&mut related, hop);
2633 }
2634 if let Some(reachability) = finding.reachability.as_ref() {
2635 for hop in &reachability.untrusted_source_trace {
2636 push_related_location(&mut related, hop);
2637 }
2638 }
2639 related
2640}
2641
2642const fn sarif_level(severity: SecuritySeverity) -> &'static str {
2643 match severity {
2644 SecuritySeverity::High | SecuritySeverity::Medium => "warning",
2645 SecuritySeverity::Low => "note",
2646 }
2647}
2648
2649fn sarif_result_for_finding(finding: &SecurityFinding) -> serde_json::Value {
2652 let rule_id = sarif_rule_id(finding);
2653 let mut message = dead_code_hint(finding).map_or_else(
2654 || finding.evidence.clone(),
2655 |hint| format!("{} Dead-code cross-link: {hint}.", finding.evidence),
2656 );
2657 if let Some(hint) = source_reachability_hint(finding) {
2658 message.push(' ');
2659 message.push_str(hint);
2660 }
2661 if let Some(runtime) = finding.runtime.as_ref() {
2662 message.push_str(" Runtime context: ");
2663 message.push_str(&runtime_hint_text(runtime));
2664 message.push('.');
2665 }
2666 let related = sarif_related_locations(finding);
2667 let mut result = serde_json::json!({
2672 "ruleId": rule_id,
2673 "level": sarif_level(finding.severity),
2674 "message": { "text": message },
2675 "locations": [sarif_location(&finding.path, finding.line, finding.col)],
2676 "relatedLocations": related,
2677 "partialFingerprints": { "fallowSecurity/v1": security_finding_id(finding) },
2678 });
2679 if let Some(code_flows) = sarif_code_flows(finding) {
2680 result["codeFlows"] = code_flows;
2681 }
2682 result
2683}
2684
2685fn sarif_rule_defs(findings: &[SecurityFinding], cwes: &[u32]) -> Vec<serde_json::Value> {
2688 let mut seen: Vec<String> = Vec::new();
2689 let mut rules: Vec<serde_json::Value> = Vec::new();
2690 for finding in findings {
2691 let rule_id = sarif_rule_id(finding);
2692 if seen.iter().any(|s| s == &rule_id) {
2693 continue;
2694 }
2695 seen.push(rule_id.clone());
2696 let cwe_taxon_index = finding.cwe.and_then(|cwe| cwe_index(cwes, cwe));
2697 rules.push(sarif_rule_def(&rule_id, finding, cwe_taxon_index));
2698 }
2699 rules
2700}
2701
2702#[must_use]
2709fn render_sarif(output: &SecurityOutput) -> String {
2710 let cwes = collect_cwes(&output.security_findings);
2711 let results: Vec<serde_json::Value> = output
2712 .security_findings
2713 .iter()
2714 .map(sarif_result_for_finding)
2715 .collect();
2716 let rules = sarif_rule_defs(&output.security_findings, &cwes);
2717
2718 let mut run = serde_json::json!({
2719 "tool": { "driver": {
2720 "name": "fallow",
2721 "version": env!("CARGO_PKG_VERSION"),
2722 "informationUri": "https://github.com/fallow-rs/fallow",
2723 "rules": rules,
2724 }},
2725 "results": results,
2726 });
2727 if let Some(taxonomy) = cwe_taxonomy(&cwes) {
2728 run["taxonomies"] = serde_json::json!([taxonomy]);
2729 run["tool"]["driver"]["supportedTaxonomies"] = serde_json::json!([
2730 { "name": "CWE", "index": 0 }
2731 ]);
2732 }
2733 if let Some(gate) = &output.gate
2737 && let Ok(gate_value) = serde_json::to_value(gate)
2738 {
2739 run["properties"] = serde_json::json!({ "fallowGate": gate_value });
2740 }
2741
2742 let sarif = serde_json::json!({
2743 "version": "2.1.0",
2744 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2745 "runs": [run],
2746 });
2747 serde_json::to_string_pretty(&sarif)
2748 .unwrap_or_else(|_| "{\"error\":\"failed to serialize sarif\"}".to_owned())
2749}
2750
2751fn fnv_hex(input: &str) -> String {
2753 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2754 for byte in input.bytes() {
2755 hash ^= u64::from(byte);
2756 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2757 }
2758 format!("{hash:016x}")
2759}
2760
2761fn security_finding_id(finding: &SecurityFinding) -> String {
2767 let fp = format!(
2768 "{}:{}:{}",
2769 sarif_rule_id(finding),
2770 finding.path.to_string_lossy().replace('\\', "/"),
2771 finding.line,
2772 );
2773 fnv_hex(&fp)
2774}
2775
2776fn sarif_location(path: &Path, line: u32, col: u32) -> serde_json::Value {
2777 serde_json::json!({
2778 "physicalLocation": {
2779 "artifactLocation": { "uri": path.to_string_lossy().replace('\\', "/") },
2780 "region": { "startLine": line.max(1), "startColumn": col.saturating_add(1) }
2781 }
2782 })
2783}
2784
2785#[cfg(test)]
2786mod tests {
2787 use super::*;
2788 use fallow_engine::results::{
2789 SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink,
2790 SecurityDeadCodeContext, SecurityDeadCodeKind, SecurityFinding, SecurityFindingKind,
2791 TraceHop, TraceHopRole,
2792 };
2793 use fallow_types::results::{
2794 SecurityReachability, SecurityTaintFlow, TaintEndpoint, TaintPath,
2795 };
2796
2797 fn sample_finding(root: &Path) -> SecurityFinding {
2799 SecurityFinding {
2800 kind: SecurityFindingKind::ClientServerLeak,
2801 path: root.join("src/app.tsx"),
2802 line: 12,
2803 col: 3,
2804 evidence: "reaches process.env.SECRET_KEY".to_owned(),
2805 source_backed: false,
2806 source_read: None,
2807 severity: SecuritySeverity::High,
2808 trace: vec![
2809 TraceHop {
2810 path: root.join("src/app.tsx"),
2811 line: 12,
2812 col: 3,
2813 role: TraceHopRole::ClientBoundary,
2814 },
2815 TraceHop {
2816 path: root.join("src/lib/util.ts"),
2817 line: 4,
2818 col: 0,
2819 role: TraceHopRole::Intermediate,
2820 },
2821 TraceHop {
2822 path: root.join("src/lib/secret.ts"),
2823 line: 8,
2824 col: 2,
2825 role: TraceHopRole::SecretSource,
2826 },
2827 ],
2828 actions: vec![],
2829 category: None,
2830 cwe: None,
2831 dead_code: None,
2832 reachability: None,
2833 finding_id: String::new(),
2834 candidate: SecurityCandidate {
2835 source_kind: None,
2836 sink: SecurityCandidateSink {
2837 path: root.join("src/app.tsx"),
2838 line: 12,
2839 col: 3,
2840 category: None,
2841 cwe: None,
2842 callee: None,
2843 url_shape: None,
2844 },
2845 boundary: SecurityCandidateBoundary {
2846 client_server: true,
2847 cross_module: false,
2848 architecture_zone: None,
2849 },
2850 network: None,
2851 },
2852 taint_flow: None,
2853 runtime: None,
2854 attack_surface: None,
2855 }
2856 }
2857
2858 fn output_with(findings: Vec<SecurityFinding>, unresolved_edge_files: usize) -> SecurityOutput {
2859 SecurityOutput {
2860 schema_version: SecuritySchemaVersion::V7,
2861 version: ToolVersion("test".to_string()),
2862 elapsed_ms: ElapsedMs(0),
2863 config: test_output_config(),
2864 meta: None,
2865 gate: None,
2866 security_findings: findings,
2867 attack_surface: None,
2868 unresolved_edge_files,
2869 unresolved_callee_sites: 0,
2870 unresolved_callee_diagnostics: None,
2871 }
2872 }
2873
2874 fn output_with_gate(verdict: SecurityGateVerdict, new_count: usize) -> SecurityOutput {
2875 SecurityOutput {
2876 schema_version: SecuritySchemaVersion::V7,
2877 version: ToolVersion("test".to_string()),
2878 elapsed_ms: ElapsedMs(0),
2879 config: test_output_config(),
2880 meta: None,
2881 gate: Some(SecurityGate {
2882 mode: SecurityGateMode::New,
2883 verdict,
2884 new_count,
2885 }),
2886 security_findings: vec![],
2887 attack_surface: None,
2888 unresolved_edge_files: 0,
2889 unresolved_callee_sites: 0,
2890 unresolved_callee_diagnostics: None,
2891 }
2892 }
2893
2894 fn survivor_candidate_json(
2895 finding_id: &str,
2896 path: &str,
2897 line: u32,
2898 kind: SecurityFindingKind,
2899 category: Option<&str>,
2900 ) -> serde_json::Value {
2901 let root = Path::new("/proj/root");
2902 let mut finding = relativize_finding(sample_finding(root), root);
2903 finding.finding_id = finding_id.to_owned();
2904 finding.path = PathBuf::from(path);
2905 finding.line = line;
2906 finding.kind = kind;
2907 finding.category = category.map(str::to_owned);
2908 finding.candidate.sink.path = PathBuf::from(path);
2909 finding.candidate.sink.line = line;
2910 finding.candidate.sink.category = category.map(str::to_owned);
2911 serde_json::to_value(finding).expect("security finding serializes")
2912 }
2913
2914 fn sample_unresolved_callee_diagnostics(root: &Path) -> SecurityUnresolvedCalleeDiagnostics {
2915 unresolved_callee_diagnostics(
2916 &[
2917 SecurityUnresolvedCalleeDiagnostic {
2918 path: root.join("src/z.ts"),
2919 line: 9,
2920 col: 4,
2921 reason: fallow_types::extract::SkippedSecurityCalleeReason::ComputedMember,
2922 expression_kind:
2923 fallow_types::extract::SkippedSecurityCalleeExpressionKind::ComputedMemberExpression,
2924 },
2925 SecurityUnresolvedCalleeDiagnostic {
2926 path: root.join("src/a.ts"),
2927 line: 3,
2928 col: 2,
2929 reason: fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch,
2930 expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other,
2931 },
2932 SecurityUnresolvedCalleeDiagnostic {
2933 path: root.join("src/a.ts"),
2934 line: 4,
2935 col: 2,
2936 reason: fallow_types::extract::SkippedSecurityCalleeReason::DynamicDispatch,
2937 expression_kind: fallow_types::extract::SkippedSecurityCalleeExpressionKind::Other,
2938 },
2939 ],
2940 root,
2941 )
2942 .expect("diagnostics summarized")
2943 }
2944
2945 fn test_output_config() -> SecurityOutputConfig {
2946 SecurityOutputConfig {
2947 rules: SecurityOutputRulesConfig {
2948 security_client_server_leak: SecurityRuleSeverityConfig {
2949 configured: Severity::Off,
2950 effective: Severity::Warn,
2951 },
2952 security_sink: SecurityRuleSeverityConfig {
2953 configured: Severity::Off,
2954 effective: Severity::Warn,
2955 },
2956 },
2957 categories_include: None,
2958 categories_exclude: None,
2959 }
2960 }
2961
2962 #[test]
2963 fn survivors_json_keeps_survivors_and_review_candidates_by_finding_id() {
2964 let dir = tempfile::tempdir().expect("temp dir");
2965 let candidates = dir.path().join("candidates.json");
2966 let verdicts = dir.path().join("verdicts.json");
2967 std::fs::write(
2968 &candidates,
2969 serde_json::json!({
2970 "kind": "security",
2971 "security_findings": [
2972 survivor_candidate_json("sec-a", "src/a.ts", 10, SecurityFindingKind::TaintedSink, Some("ssrf")),
2973 survivor_candidate_json("sec-b", "src/b.ts", 11, SecurityFindingKind::TaintedSink, Some("redos-regex")),
2974 survivor_candidate_json("sec-c", "src/c.ts", 12, SecurityFindingKind::ClientServerLeak, None)
2975 ]
2976 })
2977 .to_string(),
2978 )
2979 .expect("write candidates");
2980 std::fs::write(
2981 &verdicts,
2982 serde_json::json!({
2983 "schema_version": "fallow-security-verdicts/v1",
2984 "verdicts": [
2985 { "schema_version": "fallow-security-verdict/v1", "finding_id": "sec-b", "verdict": "dismissed" },
2986 { "schema_version": "fallow-security-verdict/v1", "finding_id": "sec-a", "verdict": "survivor", "rationale": "input controls URL" },
2987 { "schema_version": "fallow-security-verdict/v1", "finding_id": "sec-c", "verdict": "needs-human-review" }
2988 ]
2989 })
2990 .to_string(),
2991 )
2992 .expect("write verdicts");
2993
2994 let output = build_survivors_output(
2995 &SecuritySurvivorsOptions {
2996 output: OutputFormat::Json,
2997 candidates: &candidates,
2998 verdicts: &verdicts,
2999 require_verdict_for_each_candidate: false,
3000 },
3001 Instant::now(),
3002 )
3003 .expect("survivors output");
3004 let rendered: serde_json::Value =
3005 serde_json::from_str(&render_survivors_json(&output)).expect("json");
3006
3007 assert_eq!(rendered["kind"], "security-survivors");
3008 assert!(rendered["survivors"]["sec-a"].is_object());
3009 assert!(rendered["survivors"]["sec-b"].is_null());
3010 assert!(rendered["needs_human_review"]["sec-c"].is_object());
3011 assert_eq!(rendered["summary"]["dismissed"], 1);
3012 }
3013
3014 #[test]
3015 fn survivors_reject_duplicate_verdicts_and_unknown_candidates() {
3016 let dir = tempfile::tempdir().expect("temp dir");
3017 let candidates = dir.path().join("candidates.json");
3018 let verdicts = dir.path().join("verdicts.json");
3019 std::fs::write(
3020 &candidates,
3021 serde_json::json!({
3022 "security_findings": [
3023 survivor_candidate_json("sec-a", "src/a.ts", 1, SecurityFindingKind::TaintedSink, Some("ssrf"))
3024 ]
3025 })
3026 .to_string(),
3027 )
3028 .expect("write candidates");
3029 std::fs::write(
3030 &verdicts,
3031 r#"[
3032 {"schema_version":"fallow-security-verdict/v1","finding_id":"sec-a","verdict":"survivor"},
3033 {"schema_version":"fallow-security-verdict/v1","finding_id":"sec-a","verdict":"dismissed"}
3034 ]"#,
3035 )
3036 .expect("write duplicate verdicts");
3037 let duplicate = build_survivors_output(
3038 &SecuritySurvivorsOptions {
3039 output: OutputFormat::Json,
3040 candidates: &candidates,
3041 verdicts: &verdicts,
3042 require_verdict_for_each_candidate: false,
3043 },
3044 Instant::now(),
3045 )
3046 .expect_err("duplicate verdict should fail");
3047 assert!(duplicate.contains("duplicate verdict"));
3048
3049 std::fs::write(
3050 &verdicts,
3051 r#"[{"schema_version":"fallow-security-verdict/v1","finding_id":"sec-missing","verdict":"survivor"}]"#,
3052 )
3053 .expect("write missing verdict");
3054 let missing = build_survivors_output(
3055 &SecuritySurvivorsOptions {
3056 output: OutputFormat::Json,
3057 candidates: &candidates,
3058 verdicts: &verdicts,
3059 require_verdict_for_each_candidate: false,
3060 },
3061 Instant::now(),
3062 )
3063 .expect_err("missing candidate should fail");
3064 assert!(missing.contains("unknown finding_id"));
3065 }
3066
3067 #[test]
3068 fn survivors_reject_malformed_schema_versions_and_unknown_verdicts() {
3069 let dir = tempfile::tempdir().expect("temp dir");
3070 let candidates = dir.path().join("candidates.json");
3071 let verdicts = dir.path().join("verdicts.json");
3072 std::fs::write(
3073 &candidates,
3074 serde_json::json!({
3075 "security_findings": [
3076 survivor_candidate_json("sec-a", "src/a.ts", 1, SecurityFindingKind::TaintedSink, Some("ssrf"))
3077 ]
3078 })
3079 .to_string(),
3080 )
3081 .expect("write candidates");
3082 std::fs::write(
3083 &verdicts,
3084 r#"[{"schema_version":"wrong","finding_id":"sec-a","verdict":"survivor"}]"#,
3085 )
3086 .expect("write bad schema");
3087 let bad_schema = build_survivors_output(
3088 &SecuritySurvivorsOptions {
3089 output: OutputFormat::Json,
3090 candidates: &candidates,
3091 verdicts: &verdicts,
3092 require_verdict_for_each_candidate: false,
3093 },
3094 Instant::now(),
3095 )
3096 .expect_err("bad schema should fail");
3097 assert!(bad_schema.contains("schema_version"));
3098
3099 std::fs::write(
3100 &verdicts,
3101 r#"[{"schema_version":"fallow-security-verdict/v1","finding_id":"sec-a","verdict":"maybe"}]"#,
3102 )
3103 .expect("write unknown verdict");
3104 let unknown = build_survivors_output(
3105 &SecuritySurvivorsOptions {
3106 output: OutputFormat::Json,
3107 candidates: &candidates,
3108 verdicts: &verdicts,
3109 require_verdict_for_each_candidate: false,
3110 },
3111 Instant::now(),
3112 )
3113 .expect_err("unknown verdict should fail");
3114 assert!(unknown.contains("Failed to parse verifier verdict file"));
3115 }
3116
3117 #[test]
3118 fn blind_spots_group_existing_diagnostics_with_suggestions() {
3119 let root = Path::new("/proj/root");
3120 let mut output = output_with(vec![], 2);
3121 output.unresolved_callee_sites = 99;
3122 output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3123
3124 let blind_spots = build_blind_spots_output(&output);
3125 let rendered: serde_json::Value =
3126 serde_json::from_str(&render_blind_spots_json(&blind_spots)).expect("json");
3127
3128 assert_eq!(rendered["kind"], "security-blind-spots");
3129 assert_eq!(rendered["summary"]["unresolved_edge_files"], 2);
3130 assert_eq!(rendered["summary"]["unresolved_callee_sites"], 3);
3131 assert_eq!(rendered["groups"][0]["reason"], "dynamic-dispatch");
3132 assert_eq!(rendered["groups"][0]["expression_kind"], "other");
3133 assert_eq!(rendered["groups"][0]["files"][0]["path"], "src/a.ts");
3134 assert!(rendered["groups"][0]["suggestion"].is_string());
3135 }
3136
3137 #[test]
3138 fn blind_spots_human_preserves_non_clean_bill_framing() {
3139 let root = Path::new("/proj/root");
3140 let mut output = output_with(vec![], 0);
3141 output.unresolved_callee_sites = 3;
3142 output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3143
3144 let out = render_blind_spots_human(&build_blind_spots_output(&output));
3145
3146 assert!(out.contains("may have missed security candidates"));
3147 assert!(out.contains("dynamic-dispatch / other"));
3148 assert!(out.contains("Next: inspect dynamic dispatch targets"));
3149 }
3150
3151 fn tainted_with_runtime(root: &Path, state: Option<SecurityRuntimeState>) -> SecurityFinding {
3152 let mut finding = sample_finding(root);
3153 finding.kind = SecurityFindingKind::TaintedSink;
3154 finding.category = Some("dangerous-html".to_owned());
3155 finding.cwe = Some(79);
3156 finding.runtime = state.map(|state| SecurityRuntimeContext {
3157 state,
3158 function: "render".to_owned(),
3159 line: 10,
3160 invocations: Some(123),
3161 stable_id: Some("fallow:fn:test".to_owned()),
3162 evidence: Some("production runtime evidence".to_owned()),
3163 });
3164 finding
3165 }
3166
3167 #[test]
3168 fn runtime_rank_promotes_hot_and_demotes_never_executed() {
3169 let root = Path::new("/proj/root");
3170 let mut findings = [
3171 tainted_with_runtime(root, Some(SecurityRuntimeState::NeverExecuted)),
3172 tainted_with_runtime(root, None),
3173 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
3174 tainted_with_runtime(root, Some(SecurityRuntimeState::CoverageUnavailable)),
3175 ];
3176
3177 findings.sort_by_key(runtime_rank);
3178
3179 assert_eq!(
3180 findings
3181 .iter()
3182 .map(|finding| finding.runtime.as_ref().map(|runtime| runtime.state))
3183 .collect::<Vec<_>>(),
3184 vec![
3185 Some(SecurityRuntimeState::RuntimeHot),
3186 None,
3187 Some(SecurityRuntimeState::CoverageUnavailable),
3188 Some(SecurityRuntimeState::NeverExecuted),
3189 ]
3190 );
3191 }
3192
3193 #[test]
3194 fn severity_sort_orders_tiers_then_location() {
3195 let root = Path::new("/proj/root");
3196 let mut high = sample_finding(root);
3197 high.path = root.join("z.ts");
3198 high.severity = SecuritySeverity::High;
3199 let mut low = sample_finding(root);
3200 low.path = root.join("a.ts");
3201 low.severity = SecuritySeverity::Low;
3202 let mut medium_a = sample_finding(root);
3203 medium_a.path = root.join("a.ts");
3204 medium_a.severity = SecuritySeverity::Medium;
3205 medium_a.reachability = Some(fallow_types::results::SecurityReachability {
3206 reachable_from_entry: false,
3207 reachable_from_untrusted_source: true,
3208 taint_confidence: Some(TaintConfidence::ModuleLevel),
3209 untrusted_source_hop_count: Some(1),
3210 untrusted_source_trace: vec![],
3211 blast_radius: 10,
3212 crosses_boundary: false,
3213 });
3214 let mut medium_b = sample_finding(root);
3215 medium_b.path = root.join("b.ts");
3216 medium_b.severity = SecuritySeverity::Medium;
3217 medium_b.source_backed = true;
3218 medium_b.reachability = Some(fallow_types::results::SecurityReachability {
3219 reachable_from_entry: false,
3220 reachable_from_untrusted_source: true,
3221 taint_confidence: Some(TaintConfidence::ArgLevel),
3222 untrusted_source_hop_count: Some(0),
3223 untrusted_source_trace: vec![],
3224 blast_radius: 1,
3225 crosses_boundary: false,
3226 });
3227 let mut findings = vec![low, medium_b, high, medium_a];
3228
3229 sort_by_security_severity(&mut findings);
3230
3231 assert_eq!(
3232 findings
3233 .iter()
3234 .map(|finding| (finding.severity, finding.path.file_name().unwrap()))
3235 .collect::<Vec<_>>(),
3236 vec![
3237 (SecuritySeverity::High, std::ffi::OsStr::new("z.ts")),
3238 (SecuritySeverity::Medium, std::ffi::OsStr::new("b.ts")),
3239 (SecuritySeverity::Medium, std::ffi::OsStr::new("a.ts")),
3240 (SecuritySeverity::Low, std::ffi::OsStr::new("a.ts")),
3241 ]
3242 );
3243 }
3244
3245 #[test]
3246 fn human_render_includes_runtime_context_line() {
3247 let root = Path::new("/proj/root");
3248 let finding = relativize_finding(
3249 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
3250 root,
3251 );
3252 let out = render_human(&output_with(vec![finding], 0));
3253
3254 assert!(
3255 out.contains("runtime: runtime-hot in render:10"),
3256 "got: {out}"
3257 );
3258 assert!(out.contains("production runtime evidence"), "got: {out}");
3259 }
3260
3261 #[test]
3262 fn sarif_render_includes_runtime_context_in_message() {
3263 let root = Path::new("/proj/root");
3264 let finding = relativize_finding(
3265 tainted_with_runtime(root, Some(SecurityRuntimeState::RuntimeHot)),
3266 root,
3267 );
3268 let rendered = render_sarif(&output_with(vec![finding], 0));
3269 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3270 let message = sarif["runs"][0]["results"][0]["message"]["text"]
3271 .as_str()
3272 .expect("message text");
3273
3274 assert!(message.contains("Runtime context"), "got: {message}");
3275 assert!(
3276 message.contains("runtime-hot in render:10"),
3277 "got: {message}"
3278 );
3279 }
3280
3281 #[test]
3282 fn gate_human_header_fail_says_review_required_not_fail() {
3283 let gate = SecurityGate {
3284 mode: SecurityGateMode::New,
3285 verdict: SecurityGateVerdict::Fail,
3286 new_count: 2,
3287 };
3288 let header = gate_human_header(&gate);
3289 assert!(header.contains("REVIEW REQUIRED"));
3290 assert!(header.contains("2 new security items"));
3291 assert!(header.contains("not confirmed a vulnerability"));
3292 assert!(!header.to_uppercase().contains("GATE: FAIL"));
3293 }
3294
3295 #[test]
3296 fn gate_human_header_fail_singular_for_one_candidate() {
3297 let gate = SecurityGate {
3299 mode: SecurityGateMode::New,
3300 verdict: SecurityGateVerdict::Fail,
3301 new_count: 1,
3302 };
3303 let header = gate_human_header(&gate);
3304 assert!(header.contains("1 new security item in changed lines"));
3305 assert!(!header.contains("1 new security candidates"));
3306 }
3307
3308 #[test]
3309 fn gate_human_header_pass() {
3310 let gate = SecurityGate {
3311 mode: SecurityGateMode::New,
3312 verdict: SecurityGateVerdict::Pass,
3313 new_count: 0,
3314 };
3315 assert!(gate_human_header(&gate).contains("Gate: PASS"));
3316 }
3317
3318 #[test]
3319 fn gate_json_block_is_snake_case_and_present_on_pass() {
3320 let json = render_json(&output_with_gate(SecurityGateVerdict::Pass, 0));
3321 assert!(json.contains("\"gate\""));
3322 assert!(json.contains("\"mode\": \"new\""));
3323 assert!(json.contains("\"verdict\": \"pass\""));
3324 assert!(json.contains("\"new_count\": 0"));
3325 }
3326
3327 #[test]
3328 fn reachability_key_includes_path_kind_and_category() {
3329 let root = Path::new("/proj/root");
3330 let mut leak = sample_finding(root);
3331 leak.reachability = Some(SecurityReachability {
3332 reachable_from_entry: true,
3333 reachable_from_untrusted_source: false,
3334 taint_confidence: None,
3335 untrusted_source_hop_count: None,
3336 untrusted_source_trace: vec![],
3337 blast_radius: 0,
3338 crosses_boundary: false,
3339 });
3340 let mut sink = leak.clone();
3341 sink.kind = SecurityFindingKind::TaintedSink;
3342 sink.category = Some("dangerous-html".to_owned());
3343
3344 assert_eq!(
3345 security_reachability_key(&leak, root).as_deref(),
3346 Some("security-reach:src/app.tsx:client-server-leak:none")
3347 );
3348 assert_eq!(
3349 security_reachability_key(&sink, root).as_deref(),
3350 Some("security-reach:src/app.tsx:tainted-sink:dangerous-html")
3351 );
3352 }
3353
3354 #[test]
3355 fn reachability_key_ignores_unreachable_findings() {
3356 let root = Path::new("/proj/root");
3357 let finding = sample_finding(root);
3358
3359 assert!(security_reachability_key(&finding, root).is_none());
3360 }
3361
3362 #[test]
3363 fn gate_absent_from_json_when_no_gate_ran() {
3364 let json = render_json(&output_with(vec![], 0));
3365 assert!(!json.contains("\"gate\""));
3366 }
3367
3368 #[test]
3369 fn gate_sarif_is_a_run_property_not_result_severity() {
3370 let sarif = render_sarif(&output_with_gate(SecurityGateVerdict::Fail, 1));
3371 assert!(sarif.contains("fallowGate"));
3372 assert!(!sarif.contains("\"level\": \"error\""));
3374 assert!(!sarif.contains("\"level\": \"warning\""));
3375 }
3376
3377 fn add_untrusted_source_reachability(finding: &mut SecurityFinding, root: &Path) {
3378 finding.reachability = Some(SecurityReachability {
3379 reachable_from_entry: true,
3380 reachable_from_untrusted_source: true,
3381 taint_confidence: Some(TaintConfidence::ModuleLevel),
3383 untrusted_source_hop_count: Some(1),
3384 untrusted_source_trace: vec![
3385 TraceHop {
3386 path: root.join("src/routes/api.ts"),
3387 line: 3,
3388 col: 0,
3389 role: TraceHopRole::ModuleSource,
3390 },
3391 TraceHop {
3392 path: root.join("src/lib/sink.ts"),
3393 line: 9,
3394 col: 2,
3395 role: TraceHopRole::Sink,
3396 },
3397 ],
3398 blast_radius: 2,
3399 crosses_boundary: false,
3400 });
3401 }
3402
3403 fn add_taint_flow(finding: &mut SecurityFinding, root: &Path) {
3404 finding.taint_flow = Some(SecurityTaintFlow {
3405 source: TaintEndpoint {
3406 path: root.join("src/routes/api.ts"),
3407 line: 3,
3408 col: 0,
3409 },
3410 sink: TaintEndpoint {
3411 path: root.join("src/lib/sink.ts"),
3412 line: 9,
3413 col: 2,
3414 },
3415 path: TaintPath {
3416 intra_module: false,
3417 cross_module_hops: 1,
3418 },
3419 });
3420 }
3421
3422 #[test]
3423 fn relativize_strips_root_prefix() {
3424 let root = Path::new("/proj/root");
3425 let abs = root.join("src/app.tsx");
3426 let rel = relativize(&abs, root);
3427 assert_eq!(rel.to_string_lossy().replace('\\', "/"), "src/app.tsx");
3428 }
3429
3430 #[test]
3431 fn relativize_keeps_path_when_outside_root() {
3432 let root = Path::new("/proj/root");
3433 let outside = Path::new("/elsewhere/file.ts");
3434 assert_eq!(relativize(outside, root), outside.to_path_buf());
3436 }
3437
3438 #[test]
3439 fn relativize_finding_relativizes_anchor_and_every_hop() {
3440 let root = Path::new("/proj/root");
3441 let finding = relativize_finding(sample_finding(root), root);
3442 assert_eq!(
3443 finding.path.to_string_lossy().replace('\\', "/"),
3444 "src/app.tsx"
3445 );
3446 let hop_paths: Vec<String> = finding
3447 .trace
3448 .iter()
3449 .map(|h| h.path.to_string_lossy().replace('\\', "/"))
3450 .collect();
3451 assert_eq!(
3452 hop_paths,
3453 vec!["src/app.tsx", "src/lib/util.ts", "src/lib/secret.ts"]
3454 );
3455 }
3456
3457 #[test]
3458 fn relativize_finding_relativizes_untrusted_source_trace() {
3459 let root = Path::new("/proj/root");
3460 let mut finding = sample_finding(root);
3461 add_untrusted_source_reachability(&mut finding, root);
3462 let finding = relativize_finding(finding, root);
3463 let reach = finding.reachability.as_ref().expect("reachability");
3464 let hop_paths: Vec<String> = reach
3465 .untrusted_source_trace
3466 .iter()
3467 .map(|h| h.path.to_string_lossy().replace('\\', "/"))
3468 .collect();
3469 assert_eq!(hop_paths, vec!["src/routes/api.ts", "src/lib/sink.ts"]);
3470 }
3471
3472 #[test]
3473 fn fnv_hex_is_deterministic_and_16_hex_digits() {
3474 let a = fnv_hex("security/client-server-leak:src/app.tsx:12");
3475 let b = fnv_hex("security/client-server-leak:src/app.tsx:12");
3476 assert_eq!(a, b, "same input must hash identically");
3477 assert_eq!(a.len(), 16);
3478 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
3479 assert_ne!(a, fnv_hex("security/client-server-leak:src/app.tsx:13"));
3481 }
3482
3483 #[test]
3484 fn hop_role_labels_cover_every_role() {
3485 assert_eq!(
3486 hop_role_label(TraceHopRole::ClientBoundary),
3487 "client boundary"
3488 );
3489 assert_eq!(
3490 hop_role_label(TraceHopRole::UntrustedSource),
3491 "untrusted source"
3492 );
3493 assert_eq!(hop_role_label(TraceHopRole::ModuleSource), "source module");
3494 assert_eq!(hop_role_label(TraceHopRole::Intermediate), "intermediate");
3495 assert_eq!(hop_role_label(TraceHopRole::SecretSource), "secret source");
3496 assert_eq!(hop_role_label(TraceHopRole::Sink), "sink site");
3497 }
3498
3499 #[test]
3500 fn sarif_location_clamps_line_and_offsets_column() {
3501 let loc = sarif_location(Path::new("a\\b.ts"), 0, 0);
3503 let region = &loc["physicalLocation"]["region"];
3504 assert_eq!(region["startLine"], 1);
3505 assert_eq!(region["startColumn"], 1);
3506 assert_eq!(loc["physicalLocation"]["artifactLocation"]["uri"], "a/b.ts");
3508 }
3509
3510 #[test]
3511 fn human_summary_reports_zero_without_edge_line() {
3512 let out = render_human_summary(&output_with(vec![], 0));
3513 assert!(
3514 out.contains("Security review: no items to check in the scanned code."),
3515 "got: {out}"
3516 );
3517 assert!(!out.contains("Blind spot"));
3518 }
3519
3520 #[test]
3521 fn human_summary_pluralizes_and_surfaces_unresolved_edges() {
3522 let root = Path::new("/proj/root");
3523 let out = render_human_summary(&output_with(vec![sample_finding(root)], 2));
3524 assert!(
3525 out.contains("Security review: 1 item to check."),
3526 "got: {out}"
3527 );
3528 assert!(out.contains("not confirmed vulnerabilities"));
3529 assert!(out.contains("unsafe input, secrets, or settings"));
3530 assert!(out.contains("Blind spot: 2 client files use dynamic imports"));
3531 }
3532
3533 #[test]
3534 fn human_render_empty_states_no_candidates() {
3535 colored::control::set_override(false);
3536 let out = render_human(&output_with(vec![], 0));
3537 assert!(out.contains("Security review: 0 items to check"));
3538 assert!(out.contains("No security details to show."));
3539 assert!(out.contains("Result: 0 security items to check."));
3540 }
3541
3542 #[test]
3543 fn human_render_shows_finding_trace_and_next_action() {
3544 colored::control::set_override(false);
3545 let root = Path::new("/proj/root");
3546 let finding = relativize_finding(sample_finding(root), root);
3547 let out = render_human(&output_with(vec![finding], 0));
3548 assert!(out.contains("[H] high client-server-leak"));
3549 assert!(out.contains("client-server-leak"));
3550 assert!(out.contains("src/app.tsx:12"));
3551 assert!(out.contains("evidence: reaches process.env.SECRET_KEY"));
3552 assert!(out.contains("import trace:"));
3553 assert!(out.contains("src/lib/secret.ts:8 (secret source)"));
3554 assert!(out.contains("src/app.tsx:12 (client boundary)"));
3555 assert!(out.contains("Next: check whether this import can ship a secret to the browser"));
3556 assert!(out.contains("Result: 1 security item to check."));
3557 }
3558
3559 #[test]
3560 fn human_render_shows_dead_code_hint_and_delete_next_step() {
3561 colored::control::set_override(false);
3562 let root = Path::new("/proj/root");
3563 let mut finding = relativize_finding(sample_finding(root), root);
3564 finding.kind = SecurityFindingKind::TaintedSink;
3565 finding.dead_code = Some(SecurityDeadCodeContext {
3566 kind: SecurityDeadCodeKind::UnusedFile,
3567 export_name: None,
3568 line: None,
3569 guidance: "delete instead of harden".to_string(),
3570 });
3571 let out = render_human(&output_with(vec![finding], 0));
3572 assert!(
3573 out.contains("dead-code: also reported as unused-file"),
3574 "got: {out}"
3575 );
3576 assert!(
3577 out.contains("If the code is safe to remove, delete it"),
3578 "got: {out}"
3579 );
3580 }
3581
3582 #[test]
3583 fn human_render_shows_untrusted_source_path_as_module_context() {
3584 colored::control::set_override(false);
3585 let root = Path::new("/proj/root");
3586 let mut finding = sample_finding(root);
3587 finding.kind = SecurityFindingKind::TaintedSink;
3588 finding.category = Some("command-injection".to_string());
3589 add_untrusted_source_reachability(&mut finding, root);
3590 let finding = relativize_finding(finding, root);
3591
3592 let out = render_human(&output_with(vec![finding], 0));
3593
3594 assert!(
3595 out.contains("reachable from a module that receives untrusted input via 1 import hop"),
3596 "got: {out}"
3597 );
3598 assert!(out.contains("input import trace:"), "got: {out}");
3599 assert!(
3600 out.contains("src/routes/api.ts:3 (source module)"),
3601 "got: {out}"
3602 );
3603 }
3604
3605 #[test]
3606 fn human_render_surfaces_unresolved_edge_blind_spot() {
3607 colored::control::set_override(false);
3608 let out = render_human(&output_with(vec![], 3));
3609 assert!(out.contains("Blind spot: 3 client files use dynamic imports"));
3610 assert!(out.contains("Code behind those imports may be missing from this report."));
3611 }
3612
3613 #[test]
3614 fn human_render_blind_spots_use_singular_verbs() {
3615 colored::control::set_override(false);
3616 let mut output = output_with(vec![], 1);
3617 output.unresolved_callee_sites = 1;
3618
3619 let out = render_human(&output);
3620
3621 assert!(out.contains("Blind spot: 1 client file uses dynamic imports"));
3622 assert!(out.contains("Blind spot: 1 call site uses code patterns"));
3623 }
3624
3625 #[test]
3626 fn human_render_mentions_top_unresolved_callee_reason_and_file() {
3627 colored::control::set_override(false);
3628 let root = Path::new("/proj/root");
3629 let mut output = output_with(vec![], 0);
3630 output.unresolved_callee_sites = 3;
3631 output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3632
3633 let out = render_human(&output);
3634
3635 assert!(
3636 out.contains("Most unresolved callees: dynamic-dispatch in src/a.ts."),
3637 "got: {out}"
3638 );
3639 }
3640
3641 #[test]
3642 fn json_render_carries_schema_version_and_findings() {
3643 let root = Path::new("/proj/root");
3644 let finding = relativize_finding(sample_finding(root), root);
3645 let rendered = render_json(&output_with(vec![finding], 1));
3646 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3647 assert_eq!(value["schema_version"], "7");
3648 assert_eq!(value["version"], "test");
3649 assert_eq!(value["elapsed_ms"], 0);
3650 assert_eq!(
3651 value["config"]["rules"]["security_client_server_leak"]["configured"],
3652 "off"
3653 );
3654 assert_eq!(
3655 value["config"]["rules"]["security_client_server_leak"]["effective"],
3656 "warn"
3657 );
3658 assert!(value["config"]["categories_include"].is_null());
3659 assert!(value["config"]["categories_exclude"].is_null());
3660 assert_eq!(value["unresolved_edge_files"], 1);
3661 let findings = value["security_findings"].as_array().expect("array");
3662 assert_eq!(findings.len(), 1);
3663 assert_eq!(findings[0]["kind"], "client-server-leak");
3664 assert_eq!(findings[0]["path"], "src/app.tsx");
3665 assert_eq!(findings[0]["severity"], "high");
3666 }
3667
3668 #[test]
3669 fn json_render_carries_bounded_unresolved_callee_diagnostics() {
3670 let root = Path::new("/proj/root");
3671 let mut output = output_with(vec![], 0);
3672 output.unresolved_callee_sites = 3;
3673 output.unresolved_callee_diagnostics = Some(sample_unresolved_callee_diagnostics(root));
3674
3675 let rendered = render_json(&output);
3676 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3677 let diagnostics = &value["unresolved_callee_diagnostics"];
3678
3679 assert_eq!(diagnostics["sample_limit"], 25);
3680 assert_eq!(diagnostics["top_files_limit"], 10);
3681 assert_eq!(diagnostics["sampled"][0]["path"], "src/a.ts");
3682 assert_eq!(diagnostics["sampled"][0]["reason"], "dynamic-dispatch");
3683 assert_eq!(diagnostics["sampled"][0]["expression_kind"], "other");
3684 assert_eq!(diagnostics["top_files"][0]["path"], "src/a.ts");
3685 assert_eq!(diagnostics["top_files"][0]["count"], 2);
3686 assert_eq!(diagnostics["by_reason"][0]["reason"], "dynamic-dispatch");
3687 assert_eq!(diagnostics["by_reason"][0]["count"], 2);
3688 }
3689
3690 #[test]
3691 fn json_summary_omits_finding_arrays_and_counts_security_findings() {
3692 let root = Path::new("/proj/root");
3693 let mut leak = relativize_finding(sample_finding(root), root);
3694 leak.severity = SecuritySeverity::High;
3695
3696 let mut sink = relativize_finding(sample_finding(root), root);
3697 sink.kind = SecurityFindingKind::TaintedSink;
3698 sink.category = Some("dangerous-html".to_string());
3699 sink.severity = SecuritySeverity::Medium;
3700 sink.source_backed = true;
3701 sink.reachability = Some(SecurityReachability {
3702 reachable_from_entry: true,
3703 reachable_from_untrusted_source: true,
3704 taint_confidence: Some(TaintConfidence::ArgLevel),
3705 untrusted_source_hop_count: Some(0),
3706 untrusted_source_trace: vec![],
3707 blast_radius: 3,
3708 crosses_boundary: true,
3709 });
3710 sink.runtime = Some(SecurityRuntimeContext {
3711 state: SecurityRuntimeState::RuntimeHot,
3712 function: "render".to_owned(),
3713 line: 10,
3714 invocations: Some(120),
3715 stable_id: Some("src/app.tsx::render:10".to_owned()),
3716 evidence: Some("production hot path observed".to_owned()),
3717 });
3718
3719 let mut output = output_with(vec![leak, sink], 2);
3720 output.elapsed_ms = ElapsedMs(17);
3721 output.unresolved_callee_sites = 3;
3722
3723 let rendered = render_json_summary(&output);
3724 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3725
3726 assert_eq!(value["kind"], "security");
3727 assert_eq!(value["schema_version"], "7");
3728 assert_eq!(value["version"], "test");
3729 assert_eq!(value["elapsed_ms"], 17);
3730 assert!(value.get("config").is_some());
3731 assert!(value.get("security_findings").is_none());
3732 assert!(value.get("attack_surface").is_none());
3733 assert!(value.get("_meta").is_none());
3734 assert_eq!(value["summary"]["security_findings"], 2);
3735 assert_eq!(value["summary"]["by_severity"]["high"], 1);
3736 assert_eq!(value["summary"]["by_severity"]["medium"], 1);
3737 assert_eq!(value["summary"]["by_severity"]["low"], 0);
3738 assert_eq!(value["summary"]["by_category"]["client-server-leak"], 1);
3739 assert_eq!(value["summary"]["by_category"]["dangerous-html"], 1);
3740 assert_eq!(value["summary"]["by_reachability"]["entry_reachable"], 1);
3741 assert_eq!(
3742 value["summary"]["by_reachability"]["untrusted_source_reachable"],
3743 1
3744 );
3745 assert_eq!(value["summary"]["by_reachability"]["arg_level"], 1);
3746 assert_eq!(value["summary"]["by_reachability"]["module_level"], 0);
3747 assert_eq!(value["summary"]["by_reachability"]["crosses_boundary"], 1);
3748 assert_eq!(value["summary"]["by_reachability"]["source_backed"], 1);
3749 assert_eq!(value["summary"]["by_runtime_state"]["runtime_hot"], 1);
3750 assert_eq!(value["summary"]["by_runtime_state"]["runtime_cold"], 0);
3751 assert_eq!(value["summary"]["by_runtime_state"]["never_executed"], 0);
3752 assert_eq!(value["summary"]["by_runtime_state"]["low_traffic"], 0);
3753 assert_eq!(
3754 value["summary"]["by_runtime_state"]["coverage_unavailable"],
3755 0
3756 );
3757 assert_eq!(value["summary"]["by_runtime_state"]["runtime_unknown"], 0);
3758 assert_eq!(value["summary"]["by_runtime_state"]["not_collected"], 1);
3759 assert_eq!(value["summary"]["unresolved_edge_files"], 2);
3760 assert_eq!(value["summary"]["unresolved_callee_sites"], 3);
3761 assert_eq!(value["summary"]["attack_surface_entries"], 0);
3762 }
3763
3764 #[test]
3765 fn json_summary_carries_security_meta_when_explain_requested() {
3766 let root = Path::new("/proj/root");
3767 let mut output = output_with(vec![relativize_finding(sample_finding(root), root)], 0);
3768 output.meta = Some(crate::explain::security_meta());
3769
3770 let rendered = render_json_summary(&output);
3771 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3772
3773 assert!(value.get("security_findings").is_none());
3774 assert!(value["_meta"]["field_definitions"]["security_findings[]"].is_string());
3775 assert!(value["_meta"]["field_definitions"]["summary.by_reachability"].is_string());
3776 assert!(value["_meta"]["field_definitions"]["summary.by_runtime_state"].is_string());
3777 assert!(value["_meta"]["field_definitions"]["unresolved_callee_sites"].is_string());
3778 }
3779
3780 #[test]
3781 fn json_summary_preserves_gate_block() {
3782 let output = output_with_gate(SecurityGateVerdict::Fail, 1);
3783 let rendered = render_json_summary(&output);
3784 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3785
3786 assert_eq!(value["kind"], "security");
3787 assert_eq!(value["gate"]["mode"], "new");
3788 assert_eq!(value["gate"]["verdict"], "fail");
3789 assert_eq!(value["gate"]["new_count"], 1);
3790 assert_eq!(value["summary"]["security_findings"], 0);
3791 }
3792
3793 #[test]
3794 fn json_render_carries_security_meta_when_explain_requested() {
3795 let mut output = output_with(vec![], 0);
3796 output.meta = Some(crate::explain::security_meta());
3797
3798 let rendered = render_json(&output);
3799 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3800
3801 assert_eq!(
3802 value["_meta"]["field_definitions"]["security_findings[]"],
3803 "Unverified security candidates for downstream human or agent verification."
3804 );
3805 assert!(value["_meta"]["rules"]["security/tainted-sink"].is_object());
3806 }
3807
3808 #[test]
3809 fn json_render_carries_candidate_record_and_omits_impact() {
3810 let root = Path::new("/proj/root");
3814 let finding = relativize_finding(sample_finding(root), root);
3815 let rendered = render_json(&output_with(vec![finding], 0));
3816 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3817 let finding = &value["security_findings"][0];
3818
3819 let candidate = &finding["candidate"];
3820 assert!(candidate.is_object(), "candidate record present");
3821 assert!(candidate["sink"].is_object(), "sink slot present");
3822 assert_eq!(candidate["boundary"]["client_server"], true);
3823 assert!(
3824 candidate.get("impact").is_none(),
3825 "impact must NOT be a wire field"
3826 );
3827 assert!(
3828 candidate.get("source_kind").is_none(),
3829 "client-server-leak has no source kind"
3830 );
3831 assert!(
3832 finding.get("taint_flow").is_none(),
3833 "no untrusted-source flow on a client-server-leak"
3834 );
3835 assert!(
3836 finding.get("finding_id").is_some(),
3837 "finding_id is on the wire"
3838 );
3839 }
3840
3841 #[test]
3842 fn finding_id_is_stable_and_matches_sarif_fingerprint() {
3843 let root = Path::new("/proj/root");
3846 let finding = relativize_finding(sample_finding(root), root);
3847 let id = security_finding_id(&finding);
3848 assert!(!id.is_empty());
3849 assert_eq!(
3850 id,
3851 security_finding_id(&finding),
3852 "deterministic across calls"
3853 );
3854
3855 let sarif: serde_json::Value =
3856 serde_json::from_str(&render_sarif(&output_with(vec![finding], 0)))
3857 .expect("valid SARIF");
3858 assert_eq!(
3859 sarif["runs"][0]["results"][0]["partialFingerprints"]["fallowSecurity/v1"],
3860 serde_json::Value::String(id)
3861 );
3862 }
3863
3864 #[test]
3865 fn json_render_carries_dead_code_context() {
3866 let root = Path::new("/proj/root");
3867 let mut finding = relativize_finding(sample_finding(root), root);
3868 finding.kind = SecurityFindingKind::TaintedSink;
3869 finding.dead_code = Some(SecurityDeadCodeContext {
3870 kind: SecurityDeadCodeKind::UnusedExport,
3871 export_name: Some("handler".to_string()),
3872 line: Some(12),
3873 guidance: "remove export instead of harden".to_string(),
3874 });
3875 let rendered = render_json(&output_with(vec![finding], 0));
3876 let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
3877 let context = &value["security_findings"][0]["dead_code"];
3878 assert_eq!(context["kind"], "unused-export");
3879 assert_eq!(context["export_name"], "handler");
3880 assert_eq!(context["line"], 12);
3881 }
3882
3883 #[test]
3884 fn sarif_render_emits_warning_level_with_fingerprint_and_related_locations() {
3885 let root = Path::new("/proj/root");
3886 let finding = relativize_finding(sample_finding(root), root);
3887 let rendered = render_sarif(&output_with(vec![finding], 0));
3888 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3889 assert_eq!(sarif["version"], "2.1.0");
3890 let run = &sarif["runs"][0];
3891 assert_eq!(run["tool"]["driver"]["name"], "fallow");
3892 let result = &run["results"][0];
3893 assert_eq!(result["level"], "warning");
3895 assert_eq!(result["ruleId"], "security/client-server-leak");
3896 assert_eq!(result["message"]["text"], "reaches process.env.SECRET_KEY");
3897 assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 3);
3899 let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
3900 .as_array()
3901 .expect("thread flow locations");
3902 assert_eq!(flow_locations.len(), 3);
3903 assert_eq!(
3904 flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3905 "src/app.tsx"
3906 );
3907 assert_eq!(
3908 flow_locations[2]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3909 "src/lib/secret.ts"
3910 );
3911 assert_eq!(
3912 flow_locations[2]["kinds"][0],
3913 serde_json::json!("secret-source")
3914 );
3915 assert!(result["partialFingerprints"]["fallowSecurity/v1"].is_string());
3917
3918 let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
3919 assert_eq!(rules[0]["name"], "Client-server secret leak");
3920 assert!(rules[0]["help"]["text"].is_string());
3921 assert!(rules[0].get("relationships").is_none());
3922 assert!(run.get("taxonomies").is_none());
3923 }
3924
3925 #[test]
3926 fn sarif_render_keeps_low_severity_as_note() {
3927 let root = Path::new("/proj/root");
3928 let mut finding = sample_finding(root);
3929 finding.severity = SecuritySeverity::Low;
3930 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3931 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3932
3933 assert_eq!(sarif["runs"][0]["results"][0]["level"], "note");
3934 }
3935
3936 #[test]
3937 fn sarif_render_includes_dead_code_hint_in_message() {
3938 let root = Path::new("/proj/root");
3939 let mut finding = relativize_finding(sample_finding(root), root);
3940 finding.kind = SecurityFindingKind::TaintedSink;
3941 finding.dead_code = Some(SecurityDeadCodeContext {
3942 kind: SecurityDeadCodeKind::UnusedFile,
3943 export_name: None,
3944 line: None,
3945 guidance: "delete instead of harden".to_string(),
3946 });
3947 let rendered = render_sarif(&output_with(vec![finding], 0));
3948 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3949 let message = sarif["runs"][0]["results"][0]["message"]["text"]
3950 .as_str()
3951 .expect("message text");
3952 assert!(message.contains("Dead-code cross-link"), "got: {message}");
3953 assert!(
3954 message.contains("delete this file instead of hardening"),
3955 "got: {message}"
3956 );
3957 }
3958
3959 #[test]
3960 fn sarif_render_includes_untrusted_source_context_and_related_locations() {
3961 let root = Path::new("/proj/root");
3962 let mut finding = sample_finding(root);
3963 finding.kind = SecurityFindingKind::TaintedSink;
3964 finding.category = Some("command-injection".to_string());
3965 add_untrusted_source_reachability(&mut finding, root);
3966 add_taint_flow(&mut finding, root);
3967 finding.trace.push(TraceHop {
3968 path: root.join("src/lib/sink.ts"),
3969 line: 9,
3970 col: 2,
3971 role: TraceHopRole::Sink,
3972 });
3973 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
3974 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
3975 let result = &sarif["runs"][0]["results"][0];
3976 let message = result["message"]["text"].as_str().expect("message text");
3977 assert!(message.contains("Module-level context"), "got: {message}");
3978 assert!(
3979 message.contains("does not prove value flow"),
3980 "got: {message}"
3981 );
3982 assert_eq!(result["relatedLocations"].as_array().unwrap().len(), 5);
3984 let flow_locations = result["codeFlows"][0]["threadFlows"][0]["locations"]
3985 .as_array()
3986 .expect("thread flow locations");
3987 assert_eq!(flow_locations.len(), 2);
3988 assert_eq!(
3989 flow_locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3990 "src/routes/api.ts"
3991 );
3992 assert_eq!(
3993 flow_locations[1]["location"]["physicalLocation"]["artifactLocation"]["uri"],
3994 "src/lib/sink.ts"
3995 );
3996 }
3997
3998 #[test]
3999 fn sarif_tainted_sink_uses_per_category_rule_id_and_cwe_metadata() {
4000 let root = Path::new("/proj/root");
4001 let mut finding = sample_finding(root);
4002 finding.kind = SecurityFindingKind::TaintedSink;
4003 finding.category = Some("dangerous-html".to_owned());
4004 finding.cwe = Some(79);
4005 let rendered = render_sarif(&output_with(vec![relativize_finding(finding, root)], 0));
4006 let sarif: serde_json::Value = serde_json::from_str(&rendered).expect("valid SARIF JSON");
4007 let run = &sarif["runs"][0];
4008 let result = &run["results"][0];
4011 assert_eq!(result["level"], "warning");
4012 assert_eq!(result["ruleId"], "security/dangerous-html");
4013 let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
4015 assert_eq!(rules.len(), 1);
4016 assert_eq!(rules[0]["id"], "security/dangerous-html");
4017 assert_eq!(rules[0]["name"], "Dangerous HTML sink");
4018 assert!(
4019 rules[0]["help"]["text"]
4020 .as_str()
4021 .expect("help text")
4022 .contains("Verify this unverified")
4023 );
4024 assert!(
4025 rules[0]["help"]["markdown"]
4026 .as_str()
4027 .expect("help markdown")
4028 .contains("**Dangerous HTML sink**")
4029 );
4030 let tags = rules[0]["properties"]["tags"].as_array().unwrap();
4031 assert!(tags.iter().any(|t| t == "external/cwe/cwe-79"));
4032 let relationship = &rules[0]["relationships"][0];
4033 assert_eq!(relationship["target"]["id"], "CWE-79");
4034 assert_eq!(relationship["target"]["index"], 0);
4035 assert_eq!(relationship["target"]["toolComponent"]["name"], "CWE");
4036 assert_eq!(relationship["kinds"][0], "superset");
4037
4038 let taxonomy = &run["taxonomies"][0];
4039 assert_eq!(taxonomy["name"], "CWE");
4040 assert_eq!(taxonomy["taxa"][0]["id"], "CWE-79");
4041 assert_eq!(
4042 run["tool"]["driver"]["supportedTaxonomies"][0]["name"],
4043 "CWE"
4044 );
4045 }
4046
4047 #[test]
4048 fn write_sarif_file_creates_parent_dir_and_writes_valid_sarif() {
4049 let root = Path::new("/proj/root");
4050 let finding = relativize_finding(sample_finding(root), root);
4051 let output = output_with(vec![finding], 0);
4052 let dir = tempfile::tempdir().expect("tempdir");
4053 let path = dir.path().join("nested/out.sarif");
4054 write_sarif_file(&output, &path).expect("write succeeds and creates parent dir");
4055 let written = std::fs::read_to_string(&path).expect("file exists");
4056 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
4057 assert_eq!(sarif["version"], "2.1.0");
4058 }
4059
4060 const NO_CONFIG: Option<PathBuf> = None;
4062
4063 fn leak_fixture_root() -> PathBuf {
4064 Path::new(env!("CARGO_MANIFEST_DIR"))
4065 .join("../../tests/fixtures/security-client-server-leak")
4066 }
4067
4068 fn source_reachability_fixture_root() -> PathBuf {
4069 Path::new(env!("CARGO_MANIFEST_DIR"))
4070 .join("../../tests/fixtures/security-source-reachability-885")
4071 }
4072
4073 fn run_opts(root: &Path, output: OutputFormat, fail_on_issues: bool) -> SecurityOptions<'_> {
4074 SecurityOptions {
4075 root,
4076 config_path: &NO_CONFIG,
4077 output,
4078 no_cache: true,
4079 threads: 1,
4080 quiet: true,
4081 fail_on_issues,
4082 sarif_file: None,
4083 summary: false,
4084 changed_since: None,
4085 use_shared_diff_index: false,
4086 workspace: None,
4087 changed_workspaces: None,
4088 file: &[],
4089 surface: false,
4090 gate: None,
4091 runtime_coverage: None,
4092 min_invocations_hot: 100,
4093 explain: false,
4094 }
4095 }
4096
4097 #[test]
4098 fn source_reachability_fixture_marks_cross_module_sink() {
4099 let root = source_reachability_fixture_root();
4100 let mut config = load_config_for_analysis(
4101 &root,
4102 &NO_CONFIG,
4103 crate::ConfigLoadOptions {
4104 output: OutputFormat::Json,
4105 no_cache: true,
4106 threads: 1,
4107 production_override: None,
4108 quiet: true,
4109 },
4110 ProductionAnalysis::DeadCode,
4111 )
4112 .expect("fixture config loads");
4113 config.rules.security_sink = Severity::Warn;
4114
4115 let analysis = fallow_engine::analyze(&config).expect("fixture analyzes");
4116 let finding = analysis
4117 .results
4118 .security_findings
4119 .iter()
4120 .find(|finding| finding.path.ends_with("src/runner.ts"))
4121 .expect("runner sink finding");
4122 let reach = finding.reachability.as_ref().expect("reachability");
4123
4124 assert!(reach.reachable_from_untrusted_source);
4125 assert_eq!(reach.untrusted_source_hop_count, Some(1));
4126 assert_eq!(reach.taint_confidence, Some(TaintConfidence::ModuleLevel));
4130 assert_eq!(
4131 reach
4132 .untrusted_source_trace
4133 .iter()
4134 .map(|hop| hop.role)
4135 .collect::<Vec<_>>(),
4136 vec![TraceHopRole::ModuleSource, TraceHopRole::Sink]
4137 );
4138 assert!(
4139 reach.untrusted_source_trace[0]
4140 .path
4141 .ends_with("src/route.ts")
4142 );
4143
4144 assert!(
4148 finding.candidate.boundary.cross_module,
4149 "a sink reached across a module hop crosses a module boundary"
4150 );
4151 let flow = finding.taint_flow.as_ref().expect("taint_flow present");
4152 assert!(!flow.path.intra_module);
4153 assert_eq!(flow.path.cross_module_hops, 1);
4154 assert!(flow.source.path.ends_with("src/route.ts"));
4155 assert!(flow.sink.path.ends_with("src/runner.ts"));
4156 }
4157
4158 #[test]
4159 fn file_scope_keeps_security_finding_when_anchor_matches() {
4160 let root = Path::new("/proj/root");
4161 let mut results = AnalysisResults::default();
4162 results.security_findings.push(sample_finding(root));
4163
4164 filter_to_files(&mut results, root, &[PathBuf::from("src/app.tsx")], true);
4165
4166 assert_eq!(results.security_findings.len(), 1);
4167 }
4168
4169 #[test]
4170 fn file_scope_keeps_security_finding_when_trace_hop_matches() {
4171 let root = Path::new("/proj/root");
4172 let mut results = AnalysisResults::default();
4173 results.security_findings.push(sample_finding(root));
4174
4175 filter_to_files(
4176 &mut results,
4177 root,
4178 &[PathBuf::from("src/lib/secret.ts")],
4179 true,
4180 );
4181
4182 assert_eq!(results.security_findings.len(), 1);
4183 }
4184
4185 #[test]
4186 fn file_scope_drops_unrelated_security_finding() {
4187 let root = Path::new("/proj/root");
4188 let mut results = AnalysisResults::default();
4189 results.security_findings.push(sample_finding(root));
4190
4191 filter_to_files(&mut results, root, &[PathBuf::from("src/other.ts")], true);
4192
4193 assert!(results.security_findings.is_empty());
4194 }
4195
4196 #[test]
4197 fn run_is_advisory_and_exits_zero_even_with_candidates() {
4198 let root = leak_fixture_root();
4201 let code = run(&run_opts(&root, OutputFormat::Json, false));
4202 assert_eq!(code, ExitCode::SUCCESS);
4203 }
4204
4205 #[test]
4206 fn run_with_fail_on_issues_exits_one_when_candidates_found() {
4207 let root = leak_fixture_root();
4209 let code = run(&run_opts(&root, OutputFormat::Human, true));
4210 assert_eq!(code, ExitCode::from(1));
4211 }
4212
4213 #[test]
4214 fn run_rejects_unsupported_output_format() {
4215 let root = leak_fixture_root();
4217 let code = run(&run_opts(&root, OutputFormat::Compact, false));
4218 assert_eq!(code, ExitCode::from(2));
4219 }
4220
4221 #[test]
4222 fn run_summary_mode_dispatches_compact_human_renderer() {
4223 let root = leak_fixture_root();
4224 let opts = SecurityOptions {
4225 summary: true,
4226 ..run_opts(&root, OutputFormat::Human, false)
4227 };
4228 assert_eq!(run(&opts), ExitCode::SUCCESS);
4229 }
4230
4231 #[test]
4232 fn run_sarif_format_dispatches_sarif_renderer() {
4233 let root = leak_fixture_root();
4234 assert_eq!(
4235 run(&run_opts(&root, OutputFormat::Sarif, false)),
4236 ExitCode::SUCCESS
4237 );
4238 }
4239
4240 #[test]
4241 fn run_writes_sarif_sidecar_file_when_requested() {
4242 let root = leak_fixture_root();
4243 let dir = tempfile::tempdir().expect("tempdir");
4244 let sidecar = dir.path().join("security.sarif");
4245 let opts = SecurityOptions {
4246 sarif_file: Some(&sidecar),
4247 ..run_opts(&root, OutputFormat::Human, false)
4248 };
4249 assert_eq!(run(&opts), ExitCode::SUCCESS);
4250 let written = std::fs::read_to_string(&sidecar).expect("sidecar SARIF written");
4251 let sarif: serde_json::Value = serde_json::from_str(&written).expect("valid SARIF JSON");
4252 assert_eq!(sarif["version"], "2.1.0");
4253 }
4254}