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::fmt;
7
8use pedant_types::{Capability, CapabilityFinding};
9use serde::Serialize;
10
11use crate::check_config::GateConfig;
12use crate::check_config::GateRuleOverride;
13use crate::ir::DataFlowFact;
14
15/// Controls whether a gate verdict blocks CI, warns, or is purely informational.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum GateSeverity {
19    /// Blocks CI/publish with a non-zero exit code.
20    Deny,
21    /// Displayed but does not affect exit code.
22    Warn,
23    /// Logged for audit trail; no user-facing output by default.
24    Info,
25}
26
27impl fmt::Display for GateSeverity {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::Deny => f.write_str("deny"),
31            Self::Warn => f.write_str("warn"),
32            Self::Info => f.write_str("info"),
33        }
34    }
35}
36
37/// Produced when a gate rule's predicate matches the capability profile.
38#[derive(Serialize)]
39pub struct GateVerdict {
40    /// Kebab-case rule identifier (e.g., `"build-script-network"`).
41    pub rule: &'static str,
42    /// Effective severity after config overrides.
43    pub severity: GateSeverity,
44    /// Why this combination of capabilities is suspicious.
45    pub rationale: &'static str,
46}
47
48/// Public metadata for a built-in gate rule, used by `--list-checks` and MCP tools.
49pub struct GateRuleInfo {
50    /// Kebab-case identifier used in config overrides and output.
51    pub name: &'static str,
52    /// Severity applied when no config override is present.
53    pub default_severity: GateSeverity,
54    /// One-line summary of the suspicious pattern.
55    pub description: &'static str,
56}
57
58/// Internal rule definition pairing metadata with a predicate.
59struct BuiltinRule {
60    name: &'static str,
61    default_severity: GateSeverity,
62    description: &'static str,
63    rationale: &'static str,
64    predicate: fn(&[CapabilityFinding]) -> bool,
65}
66
67fn has_capability(
68    findings: &[CapabilityFinding],
69    cap: Capability,
70    build_script_only: bool,
71) -> bool {
72    findings
73        .iter()
74        .any(|f| f.capability == cap && (!build_script_only || f.build_script))
75}
76
77const BUILTIN_RULES: &[BuiltinRule] = &[
78    // --- Compile-time execution rules (build scripts) ---
79    BuiltinRule {
80        name: "build-script-network",
81        default_severity: GateSeverity::Deny,
82        description: "Build script with network access",
83        rationale: "Build scripts should not make network requests",
84        predicate: |f| has_capability(f, Capability::Network, true),
85    },
86    BuiltinRule {
87        name: "build-script-exec",
88        default_severity: GateSeverity::Warn,
89        description: "Build script spawning processes",
90        rationale: "Build scripts spawning processes is common (cc, pkg-config) but risky",
91        predicate: |f| has_capability(f, Capability::ProcessExec, true),
92    },
93    BuiltinRule {
94        name: "build-script-download-exec",
95        default_severity: GateSeverity::Deny,
96        description: "Build script with network access and process execution",
97        rationale: "Download-and-execute in build script — classic supply chain attack",
98        predicate: |f| {
99            has_capability(f, Capability::Network, true)
100                && has_capability(f, Capability::ProcessExec, true)
101        },
102    },
103    BuiltinRule {
104        name: "build-script-file-write",
105        default_severity: GateSeverity::Warn,
106        description: "Build script with filesystem write access",
107        rationale: "Build scripts writing outside OUT_DIR is suspicious",
108        predicate: |f| has_capability(f, Capability::FileWrite, true),
109    },
110    // --- Compile-time execution rules (proc macros) ---
111    BuiltinRule {
112        name: "proc-macro-network",
113        default_severity: GateSeverity::Deny,
114        description: "Proc macro with network access",
115        rationale: "Proc macros have no legitimate reason for network access",
116        predicate: |f| {
117            has_capability(f, Capability::ProcMacro, false)
118                && has_capability(f, Capability::Network, false)
119        },
120    },
121    BuiltinRule {
122        name: "proc-macro-exec",
123        default_severity: GateSeverity::Deny,
124        description: "Proc macro spawning processes",
125        rationale: "Proc macros have no legitimate reason to spawn processes",
126        predicate: |f| {
127            has_capability(f, Capability::ProcMacro, false)
128                && has_capability(f, Capability::ProcessExec, false)
129        },
130    },
131    BuiltinRule {
132        name: "proc-macro-file-write",
133        default_severity: GateSeverity::Deny,
134        description: "Proc macro with filesystem write access",
135        rationale: "Proc macros should not write to the filesystem",
136        predicate: |f| {
137            has_capability(f, Capability::ProcMacro, false)
138                && has_capability(f, Capability::FileWrite, false)
139        },
140    },
141    // --- Runtime combination rules ---
142    BuiltinRule {
143        name: "env-access-network",
144        default_severity: GateSeverity::Info,
145        description: "Environment variable access with network capability",
146        rationale: "Reading environment variables and accessing network — review for credential harvesting",
147        predicate: |f| {
148            has_capability(f, Capability::EnvAccess, false)
149                && has_capability(f, Capability::Network, false)
150        },
151    },
152    BuiltinRule {
153        name: "key-material-network",
154        default_severity: GateSeverity::Warn,
155        description: "Embedded key material with network access",
156        rationale: "Embedded key material with network access — verify intent",
157        predicate: |f| has_capability(f, Capability::Network, false) && has_key_material(f),
158    },
159];
160
161/// Check if findings contain Crypto entries from key material (not import paths).
162///
163/// Key material findings have evidence that is NOT a module path (no `::`)
164/// and is not just a constant marker. Import-based findings like `sha2::Digest`
165/// contain `::`.
166fn has_key_material(findings: &[CapabilityFinding]) -> bool {
167    findings
168        .iter()
169        .any(|f| f.capability == Capability::Crypto && !f.evidence.contains("::"))
170}
171
172/// Internal rule definition pairing metadata with a data flow predicate.
173struct FlowRule {
174    name: &'static str,
175    default_severity: GateSeverity,
176    description: &'static str,
177    rationale: &'static str,
178    predicate: fn(&[DataFlowFact]) -> bool,
179}
180
181fn has_flow(data_flows: &[DataFlowFact], source: Capability, sink: Capability) -> bool {
182    data_flows
183        .iter()
184        .any(|f| f.source_capability == source && f.sink_capability == sink)
185}
186
187const FLOW_RULES: &[FlowRule] = &[
188    FlowRule {
189        name: "env-to-network",
190        default_severity: GateSeverity::Deny,
191        description: "Data flows from environment variable to network sink",
192        rationale: "Environment variable value reaches a network call — potential credential exfiltration",
193        predicate: |f| has_flow(f, Capability::EnvAccess, Capability::Network),
194    },
195    FlowRule {
196        name: "file-to-network",
197        default_severity: GateSeverity::Deny,
198        description: "Data flows from file read to network sink",
199        rationale: "File content reaches a network call — potential data exfiltration",
200        predicate: |f| has_flow(f, Capability::FileRead, Capability::Network),
201    },
202    FlowRule {
203        name: "network-to-exec",
204        default_severity: GateSeverity::Deny,
205        description: "Data flows from network source to process execution",
206        rationale: "Network-sourced data reaches process execution — remote code execution risk",
207        predicate: |f| has_flow(f, Capability::Network, Capability::ProcessExec),
208    },
209];
210
211/// Enumerate every built-in gate rule with its default severity and description.
212pub fn all_gate_rules() -> Box<[GateRuleInfo]> {
213    BUILTIN_RULES
214        .iter()
215        .map(|r| GateRuleInfo {
216            name: r.name,
217            default_severity: r.default_severity,
218            description: r.description,
219        })
220        .chain(FLOW_RULES.iter().map(|r| GateRuleInfo {
221            name: r.name,
222            default_severity: r.default_severity,
223            description: r.description,
224        }))
225        .collect()
226}
227
228/// Run every enabled gate rule against the findings, returning fired verdicts.
229///
230/// Evaluates both capability-combination rules (from `findings`) and
231/// flow-aware rules (from `data_flows`). Respects per-rule config overrides.
232pub fn evaluate_gate_rules(
233    findings: &[CapabilityFinding],
234    data_flows: &[DataFlowFact],
235    config: &GateConfig,
236) -> Box<[GateVerdict]> {
237    if !config.enabled {
238        return Box::new([]);
239    }
240
241    let capability_verdicts = BUILTIN_RULES.iter().filter_map(|rule| {
242        let severity = resolve_severity(rule.name, rule.default_severity, config)?;
243        (rule.predicate)(findings).then_some(GateVerdict {
244            rule: rule.name,
245            severity,
246            rationale: rule.rationale,
247        })
248    });
249
250    let flow_verdicts = FLOW_RULES.iter().filter_map(|rule| {
251        let severity = resolve_severity(rule.name, rule.default_severity, config)?;
252        (rule.predicate)(data_flows).then_some(GateVerdict {
253            rule: rule.name,
254            severity,
255            rationale: rule.rationale,
256        })
257    });
258
259    capability_verdicts.chain(flow_verdicts).collect()
260}
261
262/// Resolve the effective severity for a rule, returning `None` if disabled.
263fn resolve_severity(
264    name: &str,
265    default: GateSeverity,
266    config: &GateConfig,
267) -> Option<GateSeverity> {
268    match config.overrides.get(name) {
269        Some(GateRuleOverride::Disabled) => None,
270        Some(GateRuleOverride::Severity(s)) => Some(*s),
271        None => Some(default),
272    }
273}