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::{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 tainted_sink;
pub use catalogue::catalogue_title;
pub use tainted_sink::{CategoryFilter, find_tainted_sinks};
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 PUBLIC_ENV_PREFIXES: &[&str] = &[
"NEXT_PUBLIC_",
"VITE_",
"NUXT_PUBLIC_",
"REACT_APP_",
"PUBLIC_",
"GATSBY_",
"EXPO_PUBLIC_",
"STORYBOOK_",
];
const PUBLIC_ENV_EXACT: &[&str] = &["NODE_ENV"];
const PROCESS_ENV_OBJECT: &str = "process.env";
fn is_public_env_var(name: &str) -> bool {
PUBLIC_ENV_EXACT.contains(&name) || PUBLIC_ENV_PREFIXES.iter().any(|p| name.starts_with(p))
}
#[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| ma.object == PROCESS_ENV_OBJECT && !is_public_env_var(&ma.member))
.map(|ma| ma.member.clone())
.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 \
process.env {word}: {vars} (see the secret-source hop in the trace). Candidate for \
verification: confirm the secret value actually reaches client-bundled code."
);
SecurityFinding {
kind: SecurityFindingKind::ClientServerLeak,
category: None,
cwe: None,
path: anchor.path.clone(),
line: anchor.line,
col: anchor.col,
evidence,
trace,
actions: build_actions(),
}
}
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 process.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)."
);
SecurityFinding {
kind: SecurityFindingKind::ClientServerLeak,
category: None,
cwe: None,
path: path.clone(),
line: 1,
col: 0,
evidence,
trace: vec![TraceHop {
path,
line: 1,
col: 0,
role: TraceHopRole::SecretSource,
}],
actions: build_actions(),
}
}
#[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"));
}
}