Skip to main content

scour_secrets/
secrets.rs

1//! Encrypted secrets management.
2//!
3//! This module provides **in-memory-only** decryption of user-supplied
4//! secrets files. Secrets are never written to disk in plaintext form;
5//! they are loaded from an Argon2id + AES-256-GCM encrypted `.enc` file, decrypted
6//! into memory, parsed, and converted directly into [`ScanPattern`]s
7//! for the streaming scanner.
8//!
9//! # Encryption Format
10//!
11//! ```text
12//! ┌───────────┬───────────┬─────────────┬──────────────┬──────────────────────────┐
13//! │ Magic (5B)│ Ver (1 B) │ Salt (32 B) │ Nonce (12 B) │  AES-256-GCM Ciphertext  │
14//! └───────────┴───────────┴─────────────┴──────────────┴──────────────────────────┘
15//! ```
16//!
17//! - **Magic** (`SCOUR`) + **version** (`1`): identify the format exactly, so a
18//!   plaintext secrets file is never mistaken for ciphertext and vice versa.
19//! - **Salt** (32 bytes): random, used for the Argon2id-derived key.
20//! - **Nonce** (12 bytes): random, for AES-256-GCM.
21//! - **Ciphertext**: authenticated encryption of the plaintext secrets
22//!   file (JSON / YAML / TOML).
23//!
24//! The 256-bit AES key is derived from the user password using Argon2id
25//! (memory-hard; 19 MiB / 2 passes / 1 lane), the current OWASP recommendation.
26//! There is no legacy headerless format — version 1 is the first release.
27//!
28//! # Key Derivation
29//!
30//! ```text
31//! key = Argon2id(password, salt, m=19 MiB, t=2, p=1, dkLen=32)
32//! ```
33//!
34//! # Secrets File Schema
35//!
36//! The plaintext secrets file (before encryption) must deserialize to
37//! `Vec<SecretEntry>`:
38//!
39//! ```json
40//! [
41//!   {
42//!     "pattern": "alice@corp\\.com",
43//!     "kind": "regex",
44//!     "category": "email",
45//!     "label": "alice_email"
46//!   },
47//!   {
48//!     "pattern": "sk-proj-abc123secret",
49//!     "kind": "literal",
50//!     "category": "custom:api_key",
51//!     "label": "openai_key"
52//!   }
53//! ]
54//! ```
55//!
56//! # Thread Safety
57//!
58//! All public types are `Send + Sync`. Decrypted secrets use
59//! [`zeroize::Zeroizing`] to scrub plaintext from memory on drop.
60//!
61//! # Security Considerations
62//!
63//! - AES-256-GCM provides both confidentiality and integrity (AEAD).
64//! - Argon2id is memory-hard, resisting GPU/ASIC-accelerated offline
65//!   brute-force far better than an iterated PBKDF2.
66//! - Decrypted plaintext is held in [`Zeroizing<Vec<u8>>`] and zeroed
67//!   on drop.
68//! - The library's encrypt/decrypt APIs never write plaintext secrets to
69//!   disk. The CLI's structured handoff *does* write to the secrets file by
70//!   design: values discovered by profile scans are appended so the second
71//!   (streaming) pass and future runs redact them everywhere. A plaintext
72//!   secrets file is updated in place with owner-only (0600) permissions; an
73//!   encrypted file is merged and re-encrypted, never downgraded to
74//!   plaintext. Opt out with `--no-structured-handoff`.
75//! - Nonce and salt are generated with OS CSPRNG (`rand`).
76
77use crate::category::Category;
78use crate::error::{Result, SanitizeError};
79use crate::scanner::ScanPattern;
80
81/// Result of compiling secret entries into patterns.
82/// Contains successfully compiled patterns and a list of (index, error) for failures.
83pub type PatternCompileResult = (Vec<ScanPattern>, Vec<(usize, SanitizeError)>);
84
85use aes_gcm::aead::{Aead, KeyInit};
86use aes_gcm::{Aes256Gcm, Nonce};
87use argon2::{Algorithm, Argon2, Params, Version};
88use rand::RngCore;
89use serde::{Deserialize, Serialize};
90use zeroize::{Zeroize, Zeroizing};
91
92// ---------------------------------------------------------------------------
93// Constants
94// ---------------------------------------------------------------------------
95
96/// File magic identifying a scour encrypted secrets blob. A plaintext
97/// secrets file (JSON / YAML / TOML) never begins with these bytes followed
98/// by a version byte, so the header is an exact discriminator — no content
99/// heuristic is needed.
100const MAGIC: &[u8; 5] = b"SCOUR";
101
102/// Encrypted-format version. Version 1 denotes Argon2id (see [`ARGON2_M_COST`]
103/// and siblings) plus AES-256-GCM. A future parameter or algorithm change bumps
104/// this byte without changing the magic.
105const FORMAT_VERSION: u8 = 1;
106
107/// Header length: magic + 1-byte version.
108const HEADER_LEN: usize = MAGIC.len() + 1;
109
110/// Salt length for key derivation (bytes).
111const SALT_LEN: usize = 32;
112
113/// AES-GCM nonce length (bytes). Must be 12 for AES-256-GCM.
114const NONCE_LEN: usize = 12;
115
116/// Argon2id memory cost in KiB (19 MiB — OWASP 2023 baseline).
117const ARGON2_M_COST: u32 = 19 * 1024;
118/// Argon2id time cost (iterations).
119const ARGON2_T_COST: u32 = 2;
120/// Argon2id parallelism (lanes).
121const ARGON2_P_COST: u32 = 1;
122
123/// Minimum blob size: header + salt + nonce + at least a 16-byte AES-GCM tag.
124const MIN_ENCRYPTED_LEN: usize = HEADER_LEN + SALT_LEN + NONCE_LEN + 16;
125
126/// Maximum size of a plaintext secrets file accepted by [`parse_secrets`].
127/// Prevents OOM from accidentally passing a large binary or log file as secrets.
128const MAX_SECRETS_PLAINTEXT_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
129
130// ---------------------------------------------------------------------------
131// Secrets file schema
132// ---------------------------------------------------------------------------
133
134/// A single secret entry as stored in the (plaintext) secrets file.
135///
136/// After decryption the entries are parsed from JSON, YAML, or TOML and
137/// converted into [`ScanPattern`]s.
138///
139/// Implements [`Drop`] via [`Zeroize`] to scrub sensitive pattern data
140/// from memory when no longer needed (S-1 fix).
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[non_exhaustive]
143pub struct SecretEntry {
144    /// The pattern string (regex or literal text).
145    ///
146    /// For `kind: allow` entries this is the single allowlist pattern.
147    /// Omit when using [`values`](Self::values) instead.
148    #[serde(default)]
149    pub pattern: String,
150
151    /// `"regex"`, `"literal"`, `"allow"`, `"entropy"`, or `"field-name"`.
152    ///
153    /// `"field-name"` entries are not compiled into scanner patterns — they
154    /// are extracted separately and injected into structured-processor profiles
155    /// as field-name signals.  The `pattern` field is a case-insensitive
156    /// regex matched against bare field/key names; `threshold` controls the
157    /// entropy gate (defaults to `3.5` bits/char when omitted).
158    #[serde(default = "default_kind")]
159    pub kind: String,
160
161    /// Category string. Supported values:
162    /// `email`, `name`, `phone`, `ipv4`, `ipv6`, `credit_card`, `ssn`,
163    /// `hostname`, `mac_address`, `container_id`, `uuid`, `jwt`,
164    /// `auth_token`, `file_path`, `windows_sid`, `url`, `aws_arn`,
165    /// `azure_resource_id`, or `custom:<tag>`.
166    #[serde(default = "default_category")]
167    pub category: String,
168
169    /// Human-readable label for stats reporting (appears in the redaction
170    /// summary, findings, and reports). When omitted: a `regex` pattern defaults
171    /// to a truncated form of its (non-secret) pattern text; a `literal` pattern
172    /// — whose text *is* the secret value — defaults to `literal:<category>` so
173    /// the value is never exposed in reporting output.
174    #[serde(default)]
175    pub label: Option<String>,
176
177    /// Multiple allowlist patterns for `kind: allow` entries.
178    ///
179    /// When non-empty, used instead of `pattern`. Allows a single entry to
180    /// allowlist many values compactly:
181    ///
182    /// ```toml
183    /// [[secrets]]
184    /// kind = "allow"
185    /// values = ["localhost", "true", "false", "null", "0.0.0.0"]
186    /// ```
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub values: Vec<String>,
189
190    // ── Entropy-detection fields (only used when kind = "entropy") ──────────
191    /// Minimum token length to consider (default: 20).
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub min_length: Option<usize>,
194
195    /// Maximum token length to consider (default: 200).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub max_length: Option<usize>,
198
199    /// Shannon entropy threshold in bits per character (default: 4.5).
200    /// Tokens whose entropy is at or above this value are flagged.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub threshold: Option<f64>,
203
204    /// Character set the token must consist of exclusively.
205    /// `"alphanumeric"` (default), `"base64"`, `"hex"`, or `"any"`.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub charset: Option<String>,
208}
209
210impl SecretEntry {
211    /// Create an entry with the given pattern, kind, and category; all other
212    /// fields take their serde defaults. The struct is `#[non_exhaustive]`, so
213    /// this (or deserialization) is how entries are built outside the crate.
214    #[must_use]
215    pub fn new(
216        pattern: impl Into<String>,
217        kind: impl Into<String>,
218        category: impl Into<String>,
219    ) -> Self {
220        Self {
221            pattern: pattern.into(),
222            kind: kind.into(),
223            category: category.into(),
224            label: None,
225            values: Vec::new(),
226            min_length: None,
227            max_length: None,
228            threshold: None,
229            charset: None,
230        }
231    }
232
233    /// Set the reporting label (builder style).
234    #[must_use]
235    pub fn with_label(mut self, label: impl Into<String>) -> Self {
236        self.label = Some(label.into());
237        self
238    }
239
240    /// Set the multi-value allowlist patterns (builder style).
241    #[must_use]
242    pub fn with_values(mut self, values: Vec<String>) -> Self {
243        self.values = values;
244        self
245    }
246
247    /// Set the match-length bounds (builder style). Pass `None` to leave a
248    /// bound at its default.
249    #[must_use]
250    pub fn with_length_bounds(mut self, min: Option<usize>, max: Option<usize>) -> Self {
251        self.min_length = min;
252        self.max_length = max;
253        self
254    }
255
256    /// Set the entropy threshold (builder style; `kind: entropy` /
257    /// `kind: field-name` entries).
258    #[must_use]
259    pub fn with_threshold(mut self, threshold: f64) -> Self {
260        self.threshold = Some(threshold);
261        self
262    }
263
264    /// Set the entropy charset (builder style; `kind: entropy` entries).
265    #[must_use]
266    pub fn with_charset(mut self, charset: impl Into<String>) -> Self {
267        self.charset = Some(charset.into());
268        self
269    }
270}
271
272impl Drop for SecretEntry {
273    fn drop(&mut self) {
274        self.pattern.zeroize();
275        self.kind.zeroize();
276        self.category.zeroize();
277        if let Some(ref mut l) = self.label {
278            l.zeroize();
279        }
280        for v in &mut self.values {
281            v.zeroize();
282        }
283        if let Some(ref mut s) = self.charset {
284            s.zeroize();
285        }
286    }
287}
288
289fn default_kind() -> String {
290    "literal".into()
291}
292
293fn default_category() -> String {
294    "custom:secret".into()
295}
296
297/// Supported plaintext file formats for secrets.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299#[non_exhaustive]
300pub enum SecretsFormat {
301    Json,
302    Yaml,
303    Toml,
304}
305
306impl SecretsFormat {
307    /// Detect format from file extension.
308    pub fn from_extension(path: &str) -> Option<Self> {
309        // Strip .enc suffix first if present.
310        let base = path.strip_suffix(".enc").unwrap_or(path);
311        let ext = std::path::Path::new(base).extension();
312        if ext.is_some_and(|e| e.eq_ignore_ascii_case("json")) {
313            Some(Self::Json)
314        } else if ext
315            .is_some_and(|e| e.eq_ignore_ascii_case("yaml") || e.eq_ignore_ascii_case("yml"))
316        {
317            Some(Self::Yaml)
318        } else if ext.is_some_and(|e| e.eq_ignore_ascii_case("toml")) {
319            Some(Self::Toml)
320        } else {
321            None
322        }
323    }
324
325    /// Try to auto-detect format from content.
326    pub fn detect(content: &[u8]) -> Self {
327        let s = String::from_utf8_lossy(content);
328        // Skip leading comment lines — both YAML and TOML use `#`, so a file
329        // that opens with comments must be scanned further to find the first
330        // meaningful token.
331        let first_meaningful = s
332            .lines()
333            .map(str::trim)
334            .find(|l| !l.is_empty() && !l.starts_with('#'))
335            .unwrap_or("");
336        if first_meaningful.starts_with('[') || first_meaningful.starts_with('{') {
337            // `[` is ambiguous: JSON arrays and TOML table headers both start
338            // with it. We pick JSON here because our secrets files are never
339            // bare TOML tables, and a wrong guess produces a clear parse error.
340            Self::Json
341        } else if first_meaningful.starts_with('-') || first_meaningful.starts_with("---") {
342            Self::Yaml
343        } else {
344            // Fallback: assume TOML
345            Self::Toml
346        }
347    }
348}
349
350// ---------------------------------------------------------------------------
351// TOML wrapper — serde_toml expects a top-level table
352// ---------------------------------------------------------------------------
353
354/// Wrapper for TOML deserialization: `secrets = [...]`
355#[derive(Deserialize)]
356struct TomlSecrets {
357    secrets: Vec<SecretEntry>,
358}
359
360/// Wrapper for TOML serialization.
361#[derive(Serialize)]
362struct TomlSecretsRef<'a> {
363    secrets: &'a [SecretEntry],
364}
365
366// ---------------------------------------------------------------------------
367// Key derivation
368// ---------------------------------------------------------------------------
369
370/// Derive a 256-bit key from a password and salt using Argon2id.
371///
372/// Shared by the encrypted secrets-file key (this module) and the
373/// deterministic-generator seed (the CLI's `scanner_builder`), so both use one
374/// memory-hard KDF with identical parameters (19 MiB memory, 2 passes, 1 lane).
375///
376/// `salt` must be at least 8 bytes (an Argon2 requirement). Callers that accept
377/// arbitrary user-supplied salts (e.g. a deterministic seed salt) must normalize
378/// short input to a fixed-length salt before calling.
379///
380/// # Errors
381///
382/// Returns [`SanitizeError::SecretsCipherError`] if the parameters or salt are
383/// rejected by the Argon2 implementation.
384pub fn derive_key_argon2(password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
385    let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32))
386        .map_err(|e| SanitizeError::SecretsCipherError(format!("argon2 params: {e}")))?;
387    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
388    let mut key = Zeroizing::new([0u8; 32]);
389    argon2
390        .hash_password_into(password, salt, key.as_mut())
391        .map_err(|e| SanitizeError::SecretsCipherError(format!("argon2 kdf: {e}")))?;
392    Ok(key)
393}
394
395// ---------------------------------------------------------------------------
396// Encryption
397// ---------------------------------------------------------------------------
398
399/// Encrypt a plaintext secrets file.
400///
401/// Returns the encrypted blob:
402/// `magic (5) || version (1) || salt (32) || nonce (12) || ciphertext`.
403///
404/// # Arguments
405///
406/// - `plaintext` — raw bytes of the secrets file (JSON / YAML / TOML).
407/// - `password` — user-supplied password.
408///
409/// # Errors
410///
411/// Returns [`SanitizeError::SecretsEmptyPassword`] if the password is empty, or
412/// [`SanitizeError::SecretsCipherError`] if key derivation or encryption fails.
413///
414/// # Security
415///
416/// - Salt and nonce are generated with CSPRNG.
417/// - Key is derived with Argon2id (memory-hard; see [`derive_key_argon2`]).
418/// - AES-256-GCM provides authenticated encryption.
419pub fn encrypt_secrets(plaintext: &[u8], password: &str) -> Result<Vec<u8>> {
420    if password.is_empty() {
421        return Err(SanitizeError::SecretsEmptyPassword);
422    }
423
424    let mut rng = rand::rng();
425
426    // Generate random salt and nonce.
427    let mut salt = [0u8; SALT_LEN];
428    rng.fill_bytes(&mut salt);
429
430    let mut nonce_bytes = [0u8; NONCE_LEN];
431    rng.fill_bytes(&mut nonce_bytes);
432    let nonce = Nonce::from_slice(&nonce_bytes);
433
434    // Derive key.
435    let key = derive_key_argon2(password.as_bytes(), &salt)?;
436    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
437        .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
438
439    // Encrypt.
440    let ciphertext = cipher
441        .encrypt(nonce, plaintext)
442        .map_err(|e| SanitizeError::SecretsCipherError(format!("encryption: {}", e)))?;
443
444    // Assemble: magic || version || salt || nonce || ciphertext
445    let mut output = Vec::with_capacity(HEADER_LEN + SALT_LEN + NONCE_LEN + ciphertext.len());
446    output.extend_from_slice(MAGIC);
447    output.push(FORMAT_VERSION);
448    output.extend_from_slice(&salt);
449    output.extend_from_slice(&nonce_bytes);
450    output.extend_from_slice(&ciphertext);
451
452    Ok(output)
453}
454
455// ---------------------------------------------------------------------------
456// Decryption
457// ---------------------------------------------------------------------------
458
459/// Decrypt an encrypted secrets blob in memory.
460///
461/// Returns the plaintext wrapped in [`Zeroizing`] so it is scrubbed on drop.
462///
463/// # Arguments
464///
465/// - `encrypted` — `magic (5) || version (1) || salt (32) || nonce (12) || ciphertext`.
466/// - `password` — user-supplied password.
467///
468/// # Errors
469///
470/// - [`SanitizeError::SecretsTooShort`] if the blob is too short.
471/// - [`SanitizeError::SecretsUnrecognizedFormat`] if the magic is absent or the
472///   version is unsupported (there is no legacy headerless format).
473/// - [`SanitizeError::SecretsDecryptFailed`] if the password is wrong or the
474///   ciphertext has been tampered with.
475pub fn decrypt_secrets(encrypted: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>> {
476    if encrypted.len() < MIN_ENCRYPTED_LEN {
477        return Err(SanitizeError::SecretsTooShort);
478    }
479    if &encrypted[..MAGIC.len()] != MAGIC || encrypted[MAGIC.len()] != FORMAT_VERSION {
480        return Err(SanitizeError::SecretsUnrecognizedFormat);
481    }
482
483    let body = &encrypted[HEADER_LEN..];
484    let salt = &body[..SALT_LEN];
485    let nonce_bytes = &body[SALT_LEN..SALT_LEN + NONCE_LEN];
486    let ciphertext = &body[SALT_LEN + NONCE_LEN..];
487
488    let nonce = Nonce::from_slice(nonce_bytes);
489
490    let key = derive_key_argon2(password.as_bytes(), salt)?;
491    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
492        .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
493
494    let plaintext = cipher
495        .decrypt(nonce, ciphertext)
496        .map_err(|_| SanitizeError::SecretsDecryptFailed)?;
497
498    Ok(Zeroizing::new(plaintext))
499}
500
501// ---------------------------------------------------------------------------
502// Parsing
503// ---------------------------------------------------------------------------
504
505/// Parse a decrypted plaintext into secret entries.
506///
507/// Supports JSON, YAML, and TOML. Format is auto-detected if `format`
508/// is `None`.
509///
510/// # Errors
511///
512/// Returns [`SanitizeError::SecretsInvalidUtf8`] if the plaintext is not
513/// valid UTF-8, [`SanitizeError::SecretsFormatError`] if it cannot be parsed
514/// in the specified format or if the file exceeds the size limit.
515pub fn parse_secrets(plaintext: &[u8], format: Option<SecretsFormat>) -> Result<Vec<SecretEntry>> {
516    if plaintext.len() > MAX_SECRETS_PLAINTEXT_BYTES {
517        return Err(SanitizeError::SecretsFormatError {
518            format: "secrets file".into(),
519            message: format!(
520                "file is {} bytes, exceeding the {} byte limit — \
521                 secrets files should be small YAML/JSON/TOML pattern lists",
522                plaintext.len(),
523                MAX_SECRETS_PLAINTEXT_BYTES,
524            ),
525        });
526    }
527    let fmt = format.unwrap_or_else(|| SecretsFormat::detect(plaintext));
528    let text = std::str::from_utf8(plaintext)
529        .map_err(|e| SanitizeError::SecretsInvalidUtf8(e.to_string()))?;
530
531    // Parser error messages are deliberately reduced to a location: a secrets
532    // file's content is secret by definition, and both serde data errors
533    // (`invalid type: string "…"`) and toml's snippet rendering echo source
534    // text verbatim — straight into stderr, CI logs, and scrollback.
535    match fmt {
536        SecretsFormat::Json => serde_json::from_str(text).map_err(|e| {
537            let loc = (e.line() > 0).then(|| (e.line(), e.column()));
538            secrets_parse_error("JSON", loc)
539        }),
540        SecretsFormat::Yaml => serde_yaml_ng::from_str(text)
541            .map_err(|e| secrets_parse_error("YAML", e.location().map(|l| (l.line(), l.column())))),
542        SecretsFormat::Toml => {
543            let wrapper: TomlSecrets = toml::from_str(text).map_err(|e| {
544                secrets_parse_error("TOML", e.span().map(|s| line_col_at(text, s.start)))
545            })?;
546            Ok(wrapper.secrets)
547        }
548    }
549}
550
551/// Build a location-only secrets parse error. The parser's own message is
552/// never included — see the comment in [`parse_secrets`].
553fn secrets_parse_error(format: &str, location: Option<(usize, usize)>) -> SanitizeError {
554    let loc = location.map_or_else(String::new, |(line, col)| {
555        format!(" at line {line}, column {col}")
556    });
557    SanitizeError::SecretsFormatError {
558        format: format.into(),
559        message: format!(
560            "invalid secrets file{loc} \
561             (parser details withheld — secrets file content is never echoed)"
562        ),
563    }
564}
565
566/// 1-based (line, column) of a byte offset within `text`. Columns count bytes
567/// since the last newline, which is exact for the ASCII syntax around a TOML
568/// error and close enough for an error pointer otherwise.
569pub(crate) fn line_col_at(text: &str, offset: usize) -> (usize, usize) {
570    let offset = offset.min(text.len());
571    let before = &text.as_bytes()[..offset];
572    let line = bytecount::count(before, b'\n') + 1;
573    let col = offset
574        - before
575            .iter()
576            .rposition(|&b| b == b'\n')
577            .map_or(0, |p| p + 1)
578        + 1;
579    (line, col)
580}
581
582/// Serialize secret entries back into a plaintext format.
583///
584/// Used by the encryption helper CLI.
585///
586/// # Errors
587///
588/// Returns [`SanitizeError::SecretsFormatError`] if serialization fails.
589pub fn serialize_secrets(entries: &[SecretEntry], format: SecretsFormat) -> Result<Vec<u8>> {
590    match format {
591        SecretsFormat::Json => {
592            serde_json::to_vec_pretty(entries).map_err(|e| SanitizeError::SecretsFormatError {
593                format: "JSON-serialize".into(),
594                message: e.to_string(),
595            })
596        }
597        SecretsFormat::Yaml => serde_yaml_ng::to_string(entries)
598            .map(|s| s.into_bytes())
599            .map_err(|e| SanitizeError::SecretsFormatError {
600                format: "YAML-serialize".into(),
601                message: e.to_string(),
602            }),
603        SecretsFormat::Toml => {
604            let wrapper = TomlSecretsRef { secrets: entries };
605            toml::to_string_pretty(&wrapper)
606                .map(|s| s.into_bytes())
607                .map_err(|e| SanitizeError::SecretsFormatError {
608                    format: "TOML-serialize".into(),
609                    message: e.to_string(),
610                })
611        }
612    }
613}
614
615// ---------------------------------------------------------------------------
616// Category parsing
617// ---------------------------------------------------------------------------
618
619/// Parse a category string into a [`Category`].
620///
621/// Accepted values: `email`, `name`, `phone`, `ipv4`, `ipv6`,
622/// `credit_card`, `ssn`, `hostname`, `mac_address`, `container_id`,
623/// `uuid`, `jwt`, `auth_token`, `file_path`, `windows_sid`, `url`,
624/// `aws_arn`, `azure_resource_id`, or `custom:<tag>`.
625pub fn parse_category(s: &str) -> Category {
626    match s {
627        "email" => Category::Email,
628        "name" => Category::Name,
629        "phone" => Category::Phone,
630        "ipv4" => Category::IpV4,
631        "ipv6" => Category::IpV6,
632        "credit_card" => Category::CreditCard,
633        "ssn" => Category::Ssn,
634        "hostname" => Category::Hostname,
635        "mac_address" => Category::MacAddress,
636        "container_id" => Category::ContainerId,
637        "uuid" => Category::Uuid,
638        "jwt" => Category::Jwt,
639        "auth_token" => Category::AuthToken,
640        "file_path" => Category::FilePath,
641        "windows_sid" => Category::WindowsSid,
642        "url" => Category::Url,
643        "aws_arn" => Category::AwsArn,
644        "azure_resource_id" => Category::AzureResourceId,
645        other => {
646            let tag = if let Some(tag) = other.strip_prefix("custom:") {
647                tag
648            } else {
649                // A bare unknown string is more often a typo of a built-in
650                // ("emial") than an intentional custom tag; the contract
651                // stays "never errors", but surface it.
652                tracing::warn!(
653                    category = other,
654                    "unknown category — treated as custom:{}; use the \
655                     `custom:` prefix to silence this warning",
656                    other
657                );
658                other
659            };
660            Category::Custom(tag.into())
661        }
662    }
663}
664
665// ---------------------------------------------------------------------------
666// Conversion to ScanPatterns
667// ---------------------------------------------------------------------------
668
669/// Extract allowlist patterns from a set of entries.
670///
671/// Entries with `kind: allow` are returned as raw pattern strings to be
672/// compiled into an [`AllowlistMatcher`](crate::allowlist::AllowlistMatcher). They are skipped by
673/// [`entries_to_patterns`].
674///
675/// Each entry contributes either its `values` list (when non-empty) or its
676/// `pattern` field (when `values` is absent), so both forms are supported:
677///
678/// ```toml
679/// # single pattern
680/// [[secrets]]
681/// kind = "allow"
682/// pattern = "localhost"
683///
684/// # compact multi-value form
685/// [[secrets]]
686/// kind = "allow"
687/// values = ["true", "false", "null", "0.0.0.0"]
688/// ```
689pub fn extract_allow_patterns(entries: &[SecretEntry]) -> Vec<String> {
690    let mut patterns = Vec::new();
691    for entry in entries.iter().filter(|e| e.kind == "allow") {
692        if !entry.values.is_empty() {
693            patterns.extend(entry.values.iter().cloned());
694        } else if !entry.pattern.is_empty() {
695            patterns.push(entry.pattern.clone());
696        }
697    }
698    patterns
699}
700
701/// Convert parsed [`SecretEntry`]s into compiled [`ScanPattern`]s.
702///
703/// Entries with `kind: allow` are silently skipped — they are handled by
704/// [`extract_allow_patterns`] instead.
705///
706/// Invalid entries (e.g. bad regex) are collected as errors and
707/// returned alongside the successfully compiled patterns.
708pub fn entries_to_patterns(entries: &[SecretEntry]) -> PatternCompileResult {
709    let mut patterns = Vec::with_capacity(entries.len());
710    let mut errors = Vec::new();
711
712    for (i, entry) in entries.iter().enumerate() {
713        if entry.kind == "allow"
714            || entry.kind == "entropy"
715            || entry.kind == "field-name"
716            || entry.pattern.is_empty()
717        {
718            continue;
719        }
720        let category = parse_category(&entry.category);
721        // A `literal` pattern's text IS the secret value, and labels surface in
722        // the redaction summary, findings, reports, and logs — so a literal must
723        // never default its label to its own pattern (that leaks the secret).
724        // Fall back to the category instead. A `regex` pattern is not itself a
725        // secret, so its (truncated) text remains a safe, informative default.
726        let label = entry.label.clone().unwrap_or_else(|| {
727            if entry.kind == "literal" {
728                format!("literal:{}", entry.category)
729            } else {
730                truncate_label(&entry.pattern)
731            }
732        });
733
734        let result = match entry.kind.as_str() {
735            "regex" => ScanPattern::from_regex(&entry.pattern, category, label),
736            "literal" => ScanPattern::from_literal(&entry.pattern, category, label),
737            other => {
738                errors.push((
739                    i,
740                    SanitizeError::InvalidConfig(format!(
741                        "unknown kind {:?} — expected \"literal\", \"regex\", \"allow\", \"entropy\", or \"field-name\"",
742                        other
743                    )),
744                ));
745                continue;
746            }
747        };
748
749        match result {
750            Ok(pat) => {
751                // Apply the entry's optional match-length bounds. Unset bounds
752                // keep the pattern's defaults (min 0 for regex / literal length
753                // for literals, max unbounded).
754                let min = entry.min_length.unwrap_or(pat.min_length);
755                let max = entry.max_length.unwrap_or(pat.max_length);
756                patterns.push(pat.with_length_bounds(min, max));
757            }
758            Err(e) => errors.push((i, e)),
759        }
760    }
761
762    (patterns, errors)
763}
764
765const MAX_LABEL_CHARS: usize = 32;
766
767/// Truncate to a maximum label length.
768fn truncate_label(s: &str) -> String {
769    if s.len() <= MAX_LABEL_CHARS {
770        s.to_string()
771    } else {
772        // Find a char boundary just before the limit to avoid panicking on
773        // multi-byte UTF-8 characters (e.g. Unicode in user-supplied patterns).
774        let cut = s
775            .char_indices()
776            .nth(MAX_LABEL_CHARS - 1)
777            .map_or(s.len(), |(i, _)| i);
778        format!("{}…", &s[..cut])
779    }
780}
781
782// ---------------------------------------------------------------------------
783// High-level: load encrypted secrets → ScanPatterns
784// ---------------------------------------------------------------------------
785
786/// Load, decrypt, parse, and compile an encrypted secrets file into
787/// [`ScanPattern`]s ready for the streaming scanner.
788///
789/// This is the primary entry point for CLI integration.
790///
791/// # Arguments
792///
793/// - `encrypted_bytes` — raw bytes of the `.enc` file.
794/// - `password` — user-supplied password.
795/// - `format` — optional explicit format override.
796///
797/// # Returns
798///
799/// `(patterns, warnings)` where `warnings` contains indices and errors
800/// for entries that failed to compile.
801///
802/// # Security
803///
804/// The decrypted plaintext is held in zeroizing memory and dropped
805/// immediately after parsing.
806///
807/// # Errors
808///
809/// Returns a secrets-related [`SanitizeError`] if decryption or parsing fails.
810pub fn load_encrypted_secrets(
811    encrypted_bytes: &[u8],
812    password: &str,
813    format: Option<SecretsFormat>,
814) -> Result<(PatternCompileResult, Vec<String>)> {
815    let plaintext = decrypt_secrets(encrypted_bytes, password)?;
816    let entries = parse_secrets(&plaintext, format)?;
817    let allow = extract_allow_patterns(&entries);
818    let result = entries_to_patterns(&entries);
819    // SecretEntry implements Drop with explicit zeroize() calls, so dropping
820    // the Vec is sufficient to scrub sensitive pattern data from heap memory.
821    drop(entries);
822    Ok((result, allow))
823}
824
825/// Load and parse a plaintext secrets file into [`ScanPattern`]s.
826///
827/// This function mirrors [`load_encrypted_secrets`] but skips
828/// AES decryption and password prompts entirely. It preserves
829/// memory hygiene by zeroizing parsed entries after compilation.
830///
831/// # Arguments
832///
833/// - `plaintext` — raw bytes of the secrets file (JSON / YAML / TOML).
834/// - `format` — optional explicit format override.
835///
836/// # Security
837///
838/// Even for unencrypted secrets, entries are zeroized after pattern
839/// compilation to minimise the window during which sensitive values
840/// reside in memory.
841///
842/// # Errors
843///
844/// Returns a secrets-related [`SanitizeError`] if parsing or pattern
845/// compilation fails.
846pub fn load_plaintext_secrets(
847    plaintext: &[u8],
848    format: Option<SecretsFormat>,
849) -> Result<(PatternCompileResult, Vec<String>)> {
850    let entries = parse_secrets(plaintext, format)?;
851    let allow = extract_allow_patterns(&entries);
852    let result = entries_to_patterns(&entries);
853    // SecretEntry implements Drop with explicit zeroize() calls, so dropping
854    // the Vec is sufficient to scrub sensitive pattern data from heap memory.
855    drop(entries);
856    Ok((result, allow))
857}
858
859/// Detect whether raw file bytes are a scour encrypted secrets blob.
860///
861/// Returns `true` iff the content begins with the format header
862/// (`MAGIC || FORMAT_VERSION`). Because a plaintext secrets file
863/// (JSON / YAML / TOML) never starts with those bytes, this is an exact
864/// discriminator — unlike the pre-1.0 content heuristic it cannot misclassify
865/// a plaintext file whose first token happens to be a bare key.
866#[must_use]
867pub fn looks_encrypted(data: &[u8]) -> bool {
868    data.len() >= HEADER_LEN && &data[..MAGIC.len()] == MAGIC && data[MAGIC.len()] == FORMAT_VERSION
869}
870
871/// Unified loader: auto-detect encrypted vs plaintext and load
872/// secret patterns accordingly.
873///
874/// When `force_plaintext` is `true`, decryption is skipped regardless
875/// of file content. When `false`, the function uses [`looks_encrypted`]
876/// to choose the path automatically.
877///
878/// # Arguments
879///
880/// - `data` — raw bytes read from the secrets file.
881/// - `password` — password for decryption (ignored when plaintext).
882/// - `format` — optional format override.
883/// - `force_plaintext` — if `true`, always treat as plaintext.
884///
885/// # Errors
886///
887/// Returns a secrets-related [`SanitizeError`] if decryption or parsing
888/// fails, or if a password is required but not provided.
889pub fn load_secrets_auto(
890    data: &[u8],
891    password: Option<&str>,
892    format: Option<SecretsFormat>,
893    force_plaintext: bool,
894) -> Result<AutoLoadedSecrets> {
895    let (result, allow_patterns, was_encrypted) = if force_plaintext || !looks_encrypted(data) {
896        let (result, allow) = load_plaintext_secrets(data, format)?;
897        (result, allow, false)
898    } else {
899        let pw = password.ok_or(SanitizeError::SecretsPasswordRequired)?;
900        let (result, allow) = load_encrypted_secrets(data, pw, format)?;
901        (result, allow, true)
902    };
903    let (patterns, warnings) = result;
904    Ok(AutoLoadedSecrets {
905        patterns,
906        warnings,
907        allow_patterns,
908        was_encrypted,
909    })
910}
911
912/// Result of [`load_secrets_auto`]: compiled patterns plus everything the
913/// caller needs to finish wiring a scanner.
914///
915/// `allow_patterns` are the raw strings from `kind: allow` entries in the
916/// secrets file — combine these with any CLI-provided allow values and pass
917/// the merged list to
918/// [`AllowlistMatcher::new`](crate::allowlist::AllowlistMatcher::new).
919/// A non-empty `warnings` list means some entries failed to compile and the
920/// scanner covers less than the full file.
921#[derive(Debug)]
922#[non_exhaustive]
923pub struct AutoLoadedSecrets {
924    /// Successfully compiled scan patterns.
925    pub patterns: Vec<ScanPattern>,
926    /// Entries that failed pattern compilation: `(index_in_file, error)`.
927    pub warnings: Vec<(usize, SanitizeError)>,
928    /// Raw `kind: allow` pattern strings.
929    pub allow_patterns: Vec<String>,
930    /// Whether the input bytes were AES-256-GCM encrypted.
931    pub was_encrypted: bool,
932}
933
934// ---------------------------------------------------------------------------
935// Unit tests
936// ---------------------------------------------------------------------------
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941
942    fn sample_json() -> &'static str {
943        r#"[
944            {
945                "pattern": "alice@corp\\.com",
946                "kind": "regex",
947                "category": "email",
948                "label": "alice_email"
949            },
950            {
951                "pattern": "sk-proj-abc123secret",
952                "kind": "literal",
953                "category": "custom:api_key",
954                "label": "openai_key"
955            }
956        ]"#
957    }
958
959    fn sample_yaml() -> &'static str {
960        r#"- pattern: "alice@corp\\.com"
961  kind: regex
962  category: email
963  label: alice_email
964- pattern: sk-proj-abc123secret
965  kind: literal
966  category: "custom:api_key"
967  label: openai_key
968"#
969    }
970
971    fn sample_toml() -> &'static str {
972        r#"[[secrets]]
973pattern = "alice@corp\\.com"
974kind = "regex"
975category = "email"
976label = "alice_email"
977
978[[secrets]]
979pattern = "sk-proj-abc123secret"
980kind = "literal"
981category = "custom:api_key"
982label = "openai_key"
983"#
984    }
985
986    // ---- Parsing ----
987
988    #[test]
989    fn parse_json_entries() {
990        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
991        assert_eq!(entries.len(), 2);
992        assert_eq!(entries[0].kind, "regex");
993        assert_eq!(entries[0].category, "email");
994        assert_eq!(entries[1].kind, "literal");
995    }
996
997    #[test]
998    fn parse_yaml_entries() {
999        let entries = parse_secrets(sample_yaml().as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1000        assert_eq!(entries.len(), 2);
1001        assert_eq!(entries[0].label, Some("alice_email".into()));
1002    }
1003
1004    #[test]
1005    fn parse_toml_entries() {
1006        let entries = parse_secrets(sample_toml().as_bytes(), Some(SecretsFormat::Toml)).unwrap();
1007        assert_eq!(entries.len(), 2);
1008        assert_eq!(entries[1].pattern, "sk-proj-abc123secret");
1009    }
1010
1011    #[test]
1012    fn parse_auto_detect_json() {
1013        let entries = parse_secrets(sample_json().as_bytes(), None).unwrap();
1014        assert_eq!(entries.len(), 2);
1015    }
1016
1017    #[test]
1018    fn parse_auto_detect_yaml() {
1019        let entries = parse_secrets(sample_yaml().as_bytes(), None).unwrap();
1020        assert_eq!(entries.len(), 2);
1021    }
1022
1023    // ---- Category parsing ----
1024
1025    #[test]
1026    fn parse_builtin_categories() {
1027        assert_eq!(parse_category("email"), Category::Email);
1028        assert_eq!(parse_category("ipv4"), Category::IpV4);
1029        assert_eq!(parse_category("ssn"), Category::Ssn);
1030    }
1031
1032    #[test]
1033    fn parse_custom_category() {
1034        match parse_category("custom:api_key") {
1035            Category::Custom(tag) => assert_eq!(tag.as_str(), "api_key"),
1036            other => panic!("expected Custom, got {:?}", other),
1037        }
1038    }
1039
1040    #[test]
1041    fn parse_unknown_category_becomes_custom() {
1042        match parse_category("foobar") {
1043            Category::Custom(tag) => assert_eq!(tag.as_str(), "foobar"),
1044            other => panic!("expected Custom, got {:?}", other),
1045        }
1046    }
1047
1048    // ---- Entries to patterns ----
1049
1050    #[test]
1051    fn entries_to_patterns_success() {
1052        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1053        let (patterns, errors) = entries_to_patterns(&entries);
1054        assert_eq!(patterns.len(), 2);
1055        assert!(errors.is_empty());
1056    }
1057
1058    #[test]
1059    fn entries_to_patterns_applies_length_bounds() {
1060        let json = r#"[
1061            {"pattern": "[0-9]+", "kind": "regex", "category": "custom:num",
1062             "min_length": 4, "max_length": 8},
1063            {"pattern": "[a-z]+", "kind": "regex", "category": "custom:word"}
1064        ]"#;
1065        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1066        let (patterns, errors) = entries_to_patterns(&entries);
1067        assert!(errors.is_empty());
1068        assert_eq!(patterns.len(), 2);
1069        assert_eq!(patterns[0].min_length, 4);
1070        assert_eq!(patterns[0].max_length, 8);
1071        // Unset bounds keep regex defaults (0 / unbounded).
1072        assert_eq!(patterns[1].min_length, 0);
1073        assert_eq!(patterns[1].max_length, usize::MAX);
1074    }
1075
1076    #[test]
1077    fn entries_to_patterns_bad_regex() {
1078        let json = r#"[{"pattern": "[invalid(", "kind": "regex", "category": "email"}]"#;
1079        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1080        let (patterns, errors) = entries_to_patterns(&entries);
1081        assert!(patterns.is_empty());
1082        assert_eq!(errors.len(), 1);
1083        assert_eq!(errors[0].0, 0);
1084    }
1085
1086    // ---- Encrypt / Decrypt round-trip ----
1087
1088    #[test]
1089    fn encrypt_decrypt_roundtrip() {
1090        let plaintext = sample_json().as_bytes();
1091        let password = "test-password-42";
1092
1093        let encrypted = encrypt_secrets(plaintext, password).unwrap();
1094
1095        // Encrypted blob must be larger than plaintext (salt + nonce + tag).
1096        assert!(encrypted.len() > plaintext.len());
1097
1098        let decrypted = decrypt_secrets(&encrypted, password).unwrap();
1099        assert_eq!(decrypted.as_slice(), plaintext);
1100    }
1101
1102    #[test]
1103    fn decrypt_wrong_password_fails() {
1104        let plaintext = b"hello";
1105        let encrypted = encrypt_secrets(plaintext, "correct").unwrap();
1106        let result = decrypt_secrets(&encrypted, "wrong");
1107        assert!(result.is_err());
1108    }
1109
1110    #[test]
1111    fn decrypt_truncated_blob_fails() {
1112        let result = decrypt_secrets(&[0u8; 10], "any");
1113        assert!(result.is_err());
1114    }
1115
1116    #[test]
1117    fn decrypt_tampered_blob_fails() {
1118        let plaintext = b"hello world";
1119        let mut encrypted = encrypt_secrets(plaintext, "pw").unwrap();
1120        // Flip a byte in the ciphertext portion.
1121        let last = encrypted.len() - 1;
1122        encrypted[last] ^= 0xFF;
1123        let result = decrypt_secrets(&encrypted, "pw");
1124        assert!(result.is_err());
1125    }
1126
1127    #[test]
1128    fn encrypt_empty_password_rejected() {
1129        let result = encrypt_secrets(b"hello", "");
1130        assert!(result.is_err());
1131    }
1132
1133    #[test]
1134    fn encrypt_emits_magic_and_version_header() {
1135        let encrypted = encrypt_secrets(b"hello", "pw").unwrap();
1136        assert_eq!(&encrypted[..MAGIC.len()], MAGIC, "magic prefix");
1137        assert_eq!(encrypted[MAGIC.len()], FORMAT_VERSION, "version byte");
1138    }
1139
1140    #[test]
1141    fn decrypt_rejects_missing_magic() {
1142        // A blob of the right length but without the header (e.g. the pre-1.0
1143        // headerless format, or random bytes) must be rejected as unrecognized
1144        // — never attempted as ciphertext with a legacy layout.
1145        let blob = vec![0u8; MIN_ENCRYPTED_LEN + 4];
1146        match decrypt_secrets(&blob, "pw") {
1147            Err(SanitizeError::SecretsUnrecognizedFormat) => {}
1148            other => panic!("expected SecretsUnrecognizedFormat, got {other:?}"),
1149        }
1150    }
1151
1152    #[test]
1153    fn decrypt_rejects_unsupported_version() {
1154        let mut encrypted = encrypt_secrets(b"hello", "pw").unwrap();
1155        encrypted[MAGIC.len()] = FORMAT_VERSION.wrapping_add(1);
1156        match decrypt_secrets(&encrypted, "pw") {
1157            Err(SanitizeError::SecretsUnrecognizedFormat) => {}
1158            other => panic!("expected SecretsUnrecognizedFormat, got {other:?}"),
1159        }
1160    }
1161
1162    #[test]
1163    fn looks_encrypted_requires_header() {
1164        // Plaintext whose first token merely starts with "SCOUR" is not
1165        // encrypted: byte 5 is not the version byte. The old content heuristic
1166        // could misclassify such files; the header check cannot.
1167        assert!(!looks_encrypted(b"SCOUR_KEY = \"value\"\n"));
1168        assert!(!looks_encrypted(&[0u8; MIN_ENCRYPTED_LEN]));
1169    }
1170
1171    #[test]
1172    fn derive_key_argon2_is_deterministic_and_salt_sensitive() {
1173        let salt_a = [7u8; SALT_LEN];
1174        let salt_b = [9u8; SALT_LEN];
1175        let k1 = derive_key_argon2(b"password", &salt_a).unwrap();
1176        let k2 = derive_key_argon2(b"password", &salt_a).unwrap();
1177        let k3 = derive_key_argon2(b"password", &salt_b).unwrap();
1178        assert_eq!(*k1, *k2, "same password+salt must yield the same key");
1179        assert_ne!(*k1, *k3, "different salt must yield a different key");
1180    }
1181
1182    #[test]
1183    fn derive_key_argon2_rejects_short_salt() {
1184        // Argon2 requires a salt of at least 8 bytes; callers with arbitrary
1185        // salts must normalize first (the seed path SHA-256s the salt).
1186        assert!(derive_key_argon2(b"password", b"tiny").is_err());
1187    }
1188
1189    // ---- Full pipeline: encrypt → decrypt → parse → patterns ----
1190
1191    #[test]
1192    fn full_pipeline_json() {
1193        let plaintext = sample_json().as_bytes();
1194        let password = "pipeline-test";
1195
1196        let encrypted = encrypt_secrets(plaintext, password).unwrap();
1197        let ((patterns, errors), _allow) =
1198            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Json)).unwrap();
1199
1200        assert_eq!(patterns.len(), 2);
1201        assert!(errors.is_empty());
1202        assert_eq!(patterns[0].label(), "alice_email");
1203        assert_eq!(patterns[1].label(), "openai_key");
1204    }
1205
1206    #[test]
1207    fn full_pipeline_yaml() {
1208        let plaintext = sample_yaml().as_bytes();
1209        let password = "yaml-test";
1210
1211        let encrypted = encrypt_secrets(plaintext, password).unwrap();
1212        let ((patterns, errors), _allow) =
1213            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Yaml)).unwrap();
1214
1215        assert_eq!(patterns.len(), 2);
1216        assert!(errors.is_empty());
1217    }
1218
1219    #[test]
1220    fn full_pipeline_toml() {
1221        let plaintext = sample_toml().as_bytes();
1222        let password = "toml-test";
1223
1224        let encrypted = encrypt_secrets(plaintext, password).unwrap();
1225        let ((patterns, errors), _allow) =
1226            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Toml)).unwrap();
1227
1228        assert_eq!(patterns.len(), 2);
1229        assert!(errors.is_empty());
1230    }
1231
1232    // ---- Plaintext loader ----
1233
1234    #[test]
1235    fn load_plaintext_secrets_works() {
1236        let ((patterns, errors), _allow) =
1237            load_plaintext_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1238        assert_eq!(patterns.len(), 2);
1239        assert!(errors.is_empty());
1240    }
1241
1242    // ---- Serialization round-trip ----
1243
1244    #[test]
1245    fn serialize_roundtrip_json() {
1246        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1247        let serialized = serialize_secrets(&entries, SecretsFormat::Json).unwrap();
1248        let reparsed = parse_secrets(&serialized, Some(SecretsFormat::Json)).unwrap();
1249        assert_eq!(entries.len(), reparsed.len());
1250        assert_eq!(entries[0].pattern, reparsed[0].pattern);
1251    }
1252
1253    // ---- Format detection ----
1254
1255    #[test]
1256    fn format_from_extension() {
1257        assert_eq!(
1258            SecretsFormat::from_extension("secrets.json"),
1259            Some(SecretsFormat::Json)
1260        );
1261        assert_eq!(
1262            SecretsFormat::from_extension("secrets.json.enc"),
1263            Some(SecretsFormat::Json)
1264        );
1265        assert_eq!(
1266            SecretsFormat::from_extension("secrets.yaml"),
1267            Some(SecretsFormat::Yaml)
1268        );
1269        assert_eq!(
1270            SecretsFormat::from_extension("secrets.yml.enc"),
1271            Some(SecretsFormat::Yaml)
1272        );
1273        assert_eq!(
1274            SecretsFormat::from_extension("secrets.toml"),
1275            Some(SecretsFormat::Toml)
1276        );
1277        assert_eq!(SecretsFormat::from_extension("secrets.txt"), None);
1278    }
1279
1280    #[test]
1281    fn detect_yaml_with_leading_comment_header() {
1282        // Regression: the auto-provisioned global secrets file opens with '#'
1283        // comment lines. Before the fix, detect() saw '#' first, fell through
1284        // to the TOML fallback, and failed to parse valid YAML.
1285        let content = "# Global scour-secrets allowlist — add patterns here.\n# Auto-loaded on every plain run.\n\n- pattern: foo\n  kind: allow\n";
1286        assert_eq!(
1287            SecretsFormat::detect(content.as_bytes()),
1288            SecretsFormat::Yaml
1289        );
1290    }
1291
1292    #[test]
1293    fn detect_yaml_comment_header_parses_correctly() {
1294        // Round-trip: same shape as the auto-provisioned file must load without error.
1295        let content = "# Global scour-secrets allowlist — add patterns or kind:regex entries here.\n# Auto-loaded on every plain run. Edit freely; deleted values take effect immediately.\n\n- pattern: ''\n  kind: allow\n  category: ''\n  values:\n  - localhost\n  - 127.0.0.1\n";
1296        let entries = parse_secrets(content.as_bytes(), None)
1297            .expect("auto-provisioned secrets file with comment header must parse");
1298        assert_eq!(entries.len(), 1);
1299        assert_eq!(entries[0].kind, "allow");
1300        assert!(entries[0].values.contains(&"localhost".to_string()));
1301    }
1302
1303    #[test]
1304    fn detect_json_array() {
1305        assert_eq!(
1306            SecretsFormat::detect(b"[{\"pattern\": \"foo\"}]"),
1307            SecretsFormat::Json
1308        );
1309    }
1310
1311    #[test]
1312    fn detect_toml_fallback() {
1313        // TOML that doesn't open with '[' or '{' — must not be mistaken for YAML.
1314        assert_eq!(
1315            SecretsFormat::detect(b"# toml comment\nkey = \"value\""),
1316            SecretsFormat::Toml
1317        );
1318    }
1319
1320    // ---- Defaults ----
1321
1322    #[test]
1323    fn default_kind_is_literal() {
1324        let json = r#"[{"pattern": "foo"}]"#;
1325        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1326        assert_eq!(entries[0].kind, "literal");
1327    }
1328
1329    #[test]
1330    fn default_category_is_custom_secret() {
1331        let json = r#"[{"pattern": "foo"}]"#;
1332        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1333        assert_eq!(entries[0].category, "custom:secret");
1334    }
1335
1336    #[test]
1337    fn literal_default_label_is_category_not_value() {
1338        // A `literal` pattern's text IS the secret value, and labels surface in
1339        // summaries / reports / logs — so the default label must be the
1340        // category, never the value.
1341        let json = r#"[{"pattern": "short"}]"#;
1342        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1343        let (patterns, _) = entries_to_patterns(&entries);
1344        assert_eq!(patterns[0].label(), "literal:custom:secret");
1345        assert!(!patterns[0].label().contains("short"));
1346    }
1347
1348    #[test]
1349    fn regex_default_label_is_pattern() {
1350        // A regex pattern is not itself a secret, so its text remains the
1351        // informative default label.
1352        let json = r#"[{"pattern": "ab[0-9]+", "kind": "regex"}]"#;
1353        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1354        let (patterns, _) = entries_to_patterns(&entries);
1355        assert_eq!(patterns[0].label(), "ab[0-9]+");
1356    }
1357
1358    // ---- looks_encrypted ----
1359
1360    #[test]
1361    fn looks_encrypted_json_plaintext() {
1362        assert!(!looks_encrypted(sample_json().as_bytes()));
1363    }
1364
1365    #[test]
1366    fn looks_encrypted_yaml_plaintext() {
1367        assert!(!looks_encrypted(sample_yaml().as_bytes()));
1368    }
1369
1370    #[test]
1371    fn looks_encrypted_toml_plaintext() {
1372        assert!(!looks_encrypted(sample_toml().as_bytes()));
1373    }
1374
1375    #[test]
1376    fn looks_encrypted_actual_encrypted() {
1377        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1378        assert!(looks_encrypted(&encrypted));
1379    }
1380
1381    #[test]
1382    fn looks_encrypted_too_short() {
1383        assert!(!looks_encrypted(&[0u8; 10]));
1384    }
1385
1386    // ---- load_secrets_auto ----
1387
1388    #[test]
1389    fn auto_load_plaintext_json() {
1390        let data = sample_json().as_bytes();
1391        let loaded = load_secrets_auto(data, None, Some(SecretsFormat::Json), false).unwrap();
1392        assert!(!loaded.was_encrypted);
1393        assert_eq!(loaded.patterns.len(), 2);
1394        assert!(loaded.warnings.is_empty());
1395    }
1396
1397    #[test]
1398    fn auto_load_encrypted_json() {
1399        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1400        let loaded =
1401            load_secrets_auto(&encrypted, Some("pw"), Some(SecretsFormat::Json), false).unwrap();
1402        assert!(loaded.was_encrypted);
1403        assert_eq!(loaded.patterns.len(), 2);
1404        assert!(loaded.warnings.is_empty());
1405    }
1406
1407    #[test]
1408    fn auto_load_force_plaintext() {
1409        let data = sample_json().as_bytes();
1410        let loaded = load_secrets_auto(data, None, Some(SecretsFormat::Json), true).unwrap();
1411        assert!(!loaded.was_encrypted);
1412        assert_eq!(loaded.patterns.len(), 2);
1413    }
1414
1415    #[test]
1416    fn auto_load_encrypted_no_password_fails() {
1417        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1418        let result = load_secrets_auto(&encrypted, None, None, false);
1419        assert!(result.is_err());
1420    }
1421
1422    // ---- Parse errors never echo file content (S1) ----
1423
1424    /// Marker standing in for a secret value inside a malformed secrets file.
1425    const LEAK_MARKER: &str = "SEKRET-MARKER-0xD34DB33F";
1426
1427    #[track_caller]
1428    fn assert_error_omits_marker(result: Result<Vec<SecretEntry>>) {
1429        let msg = result
1430            .expect_err("malformed input must fail to parse")
1431            .to_string();
1432        assert!(
1433            !msg.contains(LEAK_MARKER),
1434            "parse error echoed secrets file content: {msg}"
1435        );
1436        assert!(msg.contains("line"), "expected location info, got: {msg}");
1437    }
1438
1439    #[test]
1440    fn toml_syntax_error_omits_content() {
1441        // Unclosed inline table — toml's Display normally renders the whole
1442        // offending line with a caret.
1443        let bad = format!("secrets = [\n{{ pattern = \"{LEAK_MARKER}\", kind = }}\n]");
1444        assert_error_omits_marker(parse_secrets(bad.as_bytes(), Some(SecretsFormat::Toml)));
1445    }
1446
1447    #[test]
1448    fn json_data_error_omits_content() {
1449        // Type mismatch — serde_json's message embeds the value verbatim:
1450        // `invalid type: string "SEKRET-…", expected usize`.
1451        let bad = format!(r#"[{{"pattern": "p", "min_length": "{LEAK_MARKER}"}}]"#);
1452        assert_error_omits_marker(parse_secrets(bad.as_bytes(), Some(SecretsFormat::Json)));
1453    }
1454
1455    #[test]
1456    fn yaml_data_error_omits_content() {
1457        let bad = format!("- pattern: p\n  min_length: {LEAK_MARKER}\n");
1458        assert_error_omits_marker(parse_secrets(bad.as_bytes(), Some(SecretsFormat::Yaml)));
1459    }
1460
1461    #[test]
1462    fn yaml_syntax_error_omits_content() {
1463        // Unterminated quoted scalar spanning the marker.
1464        let bad = format!("- pattern: \"{LEAK_MARKER}\n  kind: literal\n");
1465        assert_error_omits_marker(parse_secrets(bad.as_bytes(), Some(SecretsFormat::Yaml)));
1466    }
1467
1468    #[test]
1469    fn line_col_at_positions() {
1470        let text = "ab\ncd\nef";
1471        assert_eq!(line_col_at(text, 0), (1, 1));
1472        assert_eq!(line_col_at(text, 4), (2, 2));
1473        assert_eq!(line_col_at(text, 6), (3, 1));
1474        // Clamped past the end.
1475        assert_eq!(line_col_at(text, 100), (3, 3));
1476    }
1477
1478    #[test]
1479    fn parse_secrets_rejects_oversized_input() {
1480        // Construct input just over the 10 MiB cap.
1481        let oversized = vec![b' '; MAX_SECRETS_PLAINTEXT_BYTES + 1];
1482        let result = parse_secrets(&oversized, None);
1483        assert!(result.is_err());
1484        let msg = result.unwrap_err().to_string();
1485        assert!(
1486            msg.contains("exceeding") || msg.contains("limit"),
1487            "unexpected error message: {msg}"
1488        );
1489    }
1490
1491    #[test]
1492    fn parse_secrets_accepts_input_at_limit() {
1493        // Valid JSON just at the cap boundary — should succeed or fail on
1494        // parse, not on the size check. We use a tiny valid payload here
1495        // to confirm the size gate does not block small files.
1496        let tiny = b"[]";
1497        let result = parse_secrets(tiny, Some(SecretsFormat::Json));
1498        assert!(
1499            result.is_ok(),
1500            "unexpected error: {:?}",
1501            result.unwrap_err()
1502        );
1503    }
1504
1505    #[test]
1506    fn truncate_label_at_boundary() {
1507        let short = "a".repeat(32);
1508        assert_eq!(truncate_label(&short), short);
1509
1510        let long = "a".repeat(33);
1511        let truncated = truncate_label(&long);
1512        assert!(truncated.ends_with('…'), "expected ellipsis: {truncated}");
1513        // Character count (not byte count) must be within the limit.
1514        // The trailing '…' is 1 char; the rest must be < MAX_LABEL_CHARS.
1515        assert!(
1516            truncated.chars().count() <= MAX_LABEL_CHARS,
1517            "char count {} exceeds limit: {truncated}",
1518            truncated.chars().count()
1519        );
1520    }
1521
1522    // ---- Multi-value allow entries ----
1523
1524    #[test]
1525    fn allow_single_pattern_field() {
1526        let json = r#"[{"kind":"allow","pattern":"localhost"}]"#;
1527        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1528        let patterns = extract_allow_patterns(&entries);
1529        assert_eq!(patterns, vec!["localhost"]);
1530    }
1531
1532    #[test]
1533    fn allow_values_list_used_instead_of_pattern() {
1534        let json = r#"[{"kind":"allow","values":["localhost","true","false","null"]}]"#;
1535        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1536        let patterns = extract_allow_patterns(&entries);
1537        assert_eq!(patterns, vec!["localhost", "true", "false", "null"]);
1538    }
1539
1540    #[test]
1541    fn allow_values_list_yaml() {
1542        let yaml =
1543            "- kind: allow\n  values:\n    - localhost\n    - \"127.0.0.1\"\n    - \"0.0.0.0\"\n";
1544        let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1545        let patterns = extract_allow_patterns(&entries);
1546        assert_eq!(patterns, vec!["localhost", "127.0.0.1", "0.0.0.0"]);
1547    }
1548
1549    #[test]
1550    fn allow_values_list_toml() {
1551        let toml = "[[secrets]]\nkind = \"allow\"\nvalues = [\"localhost\", \"true\", \"false\"]\n";
1552        let entries = parse_secrets(toml.as_bytes(), Some(SecretsFormat::Toml)).unwrap();
1553        let patterns = extract_allow_patterns(&entries);
1554        assert_eq!(patterns, vec!["localhost", "true", "false"]);
1555    }
1556
1557    #[test]
1558    fn allow_mixed_single_and_multi_value_entries() {
1559        let json = r#"[
1560            {"kind":"allow","pattern":"localhost"},
1561            {"kind":"allow","values":["true","false","null"]},
1562            {"kind":"allow","pattern":"*.internal"}
1563        ]"#;
1564        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1565        let patterns = extract_allow_patterns(&entries);
1566        assert_eq!(
1567            patterns,
1568            vec!["localhost", "true", "false", "null", "*.internal"]
1569        );
1570    }
1571
1572    #[test]
1573    fn allow_entries_skipped_by_entries_to_patterns() {
1574        let json = r#"[
1575            {"pattern":"secret","kind":"literal"},
1576            {"kind":"allow","values":["localhost","true"]}
1577        ]"#;
1578        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1579        let (patterns, errors) = entries_to_patterns(&entries);
1580        assert_eq!(patterns.len(), 1);
1581        assert!(errors.is_empty());
1582        assert_eq!(patterns[0].label(), "literal:custom:secret");
1583    }
1584
1585    #[test]
1586    fn allow_empty_values_falls_back_to_pattern() {
1587        // An entry with an empty `values` list should still use `pattern`.
1588        let json = r#"[{"kind":"allow","pattern":"localhost","values":[]}]"#;
1589        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1590        let patterns = extract_allow_patterns(&entries);
1591        assert_eq!(patterns, vec!["localhost"]);
1592    }
1593
1594    // ── kind: field-name ─────────────────────────────────────────────────────
1595
1596    #[test]
1597    fn field_name_entries_skipped_by_entries_to_patterns() {
1598        // kind:field-name entries must not produce ScanPatterns — they are
1599        // handled separately as FieldNameSignals injected into profiles.
1600        let json = r#"[
1601            {"pattern":"secret","kind":"literal"},
1602            {"pattern":"^password$","kind":"field-name","threshold":3.0}
1603        ]"#;
1604        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1605        let (patterns, errors) = entries_to_patterns(&entries);
1606        assert_eq!(
1607            patterns.len(),
1608            1,
1609            "only the literal entry should produce a pattern"
1610        );
1611        assert!(errors.is_empty());
1612        assert_eq!(patterns[0].label(), "literal:custom:secret");
1613    }
1614
1615    #[test]
1616    fn field_name_entry_parses_correctly() {
1617        let yaml = "- kind: field-name\n  pattern: \"^(password|secret)$\"\n  threshold: 3.0\n  label: my-signal\n";
1618        let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1619        assert_eq!(entries.len(), 1);
1620        assert_eq!(entries[0].kind, "field-name");
1621        assert_eq!(entries[0].pattern, "^(password|secret)$");
1622        assert_eq!(entries[0].threshold, Some(3.0));
1623        assert_eq!(entries[0].label, Some("my-signal".into()));
1624    }
1625
1626    #[test]
1627    fn field_name_entry_not_extracted_as_allow_pattern() {
1628        // kind:field-name entries must not bleed into the allowlist.
1629        let json = r#"[{"pattern":"^password$","kind":"field-name"}]"#;
1630        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1631        let allow = extract_allow_patterns(&entries);
1632        assert!(allow.is_empty());
1633    }
1634}