- Added rules for clearbit, kickbox, azure container registry, improved Azure Storage key

- Grouped JSON and JSONL outputs by rule, restoring matches arrays in reports
This commit is contained in:
Mick Grove 2025-08-18 22:56:34 -07:00
commit 951b62d61e
10 changed files with 166 additions and 39 deletions

View file

@ -2,6 +2,10 @@
All notable changes to this project will be documented in this file.
## [1.43.0]
- Added rules for clearbit, kickbox, azure container registry, improved Azure Storage key
- Grouped JSON and JSONL outputs by rule, restoring `matches` arrays in reports
## [1.42.0]
- Fixed pagination issue when calling gitlab api
- Expanded directory exclusion handling to interpret plain patterns as prefixes, ensuring options like --exclude .git also skip all nested paths

View file

@ -10,7 +10,7 @@ publish = false
[package]
name = "kingfisher"
version = "1.42.0"
version = "1.43.0"
description = "MongoDB's blazingly fast secret scanning and validation tool"
edition.workspace = true
rust-version.workspace = true

View file

@ -27,17 +27,10 @@ rules:
"AZURE_STORAGE_CONNECTION_STRING": {
"value": "DefaultEndpointsProtocol=https;AccountName=d1biblobstor521;AccountKey=NjEwGHd9+piK+iCi2C2XURWPmeDDjif9UKN1HAszYptL4iQ+yD7/dgjLMZc3VOpURsa53aJ4HZfbVWzL429C5g==;EndpointSuffix=core.windows.net"
}
negative_examples:
- 'InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com;'
- 'InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://custom.com:111/;LiveEndpoint=https://custom.com:222/;ProfilerEndpoint=https://custom.com:333/;SnapshotEndpoint=https://custom.com:444/;'
references:
- https://azure.microsoft.com/en-us/blog/windows-azure-web-sites-how-application-strings-and-connection-strings-work/
- https://docs.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string
- https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-sas#best-practices-when-using-sas
categories:
- api
- fuzzy
- secret
- name: Azure App Configuration Connection String
id: kingfisher.azure.2
@ -53,18 +46,10 @@ rules:
- 'https://foo-nonprod-appconfig.azconfig.io;Id=ABCD-E6-s0:tl6ABcdefGHi7kLMno/p;Secret=abCD1EF+GHIJxLMnOA53ST8uVWX05zaBCdE/fg9hi4k='
- 'Endpoint=https://appconfig-test01.azconfig.io;Id=09pv-l0-s0:opFCQMC6+9485xJgN5Ws;Secret=GcoEA53t7GLRNJ910M46IrbHO/Vg0tt4HujRdsaCoTY='
- ' private static string appConfigurationConnectionString = "Endpoint=https://appcs-fg-pwc.azconfig.io;Id=pi5x-l9-s0:SZLlhHA53Nz2MpAl04cU;Secret=CQ+mlfQqkzfZv4XA53gigJ/seeXMKwNsqW/rM3wmtuE=";'
negative_examples:
- |
text:
az appconfig feature delete --connection-string Endpoint=https://contoso.azconfig.io;Id=xxx;Secret=xxx --feature color --label MyLabel
references:
- https://docs.microsoft.com/en-us/azure/azure-app-configuration/
- https://docs.microsoft.com/en-us/azure/azure-app-configuration/howto-best-practices
- https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/appconfiguration/azure-appconfiguration/azure/appconfiguration/_utils.py
categories:
- api
- fuzzy
- secret
- name: Azure Personal Access Token
id: kingfisher.azure.3
@ -84,4 +69,45 @@ rules:
$token = "58oo4mvqr2tpw7b4w3loeckwfu5o6nw3sihfckvlwoxgqimlddza"
- |
if __name__ == "__main__":
ado_pat = "iyfmob6xjrfmit67anxbot64umfx2clwx7dz5ynxi4q2z3uqegvq"
ado_pat = "iyfmob6xjrfmit67anxbot64umfx2clwx7dz5ynxi4q2z3uqegvq"
- name: Azure Container Registry URL
id: kingfisher.azure.4
pattern: |
(?xi)
(
[a-z0-9][a-z0-9-]{1,100}[a-z0-9]
)\.azurecr\.io
confidence: medium
min_entropy: 2.0
examples:
- "myregistry.azurecr.io"
- name: Azure Container Registry Password
id: kingfisher.azure.5
pattern: |
(?xi)
\b
(
[A-Z0-9+/]{42}\+ACR[A-Z0-9]{6}
)
\b
confidence: medium
min_entropy: 4.0
validation:
type: Http
content:
request:
method: GET
url: "https://{{ACR_USERNAME}}.azurecr.io/v2/_catalog"
headers:
Authorization: "Basic {{ ACR_USERNAME | append: ':' | append: TOKEN | b64enc }}"
response_matcher:
- report_response: true
- type: StatusMatch
status: [200]
examples:
- "Abcdefghijklmnopqrstuvwxyz1234567890ABCD+ACRefg123"
depends_on_rule:
- rule_id: "kingfisher.azure.4"
variable: ACR_USERNAME
references:
- https://learn.microsoft.com/en-us/azure/container-registry/container-registry-authentication

View file

@ -31,9 +31,9 @@ rules:
(?xi)
\b
azure
(?:.|[\n\r]){0,32}?
(?i:(?:Access|Account|Storage)[_.-]?Key)
(?:.|[\n\r]){0,25}?
(?:.|[\n\r]){0,128}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,128}?
(
[A-Z0-9+\\/-]{86,88}={0,2}
)

33
data/rules/clearbit.yml Normal file
View file

@ -0,0 +1,33 @@
rules:
- name: Clearbit API Key
id: kingfisher.clearbit.1
pattern: |
(?xi)
\b
clearbit
(?:.|[\n\r]){0,16}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,32}?
\b
(
[0-9a-z_]{35}
)
\b
min_entropy: 3.5
confidence: medium
examples:
- clearbit_token = tq50141fm92fl4nid9c1c7liouhbertbvg1
validation:
type: Http
content:
request:
method: GET
url: https://discovery.clearbit.com/v1/companies/entities?name=kingfisher
headers:
Authorization: "Bearer {{ TOKEN }}"
response_matcher:
- report_response: true
- type: WordMatch
words:
- '"Invalid API key provided"'
negative: true

View file

@ -3,10 +3,10 @@ rules:
id: kingfisher.intercom.1
pattern: |
(?xi)
(?:intercom|ic)
(?:.|[\n\r]){0,16}?
(?:intercom|ic)
(?:.|[\n\r]){0,16}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,16}?
(?:.|[\n\r]){0,16}?
(
[0-9A-Z+/]{59}=
)

32
data/rules/kickbox.yml Normal file
View file

@ -0,0 +1,32 @@
rules:
- name: Kickbox API Key
id: kingfisher.kickbox.1
pattern: |
(?xi)
\b
kickbox
(?:.|[\n\r]){0,32}?
(?:SECRET|PRIVATE|ACCESS|KEY|TOKEN)
(?:.|[\n\r]){0,32}?
\b
(
[A-Z0-9_]+[A-Z0-9]{64}
)
\b
min_entropy: 3.5
confidence: medium
examples:
- kickbox_key=test_abcdefghijklmnopqrstuvwxyzbu9JFVJtII3FINL1rOKcNpveXD4hSMtSDx7opOWd
- kickbox_token=live_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789efgh
validation:
type: Http
content:
request:
method: GET
url: "https://api.kickbox.com/v2/verify?apikey={{ TOKEN }}&email=kingfisher"
response_matcher:
- report_response: true
- type: JsonValid
- type: WordMatch
words:
- '"success":true'

View file

@ -517,6 +517,12 @@ pub struct FindingRecordData {
pub git_metadata: Option<serde_json::Value>,
}
#[derive(Serialize, JsonSchema, Clone, Debug)]
pub struct RuleMatches {
pub id: String,
pub matches: Vec<FindingReporterRecord>,
}
impl From<finding_data::FindingDataEntry> for ReportMatch {
fn from(e: finding_data::FindingDataEntry) -> Self {
ReportMatch {

View file

@ -1,4 +1,5 @@
use super::*;
use std::collections::BTreeMap;
impl DetailsReporter {
pub fn json_format<W: std::io::Write>(
@ -8,7 +9,13 @@ impl DetailsReporter {
) -> Result<()> {
let records = self.build_finding_records(args)?;
if !records.is_empty() {
serde_json::to_writer_pretty(&mut writer, &records)?;
let mut grouped: BTreeMap<String, Vec<FindingReporterRecord>> = BTreeMap::new();
for record in records {
grouped.entry(record.rule.id.clone()).or_default().push(record);
}
let groups: Vec<RuleMatches> =
grouped.into_iter().map(|(id, matches)| RuleMatches { id, matches }).collect();
serde_json::to_writer_pretty(&mut writer, &groups)?;
writeln!(writer)?;
}
Ok(())
@ -20,9 +27,16 @@ impl DetailsReporter {
args: &cli::commands::scan::ScanArgs,
) -> Result<()> {
let records = self.build_finding_records(args)?;
for record in records {
serde_json::to_writer(&mut writer, &record)?;
writeln!(writer)?;
if !records.is_empty() {
let mut grouped: BTreeMap<String, Vec<FindingReporterRecord>> = BTreeMap::new();
for record in records {
grouped.entry(record.rule.id.clone()).or_default().push(record);
}
for (id, matches) in grouped {
let group = RuleMatches { id, matches };
serde_json::to_writer(&mut writer, &group)?;
writeln!(writer)?;
}
}
Ok(())
}
@ -223,7 +237,10 @@ mod tests {
reporter.json_format(&mut output, &create_default_args())?;
let json_output: Vec<serde_json::Value> = serde_json::from_slice(&output.into_inner())?;
assert!(!json_output.is_empty(), "JSON output should not be empty");
let first = &json_output[0];
let first_group = &json_output[0];
assert_eq!(first_group["id"], "mock_rule_1");
let matches = first_group["matches"].as_array().unwrap();
let first = &matches[0];
assert_eq!(first["rule"]["name"], "MockRule");
assert_eq!(first["finding"]["language"], "Rust");
Ok(())
@ -264,8 +281,10 @@ mod tests {
reporter.json_format(&mut output, &create_default_args())?;
let json_output: Vec<serde_json::Value> = serde_json::from_slice(&output.into_inner())?;
assert!(!json_output.is_empty(), "JSON output should not be empty");
let first = &json_output[0];
let validation_status = first["finding"]["validation"]["status"].as_str().unwrap();
let first_group = &json_output[0];
let first_match = &first_group["matches"][0];
let validation_status =
first_match["finding"]["validation"]["status"].as_str().unwrap();
assert_eq!(validation_status, expected_status);
}
Ok(())

View file

@ -41,16 +41,23 @@ fn scan_rules_has_no_validated_findings() -> Result<()> {
return Ok(());
}
let findings: Vec<Value> = serde_json::from_str(json_array_str)?;
let groups: 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 status =
finding["finding"]["validation"]["status"].as_str().unwrap_or("").to_ascii_lowercase();
// Fail only on genuinely validated secrets
assert_ne!(&status, "active credential", "Validated finding detected in rule {rule_id}");
for group in groups {
let rule_id = group["id"].as_str().unwrap_or("unknown");
if let Some(matches) = group["matches"].as_array() {
for finding in matches {
let status = finding["finding"]["validation"]["status"]
.as_str()
.unwrap_or("")
.to_ascii_lowercase();
// Fail only on genuinely validated secrets
assert_ne!(
&status, "active credential",
"Validated finding detected in rule {rule_id}"
);
}
}
}
Ok(())