Skip to main content

pedant_core/
gate.rs

1//! Gate rules engine: evaluates capability profiles and data flows against security rules.
2//!
3//! Capability-combination rules fire on suspicious co-occurrence of capabilities.
4//! Flow-aware rules fire when taint analysis detects a data path from source to sink.
5
6use std::collections::BTreeSet;
7use std::fmt;
8
9use pedant_types::{Capability, CapabilityFinding, FindingOrigin};
10use serde::Serialize;
11
12use crate::check_config::GateConfig;
13use crate::check_config::GateRuleOverride;
14use crate::ir::{DataFlowFact, DataFlowKind};
15
16/// Controls whether a gate verdict blocks CI, warns, or is purely informational.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "lowercase")]
19pub enum GateSeverity {
20    /// Blocks CI/publish with a non-zero exit code.
21    Deny,
22    /// Displayed but does not affect exit code.
23    Warn,
24    /// Logged for audit trail; no user-facing output by default.
25    Info,
26}
27
28impl fmt::Display for GateSeverity {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::Deny => f.write_str("deny"),
32            Self::Warn => f.write_str("warn"),
33            Self::Info => f.write_str("info"),
34        }
35    }
36}
37
38/// Produced when a gate rule's predicate matches the capability profile.
39#[derive(Serialize)]
40pub struct GateVerdict {
41    /// Kebab-case rule identifier (e.g., `"build-script-network"`).
42    pub rule: &'static str,
43    /// Effective severity after config overrides.
44    pub severity: GateSeverity,
45    /// Why this combination of capabilities is suspicious.
46    pub rationale: &'static str,
47}
48
49/// Public metadata for a built-in gate rule, used by `--list-checks` and MCP tools.
50pub struct GateRuleInfo {
51    /// Kebab-case identifier used in config overrides and output.
52    pub name: &'static str,
53    /// Severity applied when no config override is present.
54    pub default_severity: GateSeverity,
55    /// One-line summary of the suspicious pattern.
56    pub description: &'static str,
57    /// Why this combination of capabilities is suspicious.
58    pub rationale: &'static str,
59}
60
61/// Precomputed summary of capability findings and data flows for gate evaluation.
62///
63/// Built once per evaluation from raw findings and flows. Every rule predicate
64/// reads from this summary instead of rescanning the original slices.
65pub struct GateInputSummary {
66    all_capabilities: BTreeSet<Capability>,
67    build_hook_capabilities: BTreeSet<Capability>,
68    has_key_material: bool,
69    flow_kinds: BTreeSet<DataFlowKind>,
70    taint_pairs: BTreeSet<(Capability, Capability)>,
71}
72
73/// Check if a single finding represents embedded key material (not a crypto import).
74///
75/// When `origin` metadata is present, uses it directly: `StringLiteral` origin
76/// indicates embedded key material. Falls back to evidence-based heuristic
77/// (no `::` in evidence) for legacy findings without origin metadata.
78fn is_key_material(f: &CapabilityFinding) -> bool {
79    match (f.capability, f.origin) {
80        (Capability::Crypto, Some(FindingOrigin::StringLiteral)) => true,
81        (Capability::Crypto, None) => !f.evidence.contains("::"),
82        _ => false,
83    }
84}
85
86impl GateInputSummary {
87    /// Build a summary from raw findings and data flow facts.
88    pub fn from_analysis(findings: &[CapabilityFinding], flows: &[DataFlowFact]) -> Self {
89        let mut all_capabilities = BTreeSet::new();
90        let mut build_hook_capabilities = BTreeSet::new();
91        let has_key_material = findings.iter().any(is_key_material);
92
93        for finding in findings {
94            all_capabilities.insert(finding.capability);
95            if finding.is_build_hook() {
96                build_hook_capabilities.insert(finding.capability);
97            }
98        }
99
100        Self::with_capability_sets(
101            all_capabilities,
102            build_hook_capabilities,
103            has_key_material,
104            flows,
105        )
106    }
107
108    /// Build a summary from borrowed finding references and data flow facts.
109    pub fn from_refs(findings: &[&CapabilityFinding], flows: &[DataFlowFact]) -> Self {
110        let mut all_capabilities = BTreeSet::new();
111        let mut build_hook_capabilities = BTreeSet::new();
112        let has_key_material = findings.iter().any(|f| is_key_material(f));
113
114        for finding in findings {
115            all_capabilities.insert(finding.capability);
116            if finding.is_build_hook() {
117                build_hook_capabilities.insert(finding.capability);
118            }
119        }
120
121        Self::with_capability_sets(
122            all_capabilities,
123            build_hook_capabilities,
124            has_key_material,
125            flows,
126        )
127    }
128
129    fn with_capability_sets(
130        all_capabilities: BTreeSet<Capability>,
131        build_hook_capabilities: BTreeSet<Capability>,
132        has_key_material: bool,
133        flows: &[DataFlowFact],
134    ) -> Self {
135        let mut flow_kinds = BTreeSet::new();
136        let mut taint_pairs = BTreeSet::new();
137        for flow in flows {
138            flow_kinds.insert(flow.kind);
139            if let (Some(src), Some(sink)) = (flow.source_capability, flow.sink_capability) {
140                taint_pairs.insert((src, sink));
141            }
142        }
143
144        Self {
145            all_capabilities,
146            build_hook_capabilities,
147            has_key_material,
148            flow_kinds,
149            taint_pairs,
150        }
151    }
152
153    fn has_capability(&self, cap: Capability) -> bool {
154        self.all_capabilities.contains(&cap)
155    }
156
157    fn has_build_hook_capability(&self, cap: Capability) -> bool {
158        self.build_hook_capabilities.contains(&cap)
159    }
160
161    fn has_flow(&self, source: Capability, sink: Capability) -> bool {
162        self.taint_pairs.contains(&(source, sink))
163    }
164
165    fn has_kind(&self, kind: DataFlowKind) -> bool {
166        self.flow_kinds.contains(&kind)
167    }
168}
169
170/// Internal rule definition pairing metadata with a predicate over `GateInputSummary`.
171struct Rule {
172    name: &'static str,
173    default_severity: GateSeverity,
174    description: &'static str,
175    rationale: &'static str,
176    predicate: fn(&GateInputSummary) -> bool,
177}
178
179impl Rule {
180    /// Extract public metadata from this rule.
181    fn info(&self) -> GateRuleInfo {
182        GateRuleInfo {
183            name: self.name,
184            default_severity: self.default_severity,
185            description: self.description,
186            rationale: self.rationale,
187        }
188    }
189
190    /// Evaluate this rule against the summary, returning a verdict if the predicate
191    /// fires and the rule is not disabled by config.
192    fn evaluate(&self, summary: &GateInputSummary, config: &GateConfig) -> Option<GateVerdict> {
193        let severity = resolve_severity(self.name, self.default_severity, config)?;
194        (self.predicate)(summary).then_some(GateVerdict {
195            rule: self.name,
196            severity,
197            rationale: self.rationale,
198        })
199    }
200}
201
202const RULES: &[Rule] = &[
203    // --- Compile-time execution rules (build scripts) ---
204    Rule {
205        name: "build-script-network",
206        default_severity: GateSeverity::Deny,
207        description: "Build script with network access",
208        rationale: "Build scripts should not make network requests",
209        predicate: |s| s.has_build_hook_capability(Capability::Network),
210    },
211    Rule {
212        name: "build-script-exec",
213        default_severity: GateSeverity::Warn,
214        description: "Build script spawning processes",
215        rationale: "Build scripts spawning processes is common (cc, pkg-config) but risky",
216        predicate: |s| s.has_build_hook_capability(Capability::ProcessExec),
217    },
218    Rule {
219        name: "build-script-download-exec",
220        default_severity: GateSeverity::Deny,
221        description: "Build script with network access and process execution",
222        rationale: "Download-and-execute in build script — classic supply chain attack",
223        predicate: |s| {
224            s.has_build_hook_capability(Capability::Network)
225                && s.has_build_hook_capability(Capability::ProcessExec)
226        },
227    },
228    Rule {
229        name: "build-script-file-write",
230        default_severity: GateSeverity::Warn,
231        description: "Build script with filesystem write access",
232        rationale: "Build scripts writing outside OUT_DIR is suspicious",
233        predicate: |s| s.has_build_hook_capability(Capability::FileWrite),
234    },
235    // --- Compile-time execution rules (proc macros) ---
236    Rule {
237        name: "proc-macro-network",
238        default_severity: GateSeverity::Deny,
239        description: "Proc macro with network access",
240        rationale: "Proc macros have no legitimate reason for network access",
241        predicate: |s| {
242            s.has_capability(Capability::ProcMacro) && s.has_capability(Capability::Network)
243        },
244    },
245    Rule {
246        name: "proc-macro-exec",
247        default_severity: GateSeverity::Deny,
248        description: "Proc macro spawning processes",
249        rationale: "Proc macros have no legitimate reason to spawn processes",
250        predicate: |s| {
251            s.has_capability(Capability::ProcMacro) && s.has_capability(Capability::ProcessExec)
252        },
253    },
254    Rule {
255        name: "proc-macro-file-write",
256        default_severity: GateSeverity::Deny,
257        description: "Proc macro with filesystem write access",
258        rationale: "Proc macros should not write to the filesystem",
259        predicate: |s| {
260            s.has_capability(Capability::ProcMacro) && s.has_capability(Capability::FileWrite)
261        },
262    },
263    // --- Runtime combination rules ---
264    Rule {
265        name: "env-access-network",
266        default_severity: GateSeverity::Info,
267        description: "Environment variable access with network capability",
268        rationale: "Reading environment variables and accessing network — review for credential harvesting",
269        predicate: |s| {
270            s.has_capability(Capability::EnvAccess) && s.has_capability(Capability::Network)
271        },
272    },
273    Rule {
274        name: "key-material-network",
275        default_severity: GateSeverity::Warn,
276        description: "Embedded key material with network access",
277        rationale: "Embedded key material with network access — verify intent",
278        predicate: |s| s.has_capability(Capability::Network) && s.has_key_material,
279    },
280    // --- Flow-aware rules ---
281    Rule {
282        name: "env-to-network",
283        default_severity: GateSeverity::Deny,
284        description: "Data flows from environment variable to network sink",
285        rationale: "Environment variable value reaches a network call — potential credential exfiltration",
286        predicate: |s| s.has_flow(Capability::EnvAccess, Capability::Network),
287    },
288    Rule {
289        name: "file-to-network",
290        default_severity: GateSeverity::Deny,
291        description: "Data flows from file read to network sink",
292        rationale: "File content reaches a network call — potential data exfiltration",
293        predicate: |s| s.has_flow(Capability::FileRead, Capability::Network),
294    },
295    Rule {
296        name: "network-to-exec",
297        default_severity: GateSeverity::Deny,
298        description: "Data flows from network source to process execution",
299        rationale: "Network-sourced data reaches process execution — remote code execution risk",
300        predicate: |s| s.has_flow(Capability::Network, Capability::ProcessExec),
301    },
302    // --- Quality rules ---
303    Rule {
304        name: "dead-store",
305        default_severity: GateSeverity::Warn,
306        description: "Value assigned then overwritten before read",
307        rationale: "Dead store indicates wasted computation or a missing read",
308        predicate: |s| s.has_kind(DataFlowKind::DeadStore),
309    },
310    Rule {
311        name: "discarded-result",
312        default_severity: GateSeverity::Warn,
313        description: "Result-returning function called without binding the return",
314        rationale: "Discarded Result silently drops errors — handle or explicitly discard",
315        predicate: |s| s.has_kind(DataFlowKind::DiscardedResult),
316    },
317    Rule {
318        name: "partial-error-handling",
319        default_severity: GateSeverity::Warn,
320        description: "Result handled on some paths, dropped on others",
321        rationale: "Inconsistent error handling — some branches swallow errors silently",
322        predicate: |s| s.has_kind(DataFlowKind::PartialErrorHandling),
323    },
324    Rule {
325        name: "swallowed-ok",
326        default_severity: GateSeverity::Warn,
327        description: ".ok() on Result where Option is discarded",
328        rationale: ".ok() silently drops the error — handle the Result or explicitly discard with comment",
329        predicate: |s| s.has_kind(DataFlowKind::SwallowedOk),
330    },
331    Rule {
332        name: "immutable-growable",
333        default_severity: GateSeverity::Info,
334        description: "Vec or String never mutated after construction",
335        rationale: "Immutable growable collection — use Box<[T]> or Box<str> instead",
336        predicate: |s| s.has_kind(DataFlowKind::ImmutableGrowable),
337    },
338    // --- Performance rules ---
339    Rule {
340        name: "repeated-call",
341        default_severity: GateSeverity::Info,
342        description: "Same function called with identical arguments in single scope",
343        rationale: "Repeated call with same arguments — cache the result in a local binding",
344        predicate: |s| s.has_kind(DataFlowKind::RepeatedCall),
345    },
346    Rule {
347        name: "unnecessary-clone",
348        default_severity: GateSeverity::Info,
349        description: "Clone called but original never used afterward",
350        rationale: "Unnecessary clone — move the original instead of copying",
351        predicate: |s| s.has_kind(DataFlowKind::UnnecessaryClone),
352    },
353    Rule {
354        name: "allocation-in-loop",
355        default_severity: GateSeverity::Info,
356        description: "Heap allocation inside loop body",
357        rationale: "Allocation per iteration — hoist outside the loop and reuse with clear()",
358        predicate: |s| s.has_kind(DataFlowKind::AllocationInLoop),
359    },
360    Rule {
361        name: "redundant-collect",
362        default_severity: GateSeverity::Info,
363        description: "Collect followed immediately by re-iteration",
364        rationale: "Redundant collect — chain iterator operations without intermediate Vec",
365        predicate: |s| s.has_kind(DataFlowKind::RedundantCollect),
366    },
367    // --- Concurrency rules ---
368    Rule {
369        name: "lock-across-await",
370        default_severity: GateSeverity::Deny,
371        description: "Lock guard held across .await point",
372        rationale: "Lock guard held across await — potential deadlock or task starvation",
373        predicate: |s| s.has_kind(DataFlowKind::LockAcrossAwait),
374    },
375    Rule {
376        name: "inconsistent-lock-order",
377        default_severity: GateSeverity::Deny,
378        description: "Same locks acquired in different orders across functions",
379        rationale: "Inconsistent lock ordering across functions — potential deadlock",
380        predicate: |s| s.has_kind(DataFlowKind::InconsistentLockOrder),
381    },
382    Rule {
383        name: "unobserved-spawn",
384        default_severity: GateSeverity::Warn,
385        description: "Thread/task spawned with dropped JoinHandle",
386        rationale: "Dropped JoinHandle means panics in the spawned thread/task vanish silently",
387        predicate: |s| s.has_kind(DataFlowKind::UnobservedSpawn),
388    },
389];
390
391/// Enumerate every built-in gate rule with its default severity and description.
392pub fn all_gate_rules() -> Box<[GateRuleInfo]> {
393    RULES.iter().map(Rule::info).collect()
394}
395
396/// Run every enabled gate rule against a precomputed summary, returning fired verdicts.
397///
398/// Build the summary via `GateInputSummary::from_analysis` before calling this.
399/// Respects per-rule config overrides.
400pub fn evaluate_gate_rules(summary: &GateInputSummary, config: &GateConfig) -> Box<[GateVerdict]> {
401    if !config.enabled {
402        return Box::new([]);
403    }
404
405    RULES
406        .iter()
407        .filter_map(|rule| rule.evaluate(summary, config))
408        .collect()
409}
410
411/// Resolve the effective severity for a rule, returning `None` if disabled.
412fn resolve_severity(
413    name: &str,
414    default: GateSeverity,
415    config: &GateConfig,
416) -> Option<GateSeverity> {
417    match config.overrides.get(name) {
418        Some(GateRuleOverride::Disabled) => None,
419        Some(GateRuleOverride::Severity(s)) => Some(*s),
420        None => Some(default),
421    }
422}