diff --git a/CHANGELOG.md b/CHANGELOG.md index b06639d..d6bc09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [1.36.0] - Fixed GitHub organization and GitLab group scans when using `--git-history=none` +- JWT tokens without both `iss` and `aud` are no longer reported as active credentials ## [1.35.0] - Remote scans with `--git-history=none` now clone repositories with a working tree and scan the current files instead of erroring with "No inputs to scan". diff --git a/data/rules/jira.yml b/data/rules/jira.yml index 82ac195..a555d85 100644 --- a/data/rules/jira.yml +++ b/data/rules/jira.yml @@ -11,7 +11,7 @@ rules: visible: false confidence: medium examples: - - example-jira.atlassian.net + - examplefoo-jira.atlassian.net - jira.sprintUri= https://example.atlassian.net/rest - name: Jira Token diff --git a/src/scanner/validation.rs b/src/scanner/validation.rs index 210ef26..1ba02e1 100644 --- a/src/scanner/validation.rs +++ b/src/scanner/validation.rs @@ -335,12 +335,6 @@ pub async fn run_secret_validation( ds.replace_matches(updated_arcs); } - // ── 5. Done ───────────────────────────────────────────────────────────── - println!( - "Validation complete – {} succeeded, {} failed", - success_count.load(Ordering::Relaxed), - fail_count.load(Ordering::Relaxed) - ); Ok(()) } diff --git a/src/validation/jwt.rs b/src/validation/jwt.rs index 25a7206..d5485d6 100644 --- a/src/validation/jwt.rs +++ b/src/validation/jwt.rs @@ -71,7 +71,11 @@ pub async fn validate_jwt(token: &str) -> Result<(bool, String)> { // --------------------------------------------------------------------------- let issuer = claims.iss.clone().unwrap_or_default(); + let aud_strings = extract_aud_strings(&claims); + if issuer.trim().is_empty() && aud_strings.iter().all(|s| s.trim().is_empty()) { + return Ok((false, "JWT missing issuer and audience".to_string())); + } if let Some(iss) = claims.iss.clone() { // parse header now (kid, alg) let header = decode_header(token).map_err(|e| anyhow!("decode header: {e}"))?; diff --git a/tests/int_rules_no_validated_findings.rs b/tests/int_rules_no_validated_findings.rs new file mode 100644 index 0000000..51d4a3b --- /dev/null +++ b/tests/int_rules_no_validated_findings.rs @@ -0,0 +1,79 @@ +use anyhow::Result; +use assert_cmd::Command; +use serde_json::Value; + +#[test] +fn scan_rules_has_no_validated_findings() -> Result<()> { + let output = Command::cargo_bin("kingfisher")? + .args([ + "scan", "data/rules", + "--format", "json", + "--no-update-check", + "--only-valid", + ]) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Find the first '[' — start of array + let start = match stdout.find('[') { + Some(i) => i, + None => return Ok(()), // no array found + }; + + let mut depth = 0usize; + let mut end = None; + for (i, ch) in stdout.char_indices().skip(start) { + match ch { + '[' => depth += 1, + ']' => { + depth -= 1; + if depth == 0 { + end = Some(i); + break; + } + } + _ => {} + } + } + + let json_array_str = match end { + Some(end_idx) => &stdout[start..=end_idx], + None => return Ok(()), // no matching close found + }; + + if json_array_str.trim().is_empty() { + return Ok(()); + } + + let findings: Vec = serde_json::from_str(json_array_str)?; + + for finding in findings { + let rule_id = finding["rule"]["id"].as_str().unwrap_or("unknown"); + let rule_prevalidated = finding["rule"]["prevalidated"].as_bool().unwrap_or(false); + + let status = finding["finding"]["validation"]["status"] + .as_str() + .unwrap_or("") + .to_ascii_lowercase(); + + let response = finding["finding"]["validation"]["response"] + .as_str() + .unwrap_or("") + .to_ascii_lowercase(); + + // Skip anything intentionally marked as prevalidated + if rule_prevalidated || status == "prevalidated" || response == "prevalidated" { + continue; + } + + // Fail only on genuinely validated secrets + assert_ne!( + status.as_str(), + "active credential", + "Validated finding detected in rule {rule_id}" + ); + } + + Ok(()) +}