Skip to main content

har/
config.rs

1use crate::glob::glob_match;
2use crate::model::Entry;
3use crate::vendor::vendor_for;
4use serde::Deserialize;
5use std::path::Path;
6
7#[derive(Debug, Default, Deserialize)]
8pub struct Config {
9    #[serde(default)]
10    pub ownership: Vec<OwnershipRule>,
11    #[serde(default)]
12    pub required_headers: Vec<RequiredHeaderRule>,
13    #[serde(default)]
14    pub rules: Vec<Rule>,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18pub struct RequiredHeaderRule {
19    /// Host glob the rule applies to.
20    pub host: String,
21    /// Header names that must be present on matching requests.
22    #[serde(default)]
23    pub headers: Vec<String>,
24}
25
26#[derive(Debug, Clone, Default, Deserialize)]
27pub struct Rule {
28    /// Human-readable rule name (shown in findings).
29    pub name: String,
30    /// Host glob the rule applies to (None = any host).
31    #[serde(default)]
32    pub host: Option<String>,
33    /// Path glob the rule applies to (None = any path).
34    #[serde(default)]
35    pub path: Option<String>,
36    /// HTTP method glob (None = any method).
37    #[serde(default)]
38    pub method: Option<String>,
39    /// Status glob, matched against the stringified status (e.g. "2*", "404").
40    #[serde(default)]
41    pub status: Option<String>,
42    /// Header names that must be present on matching requests.
43    #[serde(default)]
44    pub require_headers: Vec<String>,
45    /// Maximum allowed request duration in milliseconds.
46    #[serde(default)]
47    pub max_latency_ms: Option<f64>,
48    /// If true, any matching request is itself a violation.
49    #[serde(default)]
50    pub forbid: bool,
51}
52
53#[derive(Debug, Clone, Deserialize)]
54pub struct OwnershipRule {
55    pub name: String,
56    #[serde(default)]
57    pub host: Option<String>,
58    #[serde(default)]
59    pub path: Option<String>,
60    #[serde(default)]
61    pub owner: Option<String>,
62    #[serde(default)]
63    pub criticality: Option<String>,
64}
65
66#[derive(Debug, Clone)]
67pub struct Subsystem {
68    pub name: String,
69    pub owner: Option<String>,
70    pub criticality: Option<String>,
71}
72
73#[derive(Debug, thiserror::Error)]
74pub enum ConfigError {
75    #[error("failed to read config file")]
76    Io(#[source] std::io::Error),
77    #[error("failed to parse config YAML")]
78    Parse(#[source] yaml_serde::Error),
79}
80
81impl Config {
82    /// Load config from an explicit path, or discover `wiretrail.yaml` in the
83    /// current directory. A missing default file yields an empty config.
84    pub fn load(explicit: Option<&Path>) -> Result<Config, ConfigError> {
85        match explicit {
86            Some(p) => {
87                let text = std::fs::read_to_string(p).map_err(ConfigError::Io)?;
88                Config::from_yaml_str(&text)
89            }
90            None => {
91                let default = Path::new("wiretrail.yaml");
92                if default.is_file() {
93                    let text = std::fs::read_to_string(default).map_err(ConfigError::Io)?;
94                    Config::from_yaml_str(&text)
95                } else {
96                    Ok(Config::default())
97                }
98            }
99        }
100    }
101
102    pub fn from_yaml_str(s: &str) -> Result<Config, ConfigError> {
103        yaml_serde::from_str(s).map_err(ConfigError::Parse)
104    }
105
106    /// Resolve an entry's subsystem: first matching ownership rule, then a
107    /// built-in vendor name, then the raw host.
108    pub fn subsystem_for(&self, e: &Entry) -> Subsystem {
109        for rule in &self.ownership {
110            if rule_matches(rule, e) {
111                return Subsystem {
112                    name: rule.name.clone(),
113                    owner: rule.owner.clone(),
114                    criticality: rule.criticality.clone(),
115                };
116            }
117        }
118        if let Some(v) = vendor_for(&e.host) {
119            return Subsystem {
120                name: v.to_string(),
121                owner: None,
122                criticality: None,
123            };
124        }
125        let name = if e.host.is_empty() {
126            "(unknown)".to_string()
127        } else {
128            e.host.clone()
129        };
130        Subsystem {
131            name,
132            owner: None,
133            criticality: None,
134        }
135    }
136}
137
138fn rule_matches(rule: &OwnershipRule, e: &Entry) -> bool {
139    // A rule with neither host nor path never matches (avoids accidental catch-all).
140    if rule.host.is_none() && rule.path.is_none() {
141        return false;
142    }
143    if let Some(h) = &rule.host
144        && !glob_match(h, &e.host)
145    {
146        return false;
147    }
148    if let Some(p) = &rule.path
149        && !glob_match(p, &e.path)
150    {
151        return false;
152    }
153    true
154}
155
156#[cfg(test)]
157mod tests {
158    use super::Config;
159    use crate::model::sample_entry;
160
161    #[test]
162    fn parses_ownership_rules_from_yaml() {
163        let yaml = r#"
164ownership:
165  - name: Torii Addon
166    host: "torii.*"
167    owner: Addons
168    criticality: high
169  - name: GitHub Releases
170    host: "api.github.com"
171    path: "/repos/*"
172"#;
173        let cfg = Config::from_yaml_str(yaml).unwrap();
174        assert_eq!(cfg.ownership.len(), 2);
175    }
176
177    #[test]
178    fn rule_match_wins_over_vendor() {
179        let cfg =
180            Config::from_yaml_str("ownership:\n  - name: Torii Addon\n    host: \"torii.*\"\n")
181                .unwrap();
182        let e = sample_entry(0, "torii.nexioapp.org", "GET", "/manifest.json", 308);
183        let s = cfg.subsystem_for(&e);
184        assert_eq!(s.name, "Torii Addon");
185    }
186
187    #[test]
188    fn falls_back_to_vendor_then_host() {
189        let cfg = Config::default();
190        let gh = sample_entry(0, "api.github.com", "GET", "/x", 200);
191        assert_eq!(cfg.subsystem_for(&gh).name, "GitHub");
192        let unknown = sample_entry(1, "torii.nexioapp.org", "GET", "/x", 200);
193        assert_eq!(cfg.subsystem_for(&unknown).name, "torii.nexioapp.org");
194    }
195
196    #[test]
197    fn path_rule_requires_path_match() {
198        let cfg = Config::from_yaml_str(
199            "ownership:\n  - name: Repos\n    host: \"api.github.com\"\n    path: \"/repos/*\"\n",
200        )
201        .unwrap();
202        let hit = sample_entry(0, "api.github.com", "GET", "/repos/foo/bar", 200);
203        let miss = sample_entry(1, "api.github.com", "GET", "/users/foo", 200);
204        assert_eq!(cfg.subsystem_for(&hit).name, "Repos");
205        // miss does not match the rule -> vendor fallback
206        assert_eq!(cfg.subsystem_for(&miss).name, "GitHub");
207    }
208
209    #[test]
210    fn parses_required_headers() {
211        let yaml = r#"
212required_headers:
213  - host: "api.company.com"
214    headers: ["Authorization", "X-App-Version"]
215"#;
216        let cfg = Config::from_yaml_str(yaml).unwrap();
217        assert_eq!(cfg.required_headers.len(), 1);
218        assert_eq!(cfg.required_headers[0].host, "api.company.com");
219        assert_eq!(
220            cfg.required_headers[0].headers,
221            vec!["Authorization", "X-App-Version"]
222        );
223    }
224
225    #[test]
226    fn required_headers_defaults_empty() {
227        let cfg = Config::from_yaml_str("ownership: []").unwrap();
228        assert!(cfg.required_headers.is_empty());
229    }
230
231    #[test]
232    fn parses_rules_from_yaml() {
233        let yaml = r#"
234rules:
235  - name: "API needs auth"
236    host: "api.*"
237    require_headers: ["Authorization"]
238    max_latency_ms: 2000
239  - name: "no internal hosts"
240    host: "*.internal"
241    forbid: true
242"#;
243        let cfg = Config::from_yaml_str(yaml).unwrap();
244        assert_eq!(cfg.rules.len(), 2);
245        assert_eq!(cfg.rules[0].require_headers, vec!["Authorization"]);
246        assert_eq!(cfg.rules[0].max_latency_ms, Some(2000.0));
247        assert!(cfg.rules[1].forbid);
248    }
249}