forked from mirrors/kingfisher
added jdbc rule and validator
This commit is contained in:
parent
d6c1dfc9d0
commit
2ed94f75d7
7 changed files with 319 additions and 0 deletions
|
|
@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [v1.64.0]
|
||||
- Fixed a bug when using --redact, that broke validation
|
||||
- Added JDBC rule with validator
|
||||
|
||||
## [v1.63.1]
|
||||
- Updated allocator
|
||||
|
|
|
|||
24
data/rules/jdbc.yml
Normal file
24
data/rules/jdbc.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
rules:
|
||||
- name: JDBC connection string with embedded credentials
|
||||
id: kingfisher.jdbc.1
|
||||
pattern: |
|
||||
(?xi)
|
||||
(
|
||||
jdbc:
|
||||
[a-z][a-z0-9+.-]{2,30}
|
||||
(?:[:][a-z0-9+.-]{1,30})*
|
||||
:
|
||||
[^\s"'<>,(){}\[\]]{10,512}
|
||||
)
|
||||
min_entropy: 3.3
|
||||
confidence: medium
|
||||
validation:
|
||||
type: Jdbc
|
||||
examples:
|
||||
- jdbc:postgresql://db.example.com:5432/app?user=admin&password=s3cr3t
|
||||
- jdbc:mysql://admin:s3cr3t@prod.internal:3306/inventory
|
||||
- jdbc:oracle:thin:@ora.example.net:1521/ORCLPDB1
|
||||
- jdbc:sqlserver://sql.example.org:1433;databaseName=inventory;user=sa;password=s3cr3t!
|
||||
references:
|
||||
- https://docs.oracle.com/javase/8/docs/api/java/sql/DriverManager.html
|
||||
- https://www.postgresql.org/docs/current/jdbc-use.html
|
||||
|
|
@ -47,6 +47,7 @@ pub enum Validation {
|
|||
GCP,
|
||||
MongoDB,
|
||||
Postgres,
|
||||
Jdbc,
|
||||
JWT,
|
||||
Raw(String),
|
||||
Http(HttpValidation),
|
||||
|
|
|
|||
|
|
@ -198,6 +198,13 @@ pub fn is_safe_match_reason(input: &[u8]) -> Option<&'static str> {
|
|||
.map(|rule| rule.description)
|
||||
}
|
||||
|
||||
/// Test helper: clear all user-provided allow-list configuration.
|
||||
#[doc(hidden)]
|
||||
pub fn clear_user_filters_for_tests() {
|
||||
USER_SAFE_REGEXES.lock().unwrap().clear();
|
||||
USER_SAFE_SKIPWORDS.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Returns true if the input likely contains *benign* placeholder/test strings,
|
||||
/// and logs which rule triggered at `debug!` level.
|
||||
pub fn is_safe_match(input: &[u8]) -> bool {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ mod azure;
|
|||
mod coinbase;
|
||||
mod gcp;
|
||||
mod httpvalidation;
|
||||
mod jdbc;
|
||||
mod jwt;
|
||||
mod mongodb;
|
||||
mod postgres;
|
||||
|
|
@ -676,6 +677,58 @@ async fn timed_validate_single_match<'a>(
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- JDBC validator
|
||||
Some(Validation::Jdbc) => {
|
||||
let jdbc_conn = captured_values
|
||||
.iter()
|
||||
.find(|(n, ..)| n == "TOKEN")
|
||||
.map(|(_, v, ..)| v.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
if jdbc_conn.is_empty() {
|
||||
m.validation_success = false;
|
||||
m.validation_response_body = "JDBC connection string not found.".to_string();
|
||||
m.validation_response_status = StatusCode::BAD_REQUEST;
|
||||
commit_and_return(m);
|
||||
return;
|
||||
}
|
||||
|
||||
let cache_key = jdbc::generate_jdbc_cache_key(&jdbc_conn);
|
||||
if let Some(cached) = cache.get(&cache_key) {
|
||||
let c = cached.value();
|
||||
if c.timestamp.elapsed() < Duration::from_secs(VALIDATION_CACHE_SECONDS) {
|
||||
m.validation_success = c.is_valid;
|
||||
m.validation_response_body = c.body.clone();
|
||||
m.validation_response_status = c.status;
|
||||
commit_and_return(m);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match jdbc::validate_jdbc(&jdbc_conn).await {
|
||||
Ok(outcome) => {
|
||||
m.validation_success = outcome.valid;
|
||||
m.validation_response_body = outcome.message;
|
||||
m.validation_response_status = outcome.status;
|
||||
}
|
||||
Err(e) => {
|
||||
m.validation_success = false;
|
||||
m.validation_response_body = format!("JDBC validation error: {}", e);
|
||||
m.validation_response_status = StatusCode::BAD_GATEWAY;
|
||||
}
|
||||
}
|
||||
|
||||
cache.insert(
|
||||
cache_key,
|
||||
CachedResponse {
|
||||
body: m.validation_response_body.clone(),
|
||||
status: m.validation_response_status,
|
||||
is_valid: m.validation_success,
|
||||
timestamp: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Postgres validator
|
||||
Some(Validation::Postgres) => {
|
||||
let pg_url = globals
|
||||
|
|
|
|||
154
src/validation/jdbc.rs
Normal file
154
src/validation/jdbc.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use http::StatusCode;
|
||||
use tracing::debug;
|
||||
use url::Url;
|
||||
use xxhash_rust::xxh3::xxh3_64;
|
||||
|
||||
use super::postgres;
|
||||
|
||||
/// Result of attempting to validate a JDBC connection string.
|
||||
pub struct JdbcValidationOutcome {
|
||||
pub valid: bool,
|
||||
pub status: StatusCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Produce a short-lived cache key for JDBC validations.
|
||||
pub fn generate_jdbc_cache_key(raw: &str) -> String {
|
||||
format!("Jdbc:{:016x}", xxh3_64(raw.as_bytes()))
|
||||
}
|
||||
|
||||
/// Validate a JDBC connection string by dispatching to the supported backend validators.
|
||||
pub async fn validate_jdbc(jdbc_conn: &str) -> 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:`"));
|
||||
}
|
||||
|
||||
let without_prefix = &trimmed[5..];
|
||||
let (raw_subprotocol, subname) = without_prefix
|
||||
.split_once(':')
|
||||
.ok_or_else(|| anyhow!("JDBC connection string is missing a subprotocol"))?;
|
||||
let subprotocol = raw_subprotocol.trim();
|
||||
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")
|
||||
}
|
||||
other => {
|
||||
debug!("Unsupported JDBC subprotocol encountered: {}", other);
|
||||
Ok(JdbcValidationOutcome {
|
||||
valid: false,
|
||||
status: StatusCode::NOT_IMPLEMENTED,
|
||||
message: format!(
|
||||
"JDBC validation not implemented for subprotocol `{}`.",
|
||||
subprotocol
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_postgres_jdbc(subname: &str) -> Result<JdbcValidationOutcome> {
|
||||
let normalized = normalize_postgres_url(subname)?;
|
||||
let (ok, meta) = postgres::validate_postgres(&normalized).await?;
|
||||
|
||||
let mut message = if ok {
|
||||
"JDBC Postgres connection is valid.".to_string()
|
||||
} else {
|
||||
"JDBC Postgres connection failed.".to_string()
|
||||
};
|
||||
|
||||
if !meta.is_empty() {
|
||||
let joined = meta.join("; ");
|
||||
if ok {
|
||||
message.push_str(&format!(" Details: {}", joined));
|
||||
} else {
|
||||
message = format!("JDBC Postgres validation result: {}", joined);
|
||||
}
|
||||
}
|
||||
|
||||
let status = if ok {
|
||||
StatusCode::OK
|
||||
} else if meta.iter().any(|m| m.to_ascii_lowercase().contains("skip")) {
|
||||
StatusCode::CONTINUE
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED
|
||||
};
|
||||
|
||||
Ok(JdbcValidationOutcome { valid: ok, status, message })
|
||||
}
|
||||
|
||||
fn normalize_postgres_url(subname: &str) -> Result<String> {
|
||||
let trimmed = subname.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("Postgres JDBC connection string is empty"));
|
||||
}
|
||||
|
||||
// First try parsing using the standard JDBC layout, otherwise fall back to a canonical URL.
|
||||
let candidate = format!("postgresql:{}", trimmed);
|
||||
let mut url = Url::parse(&candidate).or_else(|_| {
|
||||
let fallback = format!("postgresql://{}", trimmed.trim_start_matches('/'));
|
||||
Url::parse(&fallback)
|
||||
})?;
|
||||
|
||||
// Extract credentials from the query string when they are present.
|
||||
let mut user = None;
|
||||
let mut password = None;
|
||||
if url.query().is_some() {
|
||||
let mut preserved = Vec::new();
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.to_ascii_lowercase().as_str() {
|
||||
"user" | "username" => user = Some(value.into_owned()),
|
||||
"password" | "pass" | "pwd" => password = Some(value.into_owned()),
|
||||
_ => preserved.push((key.into_owned(), value.into_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
pairs.clear();
|
||||
for (key, value) in preserved {
|
||||
pairs.append_pair(&key, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(user) = user {
|
||||
url.set_username(&user).map_err(|_| anyhow!("Failed to apply Postgres username"))?;
|
||||
}
|
||||
if let Some(password) = password {
|
||||
url.set_password(Some(&password))
|
||||
.map_err(|_| anyhow!("Failed to apply Postgres password"))?;
|
||||
}
|
||||
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_postgres_url;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn normalizes_postgres_query_credentials() {
|
||||
let normalized = normalize_postgres_url(
|
||||
"//db.example.com:5432/app?user=admin&password=s3cr3t&sslmode=require",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(normalized, "postgresql://admin:s3cr3t@db.example.com:5432/app?sslmode=require");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_existing_credentials() {
|
||||
let normalized =
|
||||
normalize_postgres_url("//db.example.com:5432/app?sslmode=prefer").unwrap();
|
||||
assert_eq!(normalized, "postgresql://db.example.com:5432/app?sslmode=prefer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_input() {
|
||||
assert!(normalize_postgres_url("").is_err());
|
||||
}
|
||||
}
|
||||
79
tests/jdbc_rule.rs
Normal file
79
tests/jdbc_rule.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use kingfisher::{rules::rule::RuleSyntax, safe_list};
|
||||
|
||||
fn load_jdbc_rule() -> Result<RuleSyntax> {
|
||||
let rules = RuleSyntax::from_yaml_file("data/rules/jdbc.yml")?;
|
||||
rules
|
||||
.into_iter()
|
||||
.find(|rule| rule.id == "kingfisher.jdbc.1")
|
||||
.ok_or_else(|| anyhow!("JDBC rule not found"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdbc_rule_matches_expected_patterns() -> Result<()> {
|
||||
let rule = load_jdbc_rule()?;
|
||||
let regex = rule.as_regex()?;
|
||||
|
||||
let sample = r#"
|
||||
datasource.url=jdbc:postgresql://db.acme.local:5432/app?user=svc_writer&password=P@s5w0rd
|
||||
connection.read=jdbc:mysql://analyst:letmein@reports.internal:3306/analytics
|
||||
cache="jdbc:sqlite:/var/lib/app/cache.db"
|
||||
vendor.dsn=jdbc:oracle:thin:@ora.example.net:1521/ORCLPDB1
|
||||
backup=jdbc:mysql://host:3306/db,other_token
|
||||
jdbc:xyz:short // this should be ignored
|
||||
somejdbc:mysql://host/db // false prefix
|
||||
jdbc:mysql://host/db>next // malformed with trailing bracket
|
||||
"#;
|
||||
|
||||
let matches: BTreeSet<String> = regex
|
||||
.captures_iter(sample.as_bytes())
|
||||
.filter_map(|caps| caps.name("TOKEN"))
|
||||
.map(|m| String::from_utf8_lossy(m.as_bytes()).into_owned())
|
||||
.collect();
|
||||
|
||||
let expected = BTreeSet::from([
|
||||
"jdbc:postgresql://db.acme.local:5432/app?user=svc_writer&password=P@s5w0rd".to_string(),
|
||||
"jdbc:mysql://analyst:letmein@reports.internal:3306/analytics".to_string(),
|
||||
"jdbc:sqlite:/var/lib/app/cache.db".to_string(),
|
||||
"jdbc:oracle:thin:@ora.example.net:1521/ORCLPDB1".to_string(),
|
||||
"jdbc:mysql://host:3306/db".to_string(),
|
||||
]);
|
||||
|
||||
assert_eq!(matches, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdbc_rule_respects_user_skip_regex() -> Result<()> {
|
||||
safe_list::clear_user_filters_for_tests();
|
||||
safe_list::add_user_regex(r"^jdbc:sqlite::temporary_ignore_secret$")?;
|
||||
|
||||
let rule = load_jdbc_rule()?;
|
||||
let regex = rule.as_regex()?;
|
||||
|
||||
let sample = r#"
|
||||
jdbc:sqlite::temporary_ignore_secret
|
||||
jdbc:mysql://data_ingest:pa55word@analytics.internal:3306/raw
|
||||
"#;
|
||||
|
||||
let matches: Vec<String> = regex
|
||||
.captures_iter(sample.as_bytes())
|
||||
.filter_map(|caps| caps.name("TOKEN"))
|
||||
.map(|m| String::from_utf8_lossy(m.as_bytes()).into_owned())
|
||||
.collect();
|
||||
|
||||
let retained: Vec<String> = matches
|
||||
.into_iter()
|
||||
.filter(|m| !safe_list::is_user_match(m.as_bytes(), m.as_bytes()))
|
||||
.collect();
|
||||
|
||||
safe_list::clear_user_filters_for_tests();
|
||||
|
||||
assert_eq!(
|
||||
retained,
|
||||
vec!["jdbc:mysql://data_ingest:pa55word@analytics.internal:3306/raw".to_string()]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue