kingfisher/docs/access-map-viewer/index.html
2025-12-12 17:21:17 -08:00

1960 lines
64 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" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kingfisher Access Map Viewer</title>
<style>
: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;
--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;
--success: #34d399;
--radius: 8px;
--hover: #1f2937;
--table-header: #0f172a;
--code-bg: #0f172a;
--code-border: #1f2937;
}
:root[data-theme="light"] {
color-scheme: light;
--brand: #0e7c56;
--brand-dark: #07402c;
--brand-soft: #e6f4ed;
--bg: #f3f4f6;
--surface: #ffffff;
--surface-muted: #f9fafb;
--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;
--hover: #e5e7eb;
--table-header: #f9fafb;
--code-bg: #0f172a;
--code-border: #1f2937;
}
* { 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: var(--surface);
}
.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: var(--surface-muted);
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: var(--surface-muted);
overflow-y: auto;
padding: 16px;
}
.am-main {
padding: 24px;
overflow-y: auto;
background: var(--surface);
}
.am-search {
position: sticky;
top: 0;
background: var(--surface-muted);
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;
background: var(--surface);
color: var(--text-main);
}
.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: var(--hover);
}
.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(--surface-muted);
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: var(--table-header);
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: var(--surface-muted); 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: var(--surface-muted); color: var(--text-muted); }
/* Finding detail snippet */
.snippet-box {
background: var(--code-bg);
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 var(--code-border);
}
#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: var(--surface-muted);
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;
background: var(--surface);
color: var(--text-main);
}
.rows-label {
font-size: 12px;
color: var(--text-muted);
}
.rows-select {
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
font-size: 13px;
color: var(--text-main);
}
.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: var(--surface);
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
color: var(--text-main);
}
.pager-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* Buttons & loader */
.btn {
background: var(--surface);
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: var(--surface-muted);
border-color: var(--border);
}
.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 var(--border);
border-top-color: #0e7c56;
animation: spin 0.8s linear infinite;
}
.progress-track {
width: 220px;
height: 6px;
border-radius: 999px;
background: var(--border);
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:var(--surface); padding:18px 22px; border-radius:10px; border:1px solid var(--border); box-shadow:var(--shadow-md); text-align:center; min-width:260px; color:var(--text-main);">
<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>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" id="theme-toggle" type="button">Light Mode</button>
<button class="btn" id="reset-btn">Load New Report</button>
</div>
</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 (auto-loads if provided on the CLI)</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>
<div class="upload-sub">Your file stays in the browser—load JSON or JSONL reports locally.</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>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" id="copy-access-map" type="button">Copy Access Map</button>
<button class="btn" id="am-toggle" type="button">Collapse</button>
</div>
</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;">
<button class="btn" id="download-json" type="button">Download JSON</button>
<button class="btn" id="download-csv" type="button">Export CSV</button>
<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 filteredAccessMapView = [];
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 downloadJsonBtn = document.getElementById("download-json");
const downloadCsvBtn = document.getElementById("download-csv");
const treeSearch = document.getElementById("tree-search");
const amContainer = document.getElementById("am-container");
const amToggle = document.getElementById("am-toggle");
const copyAccessMapButton = document.getElementById("copy-access-map");
const themeToggle = document.getElementById("theme-toggle");
const THEME_KEY = "access-map-viewer-theme";
function setTheme(theme) {
const normalized = theme === "light" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", normalized);
if (themeToggle) {
themeToggle.textContent = normalized === "dark" ? "Light Mode" : "Dark Mode";
}
}
setTheme(localStorage.getItem(THEME_KEY));
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]);
});
const resetButton = document.getElementById("reset-btn");
resetButton.addEventListener("click", () => {
const confirmReset = confirm(
"Loading a new report will clear the currently loaded data (your file stays on disk). Continue?",
);
if (confirmReset) {
resetViewer(true);
}
});
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";
}
});
downloadJsonBtn.addEventListener("click", downloadFindingsJson);
downloadCsvBtn.addEventListener("click", downloadFindingsCsv);
copyAccessMapButton.addEventListener("click", copyFilteredAccessMap);
if (themeToggle) {
themeToggle.addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
const next = current === "dark" ? "light" : "dark";
localStorage.setItem(THEME_KEY, next);
setTheme(next);
});
}
loadCliReport();
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);
}
async function loadCliReport() {
try {
loaderText.textContent = "Loading report from CLI…";
loader.classList.remove("hidden");
const response = await fetch("/report", { cache: "no-store" });
if (response.status === 404) {
loader.classList.add("hidden");
return;
}
if (!response.ok) {
throw new Error("Server returned status " + response.status);
}
const text = await response.text();
parseAndRender(text);
} catch (err) {
loader.classList.add("hidden");
errorMsg.textContent = "Failed to load report from CLI: " + err.message;
errorMsg.classList.remove("hidden");
}
}
function resetViewer(promptFilePicker = false) {
findings = [];
accessMap = [];
filteredAccessMapView = [];
rawData = null;
currentFilter = "";
validationFilter = "all";
pageSize = 10;
currentPage = 1;
sortField = "rule";
sortDirection = "asc";
searchInput.value = "";
validationSelect.value = "all";
rowsSelect.value = "10";
treeSearch.value = "";
document.getElementById("am-empty-state").classList.remove("hidden");
document.getElementById("am-detail-view").classList.add("hidden");
amContainer.classList.remove("hidden");
amToggle.textContent = "Collapse";
renderAccessMapTree();
renderFindingsTable();
updateMetrics();
dashboard.classList.add("hidden");
uploadSection.classList.remove("hidden");
errorMsg.classList.add("hidden");
errorMsg.textContent = "";
loader.classList.add("hidden");
loaderText.textContent = "Processing report...";
fileInput.value = "";
if (promptFilePicker) {
fileInput.click();
}
}
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 triggerDownload(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function downloadFindingsJson() {
const filtered = getFilteredSortedFindings();
if (!filtered.length) {
alert("No findings are available for the current filters.");
return;
}
const json = JSON.stringify(filtered, null, 2);
triggerDownload("kingfisher-findings.json", json, "application/json");
}
function csvEscape(value) {
const str = String(value ?? "");
if (/[",\n]/.test(str)) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
function downloadFindingsCsv() {
const filtered = getFilteredSortedFindings();
if (!filtered.length) {
alert("No findings are available for the current filters.");
return;
}
const headers = [
"rule_id",
"rule_name",
"file_path",
"line",
"validation_status",
"confidence",
"snippet",
];
const rows = filtered.map((entry) => {
const rule = entry.rule || {};
const finding = entry.finding || {};
const status =
finding.validation && finding.validation.status ? finding.validation.status : "";
return [
rule.id || "",
rule.name || "",
finding.path || "",
finding.line != null ? finding.line : "",
status,
finding.confidence || "",
(finding.snippet || "").replace(/\s+/g, " ").trim(),
];
});
const csv = [headers.join(",")].concat(rows.map((r) => r.map(csvEscape).join(","))).join("\n");
triggerDownload("kingfisher-findings.csv", csv, "text/csv");
}
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();
filteredAccessMapView = buildRenderableAccessMap(filterLower);
if (!filteredAccessMapView || filteredAccessMapView.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;
}
filteredAccessMapView.forEach((entry) => {
const identity = entry.identity;
const account = identity.account || "unknown-identity";
const identityNameMatches = entry.identityNameMatches;
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);
entry.groups.forEach((group) => {
const resourcesToShow = Array.isArray(group.resources) ? group.resources : [];
const perms = Array.isArray(group.permissions) ? 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 buildRenderableAccessMap(filterLower) {
if (!accessMap || accessMap.length === 0) return [];
const identities = [];
accessMap.forEach((identity) => {
const account = identity.account || "unknown-identity";
const groups = Array.isArray(identity.groups) ? identity.groups : [];
const identityNameMatches = Boolean(filterLower) && account.toLowerCase().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.filter((resName) => {
const resLower = String(resName).toLowerCase();
const matches = !filterLower || resLower.includes(filterLower) || identityNameMatches;
if (!identityNameMatches && filterLower && resLower.includes(filterLower)) {
anyResourceMatches = true;
}
return matches;
});
return { resources, filteredResources, permissions: perms };
});
const hasMatch = !filterLower || identityNameMatches || anyResourceMatches;
if (!hasMatch) return;
const viewGroups = preparedGroups.map((group) => ({
resources: !filterLower || identityNameMatches ? group.resources : group.filteredResources,
permissions: group.permissions,
}));
identities.push({ identity, groups: viewGroups, identityNameMatches });
});
return identities;
}
function copyFilteredAccessMap() {
if (!filteredAccessMapView || filteredAccessMapView.length === 0) {
alert("No access map entries are available for the current filter.");
return;
}
const payload = filteredAccessMapView.map((entry) => {
return Object.assign({}, entry.identity, { groups: entry.groups });
});
const text = JSON.stringify(payload, null, 2);
const fallbackCopy = () => {
const area = document.createElement("textarea");
area.value = text;
document.body.appendChild(area);
area.select();
document.execCommand("copy");
document.body.removeChild(area);
alert("Copied access map for " + payload.length + " identities.");
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => alert("Copied access map for " + payload.length + " identities."))
.catch(fallbackCopy);
} else {
fallbackCopy();
}
}
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 setDetailName(text, link) {
const nameEl = document.getElementById("am-detail-name");
nameEl.innerHTML = "";
if (link) {
const anchor = document.createElement("a");
anchor.href = link;
anchor.target = "_blank";
anchor.rel = "noopener noreferrer";
anchor.textContent = text;
nameEl.appendChild(anchor);
} else {
nameEl.textContent = text;
}
}
function extractResourceParts(label) {
if (!label) return { resourceType: "", resourceName: "" };
const idx = label.indexOf(":");
if (idx === -1) return { resourceType: "", resourceName: label };
return { resourceType: label.slice(0, idx), resourceName: label.slice(idx + 1) };
}
function buildResourceConsoleLink(provider, resourceType, resourceName) {
if (!provider || !resourceName) return null;
const normalizedProvider = provider.toLowerCase();
if (normalizedProvider === "aws") return awsResourceConsoleLink(resourceName);
if (normalizedProvider === "gcp") return gcpResourceConsoleLink(resourceName);
return null;
}
function awsResourceConsoleLink(resource) {
if (!resource || !resource.startsWith("arn:")) return null;
const parts = resource.split(":");
if (parts.length < 6) return null;
const service = parts[2];
const region = parts[3];
const resourcePart = parts[5] || "";
if (service === "s3") {
const bucket = resourcePart.replace(/^\/*/, "");
return `https://console.aws.amazon.com/s3/buckets/${encodeURIComponent(bucket)}`;
}
if (service === "iam") {
const match = resource.match(/^arn:aws:iam::\d+:([^/]+)\/(.+)$/);
const kind = match ? match[1] : null;
const res = match ? match[2] : null;
if (kind === "role") {
return `https://console.aws.amazon.com/iam/home?#/roles/${encodeURIComponent(res)}`;
}
if (kind === "user") {
return `https://console.aws.amazon.com/iam/home?#/users/${encodeURIComponent(res)}`;
}
return null;
}
if (service === "lambda") {
const match = resourcePart.match(/^function[:\/](.+)$/);
if (match && match[1]) {
const fnName = match[1];
const regionQuery = region ? `?region=${encodeURIComponent(region)}` : "";
return `https://console.aws.amazon.com/lambda/home${regionQuery}#/functions/${encodeURIComponent(fnName)}`;
}
return null;
}
if (service === "ec2") {
const match = resourcePart.match(/^instance\/(.+)$/);
if (match && match[1]) {
const instanceId = match[1];
return `https://console.aws.amazon.com/ec2/v2/home?#InstanceDetails:instanceId=${encodeURIComponent(instanceId)}`;
}
return null;
}
if (service === "kms") {
const match = resourcePart.match(/^(?:key|alias)\/(.+)$/);
if (match && match[1]) {
return `https://console.aws.amazon.com/kms/home?#/kms/keys/${encodeURIComponent(match[1])}`;
}
return null;
}
if (service === "secretsmanager") {
return `https://console.aws.amazon.com/secretsmanager/home?#/secret?name=${encodeURIComponent(resource)}`;
}
if (service === "dynamodb") {
const match = resourcePart.match(/^(?:table\/(.+)|table:(.+))/);
const tableRaw = match ? match[1] || match[2] : null;
const table = tableRaw ? tableRaw.split("/")[0] : null;
if (table) {
const regionQuery = region ? `?region=${encodeURIComponent(region)}` : "";
return `https://console.aws.amazon.com/dynamodbv2/home${regionQuery}#/table/${encodeURIComponent(table)}/items`;
}
return null;
}
return null;
}
function gcpResourceConsoleLink(resource) {
if (!resource) return null;
const projectMatch = resource.match(/^projects\/([^/]+)/);
const project = projectMatch ? projectMatch[1] : null;
if (resource.includes("/buckets/")) {
const bucketMatch = resource.match(/\/buckets\/([^/]+)/);
const bucket = bucketMatch ? bucketMatch[1] : null;
if (bucket && project) {
return `https://console.cloud.google.com/storage/browser/${encodeURIComponent(bucket)}?project=${encodeURIComponent(project)}`;
}
}
if (resource.includes("/datasets/")) {
const datasetMatch = resource.match(/\/datasets\/([^/]+)/);
const dataset = datasetMatch ? datasetMatch[1] : null;
if (dataset && project) {
return `https://console.cloud.google.com/bigquery?project=${encodeURIComponent(project)}&p=${encodeURIComponent(project)}&d=${encodeURIComponent(dataset)}&page=dataset`;
}
}
if (resource.includes("/secrets/")) {
const secretMatch = resource.match(/\/secrets\/([^/:]+)/);
const secret = secretMatch ? secretMatch[1] : null;
if (secret && project) {
return `https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(secret)}/versions?project=${encodeURIComponent(project)}`;
}
}
if (resource.includes("/functions/")) {
const fnMatch = resource.match(/\/locations\/([^/]+)\/functions\/([^/]+)/);
if (fnMatch && project) {
const region = fnMatch[1];
const fnName = fnMatch[2];
return `https://console.cloud.google.com/functions/details/${encodeURIComponent(region)}/${encodeURIComponent(fnName)}?project=${encodeURIComponent(project)}`;
}
}
if (project) {
return `https://console.cloud.google.com/home/dashboard?project=${encodeURIComponent(project)}`;
}
return null;
}
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 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");
let detailName = "";
let detailLink = null;
if (type === "identity") {
icon.textContent = "👤";
icon.className = "detail-icon-lg";
detailName = 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";
const resourceLabel = data.name || "(resource)";
const { resourceType, resourceName } = extractResourceParts(resourceLabel);
detailName = resourceLabel;
detailLink = buildResourceConsoleLink(data.provider, resourceType, resourceName);
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";
detailName = data.name || "(permission)";
typeField.textContent = "Permission string";
cloudField.textContent = "-";
}
setDetailName(detailName, detailLink);
}
</script>
</body>
</html>