Files
fermata/tests/core_secrets_config.rs
Gabor Körber 087429d275 feat(fermata): add secret filtering engine — the security brain
Implement Goals 1–3 and 5 from the reveal-layer security brain goal.
fermata now detects, redacts, and scans for secrets in AI agent tool
output, filling the ecosystem gap where no coding agent filters secrets
post-read.

New core/secrets/ module:
- config.rs: .botsecrets TOML format with hierarchical merge and ~40
  built-in key patterns
- parser.rs: multi-format secret file parser (.env, TOML, YAML, JSON,
  Python assignments, Java properties)
- manifest.rs: file discovery + parsing → known-secrets set
- redactor.rs: Aho-Corasick multi-pattern replacement with 4 styles
- scanner.rs: RegexSet heuristic detection with 35 gitleaks-derived
  patterns (MIT) and Shannon entropy filtering
- patterns.rs: curated rules for AWS, GitHub, Stripe, Slack, JWT, etc.

Hook integration:
- fermata hook --event post-tool-use reads tool output, runs redactor +
  scanner, returns updatedToolOutput for Claude Code
- Backward compatible: --event pre-tool-use (default) unchanged
- Fail-open: errors produce {} and exit 0

Library API:
- Redactor::new(manifest, style).redact(text) → RedactedText
- Scanner::new(config).scan(text) → Vec<Finding>
- Compiles without CLI feature for embedding in other crates

195 tests (130 new), all passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 17:29:07 +02:00

389 lines
11 KiB
Rust

use dirigent_fermata::core::secrets::config::{
EnforcementMode, HeuristicMode, ParseErrorAction, RedactionStyle, SecretsConfig,
BUILTIN_KEY_PATTERNS,
};
#[test]
fn parse_minimal_files_only() {
let cfg = SecretsConfig::from_toml(
r#"
[files]
patterns = [".env", ".env.*"]
"#,
)
.unwrap();
assert_eq!(cfg.files.patterns, vec![".env", ".env.*"]);
// Other sections use defaults
assert_eq!(cfg.redaction.style, RedactionStyle::Masked);
assert_eq!(cfg.enforcement.mode, EnforcementMode::Permissive);
}
#[test]
fn parse_full_config() {
let cfg = SecretsConfig::from_toml(
r#"
[files]
patterns = [".env", "secrets.*"]
[keys]
include = ["STRIPE_*", "TWILIO_*"]
exclude = ["PUBLIC_KEY", "SSH_KEY_PATH"]
[redaction]
style = "typed"
[heuristic]
enabled = false
mode = "report"
patterns = ['AKIA[A-Z2-7]{16}']
[enforcement]
mode = "strict"
on_parse_error = "deny"
[[file]]
path = "settings.py"
format = "python-assignments"
keys = ["SECRET_KEY", "DATABASES.*.PASSWORD"]
"#,
)
.unwrap();
assert_eq!(cfg.files.patterns, vec![".env", "secrets.*"]);
assert_eq!(cfg.keys.include, vec!["STRIPE_*", "TWILIO_*"]);
assert_eq!(cfg.keys.exclude, vec!["PUBLIC_KEY", "SSH_KEY_PATH"]);
assert_eq!(cfg.redaction.style, RedactionStyle::Typed);
assert!(!cfg.heuristic.enabled);
assert_eq!(cfg.heuristic.mode, HeuristicMode::Report);
assert_eq!(cfg.heuristic.patterns, vec!["AKIA[A-Z2-7]{16}"]);
assert_eq!(cfg.enforcement.mode, EnforcementMode::Strict);
assert_eq!(cfg.enforcement.on_parse_error, ParseErrorAction::Deny);
assert_eq!(cfg.file_overrides.len(), 1);
assert_eq!(cfg.file_overrides[0].path, "settings.py");
assert_eq!(
cfg.file_overrides[0].format.as_deref(),
Some("python-assignments")
);
assert_eq!(
cfg.file_overrides[0].keys,
vec!["SECRET_KEY", "DATABASES.*.PASSWORD"]
);
}
#[test]
fn empty_toml_returns_defaults() {
let cfg = SecretsConfig::from_toml("").unwrap();
assert!(!cfg.files.patterns.is_empty());
assert!(cfg.files.patterns.contains(&".env".to_string()));
assert_eq!(cfg.redaction.style, RedactionStyle::Masked);
assert!(cfg.heuristic.enabled);
assert_eq!(cfg.heuristic.mode, HeuristicMode::Enforce);
assert_eq!(cfg.enforcement.mode, EnforcementMode::Permissive);
assert_eq!(
cfg.enforcement.on_parse_error,
ParseErrorAction::MaskEntireFile
);
assert!(cfg.file_overrides.is_empty());
}
#[test]
fn invalid_toml_produces_error() {
let result = SecretsConfig::from_toml("this is not valid {{ toml");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("expected"),
"error should describe parse issue: {err_msg}"
);
}
#[test]
fn effective_key_includes_has_builtins() {
let cfg = SecretsConfig::default();
let effective = cfg.effective_key_includes();
for builtin in BUILTIN_KEY_PATTERNS {
assert!(
effective.contains(&builtin.to_string()),
"missing builtin: {builtin}"
);
}
}
#[test]
fn effective_key_includes_adds_user_patterns() {
let cfg = SecretsConfig::from_toml(
r#"
[keys]
include = ["MY_CUSTOM_SECRET_*"]
"#,
)
.unwrap();
let effective = cfg.effective_key_includes();
assert!(effective.contains(&"MY_CUSTOM_SECRET_*".to_string()));
// Builtins still present
assert!(effective.contains(&"*PASSWORD*".to_string()));
}
#[test]
fn effective_key_includes_removes_excluded() {
let cfg = SecretsConfig::from_toml(
r#"
[keys]
exclude = ["*TOKEN*", "SENTRY_DSN"]
"#,
)
.unwrap();
let effective = cfg.effective_key_includes();
assert!(
!effective.contains(&"*TOKEN*".to_string()),
"excluded pattern should be removed"
);
assert!(
!effective.contains(&"SENTRY_DSN".to_string()),
"excluded pattern should be removed"
);
// Other builtins still present
assert!(effective.contains(&"*PASSWORD*".to_string()));
}
#[test]
fn key_matches_glob_case_insensitive() {
let cfg = SecretsConfig::default();
assert!(cfg.key_matches("DATABASE_URL"));
assert!(cfg.key_matches("database_url"));
assert!(cfg.key_matches("my_password_here"));
assert!(cfg.key_matches("MY_PASSWORD_HERE"));
assert!(cfg.key_matches("STRIPE_SECRET_KEY"));
assert!(cfg.key_matches("AWS_ACCESS_KEY_ID"));
}
#[test]
fn key_matches_non_secret_keys() {
let cfg = SecretsConfig::default();
assert!(!cfg.key_matches("DEBUG"));
assert!(!cfg.key_matches("LOG_LEVEL"));
assert!(!cfg.key_matches("PORT"));
assert!(!cfg.key_matches("HOST"));
}
#[test]
fn key_matches_respects_user_include() {
let cfg = SecretsConfig::from_toml(
r#"
[keys]
include = ["MY_APP_*"]
"#,
)
.unwrap();
assert!(cfg.key_matches("MY_APP_SETTING"));
assert!(cfg.key_matches("my_app_setting"));
}
#[test]
fn key_matches_respects_user_exclude() {
let cfg = SecretsConfig::from_toml(
r#"
[keys]
exclude = ["*TOKEN*"]
"#,
)
.unwrap();
// TOKEN patterns were excluded, so GITHUB_TOKEN should no longer match
// via the *TOKEN* pattern. But it might match via GITHUB_TOKEN literal.
// Let's check something that only matched *TOKEN*.
assert!(!cfg.key_matches("MY_TOKEN"));
// PASSWORD still matches
assert!(cfg.key_matches("MY_PASSWORD"));
}
#[test]
fn builtin_file_patterns_present() {
let cfg = SecretsConfig::default();
let patterns = &cfg.files.patterns;
assert!(patterns.contains(&".env".to_string()));
assert!(patterns.contains(&"*.pem".to_string()));
assert!(patterns.contains(&".aws/credentials".to_string()));
assert!(patterns.contains(&"terraform.tfvars".to_string()));
}
#[test]
fn load_missing_files_returns_defaults() {
let tmp = tempfile::tempdir().unwrap();
let cfg = SecretsConfig::load(tmp.path()).unwrap();
assert_eq!(cfg.files.patterns, SecretsConfig::default().files.patterns);
assert_eq!(cfg.redaction.style, RedactionStyle::Masked);
}
#[test]
fn load_project_botsecrets() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(".botsecrets"),
r#"
[redaction]
style = "named"
[keys]
include = ["CUSTOM_*"]
"#,
)
.unwrap();
let cfg = SecretsConfig::load(tmp.path()).unwrap();
assert_eq!(cfg.redaction.style, RedactionStyle::Named);
assert!(cfg.effective_key_includes().contains(&"CUSTOM_*".to_string()));
// File patterns remain at defaults (not overridden)
assert!(cfg.files.patterns.contains(&".env".to_string()));
}
#[test]
fn load_local_overrides_project() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(".botsecrets"),
r#"
[redaction]
style = "named"
[enforcement]
mode = "strict"
"#,
)
.unwrap();
std::fs::write(
tmp.path().join(".botsecrets.local"),
r#"
[redaction]
style = "absent"
"#,
)
.unwrap();
let cfg = SecretsConfig::load(tmp.path()).unwrap();
// .local overrides .botsecrets for redaction style
assert_eq!(cfg.redaction.style, RedactionStyle::Absent);
// enforcement from .botsecrets is preserved (not in .local)
assert_eq!(cfg.enforcement.mode, EnforcementMode::Strict);
}
#[test]
fn load_invalid_botsecrets_returns_error() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".botsecrets"), "invalid {{ toml").unwrap();
let result = SecretsConfig::load(tmp.path());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains(".botsecrets"), "error should mention file: {err}");
}
#[test]
fn merge_keys_accumulate() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(".botsecrets"),
r#"
[keys]
include = ["FROM_PROJECT"]
exclude = ["EXCLUDE_PROJECT"]
"#,
)
.unwrap();
std::fs::write(
tmp.path().join(".botsecrets.local"),
r#"
[keys]
include = ["FROM_LOCAL"]
exclude = ["EXCLUDE_LOCAL"]
"#,
)
.unwrap();
let cfg = SecretsConfig::load(tmp.path()).unwrap();
assert!(cfg.keys.include.contains(&"FROM_PROJECT".to_string()));
assert!(cfg.keys.include.contains(&"FROM_LOCAL".to_string()));
assert!(cfg.keys.exclude.contains(&"EXCLUDE_PROJECT".to_string()));
assert!(cfg.keys.exclude.contains(&"EXCLUDE_LOCAL".to_string()));
}
#[test]
fn merge_file_patterns_replaced_not_appended() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(".botsecrets"),
r#"
[files]
patterns = ["only-this.env"]
"#,
)
.unwrap();
let cfg = SecretsConfig::load(tmp.path()).unwrap();
assert_eq!(cfg.files.patterns, vec!["only-this.env"]);
// Defaults should be gone, replaced by the project's list
assert!(!cfg.files.patterns.contains(&".env".to_string()));
}
#[test]
fn all_redaction_styles_parse() {
for (input, expected) in [
("masked", RedactionStyle::Masked),
("typed", RedactionStyle::Typed),
("named", RedactionStyle::Named),
("absent", RedactionStyle::Absent),
] {
let toml_str = format!("[redaction]\nstyle = \"{input}\"");
let cfg = SecretsConfig::from_toml(&toml_str).unwrap();
assert_eq!(cfg.redaction.style, expected, "failed for: {input}");
}
}
#[test]
fn all_enforcement_modes_parse() {
for (input, expected) in [
("strict", EnforcementMode::Strict),
("permissive", EnforcementMode::Permissive),
("audit", EnforcementMode::Audit),
] {
let toml_str = format!("[enforcement]\nmode = \"{input}\"");
let cfg = SecretsConfig::from_toml(&toml_str).unwrap();
assert_eq!(cfg.enforcement.mode, expected, "failed for: {input}");
}
}
#[test]
fn all_heuristic_modes_parse() {
for (input, expected) in [
("enforce", HeuristicMode::Enforce),
("report", HeuristicMode::Report),
("disabled", HeuristicMode::Disabled),
] {
let toml_str = format!("[heuristic]\nmode = \"{input}\"");
let cfg = SecretsConfig::from_toml(&toml_str).unwrap();
assert_eq!(cfg.heuristic.mode, expected, "failed for: {input}");
}
}
#[test]
fn serialization_roundtrip() {
let cfg = SecretsConfig::from_toml(
r#"
[files]
patterns = [".env"]
[redaction]
style = "typed"
[enforcement]
mode = "audit"
on_parse_error = "allow"
"#,
)
.unwrap();
let serialized = toml::to_string(&cfg).unwrap();
let deserialized: SecretsConfig = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.redaction.style, RedactionStyle::Typed);
assert_eq!(deserialized.enforcement.mode, EnforcementMode::Audit);
assert_eq!(
deserialized.enforcement.on_parse_error,
ParseErrorAction::Allow
);
}