Skip to main content

kovra_core/
doctor.rs

1//! `kovra doctor` / `lint` — validate a project's secret configuration (L12).
2//!
3//! Diagnoses the most common dev friction — a misconfigured secret contract —
4//! using **coordinates and resolution status only**. It never materializes or
5//! reveals a value (I11/I12): findings carry coordinates and a status, never
6//! secret bytes. Four check classes (spec §13):
7//!
8//! 1. **resolution** — every `.env.refs` `secret:` URI resolves in the vault
9//!    (via the §1.1 override table); an unresolved coordinate with no fallback
10//!    is an error.
11//! 2. **orphans** — vault entries (for the checked environment) that no
12//!    `.env.refs` line references — a warning (dead custody or a missing line).
13//! 3. **prod fallback** — a `prod` coordinate carrying a `| fallback` is a hard
14//!    error (I4c / [`crate::prod_forbids_fallback`]): prod must never silently
15//!    fall back to a non-custodied value.
16//! 4. **references** — a coordinate that resolves to an external reference
17//!    (`azure-kv://…`) is reported as a reference with its scheme; offline, its
18//!    remote accessibility is not probed (it degrades gracefully without a
19//!    provider — status only, never the value).
20
21use std::collections::BTreeSet;
22use std::str::FromStr;
23
24use crate::coordinate::Coordinate;
25use crate::crypto::KEY_LEN;
26use crate::envrefs::{EnvRefs, Source};
27use crate::error::CoreError;
28use crate::policy::prod_forbids_fallback;
29use crate::registry::{Registry, Resolution};
30use crate::store;
31
32/// How serious a [`Finding`] is. Only [`Severity::Error`] fails the command.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Severity {
35    /// A hard problem — `doctor` exits non-zero.
36    Error,
37    /// Worth attention but not fatal (e.g. an orphan, a used fallback).
38    Warning,
39    /// Informational (e.g. a reference whose remote access wasn't probed).
40    Info,
41}
42
43impl Severity {
44    /// A short uppercase tag for rendering (`ERROR` / `WARN` / `INFO`).
45    pub fn tag(&self) -> &'static str {
46        match self {
47            Severity::Error => "ERROR",
48            Severity::Warning => "WARN",
49            Severity::Info => "INFO",
50        }
51    }
52}
53
54/// A single diagnostic. Carries a coordinate (an address, never a value) and a
55/// human message — never any secret bytes (I11/I12).
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Finding {
58    /// Severity — only [`Severity::Error`] is fatal.
59    pub severity: Severity,
60    /// The coordinate or `.env.refs` variable the finding is about, if any.
61    pub coordinate: Option<String>,
62    /// The diagnostic message (value-free).
63    pub message: String,
64}
65
66/// The outcome of a `doctor` run: an ordered list of findings.
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct Report {
69    /// Findings in check order.
70    pub findings: Vec<Finding>,
71}
72
73impl Report {
74    fn push(&mut self, severity: Severity, coordinate: Option<String>, message: impl Into<String>) {
75        self.findings.push(Finding {
76            severity,
77            coordinate,
78            message: message.into(),
79        });
80    }
81
82    /// Whether any finding is an [`Severity::Error`] — the command's exit gate.
83    pub fn has_errors(&self) -> bool {
84        self.findings.iter().any(|f| f.severity == Severity::Error)
85    }
86
87    /// Count of findings at a given severity.
88    pub fn count(&self, severity: Severity) -> usize {
89        self.findings
90            .iter()
91            .filter(|f| f.severity == severity)
92            .count()
93    }
94}
95
96/// Run all checks against `refs` resolved for `env`, over `registry` with an
97/// already-materialized master `key`. `project` is the resolved project vault
98/// (the `--project` override or the `.env.refs` `project =` line), if any.
99///
100/// Pure of value materialization: it resolves coordinates to **records** (to
101/// read modality + existence) but never unseals or returns a value.
102pub fn check(
103    refs: &EnvRefs,
104    env: &str,
105    registry: &Registry,
106    key: &[u8; KEY_LEN],
107    project: Option<&str>,
108) -> Result<Report, CoreError> {
109    let mut report = Report::default();
110    let mut referenced: BTreeSet<String> = BTreeSet::new();
111
112    for (name, source) in &refs.vars {
113        let Source::Uri { uri, fallback } = source else {
114            continue; // literals / env-passthroughs are not vault coordinates
115        };
116
117        let coord = match Coordinate::from_str(uri) {
118            Ok(c) => c.with_env(env),
119            Err(e) => {
120                report.push(
121                    Severity::Error,
122                    Some(uri.clone()),
123                    format!("`{name}`: malformed coordinate — {e}"),
124                );
125                continue;
126            }
127        };
128        // `with_env` made the environment a literal, so `canonical_path` cannot
129        // hit its placeholder error; the env is the first canonical segment.
130        let canonical = coord
131            .canonical_path()
132            .expect("with_env replaced the ${ENV} placeholder");
133        let effective_env = canonical.split('/').next().unwrap_or(env);
134
135        // Check 3 — prod must never carry a fallback (hard error, I4c).
136        if prod_forbids_fallback(effective_env) && fallback.is_some() {
137            report.push(
138                Severity::Error,
139                Some(canonical.clone()),
140                format!("`{name}`: a `prod` coordinate must not have a `| fallback` (I4c)"),
141            );
142        }
143
144        // Check 1 — resolution status (never the value).
145        match registry.resolve_with_key(&coord, project, key)? {
146            Resolution::Found { record, .. } => {
147                referenced.insert(canonical.clone());
148                // Check 4 — a reference resolves but is not probed offline.
149                if let Some(reference) = record.reference() {
150                    let scheme = crate::reference_scheme(reference).unwrap_or("?");
151                    report.push(
152                        Severity::Info,
153                        Some(canonical.clone()),
154                        format!(
155                            "`{name}`: resolves to a `{scheme}` reference; \
156                             remote accessibility not probed offline"
157                        ),
158                    );
159                }
160            }
161            Resolution::NotFound => {
162                if fallback.is_some() {
163                    report.push(
164                        Severity::Warning,
165                        Some(canonical.clone()),
166                        format!("`{name}`: coordinate does not resolve, but a fallback is set"),
167                    );
168                } else {
169                    report.push(
170                        Severity::Error,
171                        Some(canonical.clone()),
172                        format!("`{name}`: coordinate does not resolve and has no fallback"),
173                    );
174                }
175            }
176        }
177    }
178
179    // Check 2 — orphans: vault entries (for this env) that nothing references.
180    for record in vault_records(registry, key, project)? {
181        if record.environment() != env {
182            continue; // only compare within the environment being checked
183        }
184        let path = record.canonical_path();
185        if !referenced.contains(&path) {
186            report.push(
187                Severity::Warning,
188                Some(path),
189                "orphan secret: in the vault for this env but not referenced by `.env.refs`",
190            );
191        }
192    }
193
194    Ok(report)
195}
196
197/// Load every record from the global vault plus the resolved project vault (if
198/// any). Reads records (modality/coordinate), never returns a value.
199fn vault_records(
200    registry: &Registry,
201    key: &[u8; KEY_LEN],
202    project: Option<&str>,
203) -> Result<Vec<crate::record::SecretRecord>, CoreError> {
204    let mut out = Vec::new();
205    out.extend(
206        store::load_all(&registry.global_dir(), key)?
207            .records
208            .into_iter()
209            .map(|(_, r)| r),
210    );
211    if let Some(name) = project {
212        let dir = registry.project_dir(name);
213        if dir.exists() {
214            out.extend(
215                store::load_all(&dir, key)?
216                    .records
217                    .into_iter()
218                    .map(|(_, r)| r),
219            );
220        }
221    }
222    Ok(out)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::record::SecretRecord;
229    use crate::sensitivity::Sensitivity;
230    use crate::{Coordinate, SecretValue, crypto::KEY_LEN, seal};
231
232    const KEY: [u8; KEY_LEN] = [7u8; KEY_LEN];
233
234    fn registry_with(records: &[SecretRecord]) -> (tempfile::TempDir, Registry) {
235        let tmp = tempfile::tempdir().unwrap();
236        let registry = Registry::open(tmp.path()).unwrap();
237        for rec in records {
238            let coord = Coordinate::from_str(&format!("secret:{}", rec.canonical_path())).unwrap();
239            store::write_record(&registry.global_dir(), &coord, &seal(rec, &KEY).unwrap()).unwrap();
240        }
241        (tmp, registry)
242    }
243
244    fn literal(env: &str, component: &str, key: &str, sens: Sensitivity) -> SecretRecord {
245        SecretRecord::Literal {
246            value: SecretValue::new(b"x".to_vec()),
247            sensitivity: sens,
248            revealable: false,
249            environment: env.to_string(),
250            component: component.to_string(),
251            key: key.to_string(),
252            description: None,
253            created: "t".to_string(),
254            updated: "t".to_string(),
255        }
256    }
257
258    fn reference(env: &str, component: &str, key: &str) -> SecretRecord {
259        SecretRecord::Reference {
260            reference: "azure-kv://corp-kv/db-url".to_string(),
261            sensitivity: Sensitivity::High,
262            revealable: false,
263            environment: env.to_string(),
264            component: component.to_string(),
265            key: key.to_string(),
266            description: None,
267            created: "t".to_string(),
268            updated: "t".to_string(),
269        }
270    }
271
272    #[test]
273    fn clean_config_has_no_errors() {
274        let (_tmp, reg) = registry_with(&[literal("dev", "db", "password", Sensitivity::Medium)]);
275        let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
276        let report = check(&refs, "dev", &reg, &KEY, None).unwrap();
277        assert!(!report.has_errors(), "clean config: {:?}", report.findings);
278    }
279
280    #[test]
281    fn unresolved_coordinate_without_fallback_is_error() {
282        let (_tmp, reg) = registry_with(&[]);
283        let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
284        let report = check(&refs, "dev", &reg, &KEY, None).unwrap();
285        assert!(report.has_errors());
286        assert!(
287            report
288                .findings
289                .iter()
290                .any(|f| f.message.contains("does not resolve"))
291        );
292    }
293
294    #[test]
295    fn unresolved_with_fallback_is_only_a_warning() {
296        let (_tmp, reg) = registry_with(&[]);
297        let refs = EnvRefs::parse("DB=secret:${ENV}/db/password | localhost").unwrap();
298        let report = check(&refs, "dev", &reg, &KEY, None).unwrap();
299        assert!(!report.has_errors());
300        assert_eq!(report.count(Severity::Warning), 1);
301    }
302
303    #[test]
304    fn prod_with_fallback_is_a_hard_error() {
305        let (_tmp, reg) = registry_with(&[literal("prod", "db", "password", Sensitivity::High)]);
306        let refs = EnvRefs::parse("DB=secret:prod/db/password | localhost").unwrap();
307        let report = check(&refs, "prod", &reg, &KEY, None).unwrap();
308        assert!(report.has_errors());
309        assert!(
310            report
311                .findings
312                .iter()
313                .any(|f| f.message.contains("must not have a `| fallback`"))
314        );
315    }
316
317    #[test]
318    fn orphan_vault_entry_is_flagged() {
319        let (_tmp, reg) = registry_with(&[
320            literal("dev", "db", "password", Sensitivity::Medium),
321            literal("dev", "app", "unused", Sensitivity::Medium),
322        ]);
323        let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
324        let report = check(&refs, "dev", &reg, &KEY, None).unwrap();
325        assert!(!report.has_errors(), "orphan is a warning, not an error");
326        assert!(
327            report.findings.iter().any(|f| {
328                f.severity == Severity::Warning && f.coordinate.as_deref() == Some("dev/app/unused")
329            }),
330            "the unused vault entry must be flagged as an orphan: {:?}",
331            report.findings
332        );
333    }
334
335    #[test]
336    fn reference_is_reported_as_info_not_value() {
337        let (_tmp, reg) = registry_with(&[reference("dev", "db", "url")]);
338        let refs = EnvRefs::parse("DB=secret:${ENV}/db/url").unwrap();
339        let report = check(&refs, "dev", &reg, &KEY, None).unwrap();
340        assert!(!report.has_errors());
341        let info = report
342            .findings
343            .iter()
344            .find(|f| f.severity == Severity::Info)
345            .expect("a reference yields an INFO finding");
346        assert!(info.message.contains("azure-kv"));
347        // No finding ever contains the reference's would-be value.
348        let blob = format!("{:?}", report.findings);
349        assert!(!blob.contains("db-url-value"));
350    }
351
352    #[test]
353    fn malformed_coordinate_is_an_error() {
354        let (_tmp, reg) = registry_with(&[]);
355        let refs = EnvRefs::parse("DB=secret:${ENV}/db/password").unwrap();
356        // Hand-craft a refs with a bad URI by bypassing parse (two segments).
357        let mut bad = refs.clone();
358        bad.vars = vec![(
359            "DB".to_string(),
360            Source::Uri {
361                uri: "secret:dev/onlytwo".to_string(),
362                fallback: None,
363            },
364        )];
365        let report = check(&bad, "dev", &reg, &KEY, None).unwrap();
366        assert!(report.has_errors());
367        assert!(
368            report
369                .findings
370                .iter()
371                .any(|f| f.message.contains("malformed coordinate"))
372        );
373    }
374}