Skip to main content

sanitize_engine/
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        let trimmed = s.trim_start();
236        if trimmed.starts_with('[') || trimmed.starts_with('{') {
237            // `[` is ambiguous: JSON arrays and TOML table headers both start
238            // with it. We pick JSON here because our secrets files are never
239            // bare TOML tables, and a wrong guess produces a clear parse error.
240            Self::Json
241        } else if trimmed.starts_with('-') || trimmed.starts_with("---") {
242            Self::Yaml
243        } else {
244            // Fallback: assume TOML
245            Self::Toml
246        }
247    }
248}
249
250// ---------------------------------------------------------------------------
251// TOML wrapper — serde_toml expects a top-level table
252// ---------------------------------------------------------------------------
253
254/// Wrapper for TOML deserialization: `secrets = [...]`
255#[derive(Deserialize)]
256struct TomlSecrets {
257    secrets: Vec<SecretEntry>,
258}
259
260/// Wrapper for TOML serialization.
261#[derive(Serialize)]
262struct TomlSecretsRef<'a> {
263    secrets: &'a [SecretEntry],
264}
265
266// ---------------------------------------------------------------------------
267// Key derivation
268// ---------------------------------------------------------------------------
269
270/// Derive a 256-bit AES key from a password and salt using PBKDF2.
271fn derive_key(password: &[u8], salt: &[u8]) -> Zeroizing<[u8; 32]> {
272    let mut key = Zeroizing::new([0u8; 32]);
273    pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, PBKDF2_ITERATIONS, key.as_mut())
274        .expect("PBKDF2 output length is valid");
275    key
276}
277
278// ---------------------------------------------------------------------------
279// Encryption
280// ---------------------------------------------------------------------------
281
282/// Encrypt a plaintext secrets file.
283///
284/// Returns the encrypted blob: `salt (32) || nonce (12) || ciphertext`.
285///
286/// # Arguments
287///
288/// - `plaintext` — raw bytes of the secrets file (JSON / YAML / TOML).
289/// - `password` — user-supplied password.
290///
291/// # Errors
292///
293/// Returns [`SanitizeError::SecretsEmptyPassword`] if the password is empty, or
294/// [`SanitizeError::SecretsCipherError`] if encryption fails.
295///
296/// # Security
297///
298/// - Salt and nonce are generated with CSPRNG.
299/// - Key is derived with PBKDF2 (600 000 iterations).
300/// - AES-256-GCM provides authenticated encryption.
301pub fn encrypt_secrets(plaintext: &[u8], password: &str) -> Result<Vec<u8>> {
302    if password.is_empty() {
303        return Err(SanitizeError::SecretsEmptyPassword);
304    }
305
306    let mut rng = rand::rng();
307
308    // Generate random salt and nonce.
309    let mut salt = [0u8; SALT_LEN];
310    rng.fill_bytes(&mut salt);
311
312    let mut nonce_bytes = [0u8; NONCE_LEN];
313    rng.fill_bytes(&mut nonce_bytes);
314    let nonce = Nonce::from_slice(&nonce_bytes);
315
316    // Derive key.
317    let key = derive_key(password.as_bytes(), &salt);
318    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
319        .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
320
321    // Encrypt.
322    let ciphertext = cipher
323        .encrypt(nonce, plaintext)
324        .map_err(|e| SanitizeError::SecretsCipherError(format!("encryption: {}", e)))?;
325
326    // Assemble: salt || nonce || ciphertext
327    let mut output = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len());
328    output.extend_from_slice(&salt);
329    output.extend_from_slice(&nonce_bytes);
330    output.extend_from_slice(&ciphertext);
331
332    Ok(output)
333}
334
335// ---------------------------------------------------------------------------
336// Decryption
337// ---------------------------------------------------------------------------
338
339/// Decrypt an encrypted secrets blob in memory.
340///
341/// Returns the plaintext wrapped in [`Zeroizing`] so it is scrubbed on drop.
342///
343/// # Arguments
344///
345/// - `encrypted` — `salt (32) || nonce (12) || ciphertext`.
346/// - `password` — user-supplied password.
347///
348/// # Errors
349///
350/// - [`SanitizeError::SecretsTooShort`] if the blob is too short,
351///   [`SanitizeError::SecretsDecryptFailed`] if the password is wrong or the ciphertext has been tampered with.
352pub fn decrypt_secrets(encrypted: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>> {
353    if encrypted.len() < MIN_ENCRYPTED_LEN {
354        return Err(SanitizeError::SecretsTooShort);
355    }
356
357    let salt = &encrypted[..SALT_LEN];
358    let nonce_bytes = &encrypted[SALT_LEN..SALT_LEN + NONCE_LEN];
359    let ciphertext = &encrypted[SALT_LEN + NONCE_LEN..];
360
361    let nonce = Nonce::from_slice(nonce_bytes);
362
363    let key = derive_key(password.as_bytes(), salt);
364    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
365        .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
366
367    let plaintext = cipher
368        .decrypt(nonce, ciphertext)
369        .map_err(|_| SanitizeError::SecretsDecryptFailed)?;
370
371    Ok(Zeroizing::new(plaintext))
372}
373
374// ---------------------------------------------------------------------------
375// Parsing
376// ---------------------------------------------------------------------------
377
378/// Parse a decrypted plaintext into secret entries.
379///
380/// Supports JSON, YAML, and TOML. Format is auto-detected if `format`
381/// is `None`.
382///
383/// # Errors
384///
385/// Returns [`SanitizeError::SecretsInvalidUtf8`] if the plaintext is not
386/// valid UTF-8, [`SanitizeError::SecretsFormatError`] if it cannot be parsed
387/// in the specified format or if the file exceeds the size limit.
388pub fn parse_secrets(plaintext: &[u8], format: Option<SecretsFormat>) -> Result<Vec<SecretEntry>> {
389    if plaintext.len() > MAX_SECRETS_PLAINTEXT_BYTES {
390        return Err(SanitizeError::SecretsFormatError {
391            format: "secrets file".into(),
392            message: format!(
393                "file is {} bytes, exceeding the {} byte limit — \
394                 secrets files should be small YAML/JSON/TOML pattern lists",
395                plaintext.len(),
396                MAX_SECRETS_PLAINTEXT_BYTES,
397            ),
398        });
399    }
400    let fmt = format.unwrap_or_else(|| SecretsFormat::detect(plaintext));
401    let text = std::str::from_utf8(plaintext)
402        .map_err(|e| SanitizeError::SecretsInvalidUtf8(e.to_string()))?;
403
404    match fmt {
405        SecretsFormat::Json => {
406            serde_json::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
407                format: "JSON".into(),
408                message: e.to_string(),
409            })
410        }
411        SecretsFormat::Yaml => {
412            serde_yaml_ng::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
413                format: "YAML".into(),
414                message: e.to_string(),
415            })
416        }
417        SecretsFormat::Toml => {
418            let wrapper: TomlSecrets =
419                toml::from_str(text).map_err(|e| SanitizeError::SecretsFormatError {
420                    format: "TOML".into(),
421                    message: e.to_string(),
422                })?;
423            Ok(wrapper.secrets)
424        }
425    }
426}
427
428/// Serialize secret entries back into a plaintext format.
429///
430/// Used by the encryption helper CLI.
431///
432/// # Errors
433///
434/// Returns [`SanitizeError::SecretsFormatError`] if serialization fails.
435pub fn serialize_secrets(entries: &[SecretEntry], format: SecretsFormat) -> Result<Vec<u8>> {
436    match format {
437        SecretsFormat::Json => {
438            serde_json::to_vec_pretty(entries).map_err(|e| SanitizeError::SecretsFormatError {
439                format: "JSON-serialize".into(),
440                message: e.to_string(),
441            })
442        }
443        SecretsFormat::Yaml => serde_yaml_ng::to_string(entries)
444            .map(|s| s.into_bytes())
445            .map_err(|e| SanitizeError::SecretsFormatError {
446                format: "YAML-serialize".into(),
447                message: e.to_string(),
448            }),
449        SecretsFormat::Toml => {
450            let wrapper = TomlSecretsRef { secrets: entries };
451            toml::to_string_pretty(&wrapper)
452                .map(|s| s.into_bytes())
453                .map_err(|e| SanitizeError::SecretsFormatError {
454                    format: "TOML-serialize".into(),
455                    message: e.to_string(),
456                })
457        }
458    }
459}
460
461// ---------------------------------------------------------------------------
462// Category parsing
463// ---------------------------------------------------------------------------
464
465/// Parse a category string into a [`Category`].
466///
467/// Accepted values: `email`, `name`, `phone`, `ipv4`, `ipv6`,
468/// `credit_card`, `ssn`, `hostname`, `mac_address`, `container_id`,
469/// `uuid`, `jwt`, `auth_token`, `file_path`, `windows_sid`, `url`,
470/// `aws_arn`, `azure_resource_id`, or `custom:<tag>`.
471pub fn parse_category(s: &str) -> Category {
472    match s {
473        "email" => Category::Email,
474        "name" => Category::Name,
475        "phone" => Category::Phone,
476        "ipv4" => Category::IpV4,
477        "ipv6" => Category::IpV6,
478        "credit_card" => Category::CreditCard,
479        "ssn" => Category::Ssn,
480        "hostname" => Category::Hostname,
481        "mac_address" => Category::MacAddress,
482        "container_id" => Category::ContainerId,
483        "uuid" => Category::Uuid,
484        "jwt" => Category::Jwt,
485        "auth_token" => Category::AuthToken,
486        "file_path" => Category::FilePath,
487        "windows_sid" => Category::WindowsSid,
488        "url" => Category::Url,
489        "aws_arn" => Category::AwsArn,
490        "azure_resource_id" => Category::AzureResourceId,
491        other => {
492            let tag = other.strip_prefix("custom:").unwrap_or(other);
493            Category::Custom(tag.into())
494        }
495    }
496}
497
498// ---------------------------------------------------------------------------
499// Conversion to ScanPatterns
500// ---------------------------------------------------------------------------
501
502/// Zeroize all sensitive string fields in a `Vec<SecretEntry>` and drop it.
503///
504/// Extract allowlist patterns from a set of entries.
505///
506/// Entries with `kind: allow` are returned as raw pattern strings to be
507/// compiled into an [`AllowlistMatcher`](crate::allowlist::AllowlistMatcher). They are skipped by
508/// [`entries_to_patterns`].
509///
510/// Each entry contributes either its `values` list (when non-empty) or its
511/// `pattern` field (when `values` is absent), so both forms are supported:
512///
513/// ```toml
514/// # single pattern
515/// [[secrets]]
516/// kind = "allow"
517/// pattern = "localhost"
518///
519/// # compact multi-value form
520/// [[secrets]]
521/// kind = "allow"
522/// values = ["true", "false", "null", "0.0.0.0"]
523/// ```
524pub fn extract_allow_patterns(entries: &[SecretEntry]) -> Vec<String> {
525    let mut patterns = Vec::new();
526    for entry in entries.iter().filter(|e| e.kind == "allow") {
527        if !entry.values.is_empty() {
528            patterns.extend(entry.values.iter().cloned());
529        } else if !entry.pattern.is_empty() {
530            patterns.push(entry.pattern.clone());
531        }
532    }
533    patterns
534}
535
536/// Convert parsed [`SecretEntry`]s into compiled [`ScanPattern`]s.
537///
538/// Entries with `kind: allow` are silently skipped — they are handled by
539/// [`extract_allow_patterns`] instead.
540///
541/// Invalid entries (e.g. bad regex) are collected as errors and
542/// returned alongside the successfully compiled patterns.
543pub fn entries_to_patterns(entries: &[SecretEntry]) -> PatternCompileResult {
544    let mut patterns = Vec::with_capacity(entries.len());
545    let mut errors = Vec::new();
546
547    for (i, entry) in entries.iter().enumerate() {
548        if entry.kind == "allow"
549            || entry.kind == "entropy"
550            || entry.kind == "field-name"
551            || entry.pattern.is_empty()
552        {
553            continue;
554        }
555        let category = parse_category(&entry.category);
556        let label = entry
557            .label
558            .clone()
559            .unwrap_or_else(|| truncate_label(&entry.pattern));
560
561        let result = match entry.kind.as_str() {
562            "regex" => ScanPattern::from_regex(&entry.pattern, category, label),
563            "literal" => ScanPattern::from_literal(&entry.pattern, category, label),
564            other => {
565                errors.push((
566                    i,
567                    SanitizeError::InvalidConfig(format!(
568                        "unknown kind {:?} — expected \"literal\", \"regex\", \"allow\", \"entropy\", or \"field-name\"",
569                        other
570                    )),
571                ));
572                continue;
573            }
574        };
575
576        match result {
577            Ok(pat) => patterns.push(pat),
578            Err(e) => errors.push((i, e)),
579        }
580    }
581
582    (patterns, errors)
583}
584
585const MAX_LABEL_CHARS: usize = 32;
586
587/// Truncate to a maximum label length.
588fn truncate_label(s: &str) -> String {
589    if s.len() <= MAX_LABEL_CHARS {
590        s.to_string()
591    } else {
592        // Find a char boundary just before the limit to avoid panicking on
593        // multi-byte UTF-8 characters (e.g. Unicode in user-supplied patterns).
594        let cut = s
595            .char_indices()
596            .nth(MAX_LABEL_CHARS - 1)
597            .map_or(s.len(), |(i, _)| i);
598        format!("{}…", &s[..cut])
599    }
600}
601
602// ---------------------------------------------------------------------------
603// High-level: load encrypted secrets → ScanPatterns
604// ---------------------------------------------------------------------------
605
606/// Load, decrypt, parse, and compile an encrypted secrets file into
607/// [`ScanPattern`]s ready for the streaming scanner.
608///
609/// This is the primary entry point for CLI integration.
610///
611/// # Arguments
612///
613/// - `encrypted_bytes` — raw bytes of the `.enc` file.
614/// - `password` — user-supplied password.
615/// - `format` — optional explicit format override.
616///
617/// # Returns
618///
619/// `(patterns, warnings)` where `warnings` contains indices and errors
620/// for entries that failed to compile.
621///
622/// # Security
623///
624/// The decrypted plaintext is held in zeroizing memory and dropped
625/// immediately after parsing.
626///
627/// # Errors
628///
629/// Returns a secrets-related [`SanitizeError`] if decryption or parsing fails.
630pub fn load_encrypted_secrets(
631    encrypted_bytes: &[u8],
632    password: &str,
633    format: Option<SecretsFormat>,
634) -> Result<(PatternCompileResult, Vec<String>)> {
635    let plaintext = decrypt_secrets(encrypted_bytes, password)?;
636    let entries = parse_secrets(&plaintext, format)?;
637    let allow = extract_allow_patterns(&entries);
638    let result = entries_to_patterns(&entries);
639    // SecretEntry implements Drop with explicit zeroize() calls, so dropping
640    // the Vec is sufficient to scrub sensitive pattern data from heap memory.
641    drop(entries);
642    Ok((result, allow))
643}
644
645/// Load and parse a plaintext secrets file into [`ScanPattern`]s.
646///
647/// This function mirrors [`load_encrypted_secrets`] but skips
648/// AES decryption and password prompts entirely. It preserves
649/// memory hygiene by zeroizing parsed entries after compilation.
650///
651/// # Arguments
652///
653/// - `plaintext` — raw bytes of the secrets file (JSON / YAML / TOML).
654/// - `format` — optional explicit format override.
655///
656/// # Security
657///
658/// Even for unencrypted secrets, entries are zeroized after pattern
659/// compilation to minimise the window during which sensitive values
660/// reside in memory.
661///
662/// # Errors
663///
664/// Returns a secrets-related [`SanitizeError`] if parsing or pattern
665/// compilation fails.
666pub fn load_plaintext_secrets(
667    plaintext: &[u8],
668    format: Option<SecretsFormat>,
669) -> Result<(PatternCompileResult, Vec<String>)> {
670    let entries = parse_secrets(plaintext, format)?;
671    let allow = extract_allow_patterns(&entries);
672    let result = entries_to_patterns(&entries);
673    // SecretEntry implements Drop with explicit zeroize() calls, so dropping
674    // the Vec is sufficient to scrub sensitive pattern data from heap memory.
675    drop(entries);
676    Ok((result, allow))
677}
678
679/// Detect whether raw file bytes look like an AES-256-GCM encrypted
680/// secrets blob (binary with salt+nonce header) or a plaintext secrets
681/// file (UTF-8 JSON / YAML / TOML).
682///
683/// Returns `true` if the content appears to be encrypted.
684///
685/// Heuristic:
686/// 1. Files shorter than the minimum encrypted length cannot be valid
687///    ciphertext — return `false`.
688/// 2. The **entire** content is checked for UTF-8 validity (not just the
689///    first few bytes). Only if the whole file is valid UTF-8 and begins
690///    with a recognisable plaintext marker (`[`, `{`, `-`, `#`) is it
691///    treated as plaintext — return `false`.
692/// 3. Binary content (not valid UTF-8) or UTF-8 without a plaintext
693///    marker is assumed to be encrypted — return `true`.
694///
695/// Note: a pathological plaintext file that is valid UTF-8 but lacks a
696/// leading plaintext marker (e.g. a TOML file whose first non-whitespace
697/// character is a letter) will be misclassified as encrypted and produce
698/// a `SecretsDecryptFailed` error. Use `force_plaintext: true` in
699/// [`load_secrets_auto`] to bypass the heuristic in that case.
700pub fn looks_encrypted(data: &[u8]) -> bool {
701    if data.len() < MIN_ENCRYPTED_LEN {
702        // Too short for a valid encrypted blob — might be a tiny
703        // plaintext file, but definitely not encrypted.
704        return false;
705    }
706    // If the file is valid UTF-8 and starts with a recognisable
707    // plaintext marker, treat it as plaintext.
708    if let Ok(text) = std::str::from_utf8(data) {
709        let trimmed = text.trim_start();
710        // Recognisable plaintext markers for JSON ('[', '{'), YAML ('-'), TOML ('#').
711        // starts_with('[') already covers "[["; starts_with('-') covers "---".
712        let has_marker = trimmed.starts_with('[')
713            || trimmed.starts_with('{')
714            || trimmed.starts_with('-')
715            || trimmed.starts_with('#');
716        if has_marker {
717            return false;
718        }
719    }
720    // Binary / non-UTF-8 → assume encrypted.
721    true
722}
723
724/// Unified loader: auto-detect encrypted vs plaintext and load
725/// secret patterns accordingly.
726///
727/// When `force_plaintext` is `true`, decryption is skipped regardless
728/// of file content. When `false`, the function uses [`looks_encrypted`]
729/// to choose the path automatically.
730///
731/// # Arguments
732///
733/// - `data` — raw bytes read from the secrets file.
734/// - `password` — password for decryption (ignored when plaintext).
735/// - `format` — optional format override.
736/// - `force_plaintext` — if `true`, always treat as plaintext.
737///
738/// # Returns
739///
740/// `(patterns, warnings, was_encrypted)` — the compiled patterns,
741/// any compile warnings, and a flag indicating which path was taken.
742///
743/// # Errors
744///
745/// Returns a secrets-related [`SanitizeError`] if decryption or parsing
746/// fails, or if a password is required but not provided.
747/// Returns `((patterns, warnings, allow_patterns), was_encrypted)`.
748///
749/// `allow_patterns` are the raw strings from `kind: allow` entries in the
750/// secrets file — the caller should combine these with any `--allow` CLI
751/// values and pass the merged list to [`AllowlistMatcher::new`](crate::allowlist::AllowlistMatcher::new).
752pub fn load_secrets_auto(
753    data: &[u8],
754    password: Option<&str>,
755    format: Option<SecretsFormat>,
756    force_plaintext: bool,
757) -> Result<((PatternCompileResult, Vec<String>), bool)> {
758    if force_plaintext || !looks_encrypted(data) {
759        let (result, allow) = load_plaintext_secrets(data, format)?;
760        Ok(((result, allow), false))
761    } else {
762        let pw = password.ok_or(SanitizeError::SecretsPasswordRequired)?;
763        let (result, allow) = load_encrypted_secrets(data, pw, format)?;
764        Ok(((result, allow), true))
765    }
766}
767
768// ---------------------------------------------------------------------------
769// Unit tests
770// ---------------------------------------------------------------------------
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775
776    fn sample_json() -> &'static str {
777        r#"[
778            {
779                "pattern": "alice@corp\\.com",
780                "kind": "regex",
781                "category": "email",
782                "label": "alice_email"
783            },
784            {
785                "pattern": "sk-proj-abc123secret",
786                "kind": "literal",
787                "category": "custom:api_key",
788                "label": "openai_key"
789            }
790        ]"#
791    }
792
793    fn sample_yaml() -> &'static str {
794        r#"- pattern: "alice@corp\\.com"
795  kind: regex
796  category: email
797  label: alice_email
798- pattern: sk-proj-abc123secret
799  kind: literal
800  category: "custom:api_key"
801  label: openai_key
802"#
803    }
804
805    fn sample_toml() -> &'static str {
806        r#"[[secrets]]
807pattern = "alice@corp\\.com"
808kind = "regex"
809category = "email"
810label = "alice_email"
811
812[[secrets]]
813pattern = "sk-proj-abc123secret"
814kind = "literal"
815category = "custom:api_key"
816label = "openai_key"
817"#
818    }
819
820    // ---- Parsing ----
821
822    #[test]
823    fn parse_json_entries() {
824        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
825        assert_eq!(entries.len(), 2);
826        assert_eq!(entries[0].kind, "regex");
827        assert_eq!(entries[0].category, "email");
828        assert_eq!(entries[1].kind, "literal");
829    }
830
831    #[test]
832    fn parse_yaml_entries() {
833        let entries = parse_secrets(sample_yaml().as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
834        assert_eq!(entries.len(), 2);
835        assert_eq!(entries[0].label, Some("alice_email".into()));
836    }
837
838    #[test]
839    fn parse_toml_entries() {
840        let entries = parse_secrets(sample_toml().as_bytes(), Some(SecretsFormat::Toml)).unwrap();
841        assert_eq!(entries.len(), 2);
842        assert_eq!(entries[1].pattern, "sk-proj-abc123secret");
843    }
844
845    #[test]
846    fn parse_auto_detect_json() {
847        let entries = parse_secrets(sample_json().as_bytes(), None).unwrap();
848        assert_eq!(entries.len(), 2);
849    }
850
851    #[test]
852    fn parse_auto_detect_yaml() {
853        let entries = parse_secrets(sample_yaml().as_bytes(), None).unwrap();
854        assert_eq!(entries.len(), 2);
855    }
856
857    // ---- Category parsing ----
858
859    #[test]
860    fn parse_builtin_categories() {
861        assert_eq!(parse_category("email"), Category::Email);
862        assert_eq!(parse_category("ipv4"), Category::IpV4);
863        assert_eq!(parse_category("ssn"), Category::Ssn);
864    }
865
866    #[test]
867    fn parse_custom_category() {
868        match parse_category("custom:api_key") {
869            Category::Custom(tag) => assert_eq!(tag.as_str(), "api_key"),
870            other => panic!("expected Custom, got {:?}", other),
871        }
872    }
873
874    #[test]
875    fn parse_unknown_category_becomes_custom() {
876        match parse_category("foobar") {
877            Category::Custom(tag) => assert_eq!(tag.as_str(), "foobar"),
878            other => panic!("expected Custom, got {:?}", other),
879        }
880    }
881
882    // ---- Entries to patterns ----
883
884    #[test]
885    fn entries_to_patterns_success() {
886        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
887        let (patterns, errors) = entries_to_patterns(&entries);
888        assert_eq!(patterns.len(), 2);
889        assert!(errors.is_empty());
890    }
891
892    #[test]
893    fn entries_to_patterns_bad_regex() {
894        let json = r#"[{"pattern": "[invalid(", "kind": "regex", "category": "email"}]"#;
895        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
896        let (patterns, errors) = entries_to_patterns(&entries);
897        assert!(patterns.is_empty());
898        assert_eq!(errors.len(), 1);
899        assert_eq!(errors[0].0, 0);
900    }
901
902    // ---- Encrypt / Decrypt round-trip ----
903
904    #[test]
905    fn encrypt_decrypt_roundtrip() {
906        let plaintext = sample_json().as_bytes();
907        let password = "test-password-42";
908
909        let encrypted = encrypt_secrets(plaintext, password).unwrap();
910
911        // Encrypted blob must be larger than plaintext (salt + nonce + tag).
912        assert!(encrypted.len() > plaintext.len());
913
914        let decrypted = decrypt_secrets(&encrypted, password).unwrap();
915        assert_eq!(decrypted.as_slice(), plaintext);
916    }
917
918    #[test]
919    fn decrypt_wrong_password_fails() {
920        let plaintext = b"hello";
921        let encrypted = encrypt_secrets(plaintext, "correct").unwrap();
922        let result = decrypt_secrets(&encrypted, "wrong");
923        assert!(result.is_err());
924    }
925
926    #[test]
927    fn decrypt_truncated_blob_fails() {
928        let result = decrypt_secrets(&[0u8; 10], "any");
929        assert!(result.is_err());
930    }
931
932    #[test]
933    fn decrypt_tampered_blob_fails() {
934        let plaintext = b"hello world";
935        let mut encrypted = encrypt_secrets(plaintext, "pw").unwrap();
936        // Flip a byte in the ciphertext portion.
937        let last = encrypted.len() - 1;
938        encrypted[last] ^= 0xFF;
939        let result = decrypt_secrets(&encrypted, "pw");
940        assert!(result.is_err());
941    }
942
943    #[test]
944    fn encrypt_empty_password_rejected() {
945        let result = encrypt_secrets(b"hello", "");
946        assert!(result.is_err());
947    }
948
949    // ---- Full pipeline: encrypt → decrypt → parse → patterns ----
950
951    #[test]
952    fn full_pipeline_json() {
953        let plaintext = sample_json().as_bytes();
954        let password = "pipeline-test";
955
956        let encrypted = encrypt_secrets(plaintext, password).unwrap();
957        let ((patterns, errors), _allow) =
958            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Json)).unwrap();
959
960        assert_eq!(patterns.len(), 2);
961        assert!(errors.is_empty());
962        assert_eq!(patterns[0].label(), "alice_email");
963        assert_eq!(patterns[1].label(), "openai_key");
964    }
965
966    #[test]
967    fn full_pipeline_yaml() {
968        let plaintext = sample_yaml().as_bytes();
969        let password = "yaml-test";
970
971        let encrypted = encrypt_secrets(plaintext, password).unwrap();
972        let ((patterns, errors), _allow) =
973            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Yaml)).unwrap();
974
975        assert_eq!(patterns.len(), 2);
976        assert!(errors.is_empty());
977    }
978
979    #[test]
980    fn full_pipeline_toml() {
981        let plaintext = sample_toml().as_bytes();
982        let password = "toml-test";
983
984        let encrypted = encrypt_secrets(plaintext, password).unwrap();
985        let ((patterns, errors), _allow) =
986            load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Toml)).unwrap();
987
988        assert_eq!(patterns.len(), 2);
989        assert!(errors.is_empty());
990    }
991
992    // ---- Plaintext loader ----
993
994    #[test]
995    fn load_plaintext_secrets_works() {
996        let ((patterns, errors), _allow) =
997            load_plaintext_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
998        assert_eq!(patterns.len(), 2);
999        assert!(errors.is_empty());
1000    }
1001
1002    // ---- Serialization round-trip ----
1003
1004    #[test]
1005    fn serialize_roundtrip_json() {
1006        let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1007        let serialized = serialize_secrets(&entries, SecretsFormat::Json).unwrap();
1008        let reparsed = parse_secrets(&serialized, Some(SecretsFormat::Json)).unwrap();
1009        assert_eq!(entries.len(), reparsed.len());
1010        assert_eq!(entries[0].pattern, reparsed[0].pattern);
1011    }
1012
1013    // ---- Format detection ----
1014
1015    #[test]
1016    fn format_from_extension() {
1017        assert_eq!(
1018            SecretsFormat::from_extension("secrets.json"),
1019            Some(SecretsFormat::Json)
1020        );
1021        assert_eq!(
1022            SecretsFormat::from_extension("secrets.json.enc"),
1023            Some(SecretsFormat::Json)
1024        );
1025        assert_eq!(
1026            SecretsFormat::from_extension("secrets.yaml"),
1027            Some(SecretsFormat::Yaml)
1028        );
1029        assert_eq!(
1030            SecretsFormat::from_extension("secrets.yml.enc"),
1031            Some(SecretsFormat::Yaml)
1032        );
1033        assert_eq!(
1034            SecretsFormat::from_extension("secrets.toml"),
1035            Some(SecretsFormat::Toml)
1036        );
1037        assert_eq!(SecretsFormat::from_extension("secrets.txt"), None);
1038    }
1039
1040    // ---- Defaults ----
1041
1042    #[test]
1043    fn default_kind_is_literal() {
1044        let json = r#"[{"pattern": "foo"}]"#;
1045        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1046        assert_eq!(entries[0].kind, "literal");
1047    }
1048
1049    #[test]
1050    fn default_category_is_custom_secret() {
1051        let json = r#"[{"pattern": "foo"}]"#;
1052        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1053        assert_eq!(entries[0].category, "custom:secret");
1054    }
1055
1056    #[test]
1057    fn default_label_from_pattern() {
1058        let json = r#"[{"pattern": "short"}]"#;
1059        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1060        let (patterns, _) = entries_to_patterns(&entries);
1061        assert_eq!(patterns[0].label(), "short");
1062    }
1063
1064    // ---- looks_encrypted ----
1065
1066    #[test]
1067    fn looks_encrypted_json_plaintext() {
1068        assert!(!looks_encrypted(sample_json().as_bytes()));
1069    }
1070
1071    #[test]
1072    fn looks_encrypted_yaml_plaintext() {
1073        assert!(!looks_encrypted(sample_yaml().as_bytes()));
1074    }
1075
1076    #[test]
1077    fn looks_encrypted_toml_plaintext() {
1078        assert!(!looks_encrypted(sample_toml().as_bytes()));
1079    }
1080
1081    #[test]
1082    fn looks_encrypted_actual_encrypted() {
1083        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1084        assert!(looks_encrypted(&encrypted));
1085    }
1086
1087    #[test]
1088    fn looks_encrypted_too_short() {
1089        assert!(!looks_encrypted(&[0u8; 10]));
1090    }
1091
1092    // ---- load_secrets_auto ----
1093
1094    #[test]
1095    fn auto_load_plaintext_json() {
1096        let data = sample_json().as_bytes();
1097        let (((pats, errs), _allow), was_enc) =
1098            load_secrets_auto(data, None, Some(SecretsFormat::Json), false).unwrap();
1099        assert!(!was_enc);
1100        assert_eq!(pats.len(), 2);
1101        assert!(errs.is_empty());
1102    }
1103
1104    #[test]
1105    fn auto_load_encrypted_json() {
1106        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1107        let (((pats, errs), _allow), was_enc) =
1108            load_secrets_auto(&encrypted, Some("pw"), Some(SecretsFormat::Json), false).unwrap();
1109        assert!(was_enc);
1110        assert_eq!(pats.len(), 2);
1111        assert!(errs.is_empty());
1112    }
1113
1114    #[test]
1115    fn auto_load_force_plaintext() {
1116        let data = sample_json().as_bytes();
1117        let (((pats, _), _allow), was_enc) =
1118            load_secrets_auto(data, None, Some(SecretsFormat::Json), true).unwrap();
1119        assert!(!was_enc);
1120        assert_eq!(pats.len(), 2);
1121    }
1122
1123    #[test]
1124    fn auto_load_encrypted_no_password_fails() {
1125        let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1126        let result = load_secrets_auto(&encrypted, None, None, false);
1127        assert!(result.is_err());
1128    }
1129
1130    #[test]
1131    fn parse_secrets_rejects_oversized_input() {
1132        // Construct input just over the 10 MiB cap.
1133        let oversized = vec![b' '; MAX_SECRETS_PLAINTEXT_BYTES + 1];
1134        let result = parse_secrets(&oversized, None);
1135        assert!(result.is_err());
1136        let msg = result.unwrap_err().to_string();
1137        assert!(
1138            msg.contains("exceeding") || msg.contains("limit"),
1139            "unexpected error message: {msg}"
1140        );
1141    }
1142
1143    #[test]
1144    fn parse_secrets_accepts_input_at_limit() {
1145        // Valid JSON just at the cap boundary — should succeed or fail on
1146        // parse, not on the size check. We use a tiny valid payload here
1147        // to confirm the size gate does not block small files.
1148        let tiny = b"[]";
1149        let result = parse_secrets(tiny, Some(SecretsFormat::Json));
1150        assert!(
1151            result.is_ok(),
1152            "unexpected error: {:?}",
1153            result.unwrap_err()
1154        );
1155    }
1156
1157    #[test]
1158    fn truncate_label_at_boundary() {
1159        let short = "a".repeat(32);
1160        assert_eq!(truncate_label(&short), short);
1161
1162        let long = "a".repeat(33);
1163        let truncated = truncate_label(&long);
1164        assert!(truncated.ends_with('…'), "expected ellipsis: {truncated}");
1165        // Character count (not byte count) must be within the limit.
1166        // The trailing '…' is 1 char; the rest must be < MAX_LABEL_CHARS.
1167        assert!(
1168            truncated.chars().count() <= MAX_LABEL_CHARS,
1169            "char count {} exceeds limit: {truncated}",
1170            truncated.chars().count()
1171        );
1172    }
1173
1174    // ---- Multi-value allow entries ----
1175
1176    #[test]
1177    fn allow_single_pattern_field() {
1178        let json = r#"[{"kind":"allow","pattern":"localhost"}]"#;
1179        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1180        let patterns = extract_allow_patterns(&entries);
1181        assert_eq!(patterns, vec!["localhost"]);
1182    }
1183
1184    #[test]
1185    fn allow_values_list_used_instead_of_pattern() {
1186        let json = r#"[{"kind":"allow","values":["localhost","true","false","null"]}]"#;
1187        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1188        let patterns = extract_allow_patterns(&entries);
1189        assert_eq!(patterns, vec!["localhost", "true", "false", "null"]);
1190    }
1191
1192    #[test]
1193    fn allow_values_list_yaml() {
1194        let yaml =
1195            "- kind: allow\n  values:\n    - localhost\n    - \"127.0.0.1\"\n    - \"0.0.0.0\"\n";
1196        let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1197        let patterns = extract_allow_patterns(&entries);
1198        assert_eq!(patterns, vec!["localhost", "127.0.0.1", "0.0.0.0"]);
1199    }
1200
1201    #[test]
1202    fn allow_values_list_toml() {
1203        let toml = "[[secrets]]\nkind = \"allow\"\nvalues = [\"localhost\", \"true\", \"false\"]\n";
1204        let entries = parse_secrets(toml.as_bytes(), Some(SecretsFormat::Toml)).unwrap();
1205        let patterns = extract_allow_patterns(&entries);
1206        assert_eq!(patterns, vec!["localhost", "true", "false"]);
1207    }
1208
1209    #[test]
1210    fn allow_mixed_single_and_multi_value_entries() {
1211        let json = r#"[
1212            {"kind":"allow","pattern":"localhost"},
1213            {"kind":"allow","values":["true","false","null"]},
1214            {"kind":"allow","pattern":"*.internal"}
1215        ]"#;
1216        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1217        let patterns = extract_allow_patterns(&entries);
1218        assert_eq!(
1219            patterns,
1220            vec!["localhost", "true", "false", "null", "*.internal"]
1221        );
1222    }
1223
1224    #[test]
1225    fn allow_entries_skipped_by_entries_to_patterns() {
1226        let json = r#"[
1227            {"pattern":"secret","kind":"literal"},
1228            {"kind":"allow","values":["localhost","true"]}
1229        ]"#;
1230        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1231        let (patterns, errors) = entries_to_patterns(&entries);
1232        assert_eq!(patterns.len(), 1);
1233        assert!(errors.is_empty());
1234        assert_eq!(patterns[0].label(), "secret");
1235    }
1236
1237    #[test]
1238    fn allow_empty_values_falls_back_to_pattern() {
1239        // An entry with an empty `values` list should still use `pattern`.
1240        let json = r#"[{"kind":"allow","pattern":"localhost","values":[]}]"#;
1241        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1242        let patterns = extract_allow_patterns(&entries);
1243        assert_eq!(patterns, vec!["localhost"]);
1244    }
1245
1246    // ── kind: field-name ─────────────────────────────────────────────────────
1247
1248    #[test]
1249    fn field_name_entries_skipped_by_entries_to_patterns() {
1250        // kind:field-name entries must not produce ScanPatterns — they are
1251        // handled separately as FieldNameSignals injected into profiles.
1252        let json = r#"[
1253            {"pattern":"secret","kind":"literal"},
1254            {"pattern":"^password$","kind":"field-name","threshold":3.0}
1255        ]"#;
1256        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1257        let (patterns, errors) = entries_to_patterns(&entries);
1258        assert_eq!(
1259            patterns.len(),
1260            1,
1261            "only the literal entry should produce a pattern"
1262        );
1263        assert!(errors.is_empty());
1264        assert_eq!(patterns[0].label(), "secret");
1265    }
1266
1267    #[test]
1268    fn field_name_entry_parses_correctly() {
1269        let yaml = "- kind: field-name\n  pattern: \"^(password|secret)$\"\n  threshold: 3.0\n  label: my-signal\n";
1270        let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1271        assert_eq!(entries.len(), 1);
1272        assert_eq!(entries[0].kind, "field-name");
1273        assert_eq!(entries[0].pattern, "^(password|secret)$");
1274        assert_eq!(entries[0].threshold, Some(3.0));
1275        assert_eq!(entries[0].label, Some("my-signal".into()));
1276    }
1277
1278    #[test]
1279    fn field_name_entry_not_extracted_as_allow_pattern() {
1280        // kind:field-name entries must not bleed into the allowlist.
1281        let json = r#"[{"pattern":"^password$","kind":"field-name"}]"#;
1282        let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1283        let allow = extract_allow_patterns(&entries);
1284        assert!(allow.is_empty());
1285    }
1286}