Skip to main content

vault_core/
blast.rs

1//! Phase 36 — blast radius (ADR-0142).
2//!
3//! After a compromise the question is always *which credentials were on that
4//! machine, and when?* It is never answerable, so the honest answer becomes
5//! "rotate everything", so nobody does. This module answers it from three things
6//! the vault already keeps:
7//!
8//! - the hub's audit chain says which files a node **applied** and when
9//!   (`node.apply` rows, Phase 34);
10//! - the config history says what each of those files **contained**, as the list
11//!   of vault secrets whose exact value appears in it (`exposed`, Phase 36), kept
12//!   per snapshot because by the time of a compromise the entry may have been
13//!   rotated, edited or deleted;
14//! - the vault says which of those values are **still current**, by fingerprint.
15//!
16//! The pure logic lives here so it is testable without a database; callers
17//! supply the three lookups.
18//!
19//! # What it cannot know, and says so
20//!
21//! A file applied before the history existed, or whose snapshot was pruned, has
22//! no recorded contents: it is reported as *unaccounted* rather than silently
23//! dropped, because "nothing was exposed" would be a lie. A pull target's file
24//! was never rendered by the hub, so it is not covered at all.
25
26use serde::{Deserialize, Serialize};
27use std::collections::BTreeMap;
28
29/// A secret whose exact value appeared in a rendered file or an environment.
30/// Stored with the snapshot (or the local log) at the moment it was written.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Exposed {
33    pub entry_id: String,
34    pub provider: String,
35    #[serde(default)]
36    pub key_id: String,
37    /// Which part of the entry matched: `api_key`, `api_secret`,
38    /// `extra_vars/NAME`, `version_history`, …
39    pub field: String,
40    /// Fingerprint of the value that matched (`out::fingerprint`), so "is it
41    /// still the live value" is a comparison and not a guess from timestamps.
42    pub fp: String,
43    /// Under 8 characters: it may be a coincidence of text rather than a leak.
44    /// Still reported, because a missed exposure is the failure that matters.
45    #[serde(default)]
46    pub short: bool,
47}
48
49/// One thing a host received.
50#[derive(Debug, Clone, PartialEq, Serialize)]
51pub struct Deployment {
52    pub at: String,
53    /// A node target id, or `exec` / `file` / … for a local materialisation.
54    pub via: String,
55    /// SHA-256 of the file, for a node deployment.
56    pub sha256: String,
57    pub ok: bool,
58    pub error: Option<String>,
59}
60
61/// What the vault holds for an entry now.
62#[derive(Debug, Clone, Default)]
63pub struct CurrentEntry {
64    pub provider: String,
65    pub key_id: String,
66    /// Fingerprints of every present (not historical) secret value.
67    pub fps: Vec<String>,
68    pub console_url: Option<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize)]
72pub struct EntryExposure {
73    pub entry_id: String,
74    pub provider: String,
75    pub key_id: String,
76    pub fields: Vec<String>,
77    pub first_seen: String,
78    pub last_seen: String,
79    pub times: usize,
80    /// The value that was on the host is still the entry's value, so it still
81    /// opens whatever it opened: rotate it.
82    pub still_current: bool,
83    /// The entry no longer exists in the vault.
84    pub removed: bool,
85    pub short: bool,
86    pub console_url: Option<String>,
87    pub via: Vec<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize)]
91pub struct Report {
92    pub host: String,
93    pub since: Option<String>,
94    pub deployments: usize,
95    /// Deployments with no recorded contents.
96    pub unaccounted: Vec<Deployment>,
97    pub entries: Vec<EntryExposure>,
98    /// Entries to rotate: still current, in the order listed.
99    pub rotate: Vec<String>,
100    /// One command that rotates exactly those and nothing else.
101    pub command: String,
102}
103
104/// True when a failed apply still put the new file on disk for a moment (it
105/// was written, then restored). A refused or hash-mismatched one never was.
106pub fn failed_apply_touched_disk(error: &str) -> bool {
107    error.contains("validate failed") || error.contains("reload failed")
108}
109
110fn entry_label(provider: &str, key_id: &str) -> String {
111    if key_id.is_empty() {
112        provider.to_string()
113    } else {
114        format!("{provider}:{key_id}")
115    }
116}
117
118/// A shell-safe single-quoted word, or `None` when the name has a quote in it
119/// (quoting differs between shells, and a wrong guess runs the wrong command).
120fn quote(s: &str) -> Option<String> {
121    if s.contains(['\'', '"', '\n', '\r', '\0']) {
122        None
123    } else {
124        Some(format!("'{s}'"))
125    }
126}
127
128/// Builds the report. `deployments` are what the host received, `lookup` maps a
129/// file hash to what it contained (`None` = not recorded), `current` maps an
130/// entry id to what the vault holds now (`None` = deleted).
131pub fn report(
132    host: &str,
133    since: Option<&str>,
134    deployments: &[Deployment],
135    lookup: &dyn Fn(&Deployment) -> Option<Vec<Exposed>>,
136    current: &dyn Fn(&str) -> Option<CurrentEntry>,
137) -> Report {
138    let mut unaccounted = Vec::new();
139    let mut by_entry: BTreeMap<String, EntryExposure> = BTreeMap::new();
140    let mut counted = 0usize;
141
142    for d in deployments {
143        if since.is_some_and(|s| d.at.as_str() < s) {
144            continue;
145        }
146        if !d.ok && !d.error.as_deref().is_some_and(failed_apply_touched_disk) {
147            continue;
148        }
149        counted += 1;
150        let Some(exposed) = lookup(d) else {
151            unaccounted.push(d.clone());
152            continue;
153        };
154        for e in exposed {
155            let cur = current(&e.entry_id);
156            let still = cur.as_ref().is_some_and(|c| c.fps.contains(&e.fp));
157            let slot = by_entry
158                .entry(e.entry_id.clone())
159                .or_insert_with(|| EntryExposure {
160                    entry_id: e.entry_id.clone(),
161                    provider: e.provider.clone(),
162                    key_id: e.key_id.clone(),
163                    fields: Vec::new(),
164                    first_seen: d.at.clone(),
165                    last_seen: d.at.clone(),
166                    times: 0,
167                    still_current: false,
168                    removed: cur.is_none(),
169                    short: true,
170                    console_url: cur.as_ref().and_then(|c| c.console_url.clone()),
171                    via: Vec::new(),
172                });
173            if !slot.fields.contains(&e.field) {
174                slot.fields.push(e.field.clone());
175            }
176            if d.at < slot.first_seen {
177                slot.first_seen = d.at.clone();
178            }
179            if d.at > slot.last_seen {
180                slot.last_seen = d.at.clone();
181            }
182            slot.times += 1;
183            // One exposed value that is still live is enough to need a rotation.
184            slot.still_current |= still;
185            slot.short &= e.short;
186            if !slot.via.contains(&d.via) {
187                slot.via.push(d.via.clone());
188            }
189            // Use the live name where there is one: the entry may have been renamed.
190            if let Some(c) = &cur {
191                slot.provider.clone_from(&c.provider);
192                slot.key_id.clone_from(&c.key_id);
193            }
194        }
195    }
196
197    let mut entries: Vec<EntryExposure> = by_entry.into_values().collect();
198    entries.sort_by(|a, b| {
199        b.still_current
200            .cmp(&a.still_current)
201            .then_with(|| a.provider.to_lowercase().cmp(&b.provider.to_lowercase()))
202            .then_with(|| a.key_id.cmp(&b.key_id))
203    });
204    let rotate: Vec<String> = entries
205        .iter()
206        .filter(|e| e.still_current)
207        .map(|e| entry_label(&e.provider, &e.key_id))
208        .collect();
209    let mut parts = Vec::new();
210    for label in &rotate {
211        match quote(label) {
212            Some(q) => parts.push(format!("unv entry rotate {q} --generate")),
213            None => parts.push(format!(
214                "# cannot quote this name safely, rotate it by hand: {label}"
215            )),
216        }
217    }
218    Report {
219        host: host.to_string(),
220        since: since.map(String::from),
221        deployments: counted,
222        unaccounted,
223        entries,
224        rotate,
225        command: parts.join("; "),
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn exp(id: &str, provider: &str, field: &str, fp: &str) -> Exposed {
234        Exposed {
235            entry_id: id.into(),
236            provider: provider.into(),
237            key_id: String::new(),
238            field: field.into(),
239            fp: fp.into(),
240            short: false,
241        }
242    }
243
244    fn dep(at: &str, via: &str, sha: &str) -> Deployment {
245        Deployment {
246            at: at.into(),
247            via: via.into(),
248            sha256: sha.into(),
249            ok: true,
250            error: None,
251        }
252    }
253
254    fn cur(provider: &str, fps: &[&str]) -> Option<CurrentEntry> {
255        Some(CurrentEntry {
256            provider: provider.into(),
257            key_id: String::new(),
258            fps: fps.iter().map(|s| s.to_string()).collect(),
259            console_url: Some("https://console.example/keys".into()),
260        })
261    }
262
263    #[test]
264    fn a_still_live_secret_is_rotated_and_a_rotated_away_one_is_not() {
265        let deps = [dep("2026-10-01T00:00:00Z", "nginx", "h1")];
266        let lookup = |_: &Deployment| {
267            Some(vec![
268                exp("e1", "Stripe", "api_key", "fp-live"),
269                exp("e2", "GitHub", "api_key", "fp-old"),
270            ])
271        };
272        let current = |id: &str| match id {
273            "e1" => cur("Stripe", &["fp-live"]),
274            "e2" => cur("GitHub", &["fp-new"]), // rotated since the file was written
275            _ => None,
276        };
277        let r = report("vps-01", None, &deps, &lookup, &current);
278        assert_eq!(r.rotate, vec!["Stripe"]);
279        assert_eq!(r.command, "unv entry rotate 'Stripe' --generate");
280        let github = r.entries.iter().find(|e| e.provider == "GitHub").unwrap();
281        assert!(
282            !github.still_current,
283            "a value rotated away needs no rotation"
284        );
285        assert!(
286            r.entries[0].still_current,
287            "still-current entries sort first"
288        );
289    }
290
291    #[test]
292    fn a_deleted_entry_is_reported_but_never_in_the_rotate_command() {
293        let deps = [dep("2026-10-01T00:00:00Z", "nginx", "h1")];
294        let lookup = |_: &Deployment| Some(vec![exp("gone", "Old", "api_key", "fp")]);
295        let r = report("h", None, &deps, &lookup, &|_| None);
296        assert_eq!(r.entries.len(), 1);
297        assert!(r.entries[0].removed);
298        assert!(r.rotate.is_empty());
299        assert_eq!(r.command, "");
300    }
301
302    #[test]
303    fn a_deployment_with_no_recorded_contents_is_unaccounted_not_silently_clean() {
304        let deps = [dep("2026-10-01T00:00:00Z", "nginx", "unknown-hash")];
305        let r = report("h", None, &deps, &|_| None, &|_| None);
306        assert_eq!(r.unaccounted.len(), 1);
307        assert_eq!(r.deployments, 1);
308        assert!(r.entries.is_empty());
309    }
310
311    #[test]
312    fn since_filters_by_time_and_the_window_is_inclusive() {
313        let deps = [
314            dep("2026-09-01T00:00:00Z", "a", "h1"),
315            dep("2026-10-01T00:00:00Z", "b", "h2"),
316        ];
317        let lookup = |d: &Deployment| {
318            Some(vec![exp(
319                if d.sha256 == "h1" { "old" } else { "new" },
320                "P",
321                "api_key",
322                "fp",
323            )])
324        };
325        let r = report("h", Some("2026-10-01T00:00:00Z"), &deps, &lookup, &|id| {
326            cur(id, &["fp"])
327        });
328        assert_eq!(r.entries.len(), 1);
329        assert_eq!(r.entries[0].entry_id, "new");
330    }
331
332    #[test]
333    fn repeated_exposures_collapse_into_one_row_with_a_count_and_a_range() {
334        let deps = [
335            dep("2026-10-03T00:00:00Z", "nginx", "h2"),
336            dep("2026-10-01T00:00:00Z", "nginx", "h1"),
337            dep("2026-10-02T00:00:00Z", "env", "h3"),
338        ];
339        let lookup = |_: &Deployment| Some(vec![exp("e1", "Stripe", "api_key", "fp")]);
340        let r = report("h", None, &deps, &lookup, &|_| cur("Stripe", &["fp"]));
341        assert_eq!(r.entries.len(), 1);
342        let e = &r.entries[0];
343        assert_eq!(
344            (e.times, e.first_seen.as_str(), e.last_seen.as_str()),
345            (3, "2026-10-01T00:00:00Z", "2026-10-03T00:00:00Z")
346        );
347        assert_eq!(e.via, vec!["nginx", "env"]);
348    }
349
350    #[test]
351    fn a_failed_apply_counts_only_when_the_file_reached_the_disk() {
352        let mut validate = dep("2026-10-01T00:00:00Z", "t", "h1");
353        validate.ok = false;
354        validate.error = Some("validate failed, previous file restored: x".into());
355        let mut refused = dep("2026-10-01T00:00:00Z", "t", "h2");
356        refused.ok = false;
357        refused.error =
358            Some("content does not match the hash the hub announced; nothing was written".into());
359        let lookup = |_: &Deployment| Some(vec![exp("e1", "Stripe", "api_key", "fp")]);
360        let r = report("h", None, &[validate, refused], &lookup, &|_| {
361            cur("Stripe", &["fp"])
362        });
363        assert_eq!(r.deployments, 1, "the refused one never touched the disk");
364        assert_eq!(r.entries[0].times, 1);
365    }
366
367    #[test]
368    fn a_name_that_cannot_be_quoted_is_left_for_a_human_not_guessed() {
369        let deps = [dep("2026-10-01T00:00:00Z", "t", "h")];
370        let lookup = |_: &Deployment| Some(vec![exp("e1", "O'Brien", "api_key", "fp")]);
371        let r = report("h", None, &deps, &lookup, &|_| cur("O'Brien", &["fp"]));
372        assert!(r.command.starts_with("# cannot quote"), "{}", r.command);
373        assert!(!r.command.contains("unv entry rotate"));
374    }
375
376    #[test]
377    fn a_renamed_entry_is_listed_under_its_current_name() {
378        let deps = [dep("2026-10-01T00:00:00Z", "t", "h")];
379        let lookup = |_: &Deployment| Some(vec![exp("e1", "OldName", "api_key", "fp")]);
380        let r = report("h", None, &deps, &lookup, &|_| cur("NewName", &["fp"]));
381        assert_eq!(r.rotate, vec!["NewName"]);
382    }
383
384    #[test]
385    fn short_only_matches_are_marked_short() {
386        let deps = [dep("2026-10-01T00:00:00Z", "t", "h")];
387        let mut e = exp("e1", "P", "api_key", "fp");
388        e.short = true;
389        let lookup = move |_: &Deployment| Some(vec![e.clone()]);
390        let r = report("h", None, &deps, &lookup, &|_| cur("P", &["fp"]));
391        assert!(r.entries[0].short);
392    }
393}