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, SecuritySeverity, 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, derive_security_severity, rank_security_findings};
pub use tainted_sink::{CategoryFilter, TaintedSinkContext, find_tainted_sinks};
pub use catalogue::CalleePattern;
#[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";
const SERVER_ONLY_CATEGORY: &str = "server-only-import";
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);
let server_only_sources = compute_server_only_source_set(&modules_by_id);
find_client_server_leaks(
graph,
&modules_by_id,
&secret_sources,
&server_only_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 compute_server_only_source_set(
modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
) -> FxHashSet<FileId> {
let mut server_only: FxHashSet<FileId> = FxHashSet::default();
for (&file_id, module) in modules_by_id {
if super::server_only::is_server_only_module(module) {
server_only.insert(file_id);
}
}
server_only
}
fn find_client_server_leaks(
graph: &ModuleGraph,
modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
secret_sources: &FxHashMap<FileId, Vec<String>>,
server_only_sources: &FxHashSet<FileId>,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> (Vec<SecurityFinding>, UnresolvedEdgeStats) {
let mut findings = Vec::new();
let mut stats = UnresolvedEdgeStats::default();
let client_only_spans: FxHashMap<FileId, FxHashSet<u32>> = modules_by_id
.iter()
.filter(|(_, m)| !m.client_only_dynamic_import_spans.is_empty())
.map(|(&id, m)| {
(
id,
m.client_only_dynamic_import_spans.iter().copied().collect(),
)
})
.collect();
let empty_spans: FxHashSet<u32> = FxHashSet::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));
}
if server_only_sources.contains(&client_id) {
findings.push(build_direct_server_only_finding(graph, client_id));
}
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;
let mut reached_server_only: 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 {
if reached_secret.is_none() && secret_sources.contains_key(¤t) {
reached_secret = Some(current);
}
if reached_server_only.is_none() && server_only_sources.contains(¤t) {
reached_server_only = Some(current);
}
}
let excluded = client_only_spans.get(¤t).unwrap_or(&empty_spans);
for (target, all_type_only, span_start, all_client_only) in
graph.outgoing_edge_summaries_with_exclusions(current, excluded)
{
if all_type_only {
continue; }
if all_client_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,
));
}
if let Some(server_id) = reached_server_only
&& !server_only_sources.contains(&client_id)
{
findings.push(build_server_only_finding(
graph,
client_id,
server_id,
&parent,
line_offsets_by_file,
));
}
}
findings.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.category.cmp(&b.category))
});
(findings, stats)
}
fn build_client_server_trace(
graph: &ModuleGraph,
client_id: FileId,
sink_id: FileId,
parent: &FxHashMap<FileId, (FileId, Option<u32>)>,
line_offsets_by_file: &LineOffsetsMap<'_>,
terminal_role: TraceHopRole,
) -> Vec<TraceHop> {
let mut chain: Vec<FileId> = vec![sink_id];
let mut cursor = sink_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 == sink_id {
terminal_role
} 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,
});
}
trace
}
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 trace = build_client_server_trace(
graph,
client_id,
secret_id,
parent,
line_offsets_by_file,
TraceHopRole::SecretSource,
);
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, None);
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,
source_read: None,
severity: SecuritySeverity::Low,
trace,
actions: build_actions(),
dead_code: None,
reachability: None,
candidate,
taint_flow: None,
runtime: None,
attack_surface: None,
}
}
fn build_server_only_finding(
graph: &ModuleGraph,
client_id: FileId,
server_id: FileId,
parent: &FxHashMap<FileId, (FileId, Option<u32>)>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> SecurityFinding {
let trace = build_client_server_trace(
graph,
client_id,
server_id,
parent,
line_offsets_by_file,
TraceHopRole::Sink,
);
let anchor = &trace[0];
let evidence = "This \"use client\" file transitively imports a SERVER-ONLY module \
(it carries a \"use server\" directive or imports server-only code such as \
server-only, next/headers, next/server, or node:fs / node:child_process; see the \
sink hop in the trace). Candidate for verification: confirm whether this server-only \
code is meant to run on the client. If it is pulled in only through \
next/dynamic(..., { ssr: false }), it is the sanctioned client-only escape hatch and \
is a false positive."
.to_owned();
let candidate = client_leak_candidate(
anchor.path.clone(),
anchor.line,
anchor.col,
Some(SERVER_ONLY_CATEGORY.to_owned()),
);
SecurityFinding {
finding_id: String::new(),
kind: SecurityFindingKind::ClientServerLeak,
category: Some(SERVER_ONLY_CATEGORY.to_owned()),
cwe: None,
path: candidate.sink.path.clone(),
line: candidate.sink.line,
col: candidate.sink.col,
evidence,
source_backed: false,
source_read: None,
severity: SecuritySeverity::Low,
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,
category: Option<String>,
) -> SecurityCandidate {
SecurityCandidate {
source_kind: None,
sink: SecurityCandidateSink {
path,
line,
col,
category,
cwe: None,
callee: None,
url_shape: 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, None);
SecurityFinding {
finding_id: String::new(),
kind: SecurityFindingKind::ClientServerLeak,
category: None,
cwe: None,
path: path.clone(),
line: 1,
col: 0,
evidence,
source_backed: false,
source_read: None,
severity: SecuritySeverity::Low,
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,
}
}
fn build_direct_server_only_finding(graph: &ModuleGraph, client_id: FileId) -> SecurityFinding {
let path = graph.modules[client_id.0 as usize].path.clone();
let evidence = "This \"use client\" file directly imports SERVER-ONLY code \
(it carries a \"use server\" directive or imports server-only code such as \
server-only, next/headers, next/server, or node:fs / node:child_process). Candidate \
for verification: confirm whether this server-only code is meant to run on the client."
.to_owned();
let candidate =
client_leak_candidate(path.clone(), 1, 0, Some(SERVER_ONLY_CATEGORY.to_owned()));
SecurityFinding {
finding_id: String::new(),
kind: SecurityFindingKind::ClientServerLeak,
category: Some(SERVER_ONLY_CATEGORY.to_owned()),
cwe: None,
path: path.clone(),
line: 1,
col: 0,
evidence,
source_backed: false,
source_read: None,
severity: SecuritySeverity::Low,
trace: vec![TraceHop {
path,
line: 1,
col: 0,
role: TraceHopRole::Sink,
}],
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"));
}
}