use super::*;
impl DetailsReporter {
pub fn json_format<W: std::io::Write>(
&self,
mut writer: W,
args: &cli::commands::scan::ScanArgs,
) -> Result<()> {
let envelope = self.build_report_envelope(args)?;
let mut buf = Vec::with_capacity(8 * 1024);
serde_json::to_writer(&mut buf, &envelope)?;
buf.push(b'\n');
writer.write_all(&buf)?;
Ok(())
}
pub fn jsonl_format<W: std::io::Write>(
&self,
mut writer: W,
args: &cli::commands::scan::ScanArgs,
) -> Result<()> {
let envelope = self.build_report_envelope(args)?;
for record in envelope.findings {
serde_json::to_writer(&mut writer, &record)?;
writeln!(writer)?;
}
if let Some(access_map) = envelope.access_map {
let payload = serde_json::json!({ "access_map": access_map });
serde_json::to_writer(&mut writer, &payload)?;
writeln!(writer)?;
}
if let Some(audit) = envelope.audit {
let payload = serde_json::json!({ "audit": audit });
serde_json::to_writer(&mut writer, &payload)?;
writeln!(writer)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::commands::github::GitCloneMode;
use crate::cli::commands::github::GitHistoryMode;
use crate::cli::commands::rules::{RuleCacheArgs, RuleSpecifierArgs};
use crate::matcher::{SerializableCapture, SerializableCaptures};
use crate::rules::rule::{Confidence, Rule, RuleSyntax};
use crate::{
blob::BlobId,
cli::commands::azure::AzureRepoType,
cli::commands::bitbucket::{BitbucketAuthArgs, BitbucketRepoType},
cli::commands::gitea::GiteaRepoType,
cli::commands::github::GitHubRepoType,
cli::commands::inputs::ContentFilteringArgs,
cli::commands::inputs::InputSpecifierArgs,
cli::commands::output::{OutputArgs, ReportOutputFormat},
cli::commands::scan::ConfidenceLevel,
findings_store::FindingsStore,
location::{Location, OffsetSpan, SourcePoint, SourceSpan},
matcher::Match,
origin::Origin,
reporter::styles::Styles,
validation_body,
};
use smallvec::smallvec;
use std::{
io::Cursor,
path::PathBuf,
sync::{Arc, Mutex},
};
use url::Url;
fn create_default_args() -> cli::commands::scan::ScanArgs {
use crate::cli::commands::gitlab::GitLabRepoType;
cli::commands::scan::ScanArgs {
num_jobs: 1,
no_dedup: false,
view_report: false,
rules: RuleSpecifierArgs {
rules_path: Vec::new(),
rule: vec!["all".into()],
exclude_rule: Vec::new(),
load_builtins: true,
},
rule_cache: RuleCacheArgs::default(),
input_specifier_args: InputSpecifierArgs {
path_inputs: Vec::new(),
git_url: Vec::new(),
git_clone_dir: None,
keep_clones: false,
repo_clone_limit: None,
include_contributors: false,
github_user: Vec::new(),
github_include_gists: false,
github_organization: Vec::new(),
github_exclude: Vec::new(),
all_github_organizations: false,
github_api_url: Url::parse("https://api.github.com/").unwrap(),
github_repo_type: GitHubRepoType::Source,
github_event_user: Vec::new(),
github_event_lookback_hours: 24,
gitlab_user: Vec::new(),
gitlab_include_snippets: false,
gitlab_group: Vec::new(),
gitlab_exclude: Vec::new(),
all_gitlab_groups: false,
gitlab_api_url: Url::parse("https://gitlab.com/").unwrap(),
gitlab_repo_type: GitLabRepoType::All,
gitlab_include_subgroups: false,
huggingface_user: Vec::new(),
huggingface_organization: Vec::new(),
huggingface_model: Vec::new(),
huggingface_dataset: Vec::new(),
huggingface_space: Vec::new(),
huggingface_bucket: Vec::new(),
huggingface_exclude: Vec::new(),
gitea_user: Vec::new(),
gitea_organization: Vec::new(),
gitea_exclude: Vec::new(),
all_gitea_organizations: false,
gitea_api_url: Url::parse("https://gitea.com/api/v1/").unwrap(),
gitea_repo_type: GiteaRepoType::Source,
bitbucket_user: Vec::new(),
bitbucket_include_snippets: false,
bitbucket_workspace: Vec::new(),
bitbucket_project: Vec::new(),
bitbucket_exclude: Vec::new(),
all_bitbucket_workspaces: false,
bitbucket_api_url: Url::parse("https://api.bitbucket.org/2.0/").unwrap(),
bitbucket_repo_type: BitbucketRepoType::Source,
bitbucket_auth: BitbucketAuthArgs::default(),
azure_organization: Vec::new(),
azure_project: Vec::new(),
azure_exclude: Vec::new(),
all_azure_projects: false,
azure_base_url: Url::parse("https://dev.azure.com/").unwrap(),
azure_repo_type: AzureRepoType::Source,
jira_url: None,
jql: None,
jira_include_comments: false,
jira_include_changelog: false,
confluence_url: None,
cql: None,
max_results: 100,
slack_query: None,
slack_api_url: Url::parse("https://slack.com/api/").unwrap(),
teams_query: None,
teams_api_url: Url::parse("https://graph.microsoft.com/").unwrap(),
postman_workspaces: Vec::new(),
postman_collections: Vec::new(),
postman_environments: Vec::new(),
postman_all: false,
postman_include_mocks_monitors: false,
postman_api_url: Url::parse("https://api.getpostman.com/").unwrap(),
s3_bucket: None,
s3_prefix: None,
role_arn: None,
aws_local_profile: None,
gcs_bucket: None,
gcs_prefix: None,
gcs_service_account: None,
docker_image: Vec::new(),
docker_archive: Vec::new(),
git_clone: GitCloneMode::Bare,
git_history: GitHistoryMode::Full,
commit_metadata: true,
repo_artifacts: false,
scan_nested_repos: true,
since_commit: None,
branch: None,
branch_root: false,
branch_root_commit: None,
staged: false,
},
extra_ignore_comments: Vec::new(),
content_filtering_args: ContentFilteringArgs {
max_file_size_mb: 25.0,
no_extract_archives: false,
extraction_depth: 2,
exclude: Vec::new(), no_binary: true,
},
confidence: ConfidenceLevel::Medium,
disk_offload: false,
no_validate: false,
access_map: false,
rule_stats: false,
only_valid: false,
validation_filter: None,
include_hidden_findings: false,
min_entropy: None,
redact: false,
git_repo_timeout: 1800, audit_log: None,
output_args: OutputArgs { output: None, format: ReportOutputFormat::Pretty },
baseline_file: None,
manage_baseline: false,
skip_regex: Vec::new(),
skip_word: Vec::new(),
skip_aws_account: Vec::new(),
skip_aws_account_file: None,
no_base64: false,
turbo: false,
no_inline_ignore: false,
no_ignore_if_contains: false,
view_report_port: 7890,
view_report_address: "127.0.0.1".to_string(),
validation_timeout: 10,
validation_retries: 1,
validation_rps: None,
validation_rps_rule: Vec::new(),
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: crate::alerts::AlertOn::Findings,
alert_min_confidence: cli::commands::scan::ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: crate::alerts::AlertDetail::Auto,
alert_finding_filter: crate::alerts::AlertFindingFilter::All,
alert_prevent_empty: false,
alert_dry_run: false,
config_webhook_overrides: Vec::new(),
}
}
fn create_mock_match(rule_name: &str, rule_text_id: &str, validation_success: bool) -> Match {
let syntax = RuleSyntax {
name: rule_name.to_string(),
id: rule_text_id.to_string(),
pattern: "dummy".to_string(),
min_entropy: 0.0,
confidence: Confidence::Medium,
visible: true,
examples: vec![],
negative_examples: vec![],
references: vec![],
validation: None,
revocation: None,
depends_on_rule: vec![],
pattern_requirements: None,
tls_mode: None,
path: None,
betterleaks_filter: None,
betterleaks_secret_group: None,
authoritative: true,
vectorscan_compatible: true,
};
let rule = Arc::new(Rule::new(syntax));
Match {
location: Location::with_source_span(
OffsetSpan { start: 10, end: 20 },
Some(SourceSpan {
start: SourcePoint { line: 5, column: 10 },
end: SourcePoint { line: 5, column: 20 },
}),
),
groups: SerializableCaptures {
captures: smallvec![SerializableCapture {
name: Some("token"),
match_number: 1,
start: 10,
end: 20,
value: "mock_token".into(),
}],
},
blob_id: BlobId::new(b"mock_blob"),
finding_fingerprint: 123,
rule,
validation_response_body: validation_body::from_string("validation response"),
validation_response_status: 200,
validation_success,
validation_outcome: if validation_success {
kingfisher_core::ValidationOutcome::VerifiedActive
} else {
kingfisher_core::ValidationOutcome::VerifiedInactive
},
calculated_entropy: 4.5,
visible: true,
is_base64: false,
dependent_captures: std::collections::BTreeMap::new(),
ambiguous_dependencies: Default::default(),
dependency_candidates: Default::default(),
}
}
fn setup_mock_reporter(matches: Vec<ReportMatch>) -> DetailsReporter {
let mut datastore = FindingsStore::new(PathBuf::from("/tmp"));
if !matches.is_empty() {
let blob_metadata = BlobMetadata {
id: BlobId::new(b"mock_blob"),
num_bytes: 1024,
mime_essence: Some("text/plain".to_string()),
language: Some("Rust".to_string()),
};
let dedup = true;
for m in matches.clone() {
datastore.record(
vec![(
Arc::new(OriginSet::new(
Origin::from_file(PathBuf::from("/mock/path/file.rs")),
vec![],
)),
Arc::new(blob_metadata.clone()),
m.m.clone(),
)],
dedup,
);
}
}
DetailsReporter {
datastore: Arc::new(Mutex::new(datastore)),
styles: Styles::new(false),
validation_filter: cli::commands::scan::ValidationFilter::All,
audit_context: None,
}
}
#[test]
fn test_json_format() -> Result<()> {
let mock_match = create_mock_match("MockRule", "mock_rule_1", true);
let matches = vec![ReportMatch {
origin: OriginSet::new(Origin::from_file(PathBuf::from("/mock/path/file.rs")), vec![]),
blob_metadata: BlobMetadata {
id: BlobId::new(b"mock_blob"),
num_bytes: 1024,
mime_essence: Some("text/plain".to_string()),
language: Some("Rust".to_string()),
},
m: mock_match,
comment: None,
match_confidence: Confidence::Medium,
visible: true,
validation_response_body: validation_body::from_string("validation response"),
validation_response_status: 200,
validation_success: true,
validation_outcome: kingfisher_core::ValidationOutcome::VerifiedActive,
}];
let reporter = setup_mock_reporter(matches);
let mut collector =
crate::scan_audit::ScanAuditCollector::new("2026-01-01T00:00:00Z".to_string(), None)?;
collector.discover_local(std::path::Path::new("/tmp/repo"));
reporter.datastore.lock().unwrap().set_scan_audit(collector.finish()?);
let mut output = Cursor::new(Vec::new());
reporter.json_format(&mut output, &create_default_args())?;
let json_output: serde_json::Value = serde_json::from_slice(&output.into_inner())?;
let findings =
json_output.get("findings").and_then(|v| v.as_array()).cloned().unwrap_or_default();
assert!(!findings.is_empty(), "JSON output should not be empty");
let first = &findings[0];
assert_eq!(first["rule"]["name"], "mock_rule_1");
assert_eq!(first["rule"]["description"], "MockRule");
assert_eq!(first["rule"]["id"], "mock_rule_1");
assert_eq!(first["rule"]["title"], "MOCK_RULE_1 => [MOCK_RULE_1]");
assert_eq!(first["finding"]["language"], "Rust");
assert_eq!(json_output["audit"]["summary"]["discovered"], 1);
Ok(())
}
#[test]
fn jsonl_appends_repository_audit_record() -> Result<()> {
let reporter = setup_mock_reporter(Vec::new());
let mut collector =
crate::scan_audit::ScanAuditCollector::new("2026-01-01T00:00:00Z".to_string(), None)?;
collector.discover_local(std::path::Path::new("/tmp/repo"));
reporter.datastore.lock().unwrap().set_scan_audit(collector.finish()?);
let mut output = Cursor::new(Vec::new());
reporter.jsonl_format(&mut output, &create_default_args())?;
let lines = String::from_utf8(output.into_inner())?;
let audit: serde_json::Value = serde_json::from_str(lines.trim())?;
assert_eq!(audit["audit"]["schema"], "kingfisher.repository-audit.v1");
assert_eq!(audit["audit"]["repositories"][0]["repository"], "/tmp/repo");
Ok(())
}
#[test]
fn repository_audit_is_emitted_by_every_report_format() -> Result<()> {
let reporter = setup_mock_reporter(Vec::new());
let mut collector =
crate::scan_audit::ScanAuditCollector::new("2026-01-01T00:00:00Z".to_string(), None)?;
collector.discover_local(std::path::Path::new("/tmp/repo"));
reporter.datastore.lock().unwrap().set_scan_audit(collector.finish()?);
let args = create_default_args();
let mut json = Cursor::new(Vec::new());
reporter.json_format(&mut json, &args)?;
let json: serde_json::Value = serde_json::from_slice(&json.into_inner())?;
assert_eq!(json["audit"]["summary"]["discovered"], 1);
let mut jsonl = Cursor::new(Vec::new());
reporter.jsonl_format(&mut jsonl, &args)?;
assert!(String::from_utf8(jsonl.into_inner())?.contains("\"audit\""));
let mut bson = Cursor::new(Vec::new());
reporter.bson_format(&mut bson, &args)?;
let bson = mongodb::bson::Document::from_reader(Cursor::new(bson.into_inner()))?;
assert!(bson.contains_key("audit"));
let mut toon = Cursor::new(Vec::new());
reporter.toon_format(&mut toon, &args)?;
let toon: serde_json::Value =
::toon_format::decode_default(&String::from_utf8(toon.into_inner())?)?;
assert_eq!(toon["audit"]["summary"]["discovered"], 1);
let mut sarif = Cursor::new(Vec::new());
reporter.sarif_format(&mut sarif, false, &args)?;
let sarif: serde_json::Value = serde_json::from_slice(&sarif.into_inner())?;
assert_eq!(sarif["runs"][0]["properties"]["repository_audit"]["summary"]["discovered"], 1);
let mut pretty = Cursor::new(Vec::new());
reporter.pretty_format(&mut pretty, &args)?;
assert!(!String::from_utf8(pretty.into_inner())?.contains("REPOSITORY COVERAGE"));
let mut audit_args = create_default_args();
audit_args.audit_log = Some(PathBuf::from("audit.jsonl"));
let mut pretty = Cursor::new(Vec::new());
reporter.pretty_format(&mut pretty, &audit_args)?;
assert!(String::from_utf8(pretty.into_inner())?.contains("REPOSITORY COVERAGE"));
let mut html = Cursor::new(Vec::new());
reporter.html_format(&mut html, &args)?;
assert!(String::from_utf8(html.into_inner())?.contains("Repository Coverage"));
Ok(())
}
#[test]
fn hidden_findings_are_opt_in_but_empty_json_reports_are_emitted() -> Result<()> {
let mut hidden_match = create_mock_match("HiddenHelper", "hidden_helper", false);
hidden_match.visible = false;
let reporter = setup_mock_reporter(vec![ReportMatch {
origin: OriginSet::new(Origin::from_file(PathBuf::from("/mock/path/file.rs")), vec![]),
blob_metadata: BlobMetadata {
id: BlobId::new(b"mock_blob"),
num_bytes: 1024,
mime_essence: Some("text/plain".to_string()),
language: Some("Rust".to_string()),
},
m: hidden_match,
comment: None,
match_confidence: Confidence::Medium,
visible: false,
validation_response_body: validation_body::from_string("validation response"),
validation_response_status: 200,
validation_success: false,
validation_outcome: kingfisher_core::ValidationOutcome::VerifiedInactive,
}]);
let datastore = reporter.datastore.lock().unwrap();
assert!(datastore.get_summary(false).is_empty());
assert_eq!(datastore.get_summary(true).get("HiddenHelper"), Some(&1));
drop(datastore);
let mut output = Cursor::new(Vec::new());
reporter.json_format(&mut output, &create_default_args())?;
let json_output: serde_json::Value = serde_json::from_slice(&output.into_inner())?;
assert_eq!(json_output["findings"].as_array().unwrap().len(), 0);
let mut args = create_default_args();
args.include_hidden_findings = true;
let mut output = Cursor::new(Vec::new());
reporter.json_format(&mut output, &args)?;
let json_output: serde_json::Value = serde_json::from_slice(&output.into_inner())?;
assert_eq!(json_output["findings"].as_array().unwrap().len(), 1);
Ok(())
}
#[test]
fn test_validation_status_in_json() -> Result<()> {
let test_cases = vec![(true, "Active Credential"), (false, "Inactive Credential")];
for (validation_success, expected_status) in test_cases {
let mock_match = create_mock_match("MockRule", "mock_rule_1", validation_success);
let matches = vec![ReportMatch {
origin: OriginSet::new(
Origin::from_file(PathBuf::from("/mock/path/file.rs")),
vec![],
),
blob_metadata: BlobMetadata {
id: BlobId::new(b"mock_blob"),
num_bytes: 1024,
mime_essence: Some("text/plain".to_string()),
language: Some("Rust".to_string()),
},
m: mock_match,
comment: None,
match_confidence: Confidence::Medium,
visible: true,
validation_response_body: validation_body::from_string("validation response"),
validation_response_status: 200,
validation_success,
validation_outcome: if validation_success {
kingfisher_core::ValidationOutcome::VerifiedActive
} else {
kingfisher_core::ValidationOutcome::VerifiedInactive
},
}];
let reporter = setup_mock_reporter(matches);
let mut output = Cursor::new(Vec::new());
reporter.json_format(&mut output, &create_default_args())?;
let json_output: serde_json::Value = serde_json::from_slice(&output.into_inner())?;
let findings =
json_output.get("findings").and_then(|v| v.as_array()).cloned().unwrap_or_default();
assert!(!findings.is_empty(), "JSON output should not be empty");
let first = &findings[0];
let validation_status = first["finding"]["validation"]["status"].as_str().unwrap();
assert_eq!(validation_status, expected_status);
let expected_outcome =
if validation_success { "verified_active" } else { "verified_inactive" };
assert_eq!(first["finding"]["validation"]["outcome"].as_str(), Some(expected_outcome));
}
Ok(())
}
}