Skip to main content

forge_guard/templates/
mod.rs

1//! Template library — pre-built audit profiles for common contract types.
2//!
3//! Templates adjust enabled checks, severity thresholds, and scoring weights
4//! to focus on the contract's specific risk profile.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10/// An audit template configuration.
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct AuditTemplate {
13    /// Template name identifier.
14    pub name: String,
15    /// Human-readable description.
16    pub description: String,
17    /// Checks to enable (check IDs or categories).
18    #[serde(default)]
19    pub enabled_checks: Vec<String>,
20    /// Checks to disable (check IDs or categories).
21    #[serde(default)]
22    pub disabled_checks: Vec<String>,
23    /// Severity thresholds per category (category -> min score).
24    #[serde(default)]
25    pub min_scores: HashMap<String, u8>,
26    /// Focus areas — categories weighted higher in scoring.
27    #[serde(default)]
28    pub focus_areas: Vec<String>,
29}
30
31impl AuditTemplate {
32    /// Create a new template.
33    pub fn new(name: &str, description: &str) -> Self {
34        Self {
35            name: name.to_string(),
36            description: description.to_string(),
37            enabled_checks: Vec::new(),
38            disabled_checks: Vec::new(),
39            min_scores: HashMap::new(),
40            focus_areas: Vec::new(),
41        }
42    }
43}
44
45/// Built-in template definitions.
46fn builtin_templates() -> Vec<AuditTemplate> {
47    vec![
48        AuditTemplate {
49            name: "erc20".into(),
50            description: "Focuses on ERC20 compliance, approval bugs, permit replay, and token-specific vulnerabilities".into(),
51            enabled_checks: vec![
52                "FA-H-014".into(),  // ERC20 Issues
53                "FA-M-005".into(),  // Unsafe Events
54            ],
55            disabled_checks: vec![],
56            min_scores: HashMap::from([
57                ("access_control".into(), 85u8),
58                ("dependencies".into(), 80u8),
59            ]),
60            focus_areas: vec!["access_control".into(), "dependencies".into()],
61        },
62        AuditTemplate {
63            name: "erc721".into(),
64            description: "Focuses on NFT-specific patterns, royalties, metadata, and collection integrity".into(),
65            enabled_checks: vec![
66                "FA-M-005".into(),  // Unsafe Events (metadata)
67            ],
68            disabled_checks: vec![],
69            min_scores: HashMap::from([
70                ("access_control".into(), 85u8),
71            ]),
72            focus_areas: vec!["access_control".into(), "security".into()],
73        },
74        AuditTemplate {
75            name: "defi".into(),
76            description: "Focuses on lending, AMM, oracle dependencies, flash loans, and MEV resistance".into(),
77            enabled_checks: vec![
78                "FA-H-011".into(),  // Oracle Manipulation
79                "FA-H-016".into(),  // Flash Loan Issues
80                "FA-H-017".into(),  // MEV Issues
81                "FA-M-003".into(),  // Timestamp Manipulation
82            ],
83            disabled_checks: vec![],
84            min_scores: HashMap::from([
85                ("exploit_resistance".into(), 80u8),
86                ("security".into(), 75u8),
87            ]),
88            focus_areas: vec!["exploit_resistance".into(), "security".into(), "chain_compatibility".into()],
89        },
90        AuditTemplate {
91            name: "bridge".into(),
92            description: "Focuses on cross-chain messaging, validator sets, replay attacks, and bridge-specific patterns".into(),
93            enabled_checks: vec![
94                "FA-H-015".into(),  // Bridge Vulnerabilities
95                "FA-H-018".into(),  // Cross-Chain Issues
96                "FA-H-013".into(),  // Replay Attacks
97            ],
98            disabled_checks: vec![],
99            min_scores: HashMap::from([
100                ("chain_compatibility".into(), 90u8),
101                ("security".into(), 80u8),
102            ]),
103            focus_areas: vec!["chain_compatibility".into(), "security".into()],
104        },
105        AuditTemplate {
106            name: "upgradeable".into(),
107            description: "Focuses on proxy patterns, storage layouts, initializers, and upgrade path safety".into(),
108            enabled_checks: vec![
109                "FA-H-007".into(),  // Storage Collision
110                "FA-H-010".into(),  // Proxy Vulnerabilities
111                "FA-H-022".into(),  // Unsafe Upgrade Paths
112                "FA-H-023".into(),  // Clone Vulnerabilities
113            ],
114            disabled_checks: vec![],
115            min_scores: HashMap::from([
116                ("upgradeability".into(), 90u8),
117                ("proxy_safety".into(), 90u8),
118            ]),
119            focus_areas: vec!["upgradeability".into(), "proxy_safety".into(), "architecture".into()],
120        },
121    ]
122}
123
124/// Load user-custom templates from `~/.forge-guard/templates/*.json`.
125fn load_user_templates() -> Vec<AuditTemplate> {
126    let mut templates = Vec::new();
127
128    if let Some(user_dir) = user_template_dir() {
129        if user_dir.exists() {
130            if let Ok(entries) = std::fs::read_dir(&user_dir) {
131                for entry in entries.flatten() {
132                    let path = entry.path();
133                    if path.extension().is_some_and(|e| e == "json") {
134                        if let Ok(content) = std::fs::read_to_string(&path) {
135                            if let Ok(template) = serde_json::from_str::<AuditTemplate>(&content) {
136                                templates.push(template);
137                            }
138                        }
139                    }
140                }
141            }
142        }
143    }
144
145    templates
146}
147
148/// Get a template by name (built-in or user-custom).
149/// User templates take precedence over built-ins with the same name.
150pub fn get_template(name: &str) -> Option<AuditTemplate> {
151    // User templates override built-ins
152    if let Some(t) = load_user_templates().into_iter().find(|t| t.name == name) {
153        return Some(t);
154    }
155    builtin_templates().into_iter().find(|t| t.name == name)
156}
157
158/// List all available templates (built-in + user-custom).
159/// User templates with the same name as a built-in replace it in the list,
160/// matching what `get_template` will actually return.
161pub fn list_templates() -> Vec<AuditTemplate> {
162    let mut templates = load_user_templates();
163
164    // Add built-ins that aren't shadowed by a user template
165    for template in builtin_templates() {
166        if !templates.iter().any(|t| t.name == template.name) {
167            templates.push(template);
168        }
169    }
170
171    templates
172}
173
174/// Apply an audit template to a project config.
175///
176/// Adjusts the security engine's enabled/disabled check lists so the audit
177/// focuses on the template's risk profile.
178pub fn apply_to_config(config: &mut crate::core::ProjectConfig, template: &AuditTemplate) {
179    config.security.enabled_checks = template.enabled_checks.clone();
180    config.security.disabled_checks = template.disabled_checks.clone();
181}
182
183/// Get the user template directory path.
184fn user_template_dir() -> Option<PathBuf> {
185    let home = if cfg!(target_os = "windows") {
186        std::env::var("USERPROFILE").ok()
187    } else {
188        std::env::var("HOME").ok()
189    };
190    home.map(|h| PathBuf::from(h).join(".forge-guard").join("templates"))
191}
192
193/// Print template info for the terminal.
194pub fn print_template(t: &AuditTemplate) {
195    use colored::*;
196    println!("  {} — {}", t.name.bold().cyan(), t.description);
197    if !t.focus_areas.is_empty() {
198        println!(
199            "      {} {}",
200            "Focus areas:".dimmed(),
201            t.focus_areas.join(", ")
202        );
203    }
204    if !t.enabled_checks.is_empty() {
205        println!(
206            "      {} {}",
207            "Extra checks:".dimmed(),
208            t.enabled_checks.join(", ")
209        );
210    }
211    println!();
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_builtin_templates_exist() {
220        let templates = builtin_templates();
221        assert_eq!(templates.len(), 5);
222        assert!(templates.iter().any(|t| t.name == "erc20"));
223        assert!(templates.iter().any(|t| t.name == "erc721"));
224        assert!(templates.iter().any(|t| t.name == "defi"));
225        assert!(templates.iter().any(|t| t.name == "bridge"));
226        assert!(templates.iter().any(|t| t.name == "upgradeable"));
227    }
228
229    #[test]
230    fn test_get_template_found() {
231        let t = get_template("defi").unwrap();
232        assert_eq!(t.name, "defi");
233        assert!(t.enabled_checks.contains(&"FA-H-011".to_string()));
234        assert!(t.focus_areas.contains(&"exploit_resistance".to_string()));
235    }
236
237    #[test]
238    fn test_get_template_not_found() {
239        assert!(get_template("nonexistent").is_none());
240    }
241
242    #[test]
243    fn test_list_templates_includes_builtin() {
244        let templates = list_templates();
245        assert!(templates.iter().any(|t| t.name == "erc20"));
246    }
247
248    #[test]
249    fn test_template_has_descriptions() {
250        for t in builtin_templates() {
251            assert!(
252                !t.description.is_empty(),
253                "Template {} has no description",
254                t.name
255            );
256        }
257    }
258
259    #[test]
260    fn test_erc20_template_checks() {
261        let t = get_template("erc20").unwrap();
262        assert!(t.enabled_checks.contains(&"FA-H-014".to_string()));
263        assert!(t.focus_areas.contains(&"access_control".to_string()));
264    }
265
266    #[test]
267    fn test_bridge_template_checks() {
268        let t = get_template("bridge").unwrap();
269        assert!(t.enabled_checks.contains(&"FA-H-015".to_string()));
270        assert!(t.enabled_checks.contains(&"FA-H-018".to_string()));
271        assert!(t.focus_areas.contains(&"chain_compatibility".to_string()));
272    }
273
274    #[test]
275    fn test_upgradeable_template_checks() {
276        let t = get_template("upgradeable").unwrap();
277        assert!(t.enabled_checks.contains(&"FA-H-007".to_string()));
278        assert!(t.enabled_checks.contains(&"FA-H-010".to_string()));
279        assert!(t.focus_areas.contains(&"upgradeability".to_string()));
280    }
281
282    #[test]
283    fn test_template_serialization() {
284        let t = get_template("defi").unwrap();
285        let json = serde_json::to_string_pretty(&t).unwrap();
286        assert!(json.contains("defi"));
287        assert!(json.contains("oracle"));
288
289        let deserialized: AuditTemplate = serde_json::from_str(&json).unwrap();
290        assert_eq!(deserialized.name, "defi");
291        assert_eq!(deserialized.focus_areas.len(), 3);
292    }
293
294    #[test]
295    fn test_template_new_constructor() {
296        let t = AuditTemplate::new("custom", "A custom template");
297        assert_eq!(t.name, "custom");
298        assert_eq!(t.description, "A custom template");
299        assert!(t.enabled_checks.is_empty());
300        assert!(t.focus_areas.is_empty());
301    }
302
303    #[test]
304    fn test_user_template_dir_returns_some() {
305        // Should return Some path (the actual dir may not exist)
306        let dir = user_template_dir();
307        assert!(dir.is_some());
308    }
309
310    #[test]
311    fn test_print_template_does_not_panic() {
312        let t = get_template("erc20").unwrap();
313        // Should not panic
314        print_template(&t);
315    }
316
317    #[test]
318    fn test_apply_to_config_sets_checks() {
319        let mut config = crate::core::ProjectConfig::default();
320        let t = get_template("defi").unwrap();
321
322        crate::templates::apply_to_config(&mut config, &t);
323
324        assert!(config
325            .security
326            .enabled_checks
327            .contains(&"FA-H-011".to_string()));
328        assert!(config
329            .security
330            .enabled_checks
331            .contains(&"FA-H-016".to_string()));
332        assert!(config.security.disabled_checks.is_empty());
333    }
334
335    #[test]
336    fn test_apply_to_config_with_disabled_checks() {
337        let mut config = crate::core::ProjectConfig::default();
338        let mut t = AuditTemplate::new("custom", "Custom template");
339        t.enabled_checks = vec!["FA-H-001".into()];
340        t.disabled_checks = vec!["FA-H-014".into()];
341
342        crate::templates::apply_to_config(&mut config, &t);
343
344        assert_eq!(config.security.enabled_checks, vec!["FA-H-001".to_string()]);
345        assert_eq!(
346            config.security.disabled_checks,
347            vec!["FA-H-014".to_string()]
348        );
349    }
350
351    #[test]
352    fn test_get_template_user_override() {
353        // Write a user template that shadows a built-in name
354        let dir = user_template_dir().unwrap();
355        let _ = std::fs::remove_dir_all(&dir);
356        std::fs::create_dir_all(&dir).unwrap();
357
358        let custom = AuditTemplate {
359            name: "erc20".into(),
360            description: "User override".into(),
361            enabled_checks: vec!["FA-H-001".into()],
362            ..Default::default()
363        };
364        std::fs::write(
365            dir.join("erc20.json"),
366            serde_json::to_string(&custom).unwrap(),
367        )
368        .unwrap();
369
370        let t = get_template("erc20").unwrap();
371        assert_eq!(t.description, "User override");
372        assert_eq!(t.enabled_checks, vec!["FA-H-001".to_string()]);
373
374        let _ = std::fs::remove_dir_all(&dir);
375    }
376
377    #[test]
378    fn test_list_templates_dedupes_user_override() {
379        let templates = list_templates();
380        let count = templates.iter().filter(|t| t.name == "erc20").count();
381        assert_eq!(count, 1, "erc20 should appear exactly once");
382    }
383}