From 5253204c2a054bed722100813591c40f92145067 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Mon, 2 Feb 2026 23:22:08 -0800 Subject: [PATCH] preparing for v1.78.0 --- CHANGELOG.md | 8 + crates/kingfisher-rules/data/rules/jdbc.yml | 1 + crates/kingfisher-rules/data/rules/jwt.yml | 3 +- .../kingfisher-rules/data/rules/mongodb.yml | 4 +- crates/kingfisher-rules/data/rules/mysql.yml | 1 + .../kingfisher-rules/data/rules/postgres.yml | 3 +- crates/kingfisher-rules/src/lib.rs | 2 +- crates/kingfisher-rules/src/rule.rs | 147 +++ crates/kingfisher-scanner/src/scanner.rs | 1 + docs/USAGE.md | 40 +- src/baseline.rs | 1 + src/cli/global.rs | 80 +- src/direct_validate.rs | 19 +- src/matcher.rs | 8 + src/reporter.rs | 1 + src/reporter/json_format.rs | 1 + src/scanner/runner.rs | 21 +- src/scanner/validation.rs | 18 +- src/validation.rs | 192 ++- src/validation/jdbc.rs | 16 +- src/validation/jwt.rs | 64 +- src/validation/mongodb.rs | 20 +- src/validation/mysql.rs | 19 +- src/validation/postgres.rs | 92 +- tests/cli_validate_revoke.rs | 1039 +++++++++++++++++ tests/dependent_rule_dedup.rs | 2 + tests/fingerprint_dedup.rs | 2 + tests/int_allowlist.rs | 3 +- tests/int_bitbucket.rs | 3 +- tests/int_dedup.rs | 3 +- tests/int_github.rs | 3 +- tests/int_gitlab.rs | 4 +- tests/int_redact.rs | 3 +- tests/int_slack.rs | 3 +- tests/int_validation_cache.rs | 3 +- tests/int_vulnerable_files.rs | 3 +- tests/live_db_validation.rs | 4 +- tests/tls_mode.rs | 200 ++++ 38 files changed, 1943 insertions(+), 94 deletions(-) create mode 100644 tests/cli_validate_revoke.rs create mode 100644 tests/tls_mode.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2ccf3..393d980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ All notable changes to this project will be documented in this file. - Fixed `validate_command` and `revoke_command` generation in scan output to include all required `--var` arguments for rules with `depends_on` sections (e.g., PubNub, Azure Storage). Commands now include dependent variables like `--var SUBSCRIPTIONTOKEN=` or `--var AZURENAME=`. - Updated Azure Storage validation to use `AZURENAME` variable (matching the `depends_on_rule` configuration) with `STORAGE_ACCOUNT` maintained as a backward-compatible alias. - Added internal `dependent_captures` field to match records to preserve variables from dependent rules through the validation pipeline for accurate command generation. +- Added `--tls-mode ` global flag to control TLS certificate validation behavior during credential validation: + - `strict` (default): Full WebPKI certificate validation with trusted CA chains, hostname verification, and expiration checks + - `lax`: Accept self-signed or unknown CA certificates, useful for database connections (PostgreSQL, MySQL, MongoDB) and services using private CAs (e.g., Amazon RDS) + - `off`: Disable all TLS validation (equivalent to legacy `--ignore-certs`) +- Added rule-level `tls_mode` field allowing individual rules to opt into relaxed TLS validation when appropriate. Rules for PostgreSQL, MySQL, MongoDB, JDBC, and JWT now include `tls_mode: lax` by default. +- The `--ignore-certs` flag remains supported as a deprecated alias for `--tls-mode=off` for backward compatibility. +- Updated documentation to explain TLS validation modes and their security implications. +- Added comprehensive test coverage for TLS mode functionality including unit tests, integration tests, and rule configuration verification. ## [v1.77.0] - Added `kingfisher revoke` subcommand for revoking leaked credentials directly with the provider. diff --git a/crates/kingfisher-rules/data/rules/jdbc.yml b/crates/kingfisher-rules/data/rules/jdbc.yml index 00fffd5..4899284 100644 --- a/crates/kingfisher-rules/data/rules/jdbc.yml +++ b/crates/kingfisher-rules/data/rules/jdbc.yml @@ -21,6 +21,7 @@ rules: confidence: medium validation: type: Jdbc + tls_mode: lax examples: - jdbc:postgresql://db.example.com:5432/app?user=admin&password=s3cr3t - jdbc:mysql://admin:s3cr3t@prod.internal:3306/inventory diff --git a/crates/kingfisher-rules/data/rules/jwt.yml b/crates/kingfisher-rules/data/rules/jwt.yml index cbd5b46..a5a2503 100644 --- a/crates/kingfisher-rules/data/rules/jwt.yml +++ b/crates/kingfisher-rules/data/rules/jwt.yml @@ -25,4 +25,5 @@ rules: - https://datatracker.ietf.org/doc/html/rfc4648 - https://developer.okta.com/blog/2018/06/20/what-happens-if-your-jwt-is-stolen validation: - type: JWT \ No newline at end of file + type: JWT + tls_mode: lax \ No newline at end of file diff --git a/crates/kingfisher-rules/data/rules/mongodb.yml b/crates/kingfisher-rules/data/rules/mongodb.yml index bdca97f..cf775b1 100644 --- a/crates/kingfisher-rules/data/rules/mongodb.yml +++ b/crates/kingfisher-rules/data/rules/mongodb.yml @@ -93,6 +93,7 @@ rules: - "mongodb://mongoadmin:contoso@something.foo.mongodb.net/myFirstDatabase" validation: type: MongoDB + tls_mode: lax - name: MongoDB Atlas Service Account Token id: kingfisher.mongodb.4 pattern: | @@ -105,4 +106,5 @@ rules: examples: - mdb_sa_sk_BdIX_jLzut2WTgglKzKvSgWMDDj5hEoTqdwOyLOL validation: - type: MongoDB \ No newline at end of file + type: MongoDB + tls_mode: lax \ No newline at end of file diff --git a/crates/kingfisher-rules/data/rules/mysql.yml b/crates/kingfisher-rules/data/rules/mysql.yml index 88e3154..8f1c2c3 100644 --- a/crates/kingfisher-rules/data/rules/mysql.yml +++ b/crates/kingfisher-rules/data/rules/mysql.yml @@ -44,3 +44,4 @@ rules: - mysql://user:pass@example.com:4406/app_db?ssl-mode=REQUIRED validation: type: MySQL + tls_mode: lax diff --git a/crates/kingfisher-rules/data/rules/postgres.yml b/crates/kingfisher-rules/data/rules/postgres.yml index 1102636..7ab0007 100644 --- a/crates/kingfisher-rules/data/rules/postgres.yml +++ b/crates/kingfisher-rules/data/rules/postgres.yml @@ -38,4 +38,5 @@ rules: - CONNECTION_URI="postgis://postgres:s2Tf2k@rLMy@google.com:5434/elephant" - CONNECTION_URI="postgis://postgres:s2Tf2k@rLMy@google.com:5434/elephant" validation: - type: Postgres \ No newline at end of file + type: Postgres + tls_mode: lax \ No newline at end of file diff --git a/crates/kingfisher-rules/src/lib.rs b/crates/kingfisher-rules/src/lib.rs index b0a699b..9eea7f1 100644 --- a/crates/kingfisher-rules/src/lib.rs +++ b/crates/kingfisher-rules/src/lib.rs @@ -19,7 +19,7 @@ pub use rule::{ ChecksumActual, ChecksumRequirement, Confidence, DependsOnRule, HttpRequest, HttpValidation, MultipartConfig, MultipartPart, PatternRequirementContext, PatternRequirements, PatternValidationResult, ReportResponseData, ResponseMatcher, Revocation, Rule, RuleSyntax, - Validation, RULE_COMMENTS_PATTERN, + TlsMode, Validation, RULE_COMMENTS_PATTERN, }; // Re-export Rules collection diff --git a/crates/kingfisher-rules/src/rule.rs b/crates/kingfisher-rules/src/rule.rs index e305587..ee8c5fc 100644 --- a/crates/kingfisher-rules/src/rule.rs +++ b/crates/kingfisher-rules/src/rule.rs @@ -37,6 +37,33 @@ fn default_true() -> bool { true } +/// TLS certificate validation mode for secret validation requests. +/// +/// Controls how TLS certificates are validated when connecting to endpoints +/// during credential validation (e.g., database connections, API calls). +#[derive( + Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default, +)] +#[serde(rename_all = "lowercase")] +pub enum TlsMode { + /// Full WebPKI certificate validation: trusted CA chain, hostname match, not expired. + /// This is the default and most secure mode. + #[default] + Strict, + + /// Accept self-signed or unknown CA certificates, but still enforce: + /// - Hostname must match certificate's CN/SAN + /// - Certificate must not be expired + /// - TLS 1.2 or higher required + /// + /// Useful for database connections (PostgreSQL, MySQL, MongoDB) that often use + /// self-signed certificates or private CAs (e.g., Amazon RDS). + Lax, + + /// Disable all TLS certificate validation. Use with extreme caution. + Off, +} + /// Represents various types of validation that a rule can perform. #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] #[serde(tag = "type", content = "content")] @@ -534,6 +561,16 @@ pub struct RuleSyntax { /// Optional character type requirements for matched secrets. #[serde(default)] pub pattern_requirements: Option, + /// TLS validation mode for this rule's validation requests. + /// + /// When set to `Lax`, the rule opts into relaxed TLS validation + /// (accepting self-signed/unknown CA certs) when the user enables + /// `--tls-mode=lax` on the command line. + /// + /// This is useful for rules that validate against endpoints commonly + /// using self-signed certificates, such as database connections. + #[serde(default)] + pub tls_mode: Option, } lazy_static! { @@ -590,6 +627,7 @@ impl RuleSyntax { /// revocation: None, /// depends_on_rule: vec![], /// pattern_requirements: None, + /// tls_mode: None, /// }; /// assert_eq!(r.as_anchored_regex().unwrap().as_str(), r"hello\s*world$"); /// ``` @@ -710,6 +748,15 @@ impl Rule { pub fn pattern_requirements(&self) -> Option<&PatternRequirements> { self.syntax.pattern_requirements.as_ref() } + + /// Returns the TLS validation mode for this rule, if specified. + /// + /// When a rule returns `Some(TlsMode::Lax)`, it indicates the rule + /// is eligible for relaxed TLS validation when the user enables + /// `--tls-mode=lax` on the command line. + pub fn tls_mode(&self) -> Option { + self.syntax.tls_mode + } } #[cfg(test)] @@ -1006,4 +1053,104 @@ mod tests { assert!(matches!(reqs.validate(b"123", None, true), PatternValidationResult::Passed)); assert!(matches!(reqs.validate(b"!@#", None, true), PatternValidationResult::Passed)); } + + #[test] + fn tls_mode_default_is_strict() { + assert_eq!(TlsMode::default(), TlsMode::Strict); + } + + #[test] + fn tls_mode_serializes_to_lowercase() { + assert_eq!(serde_yaml::to_string(&TlsMode::Strict).unwrap().trim(), "strict"); + assert_eq!(serde_yaml::to_string(&TlsMode::Lax).unwrap().trim(), "lax"); + assert_eq!(serde_yaml::to_string(&TlsMode::Off).unwrap().trim(), "off"); + } + + #[test] + fn tls_mode_deserializes_from_lowercase() { + let strict: TlsMode = serde_yaml::from_str("strict").unwrap(); + assert_eq!(strict, TlsMode::Strict); + + let lax: TlsMode = serde_yaml::from_str("lax").unwrap(); + assert_eq!(lax, TlsMode::Lax); + + let off: TlsMode = serde_yaml::from_str("off").unwrap(); + assert_eq!(off, TlsMode::Off); + } + + #[derive(serde::Deserialize)] + struct TestRules { + rules: Vec, + } + + #[test] + fn rule_syntax_parses_tls_mode_from_yaml() { + let yaml = r#" +rules: + - name: Test Rule + id: test.rule.1 + pattern: "test" + tls_mode: lax +"#; + let parsed: TestRules = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(parsed.rules.len(), 1); + assert_eq!(parsed.rules[0].tls_mode, Some(TlsMode::Lax)); + } + + #[test] + fn rule_syntax_tls_mode_defaults_to_none_when_missing() { + let yaml = r#" +rules: + - name: Test Rule + id: test.rule.1 + pattern: "test" +"#; + let parsed: TestRules = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(parsed.rules.len(), 1); + assert_eq!(parsed.rules[0].tls_mode, None); + } + + #[test] + fn rule_tls_mode_method_returns_syntax_value() { + let rule = Rule::new(RuleSyntax { + name: "Test".to_string(), + id: "test.1".to_string(), + pattern: "test".to_string(), + min_entropy: 0.0, + confidence: Confidence::Low, + visible: true, + examples: vec![], + negative_examples: vec![], + references: vec![], + validation: None, + revocation: None, + depends_on_rule: vec![], + pattern_requirements: None, + tls_mode: Some(TlsMode::Lax), + }); + + assert_eq!(rule.tls_mode(), Some(TlsMode::Lax)); + } + + #[test] + fn rule_tls_mode_method_returns_none_when_not_set() { + let rule = Rule::new(RuleSyntax { + name: "Test".to_string(), + id: "test.1".to_string(), + pattern: "test".to_string(), + min_entropy: 0.0, + confidence: Confidence::Low, + visible: true, + examples: vec![], + negative_examples: vec![], + references: vec![], + validation: None, + revocation: None, + depends_on_rule: vec![], + pattern_requirements: None, + tls_mode: None, + }); + + assert_eq!(rule.tls_mode(), None); + } } diff --git a/crates/kingfisher-scanner/src/scanner.rs b/crates/kingfisher-scanner/src/scanner.rs index a8cb764..9917f26 100644 --- a/crates/kingfisher-scanner/src/scanner.rs +++ b/crates/kingfisher-scanner/src/scanner.rs @@ -566,6 +566,7 @@ mod tests { revocation: None, depends_on_rule: vec![], pattern_requirements: None, + tls_mode: None, })]; let rules_db = Arc::new(RulesDatabase::from_rules(rules).unwrap()); diff --git a/docs/USAGE.md b/docs/USAGE.md index c98985a..c99046c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -18,6 +18,7 @@ This guide covers all scan targets and usage patterns for Kingfisher. - [Jira](#jira) - [Confluence](#confluence) - [Slack](#slack) +- [TLS Certificate Validation](#tls-certificate-validation) - [Environment Variables](#environment-variables) - [Exit Codes](#exit-codes) @@ -764,7 +765,7 @@ Bitbucket no longer supports App Tokens as of September 9, 2025: https://support ### Self-hosted Bitbucket Server -Use `--bitbucket-api-url` to point Kingfisher at your server's REST endpoint, for example `https://bitbucket.example.com/rest/api/1.0/`. Provide credentials with `KF_BITBUCKET_USERNAME` plus either `KF_BITBUCKET_TOKEN` or `KF_BITBUCKET_PASSWORD`, and pass `--ignore-certs` when connecting to HTTP or otherwise insecure instances. +Use `--bitbucket-api-url` to point Kingfisher at your server's REST endpoint, for example `https://bitbucket.example.com/rest/api/1.0/`. Provide credentials with `KF_BITBUCKET_USERNAME` plus either `KF_BITBUCKET_TOKEN` or `KF_BITBUCKET_PASSWORD`, and pass `--tls-mode=off` (or the legacy `--ignore-certs`) when connecting to HTTP or otherwise insecure instances. --- @@ -869,6 +870,43 @@ KF_SLACK_TOKEN="xoxp-1234..." kingfisher scan slack "akia" \ --- +## TLS Certificate Validation + +Kingfisher validates TLS certificates when connecting to endpoints during secret validation (database connections, API calls, JWKS fetching, etc.). The `--tls-mode` flag controls this behavior: + +| Mode | Description | +| ---- | ----------- | +| `strict` | **Default.** Full WebPKI certificate validation: trusted CA chain, hostname match, certificate not expired. | +| `lax` | Accept self-signed or unknown CA certificates for rules that opt into it. Still enforces TLS 1.2+. Useful for database connections using self-signed certs or private CAs (e.g., Amazon RDS). | +| `off` | Disable all certificate validation. Use with extreme caution. | + +### When to use `--tls-mode=lax` + +The `lax` mode is designed for environments where: + +- **Database connections** use self-signed certificates (common for PostgreSQL, MySQL, MongoDB) +- **Private CAs** are used (e.g., Amazon RDS uses an Amazon-issued CA that may not be in your system trust store) +- **Internal services** have certificates not signed by public CAs + +Rules must opt into lax TLS by declaring `tls_mode: lax` in their definition. When you pass `--tls-mode=lax`, only rules with this declaration will use relaxed certificate validation. SaaS API validators (GitHub, Slack, AWS, etc.) always use strict validation regardless of this flag. + +### Examples + +```bash +# Default: strict TLS everywhere +kingfisher scan ./repo + +# Lax TLS for database connection rules (Postgres, MySQL, MongoDB, JDBC, JWT) +kingfisher scan --tls-mode=lax ./repo + +# Disable all TLS validation (not recommended) +kingfisher scan --tls-mode=off ./repo +``` + +The legacy `--ignore-certs` flag is still supported as an alias for `--tls-mode=off`. + +--- + ## Environment Variables | Variable | Purpose | diff --git a/src/baseline.rs b/src/baseline.rs index 4144830..7500504 100644 --- a/src/baseline.rs +++ b/src/baseline.rs @@ -149,6 +149,7 @@ mod tests { revocation: None, depends_on_rule: vec![], pattern_requirements: None, + tls_mode: None, })) } diff --git a/src/cli/global.rs b/src/cli/global.rs index 78782ae..c68e172 100644 --- a/src/cli/global.rs +++ b/src/cli/global.rs @@ -42,6 +42,11 @@ impl CommandLineArgs { args.global_args.progress = Mode::Never; } + // Handle deprecated --ignore-certs flag as alias for --tls-mode=off + if args.global_args.ignore_certs { + args.global_args.tls_mode = TlsMode::Off; + } + if let Some(suffix) = args.global_args.user_agent_suffix.as_mut() { let trimmed = suffix.trim(); if trimmed.is_empty() { @@ -106,8 +111,16 @@ pub struct GlobalArgs { #[arg(global = true, long, short)] pub quiet: bool, - /// Ignore TLS certificate validation - #[arg(global = true, long)] + /// TLS certificate validation mode for secret validation requests. + /// + /// - strict: Full WebPKI validation (default) + /// - lax: Accept self-signed/unknown CA, but enforce hostname + expiry + /// - off: Disable all certificate validation + #[arg(global = true, long, value_enum, default_value = "strict")] + pub tls_mode: TlsMode, + + /// Disable TLS certificate validation (deprecated: use --tls-mode=off) + #[arg(global = true, long, hide = true)] pub ignore_certs: bool, /// Update the Kingfisher binary to the latest release @@ -135,6 +148,7 @@ impl Default for GlobalArgs { Self { verbose: 0, quiet: false, + tls_mode: TlsMode::Strict, ignore_certs: false, self_update: false, no_update_check: false, @@ -186,3 +200,65 @@ pub enum Mode { Never, Always, } + +/// TLS certificate validation mode for secret validation requests. +/// +/// Controls how TLS certificates are validated when connecting to endpoints +/// during credential validation (e.g., database connections, API calls). +#[derive(Copy, Clone, Debug, Display, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Default)] +#[strum(serialize_all = "kebab-case")] +pub enum TlsMode { + /// Full WebPKI certificate validation: trusted CA chain, hostname match, not expired. + /// This is the default and most secure mode. + #[default] + Strict, + + /// Accept self-signed or unknown CA certificates, but still enforce: + /// - Hostname must match certificate's CN/SAN + /// - Certificate must not be expired + /// - TLS 1.2 or higher required + /// + /// Useful for database connections (PostgreSQL, MySQL, MongoDB) that often use + /// self-signed certificates or private CAs (e.g., Amazon RDS). + Lax, + + /// Disable all TLS certificate validation. Use with extreme caution. + /// Equivalent to the legacy `--ignore-certs` flag. + Off, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tls_mode_default_is_strict() { + assert_eq!(TlsMode::default(), TlsMode::Strict); + } + + #[test] + fn tls_mode_display_formats_correctly() { + assert_eq!(TlsMode::Strict.to_string(), "strict"); + assert_eq!(TlsMode::Lax.to_string(), "lax"); + assert_eq!(TlsMode::Off.to_string(), "off"); + } + + #[test] + fn global_args_default_has_strict_tls() { + let args = GlobalArgs::default(); + assert_eq!(args.tls_mode, TlsMode::Strict); + assert!(!args.ignore_certs); + } + + #[test] + fn tls_mode_ordering_is_correct() { + // Strict < Lax < Off (more secure modes sort before less secure) + assert!(TlsMode::Strict < TlsMode::Lax); + assert!(TlsMode::Lax < TlsMode::Off); + } + + #[test] + fn mode_default_is_auto() { + assert_eq!(Mode::default(), Mode::Auto); + } +} diff --git a/src/direct_validate.rs b/src/direct_validate.rs index 7fbb9fb..41c68e6 100644 --- a/src/direct_validate.rs +++ b/src/direct_validate.rs @@ -344,9 +344,16 @@ pub async fn run_direct_validation( debug!("Rule selector '{}' matches {} rules, trying all", args.rule, num_matching_rules); } + // Determine if we should use lax TLS for non-HTTP validators + // For direct validation (explicit user command), lax mode applies globally + let use_lax_tls = matches!( + global_args.tls_mode, + crate::cli::global::TlsMode::Off | crate::cli::global::TlsMode::Lax + ); + // Build HTTP client let client = Client::builder() - .danger_accept_invalid_certs(global_args.ignore_certs) + .danger_accept_invalid_certs(use_lax_tls) .timeout(Duration::from_secs(args.timeout)) .user_agent(GLOBAL_USER_AGENT.as_str()) .gzip(true) @@ -533,7 +540,7 @@ pub async fn run_direct_validation( Validation::MongoDB => { // MongoDB expects a connection URI as the secret - match validate_mongodb(&secret).await { + match validate_mongodb(&secret, use_lax_tls).await { Ok((is_valid, message)) => DirectValidationResult { rule_id: String::new(), rule_name: String::new(), @@ -553,7 +560,7 @@ pub async fn run_direct_validation( Validation::MySQL => { // MySQL expects a connection URL as the secret - match validate_mysql(&secret).await { + match validate_mysql(&secret, use_lax_tls).await { Ok((is_valid, metadata)) => DirectValidationResult { rule_id: String::new(), rule_name: String::new(), @@ -577,7 +584,7 @@ pub async fn run_direct_validation( Validation::Postgres => { // Postgres expects a connection URL as the secret - match validate_postgres(&secret).await { + match validate_postgres(&secret, use_lax_tls).await { Ok((is_valid, metadata)) => DirectValidationResult { rule_id: String::new(), rule_name: String::new(), @@ -601,7 +608,7 @@ pub async fn run_direct_validation( Validation::Jdbc => { // JDBC expects a JDBC connection string as the secret - match validate_jdbc(&secret).await { + match validate_jdbc(&secret, use_lax_tls).await { Ok(outcome) => DirectValidationResult { rule_id: String::new(), rule_name: String::new(), @@ -621,7 +628,7 @@ pub async fn run_direct_validation( Validation::JWT => { // JWT expects a JWT token as the secret - match validate_jwt(&secret).await { + match validate_jwt(&secret, use_lax_tls).await { Ok((is_valid, message)) => DirectValidationResult { rule_id: String::new(), rule_name: String::new(), diff --git a/src/matcher.rs b/src/matcher.rs index 82a45ff..10dbe38 100644 --- a/src/matcher.rs +++ b/src/matcher.rs @@ -1229,6 +1229,7 @@ mod test { revocation: None, depends_on_rule: vec![], pattern_requirements: None, + tls_mode: None, }); let rules_db = RulesDatabase::from_rules(vec![rule]).unwrap(); @@ -1303,6 +1304,7 @@ mod test { }), ], pattern_requirements: None, + tls_mode: None, })]; let rules_db = RulesDatabase::from_rules(rules)?; let input = "some test data for vectorscan"; @@ -1354,6 +1356,7 @@ mod test { ignore_if_contains: Some(vec!["TEST".to_string()]), checksum: None, }), + tls_mode: None, })]; let rules_db = RulesDatabase::from_rules(rules)?; @@ -1418,6 +1421,7 @@ mod test { ignore_if_contains: Some(vec!["TEST".to_string()]), checksum: None, }), + tls_mode: None, })]; let rules_db = RulesDatabase::from_rules(rules)?; @@ -1534,6 +1538,7 @@ mod test { revocation: None, depends_on_rule: vec![], pattern_requirements: None, + tls_mode: None, }); let rules_db = RulesDatabase::from_rules(vec![rule])?; @@ -1574,6 +1579,7 @@ mod test { revocation: None, depends_on_rule: vec![], pattern_requirements: None, + tls_mode: None, }); let rules_db = RulesDatabase::from_rules(vec![rule])?; let seen = BlobIdMap::new(); @@ -1608,6 +1614,7 @@ mod test { revocation: None, depends_on_rule: vec![], pattern_requirements: None, + tls_mode: None, }); let rules_db = RulesDatabase::from_rules(vec![rule])?; let seen = BlobIdMap::new(); @@ -1650,6 +1657,7 @@ line2 revocation: None, depends_on_rule: vec![], pattern_requirements: None, + tls_mode: None, }); let rules_db = RulesDatabase::from_rules(vec![rule])?; diff --git a/src/reporter.rs b/src/reporter.rs index 3d8ac79..27d4a3b 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -1263,6 +1263,7 @@ mod tests { revocation: None, depends_on_rule: vec![], pattern_requirements: None, + tls_mode: None, })); let blob_id = BlobId::new(b"blob-data"); diff --git a/src/reporter/json_format.rs b/src/reporter/json_format.rs index 3171dcd..c31f0dd 100644 --- a/src/reporter/json_format.rs +++ b/src/reporter/json_format.rs @@ -215,6 +215,7 @@ mod tests { revocation: None, depends_on_rule: vec![], pattern_requirements: None, + tls_mode: None, }; let rule = Arc::new(Rule::new(syntax)); Match { diff --git a/src/scanner/runner.rs b/src/scanner/runner.rs index 4b33fbd..adb72df 100644 --- a/src/scanner/runner.rs +++ b/src/scanner/runner.rs @@ -277,10 +277,7 @@ pub async fn run_async_scan( info!("Starting secret validation phase..."); Some(Arc::new(( register_all(liquid::ParserBuilder::with_stdlib()).build()?, - reqwest::Client::builder() - .danger_accept_invalid_certs(global_args.ignore_certs) - .timeout(Duration::from_secs(30)) - .build()?, + crate::validation::ValidationClients::new(global_args.tls_mode)?, Arc::new(SkipMap::new()), ))) } else { @@ -350,11 +347,11 @@ pub async fn run_async_scan( } if let Some(validation) = &validation_deps { - let (parser, client, cache) = (&validation.0, &validation.1, &validation.2); + let (parser, clients, cache) = (&validation.0, &validation.1, &validation.2); run_secret_validation( Arc::clone(&datastore), parser, - client, + clients, cache, args.num_jobs, None, @@ -433,13 +430,13 @@ pub async fn run_async_scan( } if let Some(validation) = &validation_deps { - let (parser, client, cache) = (&validation.0, &validation.1, &validation.2); + let (parser, clients, cache) = (&validation.0, &validation.1, &validation.2); let initial_match_count = { datastore.lock().unwrap().get_matches().len() }; if initial_match_count > 0 { run_secret_validation( Arc::clone(&datastore), parser, - client, + clients, cache, args.num_jobs, Some(0..initial_match_count), @@ -515,7 +512,7 @@ pub async fn run_async_scan( } if let Some(validation) = validation_deps.clone() { - let (parser, client, cache) = + let (parser, clients, cache) = (&validation.0, &validation.1, &validation.2); let match_count = { repo_datastore.lock().unwrap().get_matches().len() }; @@ -523,7 +520,7 @@ pub async fn run_async_scan( rt_handle.block_on(run_secret_validation( Arc::clone(&repo_datastore), parser, - client, + clients, cache, args.num_jobs, Some(0..match_count), @@ -596,11 +593,11 @@ pub async fn run_async_scan( } if let Some(validation) = &validation_deps { - let (parser, client, cache) = (&validation.0, &validation.1, &validation.2); + let (parser, clients, cache) = (&validation.0, &validation.1, &validation.2); run_secret_validation( Arc::clone(&datastore), parser, - client, + clients, cache, args.num_jobs, None, diff --git a/src/scanner/validation.rs b/src/scanner/validation.rs index c88f362..3969b80 100644 --- a/src/scanner/validation.rs +++ b/src/scanner/validation.rs @@ -12,7 +12,7 @@ use dashmap::DashMap; use futures::{stream, StreamExt}; use indicatif::{ProgressBar, ProgressStyle}; use liquid::Parser; -use reqwest::{Client, StatusCode}; +use reqwest::StatusCode; use rustc_hash::FxHashMap; use tokio::{sync::Notify, time::timeout}; use tracing::trace; @@ -108,7 +108,7 @@ impl AccessMapCollector { pub async fn run_secret_validation( datastore: Arc>, parser: &Parser, - client: &Client, + clients: &crate::validation::ValidationClients, cache: &Arc>, num_jobs: usize, range: Option>, @@ -205,7 +205,7 @@ pub async fn run_secret_validation( .for_each_concurrent(concurrency, |rep_arc| { // clones into task let parser = parser.clone(); - let client = client.clone(); + let clients = clients.clone(); let cache_glob = cache.clone(); let val_res = &validation_results; let success = success_count.clone(); @@ -241,7 +241,7 @@ pub async fn run_secret_validation( validate_single( &mut om, &parser, - &client, + &clients, &FxHashMap::default(), &FxHashMap::default(), &Arc::new(DashMap::new()), @@ -318,7 +318,7 @@ pub async fn run_secret_validation( .map(|blob_id| { let matches_for_blob = dependent_blobs.get(blob_id).unwrap().clone(); let parser = parser.clone(); - let client = client.clone(); + let clients = clients.clone(); let val_cache = val_cache.clone(); let in_flight = in_flight.clone(); let success = success_count.clone(); @@ -352,7 +352,7 @@ pub async fn run_secret_validation( let validated: Vec<_> = stream::iter(reps.into_iter().map(|(mut rep, mut dups)| { let parser = parser.clone(); - let client = client.clone(); + let clients = clients.clone(); let dep_vars = dep_vars.clone(); let miss_deps = missing_deps.clone(); let val_cache = val_cache.clone(); @@ -365,7 +365,7 @@ pub async fn run_secret_validation( validate_single( &mut rep, &parser, - &client, + &clients, &dep_vars, &miss_deps, &val_cache, @@ -452,7 +452,7 @@ pub async fn run_secret_validation( async fn validate_single( om: &mut OwnedBlobMatch, parser: &Parser, - client: &Client, + clients: &crate::validation::ValidationClients, dep_vars: &FxHashMap>, missing_deps: &FxHashMap>, cache: &DashMap, @@ -517,7 +517,7 @@ async fn validate_single( validate_single_match( om, parser, - client, + clients, dep_vars, missing_deps, cache2, diff --git a/src/validation.rs b/src/validation.rs index a0eda14..5279772 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -19,12 +19,16 @@ use tokio::{sync::Notify, time}; use tracing::{debug, trace}; use crate::{ + cli::global::TlsMode, location::OffsetSpan, matcher::{OwnedBlobMatch, SerializableCaptures}, rules::rule::Validation, validation_body::{self, ValidationResponseBody}, }; +// Re-export TlsMode from kingfisher_rules for use in client_for_rule +pub use kingfisher_rules::TlsMode as RuleTlsMode; + pub mod aws; pub mod azure; pub mod coinbase; @@ -88,6 +92,71 @@ pub fn set_user_agent_suffix>(suffix: Option) { } } +/// Holds HTTP clients for different TLS validation modes. +/// +/// This struct is created once at scan startup and passed through the validation chain. +/// The appropriate client is selected based on the global TLS mode and each rule's +/// declared `tls_mode` setting. +#[derive(Clone)] +pub struct ValidationClients { + /// Client with full TLS certificate validation (WebPKI chain, hostname, expiry). + strict: Client, + /// Client that accepts self-signed or invalid certificates. + /// Used when `--tls-mode=lax` AND the rule opts into lax validation, + /// or when `--tls-mode=off`. + lax: Client, + /// The global TLS mode from CLI arguments. + pub global_mode: TlsMode, +} + +impl ValidationClients { + /// Create validation clients based on the global TLS mode. + pub fn new(global_mode: TlsMode) -> anyhow::Result { + let timeout = std::time::Duration::from_secs(30); + + let strict = + Client::builder().danger_accept_invalid_certs(false).timeout(timeout).build()?; + + let lax = Client::builder().danger_accept_invalid_certs(true).timeout(timeout).build()?; + + Ok(Self { strict, lax, global_mode }) + } + + /// Get the appropriate client for a given rule's TLS mode. + /// + /// The effective TLS mode depends on both the global setting and the rule's preference: + /// - If global mode is `Off`, always use the lax client (no validation). + /// - If global mode is `Lax` and the rule declares `tls_mode: lax`, use lax client. + /// - Otherwise, use the strict client. + pub fn client_for_rule(&self, rule_tls_mode: Option) -> &Client { + match self.global_mode { + TlsMode::Off => &self.lax, + TlsMode::Lax => { + // Convert rule's TlsMode to CLI TlsMode for comparison + let rule_wants_lax = matches!(rule_tls_mode, Some(kingfisher_rules::TlsMode::Lax)); + if rule_wants_lax { + &self.lax + } else { + &self.strict + } + } + TlsMode::Strict => &self.strict, + } + } + + /// Check if lax TLS should be used for a rule. + /// + /// This is useful for non-HTTP validators (Postgres, MySQL, etc.) that need to + /// configure their own TLS settings. + pub fn should_use_lax(&self, rule_tls_mode: Option) -> bool { + match self.global_mode { + TlsMode::Off => true, + TlsMode::Lax => matches!(rule_tls_mode, Some(kingfisher_rules::TlsMode::Lax)), + TlsMode::Strict => false, + } + } +} + // Use SkipMap-based cache instead of a mutex-wrapped FxHashMap. type Cache = Arc>; @@ -276,7 +345,7 @@ async fn render_template( pub async fn validate_single_match( m: &mut OwnedBlobMatch, parser: &liquid::Parser, - client: &Client, + clients: &ValidationClients, dependent_variables: &FxHashMap>, missing_dependencies: &FxHashMap>, cache: &Cache, @@ -287,7 +356,7 @@ pub async fn validate_single_match( timed_validate_single_match( m, parser, - client, + clients, dependent_variables, missing_dependencies, cache, @@ -314,13 +383,17 @@ pub async fn validate_single_match( async fn timed_validate_single_match<'a>( m: &mut OwnedBlobMatch, parser: &liquid::Parser, - client: &Client, + clients: &ValidationClients, dependent_variables: &FxHashMap>, missing_dependencies: &FxHashMap>, cache: &Cache, validation_timeout: Duration, validation_retries: u32, ) { + // Select the appropriate HTTP client based on rule's TLS mode preference + let rule_tls_mode = m.rule.tls_mode(); + let client = clients.client_for_rule(rule_tls_mode); + let use_lax_tls = clients.should_use_lax(rule_tls_mode); // ────────────────────────────────────────────────────────── // 1. process-wide fingerprint de-dup // ────────────────────────────────────────────────────────── @@ -695,7 +768,7 @@ async fn timed_validate_single_match<'a>( } } - match mongodb::validate_mongodb(&uri).await { + match mongodb::validate_mongodb(&uri, use_lax_tls).await { Ok((ok, msg)) => { m.validation_success = ok; m.validation_response_body = validation_body::from_string(msg); @@ -740,7 +813,7 @@ async fn timed_validate_single_match<'a>( } } - match mysql::validate_mysql(&mysql_url).await { + match mysql::validate_mysql(&mysql_url, use_lax_tls).await { Ok((ok, meta)) => { m.validation_success = ok; m.validation_response_body = validation_body::from_string(if ok { @@ -862,7 +935,7 @@ async fn timed_validate_single_match<'a>( } } - match jdbc::validate_jdbc(&jdbc_conn).await { + match jdbc::validate_jdbc(&jdbc_conn, use_lax_tls).await { Ok(outcome) => { m.validation_success = outcome.valid; m.validation_response_body = validation_body::from_string(outcome.message); @@ -916,7 +989,7 @@ async fn timed_validate_single_match<'a>( } } - match postgres::validate_postgres(&pg_url).await { + match postgres::validate_postgres(&pg_url, use_lax_tls).await { Ok((ok, meta)) => { m.validation_success = ok; m.validation_response_body = validation_body::from_string(if ok { @@ -961,7 +1034,7 @@ async fn timed_validate_single_match<'a>( return; } - match jwt::validate_jwt(&token).await { + match jwt::validate_jwt(&token, use_lax_tls).await { Ok((ok, msg)) => { m.validation_success = ok; m.validation_response_body = validation_body::from_string(msg); @@ -1261,6 +1334,109 @@ mod tests { assert!(body.is_char_boundary(body.len())); assert!(body.ends_with('a')); } + + mod tls_mode_tests { + use super::*; + + #[test] + fn validation_clients_new_creates_both_clients() { + let clients = ValidationClients::new(TlsMode::Strict).unwrap(); + assert_eq!(clients.global_mode, TlsMode::Strict); + + let clients_lax = ValidationClients::new(TlsMode::Lax).unwrap(); + assert_eq!(clients_lax.global_mode, TlsMode::Lax); + + let clients_off = ValidationClients::new(TlsMode::Off).unwrap(); + assert_eq!(clients_off.global_mode, TlsMode::Off); + } + + #[test] + fn client_for_rule_strict_mode_always_returns_strict_client() { + let clients = ValidationClients::new(TlsMode::Strict).unwrap(); + + // With no rule TLS mode + let client1 = clients.client_for_rule(None); + // With rule wanting lax + let client2 = clients.client_for_rule(Some(kingfisher_rules::TlsMode::Lax)); + // With rule wanting strict + let client3 = clients.client_for_rule(Some(kingfisher_rules::TlsMode::Strict)); + + // In strict mode, all should return the same strict client + assert!(std::ptr::eq(client1, client2)); + assert!(std::ptr::eq(client2, client3)); + } + + #[test] + fn client_for_rule_off_mode_always_returns_lax_client() { + let clients = ValidationClients::new(TlsMode::Off).unwrap(); + + // With no rule TLS mode + let client1 = clients.client_for_rule(None); + // With rule wanting lax + let client2 = clients.client_for_rule(Some(kingfisher_rules::TlsMode::Lax)); + // With rule wanting strict + let client3 = clients.client_for_rule(Some(kingfisher_rules::TlsMode::Strict)); + + // In off mode, all should return the same lax client + assert!(std::ptr::eq(client1, client2)); + assert!(std::ptr::eq(client2, client3)); + } + + #[test] + fn client_for_rule_lax_mode_respects_rule_preference() { + let clients = ValidationClients::new(TlsMode::Lax).unwrap(); + + // Get references to understand which is which + let strict_client = clients.client_for_rule(None); + let lax_client = clients.client_for_rule(Some(kingfisher_rules::TlsMode::Lax)); + + // When rule doesn't specify, should get strict + assert!(std::ptr::eq(clients.client_for_rule(None), strict_client)); + + // When rule wants strict, should get strict + assert!(std::ptr::eq( + clients.client_for_rule(Some(kingfisher_rules::TlsMode::Strict)), + strict_client + )); + + // When rule wants lax, should get lax + assert!(std::ptr::eq( + clients.client_for_rule(Some(kingfisher_rules::TlsMode::Lax)), + lax_client + )); + + // Strict and lax clients should be different + assert!(!std::ptr::eq(strict_client, lax_client)); + } + + #[test] + fn should_use_lax_off_mode_always_returns_true() { + let clients = ValidationClients::new(TlsMode::Off).unwrap(); + + assert!(clients.should_use_lax(None)); + assert!(clients.should_use_lax(Some(kingfisher_rules::TlsMode::Strict))); + assert!(clients.should_use_lax(Some(kingfisher_rules::TlsMode::Lax))); + } + + #[test] + fn should_use_lax_strict_mode_always_returns_false() { + let clients = ValidationClients::new(TlsMode::Strict).unwrap(); + + assert!(!clients.should_use_lax(None)); + assert!(!clients.should_use_lax(Some(kingfisher_rules::TlsMode::Strict))); + assert!(!clients.should_use_lax(Some(kingfisher_rules::TlsMode::Lax))); + } + + #[test] + fn should_use_lax_lax_mode_respects_rule_preference() { + let clients = ValidationClients::new(TlsMode::Lax).unwrap(); + + // Only true when rule explicitly opts in + assert!(!clients.should_use_lax(None)); + assert!(!clients.should_use_lax(Some(kingfisher_rules::TlsMode::Strict))); + assert!(clients.should_use_lax(Some(kingfisher_rules::TlsMode::Lax))); + } + } } // #[cfg(test)] diff --git a/src/validation/jdbc.rs b/src/validation/jdbc.rs index 5c6cb73..245eb61 100644 --- a/src/validation/jdbc.rs +++ b/src/validation/jdbc.rs @@ -19,7 +19,11 @@ pub fn generate_jdbc_cache_key(raw: &str) -> String { } /// Validate a JDBC connection string by dispatching to the supported backend validators. -pub async fn validate_jdbc(jdbc_conn: &str) -> Result { +/// +/// # Arguments +/// * `jdbc_conn` - The JDBC connection string to validate +/// * `lax_tls` - If true, accept self-signed or invalid certificates +pub async fn validate_jdbc(jdbc_conn: &str, lax_tls: bool) -> Result { let trimmed = jdbc_conn.trim(); if !trimmed.to_ascii_lowercase().starts_with("jdbc:") { return Err(anyhow!("JDBC connection string must start with `jdbc:`")); @@ -33,9 +37,9 @@ pub async fn validate_jdbc(jdbc_conn: &str) -> Result { let subprotocol_lower = subprotocol.to_ascii_lowercase(); match subprotocol_lower.as_str() { - "postgres" | "postgresql" | "postgis" => { - validate_postgres_jdbc(subname).await.context("Postgres JDBC validation failed") - } + "postgres" | "postgresql" | "postgis" => validate_postgres_jdbc(subname, lax_tls) + .await + .context("Postgres JDBC validation failed"), other => { debug!("Unsupported JDBC subprotocol encountered: {}", other); Ok(JdbcValidationOutcome { @@ -50,9 +54,9 @@ pub async fn validate_jdbc(jdbc_conn: &str) -> Result { } } -async fn validate_postgres_jdbc(subname: &str) -> Result { +async fn validate_postgres_jdbc(subname: &str, lax_tls: bool) -> Result { let normalized = normalize_postgres_url(subname)?; - let (ok, meta) = postgres::validate_postgres(&normalized).await?; + let (ok, meta) = postgres::validate_postgres(&normalized, lax_tls).await?; let mut message = if ok { "JDBC Postgres connection is valid.".to_string() diff --git a/src/validation/jwt.rs b/src/validation/jwt.rs index 856c7da..3661386 100644 --- a/src/validation/jwt.rs +++ b/src/validation/jwt.rs @@ -12,17 +12,35 @@ use tokio::net::lookup_host; use super::utils::check_url_resolvable; -/// One global, redirect-free client. Building a `Client` is comparatively -/// expensive; re-using it lets reqwest share its internal connection pool -/// and TLS sessions across JWT validations. `Lazy` ensures thread-safe, -/// one-time initialisation. -static NO_REDIRECT_CLIENT: Lazy = Lazy::new(|| { +/// Global redirect-free client with strict TLS validation. +/// Building a `Client` is comparatively expensive; re-using it lets reqwest +/// share its internal connection pool and TLS sessions across JWT validations. +static STRICT_CLIENT: Lazy = Lazy::new(|| { Client::builder() - .redirect(Policy::none()) // disable all redirects + .redirect(Policy::none()) + .danger_accept_invalid_certs(false) .build() - .expect("failed to build no-redirect Client") + .expect("failed to build strict Client") }); +/// Global redirect-free client with lax TLS validation (accepts any cert). +static LAX_CLIENT: Lazy = Lazy::new(|| { + Client::builder() + .redirect(Policy::none()) + .danger_accept_invalid_certs(true) + .build() + .expect("failed to build lax Client") +}); + +/// Get the appropriate client based on TLS mode. +fn get_client(lax_tls: bool) -> &'static Client { + if lax_tls { + &LAX_CLIENT + } else { + &STRICT_CLIENT + } +} + /// RFC 1918 + loopback + link-local nets we refuse to contact const BLOCKED_NETS: &[&str] = &[ "10.0.0.0/8", @@ -63,17 +81,32 @@ pub struct ValidateOptions { /// Backwards-compatible entry point with secure defaults: /// - `alg: none` is **rejected** /// - `iss` is **required** unless `fallback_decoding_key` is supplied (not supplied here) -pub async fn validate_jwt(token: &str) -> Result<(bool, String)> { +/// +/// # Arguments +/// * `token` - The JWT token to validate +/// * `lax_tls` - If true, accept self-signed or invalid certificates for JWKS fetching +pub async fn validate_jwt(token: &str, lax_tls: bool) -> Result<(bool, String)> { validate_jwt_with( token, &ValidateOptions { allow_alg_none: false, fallback_decoding_key: None }, + lax_tls, ) .await } /// Strict validator with policy control. /// Returns (is_active_credential, explanation). -pub async fn validate_jwt_with(token: &str, opts: &ValidateOptions) -> Result<(bool, String)> { +/// +/// # Arguments +/// * `token` - The JWT token to validate +/// * `opts` - Validation options +/// * `lax_tls` - If true, accept self-signed or invalid certificates for JWKS fetching +pub async fn validate_jwt_with( + token: &str, + opts: &ValidateOptions, + lax_tls: bool, +) -> Result<(bool, String)> { + let client = get_client(lax_tls); // --- insecure payload decode to read claims -------------------------------- let claims: Claims = { let payload_b64 = token.split('.').nth(1).ok_or_else(|| anyhow!("invalid JWT format"))?; @@ -169,7 +202,7 @@ pub async fn validate_jwt_with(token: &str, opts: &ValidateOptions) -> Result<(b // build discovery URL and fetch it (redirects disabled) let config_url = format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/')); - let cfg_resp = NO_REDIRECT_CLIENT + let cfg_resp = client .get(&config_url) .send() .await @@ -219,8 +252,7 @@ pub async fn validate_jwt_with(token: &str, opts: &ValidateOptions) -> Result<(b check_url_resolvable(&url).await.map_err(|e| anyhow!("jwks uri unresolvable: {e}"))?; // fetch JWKS with redirect-free client - let jwks_resp = - NO_REDIRECT_CLIENT.get(url).send().await.map_err(|e| anyhow!("jwks fetch failed: {e}"))?; + let jwks_resp = client.get(url).send().await.map_err(|e| anyhow!("jwks fetch failed: {e}"))?; if !jwks_resp.status().is_success() { return Ok((false, format!("jwks fetch failed: {}", jwks_resp.status()))); } @@ -293,7 +325,7 @@ mod tests { }); let token = encode(&header, &payload, &EncodingKey::from_secret(b"secret")).unwrap(); - let res = validate_jwt(&token).await.unwrap(); + let res = validate_jwt(&token, false).await.unwrap(); assert!(!res.0); assert!(res.1.contains("HMAC-signed JWTs are not validated")); } @@ -311,7 +343,7 @@ mod tests { let signature = URL_SAFE_NO_PAD.encode("sig"); let token = format!("{header}.{payload}.{signature}"); - let res = validate_jwt(&token).await.unwrap(); + let res = validate_jwt(&token, false).await.unwrap(); assert!(!res.0); assert!(res.1.contains("no kid in header")); } @@ -319,7 +351,7 @@ mod tests { #[tokio::test] async fn unsigned_token_rejected_by_default() { let token = build_unsigned_token(60); - let res = validate_jwt(&token).await.unwrap(); + let res = validate_jwt(&token, false).await.unwrap(); assert!(!res.0); assert!(res.1.contains("unsigned JWT (alg: none) not allowed")); } @@ -330,6 +362,7 @@ mod tests { let res = validate_jwt_with( &token, &ValidateOptions { allow_alg_none: true, fallback_decoding_key: None }, + false, ) .await .unwrap(); @@ -342,6 +375,7 @@ mod tests { let res = validate_jwt_with( &token, &ValidateOptions { allow_alg_none: true, fallback_decoding_key: None }, + false, ) .await .unwrap(); diff --git a/src/validation/mongodb.rs b/src/validation/mongodb.rs index 8577f41..363dc04 100644 --- a/src/validation/mongodb.rs +++ b/src/validation/mongodb.rs @@ -3,8 +3,13 @@ use std::{net::IpAddr, time::Duration}; use anyhow::Result; use bson::doc; -use mongodb::{error::ErrorKind, options::ClientOptions, Client}; +use mongodb::{ + error::ErrorKind, + options::{ClientOptions, Tls, TlsOptions}, + Client, +}; use tokio::time::timeout; +use tracing::debug; pub fn looks_like_mongodb_uri(uri: &str) -> bool { // quick scheme check first @@ -99,7 +104,11 @@ const SRV_SELECT_MS: u64 = 2500; /// Validates a MongoDB URI in ≤ 2 s. Returns `(bool, String)` where the /// boolean indicates success and the string provides a status message. -pub async fn validate_mongodb(uri: &str) -> Result<(bool, String)> { +/// +/// # Arguments +/// * `uri` - The MongoDB connection URI to validate +/// * `lax_tls` - If true, accept self-signed or invalid certificates +pub async fn validate_mongodb(uri: &str, lax_tls: bool) -> Result<(bool, String)> { // ---- quick reject without touching the network if !looks_like_mongodb_uri(uri) { return Ok((false, "Invalid MongoDB URI".to_string())); @@ -138,6 +147,13 @@ pub async fn validate_mongodb(uri: &str) -> Result<(bool, String)> { opts.max_pool_size = Some(1); opts.min_pool_size = Some(0); + // Configure TLS options based on lax_tls setting + if lax_tls { + debug!("Using lax TLS mode for MongoDB connection"); + let tls_options = TlsOptions::builder().allow_invalid_certificates(true).build(); + opts.tls = Some(Tls::Enabled(tls_options)); + } + // ---- dial and ping let client = Client::with_options(opts)?; let res = client.database("admin").run_command(doc! { "ping": 1 }).await; diff --git a/src/validation/mysql.rs b/src/validation/mysql.rs index d74db6b..8ef77c3 100644 --- a/src/validation/mysql.rs +++ b/src/validation/mysql.rs @@ -1,7 +1,7 @@ use std::{net::IpAddr, time::Duration}; use anyhow::{anyhow, Result}; -use mysql_async::{prelude::Queryable, Conn, Opts, OptsBuilder}; +use mysql_async::{prelude::Queryable, Conn, Opts, OptsBuilder, SslOpts}; use tokio::time::{error::Elapsed, timeout}; use tracing::debug; use url::Url; @@ -94,7 +94,12 @@ fn targets_localhost(opts: &Opts) -> bool { is_local_host(opts.ip_or_hostname()) } -pub async fn validate_mysql(mysql_url: &str) -> Result<(bool, Vec)> { +/// Validate a MySQL connection URL. +/// +/// # Arguments +/// * `mysql_url` - The MySQL connection URL to validate +/// * `lax_tls` - If true, accept self-signed or invalid certificates +pub async fn validate_mysql(mysql_url: &str, lax_tls: bool) -> Result<(bool, Vec)> { let opts = parse_mysql_url(mysql_url)?; if targets_localhost(&opts) { @@ -102,7 +107,15 @@ pub async fn validate_mysql(mysql_url: &str) -> Result<(bool, Vec)> { return Ok((false, vec!["skipped localhost/loopback host".into()])); } - let builder = OptsBuilder::from_opts(opts).stmt_cache_size(Some(0)); + let mut builder = OptsBuilder::from_opts(opts).stmt_cache_size(Some(0)); + + // Configure TLS options based on lax_tls setting + if lax_tls { + debug!("Using lax TLS mode for MySQL connection"); + let ssl_opts = SslOpts::default().with_danger_accept_invalid_certs(true); + builder = builder.ssl_opts(Some(ssl_opts)); + } + let opts: Opts = builder.into(); let host = opts.ip_or_hostname().to_string(); diff --git a/src/validation/postgres.rs b/src/validation/postgres.rs index 198d067..c81bb4c 100644 --- a/src/validation/postgres.rs +++ b/src/validation/postgres.rs @@ -1,8 +1,11 @@ -use std::{str::FromStr, sync::Once, time::Duration}; +use std::{str::FromStr, sync::Arc, time::Duration}; use anyhow::{anyhow, Result}; -use rustls::crypto::{ring, CryptoProvider}; -use rustls::{client::ClientConfig, RootCertStore}; +use once_cell::sync::OnceCell; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::crypto::{ring, verify_tls12_signature, verify_tls13_signature, CryptoProvider}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{client::ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme}; use rustls_native_certs::{load_native_certs, CertificateResult}; use sha1::{Digest, Sha1}; use tokio::time::{error::Elapsed, timeout}; @@ -16,15 +19,58 @@ use tracing::debug; const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); -static INIT_PROVIDER: Once = Once::new(); +static INIT_PROVIDER: OnceCell<()> = OnceCell::new(); fn ensure_crypto_provider() { - INIT_PROVIDER.call_once(|| { + INIT_PROVIDER.get_or_init(|| { // If another part of the program already installed a provider, // ignore the error — we just need one global provider. let _ = CryptoProvider::install_default(ring::default_provider()); }); } +/// A certificate verifier that accepts any certificate (for lax TLS mode). +/// +/// This verifier still validates signatures to ensure the connection is encrypted, +/// but does not verify the certificate chain against trusted CAs. +#[derive(Debug)] +struct LaxCertVerifier(Arc); + +impl ServerCertVerifier for LaxCertVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result { + // Accept any certificate - this is the "lax" behavior + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + verify_tls12_signature(message, cert, dss, &self.0.signature_verification_algorithms) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + verify_tls13_signature(message, cert, dss, &self.0.signature_verification_algorithms) + } + + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } +} + pub fn generate_postgres_cache_key(postgres_url: &str) -> String { let mut hasher = Sha1::new(); hasher.update(postgres_url.as_bytes()); @@ -46,7 +92,12 @@ pub fn parse_postgres_url(postgres_url: &str) -> Result { } } -pub async fn validate_postgres(postgres_url: &str) -> Result<(bool, Vec)> { +/// Validate a Postgres connection URL. +/// +/// # Arguments +/// * `postgres_url` - The Postgres connection URL to validate +/// * `lax_tls` - If true, accept self-signed or invalid certificates +pub async fn validate_postgres(postgres_url: &str, lax_tls: bool) -> Result<(bool, Vec)> { let mut cfg = parse_postgres_url(postgres_url)?; // --- skip localhost/loopback/unix-socket targets entirely ------------- @@ -60,7 +111,7 @@ pub async fn validate_postgres(postgres_url: &str) -> Result<(bool, Vec) cfg.ssl_mode(SslMode::Disable); } - check_postgres_db_connection(cfg, original_mode).await + check_postgres_db_connection(cfg, original_mode, lax_tls).await } fn has_any_local_host(cfg: &Config) -> bool { @@ -98,6 +149,7 @@ fn is_local_tcp_host(s: &str) -> bool { async fn check_postgres_db_connection( mut cfg: Config, original_mode: SslMode, + lax_tls: bool, ) -> Result<(bool, Vec)> { // First attempt with caller-supplied sslmode, optional retry without TLS. for attempt in 0..=1 { @@ -121,16 +173,26 @@ async fn check_postgres_db_connection( // Ensure Rustls crypto provider is installed *before* using the builder ensure_crypto_provider(); - let CertificateResult { certs, errors, .. } = load_native_certs(); - for err in errors { - debug!("native-cert error: {err}"); - } + let tls_cfg = if lax_tls { + // Lax mode: accept any certificate (self-signed, expired, wrong hostname) + debug!("Using lax TLS mode for Postgres connection"); + let provider = Arc::new(ring::default_provider()); + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(LaxCertVerifier(provider))) + .with_no_client_auth() + } else { + // Strict mode: full certificate validation + let CertificateResult { certs, errors, .. } = load_native_certs(); + for err in errors { + debug!("native-cert error: {err}"); + } - let mut roots = RootCertStore::empty(); - let _ = roots.add_parsable_certificates(certs); + let mut roots = RootCertStore::empty(); + let _ = roots.add_parsable_certificates(certs); - let tls_cfg = - ClientConfig::builder().with_root_certificates(roots).with_no_client_auth(); + ClientConfig::builder().with_root_certificates(roots).with_no_client_auth() + }; let tls = MakeRustlsConnect::new(tls_cfg); let (client, connection) = cfg_try.connect(tls).await?; diff --git a/tests/cli_validate_revoke.rs b/tests/cli_validate_revoke.rs new file mode 100644 index 0000000..6db654e --- /dev/null +++ b/tests/cli_validate_revoke.rs @@ -0,0 +1,1039 @@ +// tests/cli_validate_revoke.rs +// +// CLI tests for the `kingfisher validate` and `kingfisher revoke` commands. +// These tests validate CLI argument parsing, error messages, and basic functionality +// without requiring actual network connections or valid credentials. + +use assert_cmd::Command; +use predicates::{prelude::PredicateBooleanExt, str::contains}; +use std::fs; +use tempfile::TempDir; + +// ============================================================================= +// Validate Command Tests +// ============================================================================= + +mod validate { + use super::*; + + #[test] + fn validate_help() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["validate", "--help"]) + .assert() + .success() + .stdout( + contains("Directly validate a known secret") + .and(contains("--rule")) + .and(contains("SECRET")), + ); + } + + #[test] + fn validate_help_shows_all_options() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["validate", "--help"]) + .assert() + .success() + .stdout( + contains("--rule") + .and(contains("--arg")) + .and(contains("--var")) + .and(contains("--timeout")) + .and(contains("--retries")) + .and(contains("--rules-path")) + .and(contains("--no-builtins")) + .and(contains("--format")), + ); + } + + #[test] + fn validate_requires_rule_flag() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["validate", "test-secret", "--no-update-check"]) + .assert() + .failure() + .stderr(contains("--rule")); + } + + #[test] + fn validate_requires_secret() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["validate", "--rule", "opsgenie", "--no-update-check"]) + .assert() + .failure() + .stderr(contains("No secret provided")); + } + + #[test] + fn validate_rejects_empty_secret() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["validate", "--rule", "opsgenie", "", "--no-update-check"]) + .assert() + .failure() + .stderr(contains("Secret cannot be empty")); + } + + #[test] + fn validate_rejects_unknown_rule() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "nonexistent.rule.xyz", + "test-secret", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("No rule found matching")); + } + + #[test] + fn validate_accepts_rule_prefix() { + // Should find rules matching a prefix like "opsgenie" + // The actual validation will fail but the rule should be found + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["validate", "--rule", "opsgenie", "fake-api-key-12345", "--no-update-check"]) + .assert() + .code(predicates::function::function(|code: &i32| { + // Exit 1 means validation failed (expected with fake key) + // Exit 0 would mean valid (unexpected but possible) + *code == 0 || *code == 1 + })) + .stdout(contains("OpsGenie").or(contains("opsgenie"))); + } + + #[test] + fn validate_accepts_full_rule_id() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "kingfisher.opsgenie.1", + "fake-api-key-12345", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_json_output() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "opsgenie", + "fake-api-key-12345", + "--format", + "json", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)) + .stdout( + contains("rule_id") + .and(contains("rule_name")) + .and(contains("is_valid")) + .and(contains("message")), + ); + } + + #[test] + fn validate_text_output() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "opsgenie", + "fake-api-key-12345", + "--format", + "text", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)) + .stdout(contains("Rule:").and(contains("Result:"))); + } + + #[test] + fn validate_with_timeout() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "opsgenie", + "fake-api-key", + "--timeout", + "5", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_rejects_invalid_timeout() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "opsgenie", + "fake-api-key", + "--timeout", + "100", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("100").or(contains("invalid")).or(contains("range"))); + } + + #[test] + fn validate_with_retries() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "opsgenie", + "fake-api-key", + "--retries", + "3", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_rejects_invalid_retries() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "opsgenie", + "fake-api-key", + "--retries", + "10", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("10").or(contains("invalid")).or(contains("range"))); + } + + #[test] + fn validate_with_var_flag() { + // AWS validation requires AKID variable + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "aws", + "--var", + "AKID=AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_with_arg_flag() { + // AWS validation with --arg (auto-assigns to AKID) + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "aws", + "--arg", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_rejects_invalid_var_format() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "aws", + "--var", + "INVALID_FORMAT_NO_EQUALS", + "fake-secret", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("Invalid variable format").or(contains("NAME=VALUE"))); + } + + #[test] + fn validate_rule_without_validation() { + // Create a temporary rule without validation + let tmp = TempDir::new().unwrap(); + fs::write( + tmp.path().join("no_validation.yml"), + r#" +rules: + - name: No Validation Rule + id: test.no.validation + pattern: "test_pattern_[a-z0-9]{4}" +"#, + ) + .unwrap(); + + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "test.no.validation", + "test_pattern_abcd", + "--rules-path", + tmp.path().to_str().unwrap(), + "--no-builtins", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("No rules with validation found")); + } + + #[test] + fn validate_no_builtins_with_custom_rule() { + let tmp = TempDir::new().unwrap(); + fs::write( + tmp.path().join("custom_rule.yml"), + r#" +rules: + - name: Custom HTTP Rule + id: test.custom.http + pattern: "custom_[a-z0-9]{8}" + validation: + type: Http + content: + request: + method: GET + url: "https://httpbin.org/status/401" + response_matcher: + - status: + - 200 + type: StatusMatch +"#, + ) + .unwrap(); + + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "test.custom.http", + "custom_12345678", + "--rules-path", + tmp.path().to_str().unwrap(), + "--no-builtins", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)) + .stdout(contains("Custom HTTP Rule")); + } + + #[test] + fn validate_missing_required_variable() { + // AWS validation requires AKID - should fail if not provided + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "aws", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("AKID").or(contains("variable"))); + } + + #[test] + fn validate_too_many_args() { + // OpsGenie only needs TOKEN, no additional variables + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "opsgenie", + "--arg", + "extra1", + "--arg", + "extra2", + "fake-api-key", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("Too many --arg")); + } + + #[test] + fn validate_mongodb_with_connection_uri() { + // MongoDB validation expects a connection URI + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "mongodb", + "mongodb://user:pass@localhost:27017/test", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_postgres_with_connection_url() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "postgres", + "postgres://user:pass@localhost:5432/test", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_mysql_with_connection_url() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "mysql", + "mysql://user:pass@localhost:3306/test", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_jdbc_with_connection_string() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "jdbc", + "jdbc:postgresql://localhost:5432/test?user=admin&password=secret", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_jwt_token() { + // A fake JWT token (will fail validation but tests the flow) + let fake_jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["validate", "--rule", "jwt", fake_jwt, "--no-update-check"]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_format_invalid_value() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "opsgenie", + "fake-key", + "--format", + "yaml", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("yaml").or(contains("invalid"))); + } +} + +// ============================================================================= +// Revoke Command Tests +// ============================================================================= + +mod revoke { + use super::*; + + #[test] + fn revoke_help() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["revoke", "--help"]) + .assert() + .success() + .stdout( + contains("Directly revoke a known secret") + .and(contains("--rule")) + .and(contains("SECRET")), + ); + } + + #[test] + fn revoke_help_shows_all_options() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["revoke", "--help"]) + .assert() + .success() + .stdout( + contains("--rule") + .and(contains("--arg")) + .and(contains("--var")) + .and(contains("--timeout")) + .and(contains("--retries")) + .and(contains("--rules-path")) + .and(contains("--no-builtins")) + .and(contains("--format")), + ); + } + + #[test] + fn revoke_requires_rule_flag() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["revoke", "test-secret", "--no-update-check"]) + .assert() + .failure() + .stderr(contains("--rule")); + } + + #[test] + fn revoke_requires_secret() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["revoke", "--rule", "slack", "--no-update-check"]) + .assert() + .failure() + .stderr(contains("No secret provided")); + } + + #[test] + fn revoke_rejects_empty_secret() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["revoke", "--rule", "slack", "", "--no-update-check"]) + .assert() + .failure() + .stderr(contains("Secret cannot be empty")); + } + + #[test] + fn revoke_rejects_unknown_rule() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["revoke", "--rule", "nonexistent.rule.xyz", "test-secret", "--no-update-check"]) + .assert() + .failure() + .stderr(contains("No rule found matching")); + } + + #[test] + fn revoke_accepts_rule_prefix() { + // Slack has revocation support + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["revoke", "--rule", "slack", "xoxb-fake-token-12345", "--no-update-check"]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)) + .stdout(contains("Slack").or(contains("slack"))); + } + + #[test] + fn revoke_accepts_full_rule_id() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "kingfisher.slack.1", + "xoxb-fake-token", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn revoke_json_output() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "slack", + "xoxb-fake-token", + "--format", + "json", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)) + .stdout( + contains("rule_id") + .and(contains("rule_name")) + .and(contains("revoked")) + .and(contains("message")), + ); + } + + #[test] + fn revoke_text_output() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "slack", + "xoxb-fake-token", + "--format", + "text", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)) + .stdout(contains("Rule:").and(contains("Result:"))); + } + + #[test] + fn revoke_with_timeout() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "slack", + "xoxb-fake-token", + "--timeout", + "5", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn revoke_rejects_invalid_timeout() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "slack", + "xoxb-fake-token", + "--timeout", + "100", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("100").or(contains("invalid")).or(contains("range"))); + } + + #[test] + fn revoke_with_retries() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "slack", + "xoxb-fake-token", + "--retries", + "3", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn revoke_rejects_invalid_retries() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "slack", + "xoxb-fake-token", + "--retries", + "10", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("10").or(contains("invalid")).or(contains("range"))); + } + + #[test] + fn revoke_with_var_flag() { + // AWS revocation requires AKID variable + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "aws", + "--var", + "AKID=AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn revoke_with_arg_flag() { + // AWS revocation with --arg (auto-assigns to AKID) + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "aws", + "--arg", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn revoke_rejects_invalid_var_format() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "aws", + "--var", + "INVALID_FORMAT_NO_EQUALS", + "fake-secret", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("Invalid variable format").or(contains("NAME=VALUE"))); + } + + #[test] + fn revoke_rule_without_revocation() { + // Create a temporary rule without revocation + let tmp = TempDir::new().unwrap(); + fs::write( + tmp.path().join("no_revocation.yml"), + r#" +rules: + - name: No Revocation Rule + id: test.no.revocation + pattern: "test_pattern_[a-z0-9]{4}" +"#, + ) + .unwrap(); + + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "test.no.revocation", + "test_pattern_abcd", + "--rules-path", + tmp.path().to_str().unwrap(), + "--no-builtins", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("No rules with revocation found")); + } + + #[test] + fn revoke_gcp_with_service_account_json() { + // GCP revocation expects service account JSON + let fake_sa_json = + r#"{"type":"service_account","project_id":"test","private_key_id":"key123"}"#; + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["revoke", "--rule", "gcp", fake_sa_json, "--no-update-check"]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn revoke_github_token() { + // GitHub has revocation support + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "github", + "ghp_fake1234567890abcdefghijklmnopqrstuvw", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn revoke_gitlab_token() { + // GitLab has revocation support + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "gitlab", + "glpat-fake1234567890abcdefgh", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn revoke_missing_required_variable() { + // AWS revocation requires AKID - should fail if not provided + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "aws", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("AKID").or(contains("variable"))); + } + + #[test] + fn revoke_format_invalid_value() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "revoke", + "--rule", + "slack", + "xoxb-fake-token", + "--format", + "xml", + "--no-update-check", + ]) + .assert() + .failure() + .stderr(contains("xml").or(contains("invalid"))); + } +} + +// ============================================================================= +// Shared/Cross-Command Tests +// ============================================================================= + +mod shared { + use super::*; + + #[test] + fn validate_and_revoke_exist_as_subcommands() { + // Verify both commands show up in main help + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .arg("--help") + .assert() + .success() + .stdout(contains("validate").and(contains("revoke"))); + } + + #[test] + fn validate_accepts_stdin_marker() { + // Test that '-' is accepted as stdin marker (the actual read will fail in test) + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["validate", "--help"]) + .assert() + .success() + .stdout(contains("stdin").or(contains("-"))); + } + + #[test] + fn revoke_accepts_stdin_marker() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["revoke", "--help"]) + .assert() + .success() + .stdout(contains("stdin").or(contains("-"))); + } + + #[test] + fn validate_with_custom_rules_path() { + let tmp = TempDir::new().unwrap(); + fs::write( + tmp.path().join("custom.yml"), + r#" +rules: + - name: Custom Validate Rule + id: custom.validate.test + pattern: "customval_[a-z0-9]{4}" + validation: + type: Http + content: + request: + method: GET + url: "https://httpbin.org/status/200" + response_matcher: + - status: + - 200 + type: StatusMatch +"#, + ) + .unwrap(); + + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "custom.validate.test", + "customval_abcd", + "--rules-path", + tmp.path().to_str().unwrap(), + "--no-builtins", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)) + .stdout(contains("Custom Validate Rule")); + } + + #[test] + fn multiple_rules_path_flags() { + let tmp1 = TempDir::new().unwrap(); + let tmp2 = TempDir::new().unwrap(); + + fs::write( + tmp1.path().join("rule1.yml"), + r#" +rules: + - name: Rule One + id: multi.path.one + pattern: "ruleone_[a-z]{4}" + validation: + type: Http + content: + request: + method: GET + url: "https://httpbin.org/status/200" + response_matcher: + - status: + - 200 + type: StatusMatch +"#, + ) + .unwrap(); + + fs::write( + tmp2.path().join("rule2.yml"), + r#" +rules: + - name: Rule Two + id: multi.path.two + pattern: "ruletwo_[a-z]{4}" + validation: + type: Http + content: + request: + method: GET + url: "https://httpbin.org/status/200" + response_matcher: + - status: + - 200 + type: StatusMatch +"#, + ) + .unwrap(); + + // Should be able to find rules from both paths + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "validate", + "--rule", + "multi.path.one", + "ruleone_abcd", + "--rules-path", + tmp1.path().to_str().unwrap(), + "--rules-path", + tmp2.path().to_str().unwrap(), + "--no-builtins", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)) + .stdout(contains("Rule One")); + } + + #[test] + fn validate_with_verbose_flag() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "--verbose", + "validate", + "--rule", + "opsgenie", + "fake-api-key", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn revoke_with_verbose_flag() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "--verbose", + "revoke", + "--rule", + "slack", + "xoxb-fake-token", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_with_quiet_flag() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "--quiet", + "validate", + "--rule", + "opsgenie", + "fake-api-key", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn revoke_with_quiet_flag() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args(["--quiet", "revoke", "--rule", "slack", "xoxb-fake-token", "--no-update-check"]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } + + #[test] + fn validate_with_tls_lax_flag() { + Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) + .args([ + "--tls-mode", + "lax", + "validate", + "--rule", + "opsgenie", + "fake-api-key", + "--no-update-check", + ]) + .assert() + .code(predicates::function::function(|code: &i32| *code == 0 || *code == 1)); + } +} diff --git a/tests/dependent_rule_dedup.rs b/tests/dependent_rule_dedup.rs index 824e24a..273468e 100644 --- a/tests/dependent_rule_dedup.rs +++ b/tests/dependent_rule_dedup.rs @@ -27,6 +27,7 @@ fn make_rule(rule_id: &str, depends_on_rule: Vec>) -> Arc< revocation: None, depends_on_rule, pattern_requirements: None, + tls_mode: None, })) } @@ -57,6 +58,7 @@ fn make_match(rule: Arc, blob_id: BlobId, value: &str) -> Match { calculated_entropy: 0.0, visible: true, is_base64: false, + dependent_captures: std::collections::BTreeMap::new(), } } diff --git a/tests/fingerprint_dedup.rs b/tests/fingerprint_dedup.rs index d3bffc4..86549a4 100644 --- a/tests/fingerprint_dedup.rs +++ b/tests/fingerprint_dedup.rs @@ -35,6 +35,7 @@ fn make_match(fp: u64, rule_id: &str) -> Match { revocation: None, depends_on_rule: vec![], pattern_requirements: None, + tls_mode: None, }; let rule = Arc::new(Rule::new(syntax)); Match { @@ -63,6 +64,7 @@ fn make_match(fp: u64, rule_id: &str) -> Match { calculated_entropy: 0.0, visible: true, is_base64: false, + dependent_captures: std::collections::BTreeMap::new(), } } diff --git a/tests/int_allowlist.rs b/tests/int_allowlist.rs index 6c9932f..256a5a1 100644 --- a/tests/int_allowlist.rs +++ b/tests/int_allowlist.rs @@ -17,7 +17,7 @@ use kingfisher::{ rules::RuleSpecifierArgs, scan::{ConfidenceLevel, ScanArgs}, }, - global::Mode, + global::{Mode, TlsMode}, GlobalArgs, }, findings_store::FindingsStore, @@ -170,6 +170,7 @@ fn run_skiplist(skip_regex: Vec, skip_skipword: Vec) -> Result Result<()> { self_update: false, ignore_certs: false, user_agent_suffix: None, + tls_mode: TlsMode::Strict, }; let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir))); diff --git a/tests/int_dedup.rs b/tests/int_dedup.rs index 44b5d10..3ed4f67 100644 --- a/tests/int_dedup.rs +++ b/tests/int_dedup.rs @@ -21,7 +21,7 @@ use kingfisher::{ rules::RuleSpecifierArgs, scan::{ConfidenceLevel, ScanArgs}, }, - global::Mode, + global::{Mode, TlsMode}, GlobalArgs, }, findings_store::FindingsStore, @@ -190,6 +190,7 @@ rules: self_update: false, ignore_certs: false, user_agent_suffix: None, + tls_mode: TlsMode::Strict, }; // ── load rules once ───────────────────────────────────────────── diff --git a/tests/int_github.rs b/tests/int_github.rs index ea7b50a..1ca4ebc 100644 --- a/tests/int_github.rs +++ b/tests/int_github.rs @@ -18,7 +18,7 @@ use kingfisher::{ rules::RuleSpecifierArgs, scan::{ConfidenceLevel, ScanArgs}, }, - global::Mode, + global::{Mode, TlsMode}, GlobalArgs, }, findings_store::FindingsStore, @@ -177,6 +177,7 @@ fn test_github_remote_scan() -> Result<()> { self_update: false, ignore_certs: false, user_agent_suffix: None, + tls_mode: TlsMode::Strict, }; // Create in-memory datastore let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir))); diff --git a/tests/int_gitlab.rs b/tests/int_gitlab.rs index 484f950..2c821e4 100644 --- a/tests/int_gitlab.rs +++ b/tests/int_gitlab.rs @@ -18,7 +18,7 @@ use kingfisher::{ rules::RuleSpecifierArgs, scan::{ConfidenceLevel, ScanArgs}, }, - global::Mode, + global::{Mode, TlsMode}, GlobalArgs, }, findings_store::FindingsStore, @@ -175,6 +175,7 @@ fn test_gitlab_remote_scan() -> Result<()> { self_update: false, ignore_certs: false, user_agent_suffix: None, + tls_mode: TlsMode::Strict, }; let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir))); @@ -338,6 +339,7 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> { self_update: false, ignore_certs: false, user_agent_suffix: None, + tls_mode: TlsMode::Strict, }; let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir))); diff --git a/tests/int_redact.rs b/tests/int_redact.rs index 8ecc85d..c90f8dc 100644 --- a/tests/int_redact.rs +++ b/tests/int_redact.rs @@ -18,7 +18,7 @@ use kingfisher::{ rules::RuleSpecifierArgs, scan::{ConfidenceLevel, ScanArgs}, }, - global::{GlobalArgs, Mode}, + global::{GlobalArgs, Mode, TlsMode}, }, findings_store::FindingsStore, rule_loader::RuleLoader, @@ -153,6 +153,7 @@ async fn test_redact_hashes_finding_values() -> Result<()> { progress: Mode::Never, ignore_certs: false, user_agent_suffix: None, + tls_mode: TlsMode::Strict, }; let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?; diff --git a/tests/int_slack.rs b/tests/int_slack.rs index b8aeb5a..e765230 100644 --- a/tests/int_slack.rs +++ b/tests/int_slack.rs @@ -14,7 +14,7 @@ use kingfisher::{ rules::RuleSpecifierArgs, scan::{ConfidenceLevel, ScanArgs}, }, - global::Mode, + global::{Mode, TlsMode}, GlobalArgs, }, findings_store::FindingsStore, @@ -308,6 +308,7 @@ async fn test_scan_slack_messages() -> Result<()> { progress: Mode::Never, ignore_certs: false, user_agent_suffix: None, + tls_mode: TlsMode::Strict, }; let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir))); diff --git a/tests/int_validation_cache.rs b/tests/int_validation_cache.rs index 69a9027..3dcc498 100644 --- a/tests/int_validation_cache.rs +++ b/tests/int_validation_cache.rs @@ -21,7 +21,7 @@ use kingfisher::{ rules::RuleSpecifierArgs, scan::{ConfidenceLevel, ScanArgs}, }, - global::Mode, + global::{Mode, TlsMode}, GlobalArgs, }, findings_store::FindingsStore, @@ -251,6 +251,7 @@ async fn test_validation_cache_and_depvars() -> Result<()> { self_update: false, ignore_certs: false, user_agent_suffix: None, + tls_mode: TlsMode::Strict, }; let update_status = UpdateStatus::default(); diff --git a/tests/int_vulnerable_files.rs b/tests/int_vulnerable_files.rs index 3aaa35e..b2e3ca0 100644 --- a/tests/int_vulnerable_files.rs +++ b/tests/int_vulnerable_files.rs @@ -19,7 +19,7 @@ use kingfisher::{ rules::RuleSpecifierArgs, scan::{ConfidenceLevel, ScanArgs}, }, - global::Mode, + global::{Mode, TlsMode}, GlobalArgs, }, findings_store::FindingsStore, @@ -313,6 +313,7 @@ impl TestContext { progress: Mode::Never, ignore_certs: false, user_agent_suffix: None, + tls_mode: TlsMode::Strict, }; let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir))); diff --git a/tests/live_db_validation.rs b/tests/live_db_validation.rs index 25836ef..dd5c12d 100644 --- a/tests/live_db_validation.rs +++ b/tests/live_db_validation.rs @@ -53,7 +53,7 @@ async fn validates_mysql_secret_against_testcontainer() -> Result<()> { wait_for_port(HOST_ALIAS, port).await?; let uri = format!("mysql://root:secret@{HOST_ALIAS}:{port}/app"); - let (is_valid, metadata) = validate_mysql(&uri).await?; + let (is_valid, metadata) = validate_mysql(&uri, false).await?; assert!(is_valid, "expected MySQL validation to succeed, got {metadata:?}"); assert!( @@ -85,7 +85,7 @@ async fn validates_postgres_secret_against_testcontainer() -> Result<()> { wait_for_port(HOST_ALIAS, port).await?; let uri = format!("postgres://postgres:secret@{HOST_ALIAS}:{port}/postgres"); - let (is_valid, metadata) = validate_postgres(&uri).await?; + let (is_valid, metadata) = validate_postgres(&uri, false).await?; assert!(is_valid, "expected Postgres validation to succeed"); assert!(metadata.is_empty(), "expected no metadata but found {metadata:?}"); diff --git a/tests/tls_mode.rs b/tests/tls_mode.rs new file mode 100644 index 0000000..3f4d7cf --- /dev/null +++ b/tests/tls_mode.rs @@ -0,0 +1,200 @@ +//! Tests for the `--tls-mode` CLI feature and TLS validation behavior. +//! +//! These tests verify that: +//! - The `--tls-mode` CLI flag is parsed correctly +//! - The `--ignore-certs` legacy flag is treated as `--tls-mode=off` +//! - Rules with `tls_mode: lax` are correctly parsed and respected +//! - The TLS mode behavior works as expected for different validators + +use assert_cmd::Command; +use predicates::prelude::*; + +/// Test that `--tls-mode` is recognized as a valid global option. +#[test] +fn tls_mode_flag_is_recognized() { + let mut cmd = Command::cargo_bin("kingfisher").unwrap(); + cmd.arg("--tls-mode=strict").arg("--help"); + cmd.assert().success(); +} + +/// Test that all TLS mode values are accepted. +#[test] +fn tls_mode_accepts_all_values() { + for mode in ["strict", "lax", "off"] { + let mut cmd = Command::cargo_bin("kingfisher").unwrap(); + cmd.arg(format!("--tls-mode={}", mode)).arg("--help"); + cmd.assert().success(); + } +} + +/// Test that invalid TLS mode values are rejected. +#[test] +fn tls_mode_rejects_invalid_values() { + let mut cmd = Command::cargo_bin("kingfisher").unwrap(); + cmd.arg("--tls-mode=invalid").arg("--help"); + cmd.assert().failure().stderr(predicate::str::contains("invalid")); +} + +/// Test that `--ignore-certs` is still accepted (deprecated but supported). +#[test] +fn ignore_certs_flag_still_works() { + let mut cmd = Command::cargo_bin("kingfisher").unwrap(); + cmd.arg("--ignore-certs").arg("--help"); + cmd.assert().success(); +} + +/// Test that --tls-mode appears in the help output. +#[test] +fn tls_mode_appears_in_help() { + let mut cmd = Command::cargo_bin("kingfisher").unwrap(); + cmd.arg("--help"); + cmd.assert().success().stdout(predicate::str::contains("--tls-mode")); +} + +/// Test that rules list subcommand runs with tls-mode flag. +#[test] +fn rules_list_works_with_tls_mode() { + let mut cmd = Command::cargo_bin("kingfisher").unwrap(); + cmd.arg("--tls-mode=lax").arg("rules").arg("list"); + cmd.assert() + .success() + .stdout(predicate::str::contains("postgres").or(predicate::str::contains("Postgres"))); +} + +/// Test that a scan with `--tls-mode=strict` runs successfully. +#[test] +fn scan_with_strict_mode_runs() { + let mut cmd = Command::cargo_bin("kingfisher").unwrap(); + cmd.arg("--tls-mode=strict").arg("scan").arg("--no-validate").arg("-"); + cmd.write_stdin("test input with no secrets"); + cmd.assert().success(); +} + +/// Test that a scan with `--tls-mode=lax` runs successfully. +#[test] +fn scan_with_lax_mode_runs() { + let mut cmd = Command::cargo_bin("kingfisher").unwrap(); + cmd.arg("--tls-mode=lax").arg("scan").arg("--no-validate").arg("-"); + cmd.write_stdin("test input with no secrets"); + cmd.assert().success(); +} + +/// Test that a scan with `--tls-mode=off` runs successfully. +#[test] +fn scan_with_off_mode_runs() { + let mut cmd = Command::cargo_bin("kingfisher").unwrap(); + cmd.arg("--tls-mode=off").arg("scan").arg("--no-validate").arg("-"); + cmd.write_stdin("test input with no secrets"); + cmd.assert().success(); +} + +#[cfg(test)] +mod rule_tls_mode_tests { + use kingfisher_rules::{RuleSyntax, TlsMode}; + use serde::Deserialize; + + /// Helper struct for deserializing rule YAML files. + #[derive(Deserialize)] + struct RawRules { + rules: Vec, + } + + /// Test that the postgres rule has tls_mode: lax. + #[test] + fn postgres_rule_has_lax_tls_mode() { + let yaml = include_str!("../crates/kingfisher-rules/data/rules/postgres.yml"); + let raw: RawRules = serde_yaml::from_str(yaml).expect("postgres rules should parse"); + + let postgres_rule = raw.rules.iter().find(|r| r.id == "kingfisher.postgres.1"); + assert!(postgres_rule.is_some(), "postgres rule should exist"); + assert_eq!( + postgres_rule.unwrap().tls_mode, + Some(TlsMode::Lax), + "postgres rule should have tls_mode: lax" + ); + } + + /// Test that the mysql rule has tls_mode: lax. + #[test] + fn mysql_rule_has_lax_tls_mode() { + let yaml = include_str!("../crates/kingfisher-rules/data/rules/mysql.yml"); + let raw: RawRules = serde_yaml::from_str(yaml).expect("mysql rules should parse"); + + let mysql_rule = raw.rules.iter().find(|r| r.id == "kingfisher.mysql.1"); + assert!(mysql_rule.is_some(), "mysql rule should exist"); + assert_eq!( + mysql_rule.unwrap().tls_mode, + Some(TlsMode::Lax), + "mysql rule should have tls_mode: lax" + ); + } + + /// Test that the mongodb URI rule has tls_mode: lax. + #[test] + fn mongodb_uri_rule_has_lax_tls_mode() { + let yaml = include_str!("../crates/kingfisher-rules/data/rules/mongodb.yml"); + let raw: RawRules = serde_yaml::from_str(yaml).expect("mongodb rules should parse"); + + let mongodb_rule = raw.rules.iter().find(|r| r.id == "kingfisher.mongodb.3"); + assert!(mongodb_rule.is_some(), "mongodb.3 rule should exist"); + assert_eq!( + mongodb_rule.unwrap().tls_mode, + Some(TlsMode::Lax), + "mongodb.3 rule should have tls_mode: lax" + ); + } + + /// Test that the jdbc rule has tls_mode: lax. + #[test] + fn jdbc_rule_has_lax_tls_mode() { + let yaml = include_str!("../crates/kingfisher-rules/data/rules/jdbc.yml"); + let raw: RawRules = serde_yaml::from_str(yaml).expect("jdbc rules should parse"); + + let jdbc_rule = raw.rules.iter().find(|r| r.id == "kingfisher.jdbc.1"); + assert!(jdbc_rule.is_some(), "jdbc rule should exist"); + assert_eq!( + jdbc_rule.unwrap().tls_mode, + Some(TlsMode::Lax), + "jdbc rule should have tls_mode: lax" + ); + } + + /// Test that the jwt rule has tls_mode: lax. + #[test] + fn jwt_rule_has_lax_tls_mode() { + let yaml = include_str!("../crates/kingfisher-rules/data/rules/jwt.yml"); + let raw: RawRules = serde_yaml::from_str(yaml).expect("jwt rules should parse"); + + let jwt_rule = raw.rules.iter().find(|r| r.id == "kingfisher.jwt.1"); + assert!(jwt_rule.is_some(), "jwt rule should exist"); + assert_eq!( + jwt_rule.unwrap().tls_mode, + Some(TlsMode::Lax), + "jwt rule should have tls_mode: lax" + ); + } + + /// Test that rules without tls_mode (e.g., SaaS APIs) have None. + #[test] + fn github_rule_has_no_tls_mode() { + let yaml = include_str!("../crates/kingfisher-rules/data/rules/github.yml"); + let raw: RawRules = serde_yaml::from_str(yaml).expect("github rules should parse"); + + // GitHub rules should not have tls_mode set (SaaS API, always strict) + for rule in &raw.rules { + assert_eq!(rule.tls_mode, None, "github rule {} should not have tls_mode set", rule.id); + } + } + + /// Test that rules without tls_mode (e.g., SaaS APIs) have None. + #[test] + fn aws_rule_has_no_tls_mode() { + let yaml = include_str!("../crates/kingfisher-rules/data/rules/aws.yml"); + let raw: RawRules = serde_yaml::from_str(yaml).expect("aws rules should parse"); + + // AWS rules should not have tls_mode set (SaaS API, always strict) + for rule in &raw.rules { + assert_eq!(rule.tls_mode, None, "aws rule {} should not have tls_mode set", rule.id); + } + } +}