Skip to main content

rust_sanitize/
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 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//! │  Salt (32 B)                   │  Nonce (12 B)│  AES-256-GCM Ciphertext     │
14//! └────────────────────────────────┴──────────────┴─────────────────────────────┘
15//! ```
16//!
17//! - **Salt** (32 bytes): random, used for PBKDF2-derived key.
18//! - **Nonce** (12 bytes): random, for AES-256-GCM.
19//! - **Ciphertext**: authenticated encryption of the plaintext secrets
20//!   file (JSON / YAML / TOML).
21//!
22//! The 256-bit AES key is derived from the user password using
23//! PBKDF2-HMAC-SHA256 with 600 000 iterations, which meets current
24//! OWASP recommendations.
25//!
26//! # Key Derivation
27//!
28//! ```text
29//! key = PBKDF2-HMAC-SHA256(password, salt, iterations=600_000, dkLen=32)
30//! ```
31//!
32//! # Secrets File Schema
33//!
34//! The plaintext secrets file (before encryption) must deserialize to
35//! `Vec<SecretEntry>`:
36//!
37//! ```json
38//! [
39//!   {
40//!     "pattern": "alice@corp\\.com",
41//!     "kind": "regex",
42//!     "category": "email",
43//!     "label": "alice_email"
44//!   },
45//!   {
46//!     "pattern": "sk-proj-abc123secret",
47//!     "kind": "literal",
48//!     "category": "custom:api_key",
49//!     "label": "openai_key"
50//!   }
51//! ]
52//! ```
53//!
54//! # Thread Safety
55//!
56//! All public types are `Send + Sync`. Decrypted secrets use
57//! [`zeroize::Zeroizing`] to scrub plaintext from memory on drop.
58//!
59//! # Security Considerations
60//!
61//! - AES-256-GCM provides both confidentiality and integrity (AEAD).
62//! - PBKDF2 with 600 000 iterations resists offline brute-force attacks.
63//! - Decrypted plaintext is held in [`Zeroizing<Vec<u8>>`] and zeroed
64//!   on drop.
65//! - The plaintext secrets file is never written to disk by this crate.
66//! - Nonce and salt are generated with OS CSPRNG (`rand`).
67
68use crate::category::Category;
69use crate::error::{Result, SanitizeError};
70use crate::scanner::ScanPattern;
71
72/// Result of compiling secret entries into patterns.
73/// Contains successfully compiled patterns and a list of (index, error) for failures.
74pub type PatternCompileResult = (Vec<ScanPattern>, Vec<(usize, SanitizeError)>);
75
76use aes_gcm::aead::{Aead, KeyInit};
77use aes_gcm::{Aes256Gcm, Nonce};
78use hmac::Hmac;
79use rand::RngCore;
80use serde::{Deserialize, Serialize};
81use sha2::Sha256;
82use zeroize::{Zeroize, Zeroizing};
83
84// ---------------------------------------------------------------------------
85// Constants
86// ---------------------------------------------------------------------------
87
88/// Salt length for PBKDF2 key derivation (bytes).
89const SALT_LEN: usize = 32;
90
91/// AES-GCM nonce length (bytes). Must be 12 for AES-256-GCM.
92const NONCE_LEN: usize = 12;
93
94/// PBKDF2 iteration count — OWASP 2023+ recommendation.
95const PBKDF2_ITERATIONS: u32 = 600_000;
96
97/// Minimum ciphertext size: salt + nonce + at least 16-byte AES-GCM tag.
98const MIN_ENCRYPTED_LEN: usize = SALT_LEN + NONCE_LEN + 16;
99
100/// Maximum size of a plaintext secrets file accepted by [`parse_secrets`].
101/// Prevents OOM from accidentally passing a large binary or log file as secrets.
102const MAX_SECRETS_PLAINTEXT_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
103
104// ---------------------------------------------------------------------------
105// Secrets file schema
106// ---------------------------------------------------------------------------
107
108/// A single secret entry as stored in the (plaintext) secrets file.
109///
110/// After decryption the entries are parsed from JSON, YAML, or TOML and
111/// converted into [`ScanPattern`]s.
112///
113/// Implements [`Drop`] via [`Zeroize`] to scrub sensitive pattern data
114/// from memory when no longer needed (S-1 fix).
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct SecretEntry {
117    /// The pattern string (regex or literal text).
118    ///
119    /// For `kind: allow` entries this is the single allowlist pattern.
120    /// Omit when using [`values`](Self::values) instead.
121    #[serde(default)]
122    pub pattern: String,
123
124    /// `"regex"`, `"literal"`, `"allow"`, `"entropy"`, or `"field-name"`.
125    ///
126    /// `"field-name"` entries are not compiled into scanner patterns — they
127    /// are extracted separately and injected into structured-processor profiles
128    /// as field-name signals.  The `pattern` field is a case-insensitive
129    /// regex matched against bare field/key names; `threshold` controls the
130    /// entropy gate (defaults to `3.5` bits/char when omitted).
131    #[serde(default = "default_kind")]
132    pub kind: String,
133
134    /// Category string. Supported values:
135    /// `email`, `name`, `phone`, `ipv4`, `ipv6`, `credit_card`, `ssn`,
136    /// `hostname`, `mac_address`, `container_id`, `uuid`, `jwt`,
137    /// `auth_token`, `file_path`, `windows_sid`, `url`, `aws_arn`,
138    /// `azure_resource_id`, or `custom:<tag>`.
139    #[serde(default = "default_category")]
140    pub category: String,
141
142    /// Human-readable label for stats reporting. Defaults to a truncated
143    /// version of `pattern` if omitted.
144    #[serde(default)]
145    pub label: Option<String>,
146
147    /// Multiple allowlist patterns for `kind: allow` entries.
148    ///
149    /// When non-empty, used instead of `pattern`. Allows a single entry to
150    /// allowlist many values compactly:
151    ///
152    /// ```toml
153    /// [[secrets]]
154    /// kind = "allow"
155    /// values = ["localhost", "true", "false", "null", "0.0.0.0"]
156    /// ```
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub values: Vec<String>,
159
160    // ── Entropy-detection fields (only used when kind = "entropy") ──────────
161    /// Minimum token length to consider (default: 20).
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub min_length: Option<usize>,
164
165    /// Maximum token length to consider (default: 200).
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub max_length: Option<usize>,
168
169    /// Shannon entropy threshold in bits per character (default: 4.5).
170    /// Tokens whose entropy is at or above this value are flagged.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub threshold: Option<f64>,
173
174    /// Character set the token must consist of exclusively.
175    /// `"alphanumeric"` (default), `"base64"`, `"hex"`, or `"any"`.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub charset: Option<String>,
178}
179
180impl Drop for SecretEntry {
181    fn drop(&mut self) {
182        self.pattern.zeroize();
183        self.kind.zeroize();
184        self.category.zeroize();
185        if let Some(ref mut l) = self.label {
186            l.zeroize();
187        }
188        for v in &mut self.values {
189            v.zeroize();
190        }
191        if let Some(ref mut s) = self.charset {
192            s.zeroize();
193        }
194    }
195}
196
197fn default_kind() -> String {
198    "literal".into()
199}
200
201fn default_category() -> String {
202    "custom:secret".into()
203}
204
205/// Supported plaintext file formats for secrets.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum SecretsFormat {
208    Json,
209    Yaml,
210    Toml,
211}
212
213impl SecretsFormat {
214    /// Detect format from file extension.
215    pub fn from_extension(path: &str) -> Option<Self> {
216        // Strip .enc suffix first if present.
217        let base = path.strip_suffix(".enc").unwrap_or(path);
218        let ext = std::path::Path::new(base).extension();
219        if ext.is_some_and(|e| e.eq_ignore_ascii_case("json")) {
220            Some(Self::Json)
221        } else if ext
222            .is_some_and(|e| e.eq_ignore_ascii_case("yaml") || e.eq_ignore_ascii_case("yml"))
223        {
224            Some(Self::Yaml)
225        } else if ext.is_some_and(|e| e.eq_ignore_ascii_case("toml")) {
226            Some(Self::Toml)
227        } else {
228            None
229        }
230    }
231
232    /// Try to auto-detect format from content.
233    pub fn detect(content: &[u8]) -> Self {
234        let s = String::from_utf8_lossy(content);
235        // Skip leading comment lines — both YAML and TOML use `#`, so a file
236        // that opens with comments must be scanned further to find the first
237        // meaningful token.
238        let first_meaningful = s
239            .lines()
240            .map(str::trim)
241            .find(|l| !l.is_empty() && !l.starts_with('#'))
242            .unwrap_or("");
243        if first_meaningful.starts_with('[') || first_meaningful.starts_with('{') {
244            // `[` is ambiguous: JSON arrays and TOML table headers both start
245            // with it. We pick JSON here because our secrets files are never
246            // bare TOML tables, and a wrong guess produces a clear parse error.
247            Self::Json
248        } else if first_meaningful.starts_with('-') || first_meaningful.starts_with("---") {
249            Self::Yaml
250        } else {
251            // Fallback: assume TOML
252            Self::Toml
253        }
254    }
255}
256
257// ---------------------------------------------------------------------------
258// TOML wrapper — serde_toml expects a top-level table
259// ---------------------------------------------------------------------------
260
261/// Wrapper for TOML deserialization: `secrets = [...]`
262#[derive(Deserialize)]
263struct TomlSecrets {
264    secrets: Vec<SecretEntry>,
265}
266
267/// Wrapper for TOML serialization.
268#[derive(Serialize)]
269struct TomlSecretsRef<'a> {
270    secrets: &'a [SecretEntry],
271}
272
273// ---------------------------------------------------------------------------
274// Key derivation
275// ---------------------------------------------------------------------------
276
277/// Derive a 256-bit AES key from a password and salt using PBKDF2.
278fn derive_key(password: &[u8], salt: &[u8]) -> Zeroizing<[u8; 32]> {
279    let mut key = Zeroizing::new([0u8; 32]);
280    pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, PBKDF2_ITERATIONS, key.as_mut())
281        .expect("PBKDF2 output length is valid");
282    key
283}
284
285// ---------------------------------------------------------------------------
286// Encryption
287// ---------------------------------------------------------------------------
288
289/// Encrypt a plaintext secrets file.
290///
291/// Returns the encrypted blob: `salt (32) || nonce (12) || ciphertext`.
292///
293/// # Arguments
294///
295/// - `plaintext` — raw bytes of the secrets file (JSON / YAML / TOML).
296/// - `password` — user-supplied password.
297///
298/// # Errors
299///
300/// Returns [`SanitizeError::SecretsEmptyPassword`] if the password is empty, or
301/// [`SanitizeError::SecretsCipherError`] if encryption fails.
302///
303/// # Security
304///
305/// - Salt and nonce are generated with CSPRNG.
306/// - Key is derived with PBKDF2 (600 000 iterations).
307/// - AES-256-GCM provides authenticated encryption.
308pub fn encrypt_secrets(plaintext: &[u8], password: &str) -> Result<Vec<u8>> {
309    if password.is_empty() {
310        return Err(SanitizeError::SecretsEmptyPassword);
311    }
312
313    let mut rng = rand::rng();
314
315    // Generate random salt and nonce.
316    let mut salt = [0u8; SALT_LEN];
317    rng.fill_bytes(&mut salt);
318
319    let mut nonce_bytes = [0u8; NONCE_LEN];
320    rng.fill_bytes(&mut nonce_bytes);
321    let nonce = Nonce::from_slice(&nonce_bytes);
322
323    // Derive key.
324    let key = derive_key(password.as_bytes(), &salt);
325    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
326        .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
327
328    // Encrypt.
329    let ciphertext = cipher
330        .encrypt(nonce, plaintext)
331        .map_err(|e| SanitizeError::SecretsCipherError(format!("encryption: {}", e)))?;
332
333    // Assemble: salt || nonce || ciphertext
334    let mut output = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
335    output.extend_from_slice(&salt);
336    output.extend_from_slice(&nonce_bytes);
337    output.extend_from_slice(&ciphertext);
338
339    Ok(output)
340}
341
342// ---------------------------------------------------------------------------
343// Decryption
344// ---------------------------------------------------------------------------
345
346/// Decrypt an encrypted secrets blob in memory.
347///
348/// Returns the plaintext wrapped in [`Zeroizing`] so it is scrubbed on drop.
349///
350/// # Arguments
351///
352/// - `encrypted` — `salt (32) || nonce (12) || ciphertext`.
353/// - `password` — user-supplied password.
354///
355/// # Errors
356///
357/// - [`SanitizeError::SecretsTooShort`] if the blob is too short,
358///   [`SanitizeError::SecretsDecryptFailed`] if the password is wrong or the ciphertext has been tampered with.
359pub fn decrypt_secrets(encrypted: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>> {
360    if encrypted.len() < MIN_ENCRYPTED_LEN {
361        return Err(SanitizeError::SecretsTooShort);
362    }
363
364    let salt = &encrypted[..SALT_LEN];
365    let nonce_bytes = &encrypted[SALT_LEN..SALT_LEN + NONCE_LEN];
366    let ciphertext = &encrypted[SALT_LEN + NONCE_LEN..];
367
368    let nonce = Nonce::from_slice(nonce_bytes);
369
370    let key = derive_key(password.as_bytes(), salt);
371    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
372        .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
373
374    let plaintext = cipher
375        .decrypt(nonce, ciphertext)
376        .map_err(|_| SanitizeError::SecretsDecryptFailed)?;
377
378    Ok(Zeroizing::new(plaintext))
379}
380
381// ---------------------------------------------------------------------------
382// Parsing
383// ---------------------------------------------------------------------------
384
385/// Parse a decrypted plaintext into secret entries.
386///
387/// Supports JSON, YAML, and TOML. Format is auto-detected if `format`
388/// is `None`.
389///
390/// # Errors
391///
392/// Returns [`SanitizeError::SecretsInvalidUtf8`] if the plaintext is not
393/// valid UTF-8, [`SanitizeError::SecretsFormatError`] if it cannot be parsed
394/// in the specified format or if the file exceeds the size limit.
395pub fn parse_secrets(plaintext: &[u8], format: Option<SecretsFormat>) -> Result<Vec<SecretEntry>> {
396    if plaintext.len() > MAX_SECRETS_PLAINTEXT_BYTES {
397        return Err(SanitizeError::SecretsFormatError {
398            format: "secrets file".into(),
399            message: format!(
400                "file is {} bytes, exceeding the {} byte limit — \
401                 secrets files should be small YAML/JSON/TOML pattern lists",
402                plaintext.len(),
403                MAX_SECRETS_PLAINTEXT_BYTES,
404            ),
405        });
406    }
407    let fmt = format.unwrap_or_else(|| SecretsFormat::detect(plaintext));
408    let text = std::str::from_utf8(plaintext)
409        .map_err(|e| SanitizeError::SecretsInvalidUtf8(e.to_string()))?;
410
411    match fmt {
412        SecretsFormat::Json => {
413            serde_json::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
414                format: "JSON".into(),
415                message: e.to_string(),
416            })
417        }
418        SecretsFormat::Yaml => {
419            serde_yaml_ng::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
420                format: "YAML".into(),
421                message: e.to_string(),
422            })
423        }
424        SecretsFormat::Toml => {
425            let wrapper: TomlSecrets =
426                toml::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
427                    format: "TOML".into(),
428                    message: e.to_string(),
429                })?;
430            Ok(wrapper.secrets)
431        }
432    }
433}
434
435/// Serialize secret entries back into a plaintext format.
436///
437/// Used by the encryption helper CLI.
438///
439/// # Errors
440///
441/// Returns [`SanitizeError::SecretsFormatError`] if serialization fails.
442pub fn serialize_secrets(entries: &[SecretEntry], format: SecretsFormat) -> Result<Vec<u8>> {
443    match format {
444        SecretsFormat::Json => {
445            serde_json::to_vec_pretty(entries).map_err(|e| SanitizeError::SecretsFormatError {
446                format: "JSON-serialize".into(),
447                message: e.to_string(),
448            })
449        }
450        SecretsFormat::Yaml => serde_yaml_ng::to_string(entries)
451            .map(|s| s.into_bytes())
452            .map_err(|e| SanitizeError::SecretsFormatError {
453                format: "YAML-serialize".into(),
454                message: e.to_string(),
455            }),
456        SecretsFormat::Toml => {
457            let wrapper = TomlSecretsRef { secrets: entries };
458            toml::to_string_pretty(&wrapper)
459                .map(|s| s.into_bytes())
460                .map_err(|e| SanitizeError::SecretsFormatError {
461                    format: "TOML-serialize".into(),
462                    message: e.to_string(),
463                })
464        }
465    }
466}
467
468// ---------------------------------------------------------------------------
469// Category parsing
470// ---------------------------------------------------------------------------
471
472/// Parse a category string into a [`Category`].
473///
474/// Accepted values: `email`, `name`, `phone`, `ipv4`, `ipv6`,
475/// `credit_card`, `ssn`, `hostname`, `mac_address`, `container_id`,
476/// `uuid`, `jwt`, `auth_token`, `file_path`, `windows_sid`, `url`,
477/// `aws_arn`, `azure_resource_id`, or `custom:<tag>`.
478pub fn parse_category(s: &str) -> Category {
479    match s {
480        "email" => Category::Email,
481        "name" => Category::Name,
482        "phone" => Category::Phone,
483        "ipv4" => Category::IpV4,
484        "ipv6" => Category::IpV6,
485        "credit_card" => Category::CreditCard,
486        "ssn" => Category::Ssn,
487        "hostname" => Category::Hostname,
488        "mac_address" => Category::MacAddress,
489        "container_id" => Category::ContainerId,
490        "uuid" => Category::Uuid,
491        "jwt" => Category::Jwt,
492        "auth_token" => Category::AuthToken,
493        "file_path" => Category::FilePath,
494        "windows_sid" => Category::WindowsSid,
495        "url" => Category::Url,
496        "aws_arn" => Category::AwsArn,
497        "azure_resource_id" => Category::AzureResourceId,
498        other => {
499            let tag = other.strip_prefix("custom:").unwrap_or(other);
500            Category::Custom(tag.into())
501        }
502    }
503}
504
505// ---------------------------------------------------------------------------
506// Conversion to ScanPatterns
507// ---------------------------------------------------------------------------
508
509/// Extract allowlist patterns from a set of entries.
510///
511/// Entries with `kind: allow` are returned as raw pattern strings to be
512/// compiled into an [`AllowlistMatcher`](crate::allowlist::AllowlistMatcher). They are skipped by
513/// [`entries_to_patterns`].
514///
515/// Each entry contributes either its `values` list (when non-empty) or its
516/// `pattern` field (when `values` is absent), so both forms are supported:
517///
518/// ```toml
519/// # single pattern
520/// [[secrets]]
521/// kind = "allow"
522/// pattern = "localhost"
523///
524/// # compact multi-value form
525/// [[secrets]]
526/// kind = "allow"
527/// values = ["true", "false", "null", "0.0.0.0"]
528/// ```
529pub fn extract_allow_patterns(entries: &[SecretEntry]) -> Vec<String> {
530    let mut patterns = Vec::new();
531    for entry in entries.iter().filter(|e| e.kind == "allow") {
532        if !entry.values.is_empty() {
533            patterns.extend(entry.values.iter().cloned());
534        } else if !entry.pattern.is_empty() {
535            patterns.push(entry.pattern.clone());
536        }
537    }
538    patterns
539}
540
541/// Convert parsed [`SecretEntry`]s into compiled [`ScanPattern`]s.
542///
543/// Entries with `kind: allow` are silently skipped — they are handled by
544/// [`extract_allow_patterns`] instead.
545///
546/// Invalid entries (e.g. bad regex) are collected as errors and
547/// returned alongside the successfully compiled patterns.
548pub fn entries_to_patterns(entries: &[SecretEntry]) -> PatternCompileResult {
549    let mut patterns = Vec::with_capacity(entries.len());
550    let mut errors = Vec::new();
551
552    for (i, entry) in entries.iter().enumerate() {
553        if entry.kind == "allow"
554            || entry.kind == "entropy"
555            || entry.kind == "field-name"
556            || entry.pattern.is_empty()
557        {
558            continue;
559        }
560        let category = parse_category(&entry.category);
561        let label = entry
562            .label
563            .clone()
564            .unwrap_or_else(|| truncate_label(&entry.pattern));
565
566        let result = match entry.kind.as_str() {
567            "regex" => ScanPattern::from_regex(&entry.pattern, category, label),
568            "literal" => ScanPattern::from_literal(&entry.pattern, category, label),
569            other => {
570                errors.push((
571                    i,
572                    SanitizeError::InvalidConfig(format!(
573                        "unknown kind {:?} — expected \"literal\", \"regex\", \"allow\", \"entropy\", or \"field-name\"",
574                        other
575                    )),
576                ));
577                continue;
578            }
579        };
580
581        match result {
582            Ok(pat) => patterns.push(pat),
583            Err(e) => errors.push((i, e)),
584        }
585    }
586
587    (patterns, errors)
588}
589
590const MAX_LABEL_CHARS: usize = 32;
591
592/// Truncate to a maximum label length.
593fn truncate_label(s: &str) -> String {
594    if s.len() <= MAX_LABEL_CHARS {
595        s.to_string()
596    } else {
597        // Find a char boundary just before the limit to avoid panicking on
598        // multi-byte UTF-8 characters (e.g. Unicode in user-supplied patterns).
599        let cut = s
600            .char_indices()
601            .nth(MAX_LABEL_CHARS - 1)
602            .map_or(s.len(), |(i, _)| i);
603        format!("{}…", &s[..cut])
604    }
605}
606
607// ---------------------------------------------------------------------------
608// High-level: load encrypted secrets → ScanPatterns
609// ---------------------------------------------------------------------------
610
611/// Load, decrypt, parse, and compile an encrypted secrets file into
612/// [`ScanPattern`]s ready for the streaming scanner.
613///
614/// This is the primary entry point for CLI integration.
615///
616/// # Arguments
617///
618/// - `encrypted_bytes` — raw bytes of the `.enc` file.
619/// - `password` — user-supplied password.
620/// - `format` — optional explicit format override.
621///
622/// # Returns
623///
624/// `(patterns, warnings)` where `warnings` contains indices and errors
625/// for entries that failed to compile.
626///
627/// # Security
628///
629/// The decrypted plaintext is held in zeroizing memory and dropped
630/// immediately after parsing.
631///
632/// # Errors
633///
634/// Returns a secrets-related [`SanitizeError`] if decryption or parsing fails.
635pub fn load_encrypted_secrets(
636    encrypted_bytes: &[u8],
637    password: &str,
638    format: Option<SecretsFormat>,
639) -> Result<(PatternCompileResult, Vec<String>)> {
640    let plaintext = decrypt_secrets(encrypted_bytes, password)?;
641    let entries = parse_secrets(&plaintext, format)?;
642    let allow = extract_allow_patterns(&entries);
643    let result = entries_to_patterns(&entries);
644    // SecretEntry implements Drop with explicit zeroize() calls, so dropping
645    // the Vec is sufficient to scrub sensitive pattern data from heap memory.
646    drop(entries);
647    Ok((result, allow))
648}
649
650/// Load and parse a plaintext secrets file into [`ScanPattern`]s.
651///
652/// This function mirrors [`load_encrypted_secrets`] but skips
653/// AES decryption and password prompts entirely. It preserves
654/// memory hygiene by zeroizing parsed entries after compilation.
655///
656/// # Arguments
657///
658/// - `plaintext` — raw bytes of the secrets file (JSON / YAML / TOML).
659/// - `format` — optional explicit format override.
660///
661/// # Security
662///
663/// Even for unencrypted secrets, entries are zeroized after pattern
664/// compilation to minimise the window during which sensitive values
665/// reside in memory.
666///
667/// # Errors
668///
669/// Returns a secrets-related [`SanitizeError`] if parsing or pattern
670/// compilation fails.
671pub fn load_plaintext_secrets(
672    plaintext: &[u8],
673    format: Option<SecretsFormat>,
674) -> Result<(PatternCompileResult, Vec<String>)> {
675    let entries = parse_secrets(plaintext, format)?;
676    let allow = extract_allow_patterns(&entries);
677    let result = entries_to_patterns(&entries);
678    // SecretEntry implements Drop with explicit zeroize() calls, so dropping
679    // the Vec is sufficient to scrub sensitive pattern data from heap memory.
680    drop(entries);
681    Ok((result, allow))
682}
683
684/// Detect whether raw file bytes look like an AES-256-GCM encrypted
685/// secrets blob (binary with salt+nonce header) or a plaintext secrets
686/// file (UTF-8 JSON / YAML / TOML).
687///
688/// Returns `true` if the content appears to be encrypted.
689///
690/// Heuristic:
691/// 1. Files shorter than the minimum encrypted length cannot be valid
692///    ciphertext — return `false`.
693/// 2. The **entire** content is checked for UTF-8 validity (not just the
694///    first few bytes). Only if the whole file is valid UTF-8 and begins
695///    with a recognisable plaintext marker (`[`, `{`, `-`, `#`) is it
696///    treated as plaintext — return `false`.
697/// 3. Binary content (not valid UTF-8) or UTF-8 without a plaintext
698///    marker is assumed to be encrypted — return `true`.
699///
700/// Note: a pathological plaintext file that is valid UTF-8 but lacks a
701/// leading plaintext marker (e.g. a TOML file whose first non-whitespace
702/// character is a letter) will be misclassified as encrypted and produce
703/// a `SecretsDecryptFailed` error. Use `force_plaintext: true` in
704/// [`load_secrets_auto`] to bypass the heuristic in that case.
705pub fn looks_encrypted(data: &[u8]) -> bool {
706    if data.len() < MIN_ENCRYPTED_LEN {
707        // Too short for a valid encrypted blob — might be a tiny
708        // plaintext file, but definitely not encrypted.
709        return false;
710    }
711    // If the file is valid UTF-8 and starts with a recognisable
712    // plaintext marker, treat it as plaintext.
713    if let Ok(text) = std::str::from_utf8(data) {
714        let trimmed = text.trim_start();
715        // Recognisable plaintext markers:
716        //   JSON  → '[' (array) or '{' (object)
717        //   YAML  → '-' (sequence item) or '---' (document start)
718        //   TOML  → '#' (comment) or '[' (table header)
719        //   TOML bare-key → any ASCII letter (e.g. `pattern = "foo"`)
720        //
721        // starts_with('[') already covers "[["; starts_with('-') covers "---".
722        // The ASCII-letter check handles TOML files whose first meaningful token
723        // is a bare key — without it those files were misclassified as encrypted
724        // and produced a confusing "decryption failed" error.
725        //
726        // A valid AES-256-GCM ciphertext starts with 32 bytes of random salt and
727        // 12 bytes of random nonce; the probability that those 44 bytes are entirely
728        // valid UTF-8 AND begin with an ASCII letter is negligible (< 2^-6).
729        let first_char = trimmed.chars().next();
730        let has_marker = trimmed.starts_with('[')
731            || trimmed.starts_with('{')
732            || trimmed.starts_with('-')
733            || trimmed.starts_with('#')
734            || first_char.is_some_and(|c| c.is_ascii_alphabetic());
735        if has_marker {
736            return false;
737        }
738    }
739    // Binary / non-UTF-8 → assume encrypted.
740    true
741}
742
743/// Unified loader: auto-detect encrypted vs plaintext and load
744/// secret patterns accordingly.
745///
746/// When `force_plaintext` is `true`, decryption is skipped regardless
747/// of file content. When `false`, the function uses [`looks_encrypted`]
748/// to choose the path automatically.
749///
750/// # Arguments
751///
752/// - `data` — raw bytes read from the secrets file.
753/// - `password` — password for decryption (ignored when plaintext).
754/// - `format` — optional format override.
755/// - `force_plaintext` — if `true`, always treat as plaintext.
756///
757/// # Returns
758///
759/// `(patterns, warnings, was_encrypted)` — the compiled patterns,
760/// any compile warnings, and a flag indicating which path was taken.
761///
762/// # Errors
763///
764/// Returns a secrets-related [`SanitizeError`] if decryption or parsing
765/// fails, or if a password is required but not provided.
766/// Returns `((patterns, warnings, allow_patterns), was_encrypted)`.
767///
768/// `allow_patterns` are the raw strings from `kind: allow` entries in the
769/// secrets file — the caller should combine these with any `--allow` CLI
770/// values and pass the merged list to [`AllowlistMatcher::new`](crate::allowlist::AllowlistMatcher::new).
771pub fn load_secrets_auto(
772    data: &[u8],
773    password: Option<&str>,
774    format: Option<SecretsFormat>,
775    force_plaintext: bool,
776) -> Result<((PatternCompileResult, Vec<String>), bool)> {
777    if force_plaintext || !looks_encrypted(data) {
778        let (result, allow) = load_plaintext_secrets(data, format)?;
779        Ok(((result, allow), false))
780    } else {
781        let pw = password.ok_or(SanitizeError::SecretsPasswordRequired)?;
782        let (result, allow) = load_encrypted_secrets(data, pw, format)?;
783        Ok(((result, allow), true))
784    }
785}
786
787// ---------------------------------------------------------------------------
788// Unit tests
789// ---------------------------------------------------------------------------
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    fn sample_json() -> &'static str {
796        r#"[
797            {
798                "pattern": "alice@corp\\.com",
799                "kind": "regex",
800                "category": "email",
801                "label": "alice_email"
802            },
803            {
804                "pattern": "sk-proj-abc123secret",
805                "kind": "literal",
806                "category": "custom:api_key",
807                "label": "openai_key"
808            }
809        ]"#
810    }
811
812    fn sample_yaml() -> &'static str {
813        r#"- pattern: "alice@corp\\.com"
814  kind: regex
815  category: email
816  label: alice_email
817- pattern: sk-proj-abc123secret
818  kind: literal
819  category: "custom:api_key"
820  label: openai_key
821"#
822    }
823
824    fn sample_toml() -> &'static str {
825        r#"[[secrets]]
826pattern = "alice@corp\\.com"
827kind = "regex"
828category = "email"
829label = "alice_email"
830
831[[secrets]]
832pattern = "sk-proj-abc123secret"
833kind = "literal"
834category = "custom:api_key"
835label = "openai_key"
836"#
837    }
838
839    // ---- Parsing ----
840
841    #[test]
842    fn parse_json_entries() {
843        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
844        assert_eq!(entries.len(), 2);
845        assert_eq!(entries[0].kind, "regex");
846        assert_eq!(entries[0].category, "email");
847        assert_eq!(entries[1].kind, "literal");
848    }
849
850    #[test]
851    fn parse_yaml_entries() {
852        let entries = parse_secrets(sample_yaml().as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
853        assert_eq!(entries.len(), 2);
854        assert_eq!(entries[0].label, Some("alice_email".into()));
855    }
856
857    #[test]
858    fn parse_toml_entries() {
859        let entries = parse_secrets(sample_toml().as_bytes(), Some(SecretsFormat::Toml)).unwrap();
860        assert_eq!(entries.len(), 2);
861        assert_eq!(entries[1].pattern, "sk-proj-abc123secret");
862    }
863
864    #[test]
865    fn parse_auto_detect_json() {
866        let entries = parse_secrets(sample_json().as_bytes(), None).unwrap();
867        assert_eq!(entries.len(), 2);
868    }
869
870    #[test]
871    fn parse_auto_detect_yaml() {
872        let entries = parse_secrets(sample_yaml().as_bytes(), None).unwrap();
873        assert_eq!(entries.len(), 2);
874    }
875
876    // ---- Category parsing ----
877
878    #[test]
879    fn parse_builtin_categories() {
880        assert_eq!(parse_category("email"), Category::Email);
881        assert_eq!(parse_category("ipv4"), Category::IpV4);
882        assert_eq!(parse_category("ssn"), Category::Ssn);
883    }
884
885    #[test]
886    fn parse_custom_category() {
887        match parse_category("custom:api_key") {
888            Category::Custom(tag) => assert_eq!(tag.as_str(), "api_key"),
889            other => panic!("expected Custom, got {:?}", other),
890        }
891    }
892
893    #[test]
894    fn parse_unknown_category_becomes_custom() {
895        match parse_category("foobar") {
896            Category::Custom(tag) => assert_eq!(tag.as_str(), "foobar"),
897            other => panic!("expected Custom, got {:?}", other),
898        }
899    }
900
901    // ---- Entries to patterns ----
902
903    #[test]
904    fn entries_to_patterns_success() {
905        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
906        let (patterns, errors) = entries_to_patterns(&entries);
907        assert_eq!(patterns.len(), 2);
908        assert!(errors.is_empty());
909    }
910
911    #[test]
912    fn entries_to_patterns_bad_regex() {
913        let json = r#"[{"pattern": "[invalid(", "kind": "regex", "category": "email"}]"#;
914        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
915        let (patterns, errors) = entries_to_patterns(&entries);
916        assert!(patterns.is_empty());
917        assert_eq!(errors.len(), 1);
918        assert_eq!(errors[0].0, 0);
919    }
920
921    // ---- Encrypt / Decrypt round-trip ----
922
923    #[test]
924    fn encrypt_decrypt_roundtrip() {
925        let plaintext = sample_json().as_bytes();
926        let password = "test-password-42";
927
928        let encrypted = encrypt_secrets(plaintext, password).unwrap();
929
930        // Encrypted blob must be larger than plaintext (salt + nonce + tag).
931        assert!(encrypted.len() > plaintext.len());
932
933        let decrypted = decrypt_secrets(&encrypted, password).unwrap();
934        assert_eq!(decrypted.as_slice(), plaintext);
935    }
936
937    #[test]
938    fn decrypt_wrong_password_fails() {
939        let plaintext = b"hello";
940        let encrypted = encrypt_secrets(plaintext, "correct").unwrap();
941        let result = decrypt_secrets(&encrypted, "wrong");
942        assert!(result.is_err());
943    }
944
945    #[test]
946    fn decrypt_truncated_blob_fails() {
947        let result = decrypt_secrets(&[0u8; 10], "any");
948        assert!(result.is_err());
949    }
950
951    #[test]
952    fn decrypt_tampered_blob_fails() {
953        let plaintext = b"hello world";
954        let mut encrypted = encrypt_secrets(plaintext, "pw").unwrap();
955        // Flip a byte in the ciphertext portion.
956        let last = encrypted.len() - 1;
957        encrypted[last] ^= 0xFF;
958        let result = decrypt_secrets(&encrypted, "pw");
959        assert!(result.is_err());
960    }
961
962    #[test]
963    fn encrypt_empty_password_rejected() {
964        let result = encrypt_secrets(b"hello", "");
965        assert!(result.is_err());
966    }
967
968    // ---- Full pipeline: encrypt → decrypt → parse → patterns ----
969
970    #[test]
971    fn full_pipeline_json() {
972        let plaintext = sample_json().as_bytes();
973        let password = "pipeline-test";
974
975        let encrypted = encrypt_secrets(plaintext, password).unwrap();
976        let ((patterns, errors), _allow) =
977            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Json)).unwrap();
978
979        assert_eq!(patterns.len(), 2);
980        assert!(errors.is_empty());
981        assert_eq!(patterns[0].label(), "alice_email");
982        assert_eq!(patterns[1].label(), "openai_key");
983    }
984
985    #[test]
986    fn full_pipeline_yaml() {
987        let plaintext = sample_yaml().as_bytes();
988        let password = "yaml-test";
989
990        let encrypted = encrypt_secrets(plaintext, password).unwrap();
991        let ((patterns, errors), _allow) =
992            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Yaml)).unwrap();
993
994        assert_eq!(patterns.len(), 2);
995        assert!(errors.is_empty());
996    }
997
998    #[test]
999    fn full_pipeline_toml() {
1000        let plaintext = sample_toml().as_bytes();
1001        let password = "toml-test";
1002
1003        let encrypted = encrypt_secrets(plaintext, password).unwrap();
1004        let ((patterns, errors), _allow) =
1005            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Toml)).unwrap();
1006
1007        assert_eq!(patterns.len(), 2);
1008        assert!(errors.is_empty());
1009    }
1010
1011    // ---- Plaintext loader ----
1012
1013    #[test]
1014    fn load_plaintext_secrets_works() {
1015        let ((patterns, errors), _allow) =
1016            load_plaintext_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1017        assert_eq!(patterns.len(), 2);
1018        assert!(errors.is_empty());
1019    }
1020
1021    // ---- Serialization round-trip ----
1022
1023    #[test]
1024    fn serialize_roundtrip_json() {
1025        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1026        let serialized = serialize_secrets(&entries, SecretsFormat::Json).unwrap();
1027        let reparsed = parse_secrets(&serialized, Some(SecretsFormat::Json)).unwrap();
1028        assert_eq!(entries.len(), reparsed.len());
1029        assert_eq!(entries[0].pattern, reparsed[0].pattern);
1030    }
1031
1032    // ---- Format detection ----
1033
1034    #[test]
1035    fn format_from_extension() {
1036        assert_eq!(
1037            SecretsFormat::from_extension("secrets.json"),
1038            Some(SecretsFormat::Json)
1039        );
1040        assert_eq!(
1041            SecretsFormat::from_extension("secrets.json.enc"),
1042            Some(SecretsFormat::Json)
1043        );
1044        assert_eq!(
1045            SecretsFormat::from_extension("secrets.yaml"),
1046            Some(SecretsFormat::Yaml)
1047        );
1048        assert_eq!(
1049            SecretsFormat::from_extension("secrets.yml.enc"),
1050            Some(SecretsFormat::Yaml)
1051        );
1052        assert_eq!(
1053            SecretsFormat::from_extension("secrets.toml"),
1054            Some(SecretsFormat::Toml)
1055        );
1056        assert_eq!(SecretsFormat::from_extension("secrets.txt"), None);
1057    }
1058
1059    #[test]
1060    fn detect_yaml_with_leading_comment_header() {
1061        // Regression: the auto-provisioned global secrets file opens with '#'
1062        // comment lines. Before the fix, detect() saw '#' first, fell through
1063        // to the TOML fallback, and failed to parse valid YAML.
1064        let content = "# Global sanitize allowlist — add patterns here.\n# Auto-loaded on every plain run.\n\n- pattern: foo\n  kind: allow\n";
1065        assert_eq!(
1066            SecretsFormat::detect(content.as_bytes()),
1067            SecretsFormat::Yaml
1068        );
1069    }
1070
1071    #[test]
1072    fn detect_yaml_comment_header_parses_correctly() {
1073        // Round-trip: same shape as the auto-provisioned file must load without error.
1074        let content = "# Global sanitize 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";
1075        let entries = parse_secrets(content.as_bytes(), None)
1076            .expect("auto-provisioned secrets file with comment header must parse");
1077        assert_eq!(entries.len(), 1);
1078        assert_eq!(entries[0].kind, "allow");
1079        assert!(entries[0].values.contains(&"localhost".to_string()));
1080    }
1081
1082    #[test]
1083    fn detect_json_array() {
1084        assert_eq!(
1085            SecretsFormat::detect(b"[{\"pattern\": \"foo\"}]"),
1086            SecretsFormat::Json
1087        );
1088    }
1089
1090    #[test]
1091    fn detect_toml_fallback() {
1092        // TOML that doesn't open with '[' or '{' — must not be mistaken for YAML.
1093        assert_eq!(
1094            SecretsFormat::detect(b"# toml comment\nkey = \"value\""),
1095            SecretsFormat::Toml
1096        );
1097    }
1098
1099    // ---- Defaults ----
1100
1101    #[test]
1102    fn default_kind_is_literal() {
1103        let json = r#"[{"pattern": "foo"}]"#;
1104        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1105        assert_eq!(entries[0].kind, "literal");
1106    }
1107
1108    #[test]
1109    fn default_category_is_custom_secret() {
1110        let json = r#"[{"pattern": "foo"}]"#;
1111        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1112        assert_eq!(entries[0].category, "custom:secret");
1113    }
1114
1115    #[test]
1116    fn default_label_from_pattern() {
1117        let json = r#"[{"pattern": "short"}]"#;
1118        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1119        let (patterns, _) = entries_to_patterns(&entries);
1120        assert_eq!(patterns[0].label(), "short");
1121    }
1122
1123    // ---- looks_encrypted ----
1124
1125    #[test]
1126    fn looks_encrypted_json_plaintext() {
1127        assert!(!looks_encrypted(sample_json().as_bytes()));
1128    }
1129
1130    #[test]
1131    fn looks_encrypted_yaml_plaintext() {
1132        assert!(!looks_encrypted(sample_yaml().as_bytes()));
1133    }
1134
1135    #[test]
1136    fn looks_encrypted_toml_plaintext() {
1137        assert!(!looks_encrypted(sample_toml().as_bytes()));
1138    }
1139
1140    #[test]
1141    fn looks_encrypted_actual_encrypted() {
1142        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1143        assert!(looks_encrypted(&encrypted));
1144    }
1145
1146    #[test]
1147    fn looks_encrypted_too_short() {
1148        assert!(!looks_encrypted(&[0u8; 10]));
1149    }
1150
1151    // ---- load_secrets_auto ----
1152
1153    #[test]
1154    fn auto_load_plaintext_json() {
1155        let data = sample_json().as_bytes();
1156        let (((pats, errs), _allow), was_enc) =
1157            load_secrets_auto(data, None, Some(SecretsFormat::Json), false).unwrap();
1158        assert!(!was_enc);
1159        assert_eq!(pats.len(), 2);
1160        assert!(errs.is_empty());
1161    }
1162
1163    #[test]
1164    fn auto_load_encrypted_json() {
1165        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1166        let (((pats, errs), _allow), was_enc) =
1167            load_secrets_auto(&encrypted, Some("pw"), Some(SecretsFormat::Json), false).unwrap();
1168        assert!(was_enc);
1169        assert_eq!(pats.len(), 2);
1170        assert!(errs.is_empty());
1171    }
1172
1173    #[test]
1174    fn auto_load_force_plaintext() {
1175        let data = sample_json().as_bytes();
1176        let (((pats, _), _allow), was_enc) =
1177            load_secrets_auto(data, None, Some(SecretsFormat::Json), true).unwrap();
1178        assert!(!was_enc);
1179        assert_eq!(pats.len(), 2);
1180    }
1181
1182    #[test]
1183    fn auto_load_encrypted_no_password_fails() {
1184        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1185        let result = load_secrets_auto(&encrypted, None, None, false);
1186        assert!(result.is_err());
1187    }
1188
1189    #[test]
1190    fn parse_secrets_rejects_oversized_input() {
1191        // Construct input just over the 10 MiB cap.
1192        let oversized = vec![b' '; MAX_SECRETS_PLAINTEXT_BYTES + 1];
1193        let result = parse_secrets(&oversized, None);
1194        assert!(result.is_err());
1195        let msg = result.unwrap_err().to_string();
1196        assert!(
1197            msg.contains("exceeding") || msg.contains("limit"),
1198            "unexpected error message: {msg}"
1199        );
1200    }
1201
1202    #[test]
1203    fn parse_secrets_accepts_input_at_limit() {
1204        // Valid JSON just at the cap boundary — should succeed or fail on
1205        // parse, not on the size check. We use a tiny valid payload here
1206        // to confirm the size gate does not block small files.
1207        let tiny = b"[]";
1208        let result = parse_secrets(tiny, Some(SecretsFormat::Json));
1209        assert!(
1210            result.is_ok(),
1211            "unexpected error: {:?}",
1212            result.unwrap_err()
1213        );
1214    }
1215
1216    #[test]
1217    fn truncate_label_at_boundary() {
1218        let short = "a".repeat(32);
1219        assert_eq!(truncate_label(&short), short);
1220
1221        let long = "a".repeat(33);
1222        let truncated = truncate_label(&long);
1223        assert!(truncated.ends_with('…'), "expected ellipsis: {truncated}");
1224        // Character count (not byte count) must be within the limit.
1225        // The trailing '…' is 1 char; the rest must be < MAX_LABEL_CHARS.
1226        assert!(
1227            truncated.chars().count() <= MAX_LABEL_CHARS,
1228            "char count {} exceeds limit: {truncated}",
1229            truncated.chars().count()
1230        );
1231    }
1232
1233    // ---- Multi-value allow entries ----
1234
1235    #[test]
1236    fn allow_single_pattern_field() {
1237        let json = r#"[{"kind":"allow","pattern":"localhost"}]"#;
1238        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1239        let patterns = extract_allow_patterns(&entries);
1240        assert_eq!(patterns, vec!["localhost"]);
1241    }
1242
1243    #[test]
1244    fn allow_values_list_used_instead_of_pattern() {
1245        let json = r#"[{"kind":"allow","values":["localhost","true","false","null"]}]"#;
1246        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1247        let patterns = extract_allow_patterns(&entries);
1248        assert_eq!(patterns, vec!["localhost", "true", "false", "null"]);
1249    }
1250
1251    #[test]
1252    fn allow_values_list_yaml() {
1253        let yaml =
1254            "- kind: allow\n  values:\n    - localhost\n    - \"127.0.0.1\"\n    - \"0.0.0.0\"\n";
1255        let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1256        let patterns = extract_allow_patterns(&entries);
1257        assert_eq!(patterns, vec!["localhost", "127.0.0.1", "0.0.0.0"]);
1258    }
1259
1260    #[test]
1261    fn allow_values_list_toml() {
1262        let toml = "[[secrets]]\nkind = \"allow\"\nvalues = [\"localhost\", \"true\", \"false\"]\n";
1263        let entries = parse_secrets(toml.as_bytes(), Some(SecretsFormat::Toml)).unwrap();
1264        let patterns = extract_allow_patterns(&entries);
1265        assert_eq!(patterns, vec!["localhost", "true", "false"]);
1266    }
1267
1268    #[test]
1269    fn allow_mixed_single_and_multi_value_entries() {
1270        let json = r#"[
1271            {"kind":"allow","pattern":"localhost"},
1272            {"kind":"allow","values":["true","false","null"]},
1273            {"kind":"allow","pattern":"*.internal"}
1274        ]"#;
1275        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1276        let patterns = extract_allow_patterns(&entries);
1277        assert_eq!(
1278            patterns,
1279            vec!["localhost", "true", "false", "null", "*.internal"]
1280        );
1281    }
1282
1283    #[test]
1284    fn allow_entries_skipped_by_entries_to_patterns() {
1285        let json = r#"[
1286            {"pattern":"secret","kind":"literal"},
1287            {"kind":"allow","values":["localhost","true"]}
1288        ]"#;
1289        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1290        let (patterns, errors) = entries_to_patterns(&entries);
1291        assert_eq!(patterns.len(), 1);
1292        assert!(errors.is_empty());
1293        assert_eq!(patterns[0].label(), "secret");
1294    }
1295
1296    #[test]
1297    fn allow_empty_values_falls_back_to_pattern() {
1298        // An entry with an empty `values` list should still use `pattern`.
1299        let json = r#"[{"kind":"allow","pattern":"localhost","values":[]}]"#;
1300        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1301        let patterns = extract_allow_patterns(&entries);
1302        assert_eq!(patterns, vec!["localhost"]);
1303    }
1304
1305    // ── kind: field-name ─────────────────────────────────────────────────────
1306
1307    #[test]
1308    fn field_name_entries_skipped_by_entries_to_patterns() {
1309        // kind:field-name entries must not produce ScanPatterns — they are
1310        // handled separately as FieldNameSignals injected into profiles.
1311        let json = r#"[
1312            {"pattern":"secret","kind":"literal"},
1313            {"pattern":"^password$","kind":"field-name","threshold":3.0}
1314        ]"#;
1315        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1316        let (patterns, errors) = entries_to_patterns(&entries);
1317        assert_eq!(
1318            patterns.len(),
1319            1,
1320            "only the literal entry should produce a pattern"
1321        );
1322        assert!(errors.is_empty());
1323        assert_eq!(patterns[0].label(), "secret");
1324    }
1325
1326    #[test]
1327    fn field_name_entry_parses_correctly() {
1328        let yaml = "- kind: field-name\n  pattern: \"^(password|secret)$\"\n  threshold: 3.0\n  label: my-signal\n";
1329        let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1330        assert_eq!(entries.len(), 1);
1331        assert_eq!(entries[0].kind, "field-name");
1332        assert_eq!(entries[0].pattern, "^(password|secret)$");
1333        assert_eq!(entries[0].threshold, Some(3.0));
1334        assert_eq!(entries[0].label, Some("my-signal".into()));
1335    }
1336
1337    #[test]
1338    fn field_name_entry_not_extracted_as_allow_pattern() {
1339        // kind:field-name entries must not bleed into the allowlist.
1340        let json = r#"[{"pattern":"^password$","kind":"field-name"}]"#;
1341        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1342        let allow = extract_allow_patterns(&entries);
1343        assert!(allow.is_empty());
1344    }
1345}