diff --git a/CHANGELOG.md b/CHANGELOG.md index e619f05..d22afcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## [v1.71.0] +- Improved Report Viewer layout +- Improved Salesforce rule + ## [v1.70.0] - Added `--staged` argument to support new `pre-commit` behavior and added integration coverage to ensure validated secrets block commits when used as pre-commit hook - Added new rules for AWS Bedrock, Voyage.ai, Posthog, Atlassian diff --git a/Cargo.toml b/Cargo.toml index fef080a..159f03c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ publish = false [package] name = "kingfisher" -version = "1.70.0" +version = "1.71.0" description = "MongoDB's blazingly fast and accurate secret scanning and validation tool" edition.workspace = true rust-version.workspace = true diff --git a/README.md b/README.md index 06be427..945df27 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,12 @@ For a look at how Kingfisher has grown from its early foundations into today's f ### Performance, Accuracy, and Hundreds of Rules - **Performance**: multithreaded, Hyperscan‑powered scanning built for huge codebases - **Extensible rules**: hundreds of built-in detectors plus YAML-defined custom rules ([docs/RULES.md](/docs/RULES.md)) -- **Broad AI SaaS coverage**: finds and validates tokens for OpenAI, Anthropic, Google Gemini, Cohere, Mistral, Stability AI, Replicate, xAI (Grok), Ollama, Langchain, Perplexity, Weights & Biases, Cerebras, Friendli, Fireworks.ai, NVIDIA NIM, Together.ai, Zhipu, and many more +- **Blast Radius Mapping**: instantly map leaked keys to their effective cloud identities and exposed resources with `--access-map` +- **Broad AI SaaS coverage**: finds and validates tokens for OpenAI, Anthropic, Google Gemini, Cohere, AWS Bedrock, Voyage AI, Mistral, Stability AI, Replicate, xAI (Grok), Ollama, Langchain, Perplexity, Weights & Biases, Cerebras, Friendli, Fireworks.ai, NVIDIA NIM, Together.ai, Zhipu, and many more - **Compressed Files**: Supports extracting and scanning compressed files for secrets - **Baseline management**: generate and track baselines to suppress known secrets ([docs/BASELINE.md](/docs/BASELINE.md)) - **Checksum-aware detection**: verifies tokens with built-in checksums (e.g., GitHub, Confluent, Zuplo) — no API calls required +- **Built-in Report Viewer**: Visualize and triage findings locally with `kingisher view ./report-file.json` **Learn more:** [Introducing Kingfisher: Real‑Time Secret Detection and Validation](https://www.mongodb.com/blog/post/product-release-announcements/introducing-kingfisher-real-time-secret-detection-validation) @@ -559,7 +561,17 @@ kingfisher scan /path/to/repo --format sarif --output findings.sarif ### Access map outputs and viewer -- Add `--access-map` to enrich JSON, JSONL, BSON, pretty, and SARIF reports with an `access_map` array containing providers, accounts/projects, resources, and the permissions available for each resource (grouped when identical). +**Stop Guessing, Start Mapping: Understand Your True Blast Radius** + +Finding a leaked credential is only the first step. The critical question isn’t just “Is this a secret?”—it’s “What can an attacker do with it?” + +Kingfisher's `--access-map` feature transforms secret detection from a simple alert into a comprehensive threat assessment. Instead of leaving you with a cryptic API key, Kingfisher actively authenticates against your cloud provider (AWS or GCP) to map the full extent of the credential's power. + +* Instant Identity Resolution: Immediately identify who the key belongs to—whether it's a specific IAM user, an assumed role, or a service account. +* Visualize the Blast Radius: See exactly which resources (S3 buckets, EC2 instances, projects) are exposed and at risk. + + +Add `--access-map` to enrich JSON, JSONL, BSON, pretty, and SARIF reports with an `access_map` containing the resources and the permissions that the key can access - for each resource (grouped when identical). - If you validated cloud credentials without `--access-map`, Kingfisher will remind you on stderr to rerun with the flag so the access map appears in the output. - Run `kingfisher view ./kingfisher.json` to explore a report locally in a local web UI diff --git a/data/rules/salesforce.yml b/data/rules/salesforce.yml index a880203..415e5d2 100644 --- a/data/rules/salesforce.yml +++ b/data/rules/salesforce.yml @@ -63,36 +63,22 @@ rules: examples: - https://example123.my.salesforce.com - mydomainname.my.salesforce.com - - name: Salesforce Consumer Key and Secret with Token URL + - name: Salesforce Consumer Key id: kingfisher.salesforce.3 pattern: | - (?xi)(?s) + (?x)(?s) (?:salesforce|sforce) (?:.|[\n\r]){0,256}? - \bconsumer\s{0,8}key\b + \bconsumerKey\b (?:.|[\n\r]){0,32}? \b - (?P - [A-Z0-9+/=._-]{16,256} - ) - \b.*? - (?:.|[\n\r]){0,256}? - \bconsumer\s{0,8}secret\b - (?:.|[\n\r]){0,32}? - \b - (?P + ( [A-Za-z0-9+/=._-]{16,256} ) - .*? - \btoken\s{0,8}url\b - (?:.|[\n\r]){0,16}? - (?P - https?:// - [A-Za-z0-9.-]{3,253} - / - (?:services/oauth2/token|oauth/login/[\w-]+/token|oauth2/(?:v1/)?token|oauth/token|[\w]{1,10}/oauth/token) - ) + \b min_entropy: 3.3 + pattern_requirements: + min_digits: 3 confidence: medium examples: - | @@ -100,7 +86,7 @@ rules: https://login.example.com/oauth/login/v2/authorize?authHint=SALESFORCE_OAUTH2&authType=oauth2&prompt=login 012cbddfa6b05ec1941143c0d37a036291492be9f2df0b42c5c0c220198185de - REDACTED_CONSUMER_SECRET_1 + 7TVG9nQ8gW5RaRxV8i1SaI7vwa0xtQQoejTa48AR5QR6HBYV9YBKPnAzPU7bs6QxOgdjJy9TPabQYVTZtgT83 ExampleProviderOne false OpenIdConnect @@ -113,8 +99,8 @@ rules: https://api.example.net/oauth/authorize - REDACTED_CONSUMER_KEY_2 - REDACTED_CONSUMER_SECRET_2 + 012cbddfa6b05ec1941143c0d37a036291492be9f2df0b42c5c0c220198185de + 7TVG9nQ8gW5RaRxV8i1SaI7vwa0xtQQoejTa48AR5QR6HBYV9YBKPnAzPU7bs6QxOgdjJy9TPabQYVTZtgT83 ExampleBatchConnect false OpenIdConnect @@ -127,8 +113,65 @@ rules: https://api.example.net/oauth/authorize - REDACTED_CONSUMER_KEY_3 - REDACTED_CONSUMER_SECRET_3 + 012cbddfa6b05ec1941143c0d37a036291492be9f2df0b42c5c0c220198185de + 7TVG9nQ8gW5RaRxV8i1SaI7vwa0xtQQoejTa48AR5QR6HBYV9YBKPnAzPU7bs6QxOgdjJy9TPabQYVTZtgT83 + ExampleConnect + false + OpenIdConnect + true + false + true + https://api.example.net/oauth/token + + - name: Salesforce Consumer Secret + id: kingfisher.salesforce.4 + pattern: | + (?x)(?s) + \bconsumerSecret\b + (?:.|[\n\r]){0,32}? + \b + ( + [A-Za-z0-9+/=._-]{16,256} + ) + min_entropy: 3.3 + pattern_requirements: + min_digits: 6 + confidence: medium + examples: + - | + + + https://login.example.com/oauth/login/v2/authorize?authHint=SALESFORCE_OAUTH2&authType=oauth2&prompt=login + 012cbddfa6b05ec1941143c0d37a036291492be9f2df0b42c5c0c220198185de + 7TVG9nQ8gW5RaRxV8i1SaI7vwa0xtQQoejTa48AR5QR6HBYV9YBKPnAzPU7bs6QxOgdjJy9TPabQYVTZtgT83 + ExampleProviderOne + false + OpenIdConnect + true + false + true + https://login.example.com/oauth/login/v2/token + + - | + + + https://api.example.net/oauth/authorize + 012cbddfa6b05ec1941143c0d37a036291492be9f2df0b42c5c0c220198185de + 7TVG9nQ8gW5RaRxV8i1SaI7vwa0xtQQoejTa48AR5QR6HBYV9YBKPnAzPU7bs6QxOgdjJy9TPabQYVTZtgT83 + ExampleBatchConnect + false + OpenIdConnect + true + false + true + https://api.example.net/oauth/token + + - | + + + https://api.example.net/oauth/authorize + 012cbddfa6b05ec1941143c0d37a036291492be9f2df0b42c5c0c220198185de + 7TVG9nQ8gW5RaRxV8i1SaI7vwa0xtQQoejTa48AR5QR6HBYV9YBKPnAzPU7bs6QxOgdjJy9TPabQYVTZtgT83 ExampleConnect false OpenIdConnect @@ -137,33 +180,13 @@ rules: true https://api.example.net/oauth/token - validation: - type: Http - content: - request: - method: POST - url: "{{ TOKEN_URL | default: 'https://login.salesforce.com/services/oauth2/token' }}" - headers: - Content-Type: application/x-www-form-urlencoded - body: grant_type=client_credentials&client_id={{ CONSUMER_KEY | url_encode }}&client_secret={{ CONSUMER_SECRET | url_encode }} - response_matcher: - - report_response: true - - type: StatusMatch - status: [200] - - type: StatusMatch - status: [400, 401, 403] - negative: true - - type: WordMatch - words: ["invalid_", "authentication failed"] - match_all_words: false - negative: true - name: Salesforce Consumer Key and Secret id: kingfisher.salesforce.4 pattern: | (?xi)(?s) (?:salesforce|sforce) (?:.|[\n\r]){0,256}? - \bconsumer\s{0,8}key\b + \bconsumerKey\b (?:.|[\n\r]){0,32}? \b (?P @@ -177,7 +200,9 @@ rules: (?P [A-Za-z0-9+/=._-]{16,256} ) - min_entropy: 3.3 + min_entropy: 3.5 + pattern_requirements: + min_digits: 3 confidence: medium examples: - | @@ -185,7 +210,7 @@ rules: https://login.example.com/oauth/login/v2/authorize?authHint=SALESFORCE_OAUTH2&authType=oauth2&prompt=login 012cbddfa6b05ec1941143c0d37a036291492be9f2df0b42c5c0c220198185de - REDACTED_CONSUMER_SECRET_1 + 7TVG9nQ8gW5RaRxV8i1SaI7vwa0xtQQoejTa48AR5QR6HBYV9YBKPnAzPU7bs6QxOgdjJy9TPabQYVTZtgT83 ExampleProviderOne false OpenIdConnect @@ -198,8 +223,8 @@ rules: https://api.example.net/oauth/authorize - REDACTED_CONSUMER_KEY_2 - REDACTED_CONSUMER_SECRET_2 + 012cbddfa6b05ec1941143c0d37a036291492be9f2df0b42c5c0c220198185de + 7TVG9nQ8gW5RaRxV8i1SaI7vwa0xtQQoejTa48AR5QR6HBYV9YBKPnAzPU7bs6QxOgdjJy9TPabQYVTZtgT83 ExampleBatchConnect false OpenIdConnect @@ -212,8 +237,8 @@ rules: https://api.example.net/oauth/authorize - REDACTED_CONSUMER_KEY_3 - REDACTED_CONSUMER_SECRET_3 + 012cbddfa6b05ec1941143c0d37a036291492be9f2df0b42c5c0c220198185de + 7TVG9nQ8gW5RaRxV8i1SaI7vwa0xtQQoejTa48AR5QR6HBYV9YBKPnAzPU7bs6QxOgdjJy9TPabQYVTZtgT83 ExampleConnect false OpenIdConnect @@ -221,24 +246,4 @@ rules: false true https://api.example.net/oauth/token - - validation: - type: Http - content: - request: - method: POST - url: "https://login.salesforce.com/services/oauth2/token" - headers: - Content-Type: application/x-www-form-urlencoded - body: grant_type=client_credentials&client_id={{ CONSUMER_KEY | url_encode }}&client_secret={{ CONSUMER_SECRET | url_encode }} - response_matcher: - - report_response: true - - type: StatusMatch - status: [200] - - type: StatusMatch - status: [400, 401, 403] - negative: true - - type: WordMatch - words: ["invalid_", "authentication failed"] - match_all_words: false - negative: true \ No newline at end of file + \ No newline at end of file diff --git a/docs/access-map-viewer/index.html b/docs/access-map-viewer/index.html index e658dcf..be621fb 100644 --- a/docs/access-map-viewer/index.html +++ b/docs/access-map-viewer/index.html @@ -8,14 +8,16 @@ :root { color-scheme: dark; --brand: #0e7c56; - --brand-dark: #0a5d40; - --brand-soft: #123025; - --bg: #0b1220; - --surface: #111827; - --surface-muted: #0f172a; - --text-main: #e5e7eb; - --text-muted: #9ca3af; - --border: #1f2937; + --brand-dark: #0c6d4d; + --brand-soft: #10241c; + --bg: #060b16; + --surface: #0d1424; + --surface-muted: #111a2b; + --surface-strong: #0f192c; + --text-main: #f3f4f6; + --text-muted: #c7d2e2; + --border: #1f2a3f; + --border-strong: #2c3a55; --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.15); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.25), 0 2px 4px -2px rgb(0 0 0 / 0.2); --critical: #fca5a5; @@ -40,6 +42,7 @@ --brand-soft: #e6f4ed; --bg: #f3f4f6; --surface: #ffffff; + --surface-strong: #f4f6fb; --surface-muted: #f9fafb; --text-main: #111827; --text-muted: #6b7280; @@ -119,6 +122,87 @@ border-left: 1px solid rgba(255, 255, 255, 0.25); } + .layout { + display: grid; + grid-template-columns: 240px 1fr; + gap: 18px; + align-items: start; + } + + .sidebar { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + padding: 14px; + position: sticky; + top: 82px; + display: flex; + flex-direction: column; + gap: 12px; + } + + .nav-group { + display: flex; + flex-direction: column; + gap: 8px; + } + + .nav-button { + width: 100%; + text-align: left; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid transparent; + background: var(--surface-muted); + color: var(--text-main); + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + transition: border 0.15s ease, background 0.15s ease, transform 0.1s ease; + } + + .nav-button:hover { + background: var(--hover); + border-color: var(--border); + } + + .nav-button.active { + background: var(--brand-soft); + border-color: var(--brand); + color: var(--brand-dark); + box-shadow: var(--shadow-sm); + } + + .nav-icon { + font-size: 16px; + opacity: 0.9; + } + + .view { + display: flex; + flex-direction: column; + gap: 18px; + } + + .view-stack { + display: flex; + flex-direction: column; + gap: 18px; + } + + @media (max-width: 1100px) { + .layout { + grid-template-columns: 1fr; + } + .sidebar { + position: relative; + top: auto; + } + } + .info-banner { background: var(--surface); border-bottom: 1px solid var(--border); @@ -151,7 +235,7 @@ display: flex; justify-content: space-between; align-items: center; - background: var(--surface); + background: var(--surface-strong); } .panel__title h3 { margin: 0; font-size: 16px; font-weight: 600; } @@ -253,6 +337,15 @@ border-color: var(--brand); } + .am-empty-hint { + padding: 12px 16px; + background: var(--surface-muted); + border: 1px dashed var(--border); + border-radius: 8px; + color: var(--text-muted); + margin: 12px 20px 0; + } + /* Tree */ .tree-node { position: relative; @@ -385,6 +478,55 @@ word-break: break-all; } + .chart-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + padding: 0 20px 20px; + } + + .chart-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow-sm); + display: flex; + gap: 12px; + align-items: center; + min-height: 220px; + } + + .chart-legend { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 13px; + color: var(--text-main); + } + + .chart-legend-item { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + } + + .legend-swatch { + width: 14px; + height: 14px; + border-radius: 4px; + border: 1px solid var(--border); + flex-shrink: 0; + } + + #status-chart { + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow-sm); + } + /* Findings table */ .table-container { width: 100%; overflow-x: auto; } .table { width: 100%; border-collapse: collapse; font-size: 13px; } @@ -436,8 +578,10 @@ border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; - overflow-x: auto; - white-space: pre; + max-width: 100%; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; border: 1px solid var(--code-border); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35), 0 0 0 1px var(--code-border-strong); } @@ -474,17 +618,19 @@ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } - .path-mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 12px; + .path-area { + width: 100%; + min-height: 48px; background: var(--surface-muted); - padding: 4px 8px; - border-radius: 4px; - display: inline-block; - max-width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + color: var(--text-main); + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border-strong); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 13px; + resize: vertical; + overflow: auto; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); } .link-mono a { @@ -569,6 +715,31 @@ border-color: var(--border); } + #theme-toggle { + background: #f9fafb; + color: var(--brand); + border-color: rgba(255, 255, 255, 0.7); + font-weight: 700; + box-shadow: var(--shadow-sm); + } + + #theme-toggle:hover { + background: #ffffff; + border-color: rgba(255, 255, 255, 0.9); + color: var(--brand-dark); + } + + :root[data-theme="light"] #theme-toggle { + background: #0e7c56; + color: #ffffff; + border-color: #0c6d4d; + } + + :root[data-theme="light"] #theme-toggle:hover { + background: #0c6d4d; + border-color: #0a5d40; + } + .hidden { display: none !important; } .loading-overlay { @@ -662,190 +833,238 @@ - - + + -
-
-
-

Findings

-

Detailed list of detected secrets

-
-
- - - - - Rows - -
- - 0 of 0 - -
-
-
-
- - - - - - - - - - - -
- Rule - - Location - - Validation - - Confidence - - Line -
-
-
+ + @@ -860,6 +1079,7 @@ let currentPage = 1; let sortField = "rule"; let sortDirection = "asc"; + let autoCollapsedAccessMap = false; const dropZone = document.getElementById("drop-zone"); const fileInput = document.getElementById("file-input"); @@ -868,6 +1088,7 @@ const errorMsg = document.getElementById("error-msg"); const uploadSection = document.getElementById("upload-section"); const dashboard = document.getElementById("dashboard"); + const downloadPdfBtn = document.getElementById("download-pdf"); const searchInput = document.getElementById("search-input"); const validationSelect = document.getElementById("validation-filter"); @@ -883,7 +1104,17 @@ const amContainer = document.getElementById("am-container"); const amToggle = document.getElementById("am-toggle"); const copyAccessMapButton = document.getElementById("copy-access-map"); + const amEmptyNotice = document.getElementById("am-empty-notice"); const themeToggle = document.getElementById("theme-toggle"); + const fdPathInput = document.getElementById("fd-path"); + const navButtons = document.querySelectorAll("[data-view-target]"); + const statusChartCanvas = document.getElementById("status-chart"); + const statusLegend = document.getElementById("status-legend"); + const viewRegistry = { + "view-dashboard": document.getElementById("view-dashboard"), + "view-access": document.getElementById("view-access"), + "view-findings": document.getElementById("view-findings"), + }; const THEME_KEY = "access-map-viewer-theme"; @@ -915,6 +1146,10 @@ if (e.dataTransfer.files.length) processFile(e.dataTransfer.files[0]); }); + navButtons.forEach((btn) => { + btn.addEventListener("click", () => setActiveView(btn.dataset.viewTarget)); + }); + const resetButton = document.getElementById("reset-btn"); resetButton.addEventListener("click", () => { @@ -966,18 +1201,16 @@ }); amToggle.addEventListener("click", () => { - if (amContainer.classList.contains("hidden")) { - amContainer.classList.remove("hidden"); - amToggle.textContent = "Collapse"; - } else { - amContainer.classList.add("hidden"); - amToggle.textContent = "Expand"; - } + const willCollapse = !amContainer.classList.contains("hidden"); + setAccessMapCollapsed(willCollapse); }); downloadJsonBtn.addEventListener("click", downloadFindingsJson); downloadCsvBtn.addEventListener("click", downloadFindingsCsv); copyAccessMapButton.addEventListener("click", copyFilteredAccessMap); + if (downloadPdfBtn) { + downloadPdfBtn.addEventListener("click", generatePdfReport); + } if (themeToggle) { themeToggle.addEventListener("click", () => { const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; @@ -1004,6 +1237,51 @@ }); }); + function setActiveView(targetId) { + const fallback = "view-dashboard"; + const viewId = targetId && viewRegistry[targetId] ? targetId : fallback; + + Object.entries(viewRegistry).forEach(([id, el]) => { + if (!el) return; + if (id === viewId) el.classList.remove("hidden"); + else el.classList.add("hidden"); + }); + + navButtons.forEach((btn) => { + btn.classList.toggle("active", btn.dataset.viewTarget === viewId); + }); + } + + function setAccessMapCollapsed(collapsed, { auto = false } = {}) { + if (!amContainer) return; + if (collapsed) { + amContainer.classList.add("hidden"); + amToggle.textContent = "Expand"; + autoCollapsedAccessMap = auto; + } else { + amContainer.classList.remove("hidden"); + amToggle.textContent = "Collapse"; + autoCollapsedAccessMap = false; + } + } + + function syncAccessMapUi(hasAccess) { + const previouslyAutoCollapsed = autoCollapsedAccessMap; + if (amToggle) amToggle.disabled = !hasAccess; + if (amEmptyNotice) { + amEmptyNotice.classList.toggle("hidden", hasAccess); + } + + if (!hasAccess) { + setAccessMapCollapsed(true, { auto: true }); + return; + } + + if (previouslyAutoCollapsed) { + setAccessMapCollapsed(false); + } + } + function escapeHtml(unsafe) { if (unsafe === undefined || unsafe === null) return ""; return unsafe @@ -1096,13 +1374,15 @@ document.getElementById("am-empty-state").classList.remove("hidden"); document.getElementById("am-detail-view").classList.add("hidden"); - amContainer.classList.remove("hidden"); - amToggle.textContent = "Collapse"; + setAccessMapCollapsed(true, { auto: true }); + if (amToggle) amToggle.disabled = true; + if (amEmptyNotice) amEmptyNotice.classList.add("hidden"); renderAccessMapTree(); renderFindingsTable(); updateMetrics(); + setActiveView("view-dashboard"); dashboard.classList.add("hidden"); uploadSection.classList.remove("hidden"); @@ -1194,6 +1474,7 @@ searchInput.value = ""; validationSelect.value = "all"; + setActiveView("view-dashboard"); updateMetrics(); renderAccessMapTree(); renderFindingsTable(); @@ -1207,7 +1488,8 @@ } function updateMetrics() { - document.getElementById("stat-total").textContent = findings.length.toString(); + const totalFindings = findings.length; + document.getElementById("stat-total").textContent = totalFindings.toString(); const highSev = findings.filter((f) => { const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : ""; @@ -1215,24 +1497,16 @@ }).length; document.getElementById("stat-high").textContent = highSev.toString(); - const active = findings.filter((f) => { - const status = - f.finding && f.finding.validation && f.finding.validation.status - ? String(f.finding.validation.status) - : ""; - return normalizeValidationStatus(status) === "active"; - }).length; - document.getElementById("stat-active").textContent = active.toString(); + const validationCounts = calculateValidationCounts(); + document.getElementById("stat-active").textContent = (validationCounts.active || 0).toString(); document.getElementById("stat-identities").textContent = (accessMap || []).length.toString(); const durEl = document.getElementById("stat-duration"); - if (rawData && typeof rawData.scan_duration !== "undefined") { - const d = Number(rawData.scan_duration); - durEl.textContent = Number.isFinite(d) ? d.toFixed(2) + "s" : "-"; - } else { - durEl.textContent = "-"; - } + const scanSeconds = resolveScanDurationSeconds(rawData); + durEl.textContent = formatDurationText(scanSeconds); + + renderStatusChart(validationCounts); } function getFilteredSortedFindings() { @@ -1358,6 +1632,155 @@ return "unknown"; } + function calculateValidationCounts() { + const counts = { active: 0, inactive: 0, not_attempted: 0, unknown: 0 }; + findings.forEach((f) => { + const status = + f.finding && f.finding.validation && f.finding.validation.status + ? f.finding.validation.status + : ""; + const key = normalizeValidationStatus(status); + counts[key] = (counts[key] || 0) + 1; + }); + 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"); + if (!ctx) return; + + const palette = { + active: "#22c55e", + inactive: "#f97316", + not_attempted: "#38bdf8", + unknown: "#9ca3af", + }; + + const dataPoints = [ + { key: "active", label: "Active", color: palette.active }, + { key: "inactive", label: "Inactive", color: palette.inactive }, + { key: "not_attempted", label: "Not Attempted", color: palette.not_attempted }, + { key: "unknown", label: "Unknown", color: palette.unknown }, + ]; + + const total = dataPoints.reduce((sum, entry) => sum + (counts[entry.key] || 0), 0); + ctx.clearRect(0, 0, statusChartCanvas.width, statusChartCanvas.height); + + const style = getComputedStyle(document.documentElement); + const surfaceColor = style.getPropertyValue("--surface") || "#0d1424"; + const textColor = style.getPropertyValue("--text-main") || "#e5e7eb"; + + const radius = Math.min(statusChartCanvas.width, statusChartCanvas.height) / 2 - 10; + const centerX = statusChartCanvas.width / 2; + const centerY = statusChartCanvas.height / 2; + + ctx.save(); + ctx.fillStyle = surfaceColor; + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + if (total === 0) { + ctx.fillStyle = textColor; + ctx.font = "600 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("No validation data yet", centerX, centerY); + } else { + let startAngle = -Math.PI / 2; + dataPoints.forEach((entry) => { + const value = counts[entry.key] || 0; + if (!value) return; + const slice = (value / total) * Math.PI * 2; + ctx.beginPath(); + ctx.moveTo(centerX, centerY); + ctx.fillStyle = entry.color; + ctx.arc(centerX, centerY, radius, startAngle, startAngle + slice); + ctx.closePath(); + ctx.fill(); + startAngle += slice; + }); + } + + const innerRadius = radius * 0.55; + ctx.save(); + ctx.globalCompositeOperation = "destination-out"; + ctx.beginPath(); + ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + ctx.save(); + ctx.fillStyle = textColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = "700 16px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + ctx.fillText(total + " findings", centerX, centerY); + ctx.restore(); + + if (statusLegend) { + statusLegend.innerHTML = ""; + dataPoints.forEach((entry) => { + const row = document.createElement("div"); + row.className = "chart-legend-item"; + const swatch = document.createElement("span"); + swatch.className = "legend-swatch"; + swatch.style.background = entry.color; + const text = document.createElement("span"); + text.textContent = `${entry.label}: ${counts[entry.key] || 0}`; + row.appendChild(swatch); + row.appendChild(text); + statusLegend.appendChild(row); + }); + } + } + function triggerDownload(filename, content, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); @@ -1425,6 +1848,185 @@ triggerDownload("kingfisher-findings.csv", csv, "text/csv"); } + function generatePdfReport() { + if (!rawData) { + alert("Load a report before downloading a PDF report."); + return; + } + + const statusOrder = { active: 0, inactive: 1, not_attempted: 2, unknown: 3 }; + const findingsForReport = Array.isArray(findings) ? findings.slice() : []; + findingsForReport.sort((a, b) => { + const fa = a.finding || {}; + const fb = b.finding || {}; + const keyA = normalizeValidationStatus(fa.validation && fa.validation.status ? fa.validation.status : ""); + const keyB = normalizeValidationStatus(fb.validation && fb.validation.status ? fb.validation.status : ""); + const sa = statusOrder.hasOwnProperty(keyA) ? statusOrder[keyA] : 4; + const sb = statusOrder.hasOwnProperty(keyB) ? statusOrder[keyB] : 4; + if (sa !== sb) return sa - sb; + + const ra = (a.rule && (a.rule.name || a.rule.id)) || ""; + const rb = (b.rule && (b.rule.name || b.rule.id)) || ""; + return ra.localeCompare(rb); + }); + + const counts = calculateValidationCounts(); + 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 conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : ""; + return conf.toLowerCase() === "high"; + }).length; + + const findingsHtml = findingsForReport.length + ? findingsForReport + .map((entry) => { + const rule = entry.rule || {}; + const finding = entry.finding || {}; + const statusRaw = finding.validation && finding.validation.status ? finding.validation.status : "Unknown"; + const status = normalizeValidationStatus(statusRaw); + const snippet = (finding.snippet || "").toString().replace(/\s+/g, " ").trim(); + return ` + + ${escapeHtml(rule.name || rule.id || "")} + ${escapeHtml(finding.path || "")} + ${escapeHtml(statusRaw)} + ${escapeHtml(finding.confidence || "")} + ${finding.line != null ? escapeHtml(finding.line) : ""} + ${escapeHtml(snippet.slice(0, 200))} + + `; + }) + .join("") + : '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("") + : ""; + + const accessSectionContent = hasAccess + ? `
      ${accessListHtml}
    ` + : "

    No Access Map entries were found for this report.

    "; + + const pdfHtml = ` + + + + + Kingfisher Report + + + +

    Kingfisher Report

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

    Dashboard

    +
    +
    Total Findings
    ${findings.length}
    +
    High Confidence
    ${highConfidence}
    +
    Active Credentials
    ${counts.active || 0}
    +
    Identities Mapped
    ${accessMap.length}
    +
    Scan Duration
    ${durationText}
    +
    +
    + ${statusImage ? `Status chart` : ""} +
    + Active: ${counts.active || 0} + Inactive: ${counts.inactive || 0} + Not Attempted: ${counts.not_attempted || 0} + Unknown: ${counts.unknown || 0} +
    +
    +
    + +
    +

    Access Map

    + ${accessSectionContent} +
    + +
    +

    Findings (Active first)

    + + + + + + + + + + + + + ${findingsHtml} + +
    RuleFile PathStatusConfidenceLineSnippet
    +
    + + + `; + + const win = window.open("", "_blank", "width=1200,height=900"); + if (!win) { + alert("Please allow pop-ups to download the PDF report."); + return; + } + win.document.write(pdfHtml); + win.document.close(); + win.focus(); + setTimeout(() => win.print(), 400); + } + function renderFindingsTable() { const all = getFilteredSortedFindings(); const total = all.length; @@ -1518,7 +2120,11 @@ document.getElementById("fd-commit").textContent = commit; const path = finding.path || ""; - document.getElementById("fd-path").textContent = path || "—"; + if (fdPathInput) { + fdPathInput.value = path || "—"; + fdPathInput.style.height = "auto"; + fdPathInput.style.height = Math.min(fdPathInput.scrollHeight, 260) + "px"; + } const gitUrlWrapper = document.getElementById("fd-git-url-wrapper"); const gitUrlEl = document.getElementById("fd-git-url"); @@ -1563,6 +2169,8 @@ const filterLower = (filter || "").toLowerCase(); filteredAccessMapView = buildRenderableAccessMap(filterLower); + const hasAccess = Array.isArray(accessMap) && accessMap.length > 0; + syncAccessMapUi(hasAccess); if (!filteredAccessMapView || filteredAccessMapView.length === 0) { root.innerHTML =