JWT tokens without both 'iss' and 'aud' are no longer reported as active credentials

This commit is contained in:
Mick Grove 2025-08-07 17:21:16 -07:00
commit b71fb5e6e2
5 changed files with 85 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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