use rustc_hash::{FxHashMap, FxHashSet};
use std::collections::VecDeque;
use fallow_types::extract::ModuleInfo;
use fallow_types::output::{IssueAction, SuppressFileAction, SuppressFileKind};
use fallow_types::results::{
SecurityCandidate, SecurityCandidateBoundary, SecurityCandidateSink, SecurityFinding,
SecurityFindingKind, TraceHop, TraceHopRole,
};
use fallow_types::suppress::IssueKind;
use super::{LineOffsetsMap, byte_offset_to_line_col};
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::suppress::SuppressionContext;
mod catalogue;
mod hardcoded_secret;
mod rank;
mod tainted_sink;
pub use hardcoded_secret::find_hardcoded_secret_candidates;
pub use rank::{annotate_dead_code_cross_links, rank_security_findings};
pub use tainted_sink::{CategoryFilter, find_tainted_sinks};
#[must_use]
pub fn catalogue_title(id: &str) -> Option<&'static str> {
if id == hardcoded_secret::CATEGORY_ID {
Some(hardcoded_secret::CATEGORY_TITLE)
} else {
catalogue::catalogue_title(id)
}
}
const SUPPRESS_KIND: &str = "security-client-server-leak";
fn build_actions() -> Vec<IssueAction> {
vec![IssueAction::SuppressFile(SuppressFileAction {
kind: SuppressFileKind::SuppressFile,
auto_fixable: false,
description: "Suppress with a file-level comment at the top of the client file".to_string(),
comment: format!("// fallow-ignore-file {SUPPRESS_KIND}"),
})]
}
const USE_CLIENT: &str = "use client";
const fn secret_word(count: usize) -> &'static str {
if count == 1 { "secret" } else { "secrets" }
}
const PROCESS_ENV_OBJECT: &str = "process.env";
const IMPORT_META_ENV_OBJECT: &str = "import.meta.env";
const ENV_SOURCE_OBJECTS: &[&str] = &[PROCESS_ENV_OBJECT, IMPORT_META_ENV_OBJECT];
use fallow_types::extract::is_public_env_var;
#[derive(Debug, Default, Clone, Copy)]
pub struct UnresolvedEdgeStats {
pub client_files_with_unresolved_edges: usize,
}
#[must_use]
pub fn find_security_findings(
graph: &ModuleGraph,
modules: &[ModuleInfo],
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> (Vec<SecurityFinding>, UnresolvedEdgeStats) {
let modules_by_id: FxHashMap<FileId, &ModuleInfo> =
modules.iter().map(|m| (m.file_id, m)).collect();
let secret_sources = compute_secret_source_set(&modules_by_id);
find_client_server_leaks(
graph,
&modules_by_id,
&secret_sources,
suppressions,
line_offsets_by_file,
)
}
fn compute_secret_source_set(
modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
) -> FxHashMap<FileId, Vec<String>> {
let mut sources: FxHashMap<FileId, Vec<String>> = FxHashMap::default();
for (&file_id, module) in modules_by_id {
let mut vars: Vec<String> = module
.member_accesses
.iter()
.filter(|ma| {
ENV_SOURCE_OBJECTS.contains(&ma.object.as_str()) && !is_public_env_var(&ma.member)
})
.map(|ma| format!("{}.{}", ma.object, ma.member))
.collect();
if vars.is_empty() {
continue;
}
vars.sort_unstable();
vars.dedup();
sources.insert(file_id, vars);
}
sources
}
fn find_client_server_leaks(
graph: &ModuleGraph,
modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
secret_sources: &FxHashMap<FileId, Vec<String>>,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> (Vec<SecurityFinding>, UnresolvedEdgeStats) {
let mut findings = Vec::new();
let mut stats = UnresolvedEdgeStats::default();
for node in &graph.modules {
let Some(module) = modules_by_id.get(&node.file_id) else {
continue;
};
if !module.directives.iter().any(|d| d == USE_CLIENT) {
continue;
}
let client_id = node.file_id;
if suppressions.is_file_suppressed(client_id, IssueKind::SecurityClientServerLeak) {
continue;
}
if secret_sources.contains_key(&client_id) {
findings.push(build_direct_finding(graph, client_id, secret_sources));
}
let mut visited: FxHashSet<FileId> = FxHashSet::default();
visited.insert(client_id);
let mut parent: FxHashMap<FileId, (FileId, Option<u32>)> = FxHashMap::default();
let mut queue: VecDeque<FileId> = VecDeque::new();
queue.push_back(client_id);
let mut had_unresolved_edge = false;
let mut reached_secret: Option<FileId> = None;
while let Some(current) = queue.pop_front() {
if let Some(current_module) = modules_by_id.get(¤t)
&& !current_module.dynamic_import_patterns.is_empty()
{
had_unresolved_edge = true;
}
if current != client_id
&& reached_secret.is_none()
&& secret_sources.contains_key(¤t)
{
reached_secret = Some(current);
}
for (target, all_type_only, span_start) in graph.outgoing_edge_summaries(current) {
if all_type_only {
continue; }
if visited.insert(target) {
parent.insert(target, (current, span_start));
queue.push_back(target);
}
}
}
if had_unresolved_edge {
stats.client_files_with_unresolved_edges += 1;
}
if let Some(secret_id) = reached_secret
&& !secret_sources.contains_key(&client_id)
{
findings.push(build_leak_finding(
graph,
client_id,
secret_id,
&parent,
secret_sources,
line_offsets_by_file,
));
}
}
findings.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
(findings, stats)
}
fn build_leak_finding(
graph: &ModuleGraph,
client_id: FileId,
secret_id: FileId,
parent: &FxHashMap<FileId, (FileId, Option<u32>)>,
secret_sources: &FxHashMap<FileId, Vec<String>>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> SecurityFinding {
let mut chain: Vec<FileId> = vec![secret_id];
let mut cursor = secret_id;
while let Some(&(prev, _)) = parent.get(&cursor) {
chain.push(prev);
cursor = prev;
if prev == client_id {
break;
}
}
chain.reverse();
let mut trace: Vec<TraceHop> = Vec::with_capacity(chain.len());
for (idx, &file_id) in chain.iter().enumerate() {
let role = if idx == 0 {
TraceHopRole::ClientBoundary
} else if file_id == secret_id {
TraceHopRole::SecretSource
} else {
TraceHopRole::Intermediate
};
let (line, col) = if let Some(&next) = chain.get(idx + 1) {
parent
.get(&next)
.and_then(|&(_, span)| span)
.map_or((1, 0), |s| {
byte_offset_to_line_col(line_offsets_by_file, file_id, s)
})
} else {
(1, 0)
};
trace.push(TraceHop {
path: graph.modules[file_id.0 as usize].path.clone(),
line,
col,
role,
});
}
let anchor = &trace[0];
let empty = Vec::new();
let var_list = secret_sources.get(&secret_id).unwrap_or(&empty);
let vars = var_list.join(", ");
let word = secret_word(var_list.len());
let evidence = format!(
"This \"use client\" file transitively imports a module that reads non-public \
env {word}: {vars} (see the secret-source hop in the trace). Candidate for \
verification: confirm the secret value actually reaches client-bundled code."
);
let candidate = client_leak_candidate(anchor.path.clone(), anchor.line, anchor.col);
SecurityFinding {
finding_id: String::new(),
kind: SecurityFindingKind::ClientServerLeak,
category: None,
cwe: None,
path: candidate.sink.path.clone(),
line: candidate.sink.line,
col: candidate.sink.col,
evidence,
source_backed: false,
trace,
actions: build_actions(),
dead_code: None,
reachability: None,
candidate,
taint_flow: None,
runtime: None,
attack_surface: None,
}
}
fn client_leak_candidate(path: std::path::PathBuf, line: u32, col: u32) -> SecurityCandidate {
SecurityCandidate {
source_kind: None,
sink: SecurityCandidateSink {
path,
line,
col,
category: None,
cwe: None,
callee: None,
},
boundary: SecurityCandidateBoundary::default(),
network: None,
}
}
fn build_direct_finding(
graph: &ModuleGraph,
client_id: FileId,
secret_sources: &FxHashMap<FileId, Vec<String>>,
) -> SecurityFinding {
let path = graph.modules[client_id.0 as usize].path.clone();
let empty = Vec::new();
let var_list = secret_sources.get(&client_id).unwrap_or(&empty);
let vars = var_list.join(", ");
let word = secret_word(var_list.len());
let evidence = format!(
"This \"use client\" file directly reads non-public env {word}: {vars}. \
Candidate for verification: confirm the secret value actually reaches client-bundled \
code (it may be guarded, server-only, or build-time-stripped)."
);
let candidate = client_leak_candidate(path.clone(), 1, 0);
SecurityFinding {
finding_id: String::new(),
kind: SecurityFindingKind::ClientServerLeak,
category: None,
cwe: None,
path: path.clone(),
line: 1,
col: 0,
evidence,
source_backed: false,
trace: vec![TraceHop {
path,
line: 1,
col: 0,
role: TraceHopRole::SecretSource,
}],
actions: build_actions(),
dead_code: None,
reachability: None,
candidate,
taint_flow: None,
runtime: None,
attack_surface: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_env_vars_are_not_secrets() {
assert!(is_public_env_var("NODE_ENV"));
assert!(is_public_env_var("NEXT_PUBLIC_API_URL"));
assert!(is_public_env_var("VITE_TITLE"));
assert!(is_public_env_var("PUBLIC_SITE_NAME"));
assert!(is_public_env_var("EXPO_PUBLIC_KEY"));
}
#[test]
fn real_secrets_are_not_public() {
assert!(!is_public_env_var("DATABASE_URL"));
assert!(!is_public_env_var("STRIPE_SECRET_KEY"));
assert!(!is_public_env_var("SESSION_SECRET"));
assert!(!is_public_env_var("MY_NEXT_PUBLIC_FAKE"));
}
}