Skip to main content

gossan_cloud/
permutations.rs

1//! Bucket/asset name permutation generation from organization names.
2
3/// Generate bucket/account name candidates from an org name.
4/// These are the patterns attackers enumerate — we do the same.
5use serde::Deserialize;
6use std::sync::OnceLock;
7
8/// Permutation configuration from TOML.
9#[derive(Debug, Clone, Deserialize)]
10struct PermutationConfig {
11    suffixes: StringList,
12    prefixes: StringList,
13    transforms: Transforms,
14}
15
16#[derive(Debug, Clone, Deserialize)]
17struct StringList {
18    values: Vec<String>,
19}
20
21#[derive(Debug, Clone, Deserialize)]
22struct Transforms {
23    #[serde(rename = "dot_to_hyphen")]
24    dot_to_hyphen: bool,
25    #[serde(rename = "hyphen_to_dot")]
26    hyphen_to_dot: bool,
27}
28
29/// Built-in permutations.toml content (embedded at compile time).
30const BUILTIN_PERMUTATIONS: &str = include_str!("../rules/permutations.toml");
31
32/// Global cache for built-in permutations.
33static PERMUTATIONS: OnceLock<PermutationConfig> = OnceLock::new();
34
35/// Initialize and return the built-in permutation config.
36fn builtin_permutations() -> &'static PermutationConfig {
37    PERMUTATIONS.get_or_init(|| {
38        match toml::from_str::<PermutationConfig>(BUILTIN_PERMUTATIONS) {
39            Ok(config) => config,
40            Err(e) => {
41                tracing::error!(error = %e, "failed to parse built-in permutations.toml");
42                // Fallback to minimal hardcoded lists only on parse failure
43                PermutationConfig {
44                    suffixes: StringList {
45                        values: vec![
46                            "".to_string(),
47                            "-assets".to_string(),
48                            "-static".to_string(),
49                            "-dev".to_string(),
50                            "-prod".to_string(),
51                        ],
52                    },
53                    prefixes: StringList {
54                        values: vec!["".to_string(), "assets-".to_string(), "dev-".to_string()],
55                    },
56                    transforms: Transforms {
57                        dot_to_hyphen: true,
58                        hyphen_to_dot: true,
59                    },
60                }
61            }
62        }
63    })
64}
65
66/// Generate bucket/account name candidates from an organization name.
67pub fn generate(org: &str) -> Vec<String> {
68    let o = org.to_lowercase();
69    let config = builtin_permutations();
70    let suffixes = &config.suffixes.values;
71    let prefixes = &config.prefixes.values;
72
73    let mut candidates = std::collections::HashSet::new();
74
75    for suffix in suffixes {
76        for prefix in prefixes {
77            let name = format!("{}{}{}", prefix, o, suffix);
78            // S3/GCS bucket names: 3–63 chars, lowercase alphanumeric + hyphens
79            if name.len() >= 3 && name.len() <= 63 {
80                candidates.insert(name);
81            }
82        }
83    }
84
85    // Apply transforms based on configuration, but validate length AFTER transformation
86    if config.transforms.dot_to_hyphen {
87        let transformed = o.replace('.', "-");
88        if transformed.len() >= 3 && transformed.len() <= 63 {
89            candidates.insert(transformed);
90        }
91    }
92    if config.transforms.hyphen_to_dot {
93        let transformed = o.replace('-', ".");
94        if transformed.len() >= 3 && transformed.len() <= 63 {
95            candidates.insert(transformed);
96        }
97    }
98
99    candidates.into_iter().collect()
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn generate_includes_expected_prefix_and_suffix_forms() {
108        let candidates = generate("example");
109        assert!(candidates.contains(&"example-assets".to_string()));
110        assert!(candidates.contains(&"assets-example".to_string()));
111        assert!(candidates.contains(&"example".to_string()));
112    }
113
114    #[test]
115    fn generate_deduplicates_candidates() {
116        let candidates = generate("example");
117        let unique = candidates.iter().collect::<std::collections::HashSet<_>>();
118        assert_eq!(unique.len(), candidates.len());
119    }
120
121    #[test]
122    fn generate_normalizes_case_and_preserves_valid_lengths() {
123        let candidates = generate("ExAmPlE");
124        assert!(candidates.iter().all(|name| name == &name.to_lowercase()));
125        assert!(candidates.iter().all(|name| (3..=63).contains(&name.len())));
126    }
127
128    #[test]
129    fn permutations_load_from_toml() {
130        let config = builtin_permutations();
131        assert!(
132            !config.suffixes.values.is_empty(),
133            "should have suffixes from TOML"
134        );
135        assert!(
136            !config.prefixes.values.is_empty(),
137            "should have prefixes from TOML"
138        );
139    }
140
141    #[test]
142    fn permutations_include_expected_suffixes() {
143        let config = builtin_permutations();
144        let suffixes = &config.suffixes.values;
145
146        // Check for common suffixes
147        assert!(
148            suffixes.contains(&"".to_string()),
149            "should include empty suffix"
150        );
151        assert!(
152            suffixes.contains(&"-assets".to_string()),
153            "should include -assets"
154        );
155        assert!(
156            suffixes.contains(&"-prod".to_string()),
157            "should include -prod"
158        );
159        assert!(
160            suffixes.contains(&"-backup".to_string()),
161            "should include -backup"
162        );
163    }
164
165    #[test]
166    fn permutations_include_expected_prefixes() {
167        let config = builtin_permutations();
168        let prefixes = &config.prefixes.values;
169
170        // Check for common prefixes
171        assert!(
172            prefixes.contains(&"".to_string()),
173            "should include empty prefix"
174        );
175        assert!(
176            prefixes.contains(&"assets-".to_string()),
177            "should include assets-"
178        );
179        assert!(
180            prefixes.contains(&"dev-".to_string()),
181            "should include dev-"
182        );
183    }
184
185    #[test]
186    fn transforms_are_enabled() {
187        let config = builtin_permutations();
188        assert!(
189            config.transforms.dot_to_hyphen,
190            "dot_to_hyphen should be enabled"
191        );
192        assert!(
193            config.transforms.hyphen_to_dot,
194            "hyphen_to_dot should be enabled"
195        );
196    }
197}