1use crate::category::Category;
78use crate::error::{Result, SanitizeError};
79use crate::scanner::ScanPattern;
80
81pub type PatternCompileResult = (Vec<ScanPattern>, Vec<(usize, SanitizeError)>);
84
85use aes_gcm::aead::{Aead, KeyInit};
86use aes_gcm::{Aes256Gcm, Nonce};
87use argon2::{Algorithm, Argon2, Params, Version};
88use rand::RngCore;
89use serde::{Deserialize, Serialize};
90use zeroize::{Zeroize, Zeroizing};
91
92const MAGIC: &[u8; 5] = b"SCOUR";
101
102const FORMAT_VERSION: u8 = 1;
106
107const HEADER_LEN: usize = MAGIC.len() + 1;
109
110const SALT_LEN: usize = 32;
112
113const NONCE_LEN: usize = 12;
115
116const ARGON2_M_COST: u32 = 19 * 1024;
118const ARGON2_T_COST: u32 = 2;
120const ARGON2_P_COST: u32 = 1;
122
123const MIN_ENCRYPTED_LEN: usize = HEADER_LEN + SALT_LEN + NONCE_LEN + 16;
125
126const MAX_SECRETS_PLAINTEXT_BYTES: usize = 10 * 1024 * 1024; #[derive(Debug, Clone, Serialize, Deserialize)]
142#[non_exhaustive]
143pub struct SecretEntry {
144 #[serde(default)]
149 pub pattern: String,
150
151 #[serde(default = "default_kind")]
159 pub kind: String,
160
161 #[serde(default = "default_category")]
167 pub category: String,
168
169 #[serde(default)]
175 pub label: Option<String>,
176
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub values: Vec<String>,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub min_length: Option<usize>,
194
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub max_length: Option<usize>,
198
199 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub threshold: Option<f64>,
203
204 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub charset: Option<String>,
208}
209
210impl SecretEntry {
211 #[must_use]
215 pub fn new(
216 pattern: impl Into<String>,
217 kind: impl Into<String>,
218 category: impl Into<String>,
219 ) -> Self {
220 Self {
221 pattern: pattern.into(),
222 kind: kind.into(),
223 category: category.into(),
224 label: None,
225 values: Vec::new(),
226 min_length: None,
227 max_length: None,
228 threshold: None,
229 charset: None,
230 }
231 }
232
233 #[must_use]
235 pub fn with_label(mut self, label: impl Into<String>) -> Self {
236 self.label = Some(label.into());
237 self
238 }
239
240 #[must_use]
242 pub fn with_values(mut self, values: Vec<String>) -> Self {
243 self.values = values;
244 self
245 }
246
247 #[must_use]
250 pub fn with_length_bounds(mut self, min: Option<usize>, max: Option<usize>) -> Self {
251 self.min_length = min;
252 self.max_length = max;
253 self
254 }
255
256 #[must_use]
259 pub fn with_threshold(mut self, threshold: f64) -> Self {
260 self.threshold = Some(threshold);
261 self
262 }
263
264 #[must_use]
266 pub fn with_charset(mut self, charset: impl Into<String>) -> Self {
267 self.charset = Some(charset.into());
268 self
269 }
270}
271
272impl Drop for SecretEntry {
273 fn drop(&mut self) {
274 self.pattern.zeroize();
275 self.kind.zeroize();
276 self.category.zeroize();
277 if let Some(ref mut l) = self.label {
278 l.zeroize();
279 }
280 for v in &mut self.values {
281 v.zeroize();
282 }
283 if let Some(ref mut s) = self.charset {
284 s.zeroize();
285 }
286 }
287}
288
289fn default_kind() -> String {
290 "literal".into()
291}
292
293fn default_category() -> String {
294 "custom:secret".into()
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299#[non_exhaustive]
300pub enum SecretsFormat {
301 Json,
302 Yaml,
303 Toml,
304}
305
306impl SecretsFormat {
307 pub fn from_extension(path: &str) -> Option<Self> {
309 let base = path.strip_suffix(".enc").unwrap_or(path);
311 let ext = std::path::Path::new(base).extension();
312 if ext.is_some_and(|e| e.eq_ignore_ascii_case("json")) {
313 Some(Self::Json)
314 } else if ext
315 .is_some_and(|e| e.eq_ignore_ascii_case("yaml") || e.eq_ignore_ascii_case("yml"))
316 {
317 Some(Self::Yaml)
318 } else if ext.is_some_and(|e| e.eq_ignore_ascii_case("toml")) {
319 Some(Self::Toml)
320 } else {
321 None
322 }
323 }
324
325 pub fn detect(content: &[u8]) -> Self {
327 let s = String::from_utf8_lossy(content);
328 let first_meaningful = s
332 .lines()
333 .map(str::trim)
334 .find(|l| !l.is_empty() && !l.starts_with('#'))
335 .unwrap_or("");
336 if first_meaningful.starts_with('[') || first_meaningful.starts_with('{') {
337 Self::Json
341 } else if first_meaningful.starts_with('-') || first_meaningful.starts_with("---") {
342 Self::Yaml
343 } else {
344 Self::Toml
346 }
347 }
348}
349
350#[derive(Deserialize)]
356struct TomlSecrets {
357 secrets: Vec<SecretEntry>,
358}
359
360#[derive(Serialize)]
362struct TomlSecretsRef<'a> {
363 secrets: &'a [SecretEntry],
364}
365
366pub fn derive_key_argon2(password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
385 let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32))
386 .map_err(|e| SanitizeError::SecretsCipherError(format!("argon2 params: {e}")))?;
387 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
388 let mut key = Zeroizing::new([0u8; 32]);
389 argon2
390 .hash_password_into(password, salt, key.as_mut())
391 .map_err(|e| SanitizeError::SecretsCipherError(format!("argon2 kdf: {e}")))?;
392 Ok(key)
393}
394
395pub fn encrypt_secrets(plaintext: &[u8], password: &str) -> Result<Vec<u8>> {
420 if password.is_empty() {
421 return Err(SanitizeError::SecretsEmptyPassword);
422 }
423
424 let mut rng = rand::rng();
425
426 let mut salt = [0u8; SALT_LEN];
428 rng.fill_bytes(&mut salt);
429
430 let mut nonce_bytes = [0u8; NONCE_LEN];
431 rng.fill_bytes(&mut nonce_bytes);
432 let nonce = Nonce::from_slice(&nonce_bytes);
433
434 let key = derive_key_argon2(password.as_bytes(), &salt)?;
436 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
437 .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
438
439 let ciphertext = cipher
441 .encrypt(nonce, plaintext)
442 .map_err(|e| SanitizeError::SecretsCipherError(format!("encryption: {}", e)))?;
443
444 let mut output = Vec::with_capacity(HEADER_LEN + SALT_LEN + NONCE_LEN + ciphertext.len());
446 output.extend_from_slice(MAGIC);
447 output.push(FORMAT_VERSION);
448 output.extend_from_slice(&salt);
449 output.extend_from_slice(&nonce_bytes);
450 output.extend_from_slice(&ciphertext);
451
452 Ok(output)
453}
454
455pub fn decrypt_secrets(encrypted: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>> {
476 if encrypted.len() < MIN_ENCRYPTED_LEN {
477 return Err(SanitizeError::SecretsTooShort);
478 }
479 if &encrypted[..MAGIC.len()] != MAGIC || encrypted[MAGIC.len()] != FORMAT_VERSION {
480 return Err(SanitizeError::SecretsUnrecognizedFormat);
481 }
482
483 let body = &encrypted[HEADER_LEN..];
484 let salt = &body[..SALT_LEN];
485 let nonce_bytes = &body[SALT_LEN..SALT_LEN + NONCE_LEN];
486 let ciphertext = &body[SALT_LEN + NONCE_LEN..];
487
488 let nonce = Nonce::from_slice(nonce_bytes);
489
490 let key = derive_key_argon2(password.as_bytes(), salt)?;
491 let cipher = Aes256Gcm::new_from_slice(key.as_ref())
492 .map_err(|e| SanitizeError::SecretsCipherError(format!("cipher init: {}", e)))?;
493
494 let plaintext = cipher
495 .decrypt(nonce, ciphertext)
496 .map_err(|_| SanitizeError::SecretsDecryptFailed)?;
497
498 Ok(Zeroizing::new(plaintext))
499}
500
501pub fn parse_secrets(plaintext: &[u8], format: Option<SecretsFormat>) -> Result<Vec<SecretEntry>> {
516 if plaintext.len() > MAX_SECRETS_PLAINTEXT_BYTES {
517 return Err(SanitizeError::SecretsFormatError {
518 format: "secrets file".into(),
519 message: format!(
520 "file is {} bytes, exceeding the {} byte limit — \
521 secrets files should be small YAML/JSON/TOML pattern lists",
522 plaintext.len(),
523 MAX_SECRETS_PLAINTEXT_BYTES,
524 ),
525 });
526 }
527 let fmt = format.unwrap_or_else(|| SecretsFormat::detect(plaintext));
528 let text = std::str::from_utf8(plaintext)
529 .map_err(|e| SanitizeError::SecretsInvalidUtf8(e.to_string()))?;
530
531 match fmt {
536 SecretsFormat::Json => serde_json::from_str(text).map_err(|e| {
537 let loc = (e.line() > 0).then(|| (e.line(), e.column()));
538 secrets_parse_error("JSON", loc)
539 }),
540 SecretsFormat::Yaml => serde_yaml_ng::from_str(text)
541 .map_err(|e| secrets_parse_error("YAML", e.location().map(|l| (l.line(), l.column())))),
542 SecretsFormat::Toml => {
543 let wrapper: TomlSecrets = toml::from_str(text).map_err(|e| {
544 secrets_parse_error("TOML", e.span().map(|s| line_col_at(text, s.start)))
545 })?;
546 Ok(wrapper.secrets)
547 }
548 }
549}
550
551fn secrets_parse_error(format: &str, location: Option<(usize, usize)>) -> SanitizeError {
554 let loc = location.map_or_else(String::new, |(line, col)| {
555 format!(" at line {line}, column {col}")
556 });
557 SanitizeError::SecretsFormatError {
558 format: format.into(),
559 message: format!(
560 "invalid secrets file{loc} \
561 (parser details withheld — secrets file content is never echoed)"
562 ),
563 }
564}
565
566pub(crate) fn line_col_at(text: &str, offset: usize) -> (usize, usize) {
570 let offset = offset.min(text.len());
571 let before = &text.as_bytes()[..offset];
572 let line = bytecount::count(before, b'\n') + 1;
573 let col = offset
574 - before
575 .iter()
576 .rposition(|&b| b == b'\n')
577 .map_or(0, |p| p + 1)
578 + 1;
579 (line, col)
580}
581
582pub fn serialize_secrets(entries: &[SecretEntry], format: SecretsFormat) -> Result<Vec<u8>> {
590 match format {
591 SecretsFormat::Json => {
592 serde_json::to_vec_pretty(entries).map_err(|e| SanitizeError::SecretsFormatError {
593 format: "JSON-serialize".into(),
594 message: e.to_string(),
595 })
596 }
597 SecretsFormat::Yaml => serde_yaml_ng::to_string(entries)
598 .map(|s| s.into_bytes())
599 .map_err(|e| SanitizeError::SecretsFormatError {
600 format: "YAML-serialize".into(),
601 message: e.to_string(),
602 }),
603 SecretsFormat::Toml => {
604 let wrapper = TomlSecretsRef { secrets: entries };
605 toml::to_string_pretty(&wrapper)
606 .map(|s| s.into_bytes())
607 .map_err(|e| SanitizeError::SecretsFormatError {
608 format: "TOML-serialize".into(),
609 message: e.to_string(),
610 })
611 }
612 }
613}
614
615pub fn parse_category(s: &str) -> Category {
626 match s {
627 "email" => Category::Email,
628 "name" => Category::Name,
629 "phone" => Category::Phone,
630 "ipv4" => Category::IpV4,
631 "ipv6" => Category::IpV6,
632 "credit_card" => Category::CreditCard,
633 "ssn" => Category::Ssn,
634 "hostname" => Category::Hostname,
635 "mac_address" => Category::MacAddress,
636 "container_id" => Category::ContainerId,
637 "uuid" => Category::Uuid,
638 "jwt" => Category::Jwt,
639 "auth_token" => Category::AuthToken,
640 "file_path" => Category::FilePath,
641 "windows_sid" => Category::WindowsSid,
642 "url" => Category::Url,
643 "aws_arn" => Category::AwsArn,
644 "azure_resource_id" => Category::AzureResourceId,
645 other => {
646 let tag = if let Some(tag) = other.strip_prefix("custom:") {
647 tag
648 } else {
649 tracing::warn!(
653 category = other,
654 "unknown category — treated as custom:{}; use the \
655 `custom:` prefix to silence this warning",
656 other
657 );
658 other
659 };
660 Category::Custom(tag.into())
661 }
662 }
663}
664
665pub fn extract_allow_patterns(entries: &[SecretEntry]) -> Vec<String> {
690 let mut patterns = Vec::new();
691 for entry in entries.iter().filter(|e| e.kind == "allow") {
692 if !entry.values.is_empty() {
693 patterns.extend(entry.values.iter().cloned());
694 } else if !entry.pattern.is_empty() {
695 patterns.push(entry.pattern.clone());
696 }
697 }
698 patterns
699}
700
701pub fn entries_to_patterns(entries: &[SecretEntry]) -> PatternCompileResult {
709 let mut patterns = Vec::with_capacity(entries.len());
710 let mut errors = Vec::new();
711
712 for (i, entry) in entries.iter().enumerate() {
713 if entry.kind == "allow"
714 || entry.kind == "entropy"
715 || entry.kind == "field-name"
716 || entry.pattern.is_empty()
717 {
718 continue;
719 }
720 let category = parse_category(&entry.category);
721 let label = entry.label.clone().unwrap_or_else(|| {
727 if entry.kind == "literal" {
728 format!("literal:{}", entry.category)
729 } else {
730 truncate_label(&entry.pattern)
731 }
732 });
733
734 let result = match entry.kind.as_str() {
735 "regex" => ScanPattern::from_regex(&entry.pattern, category, label),
736 "literal" => ScanPattern::from_literal(&entry.pattern, category, label),
737 other => {
738 errors.push((
739 i,
740 SanitizeError::InvalidConfig(format!(
741 "unknown kind {:?} — expected \"literal\", \"regex\", \"allow\", \"entropy\", or \"field-name\"",
742 other
743 )),
744 ));
745 continue;
746 }
747 };
748
749 match result {
750 Ok(pat) => {
751 let min = entry.min_length.unwrap_or(pat.min_length);
755 let max = entry.max_length.unwrap_or(pat.max_length);
756 patterns.push(pat.with_length_bounds(min, max));
757 }
758 Err(e) => errors.push((i, e)),
759 }
760 }
761
762 (patterns, errors)
763}
764
765const MAX_LABEL_CHARS: usize = 32;
766
767fn truncate_label(s: &str) -> String {
769 if s.len() <= MAX_LABEL_CHARS {
770 s.to_string()
771 } else {
772 let cut = s
775 .char_indices()
776 .nth(MAX_LABEL_CHARS - 1)
777 .map_or(s.len(), |(i, _)| i);
778 format!("{}…", &s[..cut])
779 }
780}
781
782pub fn load_encrypted_secrets(
811 encrypted_bytes: &[u8],
812 password: &str,
813 format: Option<SecretsFormat>,
814) -> Result<(PatternCompileResult, Vec<String>)> {
815 let plaintext = decrypt_secrets(encrypted_bytes, password)?;
816 let entries = parse_secrets(&plaintext, format)?;
817 let allow = extract_allow_patterns(&entries);
818 let result = entries_to_patterns(&entries);
819 drop(entries);
822 Ok((result, allow))
823}
824
825pub fn load_plaintext_secrets(
847 plaintext: &[u8],
848 format: Option<SecretsFormat>,
849) -> Result<(PatternCompileResult, Vec<String>)> {
850 let entries = parse_secrets(plaintext, format)?;
851 let allow = extract_allow_patterns(&entries);
852 let result = entries_to_patterns(&entries);
853 drop(entries);
856 Ok((result, allow))
857}
858
859#[must_use]
867pub fn looks_encrypted(data: &[u8]) -> bool {
868 data.len() >= HEADER_LEN && &data[..MAGIC.len()] == MAGIC && data[MAGIC.len()] == FORMAT_VERSION
869}
870
871pub fn load_secrets_auto(
890 data: &[u8],
891 password: Option<&str>,
892 format: Option<SecretsFormat>,
893 force_plaintext: bool,
894) -> Result<AutoLoadedSecrets> {
895 let (result, allow_patterns, was_encrypted) = if force_plaintext || !looks_encrypted(data) {
896 let (result, allow) = load_plaintext_secrets(data, format)?;
897 (result, allow, false)
898 } else {
899 let pw = password.ok_or(SanitizeError::SecretsPasswordRequired)?;
900 let (result, allow) = load_encrypted_secrets(data, pw, format)?;
901 (result, allow, true)
902 };
903 let (patterns, warnings) = result;
904 Ok(AutoLoadedSecrets {
905 patterns,
906 warnings,
907 allow_patterns,
908 was_encrypted,
909 })
910}
911
912#[derive(Debug)]
922#[non_exhaustive]
923pub struct AutoLoadedSecrets {
924 pub patterns: Vec<ScanPattern>,
926 pub warnings: Vec<(usize, SanitizeError)>,
928 pub allow_patterns: Vec<String>,
930 pub was_encrypted: bool,
932}
933
934#[cfg(test)]
939mod tests {
940 use super::*;
941
942 fn sample_json() -> &'static str {
943 r#"[
944 {
945 "pattern": "alice@corp\\.com",
946 "kind": "regex",
947 "category": "email",
948 "label": "alice_email"
949 },
950 {
951 "pattern": "sk-proj-abc123secret",
952 "kind": "literal",
953 "category": "custom:api_key",
954 "label": "openai_key"
955 }
956 ]"#
957 }
958
959 fn sample_yaml() -> &'static str {
960 r#"- pattern: "alice@corp\\.com"
961 kind: regex
962 category: email
963 label: alice_email
964- pattern: sk-proj-abc123secret
965 kind: literal
966 category: "custom:api_key"
967 label: openai_key
968"#
969 }
970
971 fn sample_toml() -> &'static str {
972 r#"[[secrets]]
973pattern = "alice@corp\\.com"
974kind = "regex"
975category = "email"
976label = "alice_email"
977
978[[secrets]]
979pattern = "sk-proj-abc123secret"
980kind = "literal"
981category = "custom:api_key"
982label = "openai_key"
983"#
984 }
985
986 #[test]
989 fn parse_json_entries() {
990 let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
991 assert_eq!(entries.len(), 2);
992 assert_eq!(entries[0].kind, "regex");
993 assert_eq!(entries[0].category, "email");
994 assert_eq!(entries[1].kind, "literal");
995 }
996
997 #[test]
998 fn parse_yaml_entries() {
999 let entries = parse_secrets(sample_yaml().as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1000 assert_eq!(entries.len(), 2);
1001 assert_eq!(entries[0].label, Some("alice_email".into()));
1002 }
1003
1004 #[test]
1005 fn parse_toml_entries() {
1006 let entries = parse_secrets(sample_toml().as_bytes(), Some(SecretsFormat::Toml)).unwrap();
1007 assert_eq!(entries.len(), 2);
1008 assert_eq!(entries[1].pattern, "sk-proj-abc123secret");
1009 }
1010
1011 #[test]
1012 fn parse_auto_detect_json() {
1013 let entries = parse_secrets(sample_json().as_bytes(), None).unwrap();
1014 assert_eq!(entries.len(), 2);
1015 }
1016
1017 #[test]
1018 fn parse_auto_detect_yaml() {
1019 let entries = parse_secrets(sample_yaml().as_bytes(), None).unwrap();
1020 assert_eq!(entries.len(), 2);
1021 }
1022
1023 #[test]
1026 fn parse_builtin_categories() {
1027 assert_eq!(parse_category("email"), Category::Email);
1028 assert_eq!(parse_category("ipv4"), Category::IpV4);
1029 assert_eq!(parse_category("ssn"), Category::Ssn);
1030 }
1031
1032 #[test]
1033 fn parse_custom_category() {
1034 match parse_category("custom:api_key") {
1035 Category::Custom(tag) => assert_eq!(tag.as_str(), "api_key"),
1036 other => panic!("expected Custom, got {:?}", other),
1037 }
1038 }
1039
1040 #[test]
1041 fn parse_unknown_category_becomes_custom() {
1042 match parse_category("foobar") {
1043 Category::Custom(tag) => assert_eq!(tag.as_str(), "foobar"),
1044 other => panic!("expected Custom, got {:?}", other),
1045 }
1046 }
1047
1048 #[test]
1051 fn entries_to_patterns_success() {
1052 let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1053 let (patterns, errors) = entries_to_patterns(&entries);
1054 assert_eq!(patterns.len(), 2);
1055 assert!(errors.is_empty());
1056 }
1057
1058 #[test]
1059 fn entries_to_patterns_applies_length_bounds() {
1060 let json = r#"[
1061 {"pattern": "[0-9]+", "kind": "regex", "category": "custom:num",
1062 "min_length": 4, "max_length": 8},
1063 {"pattern": "[a-z]+", "kind": "regex", "category": "custom:word"}
1064 ]"#;
1065 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1066 let (patterns, errors) = entries_to_patterns(&entries);
1067 assert!(errors.is_empty());
1068 assert_eq!(patterns.len(), 2);
1069 assert_eq!(patterns[0].min_length, 4);
1070 assert_eq!(patterns[0].max_length, 8);
1071 assert_eq!(patterns[1].min_length, 0);
1073 assert_eq!(patterns[1].max_length, usize::MAX);
1074 }
1075
1076 #[test]
1077 fn entries_to_patterns_bad_regex() {
1078 let json = r#"[{"pattern": "[invalid(", "kind": "regex", "category": "email"}]"#;
1079 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1080 let (patterns, errors) = entries_to_patterns(&entries);
1081 assert!(patterns.is_empty());
1082 assert_eq!(errors.len(), 1);
1083 assert_eq!(errors[0].0, 0);
1084 }
1085
1086 #[test]
1089 fn encrypt_decrypt_roundtrip() {
1090 let plaintext = sample_json().as_bytes();
1091 let password = "test-password-42";
1092
1093 let encrypted = encrypt_secrets(plaintext, password).unwrap();
1094
1095 assert!(encrypted.len() > plaintext.len());
1097
1098 let decrypted = decrypt_secrets(&encrypted, password).unwrap();
1099 assert_eq!(decrypted.as_slice(), plaintext);
1100 }
1101
1102 #[test]
1103 fn decrypt_wrong_password_fails() {
1104 let plaintext = b"hello";
1105 let encrypted = encrypt_secrets(plaintext, "correct").unwrap();
1106 let result = decrypt_secrets(&encrypted, "wrong");
1107 assert!(result.is_err());
1108 }
1109
1110 #[test]
1111 fn decrypt_truncated_blob_fails() {
1112 let result = decrypt_secrets(&[0u8; 10], "any");
1113 assert!(result.is_err());
1114 }
1115
1116 #[test]
1117 fn decrypt_tampered_blob_fails() {
1118 let plaintext = b"hello world";
1119 let mut encrypted = encrypt_secrets(plaintext, "pw").unwrap();
1120 let last = encrypted.len() - 1;
1122 encrypted[last] ^= 0xFF;
1123 let result = decrypt_secrets(&encrypted, "pw");
1124 assert!(result.is_err());
1125 }
1126
1127 #[test]
1128 fn encrypt_empty_password_rejected() {
1129 let result = encrypt_secrets(b"hello", "");
1130 assert!(result.is_err());
1131 }
1132
1133 #[test]
1134 fn encrypt_emits_magic_and_version_header() {
1135 let encrypted = encrypt_secrets(b"hello", "pw").unwrap();
1136 assert_eq!(&encrypted[..MAGIC.len()], MAGIC, "magic prefix");
1137 assert_eq!(encrypted[MAGIC.len()], FORMAT_VERSION, "version byte");
1138 }
1139
1140 #[test]
1141 fn decrypt_rejects_missing_magic() {
1142 let blob = vec![0u8; MIN_ENCRYPTED_LEN + 4];
1146 match decrypt_secrets(&blob, "pw") {
1147 Err(SanitizeError::SecretsUnrecognizedFormat) => {}
1148 other => panic!("expected SecretsUnrecognizedFormat, got {other:?}"),
1149 }
1150 }
1151
1152 #[test]
1153 fn decrypt_rejects_unsupported_version() {
1154 let mut encrypted = encrypt_secrets(b"hello", "pw").unwrap();
1155 encrypted[MAGIC.len()] = FORMAT_VERSION.wrapping_add(1);
1156 match decrypt_secrets(&encrypted, "pw") {
1157 Err(SanitizeError::SecretsUnrecognizedFormat) => {}
1158 other => panic!("expected SecretsUnrecognizedFormat, got {other:?}"),
1159 }
1160 }
1161
1162 #[test]
1163 fn looks_encrypted_requires_header() {
1164 assert!(!looks_encrypted(b"SCOUR_KEY = \"value\"\n"));
1168 assert!(!looks_encrypted(&[0u8; MIN_ENCRYPTED_LEN]));
1169 }
1170
1171 #[test]
1172 fn derive_key_argon2_is_deterministic_and_salt_sensitive() {
1173 let salt_a = [7u8; SALT_LEN];
1174 let salt_b = [9u8; SALT_LEN];
1175 let k1 = derive_key_argon2(b"password", &salt_a).unwrap();
1176 let k2 = derive_key_argon2(b"password", &salt_a).unwrap();
1177 let k3 = derive_key_argon2(b"password", &salt_b).unwrap();
1178 assert_eq!(*k1, *k2, "same password+salt must yield the same key");
1179 assert_ne!(*k1, *k3, "different salt must yield a different key");
1180 }
1181
1182 #[test]
1183 fn derive_key_argon2_rejects_short_salt() {
1184 assert!(derive_key_argon2(b"password", b"tiny").is_err());
1187 }
1188
1189 #[test]
1192 fn full_pipeline_json() {
1193 let plaintext = sample_json().as_bytes();
1194 let password = "pipeline-test";
1195
1196 let encrypted = encrypt_secrets(plaintext, password).unwrap();
1197 let ((patterns, errors), _allow) =
1198 load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Json)).unwrap();
1199
1200 assert_eq!(patterns.len(), 2);
1201 assert!(errors.is_empty());
1202 assert_eq!(patterns[0].label(), "alice_email");
1203 assert_eq!(patterns[1].label(), "openai_key");
1204 }
1205
1206 #[test]
1207 fn full_pipeline_yaml() {
1208 let plaintext = sample_yaml().as_bytes();
1209 let password = "yaml-test";
1210
1211 let encrypted = encrypt_secrets(plaintext, password).unwrap();
1212 let ((patterns, errors), _allow) =
1213 load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Yaml)).unwrap();
1214
1215 assert_eq!(patterns.len(), 2);
1216 assert!(errors.is_empty());
1217 }
1218
1219 #[test]
1220 fn full_pipeline_toml() {
1221 let plaintext = sample_toml().as_bytes();
1222 let password = "toml-test";
1223
1224 let encrypted = encrypt_secrets(plaintext, password).unwrap();
1225 let ((patterns, errors), _allow) =
1226 load_encrypted_secrets(&encrypted, password, Some(SecretsFormat::Toml)).unwrap();
1227
1228 assert_eq!(patterns.len(), 2);
1229 assert!(errors.is_empty());
1230 }
1231
1232 #[test]
1235 fn load_plaintext_secrets_works() {
1236 let ((patterns, errors), _allow) =
1237 load_plaintext_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1238 assert_eq!(patterns.len(), 2);
1239 assert!(errors.is_empty());
1240 }
1241
1242 #[test]
1245 fn serialize_roundtrip_json() {
1246 let entries = parse_secrets(sample_json().as_bytes(), Some(SecretsFormat::Json)).unwrap();
1247 let serialized = serialize_secrets(&entries, SecretsFormat::Json).unwrap();
1248 let reparsed = parse_secrets(&serialized, Some(SecretsFormat::Json)).unwrap();
1249 assert_eq!(entries.len(), reparsed.len());
1250 assert_eq!(entries[0].pattern, reparsed[0].pattern);
1251 }
1252
1253 #[test]
1256 fn format_from_extension() {
1257 assert_eq!(
1258 SecretsFormat::from_extension("secrets.json"),
1259 Some(SecretsFormat::Json)
1260 );
1261 assert_eq!(
1262 SecretsFormat::from_extension("secrets.json.enc"),
1263 Some(SecretsFormat::Json)
1264 );
1265 assert_eq!(
1266 SecretsFormat::from_extension("secrets.yaml"),
1267 Some(SecretsFormat::Yaml)
1268 );
1269 assert_eq!(
1270 SecretsFormat::from_extension("secrets.yml.enc"),
1271 Some(SecretsFormat::Yaml)
1272 );
1273 assert_eq!(
1274 SecretsFormat::from_extension("secrets.toml"),
1275 Some(SecretsFormat::Toml)
1276 );
1277 assert_eq!(SecretsFormat::from_extension("secrets.txt"), None);
1278 }
1279
1280 #[test]
1281 fn detect_yaml_with_leading_comment_header() {
1282 let content = "# Global scour-secrets allowlist — add patterns here.\n# Auto-loaded on every plain run.\n\n- pattern: foo\n kind: allow\n";
1286 assert_eq!(
1287 SecretsFormat::detect(content.as_bytes()),
1288 SecretsFormat::Yaml
1289 );
1290 }
1291
1292 #[test]
1293 fn detect_yaml_comment_header_parses_correctly() {
1294 let content = "# Global scour-secrets allowlist — add patterns or kind:regex entries here.\n# Auto-loaded on every plain run. Edit freely; deleted values take effect immediately.\n\n- pattern: ''\n kind: allow\n category: ''\n values:\n - localhost\n - 127.0.0.1\n";
1296 let entries = parse_secrets(content.as_bytes(), None)
1297 .expect("auto-provisioned secrets file with comment header must parse");
1298 assert_eq!(entries.len(), 1);
1299 assert_eq!(entries[0].kind, "allow");
1300 assert!(entries[0].values.contains(&"localhost".to_string()));
1301 }
1302
1303 #[test]
1304 fn detect_json_array() {
1305 assert_eq!(
1306 SecretsFormat::detect(b"[{\"pattern\": \"foo\"}]"),
1307 SecretsFormat::Json
1308 );
1309 }
1310
1311 #[test]
1312 fn detect_toml_fallback() {
1313 assert_eq!(
1315 SecretsFormat::detect(b"# toml comment\nkey = \"value\""),
1316 SecretsFormat::Toml
1317 );
1318 }
1319
1320 #[test]
1323 fn default_kind_is_literal() {
1324 let json = r#"[{"pattern": "foo"}]"#;
1325 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1326 assert_eq!(entries[0].kind, "literal");
1327 }
1328
1329 #[test]
1330 fn default_category_is_custom_secret() {
1331 let json = r#"[{"pattern": "foo"}]"#;
1332 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1333 assert_eq!(entries[0].category, "custom:secret");
1334 }
1335
1336 #[test]
1337 fn literal_default_label_is_category_not_value() {
1338 let json = r#"[{"pattern": "short"}]"#;
1342 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1343 let (patterns, _) = entries_to_patterns(&entries);
1344 assert_eq!(patterns[0].label(), "literal:custom:secret");
1345 assert!(!patterns[0].label().contains("short"));
1346 }
1347
1348 #[test]
1349 fn regex_default_label_is_pattern() {
1350 let json = r#"[{"pattern": "ab[0-9]+", "kind": "regex"}]"#;
1353 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1354 let (patterns, _) = entries_to_patterns(&entries);
1355 assert_eq!(patterns[0].label(), "ab[0-9]+");
1356 }
1357
1358 #[test]
1361 fn looks_encrypted_json_plaintext() {
1362 assert!(!looks_encrypted(sample_json().as_bytes()));
1363 }
1364
1365 #[test]
1366 fn looks_encrypted_yaml_plaintext() {
1367 assert!(!looks_encrypted(sample_yaml().as_bytes()));
1368 }
1369
1370 #[test]
1371 fn looks_encrypted_toml_plaintext() {
1372 assert!(!looks_encrypted(sample_toml().as_bytes()));
1373 }
1374
1375 #[test]
1376 fn looks_encrypted_actual_encrypted() {
1377 let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1378 assert!(looks_encrypted(&encrypted));
1379 }
1380
1381 #[test]
1382 fn looks_encrypted_too_short() {
1383 assert!(!looks_encrypted(&[0u8; 10]));
1384 }
1385
1386 #[test]
1389 fn auto_load_plaintext_json() {
1390 let data = sample_json().as_bytes();
1391 let loaded = load_secrets_auto(data, None, Some(SecretsFormat::Json), false).unwrap();
1392 assert!(!loaded.was_encrypted);
1393 assert_eq!(loaded.patterns.len(), 2);
1394 assert!(loaded.warnings.is_empty());
1395 }
1396
1397 #[test]
1398 fn auto_load_encrypted_json() {
1399 let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1400 let loaded =
1401 load_secrets_auto(&encrypted, Some("pw"), Some(SecretsFormat::Json), false).unwrap();
1402 assert!(loaded.was_encrypted);
1403 assert_eq!(loaded.patterns.len(), 2);
1404 assert!(loaded.warnings.is_empty());
1405 }
1406
1407 #[test]
1408 fn auto_load_force_plaintext() {
1409 let data = sample_json().as_bytes();
1410 let loaded = load_secrets_auto(data, None, Some(SecretsFormat::Json), true).unwrap();
1411 assert!(!loaded.was_encrypted);
1412 assert_eq!(loaded.patterns.len(), 2);
1413 }
1414
1415 #[test]
1416 fn auto_load_encrypted_no_password_fails() {
1417 let encrypted = encrypt_secrets(sample_json().as_bytes(), "pw").unwrap();
1418 let result = load_secrets_auto(&encrypted, None, None, false);
1419 assert!(result.is_err());
1420 }
1421
1422 const LEAK_MARKER: &str = "SEKRET-MARKER-0xD34DB33F";
1426
1427 #[track_caller]
1428 fn assert_error_omits_marker(result: Result<Vec<SecretEntry>>) {
1429 let msg = result
1430 .expect_err("malformed input must fail to parse")
1431 .to_string();
1432 assert!(
1433 !msg.contains(LEAK_MARKER),
1434 "parse error echoed secrets file content: {msg}"
1435 );
1436 assert!(msg.contains("line"), "expected location info, got: {msg}");
1437 }
1438
1439 #[test]
1440 fn toml_syntax_error_omits_content() {
1441 let bad = format!("secrets = [\n{{ pattern = \"{LEAK_MARKER}\", kind = }}\n]");
1444 assert_error_omits_marker(parse_secrets(bad.as_bytes(), Some(SecretsFormat::Toml)));
1445 }
1446
1447 #[test]
1448 fn json_data_error_omits_content() {
1449 let bad = format!(r#"[{{"pattern": "p", "min_length": "{LEAK_MARKER}"}}]"#);
1452 assert_error_omits_marker(parse_secrets(bad.as_bytes(), Some(SecretsFormat::Json)));
1453 }
1454
1455 #[test]
1456 fn yaml_data_error_omits_content() {
1457 let bad = format!("- pattern: p\n min_length: {LEAK_MARKER}\n");
1458 assert_error_omits_marker(parse_secrets(bad.as_bytes(), Some(SecretsFormat::Yaml)));
1459 }
1460
1461 #[test]
1462 fn yaml_syntax_error_omits_content() {
1463 let bad = format!("- pattern: \"{LEAK_MARKER}\n kind: literal\n");
1465 assert_error_omits_marker(parse_secrets(bad.as_bytes(), Some(SecretsFormat::Yaml)));
1466 }
1467
1468 #[test]
1469 fn line_col_at_positions() {
1470 let text = "ab\ncd\nef";
1471 assert_eq!(line_col_at(text, 0), (1, 1));
1472 assert_eq!(line_col_at(text, 4), (2, 2));
1473 assert_eq!(line_col_at(text, 6), (3, 1));
1474 assert_eq!(line_col_at(text, 100), (3, 3));
1476 }
1477
1478 #[test]
1479 fn parse_secrets_rejects_oversized_input() {
1480 let oversized = vec![b' '; MAX_SECRETS_PLAINTEXT_BYTES + 1];
1482 let result = parse_secrets(&oversized, None);
1483 assert!(result.is_err());
1484 let msg = result.unwrap_err().to_string();
1485 assert!(
1486 msg.contains("exceeding") || msg.contains("limit"),
1487 "unexpected error message: {msg}"
1488 );
1489 }
1490
1491 #[test]
1492 fn parse_secrets_accepts_input_at_limit() {
1493 let tiny = b"[]";
1497 let result = parse_secrets(tiny, Some(SecretsFormat::Json));
1498 assert!(
1499 result.is_ok(),
1500 "unexpected error: {:?}",
1501 result.unwrap_err()
1502 );
1503 }
1504
1505 #[test]
1506 fn truncate_label_at_boundary() {
1507 let short = "a".repeat(32);
1508 assert_eq!(truncate_label(&short), short);
1509
1510 let long = "a".repeat(33);
1511 let truncated = truncate_label(&long);
1512 assert!(truncated.ends_with('…'), "expected ellipsis: {truncated}");
1513 assert!(
1516 truncated.chars().count() <= MAX_LABEL_CHARS,
1517 "char count {} exceeds limit: {truncated}",
1518 truncated.chars().count()
1519 );
1520 }
1521
1522 #[test]
1525 fn allow_single_pattern_field() {
1526 let json = r#"[{"kind":"allow","pattern":"localhost"}]"#;
1527 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1528 let patterns = extract_allow_patterns(&entries);
1529 assert_eq!(patterns, vec!["localhost"]);
1530 }
1531
1532 #[test]
1533 fn allow_values_list_used_instead_of_pattern() {
1534 let json = r#"[{"kind":"allow","values":["localhost","true","false","null"]}]"#;
1535 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1536 let patterns = extract_allow_patterns(&entries);
1537 assert_eq!(patterns, vec!["localhost", "true", "false", "null"]);
1538 }
1539
1540 #[test]
1541 fn allow_values_list_yaml() {
1542 let yaml =
1543 "- kind: allow\n values:\n - localhost\n - \"127.0.0.1\"\n - \"0.0.0.0\"\n";
1544 let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1545 let patterns = extract_allow_patterns(&entries);
1546 assert_eq!(patterns, vec!["localhost", "127.0.0.1", "0.0.0.0"]);
1547 }
1548
1549 #[test]
1550 fn allow_values_list_toml() {
1551 let toml = "[[secrets]]\nkind = \"allow\"\nvalues = [\"localhost\", \"true\", \"false\"]\n";
1552 let entries = parse_secrets(toml.as_bytes(), Some(SecretsFormat::Toml)).unwrap();
1553 let patterns = extract_allow_patterns(&entries);
1554 assert_eq!(patterns, vec!["localhost", "true", "false"]);
1555 }
1556
1557 #[test]
1558 fn allow_mixed_single_and_multi_value_entries() {
1559 let json = r#"[
1560 {"kind":"allow","pattern":"localhost"},
1561 {"kind":"allow","values":["true","false","null"]},
1562 {"kind":"allow","pattern":"*.internal"}
1563 ]"#;
1564 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1565 let patterns = extract_allow_patterns(&entries);
1566 assert_eq!(
1567 patterns,
1568 vec!["localhost", "true", "false", "null", "*.internal"]
1569 );
1570 }
1571
1572 #[test]
1573 fn allow_entries_skipped_by_entries_to_patterns() {
1574 let json = r#"[
1575 {"pattern":"secret","kind":"literal"},
1576 {"kind":"allow","values":["localhost","true"]}
1577 ]"#;
1578 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1579 let (patterns, errors) = entries_to_patterns(&entries);
1580 assert_eq!(patterns.len(), 1);
1581 assert!(errors.is_empty());
1582 assert_eq!(patterns[0].label(), "literal:custom:secret");
1583 }
1584
1585 #[test]
1586 fn allow_empty_values_falls_back_to_pattern() {
1587 let json = r#"[{"kind":"allow","pattern":"localhost","values":[]}]"#;
1589 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1590 let patterns = extract_allow_patterns(&entries);
1591 assert_eq!(patterns, vec!["localhost"]);
1592 }
1593
1594 #[test]
1597 fn field_name_entries_skipped_by_entries_to_patterns() {
1598 let json = r#"[
1601 {"pattern":"secret","kind":"literal"},
1602 {"pattern":"^password$","kind":"field-name","threshold":3.0}
1603 ]"#;
1604 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1605 let (patterns, errors) = entries_to_patterns(&entries);
1606 assert_eq!(
1607 patterns.len(),
1608 1,
1609 "only the literal entry should produce a pattern"
1610 );
1611 assert!(errors.is_empty());
1612 assert_eq!(patterns[0].label(), "literal:custom:secret");
1613 }
1614
1615 #[test]
1616 fn field_name_entry_parses_correctly() {
1617 let yaml = "- kind: field-name\n pattern: \"^(password|secret)$\"\n threshold: 3.0\n label: my-signal\n";
1618 let entries = parse_secrets(yaml.as_bytes(), Some(SecretsFormat::Yaml)).unwrap();
1619 assert_eq!(entries.len(), 1);
1620 assert_eq!(entries[0].kind, "field-name");
1621 assert_eq!(entries[0].pattern, "^(password|secret)$");
1622 assert_eq!(entries[0].threshold, Some(3.0));
1623 assert_eq!(entries[0].label, Some("my-signal".into()));
1624 }
1625
1626 #[test]
1627 fn field_name_entry_not_extracted_as_allow_pattern() {
1628 let json = r#"[{"pattern":"^password$","kind":"field-name"}]"#;
1630 let entries = parse_secrets(json.as_bytes(), Some(SecretsFormat::Json)).unwrap();
1631 let allow = extract_allow_patterns(&entries);
1632 assert!(allow.is_empty());
1633 }
1634}