kingfisher/docs/access-map-viewer/index.html
Mick Grove 078fa16e6a - Reduced per-match memory usage by compacting stored source locations and interning repeated capture names.
- Stored optional validation response bodies as boxed strings to avoid allocating empty payloads and to streamline validator caches.
- Parallelized git cloning based on the configured job count and begin scanning repositories as soon as each clone finishes to reduce end-to-end scan times.
- Combined per-repository results into a single aggregate summary after scans complete.
- Added initial access-map support and report viewer html file. Currently beta features.
2025-12-04 22:02:30 -08:00

1549 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kingfisher Access Map Viewer</title>
<style>
:root {
--brand: #0e7c56;
--brand-dark: #07402c;
--brand-soft: #e6f4ed;
--bg: #f3f4f6;
--surface: #ffffff;
--text-main: #111827;
--text-muted: #6b7280;
--border: #e5e7eb;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--critical: #dc2626;
--success: #059669;
--radius: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text-main);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* Header */
.hero {
background: var(--brand);
color: #ffffff;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-md);
position: sticky;
top: 0;
z-index: 50;
}
.hero__brand {
display: flex;
align-items: center;
gap: 12px;
}
.hero__icon {
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.15);
color: white;
border-radius: 6px;
display: grid;
place-items: center;
font-weight: 600;
font-size: 18px;
}
.hero__title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
}
.hero__subtitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
margin-left: 10px;
padding-left: 10px;
border-left: 1px solid rgba(255, 255, 255, 0.25);
}
.page {
max-width: 1600px;
margin: 24px auto;
padding: 0 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.panel__header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
}
.panel__title h3 { margin: 0; font-size: 16px; font-weight: 600; }
.panel__title p { margin: 4px 0 0; font-size: 13px; color: var(--text-muted); }
/* Upload */
.upload-area {
padding: 32px;
text-align: center;
background: #f9fafb;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid var(--border);
border-radius: var(--radius);
}
.upload-area:hover,
.upload-area.active {
background: var(--brand-soft);
border-color: var(--brand);
}
.upload-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.8; }
.upload-text { font-size: 16px; font-weight: 500; }
.upload-sub { color: var(--text-muted); margin-top: 4px; }
/* Metrics */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
padding: 20px;
gap: 16px;
}
.metric {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
box-shadow: var(--shadow-sm);
}
.metric__label {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric__value {
font-size: 26px;
font-weight: 700;
margin-top: 6px;
}
.metric.critical .metric__value { color: var(--critical); }
.metric.success .metric__value { color: var(--success); }
/* Access Map */
.am-container {
display: grid;
grid-template-columns: 2fr 1fr; /* tree 2/3, detail 1/3 */
height: 600px;
background: var(--surface);
}
.am-sidebar {
border-right: 1px solid var(--border);
background: #f9fafb;
overflow-y: auto;
padding: 16px;
}
.am-main {
padding: 24px;
overflow-y: auto;
background: #ffffff;
}
.am-search {
position: sticky;
top: 0;
background: #f9fafb;
padding-bottom: 10px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
z-index: 5;
}
.am-search input {
width: 100%;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 13px;
}
.am-search input:focus {
outline: none;
border-color: var(--brand);
}
/* Tree */
.tree-node {
position: relative;
margin-left: 20px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
.tree-node:last-child {
border-left: none;
}
.tree-node::before {
content: "";
position: absolute;
top: 14px;
left: 0;
width: 12px;
height: 1px;
background: var(--border);
}
.node-content {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
user-select: none;
font-size: 13px;
}
.node-content:hover {
background: #e5e7eb;
}
.node-icon {
width: 20px;
height: 20px;
border-radius: 4px;
display: grid;
place-items: center;
font-size: 12px;
flex-shrink: 0;
}
.icon-identity { background: #e0e7ff; color: #3730a3; }
.icon-resource { background: #ffe4e6; color: #9f1239; }
.icon-permission { background: #dcfce7; color: #166534; }
.icon-group { background: #fef3c7; color: #92400e; }
.tree-children {
padding-top: 4px;
padding-left: 8px;
display: none;
}
.tree-children.open {
display: block;
}
/* Access detail */
.am-card-header {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.am-card-title {
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
gap: 12px;
}
.detail-icon-lg {
width: 40px;
height: 40px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--bg);
border: 1px solid var(--border);
font-size: 20px;
}
.am-card-meta {
display: flex;
gap: 8px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
flex-wrap: wrap;
}
.badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
border: 1px solid transparent;
}
.badge-aws { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
.badge-gcp { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
.badge-perm { background: #ecfdf5; color: #16a34a; border-color: #bbf7d0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.detail-field label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.detail-field div {
font-weight: 500;
font-size: 14px;
word-break: break-all;
}
/* Findings table */
.table-container { width: 100%; overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
text-align: left;
padding: 12px 16px;
background: #f9fafb;
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-weight: 600;
}
.table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: #f9fafb; cursor: pointer; }
.sortable {
cursor: pointer;
user-select: none;
}
.sort-indicator {
font-size: 10px;
margin-left: 4px;
opacity: 0.6;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.status-badge.active { background: #ecfdf5; color: #15803d; }
.status-badge.inactive { background: #fef2f2; color: #b91c1c; }
.status-badge.unknown { background: #f3f4f6; color: #6b7280; }
/* Finding detail snippet */
.snippet-box {
background: #0f172a;
color: #e5e7eb;
padding: 16px;
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
overflow-x: auto;
white-space: pre;
border: 1px solid #1f2937;
}
#fd-validation-box {
margin-top: 16px;
padding: 14px 16px;
background: #ecfdf5;
border: 1px solid #bbf7d0;
border-radius: 6px;
}
#fd-validation-box label {
font-size: 13px;
font-weight: 600;
color: #166534;
display: block;
margin-bottom: 4px;
}
#fd-validation-res {
white-space: pre-wrap;
font-size: 12px;
color: #166534;
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;
background: #f3f4f6;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.link-mono a {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
color: #1d4ed8;
text-decoration: none;
word-break: break-all;
}
.link-mono a:hover {
text-decoration: underline;
}
/* Search & pagination */
.search-input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
width: 220px;
font-size: 13px;
}
.rows-label {
font-size: 12px;
color: var(--text-muted);
}
.rows-select {
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: #fff;
font-size: 13px;
}
.pager {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.pager-btn {
width: 28px;
height: 28px;
border-radius: 999px;
border: 1px solid var(--border);
background: #fff;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.pager-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* Buttons & loader */
.btn {
background: #ffffff;
border: 1px solid var(--border);
padding: 8px 16px;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
color: var(--text-main);
transition: background 0.1s, border-color 0.1s;
}
.btn:hover {
background: #f3f4f6;
border-color: #cbd5e1;
}
.hidden { display: none !important; }
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.2);
backdrop-filter: blur(2px);
z-index: 100;
display: grid;
place-items: center;
}
.spinner {
width: 40px;
height: 40px;
border-radius: 999px;
border: 3px solid #e5e7eb;
border-top-color: #0e7c56;
animation: spin 0.8s linear infinite;
}
.progress-track {
width: 220px;
height: 6px;
border-radius: 999px;
background: #e5e7eb;
overflow: hidden;
margin: 12px auto 0;
}
.progress-inner {
width: 40%;
height: 100%;
background: linear-gradient(90deg, #0e7c56, #22c55e, #f59e0b);
animation: progress-move 0.9s linear infinite;
}
@keyframes progress-move {
0% { transform: translateX(-100%); }
50% { transform: translateX(0%); }
100% { transform: translateX(100%); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="loader" class="loading-overlay hidden">
<div style="background:#ffffff; padding:18px 22px; border-radius:10px; border:1px solid var(--border); box-shadow:var(--shadow-md); text-align:center; min-width:260px;">
<div class="spinner" style="margin:0 auto 12px;"></div>
<div class="progress-track"><div class="progress-inner"></div></div>
<div id="loader-text" style="margin-top:10px; font-size:13px; color:var(--text-muted);">
Processing report...
</div>
</div>
</div>
<header class="hero">
<div class="hero__brand">
<div class="hero__icon">K</div>
<div style="display:flex; align-items:center;">
<span class="hero__title">Kingfisher Viewer</span>
<span class="hero__subtitle">Access Map &amp; Findings</span>
</div>
</div>
<button class="btn" id="reset-btn">Reset</button>
</header>
<main class="page">
<section id="upload-section" class="panel" style="max-width: 640px; margin: 0 auto;">
<div class="panel__header">
<div class="panel__title">
<h3>Upload Report</h3>
<p>Analyze Kingfisher JSON / JSONL output</p>
</div>
</div>
<div style="padding:22px;">
<div class="upload-area" id="drop-zone">
<div class="upload-icon">📄</div>
<div class="upload-text">Drag &amp; drop a report here</div>
<div class="upload-sub">…or click to choose a file</div>
<input type="file" id="file-input" hidden accept=".json,.jsonl">
</div>
<div id="error-msg" class="hidden" style="margin-top:16px; padding:10px 12px; background:#fef2f2; border:1px solid #fecaca; border-radius:6px; color:#b91c1c; font-size:13px;"></div>
</div>
</section>
<div id="dashboard" class="hidden" style="display:flex; flex-direction:column; gap:24px;">
<section class="panel">
<div class="metrics-grid">
<div class="metric">
<div class="metric__label">Total Findings</div>
<div class="metric__value" id="stat-total">0</div>
</div>
<div class="metric critical">
<div class="metric__label">High Confidence</div>
<div class="metric__value" id="stat-high">0</div>
</div>
<div class="metric success">
<div class="metric__label">Active Credentials</div>
<div class="metric__value" id="stat-active">0</div>
</div>
<div class="metric">
<div class="metric__label">Identities Mapped</div>
<div class="metric__value" id="stat-identities">0</div>
</div>
<div class="metric">
<div class="metric__label">Scan Duration</div>
<div class="metric__value" id="stat-duration" style="font-size:20px; margin-top:8px;">-</div>
</div>
</div>
</section>
<section class="panel" id="am-section">
<div class="panel__header">
<div class="panel__title">
<h3>Access Map</h3>
<p>Identity hierarchy: Identity &gt; Resources &gt; Resource &gt; Permissions</p>
</div>
<button class="btn" id="am-toggle" type="button">Collapse</button>
</div>
<div class="am-container" id="am-container">
<div class="am-sidebar">
<div class="am-search">
<input id="tree-search" type="text" placeholder="Filter identities or resources…">
</div>
<div id="am-tree-root" style="padding-top:8px;">
<div style="color:var(--text-muted); font-size:13px; text-align:center; margin-top:32px;">
No access map data found in report.
</div>
</div>
</div>
<div class="am-main">
<div id="am-empty-state" style="display:grid; place-items:center; height:100%; color:var(--text-muted);">
Select an item in the tree to view details.
</div>
<div id="am-detail-view" class="hidden">
<div class="am-card-header">
<div class="am-card-title">
<div id="am-detail-icon" class="detail-icon-lg">👤</div>
<div>
<div id="am-detail-name">Identity</div>
<div class="am-card-meta" id="am-detail-meta"></div>
</div>
</div>
</div>
<div class="detail-grid">
<div class="detail-field">
<label>Type</label>
<div id="am-detail-type">Identity</div>
</div>
<div class="detail-field">
<label>Cloud Provider</label>
<div id="am-detail-cloud">-</div>
</div>
</div>
<div id="am-perms-container" class="hidden">
<h4 style="margin-bottom:10px; border-bottom:1px solid var(--border); padding-bottom:6px;">Permissions</h4>
<div id="am-perms-list" style="display:flex; flex-wrap:wrap; gap:6px;"></div>
</div>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel__header">
<div class="panel__title">
<h3>Findings</h3>
<p>Detailed list of detected secrets</p>
</div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<input type="text" id="search-input" class="search-input" placeholder="Search rule, path, snippet">
<select id="validation-filter" class="rows-select">
<option value="all" selected>All validation states</option>
<option value="active">Active Credential</option>
<option value="inactive">Inactive Credential</option>
<option value="not_attempted">Not Attempted</option>
</select>
<span class="rows-label">Rows</span>
<select id="rows-select" class="rows-select">
<option value="10" selected>10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
<div class="pager">
<button id="page-prev" class="pager-btn" type="button">&lsaquo;</button>
<span id="page-info">0 of 0</span>
<button id="page-next" class="pager-btn" type="button">&rsaquo;</button>
</div>
</div>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th class="sortable" data-field="rule">
Rule <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="location">
Location <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="validation">
Validation <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="confidence">
Confidence <span class="sort-indicator"></span>
</th>
<th class="sortable" data-field="line">
Line <span class="sort-indicator"></span>
</th>
</tr>
</thead>
<tbody id="findings-body"></tbody>
</table>
</div>
</section>
<section id="finding-detail" class="panel hidden">
<div class="panel__header">
<div class="panel__title">
<h3>Finding Details</h3>
</div>
<button class="btn" onclick="document.getElementById('finding-detail').classList.add('hidden')">Close</button>
</div>
<div style="padding:20px 22px 22px;">
<div class="detail-grid">
<div class="detail-field">
<label>Rule ID</label>
<div id="fd-rule-id"></div>
</div>
<div class="detail-field">
<label>Fingerprint</label>
<div id="fd-fingerprint"></div>
</div>
<div class="detail-field">
<label>Entropy</label>
<div id="fd-entropy"></div>
</div>
<div class="detail-field">
<label>Git Commit</label>
<div id="fd-commit"></div>
</div>
<div class="detail-field">
<label>File Path</label>
<div id="fd-path" class="path-mono"></div>
</div>
<div class="detail-field" id="fd-git-url-wrapper">
<label>Git URL</label>
<div id="fd-git-url" class="link-mono"></div>
</div>
</div>
<label style="font-size:12px; color:var(--text-muted); display:block; margin-bottom:6px; text-transform:uppercase; letter-spacing:0.06em; font-weight:600;">
Match Snippet
</label>
<div class="snippet-box" id="fd-snippet"></div>
<div id="fd-validation-box" class="hidden">
<label>Live Validation Response</label>
<pre id="fd-validation-res"></pre>
</div>
</div>
</section>
</div>
</main>
<script>
let rawData = null;
let findings = [];
let accessMap = [];
let currentFilter = "";
let validationFilter = "all";
let pageSize = 10;
let currentPage = 1;
let sortField = "rule";
let sortDirection = "asc";
const dropZone = document.getElementById("drop-zone");
const fileInput = document.getElementById("file-input");
const loader = document.getElementById("loader");
const loaderText = document.getElementById("loader-text");
const errorMsg = document.getElementById("error-msg");
const uploadSection = document.getElementById("upload-section");
const dashboard = document.getElementById("dashboard");
const searchInput = document.getElementById("search-input");
const validationSelect = document.getElementById("validation-filter");
const rowsSelect = document.getElementById("rows-select");
const pagePrev = document.getElementById("page-prev");
const pageNext = document.getElementById("page-next");
const pageInfo = document.getElementById("page-info");
const findingsBody = document.getElementById("findings-body");
const treeSearch = document.getElementById("tree-search");
const amContainer = document.getElementById("am-container");
const amToggle = document.getElementById("am-toggle");
dropZone.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => {
if (e.target.files.length) processFile(e.target.files[0]);
});
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("active");
});
dropZone.addEventListener("dragleave", (e) => {
e.preventDefault();
dropZone.classList.remove("active");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("active");
if (e.dataTransfer.files.length) processFile(e.dataTransfer.files[0]);
});
document.getElementById("reset-btn").addEventListener("click", () => location.reload());
searchInput.addEventListener("input", (e) => {
currentFilter = e.target.value || "";
currentPage = 1;
renderFindingsTable();
});
validationSelect.addEventListener("change", (e) => {
validationFilter = e.target.value || "all";
currentPage = 1;
renderFindingsTable();
});
rowsSelect.addEventListener("change", (e) => {
pageSize = parseInt(e.target.value, 10) || 10;
currentPage = 1;
renderFindingsTable();
});
pagePrev.addEventListener("click", () => {
if (currentPage > 1) {
currentPage--;
renderFindingsTable();
}
});
pageNext.addEventListener("click", () => {
const total = getFilteredSortedFindings().length;
const totalPages = total ? Math.ceil(total / pageSize) : 1;
if (currentPage < totalPages) {
currentPage++;
renderFindingsTable();
}
});
treeSearch.addEventListener("input", (e) => {
renderAccessMapTree(e.target.value || "");
});
amToggle.addEventListener("click", () => {
if (amContainer.classList.contains("hidden")) {
amContainer.classList.remove("hidden");
amToggle.textContent = "Collapse";
} else {
amContainer.classList.add("hidden");
amToggle.textContent = "Expand";
}
});
document.querySelectorAll("th.sortable").forEach((th) => {
th.addEventListener("click", () => {
const field = th.dataset.field;
if (!field) return;
if (sortField === field) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortField = field;
sortDirection = "asc";
}
currentPage = 1;
renderFindingsTable();
});
});
function escapeHtml(unsafe) {
if (unsafe === undefined || unsafe === null) return "";
return unsafe
.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function parsePossiblyMultiJson(text) {
try {
return JSON.parse(text);
} catch (e) {
const trimmed = text.trim();
const parts = trimmed.split(/\n(?={)/g);
if (parts.length > 1) {
try {
return JSON.parse("[" + parts.join(",") + "]");
} catch (e2) {}
}
}
return null;
}
function processFile(file) {
loaderText.textContent = 'Processing "' + file.name + '"…';
loader.classList.remove("hidden");
errorMsg.classList.add("hidden");
errorMsg.textContent = "";
setTimeout(() => {
const reader = new FileReader();
reader.onload = (e) => {
try {
parseAndRender(e.target.result);
} catch (err) {
console.error(err);
errorMsg.textContent = "Error parsing file: " + err.message;
errorMsg.classList.remove("hidden");
loader.classList.add("hidden");
}
};
reader.readAsText(file);
}, 30);
}
function parseAndRender(text) {
const t0 = performance.now();
findings = [];
accessMap = [];
rawData = null;
let parsed = parsePossiblyMultiJson(text);
if (parsed === null) {
const lines = text.split("\\n");
const tmpFindings = [];
const tmpAccessMap = [];
let mainReport = null;
let statsReport = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line[0] !== "{") continue;
try {
const obj = JSON.parse(line);
if (!mainReport && (Array.isArray(obj.findings) || Array.isArray(obj.access_map))) {
mainReport = obj;
}
if (typeof obj.scan_duration !== "undefined" || obj.kingfisher || typeof obj.bytes_scanned !== "undefined") {
statsReport = obj;
}
if (obj.findings) tmpFindings.push(...obj.findings);
else if (obj.access_map) tmpAccessMap.push(...obj.access_map);
else if (obj.rule && obj.finding) tmpFindings.push(obj);
else if (obj.provider && obj.account) tmpAccessMap.push(obj);
} catch (errLine) {
console.warn("Skipping invalid JSON line", i);
}
}
findings = tmpFindings;
accessMap = tmpAccessMap;
rawData = Object.assign({}, mainReport || {}, statsReport || {});
} else {
let mainReport = null;
let statsReport = null;
if (Array.isArray(parsed)) {
parsed.forEach((item) => {
if (!item || typeof item !== "object") return;
if (!mainReport && (Array.isArray(item.findings) || Array.isArray(item.access_map))) {
mainReport = item;
}
if (typeof item.scan_duration !== "undefined" || item.kingfisher || typeof item.bytes_scanned !== "undefined") {
statsReport = item;
}
});
} else {
const item = parsed;
if (Array.isArray(item.findings) || Array.isArray(item.access_map)) {
mainReport = item;
}
if (typeof item.scan_duration !== "undefined" || item.kingfisher || typeof item.bytes_scanned !== "undefined") {
statsReport = item;
}
}
const dataForFindings = mainReport || statsReport || {};
findings = Array.isArray(dataForFindings.findings) ? dataForFindings.findings : [];
accessMap = Array.isArray(dataForFindings.access_map) ? dataForFindings.access_map : [];
rawData = Object.assign({}, dataForFindings, statsReport || {});
}
currentPage = 1;
currentFilter = "";
validationFilter = "all";
searchInput.value = "";
validationSelect.value = "all";
updateMetrics();
renderAccessMapTree();
renderFindingsTable();
uploadSection.classList.add("hidden");
dashboard.classList.remove("hidden");
const elapsed = performance.now() - t0;
loaderText.textContent = "Done in " + elapsed.toFixed(1) + " ms";
setTimeout(() => loader.classList.add("hidden"), 250);
}
function updateMetrics() {
document.getElementById("stat-total").textContent = findings.length.toString();
const highSev = findings.filter((f) => {
const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).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();
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 = "-";
}
}
function getFilteredSortedFindings() {
const filterLower = currentFilter.toLowerCase();
const validation = validationFilter;
let arr = findings.filter((f) => {
const rule = f.rule || {};
const finding = f.finding || {};
const ruleName = (rule.name || rule.id || "").toLowerCase();
const path = (finding.path || "").toLowerCase();
const snippet = (finding.snippet || "").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)) {
return false;
}
}
const normalizedStatus = normalizeValidationStatus(status);
if (validation === "active") {
return normalizedStatus === "active";
} else if (validation === "inactive") {
return normalizedStatus === "inactive";
} else if (validation === "not_attempted") {
return normalizedStatus === "not_attempted";
}
return true;
});
const dirFactor = sortDirection === "asc" ? 1 : -1;
arr.sort((a, b) => {
const ra = a.rule || {};
const rb = b.rule || {};
const fa = a.finding || {};
const fb = b.finding || {};
let va = "";
let vb = "";
switch (sortField) {
case "rule":
va = (ra.name || ra.id || "").toLowerCase();
vb = (rb.name || rb.id || "").toLowerCase();
break;
case "location":
va = (fa.path || "").toLowerCase();
vb = (fb.path || "").toLowerCase();
break;
case "confidence":
va = (fa.confidence || "").toLowerCase();
vb = (fb.confidence || "").toLowerCase();
break;
case "validation":
va = (fa.validation && fa.validation.status ? fa.validation.status : "").toLowerCase();
vb = (fb.validation && fb.validation.status ? fb.validation.status : "").toLowerCase();
break;
case "line":
va = fa.line != null ? Number(fa.line) : Number.POSITIVE_INFINITY;
vb = fb.line != null ? Number(fb.line) : Number.POSITIVE_INFINITY;
break;
default:
return 0;
}
if (typeof va === "number" && typeof vb === "number") {
if (va < vb) return -1 * dirFactor;
if (va > vb) return 1 * dirFactor;
return 0;
} else {
if (va < vb) return -1 * dirFactor;
if (va > vb) return 1 * dirFactor;
return 0;
}
});
return arr;
}
function updateSortIndicators() {
document.querySelectorAll("th.sortable").forEach((th) => {
const field = th.dataset.field;
const span = th.querySelector(".sort-indicator");
if (!span) return;
if (field === sortField) {
span.textContent = sortDirection === "asc" ? "▲" : "▼";
} else {
span.textContent = "";
}
});
}
function trimPath(path) {
if (!path) return "-";
const parts = path.split("/");
if (parts.length > 4) {
return ".../" + parts.slice(-3).join("/");
}
return path;
}
function normalizeValidationStatus(status) {
const normalized = (status || "").toString().trim().toLowerCase();
if (normalized === "active credential" || normalized === "active") {
return "active";
}
if (normalized === "inactive credential" || normalized === "inactive") {
return "inactive";
}
if (normalized === "not attempted" || normalized === "not_attempted") {
return "not_attempted";
}
return "unknown";
}
function renderFindingsTable() {
const all = getFilteredSortedFindings();
const total = all.length;
const totalPages = total ? Math.ceil(total / pageSize) : 1;
if (currentPage > totalPages) currentPage = totalPages;
const startIndex = total ? (currentPage - 1) * pageSize : 0;
const endIndex = total ? Math.min(startIndex + pageSize, total) : 0;
const pageItems = total ? all.slice(startIndex, endIndex) : [];
findingsBody.innerHTML = "";
const frag = document.createDocumentFragment();
if (pageItems.length === 0) {
findingsBody.innerHTML =
'<tr><td colspan="5" style="text-align:center; padding:24px; color:var(--text-muted);">No findings match your filters.</td></tr>';
} else {
for (let i = 0; i < pageItems.length; i++) {
const f = pageItems[i] || {};
const rule = f.rule || {};
const finding = f.finding || {};
const ruleName = rule.name || rule.id || "(unknown rule)";
const path = finding.path || "";
const status =
finding.validation && finding.validation.status
? String(finding.validation.status)
: "Unknown";
const normalizedStatus = normalizeValidationStatus(status);
const badgeClass =
normalizedStatus === "active"
? "active"
: normalizedStatus === "inactive"
? "inactive"
: "unknown";
const tr = document.createElement("tr");
tr.innerHTML = `
<td>
<div style="font-weight:600">${escapeHtml(ruleName)}</div>
<div style="font-size:11px; color:var(--text-muted)">${escapeHtml(rule.id || "")}</div>
</td>
<td style="font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size:12px; color:var(--text-muted);" title="${escapeHtml(path)}">
${escapeHtml(trimPath(path))}
</td>
<td>
<span class="status-badge ${badgeClass}">${escapeHtml(status)}</span>
</td>
<td>${escapeHtml(finding.confidence || "")}</td>
<td style="font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;">
${finding.line != null ? finding.line : ""}
</td>
`;
tr.onclick = () => showFindingDetail(f);
frag.appendChild(tr);
}
findingsBody.appendChild(frag);
}
if (total === 0) {
pageInfo.textContent = "0 of 0";
pagePrev.disabled = true;
pageNext.disabled = true;
} else {
pageInfo.textContent = (startIndex + 1) + "" + endIndex + " of " + total;
pagePrev.disabled = currentPage <= 1;
pageNext.disabled = currentPage >= totalPages;
}
updateSortIndicators();
}
function showFindingDetail(f) {
const rule = f.rule || {};
const finding = f.finding || {};
const panel = document.getElementById("finding-detail");
panel.classList.remove("hidden");
panel.scrollIntoView({ behavior: "smooth", block: "start" });
document.getElementById("fd-rule-id").textContent = rule.id || "";
document.getElementById("fd-fingerprint").textContent = finding.fingerprint || "";
document.getElementById("fd-entropy").textContent =
finding.entropy != null ? String(finding.entropy) : "";
const commit =
finding.git_metadata && finding.git_metadata.commit && finding.git_metadata.commit.id
? finding.git_metadata.commit.id.substring(0, 8)
: "N/A";
document.getElementById("fd-commit").textContent = commit;
const path = finding.path || "";
document.getElementById("fd-path").textContent = path || "—";
const gitUrlWrapper = document.getElementById("fd-git-url-wrapper");
const gitUrlEl = document.getElementById("fd-git-url");
const gitUrl = getFileUrlFromFinding(finding);
gitUrlEl.innerHTML = "";
if (gitUrl) {
gitUrlWrapper.style.display = "";
const a = document.createElement("a");
a.href = gitUrl;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = gitUrl;
gitUrlEl.appendChild(a);
} else {
gitUrlWrapper.style.display = "none";
}
document.getElementById("fd-snippet").textContent = finding.snippet || "";
const valBox = document.getElementById("fd-validation-box");
if (finding.validation && finding.validation.response) {
valBox.classList.remove("hidden");
document.getElementById("fd-validation-res").textContent =
finding.validation.response;
} else {
valBox.classList.add("hidden");
document.getElementById("fd-validation-res").textContent = "";
}
}
function getFileUrlFromFinding(finding) {
if (!finding || !finding.git_metadata) return "";
const fileObj = finding.git_metadata.file || {};
const url = fileObj.url || "";
if (url && /^https?:\/\//i.test(url)) return url;
return "";
}
function renderAccessMapTree(filter = "") {
const root = document.getElementById("am-tree-root");
root.innerHTML = "";
const filterLower = (filter || "").toLowerCase();
if (!accessMap || accessMap.length === 0) {
root.innerHTML =
'<div style="padding:16px; text-align:center; color:var(--text-muted); font-size:13px;">No access map data available</div>';
return;
}
accessMap.forEach((identity) => {
const account = identity.account || "unknown-identity";
const accountLower = account.toLowerCase();
const groups = Array.isArray(identity.groups) ? identity.groups : [];
const identityNameMatches = filterLower && accountLower.includes(filterLower);
let anyResourceMatches = false;
const preparedGroups = groups.map((group) => {
const resources = Array.isArray(group.resources) ? group.resources : [];
const perms = Array.isArray(group.permissions) ? group.permissions : [];
const filteredResources = [];
resources.forEach((resName) => {
const resLower = String(resName).toLowerCase();
const matches =
!filterLower || resLower.includes(filterLower) || identityNameMatches;
if (matches) {
filteredResources.push(resName);
if (!identityNameMatches && filterLower && resLower.includes(filterLower)) {
anyResourceMatches = true;
}
}
});
return { resources: filteredResources, permissions: perms, originalResources: resources };
});
let hasMatch;
if (!filterLower) {
hasMatch = true;
} else {
hasMatch = identityNameMatches || anyResourceMatches;
}
if (!hasMatch) return;
const idNode = createTreeNode(account, "identity", true);
idNode.container.style.borderLeft = "none";
idNode.container.style.marginLeft = "0";
idNode.header.addEventListener("click", (e) => {
e.stopPropagation();
showAccessDetail("identity", identity);
toggleNode(idNode.childrenContainer, idNode.toggleIcon);
});
root.appendChild(idNode.container);
const resGroup = createTreeNode("Resources", "group", true);
resGroup.header.addEventListener("click", (e) => {
e.stopPropagation();
toggleNode(resGroup.childrenContainer, resGroup.toggleIcon);
});
idNode.childrenContainer.appendChild(resGroup.container);
preparedGroups.forEach((group) => {
const resourcesToShow =
!filterLower || identityNameMatches ? group.originalResources : group.resources;
const perms = group.permissions;
if (resourcesToShow && resourcesToShow.length > 0) {
resourcesToShow.forEach((resName) => {
const resNode = createTreeNode(String(resName), "resource", false);
resNode.header.addEventListener("click", (e) => {
e.stopPropagation();
showAccessDetail("resource", {
name: resName,
provider: identity.provider,
permissions: perms,
});
toggleNode(resNode.childrenContainer, resNode.toggleIcon);
});
resGroup.childrenContainer.appendChild(resNode.container);
if (perms.length > 0) {
perms.forEach((perm) => {
const permNode = createLeafNode(String(perm), "permission");
permNode.addEventListener("click", (e) => {
e.stopPropagation();
showAccessDetail("permission", { name: perm });
});
resNode.childrenContainer.appendChild(permNode);
});
} else {
const empty = createLeafNode("No specific permissions listed", "group");
resNode.childrenContainer.appendChild(empty);
}
});
} else if (perms.length > 0 && (!filterLower || identityNameMatches)) {
const wildNode = createTreeNode("Project-wide / Unscoped", "resource", false);
resGroup.childrenContainer.appendChild(wildNode.container);
perms.forEach((perm) => {
const pNode = createLeafNode(String(perm), "permission");
wildNode.childrenContainer.appendChild(pNode);
});
}
});
});
}
function createTreeNode(label, type, isOpen) {
const container = document.createElement("div");
container.className = "tree-node";
const header = document.createElement("div");
header.className = "node-content";
const toggle = document.createElement("span");
toggle.style.fontSize = "10px";
toggle.style.color = "#9ca3af";
toggle.style.width = "12px";
toggle.textContent = isOpen ? "▼" : "▶";
const icon = document.createElement("span");
icon.className = "node-icon icon-" + type;
if (type === "identity") icon.textContent = "👤";
else if (type === "resource") icon.textContent = "Hx";
else if (type === "group") icon.textContent = "📁";
const text = document.createElement("span");
text.textContent = label;
text.style.whiteSpace = "nowrap";
text.style.overflow = "hidden";
text.style.textOverflow = "ellipsis";
header.appendChild(toggle);
header.appendChild(icon);
header.appendChild(text);
const children = document.createElement("div");
children.className = "tree-children";
if (isOpen) {
children.classList.add("open");
children.style.display = "block";
}
container.appendChild(header);
container.appendChild(children);
return { container, header, childrenContainer: children, toggleIcon: toggle };
}
function createLeafNode(label, type) {
const div = document.createElement("div");
div.className = "node-content";
div.style.marginLeft = "20px";
div.style.fontSize = "13px";
const icon = document.createElement("span");
icon.className = "node-icon icon-" + type;
if (type === "permission") icon.textContent = "🔑";
else if (type === "group") icon.textContent = "…";
const text = document.createElement("span");
text.textContent = label;
div.appendChild(icon);
div.appendChild(text);
return div;
}
function toggleNode(container, icon) {
if (container.style.display === "none" || !container.classList.contains("open")) {
container.classList.add("open");
container.style.display = "block";
icon.textContent = "▼";
} else {
container.classList.remove("open");
container.style.display = "none";
icon.textContent = "▶";
}
}
function addBadge(container, text, cls) {
const span = document.createElement("span");
span.className = "badge " + cls;
span.textContent = text;
container.appendChild(span);
}
function showAccessDetail(type, data) {
document.getElementById("am-empty-state").classList.add("hidden");
const view = document.getElementById("am-detail-view");
view.classList.remove("hidden");
const icon = document.getElementById("am-detail-icon");
const nameEl = document.getElementById("am-detail-name");
const meta = document.getElementById("am-detail-meta");
const typeField = document.getElementById("am-detail-type");
const cloudField = document.getElementById("am-detail-cloud");
const permsContainer = document.getElementById("am-perms-container");
const permsList = document.getElementById("am-perms-list");
meta.innerHTML = "";
permsList.innerHTML = "";
permsContainer.classList.add("hidden");
if (type === "identity") {
icon.textContent = "👤";
icon.className = "detail-icon-lg";
nameEl.textContent = data.account || "(unknown identity)";
typeField.textContent = "Identity";
const provider = (data.provider || "unknown").toUpperCase();
cloudField.textContent = provider;
if (data.provider) {
addBadge(meta, provider, data.provider === "gcp" ? "badge-gcp" : "badge-aws");
}
} else if (type === "resource") {
icon.textContent = "📦";
icon.className = "detail-icon-lg";
nameEl.textContent = data.name || "(resource)";
typeField.textContent = "Resource";
const provider = (data.provider || "unknown").toUpperCase();
cloudField.textContent = provider;
if (data.provider) {
addBadge(meta, provider, data.provider === "gcp" ? "badge-gcp" : "badge-aws");
}
if (Array.isArray(data.permissions) && data.permissions.length) {
permsContainer.classList.remove("hidden");
data.permissions.forEach((p) => {
addBadge(permsList, p, "badge-perm");
});
}
} else if (type === "permission") {
icon.textContent = "🔑";
icon.className = "detail-icon-lg";
nameEl.textContent = data.name || "(permission)";
typeField.textContent = "Permission string";
cloudField.textContent = "-";
}
}
</script>
</body>
</html>