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