Skip to main content

fallow_core/analyze/security/
mod.rs

1//! Local security candidate detection (opt-in, surfaced only by `fallow security`).
2//!
3//! These are CANDIDATES for downstream agent verification, NOT verified
4//! vulnerabilities. The graph-structural `client-server-leak` rule has two
5//! sink predicates over the SAME `"use client"` transitive static-import cone:
6//!
7//! 1. The cone reaches a module that reads a non-public env secret
8//!    (`category: None`, the original finding).
9//! 2. The cone reaches a SERVER-ONLY module (`category: Some("server-only-import")`):
10//!    a module carrying `"use server"`, importing the `server-only` poison
11//!    package, or importing a server-only Next.js / Node API (see the shared
12//!    `is_server_only_module` predicate in `analyze::server_only`).
13//!
14//! fallow emits the structural import-hop trace; it does not prove the path is
15//! exploitable.
16//!
17//! Blind spots (surfaced in-band via [`UnresolvedEdgeStats`], not silently
18//! dropped): the BFS follows only resolved static import edges, so glob-shaped
19//! dynamic `import()` patterns and unresolved specifiers can hide a real leak.
20//!
21//! ssr:false escape hatch: a server module pulled in ONLY through
22//! `next/dynamic(() => import('./X'), { ssr: false })` is the sanctioned
23//! client-only escape hatch, NOT a leak. fallow's arrow-wrapped dynamic-import
24//! detection resolves `next/dynamic(() => import('./X'))` to a STATIC graph edge
25//! (it credits the target's default export), so that edge IS in the cone. The
26//! extract layer records the `import()` span of each ssr:false call on
27//! `ModuleInfo::client_only_dynamic_import_spans`, and the BFS excludes an edge
28//! reached ONLY through such a span (see
29//! `ModuleGraph::outgoing_edge_summaries_with_exclusions`). An edge also reached
30//! via a real static import stays in the cone.
31
32use 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
60/// Segment-aware callee pattern matcher, re-exported for the boundary
61/// forbidden-call detector (`analyze::boundary_calls`).
62pub 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
78/// The inline suppression kind token for the client-server-leak rule.
79const SUPPRESS_KIND: &str = "security-client-server-leak";
80
81/// Stable `category` string distinguishing the server-only-import sink from the
82/// secret-leak sink (`category: None`). Same rule, same suppress kind, same
83/// `SecurityFinding` shape; only the category differs so JSON / human / SARIF
84/// consumers can tell "reaches server-only code" apart from "reads a secret".
85const SERVER_ONLY_CATEGORY: &str = "server-only-import";
86
87/// Build the machine-actionable suppress hint emitted on every finding. Single
88/// file-level suppress action (`auto_fixable: false`): there is no auto-fix
89/// because verifying the candidate is the agent's job, not fallow's.
90fn 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        // The client-server-leak rule is graph-structural, not source-to-sink;
116        // source-backing is a tainted-sink concept (issue #859).
117        source_backed: false,
118        // client-server-leak is module-level by construction (no arg-level read).
119        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
132/// The React Server Components client-boundary directive.
133const USE_CLIENT: &str = "use client";
134
135/// Singular/plural noun for the count of secret vars named in the evidence.
136const fn secret_word(count: usize) -> &'static str {
137    if count == 1 { "secret" } else { "secrets" }
138}
139
140/// The `member_accesses` object string for a `process.env.X` read.
141const PROCESS_ENV_OBJECT: &str = "process.env";
142/// The `member_accesses` object string for an `import.meta.env.X` read.
143const IMPORT_META_ENV_OBJECT: &str = "import.meta.env";
144/// Static env source objects that feed the client/server leak candidate rule.
145const ENV_SOURCE_OBJECTS: &[&str] = &[PROCESS_ENV_OBJECT, IMPORT_META_ENV_OBJECT];
146
147// The public-env predicate (`is_public_env_var`, `PUBLIC_ENV_PREFIXES`) is shared
148// with the extract layer in `fallow_types::extract` (issue #890), so public env
149// vars are excluded consistently here and at source-recording time.
150use fallow_types::extract::is_public_env_var;
151
152/// Blind-spot accounting surfaced in-band so the user is never told "clean" when
153/// the analysis could not see part of the import graph.
154#[derive(Debug, Default, Clone, Copy)]
155pub struct UnresolvedEdgeStats {
156    /// Number of `"use client"` files whose transitive import cone contains a
157    /// dynamic `import()` pattern the reachability BFS cannot follow, so a leak
158    /// could be hiding behind it.
159    pub client_files_with_unresolved_edges: usize,
160}
161
162/// Run the security MVP rules over the graph. Returns the findings plus the
163/// blind-spot stats. Callers gate this on the `security_client_server_leak`
164/// rule severity; it never runs under bare `fallow` or the `audit` gate.
165#[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
188/// Map each module that reads a non-public env secret to the source names it
189/// reads. `process.env.X` and `import.meta.env.X` reads both surface as
190/// `MemberAccess` rows from static-member capture.
191fn 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
214/// Set of file ids whose module is a SERVER-ONLY sink: it carries a `"use server"`
215/// directive, imports a server-only package, or imports a server-only named API
216/// from `next/headers`. Delegates to the shared
217/// [`is_server_only_module`](super::server_only::is_server_only_module) predicate
218/// so the server-only definition is identical to the
219/// `mixed_client_server_barrel` detector's.
220fn 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
232/// For each `"use client"` file, BFS its transitive static-import cone. Two
233/// distinct sink predicates run over the SAME cone:
234///
235/// - reaching a module that reads a non-public env secret emits the secret-leak
236///   finding (`category: None`);
237/// - reaching a SERVER-ONLY module emits the server-only finding
238///   (`category: Some("server-only-import")`).
239///
240/// Both can fire for one client file (they are different concerns). Type-only
241/// edges are skipped (erased at build, so they cannot carry a secret or a
242/// server-only import into the client bundle).
243/// Result of a single client file's import-cone BFS: the parent map for trace
244/// reconstruction, the first secret / server-only module reached, and whether
245/// any unresolved dynamic-import edge was seen in the cone.
246struct 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
253/// BFS the static import cone of a `"use client"` file, draining the FULL cone
254/// (no early break) so the dynamic-import blind-spot count reflects every edge,
255/// not just the path to the first finding. Type-only and `next/dynamic
256/// ssr:false`-only edges are excluded (neither can leak into the client bundle).
257fn 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(&current)
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(&current) {
293        result.reached_secret = Some(current);
294    }
295    if result.reached_server_only.is_none() && scan.server_only_sources.contains(&current) {
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    // Exclude edges reached only through a `next/dynamic ssr:false`
308    // dynamic import made by `current` (the client-only escape hatch).
309    let excluded = scan
310        .client_only_spans
311        .get(&current)
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; // type-only imports are erased at build; cannot leak.
319        }
320        if all_client_only {
321            // Reached only via next/dynamic ssr:false: the sanctioned
322            // client-only escape hatch, not a leak edge.
323            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    // Per-file set of `next/dynamic(..., { ssr: false })` dynamic-import span
344    // starts. The BFS excludes an edge reached ONLY through one of these so a
345    // server-only (or secret) module pulled in via the sanctioned client-only
346    // escape hatch is not flagged. Empty sets are skipped at the call site.
347    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
383/// Shared immutable inputs for the per-client-file leak scan.
384struct 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
395/// Emit direct and transitive client-server leak findings for one module when it
396/// is a non-suppressed `"use client"` file. Non-client and file-suppressed nodes
397/// are skipped. Direct secret / server-only reads and transitive cone reaches
398/// each emit at most one finding, never double-flagging the same file.
399fn 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    // A file-level `// fallow-ignore-file security-client-server-leak`
413    // (or a blanket file ignore) opts the whole client file out. Routed
414    // through the SuppressionContext so the marker is recorded as consumed
415    // (otherwise a working suppression would later be flagged stale).
416    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    // Direct case: the client file itself reads a non-public secret. The
433    // most direct leak; no import hop needed.
434    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        // Still count its dynamic-import blind spot below.
441    }
442
443    // Direct server-only case: the client file itself IS a server-only sink
444    // (carries "use server", imports a server-only package, or imports a
445    // server-only next/headers API). The most direct server-only leak; no
446    // import hop needed. The transitive server-only emit below is gated so a
447    // file that is both a direct AND a transitive sink is flagged once.
448    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    // Transitive case: BFS the import cone.
460    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    // Only emit a transitive secret finding when the client is not ALREADY
467    // flagged by the direct case (avoid double-flagging the same file).
468    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    // The server-only sink is a DISTINCT category, independent of the secret
482    // case: a client cone can reach BOTH a secret reader and a server-only
483    // module, and a reviewer wants to see both. Only emit a transitive
484    // server-only finding when the client is not ALREADY flagged by the
485    // direct server-only case above (avoid double-flagging the same file).
486    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
499/// Walk the BFS parent map from `sink_id` back to `client_id`, building the
500/// shortest-path trace. Each non-terminal hop's line is the import site in that
501/// file (where it imports the NEXT hop); the terminal sink hop has no outgoing
502/// edge in the chain so it anchors at line 1 with `terminal_role`. Shared by the
503/// secret-leak (`SecretSource`) and server-only (`Sink`) findings.
504fn 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    // Walk parent pointers from the sink back to the client, then reverse.
513    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(); // now [client_id, ..., sink_id]
523
524    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        // The line for a non-terminal hop is where it imports the NEXT hop.
534        // Member-access / import extraction does not carry a precise span for the
535        // terminal sink hop, so it falls back to the source module start.
536        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
556/// Reconstruct the shortest path `client -> ... -> secret` from the BFS parent
557/// map and build the finding. Each non-terminal hop's line is the import site in
558/// that file (where it imports the next hop); the secret-source hop has no
559/// outgoing edge in the chain so it anchors at line 1.
560fn 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    // Do NOT embed the secret file path in the message: the SecretSource trace
583    // hop already names it (and is root-relativized by the CLI before display),
584    // so embedding the absolute path here would leak it past relativization and
585    // make output environment-dependent.
586    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    // The client-server-leak rule is graph-structural, not catalogue-driven:
593    // no source kind, no callee, no CWE. The candidate's sink slot anchors on
594    // the client boundary file; the boundary slot is filled by the ranking pass.
595    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
600/// Build a finding for the SERVER-ONLY sink: a `"use client"` file whose
601/// transitive static-import cone reaches a server-only module (one carrying
602/// `"use server"` or importing a server-only package). Same rule, same suppress
603/// kind, same `SecurityFinding` shape as the secret leak; distinguished by
604/// `category: Some("server-only-import")` so consumers can tell the two apart.
605/// The terminal trace hop carries the `Sink` role (the server-only module is the
606/// sink of this candidate).
607fn 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    // Do NOT embed the server module path in the message: the Sink trace hop
625    // already names it (root-relativized by the CLI before display), so embedding
626    // the absolute path here would leak it past relativization and make output
627    // environment-dependent.
628    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
647/// Build the candidate record for a `client-server-leak` finding. These findings
648/// carry no source kind (graph-structural, not source-to-sink) and no callee;
649/// the sink slot anchors on the client boundary file. The boundary slot starts
650/// at its default and is filled by the post-detection ranking pass. `category`
651/// is `None` for the secret-leak finding and `Some("server-only-import")` for the
652/// server-only finding, mirroring the finding's top-level `category`.
653fn 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
675/// Build a finding for the direct case: a `"use client"` file that itself reads
676/// a non-public secret. Trace is a single hop on the client file, which is both
677/// the boundary and the secret source.
678fn 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
703/// Build a finding for the direct SERVER-ONLY case: a `"use client"` file that is
704/// itself a server-only sink (carries `"use server"`, imports a server-only
705/// package, or imports a server-only `next/headers` API). No import hop is needed,
706/// so the trace is a single self-hop on the client file, which is both the client
707/// boundary and the server-only sink. Mirrors [`build_direct_finding`] for the
708/// secret case; distinguished by `category: Some("server-only-import")`.
709fn 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        // A var that merely contains a public token mid-name is still a secret.
746        assert!(!is_public_env_var("MY_NEXT_PUBLIC_FAKE"));
747    }
748}