kingfisher/docs/access-map-viewer/index.html

4306 lines
159 KiB
HTML
Raw Permalink Normal View History

<!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: #0c6d4d;
--brand-soft: #10241c;
--bg: #060b16;
--surface: #0d1424;
--surface-muted: #111a2b;
--surface-strong: #0f192c;
--text-main: #f3f4f6;
--text-muted: #c7d2e2;
--border: #1f2a3f;
--border-strong: #2c3a55;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.15);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.25), 0 2px 4px -2px rgb(0 0 0 / 0.2);
--critical: #fca5a5;
--success: #34d399;
--radius: 8px;
--hover: #1f2937;
--table-header: #0f172a;
2025-12-12 21:51:57 -08:00
--code-bg: #0d1526;
--code-border: #1f2937;
2025-12-12 21:51:57 -08:00
--code-border-strong: rgba(56, 189, 248, 0.4);
--code-text: #e2e8f0;
--code-accent: rgba(56, 189, 248, 0.08);
--link-strong: #a5f3fc;
--link-strong-hover: #e0f2fe;
--link-underline: rgba(255, 255, 255, 0.35);
}
:root[data-theme="light"] {
color-scheme: light;
--brand: #0e7c56;
--brand-dark: #07402c;
--brand-soft: #e6f4ed;
--bg: #f3f4f6;
--surface: #ffffff;
--surface-strong: #f4f6fb;
--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;
2025-12-12 21:51:57 -08:00
--code-bg: #f8fafc;
--code-border: #e5e7eb;
--code-border-strong: rgba(14, 124, 86, 0.2);
--code-text: #0f172a;
--code-accent: rgba(14, 124, 86, 0.08);
--link-strong: #1d4ed8;
--link-strong-hover: #1e3a8a;
--link-underline: rgba(30, 64, 175, 0.35);
}
* { 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);
}
.layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 18px;
align-items: start;
}
.sidebar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 14px;
position: sticky;
top: 82px;
display: flex;
flex-direction: column;
gap: 12px;
}
.nav-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.nav-button {
width: 100%;
text-align: left;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid transparent;
background: var(--surface-muted);
color: var(--text-main);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: border 0.15s ease, background 0.15s ease, transform 0.1s ease;
}
.nav-button:hover {
background: var(--hover);
border-color: var(--border);
}
.nav-button.active {
background: var(--brand-soft);
border-color: var(--brand);
color: var(--brand-dark);
box-shadow: var(--shadow-sm);
}
.nav-icon {
font-size: 16px;
opacity: 0.9;
}
.view {
display: flex;
flex-direction: column;
gap: 18px;
}
.view-stack {
display: flex;
flex-direction: column;
gap: 18px;
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
top: auto;
}
}
2025-12-16 21:24:47 -08:00
.info-banner {
background: var(--surface);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
text-align: center;
padding: 10px 16px;
font-size: 13px;
}
.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-strong);
}
.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 {
2026-02-13 18:35:36 -08:00
padding: 16px 20px;
background: var(--surface);
}
2026-02-13 18:35:36 -08:00
.am-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
2026-02-13 18:35:36 -08:00
margin-bottom: 16px;
}
2026-02-13 18:35:36 -08:00
.am-toolbar input {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 13px;
2026-02-13 18:35:36 -08:00
background: var(--surface-muted);
color: var(--text-main);
}
2026-02-13 18:35:36 -08:00
.am-toolbar input:focus {
outline: none;
border-color: var(--brand);
}
2026-02-13 18:35:36 -08:00
.am-stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
padding: 12px 16px;
background: var(--surface-muted);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
}
.am-stat {
display: flex;
align-items: center;
gap: 6px;
}
.am-stat-value {
font-weight: 700;
font-size: 16px;
color: var(--text-main);
}
.am-stat-label {
color: var(--text-muted);
}
.am-empty-hint {
padding: 12px 16px;
background: var(--surface-muted);
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--text-muted);
margin: 12px 20px 0;
}
2026-02-13 18:35:36 -08:00
/* Identity cards */
.am-card-list {
display: flex;
flex-direction: column;
gap: 12px;
}
2026-02-13 18:35:36 -08:00
.id-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
transition: border-color 0.15s;
}
2026-02-13 18:35:36 -08:00
.id-card:hover {
border-color: var(--border-strong);
}
2026-02-13 18:35:36 -08:00
.id-card__header {
padding: 14px 16px;
display: flex;
2026-02-13 18:35:36 -08:00
align-items: flex-start;
gap: 12px;
cursor: pointer;
user-select: none;
}
2026-02-13 18:35:36 -08:00
.id-card__header:hover {
background: var(--surface-muted);
}
2026-02-13 18:35:36 -08:00
.id-card__avatar {
width: 36px;
height: 36px;
border-radius: 8px;
display: grid;
place-items: center;
2026-02-13 18:35:36 -08:00
font-size: 18px;
flex-shrink: 0;
2026-02-13 18:35:36 -08:00
background: var(--surface-muted);
border: 1px solid var(--border);
}
2026-02-13 18:35:36 -08:00
.id-card__info {
flex: 1;
min-width: 0;
}
2026-02-13 18:35:36 -08:00
.id-card__name {
font-weight: 700;
font-size: 14px;
word-break: break-all;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.id-card__meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
}
.id-card__meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.id-card__perms-preview {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.id-card__toggle {
font-size: 11px;
color: var(--text-muted);
flex-shrink: 0;
margin-top: 4px;
}
.id-card__body {
display: none;
2026-02-13 18:35:36 -08:00
border-top: 1px solid var(--border);
padding: 16px;
background: var(--surface-muted);
}
2026-02-13 18:35:36 -08:00
.id-card__body.open {
display: block;
}
2026-02-13 18:35:36 -08:00
.id-card__section {
margin-bottom: 14px;
}
2026-02-13 18:35:36 -08:00
.id-card__section:last-child {
margin-bottom: 0;
}
.id-card__section-title {
font-size: 12px;
font-weight: 700;
2026-02-13 18:35:36 -08:00
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: 8px;
display: flex;
align-items: center;
2026-02-13 18:35:36 -08:00
gap: 6px;
}
2026-02-13 18:35:36 -08:00
.resource-grid {
display: grid;
2026-02-13 18:35:36 -08:00
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
}
.resource-chip {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border: 1px solid var(--border);
2026-02-13 18:35:36 -08:00
border-radius: 6px;
background: var(--surface);
font-size: 12px;
}
.resource-chip__name {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
font-weight: 600;
word-break: break-all;
color: var(--text-main);
}
.resource-chip__name a {
font-size: 10px;
color: var(--brand);
margin-left: 4px;
}
2026-02-13 18:35:36 -08:00
.resource-chip__perms {
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.token-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 6px 16px;
}
.token-field {
font-size: 12px;
}
.token-field strong {
color: var(--text-muted);
font-weight: 600;
}
.id-card__footer {
padding: 8px 16px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-muted);
}
.id-card__footer code {
background: var(--surface-muted);
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* keep badge styles for card headers */
.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; }
2026-01-01 22:24:57 -08:00
.badge-azure { background: #ecfeff; color: #0e7490; border-color: #a5f3fc; }
.badge-github { background: #f4f4f5; color: #18181b; border-color: #d4d4d8; }
.badge-gitlab { background: #fff1f2; color: #be123c; border-color: #fecdd3; }
.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;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
padding: 0 20px 20px;
}
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow-sm);
display: flex;
gap: 12px;
align-items: center;
min-height: 220px;
}
.chart-legend {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--text-main);
}
.chart-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.legend-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
border: 1px solid var(--border);
flex-shrink: 0;
}
#status-chart {
background: var(--surface-muted);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
/* Findings table */
.table-container { width: 100%; overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.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 {
2025-12-12 21:51:57 -08:00
position: relative;
background: linear-gradient(90deg, var(--code-accent), transparent), var(--code-bg);
color: var(--code-text);
padding: 16px;
2025-12-12 21:51:57 -08:00
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
max-width: 100%;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid var(--code-border);
2025-12-12 21:51:57 -08:00
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35), 0 0 0 1px var(--code-border-strong);
}
.snippet-box::after {
content: "";
position: absolute;
inset: 0;
border-radius: 8px;
pointer-events: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
#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;
}
/* Validate command box - blue theme */
#fd-validate-box {
margin-top: 16px;
padding: 14px 16px;
background: #eff6ff;
border: 1px solid #93c5fd;
border-radius: 6px;
}
#fd-validate-box label {
font-size: 13px;
font-weight: 600;
color: #1e40af;
display: block;
margin-bottom: 8px;
}
#fd-validate-cmd {
flex: 1;
font-size: 12px;
color: #1e3a8a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: rgba(255, 255, 255, 0.5);
padding: 8px 12px;
border-radius: 4px;
word-break: break-all;
white-space: pre-wrap;
}
.copy-btn-validate {
color: #1e40af;
background: #dbeafe;
border: 1px solid #3b82f6;
}
.copy-btn-validate:hover {
background: #bfdbfe;
border-color: #2563eb;
}
/* Revoke command box - amber/warning theme */
#fd-revoke-box {
margin-top: 16px;
padding: 14px 16px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 6px;
}
#fd-revoke-box label {
font-size: 13px;
font-weight: 600;
color: #92400e;
display: block;
margin-bottom: 8px;
}
#fd-revoke-cmd {
flex: 1;
font-size: 12px;
color: #78350f;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background: rgba(255, 255, 255, 0.5);
padding: 8px 12px;
border-radius: 4px;
word-break: break-all;
white-space: pre-wrap;
}
/* Shared command container styles */
.cmd-container {
display: flex;
align-items: flex-start;
gap: 10px;
}
.copy-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
font-size: 12px;
font-weight: 500;
color: #78350f;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
}
.copy-btn:hover {
background: #fde68a;
border-color: #d97706;
}
.copy-btn.copied {
background: #d1fae5;
border-color: #10b981;
color: #065f46;
}
.path-area {
width: 100%;
min-height: 48px;
background: var(--surface-muted);
color: var(--text-main);
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border-strong);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 13px;
resize: vertical;
overflow: auto;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.link-mono a {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
2025-12-12 21:51:57 -08:00
color: var(--link-strong);
text-decoration: underline;
text-decoration-color: var(--link-underline);
text-decoration-thickness: 2px;
font-weight: 600;
word-break: break-all;
}
.link-mono a:hover {
2025-12-12 21:51:57 -08:00
color: var(--link-strong-hover);
text-decoration-color: currentColor;
}
/* 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);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#theme-toggle {
background: #f9fafb;
color: var(--brand);
border-color: rgba(255, 255, 255, 0.7);
font-weight: 700;
box-shadow: var(--shadow-sm);
}
#theme-toggle:hover {
background: #ffffff;
border-color: rgba(255, 255, 255, 0.9);
color: var(--brand-dark);
}
:root[data-theme="light"] #theme-toggle {
background: #0e7c56;
color: #ffffff;
border-color: #0c6d4d;
}
:root[data-theme="light"] #theme-toggle:hover {
background: #0c6d4d;
border-color: #0a5d40;
}
.hidden { display: none !important; }
2026-02-13 18:19:18 -08:00
/* Blast Radius (finding detail) */
.blast-radius {
margin-top: 20px;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.blast-radius__header {
padding: 12px 16px;
background: var(--surface-strong);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.blast-radius__header h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.blast-radius__body {
padding: 16px;
}
.risk-rationale {
padding: 12px 16px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
margin-bottom: 14px;
}
.risk-rationale.critical {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.risk-rationale.high {
background: #fff7ed;
border: 1px solid #fed7aa;
color: #9a3412;
}
.risk-rationale.medium {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
.risk-rationale.low {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
}
.risk-rationale.none {
background: var(--surface-muted);
border: 1px solid var(--border);
color: var(--text-muted);
}
:root[data-theme="dark"] .risk-rationale.critical {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
:root[data-theme="dark"] .risk-rationale.high {
background: rgba(249, 115, 22, 0.1);
border-color: rgba(249, 115, 22, 0.3);
color: #fdba74;
}
:root[data-theme="dark"] .risk-rationale.medium {
background: rgba(234, 179, 8, 0.1);
border-color: rgba(234, 179, 8, 0.3);
color: #fde68a;
}
:root[data-theme="dark"] .risk-rationale.low {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
color: #86efac;
}
:root[data-theme="dark"] .risk-rationale.none {
background: var(--surface-muted);
border-color: var(--border);
color: var(--text-muted);
}
.blast-identity {
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-muted);
margin-bottom: 10px;
}
.blast-identity__header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
}
.blast-resource-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.blast-resource-item {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
font-size: 13px;
}
.blast-resource-name {
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
word-break: break-all;
}
.blast-perms {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.blast-perm-tag {
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: #ecfdf5;
color: #16a34a;
border: 1px solid #bbf7d0;
}
:root[data-theme="dark"] .blast-perm-tag {
background: rgba(34, 197, 94, 0.1);
color: #86efac;
border-color: rgba(34, 197, 94, 0.3);
}
/* Scan metadata bar */
.scan-meta-bar {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 14px 20px;
background: var(--surface-muted);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
color: var(--text-muted);
}
.scan-meta-item {
display: flex;
align-items: center;
gap: 6px;
}
.scan-meta-item strong {
color: var(--text-main);
font-weight: 600;
}
/* Report type selector */
.report-type-group {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
border-top: 1px solid var(--border);
margin-top: 8px;
}
.report-type-group label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 600;
}
.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>
2025-12-16 21:24:47 -08:00
<div class="info-banner">Client-Side Only. No data is uploaded anywhere.</div>
<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">
<div class="layout">
<aside class="sidebar">
<div class="nav-group">
<button class="nav-button active" data-view-target="view-dashboard">
<span class="nav-icon">📊</span>
Dashboard
</button>
<button class="nav-button" data-view-target="view-access">
2026-01-01 22:24:57 -08:00
<span class="nav-icon">🕸️</span>
Access Map
</button>
<button class="nav-button" data-view-target="view-findings">
<span class="nav-icon">📄</span>
Findings
</button>
</div>
2026-02-13 18:19:18 -08:00
<div class="report-type-group">
<label>For Researchers</label>
<button class="btn" id="download-risk-report" type="button" style="width:100%;">Risk Report</button>
<label style="display:flex; align-items:center; gap:6px; font-size:12px; text-transform:none; letter-spacing:normal; color:var(--text-main); cursor:pointer; margin-top:2px;">
<input type="checkbox" id="risk-active-only"> Active credentials only
</label>
<div style="font-size:11px; color:var(--text-muted); line-height:1.4;">Findings + blast radius per credential. Suitable for bug bounty submissions.</div>
</div>
<div class="report-type-group">
<label>For Defenders</label>
<button class="btn" id="download-scan-report" type="button" style="width:100%;">Scan Report</button>
<div style="display:flex; flex-direction:column; gap:6px; margin-top:4px;">
<select id="report-scope" class="rows-select">
<option value="all" selected>All findings</option>
<option value="filtered">Filtered findings</option>
</select>
</div>
2026-02-13 18:19:18 -08:00
<label style="display:flex; align-items:center; gap:6px; font-size:12px; text-transform:none; letter-spacing:normal; color:var(--text-main); cursor:pointer;">
<input type="checkbox" id="scan-active-only"> Active credentials only
</label>
<div style="font-size:11px; color:var(--text-muted); line-height:1.4;">Scan summary for tickets and remediation tracking.</div>
<button class="btn" id="download-access-report" type="button" style="width:100%; margin-top:4px;" disabled>Access Map Report</button>
</div>
</aside>
<div class="view-stack">
<section id="view-dashboard" class="view">
2026-02-13 18:19:18 -08:00
<div id="scan-meta-section" class="scan-meta-bar hidden">
<div class="scan-meta-item"><strong>Report Generated:</strong> <span id="meta-timestamp"></span></div>
<div class="scan-meta-item" id="meta-target-wrap" style="display:none;"><strong>Target:</strong> <span id="meta-target"></span></div>
<div class="scan-meta-item" id="meta-duration-wrap" style="display:none;"><strong>Duration:</strong> <span id="meta-duration"></span></div>
<div class="scan-meta-item" id="meta-version-wrap" style="display:none;"><strong>Version:</strong> <span id="meta-version"></span></div>
</div>
<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>
</section>
<section class="panel">
<div class="panel__header">
<div class="panel__title">
<h3>Finding Status Distribution</h3>
<p>Active vs Inactive vs Not Attempted</p>
</div>
</div>
<div class="chart-grid">
<div class="chart-card">
<canvas id="status-chart" width="260" height="220"></canvas>
<div class="chart-legend" id="status-legend">
<!-- filled by JS -->
</div>
</div>
</div>
</section>
</section>
<section id="view-access" class="view hidden">
<section class="panel" id="am-section">
<div class="panel__header">
<div class="panel__title">
<h3>Access Map</h3>
2026-02-13 18:35:36 -08:00
<p>Credentials and what they can reach</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
2026-02-13 18:35:36 -08:00
<button class="btn" id="copy-access-map" type="button">Copy JSON</button>
</div>
</div>
2026-02-13 18:35:36 -08:00
<div id="am-empty-notice" class="am-empty-hint hidden">No Access Map entries in this report. Run a scan with <code>--access-map</code> to map credential blast radius.</div>
<div class="am-container" id="am-container">
2026-02-13 18:35:36 -08:00
<div id="am-stats-bar" class="am-stats hidden"></div>
<div class="am-toolbar">
<input id="tree-search" type="text" placeholder="Filter by identity, resource, or permission…">
</div>
2026-02-13 18:35:36 -08:00
<div id="am-card-list" class="am-card-list">
<div style="color:var(--text-muted); font-size:13px; text-align:center; padding:32px 0;">
No access map data found in report.
</div>
</div>
</div>
</section>
</section>
<section id="view-findings" class="view hidden">
<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>Validation Status</label>
<div id="fd-validation-status"></div>
</div>
<div class="detail-field">
<label>Git Commit</label>
<div id="fd-commit"></div>
</div>
2026-01-15 10:41:55 -08:00
<div class="detail-field" id="fd-committer-email-wrapper">
<label>Committer Email</label>
<div id="fd-committer-email"></div>
</div>
<div class="detail-field">
<label>File Path</label>
<textarea id="fd-path" class="path-area" readonly></textarea>
</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 id="fd-validate-box" class="hidden">
<label>Validate Command</label>
<div class="cmd-container">
<code id="fd-validate-cmd"></code>
<button class="copy-btn copy-btn-validate" id="fd-validate-copy" title="Copy to clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span>Copy</span>
</button>
</div>
</div>
<div id="fd-revoke-box" class="hidden">
<label>Revoke Command</label>
<div class="cmd-container">
<code id="fd-revoke-cmd"></code>
<button class="copy-btn" id="fd-revoke-copy" title="Copy to clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span>Copy</span>
</button>
</div>
</div>
2026-02-13 18:19:18 -08:00
<div id="fd-blast-radius" class="blast-radius hidden">
<div class="blast-radius__header">
<h4>
<span>Blast Radius</span>
<span id="fd-blast-count" class="badge" style="font-size:11px;"></span>
</h4>
<button class="btn" id="fd-export-risk-report" type="button" style="font-size:12px; padding:6px 12px;">
Export Risk Report
</button>
</div>
<div class="blast-radius__body">
<div id="fd-risk-rationale" class="risk-rationale none"></div>
<div id="fd-blast-entries"></div>
</div>
</div>
</div>
</section>
</section>
</div>
</div>
</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";
let autoCollapsedAccessMap = false;
2026-02-13 18:19:18 -08:00
let currentDetailFinding = null;
let scanMetadata = {};
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");
2026-02-13 18:19:18 -08:00
const downloadRiskReportBtn = document.getElementById("download-risk-report");
const downloadScanReportBtn = document.getElementById("download-scan-report");
const reportScopeSelect = document.getElementById("report-scope");
const downloadAccessReportBtn = document.getElementById("download-access-report");
2026-02-13 18:19:18 -08:00
const exportFindingRiskBtn = document.getElementById("fd-export-risk-report");
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");
2026-02-13 18:35:36 -08:00
const amToggle = document.getElementById("am-toggle"); // may be null now
const copyAccessMapButton = document.getElementById("copy-access-map");
const amEmptyNotice = document.getElementById("am-empty-notice");
const themeToggle = document.getElementById("theme-toggle");
const fdPathInput = document.getElementById("fd-path");
const navButtons = document.querySelectorAll("[data-view-target]");
const statusChartCanvas = document.getElementById("status-chart");
const statusLegend = document.getElementById("status-legend");
const viewRegistry = {
"view-dashboard": document.getElementById("view-dashboard"),
"view-access": document.getElementById("view-access"),
"view-findings": document.getElementById("view-findings"),
};
const THEME_KEY = "access-map-viewer-theme";
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]);
});
navButtons.forEach((btn) => {
btn.addEventListener("click", () => setActiveView(btn.dataset.viewTarget));
});
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 || "");
});
2026-02-13 18:35:36 -08:00
if (amToggle) {
amToggle.addEventListener("click", () => {
const willCollapse = !amContainer.classList.contains("hidden");
setAccessMapCollapsed(willCollapse);
});
}
downloadJsonBtn.addEventListener("click", downloadFindingsJson);
downloadCsvBtn.addEventListener("click", downloadFindingsCsv);
copyAccessMapButton.addEventListener("click", copyFilteredAccessMap);
2026-02-13 18:19:18 -08:00
if (downloadRiskReportBtn) {
downloadRiskReportBtn.addEventListener("click", () => {
const activeOnly = document.getElementById("risk-active-only");
generateRiskReport({ activeOnly: activeOnly && activeOnly.checked });
});
}
if (downloadScanReportBtn) {
downloadScanReportBtn.addEventListener("click", () => {
const scope = reportScopeSelect ? reportScopeSelect.value : "all";
2026-02-13 18:19:18 -08:00
const activeOnly = document.getElementById("scan-active-only");
generateScanReport(scope, { activeOnly: activeOnly && activeOnly.checked });
});
}
if (downloadAccessReportBtn) {
downloadAccessReportBtn.addEventListener("click", generateAccessMapReport);
}
2026-02-13 18:19:18 -08:00
if (exportFindingRiskBtn) {
exportFindingRiskBtn.addEventListener("click", () => {
if (currentDetailFinding) {
exportSingleFindingRiskReport(currentDetailFinding);
}
});
}
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 setActiveView(targetId) {
const fallback = "view-dashboard";
const viewId = targetId && viewRegistry[targetId] ? targetId : fallback;
Object.entries(viewRegistry).forEach(([id, el]) => {
if (!el) return;
if (id === viewId) el.classList.remove("hidden");
else el.classList.add("hidden");
});
navButtons.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.viewTarget === viewId);
});
}
function setAccessMapCollapsed(collapsed, { auto = false } = {}) {
if (!amContainer) return;
if (collapsed) {
amContainer.classList.add("hidden");
if (amToggle) amToggle.textContent = "Expand";
autoCollapsedAccessMap = auto;
} else {
amContainer.classList.remove("hidden");
if (amToggle) amToggle.textContent = "Collapse";
autoCollapsedAccessMap = false;
}
}
function syncAccessMapUi(hasAccess) {
const previouslyAutoCollapsed = autoCollapsedAccessMap;
if (amToggle) amToggle.disabled = !hasAccess;
if (downloadAccessReportBtn) downloadAccessReportBtn.disabled = !hasAccess;
if (amEmptyNotice) {
amEmptyNotice.classList.toggle("hidden", hasAccess);
}
if (!hasAccess) {
setAccessMapCollapsed(true, { auto: true });
return;
}
if (previouslyAutoCollapsed) {
setAccessMapCollapsed(false);
}
}
function escapeHtml(unsafe) {
if (unsafe === undefined || unsafe === null) return "";
return unsafe
.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();
2026-01-01 22:24:57 -08:00
const parts = trimmed.split(/\r?\n(?=\s*{)/g).filter(Boolean);
if (parts.length > 1) {
try {
return JSON.parse("[" + parts.join(",") + "]");
} catch (e2) {}
}
}
return null;
}
2026-01-01 22:24:57 -08:00
function collectReportData(entries) {
const findings = [];
const accessMap = [];
let mainReport = null;
let statsReport = null;
const items = Array.isArray(entries) ? entries : [entries];
items.forEach((item) => {
if (!item || typeof item !== "object") return;
if (Array.isArray(item.findings) || Array.isArray(item.access_map)) {
if (!mainReport) {
mainReport = item;
}
}
if (Array.isArray(item.findings)) {
findings.push(...item.findings);
}
if (Array.isArray(item.access_map)) {
accessMap.push(...item.access_map);
}
if (item.rule && item.finding) {
findings.push(item);
}
if (item.provider && item.account) {
accessMap.push(item);
}
if (
typeof item.scan_duration !== "undefined" ||
typeof item.scanDuration !== "undefined" ||
typeof item.bytes_scanned !== "undefined" ||
item.kingfisher ||
item.stats ||
item.summary
) {
statsReport = item;
}
});
return { findings, accessMap, mainReport, statsReport };
}
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;
2026-02-13 18:19:18 -08:00
currentDetailFinding = null;
scanMetadata = {};
currentFilter = "";
validationFilter = "all";
pageSize = 10;
currentPage = 1;
sortField = "rule";
sortDirection = "asc";
searchInput.value = "";
validationSelect.value = "all";
rowsSelect.value = "10";
treeSearch.value = "";
setAccessMapCollapsed(true, { auto: true });
if (amToggle) amToggle.disabled = true;
if (amEmptyNotice) amEmptyNotice.classList.add("hidden");
2026-02-13 18:35:36 -08:00
const amStatsBar = document.getElementById("am-stats-bar");
if (amStatsBar) amStatsBar.classList.add("hidden");
renderAccessMapTree();
renderFindingsTable();
updateMetrics();
2026-02-13 18:19:18 -08:00
const scanMetaSection = document.getElementById("scan-meta-section");
if (scanMetaSection) scanMetaSection.classList.add("hidden");
const blastSection = document.getElementById("fd-blast-radius");
if (blastSection) blastSection.classList.add("hidden");
const findingDetail = document.getElementById("finding-detail");
if (findingDetail) findingDetail.classList.add("hidden");
setActiveView("view-dashboard");
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) {
2026-01-01 22:24:57 -08:00
const lines = text.split(/\r?\n/);
const entries = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line[0] !== "{") continue;
try {
const obj = JSON.parse(line);
2026-01-01 22:24:57 -08:00
entries.push(obj);
} catch (errLine) {
console.warn("Skipping invalid JSON line", i);
}
}
2026-01-01 22:24:57 -08:00
const collected = collectReportData(entries);
findings = collected.findings;
accessMap = collected.accessMap;
if (collected.mainReport || collected.statsReport) {
rawData = Object.assign({}, collected.mainReport || {}, collected.statsReport || {});
}
} else {
2026-01-01 22:24:57 -08:00
const collected = collectReportData(parsed);
findings = collected.findings;
accessMap = collected.accessMap;
if (collected.mainReport || collected.statsReport || parsed) {
rawData = Object.assign({}, collected.mainReport || {}, collected.statsReport || {});
}
}
currentPage = 1;
currentFilter = "";
validationFilter = "all";
searchInput.value = "";
validationSelect.value = "all";
2026-02-13 18:19:18 -08:00
extractScanMetadata();
setActiveView("view-dashboard");
updateMetrics();
2026-02-13 18:19:18 -08:00
renderScanMetadata();
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);
}
2026-02-13 18:19:18 -08:00
function extractScanMetadata() {
scanMetadata = {};
if (!rawData || typeof rawData !== "object") {
scanMetadata.timestamp = new Date().toLocaleString();
return;
}
// Try to extract metadata from the raw report data
const data = rawData;
const reportMeta = data.metadata && typeof data.metadata === "object" ? data.metadata : {};
scanMetadata.timestamp =
reportMeta.scan_timestamp ||
reportMeta.generated_at ||
data.timestamp ||
data.scan_timestamp ||
data.generated_at ||
new Date().toLocaleString();
2026-02-13 18:19:18 -08:00
// Target info
scanMetadata.target =
reportMeta.target ||
data.target ||
data.scan_target ||
data.repository ||
data.repo ||
2026-02-13 18:19:18 -08:00
(data.stats && data.stats.target) ||
(data.summary && data.summary.target) || "";
// Duration
const duration = data.scan_duration || data.scanDuration ||
(data.stats && data.stats.duration) ||
(data.summary && data.summary.duration) || "";
if (duration) {
scanMetadata.duration = typeof duration === "number" ? (duration / 1000).toFixed(1) + "s" : String(duration);
}
// Version
scanMetadata.version =
reportMeta.kingfisher_version ||
data.version ||
data.kingfisher_version ||
2026-02-13 18:19:18 -08:00
(data.kingfisher && data.kingfisher.version) || "";
scanMetadata.latestVersion =
reportMeta.latest_version_available ||
(data.kingfisher && data.kingfisher.latest_version) || "";
scanMetadata.updateCheckStatus =
reportMeta.update_check_status ||
(data.kingfisher && data.kingfisher.update_check_status) || "";
// Sanitized command-line arguments (when present)
const cliArgs = reportMeta.command_line_args;
if (Array.isArray(cliArgs) && cliArgs.length > 0) {
scanMetadata.commandLineArgs = cliArgs.map((arg) => String(arg));
}
const reportSummary = reportMeta.summary && typeof reportMeta.summary === "object"
? reportMeta.summary
: null;
if (reportSummary) {
scanMetadata.summary = {
findings: Number(reportSummary.findings || 0),
active: Number(reportSummary.active_findings || 0),
inactive: Number(reportSummary.inactive_findings || 0),
unknown: Number(reportSummary.unknown_validation_findings || 0),
identities: Number(reportSummary.access_map_identities || 0),
rulesApplied: Number(reportSummary.rules_applied || 0),
confidenceLevel: reportSummary.confidence_level ? String(reportSummary.confidence_level) : "",
customRulesUsed: Boolean(reportSummary.custom_rules_used),
successfulValidations: Number(reportSummary.successful_validations || 0),
failedValidations: Number(reportSummary.failed_validations || 0),
skippedValidations: Number(reportSummary.skipped_validations || 0),
blobsScanned: Number(reportSummary.blobs_scanned || 0),
bytesScanned: Number(reportSummary.bytes_scanned || 0),
scanDurationSeconds: Number(reportSummary.scan_duration_seconds || 0),
};
}
2026-02-13 18:19:18 -08:00
// Bytes scanned
const bytes = data.bytes_scanned ||
(data.stats && data.stats.bytes_scanned) ||
(data.summary && data.summary.bytes_scanned) || 0;
if (bytes > 0) {
scanMetadata.bytesScanned = bytes >= 1048576
? (bytes / 1048576).toFixed(1) + " MB"
: bytes >= 1024
? (bytes / 1024).toFixed(1) + " KB"
: bytes + " B";
}
}
function renderScanMetadata() {
const section = document.getElementById("scan-meta-section");
if (!section) return;
const ts = document.getElementById("meta-timestamp");
const targetWrap = document.getElementById("meta-target-wrap");
const target = document.getElementById("meta-target");
const durationWrap = document.getElementById("meta-duration-wrap");
const duration = document.getElementById("meta-duration");
const versionWrap = document.getElementById("meta-version-wrap");
const version = document.getElementById("meta-version");
section.classList.remove("hidden");
if (ts) ts.textContent = scanMetadata.timestamp || new Date().toLocaleString();
if (scanMetadata.target) {
targetWrap.style.display = "";
target.textContent = scanMetadata.target;
} else {
targetWrap.style.display = "none";
}
if (scanMetadata.duration) {
durationWrap.style.display = "";
duration.textContent = scanMetadata.duration;
} else {
durationWrap.style.display = "none";
}
if (scanMetadata.version) {
versionWrap.style.display = "";
version.textContent = scanMetadata.version;
} else {
versionWrap.style.display = "none";
}
}
function updateMetrics() {
const totalFindings = findings.length;
document.getElementById("stat-total").textContent = totalFindings.toString();
const highSev = findings.filter((f) => {
const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).length;
document.getElementById("stat-high").textContent = highSev.toString();
const validationCounts = calculateValidationCounts();
document.getElementById("stat-active").textContent = (validationCounts.active || 0).toString();
document.getElementById("stat-identities").textContent = (accessMap || []).length.toString();
renderStatusChart(validationCounts);
}
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 fingerprint = (finding.fingerprint || "").toLowerCase();
const status = (finding.validation && finding.validation.status
? String(finding.validation.status)
: "").toLowerCase();
if (filterLower) {
if (!ruleName.includes(filterLower) && !path.includes(filterLower) && !snippet.includes(filterLower) && !fingerprint.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 calculateValidationCounts(list = findings) {
const counts = { active: 0, inactive: 0, not_attempted: 0, unknown: 0 };
(list || []).forEach((f) => {
const status =
f.finding && f.finding.validation && f.finding.validation.status
? f.finding.validation.status
: "";
const key = normalizeValidationStatus(status);
counts[key] = (counts[key] || 0) + 1;
});
return counts;
}
function renderStatusChart(counts) {
if (!statusChartCanvas) return;
const ctx = statusChartCanvas.getContext("2d");
if (!ctx) return;
const palette = {
active: "#22c55e",
inactive: "#f97316",
not_attempted: "#38bdf8",
unknown: "#9ca3af",
};
const dataPoints = [
{ key: "active", label: "Active", color: palette.active },
{ key: "inactive", label: "Inactive", color: palette.inactive },
{ key: "not_attempted", label: "Not Attempted", color: palette.not_attempted },
{ key: "unknown", label: "Unknown", color: palette.unknown },
];
const total = dataPoints.reduce((sum, entry) => sum + (counts[entry.key] || 0), 0);
ctx.clearRect(0, 0, statusChartCanvas.width, statusChartCanvas.height);
const style = getComputedStyle(document.documentElement);
const surfaceColor = style.getPropertyValue("--surface") || "#0d1424";
const textColor = style.getPropertyValue("--text-main") || "#e5e7eb";
const radius = Math.min(statusChartCanvas.width, statusChartCanvas.height) / 2 - 10;
const centerX = statusChartCanvas.width / 2;
const centerY = statusChartCanvas.height / 2;
ctx.save();
ctx.fillStyle = surfaceColor;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
if (total === 0) {
ctx.fillStyle = textColor;
ctx.font = "600 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("No validation data yet", centerX, centerY);
} else {
let startAngle = -Math.PI / 2;
dataPoints.forEach((entry) => {
const value = counts[entry.key] || 0;
if (!value) return;
const slice = (value / total) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.fillStyle = entry.color;
ctx.arc(centerX, centerY, radius, startAngle, startAngle + slice);
ctx.closePath();
ctx.fill();
startAngle += slice;
});
}
const innerRadius = radius * 0.55;
ctx.save();
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(centerX, centerY, innerRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.save();
ctx.fillStyle = textColor;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "700 16px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
ctx.fillText(total + " findings", centerX, centerY);
ctx.restore();
if (statusLegend) {
statusLegend.innerHTML = "";
dataPoints.forEach((entry) => {
const row = document.createElement("div");
row.className = "chart-legend-item";
const swatch = document.createElement("span");
swatch.className = "legend-swatch";
swatch.style.background = entry.color;
const text = document.createElement("span");
text.textContent = `${entry.label}: ${counts[entry.key] || 0}`;
row.appendChild(swatch);
row.appendChild(text);
statusLegend.appendChild(row);
});
}
}
function triggerDownload(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
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 getFindingIdFromFinding(finding) {
if (!finding) return "";
return (
finding.id ||
finding.finding_id ||
finding.findingId ||
finding.fingerprint ||
""
);
}
function getFindingIdFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.finding_id ||
entry.findingId ||
entry.finding ||
entry.fingerprint ||
""
);
}
function getTokenNameFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.token_name ||
entry.tokenName ||
(entry.token_details && entry.token_details.name) ||
(entry.token && entry.token.name) ||
""
);
}
function getUserIdFromAccessEntry(entry) {
if (!entry) return "";
return (
entry.user_id ||
entry.userId ||
(entry.token_details && entry.token_details.user_id) ||
(entry.token && entry.token.user_id) ||
""
);
}
function buildAccessMapListHtml({ includeMeta = false } = {}) {
if (!Array.isArray(accessMap) || accessMap.length === 0) {
return "";
}
return accessMap
.map((entry) => {
const groups = Array.isArray(entry.groups) ? entry.groups : [];
const findingId = getFindingIdFromAccessEntry(entry);
const tokenName = getTokenNameFromAccessEntry(entry);
const userId = getUserIdFromAccessEntry(entry);
const metaLine = includeMeta
? `<div style="font-size:12px; color:#475569; margin-top:4px;">
<strong>Finding ID:</strong> ${escapeHtml(findingId || "-")} ·
<strong>Token Name:</strong> ${escapeHtml(tokenName || "-")} ·
<strong>User ID:</strong> ${escapeHtml(userId || "-")}
</div>`
: "";
const groupList = groups
.map((g) => {
const resList = Array.isArray(g.resources) && g.resources.length
? g.resources.map((r) => escapeHtml(String(r))).join(", ")
: "No resources listed";
const permList = Array.isArray(g.permissions) && g.permissions.length
? g.permissions.map((p) => escapeHtml(String(p))).join(", ")
: "No permissions listed";
return `<li><strong>Resources:</strong> ${resList}<br><strong>Permissions:</strong> ${permList}</li>`;
})
.join("");
return `
<li class="access-entry">
<div class="access-head">
${escapeHtml(entry.account || "(identity)")}
<span class="tag">${escapeHtml((entry.provider || "Unknown").toUpperCase())}</span>
</div>
${metaLine}
<ul class="access-groups">
${groupList || "<li>No resources recorded.</li>"}
</ul>
</li>
`;
})
.join("");
}
2026-02-13 18:19:18 -08:00
function generateScanReport(scope = "all", { activeOnly = false } = {}) {
if (!findings || findings.length === 0) {
alert("Load a report before downloading a Scan report.");
return;
}
const statusOrder = { active: 0, inactive: 1, not_attempted: 2, unknown: 3 };
2026-02-13 18:19:18 -08:00
let baseFindings =
scope === "filtered" ? getFilteredSortedFindings().slice() : (Array.isArray(findings) ? findings.slice() : []);
2026-02-13 18:19:18 -08:00
if (activeOnly) {
baseFindings = baseFindings.filter((f) => {
const fd = f.finding || {};
return normalizeValidationStatus(fd.validation && fd.validation.status ? fd.validation.status : "") === "active";
});
if (baseFindings.length === 0) {
alert("No active credentials found for the selected scope. Uncheck 'Active credentials only' to include all findings.");
return;
}
}
const findingsForReport = baseFindings.slice();
findingsForReport.sort((a, b) => {
const fa = a.finding || {};
const fb = b.finding || {};
const keyA = normalizeValidationStatus(fa.validation && fa.validation.status ? fa.validation.status : "");
const keyB = normalizeValidationStatus(fb.validation && fb.validation.status ? fb.validation.status : "");
const sa = statusOrder.hasOwnProperty(keyA) ? statusOrder[keyA] : 4;
const sb = statusOrder.hasOwnProperty(keyB) ? statusOrder[keyB] : 4;
if (sa !== sb) return sa - sb;
const ra = (a.rule && (a.rule.name || a.rule.id)) || "";
const rb = (b.rule && (b.rule.name || b.rule.id)) || "";
return ra.localeCompare(rb);
});
const counts = calculateValidationCounts(baseFindings);
const hasAccess = Array.isArray(accessMap) && accessMap.length > 0;
const statusImage = scope === "all" && statusChartCanvas ? statusChartCanvas.toDataURL("image/png") : "";
const highConfidence = baseFindings.filter((f) => {
const conf = f.finding && f.finding.confidence ? String(f.finding.confidence) : "";
return conf.toLowerCase() === "high";
}).length;
2026-02-13 18:19:18 -08:00
const scopeLabel = (scope === "filtered" ? "Filtered findings" : "All findings") + (activeOnly ? " (active only)" : "");
// Gather unique rules for summary
const ruleSummary = {};
baseFindings.forEach((entry) => {
const rule = entry.rule || {};
const finding = entry.finding || {};
const key = rule.id || rule.name || "unknown";
if (!ruleSummary[key]) {
ruleSummary[key] = { name: rule.name || rule.id || "Unknown", count: 0, active: 0 };
}
ruleSummary[key].count++;
if (normalizeValidationStatus(finding.validation && finding.validation.status ? finding.validation.status : "") === "active") {
ruleSummary[key].active++;
}
});
const ruleSummaryRows = Object.values(ruleSummary)
.sort((a, b) => b.active - a.active || b.count - a.count)
.map((r) => `<tr><td>${escapeHtml(r.name)}</td><td>${r.count}</td><td style="color:${r.active > 0 ? "#dc2626" : "#6b7280"};font-weight:${r.active > 0 ? "700" : "400"};">${r.active}</td></tr>`)
.join("");
const findingsHtml = findingsForReport.length
? findingsForReport
.map((entry) => {
const rule = entry.rule || {};
const finding = entry.finding || {};
const statusRaw = finding.validation && finding.validation.status ? finding.validation.status : "Unknown";
2026-02-13 18:19:18 -08:00
const normalizedStatus = normalizeValidationStatus(statusRaw);
const findingId = getFindingIdFromFinding(finding);
const gitUrl = getFileUrlFromFinding(finding);
2026-02-13 18:19:18 -08:00
const statusColor = normalizedStatus === "active" ? "#dc2626" : normalizedStatus === "inactive" ? "#f97316" : "#6b7280";
return `
<tr>
<td>${escapeHtml(rule.name || rule.id || "")}</td>
2026-02-13 18:19:18 -08:00
<td style="font-size:10px;font-family:monospace;">${escapeHtml(findingId ? findingId.substring(0, 12) : "")}</td>
<td style="font-size:10px;">${escapeHtml(finding.path || "")}</td>
<td style="color:${statusColor};font-weight:600;">${escapeHtml(statusRaw)}</td>
<td>${escapeHtml(finding.confidence || "")}</td>
<td>${finding.line != null ? finding.line : ""}</td>
${gitUrl ? `<td style="font-size:10px;"><a href="${escapeHtml(gitUrl)}" style="color:#1d4ed8;">${escapeHtml(gitUrl.length > 50 ? gitUrl.substring(0, 50) + "..." : gitUrl)}</a></td>` : "<td></td>"}
</tr>
`;
})
.join("")
: '<tr><td colspan="7">No findings available.</td></tr>';
2026-02-13 18:19:18 -08:00
// Scan metadata section
const metaLines = [];
if (scanMetadata.timestamp) metaLines.push(`<strong>Scan Date:</strong> ${escapeHtml(scanMetadata.timestamp)}`);
if (scanMetadata.target) metaLines.push(`<strong>Target:</strong> ${escapeHtml(scanMetadata.target)}`);
if (scanMetadata.duration) metaLines.push(`<strong>Duration:</strong> ${escapeHtml(scanMetadata.duration)}`);
if (scanMetadata.version) metaLines.push(`<strong>Version:</strong> ${escapeHtml(scanMetadata.version)}`);
if (scanMetadata.latestVersion) metaLines.push(`<strong>Latest:</strong> ${escapeHtml(scanMetadata.latestVersion)}`);
if (scanMetadata.updateCheckStatus) metaLines.push(`<strong>Update Check:</strong> ${escapeHtml(scanMetadata.updateCheckStatus)}`);
if (scanMetadata.summary && scanMetadata.summary.rulesApplied > 0) {
metaLines.push(`<strong>Rules Applied:</strong> ${scanMetadata.summary.rulesApplied}`);
}
if (scanMetadata.summary && scanMetadata.summary.confidenceLevel) {
metaLines.push(`<strong>Confidence:</strong> ${escapeHtml(scanMetadata.summary.confidenceLevel)}`);
}
if (scanMetadata.summary) {
metaLines.push(`<strong>Custom Rules:</strong> ${scanMetadata.summary.customRulesUsed ? "Yes" : "No"}`);
}
if (scanMetadata.summary && scanMetadata.summary.successfulValidations) {
metaLines.push(`<strong>Successful Validations:</strong> ${scanMetadata.summary.successfulValidations}`);
}
if (scanMetadata.summary && scanMetadata.summary.failedValidations) {
metaLines.push(`<strong>Failed Validations:</strong> ${scanMetadata.summary.failedValidations}`);
}
if (scanMetadata.summary && scanMetadata.summary.skippedValidations) {
metaLines.push(`<strong>Skipped Validations:</strong> ${scanMetadata.summary.skippedValidations}`);
}
if (scanMetadata.summary && scanMetadata.summary.blobsScanned) {
metaLines.push(`<strong>Blobs Scanned:</strong> ${scanMetadata.summary.blobsScanned}`);
}
if (scanMetadata.summary && scanMetadata.summary.bytesScanned) {
metaLines.push(`<strong>Bytes Scanned:</strong> ${escapeHtml(formatBytes(scanMetadata.summary.bytesScanned))}`);
}
if (scanMetadata.summary && scanMetadata.summary.scanDurationSeconds) {
metaLines.push(`<strong>Scan Duration:</strong> ${escapeHtml(scanMetadata.summary.scanDurationSeconds.toFixed(3) + "s")}`);
}
const cliArgsHtml = scanMetadata.commandLineArgs && scanMetadata.commandLineArgs.length
? `<div style="margin-bottom:16px;"><div style="font-size:12px;font-weight:700;color:#0f172a;margin-bottom:6px;">Sanitized command-line arguments</div><pre style="margin:0;padding:10px 12px;background:#0f172a;color:#e2e8f0;border-radius:8px;white-space:pre-wrap;word-break:break-word;font-size:11px;line-height:1.5;">${escapeHtml(scanMetadata.commandLineArgs.join(" "))}</pre></div>`
: "";
2026-02-13 18:19:18 -08:00
const metaHtml = metaLines.length
? `<div style="display:flex;flex-wrap:wrap;gap:16px;padding:10px 14px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;font-size:12px;margin-bottom:16px;">${metaLines.join('<span style="color:#cbd5e1;">|</span>')}</div>${cliArgsHtml}`
: "";
2026-02-13 18:19:18 -08:00
// Executive summary
let execSummary = "";
if (counts.active > 0) {
execSummary = `<div style="padding:12px 16px;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;font-size:13px;color:#991b1b;line-height:1.6;margin-bottom:16px;">
<strong>Action Required:</strong> This scan found <strong>${counts.active} active credential${counts.active !== 1 ? "s" : ""}</strong> that validated successfully.
These credentials are live and should be rotated or revoked immediately.
${highConfidence > 0 ? `${highConfidence} finding${highConfidence !== 1 ? "s" : ""} ${highConfidence !== 1 ? "are" : "is"} high confidence.` : ""}
${hasAccess ? `Access mapping identified ${accessMap.length} identit${accessMap.length !== 1 ? "ies" : "y"} with resource access.` : ""}
</div>`;
} else if (baseFindings.length > 0) {
execSummary = `<div style="padding:12px 16px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;font-size:13px;color:#92400e;line-height:1.6;margin-bottom:16px;">
<strong>Review Recommended:</strong> This scan found <strong>${baseFindings.length} finding${baseFindings.length !== 1 ? "s" : ""}</strong>.
${counts.inactive > 0 ? `${counts.inactive} credential${counts.inactive !== 1 ? "s were" : " was"} inactive at scan time.` : ""}
${counts.not_attempted > 0 ? `${counts.not_attempted} ${counts.not_attempted !== 1 ? "were" : "was"} not validated.` : ""}
</div>`;
} else {
execSummary = `<div style="padding:12px 16px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:6px;font-size:13px;color:#166534;line-height:1.6;margin-bottom:16px;">
<strong>Clean Scan:</strong> No findings detected in this scan.
</div>`;
}
2026-02-13 18:19:18 -08:00
const pdfHtml = `<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Kingfisher Scan Report</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 24px; color: #0f172a; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 20px 0 10px; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
.report-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 3px solid #0e7c56; }
.report-header h1 { color: #0e7c56; }
.report-date { color: #4b5563; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 18px; }
.stat { padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; background: #f8fafc; }
.stat .label { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.04em; font-weight: 600; }
.stat .value { font-size: 22px; font-weight: 800; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; table-layout: fixed; }
th, td { border: 1px solid #d1d5db; padding: 6px 8px; font-size: 11px; text-align: left; word-break: break-word; overflow-wrap: anywhere; }
th { background: #f1f5f9; font-weight: 700; color: #374151; }
.section { margin-bottom: 24px; page-break-inside: avoid; }
.chart-wrapper { display: flex; gap: 14px; align-items: center; margin-top: 12px; }
.legend { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend .dot { width: 12px; height: 12px; border-radius: 4px; display: inline-block; }
@media print { body { padding: 12px; } .no-print { display: none; } }
</style>
</head>
<body>
<div class="report-header">
<h1>Kingfisher Scan Report</h1>
<div class="report-date">${escapeHtml(new Date().toLocaleString())}<br>Scope: ${escapeHtml(scopeLabel)}</div>
</div>
${metaHtml}
${execSummary}
<div class="section">
<h2>Summary</h2>
<div class="grid">
<div class="stat"><div class="label">Total Findings</div><div class="value">${baseFindings.length}</div></div>
<div class="stat"><div class="label">High Confidence</div><div class="value">${highConfidence}</div></div>
<div class="stat"><div class="label">Active</div><div class="value" style="color:#dc2626;">${counts.active || 0}</div></div>
<div class="stat"><div class="label">Inactive</div><div class="value" style="color:#f97316;">${counts.inactive || 0}</div></div>
<div class="stat"><div class="label">Not Validated</div><div class="value">${counts.not_attempted || 0}</div></div>
<div class="stat"><div class="label">Identities</div><div class="value">${(accessMap || []).length}</div></div>
</div>
<div class="chart-wrapper">
${statusImage ? `<img src="${statusImage}" alt="Status chart" style="max-width:240px; border:1px solid #d1d5db; border-radius:10px;">` : ""}
<div class="legend">
<span><span class="dot" style="background:#22c55e"></span> Active: ${counts.active || 0}</span>
<span><span class="dot" style="background:#f97316"></span> Inactive: ${counts.inactive || 0}</span>
<span><span class="dot" style="background:#38bdf8"></span> Not Attempted: ${counts.not_attempted || 0}</span>
<span><span class="dot" style="background:#9ca3af"></span> Unknown: ${counts.unknown || 0}</span>
</div>
</div>
</div>
${Object.keys(ruleSummary).length > 1 ? `
<div class="section">
<h2>Findings by Rule</h2>
<table style="max-width:500px;">
<thead><tr><th>Rule</th><th>Count</th><th>Active</th></tr></thead>
<tbody>${ruleSummaryRows}</tbody>
</table>
</div>
` : ""}
<div class="section">
<h2>Detailed Findings</h2>
<table>
<thead>
<tr>
<th style="width:15%;">Rule</th>
<th style="width:10%;">Fingerprint</th>
<th style="width:22%;">File Path</th>
<th style="width:12%;">Status</th>
<th style="width:9%;">Confidence</th>
<th style="width:5%;">Line</th>
<th style="width:27%;">Git URL</th>
</tr>
</thead>
<tbody>
${findingsHtml}
</tbody>
</table>
</div>
2026-02-13 18:19:18 -08:00
<div class="no-print" style="margin-top:24px;text-align:center;">
<button onclick="window.print()" style="padding:10px 24px;font-size:14px;font-weight:600;background:#0e7c56;color:#fff;border:none;border-radius:8px;cursor:pointer;">
Print / Save as PDF
</button>
</div>
</body>
</html>`;
const win = window.open("", "_blank", "width=1200,height=900");
if (!win) {
2026-02-13 18:19:18 -08:00
alert("Please allow pop-ups to view the Scan report.");
return;
}
win.document.write(pdfHtml);
win.document.close();
win.focus();
setTimeout(() => win.print(), 400);
}
function generateAccessMapReport() {
2026-02-13 18:19:18 -08:00
if (!Array.isArray(accessMap) || accessMap.length === 0) {
alert("No Access Map entries are available. Run a scan with --access-map first.");
return;
}
2026-02-13 18:19:18 -08:00
// Compute stats
let totalResources = 0;
let totalPermissions = 0;
const allPermSet = new Set();
const providerCounts = {};
accessMap.forEach((entry) => {
const prov = (entry.provider || "unknown").toUpperCase();
providerCounts[prov] = (providerCounts[prov] || 0) + 1;
(entry.groups || []).forEach((g) => {
totalResources += (g.resources || []).length;
(g.permissions || []).forEach((p) => allPermSet.add(p));
});
});
totalPermissions = allPermSet.size;
// Build per-identity cards
const identityCards = accessMap.map((entry, idx) => {
const groups = Array.isArray(entry.groups) ? entry.groups : [];
const findingId = getFindingIdFromAccessEntry(entry);
const tokenName = getTokenNameFromAccessEntry(entry);
const userId = getUserIdFromAccessEntry(entry);
const provider = (entry.provider || "Unknown").toUpperCase();
// Token details
let tokenHtml = "";
if (entry.token_details) {
const td = entry.token_details;
const fields = [];
if (td.username) fields.push(["Username", td.username]);
if (td.token_type) fields.push(["Token Type", td.token_type]);
if (td.account_type) fields.push(["Account Type", td.account_type]);
if (td.name) fields.push(["Token Name", td.name]);
if (td.email) fields.push(["Email", td.email]);
if (td.created_at) fields.push(["Created", td.created_at]);
if (td.expires_at) fields.push(["Expires", td.expires_at]);
if (td.last_used_at) fields.push(["Last Used", td.last_used_at]);
if (Array.isArray(td.scopes) && td.scopes.length) fields.push(["Scopes", td.scopes.join(", ")]);
if (fields.length) {
tokenHtml = `<div style="margin-top:10px;padding:10px 14px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;">
<div style="font-weight:700;font-size:12px;margin-bottom:6px;color:#0369a1;">Token Details</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px 16px;font-size:12px;">
${fields.map(([k, v]) => `<div><strong style="color:#475569;">${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</div>`).join("")}
</div>
</div>`;
}
}
2026-02-13 18:19:18 -08:00
// Resource groups
const groupsHtml = groups.map((g) => {
const resources = g.resources || [];
const perms = g.permissions || [];
const resRows = resources.length
? resources.map((r) => `<div style="padding:4px 8px;background:#fff;border:1px solid #e5e7eb;border-radius:4px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;word-break:break-all;">${escapeHtml(String(r))}</div>`).join("")
: '<div style="color:#9ca3af;font-size:12px;font-style:italic;">No specific resources</div>';
const permTags = perms.length
? perms.map((p) => `<span style="display:inline-block;padding:2px 7px;border-radius:4px;font-size:11px;font-weight:600;background:#ecfdf5;color:#16a34a;border:1px solid #bbf7d0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">${escapeHtml(p)}</span>`).join(" ")
: '<span style="color:#9ca3af;font-size:12px;font-style:italic;">No specific permissions</span>';
return `<div style="margin-top:10px;">
<div style="font-weight:600;font-size:12px;color:#374151;margin-bottom:6px;">Resources (${resources.length})</div>
<div style="display:flex;flex-direction:column;gap:4px;">${resRows}</div>
<div style="font-weight:600;font-size:12px;color:#374151;margin:10px 0 6px;">Permissions (${perms.length})</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;">${permTags}</div>
</div>`;
}).join("");
// Meta line
const metaParts = [];
if (findingId) metaParts.push(`<strong>Fingerprint:</strong> <code style="font-size:10px;">${escapeHtml(findingId.substring(0, 16))}</code>`);
if (tokenName) metaParts.push(`<strong>Token:</strong> ${escapeHtml(tokenName)}`);
if (userId) metaParts.push(`<strong>User:</strong> ${escapeHtml(userId)}`);
const metaLine = metaParts.length
? `<div style="font-size:12px;color:#64748b;margin-top:6px;">${metaParts.join(" · ")}</div>`
: "";
return `<div style="page-break-inside:avoid;border:1px solid #d1d5db;border-radius:8px;padding:16px;margin-bottom:12px;background:#fff;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:4px;">
<span style="font-size:16px;">👤</span>
<div>
<div style="font-size:15px;font-weight:700;">${escapeHtml(entry.account || "(identity)")}</div>
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:700;background:#e0f2fe;color:#075985;margin-top:2px;">${escapeHtml(provider)}</span>
</div>
</div>
2026-02-13 18:19:18 -08:00
${metaLine}
${tokenHtml}
${groupsHtml}
</div>`;
}).join("");
// Provider summary
const providerSummaryRows = Object.entries(providerCounts)
.sort((a, b) => b[1] - a[1])
.map(([p, c]) => `<tr><td>${escapeHtml(p)}</td><td>${c}</td></tr>`)
.join("");
const pdfHtml = `<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Kingfisher Access Map Report</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 24px; color: #0f172a; }
h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 20px 0 10px; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
.report-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 3px solid #0e7c56; }
.report-header h1 { color: #0e7c56; }
.report-date { color: #4b5563; font-size: 13px; text-align: right; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 18px; }
.stat { padding: 12px; border: 1px solid #d1d5db; border-radius: 8px; background: #f8fafc; }
.stat .label { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.04em; font-weight: 600; }
.stat .value { font-size: 22px; font-weight: 800; margin-top: 4px; }
table { border-collapse: collapse; margin-top: 8px; }
th, td { border: 1px solid #d1d5db; padding: 6px 12px; font-size: 12px; text-align: left; }
th { background: #f1f5f9; font-weight: 700; color: #374151; }
.section { margin-bottom: 24px; }
code { background: #f1f5f9; padding: 1px 5px; border-radius: 4px; font-size: 11px; }
@media print { body { padding: 12px; } .no-print { display: none; } }
</style>
</head>
<body>
<div class="report-header">
<h1>Kingfisher Access Map Report</h1>
<div class="report-date">${escapeHtml(new Date().toLocaleString())}</div>
</div>
<div class="section">
<h2>Summary</h2>
<div class="grid">
<div class="stat"><div class="label">Identities</div><div class="value">${accessMap.length}</div></div>
<div class="stat"><div class="label">Total Resources</div><div class="value">${totalResources}</div></div>
<div class="stat"><div class="label">Unique Permissions</div><div class="value">${totalPermissions}</div></div>
<div class="stat"><div class="label">Providers</div><div class="value">${Object.keys(providerCounts).length}</div></div>
</div>
${Object.keys(providerCounts).length > 1 ? `
<table>
<thead><tr><th>Provider</th><th>Identities</th></tr></thead>
<tbody>${providerSummaryRows}</tbody>
</table>
` : ""}
</div>
<div class="section">
<h2>Identities &amp; Access</h2>
${identityCards}
</div>
<div class="no-print" style="margin-top:24px;text-align:center;">
<button onclick="window.print()" style="padding:10px 24px;font-size:14px;font-weight:600;background:#0e7c56;color:#fff;border:none;border-radius:8px;cursor:pointer;">
Print / Save as PDF
</button>
</div>
</body>
</html>`;
const win = window.open("", "_blank", "width=1200,height=900");
if (!win) {
2026-02-13 18:19:18 -08:00
alert("Please allow pop-ups to view the Access Map report.");
return;
}
win.document.write(pdfHtml);
win.document.close();
win.focus();
setTimeout(() => win.print(), 400);
}
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 || "";
const fpEl = document.getElementById("fd-fingerprint");
const fpVal = finding.fingerprint || "";
fpEl.textContent = fpVal;
fpEl.innerHTML = fpVal; // reset content
if (fpVal && Array.isArray(accessMap)) {
const hasEntry = accessMap.some(entry => entry.fingerprint === fpVal);
if (hasEntry) {
const btn = document.createElement("button");
btn.className = "badge";
btn.textContent = "Go to Access Map";
btn.style.marginLeft = "10px";
btn.style.cursor = "pointer";
btn.style.background = "var(--brand-soft)";
btn.style.borderColor = "var(--brand)";
btn.style.color = "var(--brand-dark)";
btn.onclick = () => {
setActiveView("view-access");
const treeSearch = document.getElementById("tree-search");
if (treeSearch) {
treeSearch.value = fpVal;
treeSearch.dispatchEvent(new Event('input'));
}
};
fpEl.appendChild(btn);
}
}
document.getElementById("fd-entropy").textContent =
finding.entropy != null ? String(finding.entropy) : "";
const commit =
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;
2026-01-15 10:41:55 -08:00
const committerWrapper = document.getElementById("fd-committer-email-wrapper");
const committerEmailEl = document.getElementById("fd-committer-email");
const committerEmail =
finding.git_metadata &&
finding.git_metadata.commit &&
finding.git_metadata.commit.committer &&
finding.git_metadata.commit.committer.email
? String(finding.git_metadata.commit.committer.email)
: "";
if (committerWrapper && committerEmailEl) {
if (committerEmail) {
committerWrapper.style.display = "";
committerEmailEl.textContent = committerEmail;
} else {
committerWrapper.style.display = "none";
committerEmailEl.textContent = "";
}
}
const statusRaw =
finding.validation && finding.validation.status ? String(finding.validation.status) : "Unknown";
const normalizedStatus = normalizeValidationStatus(statusRaw);
const badgeClass =
normalizedStatus === "active"
? "active"
: normalizedStatus === "inactive"
? "inactive"
: "unknown";
const statusEl = document.getElementById("fd-validation-status");
if (statusEl) {
statusEl.innerHTML = `<span class="status-badge ${badgeClass}">${escapeHtml(statusRaw)}</span>`;
}
const path = finding.path || "";
if (fdPathInput) {
fdPathInput.value = path || "—";
fdPathInput.style.height = "auto";
fdPathInput.style.height = Math.min(fdPathInput.scrollHeight, 260) + "px";
}
const gitUrlWrapper = document.getElementById("fd-git-url-wrapper");
const gitUrlEl = document.getElementById("fd-git-url");
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 = "";
}
// Validate command section
const validateBox = document.getElementById("fd-validate-box");
const validateCmd = document.getElementById("fd-validate-cmd");
const validateCopyBtn = document.getElementById("fd-validate-copy");
if (finding.validate_command) {
validateBox.classList.remove("hidden");
validateCmd.textContent = finding.validate_command;
// Set up copy button
validateCopyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(finding.validate_command);
validateCopyBtn.classList.add("copied");
validateCopyBtn.querySelector("span").textContent = "Copied!";
setTimeout(() => {
validateCopyBtn.classList.remove("copied");
validateCopyBtn.querySelector("span").textContent = "Copy";
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
} else {
validateBox.classList.add("hidden");
validateCmd.textContent = "";
}
// Revoke command section
const revokeBox = document.getElementById("fd-revoke-box");
const revokeCmd = document.getElementById("fd-revoke-cmd");
const revokeCopyBtn = document.getElementById("fd-revoke-copy");
if (finding.revoke_command) {
revokeBox.classList.remove("hidden");
revokeCmd.textContent = finding.revoke_command;
// Set up copy button
revokeCopyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(finding.revoke_command);
revokeCopyBtn.classList.add("copied");
revokeCopyBtn.querySelector("span").textContent = "Copied!";
setTimeout(() => {
revokeCopyBtn.classList.remove("copied");
revokeCopyBtn.querySelector("span").textContent = "Copy";
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
} else {
revokeBox.classList.add("hidden");
revokeCmd.textContent = "";
}
2026-02-13 18:19:18 -08:00
// Blast radius
currentDetailFinding = f;
renderBlastRadius(f);
}
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 "";
}
2026-02-13 18:19:18 -08:00
function getAccessMapForFingerprint(fingerprint) {
if (!fingerprint || !Array.isArray(accessMap)) return [];
return accessMap.filter((entry) => entry.fingerprint === fingerprint);
}
function countResources(entries) {
let total = 0;
entries.forEach((entry) => {
(entry.groups || []).forEach((group) => {
total += (group.resources || []).length;
});
});
return total;
}
function countPermissions(entries) {
const permSet = new Set();
entries.forEach((entry) => {
(entry.groups || []).forEach((group) => {
(group.permissions || []).forEach((p) => permSet.add(p));
});
});
return permSet.size;
}
function generateRiskRationale(finding, accessEntries) {
const rule = finding.rule || {};
const fd = finding.finding || {};
const statusRaw = fd.validation && fd.validation.status ? String(fd.validation.status) : "";
const normalizedStatus = normalizeValidationStatus(statusRaw);
const isActive = normalizedStatus === "active";
if (!accessEntries || accessEntries.length === 0) {
if (isActive) {
return {
level: "medium",
text: `This is an active ${escapeHtml(rule.name || "credential")} found in ${escapeHtml(fd.path || "the codebase")}. No access map data is available, but the credential validated successfully, indicating it grants live access to the target service.`,
};
}
return {
level: "none",
text: "No access map data is linked to this finding. Run a scan with --access-map to map the blast radius of validated credentials.",
};
}
const providers = [...new Set(accessEntries.map((e) => (e.provider || "").toUpperCase()).filter(Boolean))];
const resourceCount = countResources(accessEntries);
const permCount = countPermissions(accessEntries);
const identityCount = accessEntries.length;
// Categorize resources
const resourceTypes = {};
accessEntries.forEach((entry) => {
(entry.groups || []).forEach((group) => {
(group.resources || []).forEach((r) => {
const rStr = String(r);
let type = "resource";
if (/s3|bucket|storage|blob/i.test(rStr)) type = "storage bucket";
else if (/lambda|function|cloud.run/i.test(rStr)) type = "serverless function";
else if (/iam|role|policy/i.test(rStr)) type = "IAM resource";
else if (/secret|vault|kms/i.test(rStr)) type = "secret/key";
else if (/ec2|instance|vm|compute/i.test(rStr)) type = "compute instance";
else if (/dynamodb|database|rds|sql/i.test(rStr)) type = "database";
else if (/repo|repository/i.test(rStr)) type = "repository";
resourceTypes[type] = (resourceTypes[type] || 0) + 1;
});
});
});
const typeDescriptions = Object.entries(resourceTypes)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => `${count} ${type}${count > 1 ? "s" : ""}`)
.join(", ");
// Determine risk level
let level = "low";
if (isActive && resourceCount > 10) level = "critical";
else if (isActive && resourceCount > 3) level = "high";
else if (isActive && resourceCount > 0) level = "medium";
else if (!isActive && resourceCount > 0) level = "low";
// Check for dangerous permissions
const allPerms = [];
accessEntries.forEach((e) => (e.groups || []).forEach((g) => allPerms.push(...(g.permissions || []))));
const dangerousPerms = allPerms.filter((p) =>
/admin|full.?control|owner|delete|write.*all|manage|superuser|\*/i.test(p)
);
if (isActive && dangerousPerms.length > 0) level = "critical";
// Check for token details with scopes
const scopeInfo = [];
accessEntries.forEach((entry) => {
if (entry.token_details && Array.isArray(entry.token_details.scopes)) {
scopeInfo.push(...entry.token_details.scopes);
}
});
let text = "";
if (isActive) {
text = `This active ${escapeHtml(rule.name || "credential")} grants access to <strong>${resourceCount} resource${resourceCount !== 1 ? "s" : ""}</strong> across <strong>${identityCount} identit${identityCount !== 1 ? "ies" : "y"}</strong> on ${providers.join(", ")}.`;
if (typeDescriptions) {
text += ` Accessible resources include ${typeDescriptions}.`;
}
if (dangerousPerms.length > 0) {
text += ` <strong>Warning:</strong> This credential has elevated privileges including ${dangerousPerms.slice(0, 3).map((p) => "<code>" + escapeHtml(p) + "</code>").join(", ")}${dangerousPerms.length > 3 ? ` and ${dangerousPerms.length - 3} more` : ""}.`;
}
if (scopeInfo.length > 0) {
text += ` Token scopes: ${scopeInfo.slice(0, 5).map((s) => escapeHtml(s)).join(", ")}${scopeInfo.length > 5 ? ` (+${scopeInfo.length - 5} more)` : ""}.`;
}
} else {
text = `This ${escapeHtml(rule.name || "credential")} has access map data showing <strong>${resourceCount} resource${resourceCount !== 1 ? "s" : ""}</strong> that would be accessible if the credential were active.`;
if (typeDescriptions) {
text += ` Resources include ${typeDescriptions}.`;
}
}
return { level, text };
}
function renderBlastRadius(f) {
const finding = f.finding || {};
const fingerprint = finding.fingerprint || "";
const blastSection = document.getElementById("fd-blast-radius");
const blastEntries = document.getElementById("fd-blast-entries");
const blastCount = document.getElementById("fd-blast-count");
const rationaleEl = document.getElementById("fd-risk-rationale");
const entries = getAccessMapForFingerprint(fingerprint);
const { level, text } = generateRiskRationale(f, entries);
// Always show the blast radius section
blastSection.classList.remove("hidden");
// Risk rationale
rationaleEl.className = "risk-rationale " + level;
rationaleEl.innerHTML = text;
// Count
const resourceTotal = countResources(entries);
blastCount.textContent = entries.length > 0
? `${entries.length} identit${entries.length !== 1 ? "ies" : "y"}, ${resourceTotal} resource${resourceTotal !== 1 ? "s" : ""}`
: "No access data";
// Render entries
blastEntries.innerHTML = "";
if (entries.length === 0) return;
entries.forEach((entry) => {
const identity = document.createElement("div");
identity.className = "blast-identity";
const header = document.createElement("div");
header.className = "blast-identity__header";
const providerBadge = document.createElement("span");
providerBadge.className = "badge " + providerBadgeClass(entry.provider);
providerBadge.textContent = (entry.provider || "").toUpperCase();
header.appendChild(providerBadge);
const nameSpan = document.createElement("span");
nameSpan.textContent = entry.account || "(identity)";
header.appendChild(nameSpan);
// Token info summary
if (entry.token_details) {
const tokenInfo = document.createElement("span");
tokenInfo.style.fontSize = "12px";
tokenInfo.style.color = "var(--text-muted)";
const parts = [];
if (entry.token_details.username) parts.push(entry.token_details.username);
if (entry.token_details.token_type) parts.push(entry.token_details.token_type);
if (parts.length) tokenInfo.textContent = "(" + parts.join(" · ") + ")";
header.appendChild(tokenInfo);
}
identity.appendChild(header);
const resList = document.createElement("ul");
resList.className = "blast-resource-list";
(entry.groups || []).forEach((group) => {
const resources = group.resources || [];
const perms = group.permissions || [];
if (resources.length === 0 && perms.length > 0) {
const item = document.createElement("li");
item.className = "blast-resource-item";
const name = document.createElement("div");
name.className = "blast-resource-name";
name.textContent = "Project-wide / Unscoped";
item.appendChild(name);
const permDiv = document.createElement("div");
permDiv.className = "blast-perms";
perms.forEach((p) => {
const tag = document.createElement("span");
tag.className = "blast-perm-tag";
tag.textContent = p;
permDiv.appendChild(tag);
});
item.appendChild(permDiv);
resList.appendChild(item);
}
resources.forEach((resName) => {
const item = document.createElement("li");
item.className = "blast-resource-item";
const name = document.createElement("div");
name.className = "blast-resource-name";
name.textContent = String(resName);
// Add console link if available
const { resourceType, resourceName } = extractResourceParts(String(resName));
const consoleLink = buildResourceConsoleLink(entry.provider, resourceType, resourceName);
if (consoleLink) {
const a = document.createElement("a");
a.href = consoleLink;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = " (open in console)";
a.style.fontSize = "11px";
a.style.fontFamily = "inherit";
a.style.color = "var(--brand)";
name.appendChild(a);
}
item.appendChild(name);
if (perms.length > 0) {
const permDiv = document.createElement("div");
permDiv.className = "blast-perms";
perms.forEach((p) => {
const tag = document.createElement("span");
tag.className = "blast-perm-tag";
tag.textContent = p;
permDiv.appendChild(tag);
});
item.appendChild(permDiv);
}
resList.appendChild(item);
});
});
identity.appendChild(resList);
blastEntries.appendChild(identity);
});
}
function exportSingleFindingRiskReport(f) {
const rule = f.rule || {};
const finding = f.finding || {};
const fingerprint = finding.fingerprint || "";
const entries = getAccessMapForFingerprint(fingerprint);
const { level, text: rationaleText } = generateRiskRationale(f, entries);
const statusRaw = finding.validation && finding.validation.status ? String(finding.validation.status) : "Unknown";
const gitUrl = getFileUrlFromFinding(finding);
const levelColors = {
critical: { bg: "#fef2f2", border: "#fca5a5", text: "#991b1b" },
high: { bg: "#fff7ed", border: "#fdba74", text: "#9a3412" },
medium: { bg: "#fffbeb", border: "#fde68a", text: "#92400e" },
low: { bg: "#f0fdf4", border: "#bbf7d0", text: "#166534" },
none: { bg: "#f8fafc", border: "#e2e8f0", text: "#475569" },
};
const colors = levelColors[level] || levelColors.none;
let accessHtml = "";
if (entries.length > 0) {
accessHtml = entries.map((entry) => {
const groups = (entry.groups || []).map((g) => {
const resList = (g.resources || []).map((r) => `<li><code>${escapeHtml(String(r))}</code></li>`).join("");
const permList = (g.permissions || []).map((p) => `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:#ecfdf5;color:#16a34a;border:1px solid #bbf7d0;margin:2px;">${escapeHtml(p)}</span>`).join(" ");
return `<div style="margin-bottom:12px;">
<div style="font-weight:600;margin-bottom:4px;">Resources:</div>
<ul style="margin:0;padding-left:20px;">${resList || "<li>No specific resources</li>"}</ul>
<div style="font-weight:600;margin:8px 0 4px;">Permissions:</div>
<div>${permList || "No specific permissions"}</div>
</div>`;
}).join("");
let tokenHtml = "";
if (entry.token_details) {
const td = entry.token_details;
const fields = [];
if (td.username) fields.push(["Username", td.username]);
if (td.token_type) fields.push(["Token Type", td.token_type]);
if (td.account_type) fields.push(["Account Type", td.account_type]);
if (td.name) fields.push(["Token Name", td.name]);
if (td.email) fields.push(["Email", td.email]);
if (td.created_at) fields.push(["Created", td.created_at]);
if (td.expires_at) fields.push(["Expires", td.expires_at]);
if (td.last_used_at) fields.push(["Last Used", td.last_used_at]);
if (Array.isArray(td.scopes) && td.scopes.length) fields.push(["Scopes", td.scopes.join(", ")]);
if (fields.length) {
tokenHtml = `<div style="margin-top:10px;padding:10px;background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;">
<div style="font-weight:700;font-size:13px;margin-bottom:6px;">Token Details</div>
${fields.map(([k, v]) => `<div style="font-size:12px;"><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</div>`).join("")}
</div>`;
}
}
return `<div style="border:1px solid #d1d5db;border-radius:8px;padding:14px;margin-bottom:10px;background:#f8fafc;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:700;background:#e0f2fe;color:#075985;">${escapeHtml((entry.provider || "").toUpperCase())}</span>
<strong>${escapeHtml(entry.account || "(identity)")}</strong>
</div>
${groups}
${tokenHtml}
</div>`;
}).join("");
}
const reportHtml = `<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Risk Report: ${escapeHtml(rule.name || rule.id || "Finding")}</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 24px; color: #0f172a; max-width: 900px; margin: 0 auto; }
h1 { font-size: 22px; margin: 0 0 6px; }
h2 { font-size: 17px; margin: 20px 0 10px; border-bottom: 2px solid #e5e7eb; padding-bottom: 6px; }
.meta { color: #4b5563; font-size: 13px; margin-bottom: 20px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 16px; }
.detail-item { padding: 8px 12px; background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 6px; }
.detail-item label { display: block; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.04em; font-weight: 600; }
.detail-item div { font-size: 14px; font-weight: 500; margin-top: 2px; word-break: break-all; }
.snippet { background: #1e293b; color: #e2e8f0; padding: 14px; border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; margin: 10px 0; }
.rationale { padding: 14px 18px; border-radius: 8px; font-size: 14px; line-height: 1.7; margin-bottom: 16px; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
@media print { body { padding: 12px; } .no-print { display: none; } }
</style>
</head>
<body>
<h1>Risk Report: ${escapeHtml(rule.name || rule.id || "")}</h1>
<div class="meta">Generated ${escapeHtml(new Date().toLocaleString())} · Fingerprint: ${escapeHtml(fingerprint)}</div>
<h2>Risk Assessment</h2>
<div class="rationale" style="background:${colors.bg};border:1px solid ${colors.border};color:${colors.text};">
<strong style="text-transform:uppercase;">${escapeHtml(level)} risk</strong><br>
${rationaleText}
</div>
<h2>Finding Details</h2>
<div class="detail-grid">
<div class="detail-item"><label>Rule</label><div>${escapeHtml(rule.name || "")} (${escapeHtml(rule.id || "")})</div></div>
<div class="detail-item"><label>Validation</label><div>${escapeHtml(statusRaw)}</div></div>
<div class="detail-item"><label>Confidence</label><div>${escapeHtml(finding.confidence || "")}</div></div>
<div class="detail-item"><label>Entropy</label><div>${finding.entropy != null ? escapeHtml(String(finding.entropy)) : "—"}</div></div>
<div class="detail-item" style="grid-column:span 2;"><label>File Path</label><div>${escapeHtml(finding.path || "")}</div></div>
${gitUrl ? `<div class="detail-item" style="grid-column:span 2;"><label>Git URL</label><div><a href="${escapeHtml(gitUrl)}" target="_blank">${escapeHtml(gitUrl)}</a></div></div>` : ""}
${finding.git_metadata && finding.git_metadata.commit ? `<div class="detail-item"><label>Commit</label><div>${escapeHtml(finding.git_metadata.commit.id ? finding.git_metadata.commit.id.substring(0, 8) : "")}</div></div>` : ""}
${finding.git_metadata && finding.git_metadata.commit && finding.git_metadata.commit.committer ? `<div class="detail-item"><label>Committer</label><div>${escapeHtml(finding.git_metadata.commit.committer.email || "")}</div></div>` : ""}
</div>
<div style="font-weight:600;font-size:12px;text-transform:uppercase;color:#6b7280;margin-bottom:6px;">Match Snippet</div>
<div class="snippet">${escapeHtml(finding.snippet || "")}</div>
${entries.length > 0 ? `
<h2>Blast Radius (${entries.length} Identit${entries.length !== 1 ? "ies" : "y"}, ${countResources(entries)} Resource${countResources(entries) !== 1 ? "s" : ""})</h2>
${accessHtml}
` : `
<h2>Blast Radius</h2>
<p style="color:#6b7280;">No access map data available. Run with <code>--access-map</code> to map credential blast radius.</p>
`}
<div class="no-print" style="margin-top:24px;text-align:center;">
<button onclick="window.print()" style="padding:10px 24px;font-size:14px;font-weight:600;background:#0e7c56;color:#fff;border:none;border-radius:8px;cursor:pointer;">
Print / Save as PDF
</button>
</div>
</body>
</html>`;
const win = window.open("", "_blank", "width=1000,height=900");
if (!win) {
alert("Please allow pop-ups to open the risk report.");
return;
}
win.document.write(reportHtml);
win.document.close();
win.focus();
}
function generateRiskReport({ activeOnly = false } = {}) {
if (!findings || findings.length === 0) {
alert("No findings loaded. Load a report first.");
return;
}
let pool = findings.slice();
if (activeOnly) {
pool = pool.filter((f) => {
const fd = f.finding || {};
return normalizeValidationStatus(fd.validation && fd.validation.status ? fd.validation.status : "") === "active";
});
if (pool.length === 0) {
alert("No active credentials found. Uncheck 'Active credentials only' to include all findings.");
return;
}
}
// Sort: active first, then by rule name
const statusOrder = { active: 0, inactive: 1, not_attempted: 2, unknown: 3 };
const sorted = pool.sort((a, b) => {
const fa = a.finding || {};
const fb = b.finding || {};
const keyA = normalizeValidationStatus(fa.validation && fa.validation.status ? fa.validation.status : "");
const keyB = normalizeValidationStatus(fb.validation && fb.validation.status ? fb.validation.status : "");
const sa = statusOrder.hasOwnProperty(keyA) ? statusOrder[keyA] : 4;
const sb = statusOrder.hasOwnProperty(keyB) ? statusOrder[keyB] : 4;
if (sa !== sb) return sa - sb;
const ra = (a.rule && (a.rule.name || a.rule.id)) || "";
const rb = (b.rule && (b.rule.name || b.rule.id)) || "";
return ra.localeCompare(rb);
});
const counts = calculateValidationCounts(sorted);
const riskScopeLabel = activeOnly ? "Active credentials only" : "All findings";
const findingsWithAccess = sorted.filter((f) => {
const fp = f.finding && f.finding.fingerprint ? f.finding.fingerprint : "";
return getAccessMapForFingerprint(fp).length > 0;
});
const levelColors = {
critical: { bg: "#fef2f2", border: "#fca5a5", text: "#991b1b" },
high: { bg: "#fff7ed", border: "#fdba74", text: "#9a3412" },
medium: { bg: "#fffbeb", border: "#fde68a", text: "#92400e" },
low: { bg: "#f0fdf4", border: "#bbf7d0", text: "#166534" },
none: { bg: "#f8fafc", border: "#e2e8f0", text: "#475569" },
};
let findingSections = "";
sorted.forEach((f, idx) => {
const rule = f.rule || {};
const finding = f.finding || {};
const fp = finding.fingerprint || "";
const entries = getAccessMapForFingerprint(fp);
const { level, text: rationaleText } = generateRiskRationale(f, entries);
const colors = levelColors[level] || levelColors.none;
const statusRaw = finding.validation && finding.validation.status ? String(finding.validation.status) : "Unknown";
const gitUrl = getFileUrlFromFinding(finding);
let accessSummary = "";
if (entries.length > 0) {
const resCount = countResources(entries);
const permCount = countPermissions(entries);
accessSummary = entries.map((entry) => {
const resources = [];
(entry.groups || []).forEach((g) => {
(g.resources || []).forEach((r) => resources.push(String(r)));
});
const perms = [];
(entry.groups || []).forEach((g) => (g.permissions || []).forEach((p) => perms.push(p)));
const uniquePerms = [...new Set(perms)];
return `<div style="margin:8px 0;padding:10px;border:1px solid #e5e7eb;border-radius:6px;background:#f8fafc;">
<div style="font-weight:700;font-size:13px;margin-bottom:6px;">
<span style="display:inline-block;padding:1px 6px;border-radius:999px;font-size:11px;background:#e0f2fe;color:#075985;margin-right:6px;">${escapeHtml((entry.provider || "").toUpperCase())}</span>
${escapeHtml(entry.account || "")}
</div>
${resources.length ? `<div style="font-size:12px;"><strong>Resources (${resources.length}):</strong> ${resources.slice(0, 8).map((r) => "<code>" + escapeHtml(r) + "</code>").join(", ")}${resources.length > 8 ? ` +${resources.length - 8} more` : ""}</div>` : ""}
${uniquePerms.length ? `<div style="font-size:12px;margin-top:4px;"><strong>Permissions (${uniquePerms.length}):</strong> ${uniquePerms.slice(0, 6).map((p) => escapeHtml(p)).join(", ")}${uniquePerms.length > 6 ? ` +${uniquePerms.length - 6} more` : ""}</div>` : ""}
</div>`;
}).join("");
}
findingSections += `
<div style="page-break-inside:avoid;border:1px solid #d1d5db;border-radius:8px;padding:16px;margin-bottom:16px;background:#fff;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<div style="font-size:16px;font-weight:700;">${idx + 1}. ${escapeHtml(rule.name || rule.id || "Unknown Rule")}</div>
<span style="display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:700;text-transform:uppercase;background:${colors.bg};border:1px solid ${colors.border};color:${colors.text};">${escapeHtml(level)}</span>
</div>
<div style="padding:10px 14px;border-radius:6px;background:${colors.bg};border:1px solid ${colors.border};color:${colors.text};font-size:13px;line-height:1.6;margin-bottom:12px;">
${rationaleText}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:12px;margin-bottom:10px;">
<div><strong>Path:</strong> ${escapeHtml(finding.path || "—")}</div>
<div><strong>Line:</strong> ${finding.line != null ? finding.line : "—"}</div>
<div><strong>Validation:</strong> ${escapeHtml(statusRaw)}</div>
<div><strong>Confidence:</strong> ${escapeHtml(finding.confidence || "—")}</div>
${gitUrl ? `<div style="grid-column:span 2;"><strong>URL:</strong> <a href="${escapeHtml(gitUrl)}" style="color:#1d4ed8;">${escapeHtml(gitUrl)}</a></div>` : ""}
</div>
<div style="background:#1e293b;color:#e2e8f0;padding:10px;border-radius:6px;font-family:monospace;font-size:11px;white-space:pre-wrap;word-break:break-all;margin-bottom:10px;">${escapeHtml((finding.snippet || "").substring(0, 300))}</div>
${accessSummary ? `<div style="margin-top:8px;"><div style="font-weight:700;font-size:13px;margin-bottom:6px;">Blast Radius</div>${accessSummary}</div>` : ""}
</div>`;
});
const reportHtml = `<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Kingfisher Risk Report</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 24px; color: #0f172a; }
h1 { font-size: 24px; margin: 0 0 4px; }
h2 { font-size: 18px; margin: 24px 0 12px; }
.meta { color: #4b5563; font-size: 13px; margin-bottom: 20px; }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-bottom: 20px; }
.summary-stat { padding: 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #f8fafc; }
.summary-stat .label { font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.04em; font-weight: 600; }
.summary-stat .value { font-size: 24px; font-weight: 800; margin-top: 4px; }
code { background: #f1f5f9; padding: 1px 5px; border-radius: 4px; font-size: 11px; }
@media print { body { padding: 12px; font-size: 11px; } .no-print { display: none; } }
</style>
</head>
<body>
<h1>Kingfisher Risk Report</h1>
<div class="meta">
Generated ${escapeHtml(new Date().toLocaleString())} ·
Scope: ${escapeHtml(riskScopeLabel)} ·
${sorted.length} finding${sorted.length !== 1 ? "s" : ""} ·
${findingsWithAccess.length} with access map data
</div>
<div class="summary-grid">
<div class="summary-stat"><div class="label">Total Findings</div><div class="value">${sorted.length}</div></div>
<div class="summary-stat"><div class="label">Active Credentials</div><div class="value" style="color:#dc2626;">${counts.active || 0}</div></div>
<div class="summary-stat"><div class="label">With Blast Radius</div><div class="value">${findingsWithAccess.length}</div></div>
<div class="summary-stat"><div class="label">Identities Mapped</div><div class="value">${(accessMap || []).length}</div></div>
</div>
<h2>Findings by Risk</h2>
${findingSections}
<div class="no-print" style="margin-top:24px;text-align:center;">
<button onclick="window.print()" style="padding:10px 24px;font-size:14px;font-weight:600;background:#0e7c56;color:#fff;border:none;border-radius:8px;cursor:pointer;">Print / Save as PDF</button>
</div>
</body>
</html>`;
const win = window.open("", "_blank", "width=1100,height=900");
if (!win) {
alert("Please allow pop-ups to view the risk report.");
return;
}
win.document.write(reportHtml);
win.document.close();
win.focus();
}
function renderAccessMapTree(filter = "") {
2026-02-13 18:35:36 -08:00
const list = document.getElementById("am-card-list");
list.innerHTML = "";
const filterLower = (filter || "").toLowerCase();
filteredAccessMapView = buildRenderableAccessMap(filterLower);
const hasAccess = Array.isArray(accessMap) && accessMap.length > 0;
syncAccessMapUi(hasAccess);
2026-02-13 18:35:36 -08:00
renderAccessMapStats();
if (!filteredAccessMapView || filteredAccessMapView.length === 0) {
2026-02-13 18:35:36 -08:00
list.innerHTML =
'<div style="padding:32px 16px; text-align:center; color:var(--text-muted); font-size:13px;">No access map data available</div>';
return;
}
2026-02-13 18:35:36 -08:00
const frag = document.createDocumentFragment();
filteredAccessMapView.forEach((entry) => {
2026-02-13 18:35:36 -08:00
frag.appendChild(buildIdentityCard(entry));
});
list.appendChild(frag);
}
function renderAccessMapStats() {
const bar = document.getElementById("am-stats-bar");
if (!bar) return;
if (!accessMap || accessMap.length === 0) {
bar.classList.add("hidden");
return;
}
let totalRes = 0;
const permSet = new Set();
const providerSet = new Set();
accessMap.forEach((entry) => {
providerSet.add((entry.provider || "unknown").toUpperCase());
(entry.groups || []).forEach((g) => {
totalRes += (g.resources || []).length;
(g.permissions || []).forEach((p) => permSet.add(p));
});
2026-02-13 18:35:36 -08:00
});
2026-02-13 18:35:36 -08:00
bar.classList.remove("hidden");
bar.innerHTML = `
<div class="am-stat"><span class="am-stat-value">${accessMap.length}</span><span class="am-stat-label">Identities</span></div>
<div class="am-stat"><span class="am-stat-value">${totalRes}</span><span class="am-stat-label">Resources</span></div>
<div class="am-stat"><span class="am-stat-value">${permSet.size}</span><span class="am-stat-label">Unique Permissions</span></div>
<div class="am-stat">${[...providerSet].map((p) => '<span class="badge ' + providerBadgeClass(p) + '">' + escapeHtml(p) + '</span>').join(" ")}</div>
`;
}
function buildIdentityCard(entry) {
const identity = entry.identity;
const groups = entry.groups;
const provider = (identity.provider || "unknown");
const account = identity.account || "(unknown identity)";
const fingerprint = identity.fingerprint || "";
// Count resources and collect all permissions
let resCount = 0;
const allPerms = new Set();
groups.forEach((g) => {
resCount += (g.resources || []).length;
(g.permissions || []).forEach((p) => allPerms.add(p));
});
const permArr = [...allPerms];
// Card container
const card = document.createElement("div");
card.className = "id-card";
// Header (always visible)
const header = document.createElement("div");
header.className = "id-card__header";
const avatar = document.createElement("div");
avatar.className = "id-card__avatar";
avatar.textContent = "👤";
const info = document.createElement("div");
info.className = "id-card__info";
const nameRow = document.createElement("div");
nameRow.className = "id-card__name";
nameRow.textContent = account + " ";
const provBadge = document.createElement("span");
provBadge.className = "badge " + providerBadgeClass(provider);
provBadge.textContent = provider.toUpperCase();
nameRow.appendChild(provBadge);
info.appendChild(nameRow);
// Meta row
const meta = document.createElement("div");
meta.className = "id-card__meta";
meta.innerHTML = `
<span class="id-card__meta-item">📦 <strong>${resCount}</strong> resource${resCount !== 1 ? "s" : ""}</span>
<span class="id-card__meta-item">🔑 <strong>${permArr.length}</strong> permission${permArr.length !== 1 ? "s" : ""}</span>
`;
// Token summary in meta
if (identity.token_details) {
const td = identity.token_details;
const parts = [];
if (td.username) parts.push(td.username);
if (td.token_type) parts.push(td.token_type);
if (parts.length) {
const tokenSpan = document.createElement("span");
tokenSpan.className = "id-card__meta-item";
tokenSpan.textContent = "🏷️ " + parts.join(" · ");
meta.appendChild(tokenSpan);
}
}
info.appendChild(meta);
// Permission preview (top 6)
if (permArr.length > 0) {
const preview = document.createElement("div");
preview.className = "id-card__perms-preview";
const limit = 6;
permArr.slice(0, limit).forEach((p) => {
const tag = document.createElement("span");
tag.className = "badge badge-perm";
tag.style.fontSize = "11px";
tag.textContent = p;
preview.appendChild(tag);
});
2026-02-13 18:35:36 -08:00
if (permArr.length > limit) {
const more = document.createElement("span");
more.className = "badge";
more.style.fontSize = "11px";
more.style.background = "var(--surface-muted)";
more.style.color = "var(--text-muted)";
more.textContent = "+" + (permArr.length - limit) + " more";
preview.appendChild(more);
}
info.appendChild(preview);
}
2026-02-13 18:35:36 -08:00
const toggle = document.createElement("div");
toggle.className = "id-card__toggle";
toggle.textContent = "▶";
2026-02-13 18:35:36 -08:00
header.appendChild(avatar);
header.appendChild(info);
header.appendChild(toggle);
// Body (expandable)
const body = document.createElement("div");
body.className = "id-card__body";
// Resources section
const resSection = document.createElement("div");
resSection.className = "id-card__section";
const resTitle = document.createElement("div");
resTitle.className = "id-card__section-title";
resTitle.textContent = "Resources (" + resCount + ")";
resSection.appendChild(resTitle);
const resGrid = document.createElement("div");
resGrid.className = "resource-grid";
groups.forEach((group) => {
const resources = group.resources || [];
const perms = group.permissions || [];
if (resources.length === 0 && perms.length > 0) {
const chip = document.createElement("div");
chip.className = "resource-chip";
const chipName = document.createElement("div");
chipName.className = "resource-chip__name";
chipName.textContent = "Project-wide / Unscoped";
chip.appendChild(chipName);
const chipPerms = document.createElement("div");
chipPerms.className = "resource-chip__perms";
perms.forEach((p) => {
const tag = document.createElement("span");
tag.className = "badge badge-perm";
tag.style.fontSize = "10px";
tag.textContent = p;
chipPerms.appendChild(tag);
});
chip.appendChild(chipPerms);
resGrid.appendChild(chip);
}
resources.forEach((resName) => {
const chip = document.createElement("div");
chip.className = "resource-chip";
const chipName = document.createElement("div");
chipName.className = "resource-chip__name";
chipName.textContent = String(resName);
// Console link
const { resourceType, resourceName } = extractResourceParts(String(resName));
const consoleLink = buildResourceConsoleLink(provider, resourceType, resourceName);
if (consoleLink) {
const a = document.createElement("a");
a.href = consoleLink;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = "open in console ↗";
chipName.appendChild(a);
}
chip.appendChild(chipName);
if (perms.length > 0) {
const chipPerms = document.createElement("div");
chipPerms.className = "resource-chip__perms";
perms.forEach((p) => {
const tag = document.createElement("span");
tag.className = "badge badge-perm";
tag.style.fontSize = "10px";
tag.textContent = p;
chipPerms.appendChild(tag);
});
2026-02-13 18:35:36 -08:00
chip.appendChild(chipPerms);
}
2026-02-13 18:35:36 -08:00
resGrid.appendChild(chip);
});
});
resSection.appendChild(resGrid);
body.appendChild(resSection);
// Token details section
if (identity.token_details) {
const tokenSection = document.createElement("div");
tokenSection.className = "id-card__section";
const tokenTitle = document.createElement("div");
tokenTitle.className = "id-card__section-title";
tokenTitle.textContent = "Token Details";
tokenSection.appendChild(tokenTitle);
const tokenGrid = document.createElement("div");
tokenGrid.className = "token-grid";
const td = identity.token_details;
const fields = [
["Name", td.name], ["Username", td.username], ["Token Type", td.token_type],
["Account Type", td.account_type], ["User ID", td.user_id], ["Email", td.email],
["Company", td.company], ["Created", td.created_at], ["Expires", td.expires_at],
["Last Used", td.last_used_at], ["Location", td.location],
];
if (identity.provider_metadata) {
if (identity.provider_metadata.version) fields.push(["Version", identity.provider_metadata.version]);
if (typeof identity.provider_metadata.enterprise === "boolean") fields.push(["Enterprise", identity.provider_metadata.enterprise ? "Yes" : "No"]);
}
fields.forEach(([label, value]) => {
if (!value) return;
const field = document.createElement("div");
field.className = "token-field";
field.innerHTML = `<strong>${escapeHtml(label)}:</strong> ${escapeHtml(String(value))}`;
tokenGrid.appendChild(field);
});
2026-02-13 18:35:36 -08:00
tokenSection.appendChild(tokenGrid);
// Scopes
if (Array.isArray(td.scopes) && td.scopes.length) {
const scopeDiv = document.createElement("div");
scopeDiv.style.marginTop = "8px";
scopeDiv.style.display = "flex";
scopeDiv.style.flexWrap = "wrap";
scopeDiv.style.gap = "4px";
td.scopes.forEach((scope) => {
const tag = document.createElement("span");
tag.className = "badge badge-perm";
tag.style.fontSize = "11px";
tag.textContent = scope;
scopeDiv.appendChild(tag);
});
tokenSection.appendChild(scopeDiv);
}
// Profile URL
if (td.url) {
const urlDiv = document.createElement("div");
urlDiv.style.marginTop = "6px";
urlDiv.style.fontSize = "12px";
const a = document.createElement("a");
a.href = td.url;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = td.url;
a.style.color = "var(--brand)";
urlDiv.appendChild(a);
tokenSection.appendChild(urlDiv);
}
body.appendChild(tokenSection);
}
card.appendChild(header);
card.appendChild(body);
// Footer with fingerprint
if (fingerprint) {
const footer = document.createElement("div");
footer.className = "id-card__footer";
footer.innerHTML = `Fingerprint: <code>${escapeHtml(fingerprint.substring(0, 20))}…</code>`;
const goBtn = document.createElement("button");
goBtn.className = "badge";
goBtn.textContent = "Go to finding";
goBtn.style.cursor = "pointer";
goBtn.style.marginLeft = "auto";
goBtn.style.background = "var(--brand-soft)";
goBtn.style.borderColor = "var(--brand)";
goBtn.style.color = "var(--brand-dark)";
goBtn.onclick = (e) => {
e.stopPropagation();
const si = document.getElementById("search-input");
if (si) {
setActiveView("view-findings");
si.value = fingerprint;
currentFilter = fingerprint;
currentPage = 1;
renderFindingsTable();
}
};
footer.appendChild(goBtn);
card.appendChild(footer);
}
// Toggle expand/collapse
header.addEventListener("click", () => {
const isOpen = body.classList.contains("open");
body.classList.toggle("open");
toggle.textContent = isOpen ? "▶" : "▼";
});
2026-02-13 18:35:36 -08:00
return card;
}
function buildRenderableAccessMap(filterLower) {
if (!accessMap || accessMap.length === 0) return [];
const identities = [];
accessMap.forEach((identity) => {
2026-01-01 22:24:57 -08:00
const account = formatIdentityLabel(identity);
const groups = Array.isArray(identity.groups) ? identity.groups : [];
const fingerprint = (identity.fingerprint || "").toLowerCase();
const identityNameMatches = Boolean(filterLower) && (account.toLowerCase().includes(filterLower) || fingerprint.includes(filterLower));
let anyResourceMatches = false;
2026-02-13 18:35:36 -08:00
let anyPermMatches = 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;
});
2026-02-13 18:35:36 -08:00
if (filterLower && !identityNameMatches) {
perms.forEach((p) => {
if (String(p).toLowerCase().includes(filterLower)) anyPermMatches = true;
});
}
return { resources, filteredResources, permissions: perms };
});
2026-02-13 18:35:36 -08:00
const hasMatch = !filterLower || identityNameMatches || anyResourceMatches || anyPermMatches;
if (!hasMatch) return;
const viewGroups = preparedGroups.map((group) => ({
2026-02-13 18:35:36 -08:00
resources: !filterLower || identityNameMatches || anyPermMatches ? 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 addBadge(container, text, cls) {
const span = document.createElement("span");
span.className = "badge " + cls;
span.textContent = text;
container.appendChild(span);
}
2026-01-01 22:24:57 -08:00
function providerBadgeClass(provider) {
const normalized = String(provider || "").toLowerCase();
if (normalized === "aws") return "badge-aws";
if (normalized === "gcp") return "badge-gcp";
if (normalized === "azure") return "badge-azure";
if (normalized === "github") return "badge-github";
if (normalized === "gitlab") return "badge-gitlab";
return "badge-aws";
}
function formatIdentityLabel(identity) {
const base = identity && identity.account ? identity.account : "unknown-identity";
const provider = identity && identity.provider ? identity.provider.toUpperCase() : null;
return provider ? `[${provider}] ${base}` : base;
}
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;
2026-02-13 18:35:36 -08:00
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 regionQuery = region ? `?region=${encodeURIComponent(region)}` : "";
2026-02-13 18:35:36 -08:00
return `https://console.aws.amazon.com/lambda/home${regionQuery}#/functions/${encodeURIComponent(match[1])}`;
}
return null;
}
if (service === "ec2") {
const match = resourcePart.match(/^instance\/(.+)$/);
2026-02-13 18:35:36 -08:00
if (match && match[1]) return `https://console.aws.amazon.com/ec2/v2/home?#InstanceDetails:instanceId=${encodeURIComponent(match[1])}`;
return null;
}
if (service === "kms") {
const match = resourcePart.match(/^(?:key|alias)\/(.+)$/);
2026-02-13 18:35:36 -08:00
if (match && match[1]) return `https://console.aws.amazon.com/kms/home?#/kms/keys/${encodeURIComponent(match[1])}`;
return null;
}
2026-02-13 18:35:36 -08:00
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\/([^/]+)/);
2026-02-13 18:35:36 -08:00
if (bucketMatch && bucketMatch[1] && project) return `https://console.cloud.google.com/storage/browser/${encodeURIComponent(bucketMatch[1])}?project=${encodeURIComponent(project)}`;
}
if (resource.includes("/datasets/")) {
const datasetMatch = resource.match(/\/datasets\/([^/]+)/);
2026-02-13 18:35:36 -08:00
if (datasetMatch && datasetMatch[1] && project) return `https://console.cloud.google.com/bigquery?project=${encodeURIComponent(project)}&p=${encodeURIComponent(project)}&d=${encodeURIComponent(datasetMatch[1])}&page=dataset`;
}
if (resource.includes("/secrets/")) {
const secretMatch = resource.match(/\/secrets\/([^/:]+)/);
2026-02-13 18:35:36 -08:00
if (secretMatch && secretMatch[1] && project) return `https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(secretMatch[1])}/versions?project=${encodeURIComponent(project)}`;
}
if (resource.includes("/functions/")) {
const fnMatch = resource.match(/\/locations\/([^/]+)\/functions\/([^/]+)/);
2026-02-13 18:35:36 -08:00
if (fnMatch && project) return `https://console.cloud.google.com/functions/details/${encodeURIComponent(fnMatch[1])}/${encodeURIComponent(fnMatch[2])}?project=${encodeURIComponent(project)}`;
}
2026-02-13 18:35:36 -08:00
if (project) return `https://console.cloud.google.com/home/dashboard?project=${encodeURIComponent(project)}`;
return null;
}
</script>
</body>
</html>