From 26f41fcf7a786e57c67b75862d130673c7b29b7d Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Wed, 14 Jan 2026 17:19:02 -0800 Subject: [PATCH 01/16] - Enhanced Access Map View: added fingerprint display, enabled searching by fingerprint, and implemented bidirectional navigation between Findings and Access Map nodes. - Added Slack Access Map support with granular permissions in the tree view. --- CHANGELOG.md | 5 + Cargo.toml | 2 +- docs/access-map-viewer/app.js | 11 ++- docs/access-map-viewer/index.html | 69 +++++++++++++- src/access_map.rs | 88 ++++++++++++------ src/access_map/aws.rs | 1 + src/access_map/azure.rs | 1 + src/access_map/azure_devops.rs | 1 + src/access_map/gcp.rs | 1 + src/access_map/github.rs | 1 + src/access_map/gitlab.rs | 1 + src/access_map/graph.rs | 48 ---------- src/access_map/report.rs | 44 ++++++++- src/access_map/slack.rs | 149 ++++++++++++++++++++++++++++++ src/cli/commands/access_map.rs | 2 + src/cli/commands/view.rs | 1 + src/reporter.rs | 3 + src/scanner/validation.rs | 52 ++++++++--- 18 files changed, 382 insertions(+), 98 deletions(-) delete mode 100644 src/access_map/graph.rs create mode 100644 src/access_map/slack.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e46c664..49abda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog All notable changes to this project will be documented in this file. + +## [v1.75.0] +- Enhanced Access Map View: added fingerprint display, enabled searching by fingerprint, and implemented bidirectional navigation between Findings and Access Map nodes. +- Added Slack Access Map support with granular permissions in the tree view. + ## [v1.74.0] - Added new rules: cursor, definednetworking, filezilla, harness, intra42, klingai, lark, mergify, naver, plaid, resend, retellai diff --git a/Cargo.toml b/Cargo.toml index 78f87e3..d47352e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ publish = false [package] name = "kingfisher" -version = "1.74.0" +version = "1.75.0" description = "MongoDB's blazingly fast and accurate secret scanning and validation tool" edition.workspace = true rust-version.workspace = true diff --git a/docs/access-map-viewer/app.js b/docs/access-map-viewer/app.js index 4c6cd4d..d49f4f3 100644 --- a/docs/access-map-viewer/app.js +++ b/docs/access-map-viewer/app.js @@ -267,6 +267,7 @@ function normalizeAccessMap(entries = []) { return entries.map((entry) => ({ provider: entry.provider, account: entry.account, + fingerprint: entry.fingerprint, groups: (entry.groups || []).map((group) => ({ resources: Array.isArray(group.resources) ? group.resources : [], permissions: Array.isArray(group.permissions) ? group.permissions : [], @@ -277,17 +278,18 @@ function normalizeAccessMap(entries = []) { return entries.map((entry) => ({ provider: entry.provider, account: entry.account, + fingerprint: entry.fingerprint, groups: [ { resources: entry.resource ? [entry.resource] : [], permissions: Array.isArray(entry.permissions) ? entry.permissions : entry.permission - ? String(entry.permission) + ? String(entry.permission) .split(",") .map((p) => p.trim()) .filter(Boolean) - : [], + : [], }, ], })); @@ -301,6 +303,7 @@ function flattenAccessMap(entries = []) { rows.push({ provider: entry.provider, account: entry.account, + fingerprint: entry.fingerprint, resource, permissions: group.permissions || [], }); @@ -364,7 +367,7 @@ function filteredFindings() { if (validationFilter === "not_attempted" && f.validationStatus.toLowerCase() !== "not attempted") return false; if (!currentFilter) return true; - const haystack = `${f.ruleId} ${f.ruleName} ${f.findingType} ${f.message} ${f.path} ${f.validationStatus}`.toLowerCase(); + const haystack = `${f.ruleId} ${f.ruleName} ${f.findingType} ${f.message} ${f.path} ${f.validationStatus} ${f.fingerprint}`.toLowerCase(); return haystack.includes(currentFilter); }) .sort((a, b) => { @@ -512,7 +515,7 @@ function downloadJson() { function copyAccessMap() { if (!accessMap.length) return; const text = JSON.stringify(accessMap, null, 2); - navigator.clipboard.writeText(text).catch(() => {}); + navigator.clipboard.writeText(text).catch(() => { }); } function exportCsv() { diff --git a/docs/access-map-viewer/index.html b/docs/access-map-viewer/index.html index 3e2da62..5944f54 100644 --- a/docs/access-map-viewer/index.html +++ b/docs/access-map-viewer/index.html @@ -1600,12 +1600,13 @@ const ruleName = (rule.name || rule.id || "").toLowerCase(); const path = (finding.path || "").toLowerCase(); const snippet = (finding.snippet || "").toLowerCase(); + const fingerprint = (finding.fingerprint || "").toLowerCase(); const status = (finding.validation && finding.validation.status ? String(finding.validation.status) : "").toLowerCase(); if (filterLower) { - if (!ruleName.includes(filterLower) && !path.includes(filterLower) && !snippet.includes(filterLower)) { + if (!ruleName.includes(filterLower) && !path.includes(filterLower) && !snippet.includes(filterLower) && !fingerprint.includes(filterLower)) { return false; } } @@ -2191,7 +2192,33 @@ panel.scrollIntoView({ behavior: "smooth", block: "start" }); document.getElementById("fd-rule-id").textContent = rule.id || ""; - document.getElementById("fd-fingerprint").textContent = finding.fingerprint || ""; + const fpEl = document.getElementById("fd-fingerprint"); + const fpVal = finding.fingerprint || ""; + fpEl.textContent = fpVal; + fpEl.innerHTML = fpVal; // reset content + + if (fpVal && Array.isArray(accessMap)) { + const hasEntry = accessMap.some(entry => entry.fingerprint === fpVal); + if (hasEntry) { + const btn = document.createElement("button"); + btn.className = "badge"; + btn.textContent = "Go to Access Map"; + btn.style.marginLeft = "10px"; + btn.style.cursor = "pointer"; + btn.style.background = "var(--brand-soft)"; + btn.style.borderColor = "var(--brand)"; + btn.style.color = "var(--brand-dark)"; + btn.onclick = () => { + setActiveView("view-access"); + const treeSearch = document.getElementById("tree-search"); + if (treeSearch) { + treeSearch.value = fpVal; + treeSearch.dispatchEvent(new Event('input')); + } + }; + fpEl.appendChild(btn); + } + } document.getElementById("fd-entropy").textContent = finding.entropy != null ? String(finding.entropy) : ""; const commit = @@ -2332,7 +2359,8 @@ const account = formatIdentityLabel(identity); const groups = Array.isArray(identity.groups) ? identity.groups : []; - const identityNameMatches = Boolean(filterLower) && account.toLowerCase().includes(filterLower); + const fingerprint = (identity.fingerprint || "").toLowerCase(); + const identityNameMatches = Boolean(filterLower) && (account.toLowerCase().includes(filterLower) || fingerprint.includes(filterLower)); let anyResourceMatches = false; const preparedGroups = groups.map((group) => { @@ -2689,6 +2717,41 @@ if (data.provider) { addBadge(meta, provider, providerBadgeClass(data.provider)); } + if (data.fingerprint) { + const fpDiv = document.createElement("div"); + fpDiv.style.width = "100%"; + fpDiv.style.marginTop = "6px"; + fpDiv.style.fontSize = "11px"; + fpDiv.style.color = "var(--text-muted)"; + fpDiv.textContent = "Fingerprint: " + data.fingerprint; + meta.appendChild(fpDiv); + + const btn = document.createElement("button"); + btn.className = "badge"; + btn.textContent = "Go to finding"; + btn.style.marginTop = "6px"; + btn.style.cursor = "pointer"; + btn.style.background = "var(--brand-soft)"; + btn.style.borderColor = "var(--brand)"; + btn.style.color = "var(--brand-dark)"; + btn.onclick = () => { + const searchInput = document.getElementById("search-input"); + if (searchInput) { + setActiveView("view-findings"); + searchInput.value = data.fingerprint; + currentFilter = data.fingerprint; + currentPage = 1; + renderFindingsTable(); + setTimeout(() => { + const tableContainer = document.querySelector('.table-container'); + if (tableContainer) { + tableContainer.scrollIntoView({behavior: 'smooth', block: 'start'}); + } + }, 50); + } + }; + meta.appendChild(btn); + } if (data.token_details) { const details = data.token_details; tokenName.textContent = details.name || "-"; diff --git a/src/access_map.rs b/src/access_map.rs index 7b4d1b2..6839a71 100644 --- a/src/access_map.rs +++ b/src/access_map.rs @@ -11,6 +11,7 @@ mod gcp; mod github; mod gitlab; mod report; +mod slack; /// Run the identity mapping workflow for the selected cloud provider. pub async fn run(args: AccessMapArgs) -> Result<()> { @@ -20,6 +21,7 @@ pub async fn run(args: AccessMapArgs) -> Result<()> { AccessMapProvider::Azure => azure::map_access(&args).await?, AccessMapProvider::Github => github::map_access(&args).await?, AccessMapProvider::Gitlab => gitlab::map_access(&args).await?, + AccessMapProvider::Slack => slack::map_access(&args).await?, }; let json = serde_json::to_string_pretty(&result)?; @@ -40,17 +42,24 @@ pub async fn run(args: AccessMapArgs) -> Result<()> { #[derive(Clone, Debug)] pub enum AccessMapRequest { /// AWS access key credentials. - Aws { access_key: String, secret_key: String, session_token: Option }, + Aws { + access_key: String, + secret_key: String, + session_token: Option, + fingerprint: String, + }, /// A GCP service account JSON document. - Gcp { credential_json: String }, + Gcp { credential_json: String, fingerprint: String }, /// An Azure storage account JSON document. - Azure { credential_json: String, containers: Option> }, + Azure { credential_json: String, containers: Option>, fingerprint: String }, /// An Azure DevOps personal access token with organization. - AzureDevops { token: String, organization: String }, + AzureDevops { token: String, organization: String, fingerprint: String }, /// A GitHub token. - Github { token: String }, + Github { token: String, fingerprint: String }, /// A GitLab token. - Gitlab { token: String }, + Gitlab { token: String, fingerprint: String }, + /// A Slack token. + Slack { token: String, fingerprint: String }, } /// Structured output describing the resolved identity and its risk profile. @@ -59,6 +68,9 @@ pub struct AccessMapResult { /// Cloud name such as "gcp", "aws", or "azure". pub cloud: String, + /// Unique fingerprint of the finding. + pub fingerprint: Option, + /// Summary of the resolved identity. pub identity: AccessSummary, @@ -183,35 +195,56 @@ pub async fn map_requests(requests: Vec) -> Vec { - aws::map_access_with_credentials(&access_key, &secret_key, session_token.as_deref()) - .await - .unwrap_or_else(|err| build_failed_result("aws", &access_key, err)) - } - AccessMapRequest::Gcp { credential_json } => { + let (mut mapped, fp) = match request { + AccessMapRequest::Aws { access_key, secret_key, session_token, fingerprint } => ( + aws::map_access_with_credentials( + &access_key, + &secret_key, + session_token.as_deref(), + ) + .await + .unwrap_or_else(|err| build_failed_result("aws", &access_key, err)), + fingerprint, + ), + AccessMapRequest::Gcp { credential_json, fingerprint } => ( gcp::map_access_from_json(&credential_json) .await - .unwrap_or_else(|err| build_failed_result("gcp", "service_account", err)) - } - AccessMapRequest::Azure { credential_json, containers } => { + .unwrap_or_else(|err| build_failed_result("gcp", "service_account", err)), + fingerprint, + ), + AccessMapRequest::Azure { credential_json, containers, fingerprint } => ( azure::map_access_from_json_with_hints(&credential_json, containers.as_deref()) .await - .unwrap_or_else(|err| build_failed_result("azure", "storage_account", err)) - } - AccessMapRequest::AzureDevops { token, organization } => { + .unwrap_or_else(|err| build_failed_result("azure", "storage_account", err)), + fingerprint, + ), + AccessMapRequest::AzureDevops { token, organization, fingerprint } => ( azure_devops::map_access_from_token(&token, &organization) .await - .unwrap_or_else(|err| build_failed_result("azure_devops", "pat", err)) - } - AccessMapRequest::Github { token } => github::map_access_from_token(&token) - .await - .unwrap_or_else(|err| build_failed_result("github", "token", err)), - AccessMapRequest::Gitlab { token } => gitlab::map_access_from_token(&token) - .await - .unwrap_or_else(|err| build_failed_result("gitlab", "token", err)), + .unwrap_or_else(|err| build_failed_result("azure_devops", "pat", err)), + fingerprint, + ), + AccessMapRequest::Github { token, fingerprint } => ( + github::map_access_from_token(&token) + .await + .unwrap_or_else(|err| build_failed_result("github", "token", err)), + fingerprint, + ), + AccessMapRequest::Gitlab { token, fingerprint } => ( + gitlab::map_access_from_token(&token) + .await + .unwrap_or_else(|err| build_failed_result("gitlab", "token", err)), + fingerprint, + ), + AccessMapRequest::Slack { token, fingerprint } => ( + slack::map_access_from_token(&token) + .await + .unwrap_or_else(|err| build_failed_result("slack", "token", err)), + fingerprint, + ), }; + mapped.fingerprint = Some(fp); results.push(mapped); } @@ -251,6 +284,7 @@ fn build_failed_result(cloud: &str, identity_label: &str, err: anyhow::Error) -> risk_notes: vec![format!("Identity mapping failed: {err}")], token_details: None, provider_metadata: None, + fingerprint: None, } } diff --git a/src/access_map/aws.rs b/src/access_map/aws.rs index c3b3ba6..995c817 100644 --- a/src/access_map/aws.rs +++ b/src/access_map/aws.rs @@ -154,6 +154,7 @@ async fn map_access_with_config(config: SdkConfig) -> Result { scopes: Vec::new(), }), provider_metadata: None, + fingerprint: None, }) } diff --git a/src/access_map/azure.rs b/src/access_map/azure.rs index 6ed7d95..6381297 100644 --- a/src/access_map/azure.rs +++ b/src/access_map/azure.rs @@ -106,6 +106,7 @@ pub async fn map_access_from_json_with_hints( risk_notes, token_details: None, provider_metadata: None, + fingerprint: None, }) } diff --git a/src/access_map/azure_devops.rs b/src/access_map/azure_devops.rs index 0cf4b92..b1e20e6 100644 --- a/src/access_map/azure_devops.rs +++ b/src/access_map/azure_devops.rs @@ -262,6 +262,7 @@ pub async fn map_access_from_token(token: &str, organization: &str) -> Result Result { risk_notes, token_details: None, provider_metadata: None, + fingerprint: None, }) } diff --git a/src/access_map/github.rs b/src/access_map/github.rs index 9465e84..62c8c61 100644 --- a/src/access_map/github.rs +++ b/src/access_map/github.rs @@ -282,6 +282,7 @@ pub async fn map_access_from_token(token: &str) -> Result { scopes: oauth_scopes.clone(), }), provider_metadata: None, + fingerprint: None, }) } diff --git a/src/access_map/gitlab.rs b/src/access_map/gitlab.rs index 6798d6a..ba2c605 100644 --- a/src/access_map/gitlab.rs +++ b/src/access_map/gitlab.rs @@ -179,6 +179,7 @@ pub async fn map_access_from_token(token: &str) -> Result { token_details, provider_metadata: metadata .map(|info| ProviderMetadata { version: info.version, enterprise: info.enterprise }), + fingerprint: None, }) } diff --git a/src/access_map/graph.rs b/src/access_map/graph.rs deleted file mode 100644 index 519fd15..0000000 --- a/src/access_map/graph.rs +++ /dev/null @@ -1,48 +0,0 @@ -use super::AccessMapResult; - -/// Convert an identity map result into a Graphviz DOT representation. -pub fn to_dot(result: &AccessMapResult) -> String { - let mut out = String::new(); - out.push_str("digraph G {\n rankdir=LR;\n"); - - out.push_str(&format!( - " identity [label=\"{} ({})\"];\n", - result.identity.id, result.identity.access_type - )); - - for role in &result.roles { - let safe_role = sanitize(&role.name); - out.push_str(&format!( - " role_{safe} [label=\"{}\"];\n identity -> role_{safe};\n", - role.name, - safe = safe_role - )); - - for perm in &role.permissions { - let safe_perm = sanitize(perm); - out.push_str(&format!( - " perm_{safe} [label=\"{}\"];\n role_{role_safe} -> perm_{safe};\n", - perm, - role_safe = safe_role, - safe = safe_perm - )); - } - } - - for res in &result.resources { - let safe = sanitize(&res.name); - out.push_str(&format!( - " res_{safe} [label=\"{} ({})\"];\n identity -> res_{safe};\n", - res.name, - res.risk, - safe = safe - )); - } - - out.push_str("}\n"); - out -} - -fn sanitize(name: &str) -> String { - name.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect() -} diff --git a/src/access_map/report.rs b/src/access_map/report.rs index 832897a..4909b4c 100644 --- a/src/access_map/report.rs +++ b/src/access_map/report.rs @@ -401,6 +401,7 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String { const CLOUD_LOGOS = { aws: '', gcp: '', + slack: '', unknown: '' }; @@ -466,6 +467,7 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String { ['Project', model.identity?.project || '—'], ['Tenant', model.identity?.tenant || '—'], ['Account', model.identity?.account_id || '—'], + ['Fingerprint', model.fingerprint || '—'], ]; fields.forEach(([label, value]) => { const item = document.createElement('div'); @@ -720,6 +722,7 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String { return { name: model.identity?.id || 'Identity', type: 'identity', + fingerprint: model.fingerprint, children: [ { name: 'Resources', type: 'section', children: resourceNodes }, { name: 'Roles', type: 'section', children: roleNodes }, @@ -734,7 +737,8 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String { if (!node) return null; const name = (node.name || '').toLowerCase(); const type = (node.type || '').toLowerCase(); - const matchesSelf = query ? name.includes(query) || type.includes(query) : true; + const fp = (node.fingerprint || '').toLowerCase(); + const matchesSelf = query ? name.includes(query) || type.includes(query) || fp.includes(query) : true; if (!node.children || node.children.length === 0) { return matchesSelf ? { ...node } : null; } @@ -911,8 +915,46 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String { const sev = document.createElement('span'); sev.textContent = `Severity: ${model.severity || 'unknown'}`; + sev.textContent = `Severity: ${model.severity || 'unknown'}`; meta.appendChild(sev); + if (model.fingerprint) { + const fp = document.createElement('div'); + fp.style.width = '100%'; + fp.style.marginTop = '4px'; + fp.style.fontSize = '11px'; + fp.style.color = '#9db4a8'; + fp.textContent = `Fingerprint: ${model.fingerprint}`; + meta.appendChild(fp); + + const btn = document.createElement('button'); + btn.className = 'badge'; + btn.style.marginTop = '6px'; + btn.style.cursor = 'pointer'; + btn.style.width = '100%'; + btn.style.justifyContent = 'center'; + btn.textContent = 'Go to finding'; + btn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + const searchInput = document.getElementById('search-input'); + if (searchInput) { + if (typeof setActiveView === 'function') setActiveView('view-findings'); + searchInput.value = model.fingerprint; + currentFilter = model.fingerprint; + currentPage = 1; + renderFindingsTable(); + setTimeout(() => { + const tableContainer = document.querySelector('.table-container'); + if (tableContainer) { + tableContainer.scrollIntoView({behavior: 'smooth', block: 'start'}); + } + }, 50); + } + }; + meta.appendChild(btn); + } + li.appendChild(meta); list.appendChild(li); }); diff --git a/src/access_map/slack.rs b/src/access_map/slack.rs new file mode 100644 index 0000000..ff4d2e6 --- /dev/null +++ b/src/access_map/slack.rs @@ -0,0 +1,149 @@ +use anyhow::{anyhow, Result}; +use reqwest::header::AUTHORIZATION; +use serde::Deserialize; + +use super::{ + build_recommendations, AccessMapArgs, AccessMapResult, AccessSummary, AccessTokenDetails, + PermissionSummary, ProviderMetadata, ResourceExposure, RoleBinding, Severity, +}; + +pub async fn map_access(args: &AccessMapArgs) -> Result { + // For CLI usage, we might expect a token via env var or file, strictly speaking + // the CLI usually takes a file path for credentials. + // For Slack, it's just a token string. + // We'll assume the file contains the token, or if it's not a file, maybe it's the token itself? + // But consistency with other providers suggests reading from file. + let path = args + .credential_path + .as_deref() + .ok_or_else(|| anyhow!("Slack access-map requires a file path containing the token"))?; + let token = std::fs::read_to_string(path)?.trim().to_string(); + map_access_from_token(&token).await +} + +pub async fn map_access_from_token(token: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post("https://slack.com/api/auth.test") + .header(AUTHORIZATION, format!("Bearer {token}")) + .send() + .await?; + + let headers = resp.headers().clone(); + let scopes_header = + headers.get("x-oauth-scopes").and_then(|v| v.to_str().ok()).unwrap_or_default().to_string(); + + let body = resp.bytes().await?; + let json: AuthTestResponse = serde_json::from_slice(&body)?; + + if !json.ok { + return Err(anyhow!("Slack auth.test failed: {}", json.error.unwrap_or_default())); + } + + let user_id = json.user_id.unwrap_or_default(); + let team_id = json.team_id.unwrap_or_default(); + let team = json.team.unwrap_or_default(); + let user = json.user.unwrap_or_default(); + let url = json.url.unwrap_or_default(); + + let scopes: Vec = + scopes_header.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + + let identity = AccessSummary { + id: format!("{}@{}", user, team), + access_type: "user".into(), // Could be bot, but auth.test doesn't strictly say. xoxb is bot. + project: Some(team.clone()), + tenant: Some(team_id.clone()), + account_id: Some(user_id.clone()), + }; + + let mut roles = Vec::new(); + // Treat scopes as permissions in a "Scopes" role + let mut expanded_permissions = Vec::new(); + + if !scopes.is_empty() { + roles.push(RoleBinding { + name: "OAuth Scopes".into(), + source: "token".into(), + permissions: scopes.clone(), + }); + expanded_permissions.extend(scopes.clone()); + } + + let permissions = classify_permissions(&scopes); + let severity = derive_severity(&permissions); + + let mut resources = Vec::new(); + resources.push(ResourceExposure { + resource_type: "workspace".into(), + name: team, + permissions: scopes.clone(), + risk: "medium".into(), + reason: "Token has access to this workspace".into(), + }); + + let recommendations = build_recommendations(severity); + + let token_details = AccessTokenDetails { + name: Some(user.clone()), + username: Some(user), + user_id: Some(user_id), + url: Some(url), + scopes, + ..Default::default() + }; + + Ok(AccessMapResult { + cloud: "slack".into(), + identity, + roles, + permissions, + resources, + severity, + recommendations, + risk_notes: Vec::new(), + token_details: Some(token_details), + provider_metadata: Some(ProviderMetadata { version: None, enterprise: None }), + fingerprint: None, + }) +} + +#[derive(Deserialize)] +struct AuthTestResponse { + ok: bool, + error: Option, + url: Option, + team: Option, + user: Option, + team_id: Option, + user_id: Option, +} + +fn classify_permissions(scopes: &[String]) -> PermissionSummary { + let mut admin = Vec::new(); + let privilege_escalation = Vec::new(); + let mut risky = Vec::new(); + let mut read_only = Vec::new(); + + for scope in scopes { + if scope.starts_with("admin") { + admin.push(scope.clone()); + } else if scope.contains("write") || scope.contains("manage") || scope.contains("remove") { + risky.push(scope.clone()); + } else { + read_only.push(scope.clone()); + } + } + + PermissionSummary { admin, privilege_escalation, risky, read_only } +} + +fn derive_severity(permissions: &PermissionSummary) -> Severity { + if !permissions.admin.is_empty() { + Severity::Critical + } else if !permissions.risky.is_empty() { + Severity::High + } else { + Severity::Medium + } +} diff --git a/src/cli/commands/access_map.rs b/src/cli/commands/access_map.rs index 4b62a47..ee86c57 100644 --- a/src/cli/commands/access_map.rs +++ b/src/cli/commands/access_map.rs @@ -35,4 +35,6 @@ pub enum AccessMapProvider { Github, /// GitLab Gitlab, + /// Slack + Slack, } diff --git a/src/cli/commands/view.rs b/src/cli/commands/view.rs index 4d1ca27..337f33e 100644 --- a/src/cli/commands/view.rs +++ b/src/cli/commands/view.rs @@ -18,6 +18,7 @@ use tokio::net::TcpListener; use tracing::{info, warn}; pub const DEFAULT_PORT: u16 = 7890; +// Embedded viewer assets - force rebuild static VIEWER_ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/docs/access-map-viewer"); /// View a Kingfisher access-map report locally. diff --git a/src/reporter.rs b/src/reporter.rs index 3d14780..e7e7e4a 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -659,6 +659,7 @@ impl DetailsReporter { groups, token_details: result.token_details.clone(), provider_metadata: result.provider_metadata.clone(), + fingerprint: result.fingerprint.clone(), }); } @@ -820,6 +821,8 @@ pub struct AccessMapEntry { pub token_details: Option, #[serde(default)] pub provider_metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, } #[derive(Serialize, JsonSchema, Clone, Debug)] diff --git a/src/scanner/validation.rs b/src/scanner/validation.rs index 194f016..1529c9e 100644 --- a/src/scanner/validation.rs +++ b/src/scanner/validation.rs @@ -35,51 +35,67 @@ pub struct AccessMapCollector { } impl AccessMapCollector { - pub fn record_aws(&self, access_key: &str, secret_key: &str) { + pub fn record_aws(&self, access_key: &str, secret_key: &str, fingerprint: String) { let key = xxhash_rust::xxh3::xxh3_64(format!("aws|{access_key}|{secret_key}").as_bytes()); self.inner.entry(key).or_insert_with(|| AccessMapRequest::Aws { access_key: access_key.to_string(), secret_key: secret_key.to_string(), session_token: None, + fingerprint, }); } - pub fn record_gcp(&self, credential_json: &str) { + pub fn record_gcp(&self, credential_json: &str, fingerprint: String) { let key = xxhash_rust::xxh3::xxh3_64(credential_json.as_bytes()); self.inner.entry(key).or_insert_with(|| AccessMapRequest::Gcp { credential_json: credential_json.to_string(), + fingerprint, }); } - pub fn record_azure(&self, credential_json: &str, containers: Option>) { + pub fn record_azure( + &self, + credential_json: &str, + containers: Option>, + fingerprint: String, + ) { let key = xxhash_rust::xxh3::xxh3_64(credential_json.as_bytes()); self.inner.entry(key).or_insert_with(|| AccessMapRequest::Azure { credential_json: credential_json.to_string(), containers, + fingerprint, }); } - pub fn record_azure_devops(&self, token: &str, organization: &str) { + pub fn record_azure_devops(&self, token: &str, organization: &str, fingerprint: String) { let key = xxhash_rust::xxh3::xxh3_64(format!("azure_devops|{organization}|{token}").as_bytes()); self.inner.entry(key).or_insert_with(|| AccessMapRequest::AzureDevops { token: token.to_string(), organization: organization.to_string(), + fingerprint, }); } - pub fn record_github(&self, token: &str) { + pub fn record_github(&self, token: &str, fingerprint: String) { let key = xxhash_rust::xxh3::xxh3_64(format!("github|{token}").as_bytes()); self.inner .entry(key) - .or_insert_with(|| AccessMapRequest::Github { token: token.to_string() }); + .or_insert_with(|| AccessMapRequest::Github { token: token.to_string(), fingerprint }); } - pub fn record_gitlab(&self, token: &str) { + pub fn record_gitlab(&self, token: &str, fingerprint: String) { let key = xxhash_rust::xxh3::xxh3_64(format!("gitlab|{token}").as_bytes()); self.inner .entry(key) - .or_insert_with(|| AccessMapRequest::Gitlab { token: token.to_string() }); + .or_insert_with(|| AccessMapRequest::Gitlab { token: token.to_string(), fingerprint }); + } + + pub fn record_slack(&self, token: &str, fingerprint: String) { + let key = xxhash_rust::xxh3::xxh3_64(format!("slack|{token}").as_bytes()); + self.inner + .entry(key) + .or_insert_with(|| AccessMapRequest::Slack { token: token.to_string(), fingerprint }); } pub fn into_requests(self) -> Vec { @@ -556,6 +572,7 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl }; let captures = utils::process_captures(&om.captures); + let fp = om.finding_fingerprint.to_string(); match om.rule.syntax().validation { Some(Validation::AWS) => { @@ -573,13 +590,13 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl } if !akid.is_empty() && !secret.is_empty() { - collector.record_aws(&akid, &secret); + collector.record_aws(&akid, &secret, fp.clone()); } } Some(Validation::GCP) => { if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") { if !value.is_empty() { - collector.record_gcp(value); + collector.record_gcp(value, fp.clone()); } } } @@ -607,14 +624,14 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl r#"{{"storage_account":"{}","storage_key":"{}"}}"#, storage_account, storage_key ); - collector.record_azure(&creds_json, containers_hint); + collector.record_azure(&creds_json, containers_hint, fp.clone()); } } _ => { if om.rule.id().starts_with("kingfisher.github.") { if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") { if !value.is_empty() { - collector.record_github(value); + collector.record_github(value, fp.clone()); } } } @@ -633,13 +650,20 @@ fn maybe_record_access_map(om: &OwnedBlobMatch, collector: Option<&AccessMapColl } if !token.is_empty() && !organization.is_empty() { - collector.record_azure_devops(&token, &organization); + collector.record_azure_devops(&token, &organization, fp.clone()); } } if is_gitlab_rule { if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") { if !value.is_empty() { - collector.record_gitlab(value); + collector.record_gitlab(value, fp.clone()); + } + } + } + if om.rule.id().starts_with("kingfisher.slack.") { + if let Some((_, value, ..)) = captures.iter().find(|(name, ..)| name == "TOKEN") { + if !value.is_empty() { + collector.record_slack(value, fp); } } } From 8c07fb3f3cc4e89d7e06b39f112523eb87763420 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Wed, 14 Jan 2026 21:45:55 -0800 Subject: [PATCH 02/16] - Enhanced Access Map View: added fingerprint display, enabled searching by fingerprint, and implemented bidirectional navigation between Findings and Access Map nodes. - Added Slack Access Map support with granular permissions in the tree view. --- README.md | 4 +- data/rules/openai.yml | 5 + docs/access-map-viewer/index.html | 271 ++++++++++++++++++++++++------ src/validation/httpvalidation.rs | 5 +- 4 files changed, 225 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index a156dcd..f92e38a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Kingfisher + # Kingfisher

Kingfisher Logo @@ -13,8 +13,6 @@ It combines Intel’s SIMD-accelerated regex engine (Hyperscan) with language-aw Designed for offensive security engineers and blue-teamers alike, Kingfisher helps you pivot across repo ecosystems, validate exposure paths, and hunt for developer-owned leaks that spill beyond the primary codebase. -For a look at how Kingfisher has grown from its early foundations into today's full-featured scanner, see [Lineage and Evolution](#lineage-and-evolution). -

## Key Features diff --git a/data/rules/openai.yml b/data/rules/openai.yml index d77801e..b8c5933 100644 --- a/data/rules/openai.yml +++ b/data/rules/openai.yml @@ -3,9 +3,11 @@ rules: id: kingfisher.openai.1 pattern: | (?xi) + \b ( sk-[A-Z0-9]{48} ) + \b pattern_requirements: min_digits: 2 min_entropy: 3.3 @@ -33,6 +35,7 @@ rules: id: kingfisher.openai.2 pattern: | (?xi) + \b ( (sk-(?:proj|svcacct|None)-[A-Z0-9_-]{100,}) ) @@ -65,9 +68,11 @@ rules: id: kingfisher.openai.3 pattern: | (?xi) + \b ( sk-None-[A-Z0-9]{48} ) + \b pattern_requirements: min_digits: 2 min_entropy: 3.3 diff --git a/docs/access-map-viewer/index.html b/docs/access-map-viewer/index.html index 5944f54..c4dd8af 100644 --- a/docs/access-map-viewer/index.html +++ b/docs/access-map-viewer/index.html @@ -718,6 +718,11 @@ border-color: var(--border); } + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + #theme-toggle { background: #f9fafb; color: var(--brand); @@ -853,8 +858,16 @@ Findings - +
+ +
+
@@ -1153,7 +1170,9 @@ const errorMsg = document.getElementById("error-msg"); const uploadSection = document.getElementById("upload-section"); const dashboard = document.getElementById("dashboard"); - const downloadPdfBtn = document.getElementById("download-pdf"); + const downloadFindingsReportBtn = document.getElementById("download-findings-report"); + const reportScopeSelect = document.getElementById("report-scope"); + const downloadAccessReportBtn = document.getElementById("download-access-report"); const searchInput = document.getElementById("search-input"); const validationSelect = document.getElementById("validation-filter"); @@ -1273,8 +1292,14 @@ downloadJsonBtn.addEventListener("click", downloadFindingsJson); downloadCsvBtn.addEventListener("click", downloadFindingsCsv); copyAccessMapButton.addEventListener("click", copyFilteredAccessMap); - if (downloadPdfBtn) { - downloadPdfBtn.addEventListener("click", generatePdfReport); + if (downloadFindingsReportBtn) { + downloadFindingsReportBtn.addEventListener("click", () => { + const scope = reportScopeSelect ? reportScopeSelect.value : "all"; + generatePdfReport(scope); + }); + } + if (downloadAccessReportBtn) { + downloadAccessReportBtn.addEventListener("click", generateAccessMapReport); } if (themeToggle) { themeToggle.addEventListener("click", () => { @@ -1333,6 +1358,7 @@ function syncAccessMapUi(hasAccess) { const previouslyAutoCollapsed = autoCollapsedAccessMap; if (amToggle) amToggle.disabled = !hasAccess; + if (downloadAccessReportBtn) downloadAccessReportBtn.disabled = !hasAccess; if (amEmptyNotice) { amEmptyNotice.classList.toggle("hidden", hasAccess); } @@ -1714,9 +1740,9 @@ return "unknown"; } - function calculateValidationCounts() { + function calculateValidationCounts(list = findings) { const counts = { active: 0, inactive: 0, not_attempted: 0, unknown: 0 }; - findings.forEach((f) => { + (list || []).forEach((f) => { const status = f.finding && f.finding.validation && f.finding.validation.status ? f.finding.validation.status @@ -1930,14 +1956,105 @@ triggerDownload("kingfisher-findings.csv", csv, "text/csv"); } - function generatePdfReport() { + function getFindingIdFromFinding(finding) { + if (!finding) return ""; + return ( + finding.id || + finding.finding_id || + finding.findingId || + finding.fingerprint || + "" + ); + } + + function getFindingIdFromAccessEntry(entry) { + if (!entry) return ""; + return ( + entry.finding_id || + entry.findingId || + entry.finding || + entry.fingerprint || + "" + ); + } + + function getTokenNameFromAccessEntry(entry) { + if (!entry) return ""; + return ( + entry.token_name || + entry.tokenName || + (entry.token_details && entry.token_details.name) || + (entry.token && entry.token.name) || + "" + ); + } + + function getUserIdFromAccessEntry(entry) { + if (!entry) return ""; + return ( + entry.user_id || + entry.userId || + (entry.token_details && entry.token_details.user_id) || + (entry.token && entry.token.user_id) || + "" + ); + } + + function buildAccessMapListHtml({ includeMeta = false } = {}) { + if (!Array.isArray(accessMap) || accessMap.length === 0) { + return ""; + } + + return accessMap + .map((entry) => { + const groups = Array.isArray(entry.groups) ? entry.groups : []; + const findingId = getFindingIdFromAccessEntry(entry); + const tokenName = getTokenNameFromAccessEntry(entry); + const userId = getUserIdFromAccessEntry(entry); + const metaLine = includeMeta + ? `
+ Finding ID: ${escapeHtml(findingId || "-")} · + Token Name: ${escapeHtml(tokenName || "-")} · + User ID: ${escapeHtml(userId || "-")} +
` + : ""; + const groupList = groups + .map((g) => { + const resList = Array.isArray(g.resources) && g.resources.length + ? g.resources.map((r) => escapeHtml(String(r))).join(", ") + : "No resources listed"; + const permList = Array.isArray(g.permissions) && g.permissions.length + ? g.permissions.map((p) => escapeHtml(String(p))).join(", ") + : "No permissions listed"; + return `
  • Resources: ${resList}
    Permissions: ${permList}
  • `; + }) + .join(""); + return ` +
  • +
    + ${escapeHtml(entry.account || "(identity)")} + ${escapeHtml((entry.provider || "Unknown").toUpperCase())} +
    + ${metaLine} +
      + ${groupList || "
    • No resources recorded.
    • "} +
    +
  • + `; + }) + .join(""); + } + + function generatePdfReport(scope = "all") { if (!rawData) { - alert("Load a report before downloading a PDF report."); + alert("Load a report before downloading a Findings report."); return; } const statusOrder = { active: 0, inactive: 1, not_attempted: 2, unknown: 3 }; - const findingsForReport = Array.isArray(findings) ? findings.slice() : []; + const baseFindings = + scope === "filtered" ? getFilteredSortedFindings().slice() : (Array.isArray(findings) ? findings.slice() : []); + const findingsForReport = baseFindings.slice(); findingsForReport.sort((a, b) => { const fa = a.finding || {}; const fb = b.finding || {}; @@ -1952,15 +2069,16 @@ return ra.localeCompare(rb); }); - const counts = calculateValidationCounts(); + const counts = calculateValidationCounts(baseFindings); const durationSeconds = resolveScanDurationSeconds(rawData); const durationText = formatDurationText(durationSeconds); const hasAccess = Array.isArray(accessMap) && accessMap.length > 0; - const statusImage = statusChartCanvas ? statusChartCanvas.toDataURL("image/png") : ""; - const highConfidence = findings.filter((f) => { + const statusImage = scope === "all" && statusChartCanvas ? statusChartCanvas.toDataURL("image/png") : ""; + const highConfidence = baseFindings.filter((f) => { const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : ""; return conf.toLowerCase() === "high"; }).length; + const scopeLabel = scope === "filtered" ? "Filtered findings" : "All findings"; const findingsHtml = findingsForReport.length ? findingsForReport @@ -1969,49 +2087,26 @@ const finding = entry.finding || {}; const statusRaw = finding.validation && finding.validation.status ? finding.validation.status : "Unknown"; const status = normalizeValidationStatus(statusRaw); + const findingId = getFindingIdFromFinding(finding); + const gitUrl = getFileUrlFromFinding(finding); const snippet = (finding.snippet || "").toString().replace(/\s+/g, " ").trim(); return ` ${escapeHtml(rule.name || rule.id || "")} + ${escapeHtml(findingId || "")} ${escapeHtml(finding.path || "")} ${escapeHtml(statusRaw)} - ${escapeHtml(finding.confidence || "")} ${finding.line != null ? escapeHtml(finding.line) : ""} + ${escapeHtml(gitUrl || "")} ${escapeHtml(snippet.slice(0, 200))} `; }) .join("") - : 'No findings available.'; + : 'No findings available.'; const accessListHtml = hasAccess - ? accessMap - .map((entry) => { - const groups = Array.isArray(entry.groups) ? entry.groups : []; - const groupList = groups - .map((g) => { - const resList = Array.isArray(g.resources) && g.resources.length - ? g.resources.map((r) => escapeHtml(String(r))).join(", ") - : "No resources listed"; - const permList = Array.isArray(g.permissions) && g.permissions.length - ? g.permissions.map((p) => escapeHtml(String(p))).join(", ") - : "No permissions listed"; - return `
  • Resources: ${resList}
    Permissions: ${permList}
  • `; - }) - .join(""); - return ` -
  • -
    - ${escapeHtml(entry.account || "(identity)")} - ${escapeHtml((entry.provider || "Unknown").toUpperCase())} -
    -
      - ${groupList || "
    • No resources recorded.
    • "} -
    -
  • - `; - }) - .join("") + ? buildAccessMapListHtml() : ""; const accessSectionContent = hasAccess @@ -2025,14 +2120,15 @@ Kingfisher Report + + +

    Kingfisher Access Map Report

    +
    Generated ${escapeHtml(new Date().toLocaleString())}
    +
    +

    Access Map

    +
      ${accessListHtml}
    +
    + + + `; + + const win = window.open("", "_blank", "width=1200,height=900"); + if (!win) { + alert("Please allow pop-ups to download the Access Map report."); return; } win.document.write(pdfHtml); @@ -2227,6 +2372,20 @@ : "N/A"; document.getElementById("fd-commit").textContent = commit; + const statusRaw = + finding.validation && finding.validation.status ? String(finding.validation.status) : "Unknown"; + const normalizedStatus = normalizeValidationStatus(statusRaw); + const badgeClass = + normalizedStatus === "active" + ? "active" + : normalizedStatus === "inactive" + ? "inactive" + : "unknown"; + const statusEl = document.getElementById("fd-validation-status"); + if (statusEl) { + statusEl.innerHTML = `${escapeHtml(statusRaw)}`; + } + const path = finding.path || ""; if (fdPathInput) { fdPathInput.value = path || "—"; @@ -2821,4 +2980,4 @@ } - + \ No newline at end of file diff --git a/src/validation/httpvalidation.rs b/src/validation/httpvalidation.rs index ace6398..21aade7 100644 --- a/src/validation/httpvalidation.rs +++ b/src/validation/httpvalidation.rs @@ -600,6 +600,9 @@ mod tests { // This should not panic AND should correctly identify HTML let result = body_looks_like_html(&body, &headers); - assert!(result, "Should correctly identify HTML even with multi-byte characters at boundary"); + assert!( + result, + "Should correctly identify HTML even with multi-byte characters at boundary" + ); } } From fdd0a8e02d071532d568104cef759d186c669ee4 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Wed, 14 Jan 2026 22:06:08 -0800 Subject: [PATCH 03/16] fix beamer rule --- data/rules/beamer.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/data/rules/beamer.yml b/data/rules/beamer.yml index bbc2029..e3904d3 100644 --- a/data/rules/beamer.yml +++ b/data/rules/beamer.yml @@ -10,7 +10,6 @@ rules: ( b_[A-Z0-9=_\\/\\\-+]{44} ) - \b pattern_requirements: min_digits: 2 min_uppercase: 1 From c2cb5b9637b693503523dd57a3cabe7efcc98f08 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Wed, 14 Jan 2026 22:12:28 -0800 Subject: [PATCH 04/16] fix beamer rule --- data/rules/azurestorage.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/data/rules/azurestorage.yml b/data/rules/azurestorage.yml index d4ae1ef..edebb26 100644 --- a/data/rules/azurestorage.yml +++ b/data/rules/azurestorage.yml @@ -28,7 +28,6 @@ rules: id: kingfisher.azurestorage.2 pattern: | (?xi) - \b azure (?:.|[\n\r]){0,128}? (?:SECRET|PRIVATE|ACCESS|KEY|TOKEN) From c57181aa6089f865a078bf67a0756277a56ad40d Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Thu, 15 Jan 2026 10:41:55 -0800 Subject: [PATCH 05/16] improving findings viewer --- CHANGELOG.md | 2 ++ data/rules/azurestorage.yml | 5 +++-- docs/access-map-viewer/index.html | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49abda4..bb5cbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. ## [v1.75.0] - Enhanced Access Map View: added fingerprint display, enabled searching by fingerprint, and implemented bidirectional navigation between Findings and Access Map nodes. - Added Slack Access Map support with granular permissions in the tree view. +- Improved HTML report +- Improved several rules ## [v1.74.0] - Added new rules: cursor, definednetworking, filezilla, harness, intra42, klingai, lark, mergify, naver, plaid, resend, retellai diff --git a/data/rules/azurestorage.yml b/data/rules/azurestorage.yml index edebb26..910de23 100644 --- a/data/rules/azurestorage.yml +++ b/data/rules/azurestorage.yml @@ -6,16 +6,17 @@ rules: (?: # A) Connection string: AccountName= (?i:AccountName)\s*=\s*([a-z0-9]{3,24})(?:\b|[^a-z0-9]) - | # B) Blob endpoint URL: .blob.core.windows.net ([a-z0-9]{3,24})\.blob\.core\.windows\.net\b - | # C) Explicit KV labels near 'azure storage/account name' with tight separators \bazure(?:[_\s-]*)(?:storage|account)(?:[_\s-]*)(?:name)\b [\s:=\"']{0,6} ([a-z0-9]{3,24})(?:\b|[^a-z0-9]) + | + # D) Explicit KV labels near 'azure storage/account name' with tight separators + (?i:Account[_.-]?Name|Storage[_.-]?(?:Name))(?:.|\s){0,32}?\b([A-Z0-9]{3,32})\b|([A-A0-9]{3,32})(?i:\.blob\.core\.windows\.net) ) min_entropy: 2.0 visible: false diff --git a/docs/access-map-viewer/index.html b/docs/access-map-viewer/index.html index c4dd8af..5831fdd 100644 --- a/docs/access-map-viewer/index.html +++ b/docs/access-map-viewer/index.html @@ -1123,6 +1123,10 @@
    +
    + +
    +
    @@ -2372,6 +2376,25 @@ : "N/A"; document.getElementById("fd-commit").textContent = commit; + const committerWrapper = document.getElementById("fd-committer-email-wrapper"); + const committerEmailEl = document.getElementById("fd-committer-email"); + const committerEmail = + finding.git_metadata && + finding.git_metadata.commit && + finding.git_metadata.commit.committer && + finding.git_metadata.commit.committer.email + ? String(finding.git_metadata.commit.committer.email) + : ""; + if (committerWrapper && committerEmailEl) { + if (committerEmail) { + committerWrapper.style.display = ""; + committerEmailEl.textContent = committerEmail; + } else { + committerWrapper.style.display = "none"; + committerEmailEl.textContent = ""; + } + } + const statusRaw = finding.validation && finding.validation.status ? String(finding.validation.status) : "Unknown"; const normalizedStatus = normalizeValidationStatus(statusRaw); From a263c0c2003db0d0b821c8111cba06186825bdc0 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Thu, 15 Jan 2026 17:51:56 -0800 Subject: [PATCH 06/16] improving findings viewer --- docs/access-map-viewer/index.html | 55 ------------------------------- 1 file changed, 55 deletions(-) diff --git a/docs/access-map-viewer/index.html b/docs/access-map-viewer/index.html index 5831fdd..c6c61fa 100644 --- a/docs/access-map-viewer/index.html +++ b/docs/access-map-viewer/index.html @@ -891,10 +891,6 @@
    Identities Mapped
    0
    -
    -
    Scan Duration
    -
    -
    -
    @@ -1613,10 +1609,6 @@ document.getElementById("stat-identities").textContent = (accessMap || []).length.toString(); - const durEl = document.getElementById("stat-duration"); - const scanSeconds = resolveScanDurationSeconds(rawData); - durEl.textContent = formatDurationText(scanSeconds); - renderStatusChart(validationCounts); } @@ -1757,50 +1749,6 @@ return counts; } - function resolveScanDurationSeconds(data) { - if (!data) return null; - const candidates = [ - data.scan_duration, - data.scanDuration, - data.duration_seconds, - data.duration, - data.stats && data.stats.scan_duration, - data.stats && data.stats.scanDuration, - data.summary && data.summary.scan_duration, - ]; - - for (const candidate of candidates) { - const parsed = parseDuration(candidate); - if (parsed != null) return parsed; - } - return null; - } - - function parseDuration(value) { - if (value === undefined || value === null) return null; - if (typeof value === "number" && Number.isFinite(value)) return value; - - const numeric = Number(value); - if (Number.isFinite(numeric)) return numeric; - - if (typeof value === "string") { - const match = value.match(/([\d.]+)\s*s/i); - if (match) { - const parsed = Number(match[1]); - if (Number.isFinite(parsed)) return parsed; - } - } - return null; - } - - function formatDurationText(seconds) { - if (seconds === null || seconds === undefined) return "-"; - const value = Number(seconds); - if (!Number.isFinite(value)) return "-"; - if (value < 1) return value.toFixed(3) + "s"; - return value.toFixed(2) + "s"; - } - function renderStatusChart(counts) { if (!statusChartCanvas) return; const ctx = statusChartCanvas.getContext("2d"); @@ -2074,8 +2022,6 @@ }); const counts = calculateValidationCounts(baseFindings); - const durationSeconds = resolveScanDurationSeconds(rawData); - const durationText = formatDurationText(durationSeconds); const hasAccess = Array.isArray(accessMap) && accessMap.length > 0; const statusImage = scope === "all" && statusChartCanvas ? statusChartCanvas.toDataURL("image/png") : ""; const highConfidence = baseFindings.filter((f) => { @@ -2158,7 +2104,6 @@
    High Confidence
    ${highConfidence}
    Active Credentials
    ${counts.active || 0}
    Identities Mapped
    ${accessMap.length}
    -
    Scan Duration
    ${durationText}
    ${statusImage ? `Status chart` : ""} From f26d3ca3838612e35050df9dcf662d3266a86918 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Thu, 15 Jan 2026 21:00:23 -0800 Subject: [PATCH 07/16] new rules --- data/rules/clay.yml | 25 +++++++++++++++++++++++++ data/rules/exaai.yml | 1 - 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 data/rules/clay.yml diff --git a/data/rules/clay.yml b/data/rules/clay.yml new file mode 100644 index 0000000..ff86401 --- /dev/null +++ b/data/rules/clay.yml @@ -0,0 +1,25 @@ +rules: + - name: Clay API Key + id: kingfisher.clay.1 + pattern: | + (?xi) + \b + clay + (?:.|[\n\r]){0,64}? + \b + ( + [a-f0-9]{20} + ) + \b + pattern_requirements: + min_digits: 6 + min_entropy: 3.0 + confidence: medium + examples: + - clay_api_key=ce1abceaffe7d7958a41 + - "CLAY_KEY: bdc55270455ca0a892e4" + - export CLAY_TOKEN=e9b711a5acbb99b8f099 + - 'clay key: f6fd04ab6b4f7992adc2' + - CLAY_API_KEY=d8dfd14ec83e4e17a7d2 + references: + - https://university.clay.com/docs/http-api-integration-overview diff --git a/data/rules/exaai.yml b/data/rules/exaai.yml index b0934ca..1110512 100644 --- a/data/rules/exaai.yml +++ b/data/rules/exaai.yml @@ -42,6 +42,5 @@ rules: - report_response: true - type: StatusMatch status: [200] - - type: JsonValid - type: WordMatch words: ['"answer"'] From 0409abead5cae78bcd8826e941343dfadd2baf3b Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Thu, 15 Jan 2026 22:02:31 -0800 Subject: [PATCH 08/16] new rules --- data/rules/groq.yml | 6 +++--- data/rules/instantly.yml | 45 ++++++++++++++++++++++++++++++++++++++++ data/rules/vastai.yml | 44 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 data/rules/instantly.yml create mode 100644 data/rules/vastai.yml diff --git a/data/rules/groq.yml b/data/rules/groq.yml index 127ebb5..8160b2b 100644 --- a/data/rules/groq.yml +++ b/data/rules/groq.yml @@ -5,13 +5,13 @@ rules: (?xi) \b ( - gsk_[a-zA-Z0-9]{52} + gsk_[A-Z0-9]{52} ) \b pattern_requirements: - min_digits: 2 + min_digits: 4 confidence: medium - min_entropy: 4.0 + min_entropy: 3.5 validation: type: Http content: diff --git a/data/rules/instantly.yml b/data/rules/instantly.yml new file mode 100644 index 0000000..4e361ce --- /dev/null +++ b/data/rules/instantly.yml @@ -0,0 +1,45 @@ +rules: + - name: Instantly API Key + id: kingfisher.instantly.1 + pattern: | + (?xi) + \b + instantly + (?:\.ai)? + (?:.|[\n\r]){0,16}? + (?:SECRET|PRIVATE|ACCESS|KEY|TOKEN) + (?:.|[\n\r]){0,16}? + \b + ( + [A-Za-z0-9+/]{66}== + ) + pattern_requirements: + min_digits: 4 + min_entropy: 3.3 + confidence: medium + categories: + - api + - secret + examples: + - 'INSTANTLY_API_KEY="ZGVhZGJlZWYtZGVhZC0xMTEtMjIyLTMzMzM0NDQ0NDQ0NDphYmNkZWZnaGlqaw=="' + - '"Authorization: Bearer ZGVhZGJlZWYtZGVhZC0xMTEtMjIyLTMzMzM0NDQ0NDQ0NDphYmNkZWZnaGlqaw=="' + references: + - https://developer.instantly.ai/api/v2/analytics/getdailyaccountanalytics + validation: + type: Http + content: + request: + method: GET + url: "https://api.instantly.ai/api/v2/accounts/analytics/daily?start_date={{ '' | date: '%Y-%m-01' }}&end_date={{ '' | date: '%Y-%m-%d' }}" + headers: + Authorization: "Bearer {{ TOKEN }}" + response_matcher: + - report_response: true + - type: StatusMatch + status: [200, 401] + - type: WordMatch + negative: true + words: + - '"Invalid authorization header or API key"' + - '"Invalid API key"' + - type: JsonValid diff --git a/data/rules/vastai.yml b/data/rules/vastai.yml new file mode 100644 index 0000000..0adc145 --- /dev/null +++ b/data/rules/vastai.yml @@ -0,0 +1,44 @@ +rules: + - name: Vast.ai API Key + id: kingfisher.vastai.1 + pattern: | + (?xi) + \b + vast(?:\.ai)? + (?:.|[\n\r]){0,16}? + (?:SECRET|PRIVATE|ACCESS|KEY|TOKEN) + (?:.|[\n\r]){0,16}? + \b + ( + [a-f0-9]{64} + ) + \b + pattern_requirements: + min_digits: 8 + min_entropy: 3.5 + confidence: medium + examples: + - VAST_API_KEY=c218521a7eaf108227795ae059866db8fdd7be16348f67ec48e66af4b2df4a76 + - 'vastai_access_key: c218521a7eaf108227795ae059866db8fdd7be16348f67ec48e66af4b2df4a76' + references: + - https://docs.vast.ai/api-reference/accounts/show-user + validation: + type: Http + content: + request: + method: GET + url: https://console.vast.ai/api/v0/users/current/ + headers: + Authorization: "Bearer {{ TOKEN }}" + Accept: application/json + response_matcher: + - report_response: true + - type: StatusMatch + status: [200] + - type: JsonValid + - type: WordMatch + match_all_words: true + words: + - '"id"' + - '"email"' + - '"balance"' From b0e2ce8af15d072c615bbc93b175a40390825740 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Thu, 15 Jan 2026 22:15:59 -0800 Subject: [PATCH 09/16] new rules --- data/rules/coderabbit.yml | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 data/rules/coderabbit.yml diff --git a/data/rules/coderabbit.yml b/data/rules/coderabbit.yml new file mode 100644 index 0000000..f404722 --- /dev/null +++ b/data/rules/coderabbit.yml @@ -0,0 +1,39 @@ +rules: + - name: CodeRabbit API Key + id: kingfisher.coderabbit.1 + pattern: | + (?xi) + \b + ( + cr-[a-f0-9]{58} + ) + \b + pattern_requirements: + min_digits: 4 + min_entropy: 3.5 + confidence: medium + examples: + - "cr-33420bb12fddf6cde6fba5414df88b07f75b2258e30c956b95f2ddbb2d" + references: + - https://coderabbit.ai/ + - https://api.coderabbit.ai/docs + validation: + type: Http + content: + request: + method: GET + url: "https://api.coderabbit.ai/v1/seats/" + headers: + accept: "application/json" + x-coderabbitai-api-key: "{{TOKEN}}" + response_matcher: + - report_response: true + - type: WordMatch + words: + - '"success"' + - '"errors"' + match_all_words: false + - type: WordMatch + negative: true + words: + - '"Invalid or inactive API key"' From 2eadddcc4e55a5460a3a71c058f85d0b230a935e Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Thu, 15 Jan 2026 22:46:07 -0800 Subject: [PATCH 10/16] new rules --- data/rules/instantly.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/rules/instantly.yml b/data/rules/instantly.yml index 4e361ce..2331cb8 100644 --- a/data/rules/instantly.yml +++ b/data/rules/instantly.yml @@ -11,7 +11,7 @@ rules: (?:.|[\n\r]){0,16}? \b ( - [A-Za-z0-9+/]{66}== + [A-Z0-9+/]{66}== ) pattern_requirements: min_digits: 4 From bff15a334b546258fa35883d3b2226edb18e6f99 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Fri, 16 Jan 2026 08:15:32 -0800 Subject: [PATCH 11/16] new rules --- data/rules/apollo.yml | 56 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 data/rules/apollo.yml diff --git a/data/rules/apollo.yml b/data/rules/apollo.yml new file mode 100644 index 0000000..1914099 --- /dev/null +++ b/data/rules/apollo.yml @@ -0,0 +1,56 @@ +rules: + - name: Apollo API Key + id: kingfisher.apollo.1 + pattern: | + (?xi) + \b + apollo + (?:.|[\n\r]){0,16}? + (?:SECRET|PRIVATE|ACCESS|KEY|TOKEN) + (?:.|[\n\r]){0,32}? + \b + ( + [A-Z0-9_-]{22} + ) + \b + pattern_requirements: + min_digits: 2 + min_uppercase: 1 + min_lowercase: 1 + min_entropy: 3.0 + confidence: medium + categories: + - api + - secret + examples: + - "x-api-key: QyZ0oxTJblhYp3_o9gJFRA" + - 'APOLLO_API_KEY="ZNh-14foqIiscbz24oKwww"' + - apollo_key=8ku3EoDJxz8fOSCdxYozdA + - apollo.io api_key oD8GCL8MNZIyg0tzeSDuhw + references: + - https://docs.apollo.io/reference/people-api-search + validation: + type: Http + content: + request: + method: POST + url: "https://api.apollo.io/api/v1/mixed_people/api_search" + headers: + accept: "application/json" + content-type: "application/json" + x-api-key: "{{ TOKEN }}" + body: | + {"page":1,"per_page":1} + response_matcher: + - report_response: true + - type: StatusMatch + status: [200, 403] + - type: WordMatch + words: + - '"total_entries"' + - '"API_INACCESSIBLE"' + match_all_words: false + - type: WordMatch + negative: true + words: + - '"Invalid access credentials"' From fee6c2d5bab133867c74fb4b283ad32c347b32d5 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Fri, 16 Jan 2026 09:30:30 -0800 Subject: [PATCH 12/16] new rules --- data/rules/customerio.yml | 96 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 data/rules/customerio.yml diff --git a/data/rules/customerio.yml b/data/rules/customerio.yml new file mode 100644 index 0000000..1c5e96c --- /dev/null +++ b/data/rules/customerio.yml @@ -0,0 +1,96 @@ +rules: + - name: Customer.io Site ID (helper) + id: kingfisher.customerio.1 + visible: false + pattern: | + (?xi) + \b + (?:customer(?:\.?io)?|customerio|cio)? + (?:site[_-]?id|siteid) + \b + (?:\s*[:=]\s*|["']\s*:\s*["']|=\s*["']) + \s* + \b + ( + [0-9a-f]{20,22} + ) + \b + pattern_requirements: + min_digits: 4 + min_entropy: 3.0 + confidence: medium + examples: + - "site id: 683f668681041ec0963a" + - "site_id=4666cc67dc875420420d" + - "customerio_site_id: 7843e1010e52cda7bc8d" + references: + - https://docs.customer.io/integrations/api/#track-api + + - name: Customer.io Tracking API Key + id: kingfisher.customerio.2 + pattern: | + (?xi) + \b + (?:customer(?:\.?io)?|customerio|cio|tracking|track) + (?:.|[\n\r]){0,32}? + (?:SECRET|PRIVATE|ACCESS|KEY|TOKEN|API[_-]?KEY) + (?:.|[\n\r]){0,16}? + \b + ( + [0-9a-f]{20} + ) + \b + pattern_requirements: + min_digits: 4 + min_entropy: 3.0 + confidence: medium + examples: + - "tracking api key: f3b0c2b92eca01472efe" + - "customerio_key = a98eab982f4692ceb78f" + - "customer.io tracking_api_key d24d3915959b4d793a67" + references: + - https://docs.customer.io/integrations/api/#track-api + + - name: Customer.io App API Key + id: kingfisher.customerio.3 + pattern: | + (?xi) + \b + (?:customer(?:\.?io)?|customerio|cio) + (?:.|[\n\r]){0,32}? + (?:SECRET|PRIVATE|ACCESS|KEY|TOKEN|API) + (?:.|[\n\r]){0,16}? + \b + ( + [0-9a-f]{32} + ) + \b + pattern_requirements: + min_digits: 6 + min_entropy: 3.0 + confidence: medium + examples: + - "app api key: 2500f16b129a6644df32a88ba106f627" + - "customerio_app_key=6e86f5734527548b7477a8b627bf4855" + - "customer.io api key 8363e3ca7e897cae7d76b8f46632e155" + - "cio_app_key: 801b93d4c8627282bbd3524362f1ea9d" + references: + - https://docs.customer.io/integrations/api/#app-api + - https://api.customer.io/v1/workspaces + validation: + type: Http + content: + request: + method: GET + url: https://api.customer.io/v1/workspaces + headers: + Authorization: "Bearer {{ TOKEN }}" + response_matcher: + - report_response: true + - type: StatusMatch + status: [200] + - type: JsonValid + - type: WordMatch + match_all_words: true + words: + - '"workspaces"' From caaa31562cdddbb568e8b3952a178216f748279b Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Fri, 16 Jan 2026 10:03:59 -0800 Subject: [PATCH 13/16] Skipped per-repository report writes when an output file is specified and emit a single aggregated report after multi-repository scans to preserve full output content in files. --- src/scanner/runner.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/scanner/runner.rs b/src/scanner/runner.rs index 1371931..4b33fbd 100644 --- a/src/scanner/runner.rs +++ b/src/scanner/runner.rs @@ -459,6 +459,7 @@ pub async fn run_async_scan( let ran_repo_scan = Arc::new(AtomicBool::new(false)); let repo_errors: Arc>> = Arc::new(Mutex::new(Vec::new())); + let output_to_file = args.output_args.output.is_some(); rayon::ThreadPoolBuilder::new() .num_threads(repo_concurrency) @@ -538,8 +539,10 @@ pub async fn run_async_scan( global_stats.update(&repo_matcher_stats.lock().unwrap()); } - crate::reporter::run(global_args, Arc::clone(&repo_datastore), &args) - .context("Failed to run report command")?; + if !output_to_file { + crate::reporter::run(global_args, Arc::clone(&repo_datastore), &args) + .context("Failed to run report command")?; + } { let mut ds = datastore.lock().unwrap(); @@ -574,6 +577,11 @@ pub async fn run_async_scan( return Err(err); } + if output_to_file && ran_repo_scan.load(Ordering::Relaxed) { + crate::reporter::run(global_args, Arc::clone(&datastore), args) + .context("Failed to run report command")?; + } + if !ran_repo_scan.load(Ordering::Relaxed) { deduplicate_new_matches(&datastore, 0)?; From 4478ae9347c8eac3667fb53577663e411602b700 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Fri, 16 Jan 2026 10:04:23 -0800 Subject: [PATCH 14/16] Skipped per-repository report writes when an output file is specified and emit a single aggregated report after multi-repository scans to preserve full output content in files. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5cbaa..c1b9eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to this project will be documented in this file. - Added Slack Access Map support with granular permissions in the tree view. - Improved HTML report - Improved several rules +- Added new rules +- Skipped per-repository report writes when an output file is specified and emit a single aggregated report after multi-repository scans to preserve full output content in files. ## [v1.74.0] - Added new rules: cursor, definednetworking, filezilla, harness, intra42, klingai, lark, mergify, naver, plaid, resend, retellai From 594534f69f940ba610792b9626b2848fa7ddf8a6 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Fri, 16 Jan 2026 11:34:13 -0800 Subject: [PATCH 15/16] Skipped per-repository report writes when an output file is specified and emit a single aggregated report after multi-repository scans to preserve full output content in files. --- CHANGELOG.md | 2 +- data/rules/apollo.yml | 4 ---- data/rules/customerio.yml | 32 ++------------------------------ data/rules/instantly.yml | 6 +----- 4 files changed, 4 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b9eb7..21a7424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to this project will be documented in this file. - Added Slack Access Map support with granular permissions in the tree view. - Improved HTML report - Improved several rules -- Added new rules +- Added new rules for Apollo, Clay, CodeRabbit, Customer.io, Instantly, Vast.ai - Skipped per-repository report writes when an output file is specified and emit a single aggregated report after multi-repository scans to preserve full output content in files. ## [v1.74.0] diff --git a/data/rules/apollo.yml b/data/rules/apollo.yml index 1914099..14f10d1 100644 --- a/data/rules/apollo.yml +++ b/data/rules/apollo.yml @@ -19,11 +19,7 @@ rules: min_lowercase: 1 min_entropy: 3.0 confidence: medium - categories: - - api - - secret examples: - - "x-api-key: QyZ0oxTJblhYp3_o9gJFRA" - 'APOLLO_API_KEY="ZNh-14foqIiscbz24oKwww"' - apollo_key=8ku3EoDJxz8fOSCdxYozdA - apollo.io api_key oD8GCL8MNZIyg0tzeSDuhw diff --git a/data/rules/customerio.yml b/data/rules/customerio.yml index 1c5e96c..5d8181d 100644 --- a/data/rules/customerio.yml +++ b/data/rules/customerio.yml @@ -1,33 +1,6 @@ rules: - - name: Customer.io Site ID (helper) - id: kingfisher.customerio.1 - visible: false - pattern: | - (?xi) - \b - (?:customer(?:\.?io)?|customerio|cio)? - (?:site[_-]?id|siteid) - \b - (?:\s*[:=]\s*|["']\s*:\s*["']|=\s*["']) - \s* - \b - ( - [0-9a-f]{20,22} - ) - \b - pattern_requirements: - min_digits: 4 - min_entropy: 3.0 - confidence: medium - examples: - - "site id: 683f668681041ec0963a" - - "site_id=4666cc67dc875420420d" - - "customerio_site_id: 7843e1010e52cda7bc8d" - references: - - https://docs.customer.io/integrations/api/#track-api - - name: Customer.io Tracking API Key - id: kingfisher.customerio.2 + id: kingfisher.customerio.1 pattern: | (?xi) \b @@ -52,7 +25,7 @@ rules: - https://docs.customer.io/integrations/api/#track-api - name: Customer.io App API Key - id: kingfisher.customerio.3 + id: kingfisher.customerio.2 pattern: | (?xi) \b @@ -70,7 +43,6 @@ rules: min_entropy: 3.0 confidence: medium examples: - - "app api key: 2500f16b129a6644df32a88ba106f627" - "customerio_app_key=6e86f5734527548b7477a8b627bf4855" - "customer.io api key 8363e3ca7e897cae7d76b8f46632e155" - "cio_app_key: 801b93d4c8627282bbd3524362f1ea9d" diff --git a/data/rules/instantly.yml b/data/rules/instantly.yml index 2331cb8..955c7cc 100644 --- a/data/rules/instantly.yml +++ b/data/rules/instantly.yml @@ -17,12 +17,8 @@ rules: min_digits: 4 min_entropy: 3.3 confidence: medium - categories: - - api - - secret examples: - - 'INSTANTLY_API_KEY="ZGVhZGJlZWYtZGVhZC0xMTEtMjIyLTMzMzM0NDQ0NDQ0NDphYmNkZWZnaGlqaw=="' - - '"Authorization: Bearer ZGVhZGJlZWYtZGVhZC0xMTEtMjIyLTMzMzM0NDQ0NDQ0NDphYmNkZWZnaGlqaw=="' + - 'INSTANTLY_API_KEY="NmNlMCI1MWUtZDBmMC00NTc4LWE0MDItMDM0NGU0ZWI0MzliOmFzWWtCZUxUY3ZPRg=="' references: - https://developer.instantly.ai/api/v2/analytics/getdailyaccountanalytics validation: From 049294af3d236f63daead4e213ba033931f7e7a0 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Fri, 16 Jan 2026 12:39:44 -0800 Subject: [PATCH 16/16] Skipped per-repository report writes when an output file is specified and emit a single aggregated report after multi-repository scans to preserve full output content in files. --- data/rules/azurestorage.yml | 2 +- src/access_map/report.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/data/rules/azurestorage.yml b/data/rules/azurestorage.yml index 910de23..57704e3 100644 --- a/data/rules/azurestorage.yml +++ b/data/rules/azurestorage.yml @@ -16,7 +16,7 @@ rules: ([a-z0-9]{3,24})(?:\b|[^a-z0-9]) | # D) Explicit KV labels near 'azure storage/account name' with tight separators - (?i:Account[_.-]?Name|Storage[_.-]?(?:Name))(?:.|\s){0,32}?\b([A-Z0-9]{3,32})\b|([A-A0-9]{3,32})(?i:\.blob\.core\.windows\.net) + (?i:Account[_.-]?Name|Storage[_.-]?(?:Name))(?:.|\s){0,32}?\b([A-Z0-9]{3,32})\b|([A-Z0-9]{3,32})(?i:\.blob\.core\.windows\.net) ) min_entropy: 2.0 visible: false diff --git a/src/access_map/report.rs b/src/access_map/report.rs index 4909b4c..be5b6ef 100644 --- a/src/access_map/report.rs +++ b/src/access_map/report.rs @@ -915,7 +915,6 @@ fn build_html(json_str: &str, compressed_json_b64: &str) -> String { const sev = document.createElement('span'); sev.textContent = `Severity: ${model.severity || 'unknown'}`; - sev.textContent = `Severity: ${model.severity || 'unknown'}`; meta.appendChild(sev); if (model.fingerprint) {