preparing for v1.78.0

This commit is contained in:
Mick Grove 2026-02-02 23:22:08 -08:00
commit 5253204c2a
38 changed files with 1943 additions and 94 deletions

View file

@ -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=<value>` or `--var AZURENAME=<value>`.
- 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 <strict|lax|off>` 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.

View file

@ -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

View file

@ -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
type: JWT
tls_mode: lax

View file

@ -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
type: MongoDB
tls_mode: lax

View file

@ -44,3 +44,4 @@ rules:
- mysql://user:pass@example.com:4406/app_db?ssl-mode=REQUIRED
validation:
type: MySQL
tls_mode: lax

View file

@ -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
type: Postgres
tls_mode: lax

View file

@ -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

View file

@ -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<PatternRequirements>,
/// 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<TlsMode>,
}
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<TlsMode> {
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<RuleSyntax>,
}
#[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);
}
}

View file

@ -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());

View file

@ -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 |

View file

@ -149,6 +149,7 @@ mod tests {
revocation: None,
depends_on_rule: vec![],
pattern_requirements: None,
tls_mode: None,
}))
}

View file

@ -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);
}
}

View file

@ -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(),

View file

@ -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])?;

View file

@ -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");

View file

@ -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 {

View file

@ -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,

View file

@ -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<Mutex<FindingsStore>>,
parser: &Parser,
client: &Client,
clients: &crate::validation::ValidationClients,
cache: &Arc<SkipMap<String, CachedResponse>>,
num_jobs: usize,
range: Option<std::ops::Range<usize>>,
@ -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<String, Vec<(String, OffsetSpan)>>,
missing_deps: &FxHashMap<String, Vec<String>>,
cache: &DashMap<String, CachedResponse>,
@ -517,7 +517,7 @@ async fn validate_single(
validate_single_match(
om,
parser,
client,
clients,
dep_vars,
missing_deps,
cache2,

View file

@ -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<S: Into<String>>(suffix: Option<S>) {
}
}
/// 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<Self> {
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<kingfisher_rules::TlsMode>) -> &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<kingfisher_rules::TlsMode>) -> 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<SkipMap<String, CachedResponse>>;
@ -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<String, Vec<(String, OffsetSpan)>>,
missing_dependencies: &FxHashMap<String, Vec<String>>,
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<String, Vec<(String, OffsetSpan)>>,
missing_dependencies: &FxHashMap<String, Vec<String>>,
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)]

View file

@ -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<JdbcValidationOutcome> {
///
/// # 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<JdbcValidationOutcome> {
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<JdbcValidationOutcome> {
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<JdbcValidationOutcome> {
}
}
async fn validate_postgres_jdbc(subname: &str) -> Result<JdbcValidationOutcome> {
async fn validate_postgres_jdbc(subname: &str, lax_tls: bool) -> Result<JdbcValidationOutcome> {
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()

View file

@ -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<Client> = 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<Client> = 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<Client> = 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();

View file

@ -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;

View file

@ -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<String>)> {
/// 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<String>)> {
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<String>)> {
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();

View file

@ -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<CryptoProvider>);
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<ServerCertVerified, rustls::Error> {
// 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<HandshakeSignatureValid, rustls::Error> {
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<HandshakeSignatureValid, rustls::Error> {
verify_tls13_signature(message, cert, dss, &self.0.signature_verification_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
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<Config> {
}
}
pub async fn validate_postgres(postgres_url: &str) -> Result<(bool, Vec<String>)> {
/// 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<String>)> {
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<String>)
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<String>)> {
// 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?;

1039
tests/cli_validate_revoke.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@ fn make_rule(rule_id: &str, depends_on_rule: Vec<Option<DependsOnRule>>) -> Arc<
revocation: None,
depends_on_rule,
pattern_requirements: None,
tls_mode: None,
}))
}
@ -57,6 +58,7 @@ fn make_match(rule: Arc<Rule>, blob_id: BlobId, value: &str) -> Match {
calculated_entropy: 0.0,
visible: true,
is_base64: false,
dependent_captures: std::collections::BTreeMap::new(),
}
}

View file

@ -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(),
}
}

View file

@ -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<String>, skip_skipword: Vec<String>) -> Result<u
self_update: false,
ignore_certs: false,
user_agent_suffix: None,
tls_mode: TlsMode::Strict,
};
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;

View file

@ -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 test_bitbucket_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)));

View file

@ -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 ─────────────────────────────────────────────

View file

@ -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)));

View file

@ -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)));

View file

@ -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)?;

View file

@ -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)));

View file

@ -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();

View file

@ -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)));

View file

@ -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:?}");

200
tests/tls_mode.rs Normal file
View file

@ -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<RuleSyntax>,
}
/// 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);
}
}
}