1use rustc_hash::{FxHashMap, FxHashSet};
33use std::collections::VecDeque;
34
35use fallow_types::extract::ModuleInfo;
36use fallow_types::output::{IssueAction, SuppressFileAction, SuppressFileKind};
37use fallow_types::results::{
38 SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink, SecurityFinding,
39 SecurityFindingKind, SecuritySeverity, TraceHop, TraceHopRole,
40};
41use fallow_types::suppress::IssueKind;
42
43use super::{LineOffsetsMap, byte_offset_to_line_col};
44use crate::discover::FileId;
45use crate::graph::{ModuleGraph, ModuleNode};
46use crate::suppress::SuppressionContext;
47
48mod catalogue;
49mod hardcoded_secret;
50mod rank;
51mod tainted_sink;
52
53pub use hardcoded_secret::find_hardcoded_secret_candidates;
54pub use rank::{
55 SecurityRankingInput, annotate_dead_code_cross_links, derive_security_severity,
56 rank_security_findings,
57};
58pub use tainted_sink::{CategoryFilter, TaintedSinkContext, find_tainted_sinks};
59
60pub use catalogue::{CalleePattern, Matcher};
63
64#[must_use]
65pub fn catalogue_matchers() -> &'static [Matcher] {
66 catalogue::catalogue().matchers()
67}
68
69#[must_use]
70pub fn catalogue_title(id: &str) -> Option<&'static str> {
71 if id == hardcoded_secret::CATEGORY_ID {
72 Some(hardcoded_secret::CATEGORY_TITLE)
73 } else {
74 catalogue::catalogue_title(id)
75 }
76}
77
78const SUPPRESS_KIND: &str = "security-client-server-leak";
80
81const SERVER_ONLY_CATEGORY: &str = "server-only-import";
86
87fn build_actions() -> Vec<IssueAction> {
91 vec![IssueAction::SuppressFile(SuppressFileAction {
92 kind: SuppressFileKind::SuppressFile,
93 auto_fixable: false,
94 description: "Suppress with a file-level comment at the top of the client file".to_string(),
95 comment: format!("// fallow-ignore-file {SUPPRESS_KIND}"),
96 })]
97}
98
99fn build_client_server_leak_finding(
100 evidence: String,
101 trace: Vec<TraceHop>,
102 candidate: SecurityCandidate,
103) -> SecurityFinding {
104 let category = candidate.sink.category.clone();
105
106 SecurityFinding {
107 finding_id: String::new(),
108 kind: SecurityFindingKind::ClientServerLeak,
109 category,
110 cwe: None,
111 path: candidate.sink.path.clone(),
112 line: candidate.sink.line,
113 col: candidate.sink.col,
114 evidence,
115 source_backed: false,
118 source_read: None,
120 severity: SecuritySeverity::Low,
121 trace,
122 actions: build_actions(),
123 dead_code: None,
124 reachability: None,
125 candidate,
126 taint_flow: None,
127 runtime: None,
128 attack_surface: None,
129 }
130}
131
132const USE_CLIENT: &str = "use client";
134
135const fn secret_word(count: usize) -> &'static str {
137 if count == 1 { "secret" } else { "secrets" }
138}
139
140const PROCESS_ENV_OBJECT: &str = "process.env";
142const IMPORT_META_ENV_OBJECT: &str = "import.meta.env";
144const ENV_SOURCE_OBJECTS: &[&str] = &[PROCESS_ENV_OBJECT, IMPORT_META_ENV_OBJECT];
146
147use fallow_types::extract::is_public_env_var;
151
152#[derive(Debug, Default, Clone, Copy)]
155pub struct UnresolvedEdgeStats {
156 pub client_files_with_unresolved_edges: usize,
160}
161
162#[must_use]
166pub fn find_security_findings(
167 graph: &ModuleGraph,
168 modules: &[ModuleInfo],
169 suppressions: &SuppressionContext<'_>,
170 line_offsets_by_file: &LineOffsetsMap<'_>,
171) -> (Vec<SecurityFinding>, UnresolvedEdgeStats) {
172 let modules_by_id: FxHashMap<FileId, &ModuleInfo> =
173 modules.iter().map(|m| (m.file_id, m)).collect();
174
175 let secret_sources = compute_secret_source_set(&modules_by_id);
176 let server_only_sources = compute_server_only_source_set(&modules_by_id);
177
178 find_client_server_leaks(
179 graph,
180 &modules_by_id,
181 &secret_sources,
182 &server_only_sources,
183 suppressions,
184 line_offsets_by_file,
185 )
186}
187
188fn compute_secret_source_set(
192 modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
193) -> FxHashMap<FileId, Vec<String>> {
194 let mut sources: FxHashMap<FileId, Vec<String>> = FxHashMap::default();
195 for (&file_id, module) in modules_by_id {
196 let mut vars: Vec<String> = module
197 .member_accesses
198 .iter()
199 .filter(|ma| {
200 ENV_SOURCE_OBJECTS.contains(&ma.object.as_str()) && !is_public_env_var(&ma.member)
201 })
202 .map(|ma| format!("{}.{}", ma.object, ma.member))
203 .collect();
204 if vars.is_empty() {
205 continue;
206 }
207 vars.sort_unstable();
208 vars.dedup();
209 sources.insert(file_id, vars);
210 }
211 sources
212}
213
214fn compute_server_only_source_set(
221 modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
222) -> FxHashSet<FileId> {
223 let mut server_only: FxHashSet<FileId> = FxHashSet::default();
224 for (&file_id, module) in modules_by_id {
225 if super::server_only::is_server_only_module(module) {
226 server_only.insert(file_id);
227 }
228 }
229 server_only
230}
231
232struct ClientConeResult {
247 parent: FxHashMap<FileId, (FileId, Option<u32>)>,
248 reached_secret: Option<FileId>,
249 reached_server_only: Option<FileId>,
250 had_unresolved_edge: bool,
251}
252
253fn walk_client_cone(scan: &LeakScanInput<'_>, client_id: FileId) -> ClientConeResult {
258 let mut visited: FxHashSet<FileId> = FxHashSet::default();
259 visited.insert(client_id);
260 let mut result = ClientConeResult {
261 parent: FxHashMap::default(),
262 reached_secret: None,
263 reached_server_only: None,
264 had_unresolved_edge: false,
265 };
266 let mut queue: VecDeque<FileId> = VecDeque::new();
267 queue.push_back(client_id);
268
269 while let Some(current) = queue.pop_front() {
270 record_client_cone_module(scan, client_id, current, &mut result);
271 enqueue_client_cone_edges(scan, current, &mut visited, &mut result.parent, &mut queue);
272 }
273
274 result
275}
276
277fn record_client_cone_module(
278 scan: &LeakScanInput<'_>,
279 client_id: FileId,
280 current: FileId,
281 result: &mut ClientConeResult,
282) {
283 if let Some(current_module) = scan.modules_by_id.get(¤t)
284 && !current_module.dynamic_import_patterns.is_empty()
285 {
286 result.had_unresolved_edge = true;
287 }
288
289 if current == client_id {
290 return;
291 }
292 if result.reached_secret.is_none() && scan.secret_sources.contains_key(¤t) {
293 result.reached_secret = Some(current);
294 }
295 if result.reached_server_only.is_none() && scan.server_only_sources.contains(¤t) {
296 result.reached_server_only = Some(current);
297 }
298}
299
300fn enqueue_client_cone_edges(
301 scan: &LeakScanInput<'_>,
302 current: FileId,
303 visited: &mut FxHashSet<FileId>,
304 parent: &mut FxHashMap<FileId, (FileId, Option<u32>)>,
305 queue: &mut VecDeque<FileId>,
306) {
307 let excluded = scan
310 .client_only_spans
311 .get(¤t)
312 .unwrap_or(scan.empty_spans);
313 for (target, all_type_only, span_start, all_client_only) in scan
314 .graph
315 .outgoing_edge_summaries_with_exclusions(current, excluded)
316 {
317 if all_type_only {
318 continue; }
320 if all_client_only {
321 continue;
324 }
325 if visited.insert(target) {
326 parent.insert(target, (current, span_start));
327 queue.push_back(target);
328 }
329 }
330}
331
332fn find_client_server_leaks(
333 graph: &ModuleGraph,
334 modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
335 secret_sources: &FxHashMap<FileId, Vec<String>>,
336 server_only_sources: &FxHashSet<FileId>,
337 suppressions: &SuppressionContext<'_>,
338 line_offsets_by_file: &LineOffsetsMap<'_>,
339) -> (Vec<SecurityFinding>, UnresolvedEdgeStats) {
340 let mut findings = Vec::new();
341 let mut stats = UnresolvedEdgeStats::default();
342
343 let client_only_spans: FxHashMap<FileId, FxHashSet<u32>> = modules_by_id
348 .iter()
349 .filter(|(_, m)| !m.client_only_dynamic_import_spans.is_empty())
350 .map(|(&id, m)| {
351 (
352 id,
353 m.client_only_dynamic_import_spans.iter().copied().collect(),
354 )
355 })
356 .collect();
357 let empty_spans: FxHashSet<u32> = FxHashSet::default();
358
359 let scan = LeakScanInput {
360 graph,
361 modules_by_id,
362 secret_sources,
363 server_only_sources,
364 suppressions,
365 line_offsets_by_file,
366 client_only_spans: &client_only_spans,
367 empty_spans: &empty_spans,
368 };
369
370 for node in &graph.modules {
371 scan_client_file_for_leaks(&scan, node, &mut findings, &mut stats);
372 }
373
374 findings.sort_by(|a, b| {
375 a.path
376 .cmp(&b.path)
377 .then(a.line.cmp(&b.line))
378 .then(a.category.cmp(&b.category))
379 });
380 (findings, stats)
381}
382
383struct LeakScanInput<'a> {
385 graph: &'a ModuleGraph,
386 modules_by_id: &'a FxHashMap<FileId, &'a ModuleInfo>,
387 secret_sources: &'a FxHashMap<FileId, Vec<String>>,
388 server_only_sources: &'a FxHashSet<FileId>,
389 suppressions: &'a SuppressionContext<'a>,
390 line_offsets_by_file: &'a LineOffsetsMap<'a>,
391 client_only_spans: &'a FxHashMap<FileId, FxHashSet<u32>>,
392 empty_spans: &'a FxHashSet<u32>,
393}
394
395fn scan_client_file_for_leaks(
400 scan: &LeakScanInput<'_>,
401 node: &ModuleNode,
402 findings: &mut Vec<SecurityFinding>,
403 stats: &mut UnresolvedEdgeStats,
404) {
405 let Some(module) = scan.modules_by_id.get(&node.file_id) else {
406 return;
407 };
408 if !module.directives.iter().any(|d| d == USE_CLIENT) {
409 return;
410 }
411 let client_id = node.file_id;
412 if scan
417 .suppressions
418 .is_file_suppressed(client_id, IssueKind::SecurityClientServerLeak)
419 {
420 return;
421 }
422
423 emit_direct_client_file_leaks(scan, client_id, findings);
424 emit_transitive_client_file_leaks(scan, client_id, findings, stats);
425}
426
427fn emit_direct_client_file_leaks(
428 scan: &LeakScanInput<'_>,
429 client_id: FileId,
430 findings: &mut Vec<SecurityFinding>,
431) {
432 if scan.secret_sources.contains_key(&client_id) {
435 findings.push(build_direct_finding(
436 scan.graph,
437 client_id,
438 scan.secret_sources,
439 ));
440 }
442
443 if scan.server_only_sources.contains(&client_id) {
449 findings.push(build_direct_server_only_finding(scan.graph, client_id));
450 }
451}
452
453fn emit_transitive_client_file_leaks(
454 scan: &LeakScanInput<'_>,
455 client_id: FileId,
456 findings: &mut Vec<SecurityFinding>,
457 stats: &mut UnresolvedEdgeStats,
458) {
459 let cone = walk_client_cone(scan, client_id);
461
462 if cone.had_unresolved_edge {
463 stats.client_files_with_unresolved_edges += 1;
464 }
465
466 if let Some(secret_id) = cone.reached_secret
469 && !scan.secret_sources.contains_key(&client_id)
470 {
471 findings.push(build_leak_finding(
472 scan.graph,
473 client_id,
474 secret_id,
475 &cone.parent,
476 scan.secret_sources,
477 scan.line_offsets_by_file,
478 ));
479 }
480
481 if let Some(server_id) = cone.reached_server_only
487 && !scan.server_only_sources.contains(&client_id)
488 {
489 findings.push(build_server_only_finding(
490 scan.graph,
491 client_id,
492 server_id,
493 &cone.parent,
494 scan.line_offsets_by_file,
495 ));
496 }
497}
498
499fn build_client_server_trace(
505 graph: &ModuleGraph,
506 client_id: FileId,
507 sink_id: FileId,
508 parent: &FxHashMap<FileId, (FileId, Option<u32>)>,
509 line_offsets_by_file: &LineOffsetsMap<'_>,
510 terminal_role: TraceHopRole,
511) -> Vec<TraceHop> {
512 let mut chain: Vec<FileId> = vec![sink_id];
514 let mut cursor = sink_id;
515 while let Some(&(prev, _)) = parent.get(&cursor) {
516 chain.push(prev);
517 cursor = prev;
518 if prev == client_id {
519 break;
520 }
521 }
522 chain.reverse(); let mut trace: Vec<TraceHop> = Vec::with_capacity(chain.len());
525 for (idx, &file_id) in chain.iter().enumerate() {
526 let role = if idx == 0 {
527 TraceHopRole::ClientBoundary
528 } else if file_id == sink_id {
529 terminal_role
530 } else {
531 TraceHopRole::Intermediate
532 };
533 let (line, col) = if let Some(&next) = chain.get(idx + 1) {
537 parent
538 .get(&next)
539 .and_then(|&(_, span)| span)
540 .map_or((1, 0), |s| {
541 byte_offset_to_line_col(line_offsets_by_file, file_id, s)
542 })
543 } else {
544 (1, 0)
545 };
546 trace.push(TraceHop {
547 path: graph.modules[file_id.0 as usize].path.clone(),
548 line,
549 col,
550 role,
551 });
552 }
553 trace
554}
555
556fn build_leak_finding(
561 graph: &ModuleGraph,
562 client_id: FileId,
563 secret_id: FileId,
564 parent: &FxHashMap<FileId, (FileId, Option<u32>)>,
565 secret_sources: &FxHashMap<FileId, Vec<String>>,
566 line_offsets_by_file: &LineOffsetsMap<'_>,
567) -> SecurityFinding {
568 let trace = build_client_server_trace(
569 graph,
570 client_id,
571 secret_id,
572 parent,
573 line_offsets_by_file,
574 TraceHopRole::SecretSource,
575 );
576
577 let anchor = &trace[0];
578 let empty = Vec::new();
579 let var_list = secret_sources.get(&secret_id).unwrap_or(&empty);
580 let vars = var_list.join(", ");
581 let word = secret_word(var_list.len());
582 let evidence = format!(
587 "This \"use client\" file transitively imports a module that reads non-public \
588 env {word}: {vars} (see the secret-source hop in the trace). Candidate for \
589 verification: confirm the secret value actually reaches client-bundled code."
590 );
591
592 let candidate = client_leak_candidate(anchor.path.clone(), anchor.line, anchor.col, None);
596
597 build_client_server_leak_finding(evidence, trace, candidate)
598}
599
600fn build_server_only_finding(
608 graph: &ModuleGraph,
609 client_id: FileId,
610 server_id: FileId,
611 parent: &FxHashMap<FileId, (FileId, Option<u32>)>,
612 line_offsets_by_file: &LineOffsetsMap<'_>,
613) -> SecurityFinding {
614 let trace = build_client_server_trace(
615 graph,
616 client_id,
617 server_id,
618 parent,
619 line_offsets_by_file,
620 TraceHopRole::Sink,
621 );
622
623 let anchor = &trace[0];
624 let evidence = "This \"use client\" file transitively imports a SERVER-ONLY module \
629 (it carries a \"use server\" directive or imports server-only code such as \
630 server-only, next/headers, next/server, or node:fs / node:child_process; see the \
631 sink hop in the trace). Candidate for verification: confirm whether this server-only \
632 code is meant to run on the client. If it is pulled in only through \
633 next/dynamic(..., { ssr: false }), it is the sanctioned client-only escape hatch and \
634 is a false positive."
635 .to_owned();
636
637 let candidate = client_leak_candidate(
638 anchor.path.clone(),
639 anchor.line,
640 anchor.col,
641 Some(SERVER_ONLY_CATEGORY.to_owned()),
642 );
643
644 build_client_server_leak_finding(evidence, trace, candidate)
645}
646
647fn client_leak_candidate(
654 path: std::path::PathBuf,
655 line: u32,
656 col: u32,
657 category: Option<String>,
658) -> SecurityCandidate {
659 SecurityCandidate {
660 source_kind: None,
661 sink: SecurityCandidateSink {
662 path,
663 line,
664 col,
665 category,
666 cwe: None,
667 callee: None,
668 url_shape: None,
669 },
670 boundary: SecurityCandidateBoundary::default(),
671 network: None,
672 }
673}
674
675fn build_direct_finding(
679 graph: &ModuleGraph,
680 client_id: FileId,
681 secret_sources: &FxHashMap<FileId, Vec<String>>,
682) -> SecurityFinding {
683 let path = graph.modules[client_id.0 as usize].path.clone();
684 let empty = Vec::new();
685 let var_list = secret_sources.get(&client_id).unwrap_or(&empty);
686 let vars = var_list.join(", ");
687 let word = secret_word(var_list.len());
688 let evidence = format!(
689 "This \"use client\" file directly reads non-public env {word}: {vars}. \
690 Candidate for verification: confirm the secret value actually reaches client-bundled \
691 code (it may be guarded, server-only, or build-time-stripped)."
692 );
693 let candidate = client_leak_candidate(path.clone(), 1, 0, None);
694 let trace = vec![TraceHop {
695 path,
696 line: 1,
697 col: 0,
698 role: TraceHopRole::SecretSource,
699 }];
700 build_client_server_leak_finding(evidence, trace, candidate)
701}
702
703fn build_direct_server_only_finding(graph: &ModuleGraph, client_id: FileId) -> SecurityFinding {
710 let path = graph.modules[client_id.0 as usize].path.clone();
711 let evidence = "This \"use client\" file directly imports SERVER-ONLY code \
712 (it carries a \"use server\" directive or imports server-only code such as \
713 server-only, next/headers, next/server, or node:fs / node:child_process). Candidate \
714 for verification: confirm whether this server-only code is meant to run on the client."
715 .to_owned();
716 let candidate =
717 client_leak_candidate(path.clone(), 1, 0, Some(SERVER_ONLY_CATEGORY.to_owned()));
718 let trace = vec![TraceHop {
719 path,
720 line: 1,
721 col: 0,
722 role: TraceHopRole::Sink,
723 }];
724 build_client_server_leak_finding(evidence, trace, candidate)
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730
731 #[test]
732 fn public_env_vars_are_not_secrets() {
733 assert!(is_public_env_var("NODE_ENV"));
734 assert!(is_public_env_var("NEXT_PUBLIC_API_URL"));
735 assert!(is_public_env_var("VITE_TITLE"));
736 assert!(is_public_env_var("PUBLIC_SITE_NAME"));
737 assert!(is_public_env_var("EXPO_PUBLIC_KEY"));
738 }
739
740 #[test]
741 fn real_secrets_are_not_public() {
742 assert!(!is_public_env_var("DATABASE_URL"));
743 assert!(!is_public_env_var("STRIPE_SECRET_KEY"));
744 assert!(!is_public_env_var("SESSION_SECRET"));
745 assert!(!is_public_env_var("MY_NEXT_PUBLIC_FAKE"));
747 }
748}