1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "lowercase")]
19pub enum GateSeverity {
20 Deny,
22 Warn,
24 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#[derive(Serialize)]
40pub struct GateVerdict {
41 pub rule: &'static str,
43 pub severity: GateSeverity,
45 pub rationale: &'static str,
47}
48
49pub struct GateRuleInfo {
51 pub name: &'static str,
53 pub default_severity: GateSeverity,
55 pub description: &'static str,
57 pub rationale: &'static str,
59}
60
61pub 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
73fn 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 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 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
170struct 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 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 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 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 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 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 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 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 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 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
391pub fn all_gate_rules() -> Box<[GateRuleInfo]> {
393 RULES.iter().map(Rule::info).collect()
394}
395
396pub 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
411fn 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}