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

4306 lines
No EOL
159 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kingfisher Access Map Viewer</title>
<style>
:root {
color-scheme: dark;
--brand: #0e7c56;
--brand-dark: #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;
--code-bg: #0d1526;
--code-border: #1f2937;
--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;
--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;
}
}
.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 {
padding: 16px 20px;
background: var(--surface);
}
.am-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.am-toolbar input {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 13px;
background: var(--surface-muted);
color: var(--text-main);
}
.am-toolbar input:focus {
outline: none;
border-color: var(--brand);
}
.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;
}
/* Identity cards */
.am-card-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.id-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
transition: border-color 0.15s;
}
.id-card:hover {
border-color: var(--border-strong);
}
.id-card__header {
padding: 14px 16px;
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
user-select: none;
}
.id-card__header:hover {
background: var(--surface-muted);
}
.id-card__avatar {
width: 36px;
height: 36px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 18px;
flex-shrink: 0;
background: var(--surface-muted);
border: 1px solid var(--border);
}
.id-card__info {
flex: 1;
min-width: 0;
}
.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;
border-top: 1px solid var(--border);
padding: 16px;
background: var(--surface-muted);
}
.id-card__body.open {
display: block;
}
.id-card__section {
margin-bottom: 14px;
}
.id-card__section:last-child {
margin-bottom: 0;
}
.id-card__section-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.resource-grid {
display: grid;
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);
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;
}
.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; }
.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 {
position: relative;
background: linear-gradient(90deg, var(--code-accent), transparent), var(--code-bg);
color: var(--code-text);
padding: 16px;
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);
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;
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 {
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; }
/* 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>
<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">
<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>
<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>
<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">
<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>
<p>Credentials and what they can reach</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<button class="btn" id="copy-access-map" type="button">Copy JSON</button>
</div>
</div>
<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">
<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>
<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>
<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>
<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;
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");
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");
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");
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 || "");
});
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);
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";
const activeOnly = document.getElementById("scan-active-only");
generateScanReport(scope, { activeOnly: activeOnly && activeOnly.checked });
});
}
if (downloadAccessReportBtn) {
downloadAccessReportBtn.addEventListener("click", generateAccessMapReport);
}
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();
const parts = trimmed.split(/\r?\n(?=\s*{)/g).filter(Boolean);
if (parts.length > 1) {
try {
return JSON.parse("[" + parts.join(",") + "]");
} catch (e2) {}
}
}
return null;
}
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;
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");
const amStatsBar = document.getElementById("am-stats-bar");
if (amStatsBar) amStatsBar.classList.add("hidden");
renderAccessMapTree();
renderFindingsTable();
updateMetrics();
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) {
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);
entries.push(obj);
} catch (errLine) {
console.warn("Skipping invalid JSON line", i);
}
}
const collected = collectReportData(entries);
findings = collected.findings;
accessMap = collected.accessMap;
if (collected.mainReport || collected.statsReport) {
rawData = Object.assign({}, collected.mainReport || {}, collected.statsReport || {});
}
} else {
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";
extractScanMetadata();
setActiveView("view-dashboard");
updateMetrics();
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);
}
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();
// Target info
scanMetadata.target =
reportMeta.target ||
data.target ||
data.scan_target ||
data.repository ||
data.repo ||
(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 ||
(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),
};
}
// 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("");
}
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 };
let baseFindings =
scope === "filtered" ? getFilteredSortedFindings().slice() : (Array.isArray(findings) ? findings.slice() : []);
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;
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";
const normalizedStatus = normalizeValidationStatus(statusRaw);
const findingId = getFindingIdFromFinding(finding);
const gitUrl = getFileUrlFromFinding(finding);
const statusColor = normalizedStatus === "active" ? "#dc2626" : normalizedStatus === "inactive" ? "#f97316" : "#6b7280";
return `
<tr>
<td>${escapeHtml(rule.name || rule.id || "")}</td>
<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>';
// 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>`
: "";
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}`
: "";
// 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>`;
}
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>
<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) {
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() {
if (!Array.isArray(accessMap) || accessMap.length === 0) {
alert("No Access Map entries are available. Run a scan with --access-map first.");
return;
}
// 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>`;
}
}
// 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>
${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) {
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;
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 = "";
}
// 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 "";
}
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 = "") {
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);
renderAccessMapStats();
if (!filteredAccessMapView || filteredAccessMapView.length === 0) {
list.innerHTML =
'<div style="padding:32px 16px; text-align:center; color:var(--text-muted); font-size:13px;">No access map data available</div>';
return;
}
const frag = document.createDocumentFragment();
filteredAccessMapView.forEach((entry) => {
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));
});
});
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);
});
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);
}
const toggle = document.createElement("div");
toggle.className = "id-card__toggle";
toggle.textContent = "▶";
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);
});
chip.appendChild(chipPerms);
}
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);
});
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 ? "▶" : "▼";
});
return card;
}
function buildRenderableAccessMap(filterLower) {
if (!accessMap || accessMap.length === 0) return [];
const identities = [];
accessMap.forEach((identity) => {
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;
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;
});
if (filterLower && !identityNameMatches) {
perms.forEach((p) => {
if (String(p).toLowerCase().includes(filterLower)) anyPermMatches = true;
});
}
return { resources, filteredResources, permissions: perms };
});
const hasMatch = !filterLower || identityNameMatches || anyResourceMatches || anyPermMatches;
if (!hasMatch) return;
const viewGroups = preparedGroups.map((group) => ({
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);
}
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;
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)}` : "";
return `https://console.aws.amazon.com/lambda/home${regionQuery}#/functions/${encodeURIComponent(match[1])}`;
}
return null;
}
if (service === "ec2") {
const match = resourcePart.match(/^instance\/(.+)$/);
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)\/(.+)$/);
if (match && match[1]) return `https://console.aws.amazon.com/kms/home?#/kms/keys/${encodeURIComponent(match[1])}`;
return null;
}
if (service === "secretsmanager") return `https://console.aws.amazon.com/secretsmanager/home?#/secret?name=${encodeURIComponent(resource)}`;
if (service === "dynamodb") {
const match = resourcePart.match(/^(?:table\/(.+)|table:(.+))/);
const tableRaw = match ? match[1] || match[2] : null;
const table = tableRaw ? tableRaw.split("/")[0] : null;
if (table) {
const regionQuery = region ? `?region=${encodeURIComponent(region)}` : "";
return `https://console.aws.amazon.com/dynamodbv2/home${regionQuery}#/table/${encodeURIComponent(table)}/items`;
}
return null;
}
return null;
}
function gcpResourceConsoleLink(resource) {
if (!resource) return null;
const projectMatch = resource.match(/^projects\/([^/]+)/);
const project = projectMatch ? projectMatch[1] : null;
if (resource.includes("/buckets/")) {
const bucketMatch = resource.match(/\/buckets\/([^/]+)/);
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\/([^/]+)/);
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\/([^/:]+)/);
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\/([^/]+)/);
if (fnMatch && project) return `https://console.cloud.google.com/functions/details/${encodeURIComponent(fnMatch[1])}/${encodeURIComponent(fnMatch[2])}?project=${encodeURIComponent(project)}`;
}
if (project) return `https://console.cloud.google.com/home/dashboard?project=${encodeURIComponent(project)}`;
return null;
}
</script>
</body>
</html>