forked from mirrors/kingfisher
6643 lines
249 KiB
HTML
6643 lines
249 KiB
HTML
<!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: #00ed64;
|
||
--brand-dark: #13aa52;
|
||
--brand-soft: rgba(0, 237, 100, 0.12);
|
||
--brand-soft-strong: rgba(0, 237, 100, 0.18);
|
||
--bg: #08110e;
|
||
--surface: #0f1716;
|
||
--surface-muted: #121d1b;
|
||
--surface-strong: #15211f;
|
||
--text-main: #f5fbf7;
|
||
--text-muted: #a8bbb1;
|
||
--border: #1f332c;
|
||
--border-strong: #315244;
|
||
--shadow-sm: 0 10px 30px rgba(0, 0, 0, 0.14);
|
||
--shadow-md: 0 18px 48px rgba(0, 0, 0, 0.2);
|
||
--shadow-lg: 0 28px 72px rgba(0, 0, 0, 0.28);
|
||
--critical: #fca5a5;
|
||
--success: #34d399;
|
||
--radius: 16px;
|
||
--hover: #182522;
|
||
--table-header: #12201d;
|
||
--code-bg: #0d1617;
|
||
--code-border: #223733;
|
||
--code-border-strong: rgba(0, 237, 100, 0.24);
|
||
--code-text: #e2e8f0;
|
||
--code-accent: rgba(0, 237, 100, 0.07);
|
||
--link-strong: #8fffd0;
|
||
--link-strong-hover: #d9fff0;
|
||
--link-underline: rgba(143, 255, 208, 0.32);
|
||
}
|
||
|
||
:root[data-theme="light"] {
|
||
color-scheme: light;
|
||
--brand: #13aa52;
|
||
--brand-dark: #0f7f3d;
|
||
--brand-soft: rgba(19, 170, 82, 0.1);
|
||
--brand-soft-strong: rgba(19, 170, 82, 0.16);
|
||
--bg: #eef4f0;
|
||
--surface: #ffffff;
|
||
--surface-strong: #f5f9f7;
|
||
--surface-muted: #f8fbf9;
|
||
--text-main: #15211d;
|
||
--text-muted: #5f746a;
|
||
--border: #dbe7df;
|
||
--border-strong: #bfd5c7;
|
||
--shadow-sm: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||
--shadow-md: 0 14px 34px rgba(15, 23, 42, 0.1);
|
||
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.12);
|
||
--critical: #dc2626;
|
||
--success: #059669;
|
||
--hover: #edf5f0;
|
||
--table-header: #f5f9f7;
|
||
--code-bg: #f7fbf9;
|
||
--code-border: #dbe7df;
|
||
--code-border-strong: rgba(19, 170, 82, 0.18);
|
||
--code-text: #0f172a;
|
||
--code-accent: rgba(19, 170, 82, 0.08);
|
||
--link-strong: #0f7f3d;
|
||
--link-strong-hover: #0b5d2d;
|
||
--link-underline: rgba(15, 127, 61, 0.28);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
background:
|
||
radial-gradient(circle at top left, rgba(0, 237, 100, 0.12), transparent 28%),
|
||
radial-gradient(circle at top right, rgba(255, 255, 255, 0.06), transparent 30%),
|
||
linear-gradient(180deg, var(--bg) 0%, var(--bg) 100%);
|
||
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;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* Header */
|
||
.hero {
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), var(--surface);
|
||
color: var(--text-main);
|
||
padding: 10px 24px;
|
||
min-height: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
box-shadow: var(--shadow-sm);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 50;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.hero__brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.hero__icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
background: linear-gradient(180deg, rgba(19, 170, 82, 0.95), rgba(15, 127, 61, 0.95));
|
||
color: #ffffff;
|
||
border-radius: 12px;
|
||
display: grid;
|
||
place-items: center;
|
||
border: 1px solid rgba(19, 170, 82, 0.25);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 10px 18px rgba(15, 23, 42, 0.12);
|
||
font-weight: 700;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.hero__title {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.hero__subtitle {
|
||
font-size: 13px;
|
||
color: var(--text-muted);
|
||
margin-left: 12px;
|
||
padding-left: 12px;
|
||
border-left: 1px solid var(--border);
|
||
}
|
||
|
||
.layout {
|
||
display: grid;
|
||
grid-template-columns: 124px 1fr;
|
||
gap: 20px;
|
||
align-items: start;
|
||
}
|
||
|
||
.sidebar {
|
||
background: linear-gradient(180deg, #0b1832 0%, #0e2143 100%);
|
||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||
border-radius: 20px;
|
||
box-shadow: var(--shadow-md);
|
||
padding: 14px 10px;
|
||
position: sticky;
|
||
top: 84px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 18px;
|
||
}
|
||
|
||
.sidebar-section-label {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar-nav {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
width: 100%;
|
||
align-items: center;
|
||
}
|
||
|
||
.nav-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
width: 100%;
|
||
align-items: center;
|
||
}
|
||
|
||
.nav-button {
|
||
width: 100%;
|
||
text-align: center;
|
||
padding: 10px 6px 9px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||
background: transparent;
|
||
color: rgba(255, 255, 255, 0.84);
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
box-shadow: none;
|
||
transition: border 0.15s ease, background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
|
||
}
|
||
|
||
.nav-button:hover {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border-color: rgba(255, 255, 255, 0.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.nav-button.active {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border-color: rgba(143, 255, 208, 0.28);
|
||
color: #ffffff;
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
:root[data-theme="light"] .nav-button.active {
|
||
color: #ffffff;
|
||
}
|
||
|
||
.nav-icon {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 10px;
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 15px;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
opacity: 0.95;
|
||
}
|
||
|
||
.nav-icon svg {
|
||
width: 18px;
|
||
height: 18px;
|
||
stroke: currentColor;
|
||
fill: none;
|
||
stroke-width: 1.8;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
|
||
.nav-button__content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
min-width: 0;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.nav-button__title {
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
color: inherit;
|
||
line-height: 1.2;
|
||
text-align: center;
|
||
max-width: 100%;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.nav-button__sub {
|
||
display: none;
|
||
}
|
||
|
||
.view {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
}
|
||
|
||
.view-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.workspace-topbar {
|
||
display: grid;
|
||
grid-template-columns: minmax(320px, 1.1fr) minmax(420px, 1fr);
|
||
gap: 18px;
|
||
align-items: start;
|
||
}
|
||
|
||
.workspace-overview,
|
||
.workspace-actions {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.workspace-overview {
|
||
padding: 18px 20px;
|
||
}
|
||
|
||
.workspace-overview__eyebrow {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.workspace-overview__header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.workspace-overview__title {
|
||
font-size: 20px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.03em;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.workspace-overview__meta {
|
||
margin-top: 6px;
|
||
font-size: 13px;
|
||
color: var(--text-muted);
|
||
max-width: 680px;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.workspace-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
background: var(--surface-muted);
|
||
border: 1px solid var(--border);
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
color: var(--text-main);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.workspace-overview__stats {
|
||
margin-top: 14px;
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.workspace-overview__stat {
|
||
padding: 12px 14px;
|
||
border-radius: 14px;
|
||
background: var(--surface-muted);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.workspace-overview__value {
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.04em;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.workspace-overview__label {
|
||
margin-top: 3px;
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--text-muted);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.workspace-actions {
|
||
padding: 14px;
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.sidebar {
|
||
position: relative;
|
||
top: auto;
|
||
flex-direction: row;
|
||
justify-content: flex-start;
|
||
padding: 10px;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
border-radius: 16px;
|
||
}
|
||
.sidebar-nav,
|
||
.nav-group {
|
||
flex-direction: row;
|
||
width: max-content;
|
||
}
|
||
.nav-button {
|
||
min-width: 112px;
|
||
max-width: 112px;
|
||
}
|
||
.workspace-topbar {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.workspace-actions {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.info-banner {
|
||
background: linear-gradient(180deg, rgba(0, 237, 100, 0.08), transparent), var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
padding: 11px 16px;
|
||
font-size: 13px;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
|
||
.page {
|
||
max-width: 1600px;
|
||
margin: 20px auto 32px;
|
||
padding: 0 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
}
|
||
|
||
.panel {
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 40%), var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-md);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.panel__header {
|
||
padding: 18px 22px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent), var(--surface-strong);
|
||
}
|
||
|
||
.panel__title h3 { margin: 0; font-size: 17px; font-weight: 700; letter-spacing: -0.02em; }
|
||
.panel__title p { margin: 5px 0 0; font-size: 13px; color: var(--text-muted); }
|
||
|
||
/* Upload */
|
||
.upload-shell {
|
||
padding: 22px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.upload-area {
|
||
padding: 44px 30px 36px;
|
||
text-align: center;
|
||
background:
|
||
radial-gradient(circle at top center, rgba(0, 237, 100, 0.12), transparent 34%),
|
||
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent),
|
||
var(--surface-muted);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
border: 1px dashed var(--border-strong);
|
||
border-radius: var(--radius);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||
}
|
||
.upload-area:hover,
|
||
.upload-area.active {
|
||
background:
|
||
radial-gradient(circle at top center, rgba(0, 237, 100, 0.18), transparent 34%),
|
||
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent),
|
||
var(--surface-muted);
|
||
border-color: var(--brand);
|
||
transform: translateY(-1px);
|
||
}
|
||
.upload-icon {
|
||
width: 68px;
|
||
height: 68px;
|
||
margin: 0 auto 16px;
|
||
border-radius: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 34px;
|
||
background: linear-gradient(180deg, rgba(0, 237, 100, 0.18), rgba(255, 255, 255, 0.02));
|
||
border: 1px solid rgba(0, 237, 100, 0.22);
|
||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.18);
|
||
opacity: 1;
|
||
}
|
||
.upload-text {
|
||
font-size: 28px;
|
||
line-height: 1.2;
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
color: var(--text-main);
|
||
max-width: 420px;
|
||
margin: 0 auto;
|
||
}
|
||
.upload-sub {
|
||
color: var(--text-muted);
|
||
margin-top: 8px;
|
||
font-size: 15px;
|
||
line-height: 1.5;
|
||
max-width: 560px;
|
||
margin-left: auto;
|
||
margin-right: auto;
|
||
}
|
||
.upload-action-row {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
margin-top: 20px;
|
||
}
|
||
.upload-primary {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 10px 16px;
|
||
border-radius: 999px;
|
||
background: linear-gradient(180deg, var(--brand), var(--brand-dark));
|
||
color: #fff;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.01em;
|
||
box-shadow: 0 14px 28px rgba(0, 237, 100, 0.18);
|
||
}
|
||
.upload-action-hint {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
.upload-help-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
text-align: left;
|
||
}
|
||
.upload-help-card {
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
padding: 14px 15px 13px;
|
||
box-shadow: var(--shadow-sm);
|
||
min-height: 0;
|
||
}
|
||
.upload-help-label {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--brand-dark);
|
||
margin-bottom: 6px;
|
||
}
|
||
.upload-help-text {
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
color: var(--text-muted);
|
||
}
|
||
.upload-help-text strong {
|
||
color: var(--text-main);
|
||
font-weight: 700;
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.upload-help-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* Metrics */
|
||
.metrics-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
padding: 22px;
|
||
gap: 18px;
|
||
}
|
||
|
||
.metric {
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 42%), var(--surface-muted);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 18px 18px 20px;
|
||
box-shadow: var(--shadow-sm);
|
||
position: relative;
|
||
overflow: hidden;
|
||
min-height: 112px;
|
||
}
|
||
|
||
.metric::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0 auto auto 0;
|
||
width: 100%;
|
||
height: 3px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.metric__label {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.metric__value {
|
||
font-size: 34px;
|
||
font-weight: 700;
|
||
margin-top: 10px;
|
||
letter-spacing: -0.04em;
|
||
}
|
||
.metric.critical::before { background: #fb7185; }
|
||
.metric.success::before { background: var(--brand); }
|
||
.metric:not(.critical):not(.success)::before { background: rgba(0, 237, 100, 0.38); }
|
||
.metric.critical .metric__label,
|
||
.metric.success .metric__label {
|
||
color: var(--text-main);
|
||
}
|
||
.metric.critical .metric__value { color: var(--critical); }
|
||
.metric.success .metric__value { color: var(--success); }
|
||
|
||
/* Access Map */
|
||
.am-container {
|
||
padding: 18px 22px 22px;
|
||
background: var(--surface);
|
||
}
|
||
|
||
.am-toolbar {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
padding: 14px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
margin-bottom: 18px;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface-muted);
|
||
}
|
||
|
||
.am-toolbar input {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--border);
|
||
font-size: 13px;
|
||
background: var(--surface);
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.am-toolbar input:focus {
|
||
outline: none;
|
||
border-color: var(--brand);
|
||
}
|
||
|
||
.am-stats {
|
||
display: flex;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
padding: 14px 16px;
|
||
background: linear-gradient(180deg, rgba(0, 237, 100, 0.06), transparent), var(--surface-muted);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
margin-bottom: 16px;
|
||
font-size: 13px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.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: 14px 16px;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface-muted);
|
||
border: 1px dashed var(--border);
|
||
border-radius: 12px;
|
||
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: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 44%), var(--surface);
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow-sm);
|
||
transition: border-color 0.15s, transform 0.12s ease, box-shadow 0.15s ease;
|
||
}
|
||
|
||
.id-card:hover {
|
||
border-color: var(--border-strong);
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
|
||
.id-card__header {
|
||
padding: 16px 18px;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.id-card__header:hover {
|
||
background: rgba(255, 255, 255, 0.02);
|
||
}
|
||
|
||
.id-card__avatar {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 12px;
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 18px;
|
||
flex-shrink: 0;
|
||
background: linear-gradient(180deg, rgba(0, 237, 100, 0.16), rgba(255, 255, 255, 0.02));
|
||
border: 1px solid rgba(0, 237, 100, 0.16);
|
||
}
|
||
|
||
.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: 18px;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent), 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: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
background: var(--surface);
|
||
font-size: 12px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.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: 10px 16px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
background: rgba(255, 255, 255, 0.02);
|
||
}
|
||
|
||
.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: 4px 9px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.01em;
|
||
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: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 18px;
|
||
box-shadow: var(--shadow-sm);
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
min-width: 0;
|
||
min-height: 220px;
|
||
overflow: hidden;
|
||
transition: transform 0.12s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
|
||
.chart-card > canvas {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.chart-card:hover {
|
||
transform: translateY(-1px);
|
||
border-color: var(--border-strong);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
|
||
.chart-legend {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: var(--text-main);
|
||
min-width: 0;
|
||
flex: 1 1 160px;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.chart-legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-weight: 600;
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.legend-swatch {
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
#status-chart,
|
||
#source-chart,
|
||
canvas[id^="source-detector-chart-"] {
|
||
background: radial-gradient(circle at center, rgba(0, 237, 100, 0.05), transparent 58%), var(--surface-muted);
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.dashboard-breakdown {
|
||
padding: 0 20px 20px;
|
||
}
|
||
.breakdown-card {
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-sm);
|
||
overflow: hidden;
|
||
}
|
||
.breakdown-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 16px 18px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent), var(--surface-strong);
|
||
}
|
||
.breakdown-title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--text-main);
|
||
}
|
||
.breakdown-sub {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
margin-top: 3px;
|
||
}
|
||
.breakdown-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.breakdown-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
color: var(--text-main);
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.breakdown-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
.breakdown-table th,
|
||
.breakdown-table td {
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
text-align: left;
|
||
vertical-align: top;
|
||
}
|
||
.breakdown-table th {
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--text-muted);
|
||
font-weight: 700;
|
||
background: var(--table-header);
|
||
}
|
||
.breakdown-sort-btn {
|
||
border: 0;
|
||
background: transparent;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: inherit;
|
||
font: inherit;
|
||
text-transform: inherit;
|
||
letter-spacing: inherit;
|
||
font-weight: inherit;
|
||
cursor: pointer;
|
||
}
|
||
.breakdown-sort-indicator {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
min-width: 10px;
|
||
text-align: center;
|
||
}
|
||
.breakdown-table tr:last-child td {
|
||
border-bottom: 0;
|
||
}
|
||
.breakdown-name {
|
||
font-weight: 700;
|
||
color: var(--text-main);
|
||
word-break: break-word;
|
||
}
|
||
.breakdown-count {
|
||
font-weight: 700;
|
||
color: var(--text-main);
|
||
white-space: nowrap;
|
||
}
|
||
.breakdown-empty {
|
||
padding: 24px 16px;
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* Findings table */
|
||
.table-container {
|
||
width: 100%;
|
||
overflow-x: auto;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.01), transparent), var(--surface);
|
||
}
|
||
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
.table th {
|
||
text-align: left;
|
||
padding: 13px 16px;
|
||
background: var(--table-header);
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--text-muted);
|
||
font-weight: 700;
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1;
|
||
}
|
||
.table td {
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
vertical-align: top;
|
||
}
|
||
.table tr:last-child td { border-bottom: none; }
|
||
.table tr:hover td {
|
||
background: linear-gradient(180deg, rgba(0, 237, 100, 0.06), transparent), 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;
|
||
}
|
||
|
||
/* Kingfisher enrichment callout - brand-colored so its origin is unmistakable */
|
||
.kf-enrichment-box {
|
||
margin-top: 16px;
|
||
padding: 14px 16px;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--brand);
|
||
background: var(--brand-soft);
|
||
}
|
||
.kf-enrichment-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.kf-enrichment-badge {
|
||
display: inline-block;
|
||
padding: 3px 10px;
|
||
border-radius: 999px;
|
||
background: linear-gradient(180deg, var(--brand), var(--brand-dark));
|
||
color: #ffffff;
|
||
font-weight: 700;
|
||
font-size: 11px;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
}
|
||
.kf-enrichment-strength {
|
||
font-size: 11px;
|
||
color: var(--brand-dark);
|
||
}
|
||
.kf-enrichment-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin: 6px 0 10px;
|
||
}
|
||
.kf-enrichment-row > label {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--brand-dark);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
#fd-kf-validation-res {
|
||
white-space: pre-wrap;
|
||
font-size: 12px;
|
||
color: var(--brand-dark);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||
background: rgba(255, 255, 255, 0.55);
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
margin-bottom: 10px;
|
||
}
|
||
#fd-kf-validate-wrap label,
|
||
#fd-kf-revoke-wrap label {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--brand-dark);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
}
|
||
#fd-kf-validate-wrap,
|
||
#fd-kf-revoke-wrap {
|
||
margin-bottom: 10px;
|
||
}
|
||
#fd-kf-validate-cmd,
|
||
#fd-kf-revoke-cmd {
|
||
flex: 1;
|
||
font-size: 12px;
|
||
color: var(--brand-dark);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||
background: rgba(255, 255, 255, 0.55);
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
word-break: break-all;
|
||
white-space: pre-wrap;
|
||
}
|
||
.kf-enrichment-footnote {
|
||
margin-top: 10px;
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
}
|
||
.chip-enriched {
|
||
display: inline-block;
|
||
background: var(--brand-soft);
|
||
color: var(--brand-dark);
|
||
border: 1px solid var(--brand);
|
||
padding: 1px 7px;
|
||
border-radius: 999px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
margin-left: 6px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
/* 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: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
width: 220px;
|
||
font-size: 13px;
|
||
background: var(--surface);
|
||
color: var(--text-main);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||
}
|
||
|
||
.rows-label {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.rows-select {
|
||
padding: 8px 10px;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
font-size: 13px;
|
||
color: var(--text-main);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||
}
|
||
|
||
.pager {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.pager-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
display: grid;
|
||
place-items: center;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
color: var(--text-main);
|
||
transition: background 0.12s ease, border-color 0.12s ease, transform 0.12s ease;
|
||
}
|
||
|
||
.pager-btn:hover:not(:disabled) {
|
||
background: var(--hover);
|
||
border-color: var(--border-strong);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.pager-btn:disabled {
|
||
opacity: 0.4;
|
||
cursor: default;
|
||
}
|
||
|
||
/* Buttons & loader */
|
||
.btn {
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent), var(--surface);
|
||
border: 1px solid var(--border);
|
||
padding: 9px 16px;
|
||
border-radius: 10px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
color: var(--text-main);
|
||
box-shadow: var(--shadow-sm);
|
||
transition: background 0.1s, border-color 0.1s, transform 0.1s ease, box-shadow 0.1s ease;
|
||
}
|
||
.btn:hover {
|
||
background: var(--surface-muted);
|
||
border-color: var(--border-strong);
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
#theme-toggle {
|
||
background: var(--surface-muted);
|
||
color: var(--text-main);
|
||
border-color: var(--border);
|
||
font-weight: 700;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
#theme-toggle:hover {
|
||
background: var(--hover);
|
||
border-color: var(--border-strong);
|
||
color: var(--text-main);
|
||
}
|
||
|
||
:root[data-theme="light"] #theme-toggle {
|
||
background: #ffffff;
|
||
color: var(--brand-dark);
|
||
border-color: var(--border-strong);
|
||
}
|
||
|
||
:root[data-theme="light"] #theme-toggle:hover {
|
||
background: var(--surface-strong);
|
||
border-color: var(--brand-soft-strong);
|
||
}
|
||
|
||
#reset-btn {
|
||
background: linear-gradient(180deg, var(--brand), var(--brand-dark));
|
||
border-color: transparent;
|
||
color: #04210f;
|
||
font-weight: 800;
|
||
box-shadow: 0 16px 30px rgba(0, 237, 100, 0.2);
|
||
}
|
||
|
||
#reset-btn:hover {
|
||
background: linear-gradient(180deg, #34f08a, var(--brand));
|
||
border-color: transparent;
|
||
color: #04210f;
|
||
}
|
||
|
||
.hidden { display: none !important; }
|
||
|
||
.btn:focus-visible,
|
||
.nav-button:focus-visible,
|
||
.search-input:focus-visible,
|
||
.rows-select:focus-visible,
|
||
.am-toolbar input:focus-visible,
|
||
.pager-btn:focus-visible {
|
||
outline: none;
|
||
border-color: var(--brand);
|
||
box-shadow: 0 0 0 3px rgba(0, 237, 100, 0.18);
|
||
}
|
||
|
||
/* 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: 16px 18px;
|
||
background: linear-gradient(180deg, rgba(0, 237, 100, 0.08), transparent), var(--surface-muted);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
font-size: 13px;
|
||
color: var(--text-muted);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.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: 8px;
|
||
padding: 12px 13px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent), var(--surface-muted);
|
||
margin-top: 0;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.workspace-actions .report-type-group {
|
||
height: 100%;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.report-type-group + .report-type-group {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.report-type-group label {
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.sidebar-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.sidebar-check {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
font-size: 12px;
|
||
text-transform: none !important;
|
||
letter-spacing: normal !important;
|
||
color: var(--text-main) !important;
|
||
cursor: pointer;
|
||
font-weight: 500 !important;
|
||
}
|
||
|
||
.sidebar-help {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
line-height: 1.45;
|
||
}
|
||
|
||
#import-note,
|
||
#fd-import-note {
|
||
box-shadow: var(--shadow-sm);
|
||
border-radius: 14px !important;
|
||
}
|
||
|
||
.loading-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(15, 23, 42, 0.2);
|
||
backdrop-filter: blur(2px);
|
||
z-index: 100;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
|
||
.spinner {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 999px;
|
||
border: 3px solid var(--border);
|
||
border-top-color: #0e7c56;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
.progress-track {
|
||
width: 220px;
|
||
height: 6px;
|
||
border-radius: 999px;
|
||
background: var(--border);
|
||
overflow: hidden;
|
||
margin: 12px auto 0;
|
||
}
|
||
.progress-inner {
|
||
width: 40%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #0e7c56, #22c55e, #f59e0b);
|
||
animation: progress-move 0.9s linear infinite;
|
||
}
|
||
@keyframes progress-move {
|
||
0% { transform: translateX(-100%); }
|
||
50% { transform: translateX(0%); }
|
||
100% { transform: translateX(100%); }
|
||
}
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="loader" class="loading-overlay hidden">
|
||
<div style="background:var(--surface); padding:18px 22px; border-radius:10px; border:1px solid var(--border); box-shadow:var(--shadow-md); text-align:center; min-width:260px; color:var(--text-main);">
|
||
<div class="spinner" style="margin:0 auto 12px;"></div>
|
||
<div class="progress-track"><div class="progress-inner"></div></div>
|
||
<div id="loader-text" style="margin-top:10px; font-size:13px; color:var(--text-muted);">
|
||
Processing report...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<header class="hero">
|
||
<div class="hero__brand">
|
||
<div class="hero__icon">K</div>
|
||
<div style="display:flex; align-items:center;">
|
||
<span class="hero__title">Kingfisher Report Viewer</span>
|
||
<span class="hero__subtitle">Access Map & Findings</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; justify-content:flex-end;">
|
||
<a class="btn" href="https://github.com/mongodb/kingfisher" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||
<button class="btn" id="theme-toggle" type="button">Light Mode</button>
|
||
<button class="btn" id="reset-btn" type="button">Clear and Load New Report(s)</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="info-banner">Runs entirely in your browser. Reports stay local and are never uploaded.</div>
|
||
|
||
<main class="page">
|
||
<section id="upload-section" class="panel" style="max-width: 920px; margin: 0 auto;">
|
||
<div class="panel__header">
|
||
<div class="panel__title">
|
||
<h3>Load Report</h3>
|
||
<p>Analyze Kingfisher JSON / JSONL output, plus Gitleaks and TruffleHog JSON reports</p>
|
||
</div>
|
||
</div>
|
||
<div class="upload-shell">
|
||
<div class="upload-area" id="drop-zone">
|
||
<div class="upload-icon">📄</div>
|
||
<div class="upload-text">Load one or more reports</div>
|
||
<div class="upload-sub">Drop files anywhere in this card, or click to choose them from disk.</div>
|
||
<div class="upload-action-row">
|
||
<span class="upload-primary">Choose Reports</span>
|
||
<span class="upload-action-hint">JSON and JSONL only</span>
|
||
</div>
|
||
<input type="file" id="file-input" hidden accept=".json,.jsonl" multiple>
|
||
<div class="upload-options" style="margin-top:14px; display:flex; justify-content:center; font-size:12px; color:var(--text-muted);">
|
||
<label id="dedup-toggle-label" style="display:inline-flex; align-items:center; gap:6px; cursor:pointer;">
|
||
<input type="checkbox" id="dedup-toggle" checked>
|
||
<span>Deduplicate findings (remove same-tool fingerprint repeats)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="upload-help-grid">
|
||
<div class="upload-help-card">
|
||
<div class="upload-help-label">Formats</div>
|
||
<div class="upload-help-text"><strong>Kingfisher</strong> JSON/JSONL, <strong>Gitleaks</strong> OSS JSON, and <strong>TruffleHog</strong> OSS JSON/JSONL.</div>
|
||
</div>
|
||
<div class="upload-help-card">
|
||
<div class="upload-help-label">Merge More</div>
|
||
<div class="upload-help-text">Choose multiple files at once, or drag in more report files after loading to merge them into the same view.</div>
|
||
</div>
|
||
<div class="upload-help-card">
|
||
<div class="upload-help-label">Deduplication</div>
|
||
<div class="upload-help-text"><strong>Kingfisher</strong> reports are already deduplicated. For imported <strong>Gitleaks</strong> and <strong>TruffleHog</strong> reports, the viewer attempts deduplication (by default) by finding fingerprint: native fingerprints when present, otherwise a synthetic fingerprint built from the tool, detector, and secret identity, with path/line/snippet fallback when needed.</div>
|
||
</div>
|
||
<div class="upload-help-card">
|
||
<div class="upload-help-label">Privacy</div>
|
||
<div class="upload-help-text">Everything stays in your browser. Files are not uploaded anywhere.</div>
|
||
</div>
|
||
<div class="upload-help-card">
|
||
<div class="upload-help-label">Attribution</div>
|
||
<div class="upload-help-text">Reads JSON output from the open-source TruffleHog and Gitleaks CLIs. Kingfisher is not affiliated with or endorsed by Truffle Security Co. or the Gitleaks project. TruffleHog and Gitleaks are trademarks of their respective owners.</div>
|
||
</div>
|
||
</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="sidebar-nav">
|
||
<div class="sidebar-section-label">Navigate</div>
|
||
<div class="nav-group">
|
||
<button class="nav-button active" data-view-target="view-dashboard" title="Dashboard">
|
||
<span class="nav-icon" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24">
|
||
<path d="M4 19h16"></path>
|
||
<path d="M7 16V10"></path>
|
||
<path d="M12 16V6"></path>
|
||
<path d="M17 16v-4"></path>
|
||
</svg>
|
||
</span>
|
||
<span class="nav-button__content">
|
||
<span class="nav-button__title">Dashboard</span>
|
||
<span class="nav-button__sub">Overview and report mix</span>
|
||
</span>
|
||
</button>
|
||
<button class="nav-button" data-view-target="view-access" title="Access Map">
|
||
<span class="nav-icon" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24">
|
||
<circle cx="12" cy="12" r="8"></circle>
|
||
<path d="M4 12h16"></path>
|
||
<path d="M12 4a12 12 0 0 1 0 16"></path>
|
||
<path d="M12 4a12 12 0 0 0 0 16"></path>
|
||
</svg>
|
||
</span>
|
||
<span class="nav-button__content">
|
||
<span class="nav-button__title">Access Map</span>
|
||
<span class="nav-button__sub">Identity reach and permissions</span>
|
||
</span>
|
||
</button>
|
||
<button class="nav-button" data-view-target="view-findings" title="Findings">
|
||
<span class="nav-icon" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24">
|
||
<path d="M8 3h6l4 4v14H8z"></path>
|
||
<path d="M14 3v5h4"></path>
|
||
<path d="M10 12h6"></path>
|
||
<path d="M10 16h6"></path>
|
||
</svg>
|
||
</span>
|
||
<span class="nav-button__content">
|
||
<span class="nav-button__title">Findings</span>
|
||
<span class="nav-button__sub">Browse detections and details</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="view-stack">
|
||
<section class="workspace-topbar">
|
||
<div class="workspace-overview">
|
||
<div class="workspace-overview__eyebrow">Report overview</div>
|
||
<div class="workspace-overview__header">
|
||
<div>
|
||
<div class="workspace-overview__title" id="sidebar-summary-source">No report loaded</div>
|
||
<div class="workspace-overview__meta" id="sidebar-summary-target">Load a report to explore findings and blast radius.</div>
|
||
</div>
|
||
<div class="workspace-pill">Access map: <span id="sidebar-summary-access">No</span></div>
|
||
</div>
|
||
<div class="workspace-overview__stats">
|
||
<div class="workspace-overview__stat">
|
||
<div class="workspace-overview__value" id="sidebar-summary-total">0</div>
|
||
<div class="workspace-overview__label">Findings</div>
|
||
</div>
|
||
<div class="workspace-overview__stat">
|
||
<div class="workspace-overview__value" id="sidebar-summary-active">0</div>
|
||
<div class="workspace-overview__label">Active</div>
|
||
</div>
|
||
<div class="workspace-overview__stat">
|
||
<div class="workspace-overview__value" id="sidebar-summary-identities">0</div>
|
||
<div class="workspace-overview__label">Identities</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="workspace-actions">
|
||
<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 class="sidebar-check">
|
||
<input type="checkbox" id="risk-active-only"> Active credentials only
|
||
</label>
|
||
<div class="sidebar-help">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 class="sidebar-check">
|
||
<input type="checkbox" id="scan-active-only"> Active credentials only
|
||
</label>
|
||
<div class="sidebar-help">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>
|
||
</div>
|
||
</section>
|
||
|
||
<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-source-wrap" style="display:none;"><strong>Source:</strong> <span id="meta-source">—</span></div>
|
||
<div class="scan-meta-item" id="meta-version-wrap" style="display:none;"><strong>Version:</strong> <span id="meta-version">—</span></div>
|
||
</div>
|
||
<div id="import-note" class="hidden" style="margin-bottom:18px; padding:14px 16px; border-radius:10px; border:1px solid #fcd34d; background:#fffbeb; color:#92400e; font-size:13px; line-height:1.6;"></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 class="metric hidden" id="stat-enriched-card" title="Imported findings that match a Kingfisher finding on commit + file + line">
|
||
<div class="metric__label">Enriched by Kingfisher</div>
|
||
<div class="metric__value" id="stat-enriched">0</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel hidden" id="dedup-panel">
|
||
<div class="panel__header">
|
||
<div class="panel__title">
|
||
<h3>Duplicates Removed by Tool</h3>
|
||
<p id="dedup-info">Same-tool fingerprint matches removed by the viewer. Uncheck “Deduplicate findings” on upload to keep every emitted row.</p>
|
||
</div>
|
||
</div>
|
||
<div class="metrics-grid">
|
||
<div class="metric" id="dedup-card-kingfisher">
|
||
<div class="metric__label">Kingfisher duplicates removed</div>
|
||
<div class="metric__value" id="dedup-count-kingfisher">0</div>
|
||
</div>
|
||
<div class="metric" id="dedup-card-trufflehog">
|
||
<div class="metric__label">TruffleHog duplicates removed</div>
|
||
<div class="metric__value" id="dedup-count-trufflehog">0</div>
|
||
</div>
|
||
<div class="metric" id="dedup-card-gitleaks">
|
||
<div class="metric__label">Gitleaks duplicates removed</div>
|
||
<div class="metric__value" id="dedup-count-gitleaks">0</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel__header">
|
||
<div class="panel__title">
|
||
<h3>Finding Status Distribution</h3>
|
||
<p>Validation state and report source mix</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 class="chart-card">
|
||
<canvas id="source-chart" width="260" height="220"></canvas>
|
||
<div class="chart-legend" id="source-legend">
|
||
<!-- filled by JS -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section class="panel">
|
||
<div class="panel__header">
|
||
<div class="panel__title">
|
||
<h3>Detector Families</h3>
|
||
<p>Collapsed Kingfisher rule families plus imported detector names</p>
|
||
</div>
|
||
</div>
|
||
<div class="dashboard-breakdown" style="margin-bottom: 18px;">
|
||
<div class="breakdown-card">
|
||
<div class="breakdown-header">
|
||
<div>
|
||
<div class="breakdown-title">Detector distribution by source</div>
|
||
<div class="breakdown-sub">Top detector families for each loaded tool, grouped into a chart per source.</div>
|
||
</div>
|
||
</div>
|
||
<div class="chart-grid" id="source-detector-charts">
|
||
<!-- filled by JS -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="dashboard-breakdown">
|
||
<div class="breakdown-card">
|
||
<div class="breakdown-header">
|
||
<div>
|
||
<div class="breakdown-title">Most frequent findings</div>
|
||
<div class="breakdown-sub">Use this to spot the noisiest rule families and imported detectors quickly.</div>
|
||
</div>
|
||
<div class="breakdown-controls">
|
||
<div class="pager">
|
||
<button id="detector-breakdown-prev" class="pager-btn" type="button">‹</button>
|
||
<span id="detector-breakdown-page-info">0 of 0</span>
|
||
<button id="detector-breakdown-next" class="pager-btn" type="button">›</button>
|
||
</div>
|
||
<label class="breakdown-toggle" for="detector-breakdown-active-only">
|
||
<input id="detector-breakdown-active-only" type="checkbox">
|
||
<span>Active only</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="detector-breakdown"></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="source-filter" class="rows-select">
|
||
<option value="all" selected>All sources</option>
|
||
<option value="kingfisher">Kingfisher</option>
|
||
<option value="gitleaks">Gitleaks</option>
|
||
<option value="trufflehog">TruffleHog</option>
|
||
</select>
|
||
<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>
|
||
<option value="canary">Canary Token (Skipped)</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">‹</button>
|
||
<span id="page-info">0 of 0</span>
|
||
<button id="page-next" class="pager-btn" type="button">›</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="source">
|
||
Source <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>Report Source</label>
|
||
<div id="fd-source"></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-import-note" class="hidden" style="margin:14px 0 0; padding:12px 14px; border-radius:8px; border:1px solid #fcd34d; background:#fffbeb; color:#92400e; font-size:12px; line-height:1.6;"></div>
|
||
|
||
<div id="fd-kf-enrichment" class="hidden kf-enrichment-box">
|
||
<div class="kf-enrichment-header">
|
||
<span class="kf-enrichment-badge">Enriched by Kingfisher</span>
|
||
<span id="fd-kf-enrichment-strength" class="kf-enrichment-strength"></span>
|
||
</div>
|
||
<div class="kf-enrichment-row">
|
||
<label>Kingfisher Validation</label>
|
||
<span id="fd-kf-validation"></span>
|
||
</div>
|
||
<pre id="fd-kf-validation-res" class="hidden"></pre>
|
||
<div id="fd-kf-validate-wrap" class="hidden">
|
||
<label>Validate with Kingfisher</label>
|
||
<div class="cmd-container">
|
||
<code id="fd-kf-validate-cmd"></code>
|
||
<button class="copy-btn" id="fd-kf-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-kf-revoke-wrap" class="hidden">
|
||
<label>Revoke</label>
|
||
<div class="cmd-container">
|
||
<code id="fd-kf-revoke-cmd"></code>
|
||
<button class="copy-btn" id="fd-kf-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 class="kf-enrichment-footnote">
|
||
Matched on <span id="fd-kf-match-key"></span> · Kingfisher rule <span id="fd-kf-rule"></span>
|
||
</div>
|
||
</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 duplicatesRemoved = { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 };
|
||
let filteredAccessMapView = [];
|
||
let currentFilter = "";
|
||
let validationFilter = "all";
|
||
let sourceFilter = "all";
|
||
let pageSize = 10;
|
||
let currentPage = 1;
|
||
let sortField = "rule";
|
||
let sortDirection = "asc";
|
||
let detectorBreakdownSortField = "count";
|
||
let detectorBreakdownSortDirection = "desc";
|
||
let detectorBreakdownActiveOnly = false;
|
||
let detectorBreakdownPage = 1;
|
||
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 sourceDetectorCharts = document.getElementById("source-detector-charts");
|
||
const detectorBreakdownActiveOnlyToggle = document.getElementById("detector-breakdown-active-only");
|
||
const detectorBreakdownPrev = document.getElementById("detector-breakdown-prev");
|
||
const detectorBreakdownNext = document.getElementById("detector-breakdown-next");
|
||
const detectorBreakdownPageInfo = document.getElementById("detector-breakdown-page-info");
|
||
|
||
const searchInput = document.getElementById("search-input");
|
||
const sourceSelect = document.getElementById("source-filter");
|
||
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 sourceChartCanvas = document.getElementById("source-chart");
|
||
const sourceLegend = document.getElementById("source-legend");
|
||
const detectorBreakdown = document.getElementById("detector-breakdown");
|
||
const viewRegistry = {
|
||
"view-dashboard": document.getElementById("view-dashboard"),
|
||
"view-access": document.getElementById("view-access"),
|
||
"view-findings": document.getElementById("view-findings"),
|
||
};
|
||
|
||
const THEME_KEY = "viewer-theme";
|
||
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||
|
||
function getStoredThemePreference() {
|
||
const stored = localStorage.getItem(THEME_KEY);
|
||
return stored === "light" || stored === "dark" ? stored : null;
|
||
}
|
||
|
||
function getPreferredTheme() {
|
||
const stored = getStoredThemePreference();
|
||
if (stored) return stored;
|
||
return systemThemeQuery.matches ? "light" : "dark";
|
||
}
|
||
|
||
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";
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function rerenderThemeSensitiveViews() {
|
||
if (!hasLoadedReportData()) return;
|
||
const validationCounts = calculateValidationCounts();
|
||
renderStatusChart(validationCounts);
|
||
renderSourceChart(calculateSourceCounts());
|
||
renderSourceDetectorCharts();
|
||
}
|
||
|
||
setTheme(getPreferredTheme());
|
||
|
||
systemThemeQuery.addEventListener("change", () => {
|
||
if (getStoredThemePreference()) return;
|
||
setTheme(getPreferredTheme());
|
||
rerenderThemeSensitiveViews();
|
||
});
|
||
|
||
// Dedup toggle: checked means "deduplicate findings" (default ON).
|
||
// localStorage stores "0" only when the user has explicitly turned dedup off.
|
||
const DEDUP_PREF_KEY = "viewer-dedup";
|
||
const dedupToggle = document.getElementById("dedup-toggle");
|
||
const dedupToggleLabel = document.getElementById("dedup-toggle-label");
|
||
if (dedupToggle) {
|
||
try {
|
||
if (localStorage.getItem(DEDUP_PREF_KEY) === "0") {
|
||
dedupToggle.checked = false;
|
||
}
|
||
} catch (e) { /* localStorage unavailable; ignore */ }
|
||
dedupToggle.addEventListener("change", () => {
|
||
try {
|
||
if (dedupToggle.checked) localStorage.removeItem(DEDUP_PREF_KEY);
|
||
else localStorage.setItem(DEDUP_PREF_KEY, "0");
|
||
} catch (e) { /* ignore */ }
|
||
});
|
||
// Prevent clicks on the toggle from bubbling to the drop-zone file picker.
|
||
const stopBubble = (e) => e.stopPropagation();
|
||
dedupToggle.addEventListener("click", stopBubble);
|
||
if (dedupToggleLabel) dedupToggleLabel.addEventListener("click", stopBubble);
|
||
}
|
||
|
||
function hasLoadedReportData() {
|
||
return findings.length > 0 || accessMap.length > 0 || rawData !== null;
|
||
}
|
||
|
||
function openReplaceFilePicker() {
|
||
fileInput.value = "";
|
||
fileInput.click();
|
||
}
|
||
|
||
dropZone.addEventListener("click", () => openReplaceFilePicker());
|
||
fileInput.addEventListener("change", (e) => {
|
||
if (e.target.files.length) {
|
||
processFiles(Array.from(e.target.files), { append: false });
|
||
}
|
||
});
|
||
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) {
|
||
processFiles(Array.from(e.dataTransfer.files), { append: hasLoadedReportData() });
|
||
}
|
||
});
|
||
|
||
navButtons.forEach((btn) => {
|
||
btn.addEventListener("click", () => setActiveView(btn.dataset.viewTarget));
|
||
});
|
||
|
||
const resetButton = document.getElementById("reset-btn");
|
||
|
||
resetButton.addEventListener("click", () => {
|
||
const confirmReset = confirm(
|
||
"Clearing and loading a new report will discard the currently loaded data from the viewer (your files stay 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();
|
||
});
|
||
|
||
sourceSelect.addEventListener("change", (e) => {
|
||
sourceFilter = e.target.value || "all";
|
||
currentPage = 1;
|
||
renderFindingsTable();
|
||
});
|
||
|
||
if (detectorBreakdownActiveOnlyToggle) {
|
||
detectorBreakdownActiveOnlyToggle.addEventListener("change", (e) => {
|
||
detectorBreakdownActiveOnly = Boolean(e.target.checked);
|
||
detectorBreakdownPage = 1;
|
||
renderDetectorBreakdown();
|
||
});
|
||
}
|
||
|
||
if (detectorBreakdownPrev) {
|
||
detectorBreakdownPrev.addEventListener("click", () => {
|
||
if (detectorBreakdownPage > 1) {
|
||
detectorBreakdownPage--;
|
||
renderDetectorBreakdown();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (detectorBreakdownNext) {
|
||
detectorBreakdownNext.addEventListener("click", () => {
|
||
detectorBreakdownPage++;
|
||
renderDetectorBreakdown();
|
||
});
|
||
}
|
||
|
||
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);
|
||
rerenderThemeSensitiveViews();
|
||
});
|
||
}
|
||
|
||
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.innerHTML = getAccessMapEmptyMessage();
|
||
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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 firstNonEmpty() {
|
||
for (let i = 0; i < arguments.length; i++) {
|
||
const value = arguments[i];
|
||
if (value === undefined || value === null) continue;
|
||
if (typeof value === "string" && value.trim() === "") continue;
|
||
return value;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function toArray(value) {
|
||
if (Array.isArray(value)) return value;
|
||
if (value === undefined || value === null || value === "") return [];
|
||
return [value];
|
||
}
|
||
|
||
function toNumberOrNull(value) {
|
||
if (value === undefined || value === null || value === "") return null;
|
||
const num = Number(value);
|
||
return Number.isFinite(num) ? num : null;
|
||
}
|
||
|
||
function slugifyIdentifier(value, fallback = "unknown") {
|
||
const slug = String(value || "")
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "");
|
||
return slug || fallback;
|
||
}
|
||
|
||
function stableHash53(input) {
|
||
const text = String(input || "");
|
||
let h1 = 0xdeadbeef ^ text.length;
|
||
let h2 = 0x41c6ce57 ^ text.length;
|
||
for (let i = 0; i < text.length; i++) {
|
||
const ch = text.charCodeAt(i);
|
||
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||
}
|
||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||
return String(4294967296 * (2097151 & h2) + (h1 >>> 0));
|
||
}
|
||
|
||
function buildSyntheticFingerprint(sourceTool, parts) {
|
||
return stableHash53([sourceTool].concat(parts || []).map((part) => String(part || "")).join("\u241f"));
|
||
}
|
||
|
||
function findDeepValue(input, candidateKeys, depth = 0) {
|
||
if (depth > 6 || input === undefined || input === null) return undefined;
|
||
const keySet = new Set((candidateKeys || []).map((key) => String(key).toLowerCase()));
|
||
|
||
if (Array.isArray(input)) {
|
||
for (const item of input) {
|
||
const found = findDeepValue(item, candidateKeys, depth + 1);
|
||
if (found !== undefined && found !== null && found !== "") return found;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
if (typeof input !== "object") return undefined;
|
||
|
||
for (const [key, value] of Object.entries(input)) {
|
||
if (keySet.has(String(key).toLowerCase()) && value !== undefined && value !== null && value !== "") {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
for (const value of Object.values(input)) {
|
||
const found = findDeepValue(value, candidateKeys, depth + 1);
|
||
if (found !== undefined && found !== null && found !== "") return found;
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
function isHttpUrl(value) {
|
||
return typeof value === "string" && /^https?:\/\//i.test(value);
|
||
}
|
||
|
||
function buildViewerImportMetadata(toolName, options = {}) {
|
||
return {
|
||
imported: Boolean(options.imported),
|
||
tools: Array.isArray(options.tools) && options.tools.length ? options.tools : [toolName],
|
||
access_map_supported: options.accessMapSupported !== false,
|
||
validate_command_supported: options.validateCommandSupported !== false,
|
||
revoke_command_supported: options.revokeCommandSupported !== false,
|
||
notes: Array.isArray(options.notes) ? options.notes.slice() : [],
|
||
};
|
||
}
|
||
|
||
function mergeViewerImportMetadata(baseMeta, incomingMeta) {
|
||
const base = baseMeta && typeof baseMeta === "object" ? baseMeta : buildViewerImportMetadata("Kingfisher", {});
|
||
const incoming = incomingMeta && typeof incomingMeta === "object" ? incomingMeta : buildViewerImportMetadata("Kingfisher", {});
|
||
const mergedTools = [...new Set(toArray(base.tools).concat(toArray(incoming.tools)).map((tool) => String(tool)).filter(Boolean))];
|
||
const mergedNotes = [...new Set(toArray(base.notes).concat(toArray(incoming.notes)).map((note) => String(note)).filter(Boolean))];
|
||
return {
|
||
imported: Boolean(base.imported || incoming.imported),
|
||
tools: mergedTools.length ? mergedTools : ["Kingfisher"],
|
||
access_map_supported: Boolean(base.access_map_supported && incoming.access_map_supported),
|
||
validate_command_supported: Boolean(base.validate_command_supported && incoming.validate_command_supported),
|
||
revoke_command_supported: Boolean(base.revoke_command_supported && incoming.revoke_command_supported),
|
||
notes: mergedNotes,
|
||
};
|
||
}
|
||
|
||
function detectReportFormat(payload) {
|
||
const stack = Array.isArray(payload) ? payload.slice() : [payload];
|
||
while (stack.length > 0) {
|
||
const item = stack.shift();
|
||
if (!item || typeof item !== "object") continue;
|
||
|
||
if (Array.isArray(item.results)) {
|
||
stack.unshift(...item.results);
|
||
continue;
|
||
}
|
||
|
||
if (Array.isArray(item.findings) || Array.isArray(item.access_map) || (item.rule && item.finding) || (item.provider && item.account)) {
|
||
return "kingfisher";
|
||
}
|
||
|
||
if (
|
||
Object.prototype.hasOwnProperty.call(item, "DetectorName") ||
|
||
Object.prototype.hasOwnProperty.call(item, "Verified") ||
|
||
Object.prototype.hasOwnProperty.call(item, "Raw") ||
|
||
Object.prototype.hasOwnProperty.call(item, "RawV2") ||
|
||
Object.prototype.hasOwnProperty.call(item, "Redacted")
|
||
) {
|
||
return "trufflehog";
|
||
}
|
||
|
||
if (
|
||
Object.prototype.hasOwnProperty.call(item, "RuleID") ||
|
||
Object.prototype.hasOwnProperty.call(item, "File") ||
|
||
Object.prototype.hasOwnProperty.call(item, "StartLine") ||
|
||
Object.prototype.hasOwnProperty.call(item, "Secret") ||
|
||
Object.prototype.hasOwnProperty.call(item, "Match")
|
||
) {
|
||
return "gitleaks";
|
||
}
|
||
}
|
||
|
||
return "kingfisher";
|
||
}
|
||
|
||
function unwrapImportedEntries(payload, format) {
|
||
if (Array.isArray(payload)) return payload;
|
||
if (payload && typeof payload === "object") {
|
||
if (format === "trufflehog" && Array.isArray(payload.results)) return payload.results;
|
||
if (format === "gitleaks" && Array.isArray(payload.results)) return payload.results;
|
||
}
|
||
return payload ? [payload] : [];
|
||
}
|
||
|
||
function normalizeRepositoryWebUrl(repoUrl) {
|
||
const raw = String(repoUrl || "").trim();
|
||
if (!raw || /^file:\/\//i.test(raw)) return "";
|
||
if (/^https?:\/\//i.test(raw)) return raw.replace(/\.git$/i, "").replace(/\/+$/g, "");
|
||
|
||
const sshMatch = raw.match(/^git@([^:]+):(.+)$/i);
|
||
if (sshMatch) {
|
||
return `https://${sshMatch[1]}/${sshMatch[2].replace(/\.git$/i, "").replace(/^\/+/, "")}`.replace(/\/+$/g, "");
|
||
}
|
||
|
||
const sshUrlMatch = raw.match(/^ssh:\/\/git@([^/]+)\/(.+)$/i);
|
||
if (sshUrlMatch) {
|
||
return `https://${sshUrlMatch[1]}/${sshUrlMatch[2].replace(/\.git$/i, "").replace(/^\/+/, "")}`.replace(/\/+$/g, "");
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
function buildGitFileUrl(repoUrl, commitId, filePath, line) {
|
||
const base = normalizeRepositoryWebUrl(repoUrl);
|
||
const commit = String(commitId || "").trim();
|
||
const normalizedPath = String(filePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
||
const lineNumber = toNumberOrNull(line);
|
||
if (!base || !commit || !normalizedPath) return "";
|
||
|
||
try {
|
||
const parsed = new URL(base);
|
||
const host = (parsed.hostname || "").toLowerCase();
|
||
if (host === "dev.azure.com" || host.endsWith(".visualstudio.com")) {
|
||
const encodedPath = encodeURIComponent(normalizedPath);
|
||
return lineNumber
|
||
? `${base}/commit/${commit}?path=/${encodedPath}&line=${lineNumber}`
|
||
: `${base}/commit/${commit}?path=/${encodedPath}`;
|
||
}
|
||
if (host === "bitbucket.org") {
|
||
const anchor = encodeURIComponent(normalizedPath).replace(/%2F/g, "/");
|
||
return lineNumber
|
||
? `${base}/commits/${commit}#L${anchor}F${lineNumber}`
|
||
: `${base}/commits/${commit}`;
|
||
}
|
||
} catch (_) {
|
||
return "";
|
||
}
|
||
|
||
return lineNumber
|
||
? `${base}/blob/${commit}/${normalizedPath}#L${lineNumber}`
|
||
: `${base}/blob/${commit}/${normalizedPath}`;
|
||
}
|
||
|
||
function buildGitMetadata(commitId, email, fileUrl, repositoryUrl) {
|
||
const gitMetadata = {};
|
||
if (commitId || email) {
|
||
gitMetadata.commit = {};
|
||
if (commitId) gitMetadata.commit.id = String(commitId);
|
||
if (email) gitMetadata.commit.committer = { email: String(email) };
|
||
}
|
||
if (fileUrl) {
|
||
gitMetadata.file = { url: String(fileUrl) };
|
||
}
|
||
if (repositoryUrl) {
|
||
gitMetadata.repository = { url: String(repositoryUrl) };
|
||
}
|
||
return Object.keys(gitMetadata).length ? gitMetadata : null;
|
||
}
|
||
|
||
function buildImportedSummary(toolName, normalizedFindings) {
|
||
const counts = calculateValidationCounts(normalizedFindings.map((entry) => ({
|
||
finding: entry.finding,
|
||
})));
|
||
return {
|
||
findings: normalizedFindings.length,
|
||
active_findings: counts.active || 0,
|
||
inactive_findings: counts.inactive || 0,
|
||
unknown_validation_findings: counts.unknown || 0,
|
||
access_map_identities: 0,
|
||
confidence_level: toolName === "Gitleaks" ? "imported" : "imported",
|
||
custom_rules_used: false,
|
||
successful_validations: counts.active || 0,
|
||
failed_validations: counts.inactive || 0,
|
||
skipped_validations: counts.not_attempted || 0,
|
||
};
|
||
}
|
||
|
||
function normalizeGitleaksFinding(item) {
|
||
if (!item || typeof item !== "object") return null;
|
||
const ruleId = String(firstNonEmpty(item.RuleID, item.ruleID, item.RuleId, item.Rule) || "imported.gitleaks.unknown");
|
||
const description = String(firstNonEmpty(item.Description, item.description, item.RuleID, item.Rule) || ruleId);
|
||
const path = String(firstNonEmpty(item.File, item.file, item.SymlinkFile, item.symlinkFile) || "");
|
||
const line = toNumberOrNull(firstNonEmpty(item.StartLine, item.startLine, item.lineNumber));
|
||
const columnStart = toNumberOrNull(firstNonEmpty(item.StartColumn, item.startColumn));
|
||
const columnEnd = toNumberOrNull(firstNonEmpty(item.EndColumn, item.endColumn));
|
||
const snippet = String(firstNonEmpty(item.Secret, item.secret, item.Match, item.match, item.line, description) || "");
|
||
const entropy = firstNonEmpty(item.Entropy, item.entropy, item.OffenderEntropy, item.offenderEntropy);
|
||
const secretIdentity = String(firstNonEmpty(item.Secret, item.secret, item.Match, item.match) || "");
|
||
const fingerprint = String(
|
||
firstNonEmpty(item.Fingerprint, item.fingerprint) ||
|
||
(secretIdentity
|
||
? buildSyntheticFingerprint("gitleaks", [ruleId, secretIdentity])
|
||
: buildSyntheticFingerprint("gitleaks", [ruleId, path, line, columnStart, snippet]))
|
||
);
|
||
const repoUrl = firstNonEmpty(item.RepoURL, item.repoURL, item.Repo, item.repo);
|
||
const directUrl = firstNonEmpty(item.LeakURL, item.leakURL, item.Url, item.url);
|
||
const fileUrl = isHttpUrl(directUrl)
|
||
? directUrl
|
||
: buildGitFileUrl(repoUrl, firstNonEmpty(item.Commit, item.commit), path, line);
|
||
const gitMetadata = buildGitMetadata(
|
||
firstNonEmpty(item.Commit, item.commit),
|
||
firstNonEmpty(item.Email, item.email),
|
||
fileUrl,
|
||
normalizeRepositoryWebUrl(repoUrl)
|
||
);
|
||
|
||
return {
|
||
rule: {
|
||
id: "imported.gitleaks." + slugifyIdentifier(ruleId),
|
||
name: description,
|
||
},
|
||
finding: {
|
||
snippet,
|
||
fingerprint,
|
||
confidence: "medium",
|
||
entropy: entropy !== "" ? String(entropy) : "",
|
||
validation: {
|
||
status: "Not Attempted",
|
||
response: "",
|
||
},
|
||
language: "Imported",
|
||
line,
|
||
column_start: columnStart,
|
||
column_end: columnEnd,
|
||
path,
|
||
git_metadata: gitMetadata,
|
||
viewer_import: {
|
||
source_tool: "Gitleaks",
|
||
source_rule_id: ruleId,
|
||
source_rule_name: description,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function normalizeTruffleHogFinding(item) {
|
||
if (!item || typeof item !== "object") return null;
|
||
const detectorName = String(firstNonEmpty(item.DetectorName, item.detectorName, item.DetectorDescription, item.detectorDescription) || "Unknown Detector");
|
||
const detectorDescription = String(firstNonEmpty(item.DetectorDescription, item.detectorDescription, detectorName) || detectorName);
|
||
const sourceMetadata = item.SourceMetadata || item.sourceMetadata || {};
|
||
const gitSource = findDeepValue(sourceMetadata, ["git"]) || {};
|
||
const path = String(firstNonEmpty(
|
||
findDeepValue(sourceMetadata, ["file", "path", "filename"]),
|
||
item.File,
|
||
item.file
|
||
) || "");
|
||
const line = toNumberOrNull(firstNonEmpty(
|
||
findDeepValue(sourceMetadata, ["line", "line_number", "linenumber", "startline"]),
|
||
item.Line,
|
||
item.line
|
||
));
|
||
const sourceUrl = firstNonEmpty(
|
||
findDeepValue(sourceMetadata, ["url", "link", "htmlurl", "html_url", "rawurl", "raw_url"]),
|
||
item.Url,
|
||
item.url
|
||
);
|
||
const repositoryUrl = firstNonEmpty(
|
||
findDeepValue(gitSource, ["repository", "repositoryurl", "repo", "repo_url", "remote", "remoteurl", "remote_url"]),
|
||
findDeepValue(sourceMetadata, ["repository", "repositoryurl", "repo", "repo_url", "remote", "remoteurl", "remote_url"]),
|
||
item.Repository,
|
||
item.repository
|
||
);
|
||
const commitId = firstNonEmpty(
|
||
findDeepValue(sourceMetadata, ["commit", "commitid", "hash", "sha"]),
|
||
item.Commit,
|
||
item.commit
|
||
);
|
||
const email = firstNonEmpty(
|
||
findDeepValue(sourceMetadata, ["email", "authoremail", "committeremail"]),
|
||
item.Email,
|
||
item.email
|
||
);
|
||
const snippet = String(firstNonEmpty(item.Redacted, item.redacted, item.RawV2, item.rawV2, item.Raw, item.raw, detectorDescription) || "");
|
||
const secretIdentity = String(firstNonEmpty(item.RawV2, item.rawV2, item.Raw, item.raw, item.Redacted, item.redacted) || "");
|
||
const fingerprint = String(
|
||
firstNonEmpty(item.Fingerprint, item.fingerprint) ||
|
||
(secretIdentity
|
||
? buildSyntheticFingerprint("trufflehog", [detectorName, secretIdentity])
|
||
: buildSyntheticFingerprint("trufflehog", [detectorName, path, line, snippet]))
|
||
);
|
||
const verified = Boolean(item.Verified);
|
||
const verificationError = String(firstNonEmpty(item.VerificationError, item.verificationError) || "");
|
||
const normalizedRepoUrl = normalizeRepositoryWebUrl(repositoryUrl);
|
||
const fileUrl = isHttpUrl(sourceUrl) ? sourceUrl : buildGitFileUrl(normalizedRepoUrl, commitId, path, line);
|
||
const gitMetadata = buildGitMetadata(commitId, email, fileUrl, normalizedRepoUrl);
|
||
|
||
return {
|
||
rule: {
|
||
id: "imported.trufflehog." + slugifyIdentifier(detectorName),
|
||
name: detectorName,
|
||
},
|
||
finding: {
|
||
snippet,
|
||
fingerprint,
|
||
confidence: verified ? "high" : "medium",
|
||
entropy: "",
|
||
validation: {
|
||
status: verified ? "Active Credential" : "Not Attempted",
|
||
response: verificationError,
|
||
},
|
||
language: "Imported",
|
||
line,
|
||
column_start: null,
|
||
column_end: null,
|
||
path,
|
||
git_metadata: gitMetadata,
|
||
viewer_import: {
|
||
source_tool: "TruffleHog",
|
||
source_rule_id: detectorName,
|
||
source_rule_name: detectorName,
|
||
source_rule_description: detectorDescription,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function normalizeImportedPayload(payload, toolName) {
|
||
const format = toolName === "TruffleHog" ? "trufflehog" : "gitleaks";
|
||
const entries = unwrapImportedEntries(payload, format);
|
||
const normalizedFindings = entries
|
||
.map((item) => format === "trufflehog" ? normalizeTruffleHogFinding(item) : normalizeGitleaksFinding(item))
|
||
.filter(Boolean);
|
||
const firstEntry = entries.find((item) => item && typeof item === "object") || {};
|
||
const notes = toolName === "TruffleHog"
|
||
? ["Imported from the open-source TruffleHog JSON output. Only findings marked verified are shown as Active; others are Not Attempted. Re-scan with Kingfisher --access-map for validation, revoke commands, and blast radius. Not affiliated with Truffle Security Co."]
|
||
: ["Imported from the open-source Gitleaks JSON output. No validation, revoke commands, or access-map data. Re-scan with Kingfisher --access-map for full enrichment. Not affiliated with the Gitleaks project."];
|
||
const target = String(firstNonEmpty(
|
||
firstEntry.SourceName,
|
||
firstEntry.sourceName,
|
||
firstEntry.Repo,
|
||
firstEntry.repo,
|
||
firstEntry.Repository,
|
||
firstEntry.repository
|
||
) || "");
|
||
|
||
return {
|
||
f: normalizedFindings,
|
||
am: [],
|
||
rd: {
|
||
findings: normalizedFindings,
|
||
access_map: [],
|
||
metadata: {
|
||
generated_at: new Date().toISOString(),
|
||
scan_timestamp: new Date().toISOString(),
|
||
target,
|
||
kingfisher_version: "",
|
||
summary: buildImportedSummary(toolName, normalizedFindings),
|
||
viewer_import: buildViewerImportMetadata(toolName, {
|
||
imported: true,
|
||
accessMapSupported: false,
|
||
validateCommandSupported: false,
|
||
revokeCommandSupported: false,
|
||
notes,
|
||
}),
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function normalizeKingfisherPayload(payload) {
|
||
const collected = collectReportData(payload);
|
||
const rd = collected.mainReport || collected.statsReport
|
||
? Object.assign({}, collected.mainReport || {}, collected.statsReport || {})
|
||
: {};
|
||
rd.metadata = Object.assign({}, rd.metadata || {});
|
||
rd.metadata.viewer_import = mergeViewerImportMetadata(
|
||
buildViewerImportMetadata("Kingfisher", {
|
||
imported: false,
|
||
accessMapSupported: true,
|
||
validateCommandSupported: true,
|
||
revokeCommandSupported: true,
|
||
notes: [],
|
||
}),
|
||
rd.metadata.viewer_import
|
||
);
|
||
return { f: collected.findings, am: collected.accessMap, rd };
|
||
}
|
||
|
||
function normalizeReportPayload(payload) {
|
||
if (Array.isArray(payload)) {
|
||
const topFormat = detectReportFormat(payload);
|
||
if (topFormat === "gitleaks") {
|
||
return normalizeImportedPayload(payload, "Gitleaks");
|
||
}
|
||
if (topFormat === "trufflehog") {
|
||
return normalizeImportedPayload(payload, "TruffleHog");
|
||
}
|
||
const entryFormats = [];
|
||
let hasImported = false;
|
||
for (const entry of payload) {
|
||
if (entry === null || typeof entry !== "object") {
|
||
entryFormats.push(null);
|
||
continue;
|
||
}
|
||
const fmt = detectReportFormat(entry);
|
||
entryFormats.push(fmt);
|
||
if (fmt === "gitleaks" || fmt === "trufflehog") {
|
||
hasImported = true;
|
||
}
|
||
}
|
||
if (!hasImported) {
|
||
return normalizeKingfisherPayload(payload);
|
||
}
|
||
const combined = { f: [], am: [], rd: null };
|
||
for (let i = 0; i < payload.length; i++) {
|
||
const entry = payload[i];
|
||
const fmt = entryFormats[i];
|
||
if (entry === null || typeof entry !== "object") continue;
|
||
let partial;
|
||
if (fmt === "trufflehog") {
|
||
partial = normalizeImportedPayload(entry, "TruffleHog");
|
||
} else if (fmt === "gitleaks") {
|
||
partial = normalizeImportedPayload(entry, "Gitleaks");
|
||
} else {
|
||
partial = normalizeKingfisherPayload(entry);
|
||
}
|
||
if (!partial) continue;
|
||
if (Array.isArray(partial.f)) combined.f.push(...partial.f);
|
||
if (Array.isArray(partial.am)) combined.am.push(...partial.am);
|
||
combined.rd = mergeRawReportData(combined.rd, partial.rd);
|
||
}
|
||
return combined;
|
||
}
|
||
const format = detectReportFormat(payload);
|
||
if (format === "trufflehog") {
|
||
return normalizeImportedPayload(payload, "TruffleHog");
|
||
}
|
||
if (format === "gitleaks") {
|
||
return normalizeImportedPayload(payload, "Gitleaks");
|
||
}
|
||
return normalizeKingfisherPayload(payload);
|
||
}
|
||
|
||
function mergeRawReportData(base, incoming) {
|
||
if (!base) return incoming || null;
|
||
if (!incoming) return base;
|
||
|
||
const merged = Object.assign({}, base, incoming);
|
||
merged.metadata = Object.assign({}, base.metadata || {}, incoming.metadata || {});
|
||
merged.metadata.viewer_import = mergeViewerImportMetadata(
|
||
base.metadata && base.metadata.viewer_import,
|
||
incoming.metadata && incoming.metadata.viewer_import
|
||
);
|
||
|
||
if (!merged.metadata.target) {
|
||
merged.metadata.target = firstNonEmpty(base.metadata && base.metadata.target, incoming.metadata && incoming.metadata.target) || "";
|
||
}
|
||
if (!merged.metadata.scan_timestamp) {
|
||
merged.metadata.scan_timestamp = firstNonEmpty(base.metadata && base.metadata.scan_timestamp, incoming.metadata && incoming.metadata.scan_timestamp) || new Date().toISOString();
|
||
}
|
||
if (!merged.metadata.generated_at) {
|
||
merged.metadata.generated_at = firstNonEmpty(base.metadata && base.metadata.generated_at, incoming.metadata && incoming.metadata.generated_at) || new Date().toISOString();
|
||
}
|
||
|
||
return merged;
|
||
}
|
||
|
||
function processFiles(files, { append = false } = {}) {
|
||
const validFiles = files.filter((f) => {
|
||
const name = f.name.toLowerCase();
|
||
return name.endsWith(".json") || name.endsWith(".jsonl");
|
||
});
|
||
|
||
if (validFiles.length === 0) {
|
||
errorMsg.textContent = "No JSON or JSONL files found in the selection.";
|
||
errorMsg.classList.remove("hidden");
|
||
return;
|
||
}
|
||
|
||
const label = validFiles.length === 1
|
||
? (append ? 'Importing "' + validFiles[0].name + '"…' : 'Processing "' + validFiles[0].name + '"…')
|
||
: (append ? "Importing " + validFiles.length + " additional files…" : "Processing " + validFiles.length + " files…");
|
||
loaderText.textContent = label;
|
||
loader.classList.remove("hidden");
|
||
errorMsg.classList.add("hidden");
|
||
errorMsg.textContent = "";
|
||
|
||
setTimeout(() => {
|
||
let completed = 0;
|
||
const texts = new Array(validFiles.length);
|
||
|
||
validFiles.forEach((file, idx) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
texts[idx] = e.target.result;
|
||
completed++;
|
||
if (completed === validFiles.length) {
|
||
try {
|
||
parseAndRenderMultiple(texts, { append });
|
||
} catch (err) {
|
||
console.error(err);
|
||
errorMsg.textContent = "Error parsing files: " + err.message;
|
||
errorMsg.classList.remove("hidden");
|
||
loader.classList.add("hidden");
|
||
}
|
||
}
|
||
};
|
||
reader.onerror = () => {
|
||
texts[idx] = null;
|
||
completed++;
|
||
if (completed === validFiles.length) {
|
||
const validTexts = texts.filter(Boolean);
|
||
if (validTexts.length === 0) {
|
||
errorMsg.textContent = "Failed to read any files.";
|
||
errorMsg.classList.remove("hidden");
|
||
loader.classList.add("hidden");
|
||
} else {
|
||
try {
|
||
parseAndRenderMultiple(validTexts, { append });
|
||
} catch (err) {
|
||
console.error(err);
|
||
errorMsg.textContent = "Error parsing files: " + 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 = {};
|
||
duplicatesRemoved = { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 };
|
||
|
||
currentFilter = "";
|
||
validationFilter = "all";
|
||
sourceFilter = "all";
|
||
pageSize = 10;
|
||
currentPage = 1;
|
||
sortField = "rule";
|
||
sortDirection = "asc";
|
||
detectorBreakdownActiveOnly = false;
|
||
detectorBreakdownPage = 1;
|
||
|
||
searchInput.value = "";
|
||
validationSelect.value = "all";
|
||
sourceSelect.value = "all";
|
||
rowsSelect.value = "10";
|
||
treeSearch.value = "";
|
||
if (detectorBreakdownActiveOnlyToggle) detectorBreakdownActiveOnlyToggle.checked = false;
|
||
|
||
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 importNote = document.getElementById("import-note");
|
||
if (importNote) {
|
||
importNote.classList.add("hidden");
|
||
importNote.innerHTML = "";
|
||
}
|
||
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) {
|
||
openReplaceFilePicker();
|
||
}
|
||
}
|
||
|
||
function parseAndRenderMultiple(texts, { append = false } = {}) {
|
||
const t0 = performance.now();
|
||
const nextFindings = append ? findings.slice() : [];
|
||
const nextAccessMap = append ? accessMap.slice() : [];
|
||
let nextRawData = append ? rawData : null;
|
||
|
||
for (let i = 0; i < texts.length; i++) {
|
||
const text = texts[i];
|
||
if (!text) continue;
|
||
const { f, am, rd } = parseSingleText(text);
|
||
nextFindings.push(...f);
|
||
nextAccessMap.push(...am);
|
||
nextRawData = mergeRawReportData(nextRawData, rd);
|
||
}
|
||
|
||
// Deduplicate findings by fingerprint (skip when disabled via UI toggle)
|
||
const dedupDisabledMulti = isDedupDisabled();
|
||
let dedupResultMulti;
|
||
if (dedupDisabledMulti) {
|
||
dedupResultMulti = { findings: nextFindings.slice(), dropped: { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 } };
|
||
} else {
|
||
dedupResultMulti = deduplicateFindings(nextFindings);
|
||
}
|
||
findings = dedupResultMulti.findings;
|
||
duplicatesRemoved = dedupResultMulti.dropped;
|
||
// Layer Kingfisher validation/revoke data onto matched imported findings
|
||
findings = enrichImportedFindings(findings);
|
||
// Deduplicate access map entries by fingerprint
|
||
accessMap = deduplicateAccessMap(nextAccessMap);
|
||
rawData = nextRawData;
|
||
|
||
finalizeRender(t0);
|
||
}
|
||
|
||
function parseAndRender(text) {
|
||
const t0 = performance.now();
|
||
findings = [];
|
||
accessMap = [];
|
||
rawData = null;
|
||
|
||
const { f, am, rd } = parseSingleText(text);
|
||
const dedupDisabledSingle = isDedupDisabled();
|
||
let dedupResultSingle;
|
||
if (dedupDisabledSingle) {
|
||
dedupResultSingle = { findings: f.slice(), dropped: { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 } };
|
||
} else {
|
||
dedupResultSingle = deduplicateFindings(f);
|
||
}
|
||
findings = dedupResultSingle.findings;
|
||
duplicatesRemoved = dedupResultSingle.dropped;
|
||
findings = enrichImportedFindings(findings);
|
||
accessMap = deduplicateAccessMap(am);
|
||
rawData = rd;
|
||
|
||
finalizeRender(t0);
|
||
}
|
||
|
||
function parseSingleText(text) {
|
||
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] !== "{" && line[0] !== "[")) continue;
|
||
try {
|
||
const obj = JSON.parse(line);
|
||
entries.push(obj);
|
||
} catch (errLine) {
|
||
console.warn("Skipping invalid JSON line", i);
|
||
}
|
||
}
|
||
return normalizeReportPayload(entries);
|
||
}
|
||
|
||
return normalizeReportPayload(parsed);
|
||
}
|
||
|
||
function isDedupDisabled() {
|
||
// Toggle is default-checked; only disabled when the user has unchecked it.
|
||
const el = document.getElementById("dedup-toggle");
|
||
return !!(el && !el.checked);
|
||
}
|
||
|
||
function deduplicateFindings(list) {
|
||
const seen = new Set();
|
||
const result = [];
|
||
const dropped = { kingfisher: 0, trufflehog: 0, gitleaks: 0, total: 0 };
|
||
for (const f of list) {
|
||
const fp = f.finding && f.finding.fingerprint ? f.finding.fingerprint : "";
|
||
if (fp && seen.has(fp)) {
|
||
const tool = getFindingSourceTool(f.finding || {});
|
||
dropped[tool] = (dropped[tool] || 0) + 1;
|
||
dropped.total += 1;
|
||
continue;
|
||
}
|
||
if (fp) seen.add(fp);
|
||
result.push(f);
|
||
}
|
||
return { findings: result, dropped };
|
||
}
|
||
|
||
function normalizePathForMatch(p) {
|
||
if (!p) return "";
|
||
return String(p).replace(/^\.\//, "").replace(/^\/+|\/+$/g, "").toLowerCase();
|
||
}
|
||
|
||
// Two paths match if they're equal, or one is a path-suffix of the other.
|
||
// Handles Kingfisher absolute filesystem paths vs TruffleHog/Gitleaks
|
||
// repo-relative paths for the same file.
|
||
function pathsCompatible(a, b) {
|
||
if (!a || !b) return false;
|
||
if (a === b) return true;
|
||
if (a.endsWith("/" + b)) return true;
|
||
if (b.endsWith("/" + a)) return true;
|
||
return false;
|
||
}
|
||
|
||
function enrichImportedFindings(findingList) {
|
||
// Bucket native Kingfisher findings by line (and commit+line) so we can
|
||
// run a cheap path-compatibility check at lookup time.
|
||
const byLine = new Map();
|
||
const byCommitLine = new Map();
|
||
for (const f of findingList) {
|
||
if (!f || !f.finding) continue;
|
||
if (f.finding.viewer_import) continue;
|
||
const fg = f.finding;
|
||
const path = normalizePathForMatch(fg.path);
|
||
const line = fg.line != null ? String(fg.line) : "";
|
||
if (!path || !line) continue;
|
||
const entry = { path, f };
|
||
if (!byLine.has(line)) byLine.set(line, []);
|
||
byLine.get(line).push(entry);
|
||
const commit = fg.git_metadata && fg.git_metadata.commit
|
||
? String(fg.git_metadata.commit.id || "") : "";
|
||
if (commit) {
|
||
const k = commit + "|" + line;
|
||
if (!byCommitLine.has(k)) byCommitLine.set(k, []);
|
||
byCommitLine.get(k).push(entry);
|
||
}
|
||
}
|
||
for (const f of findingList) {
|
||
if (!f || !f.finding || !f.finding.viewer_import) continue;
|
||
const fg = f.finding;
|
||
const path = normalizePathForMatch(fg.path);
|
||
const line = fg.line != null ? String(fg.line) : "";
|
||
if (!path || !line) continue;
|
||
const commit = fg.git_metadata && fg.git_metadata.commit
|
||
? String(fg.git_metadata.commit.id || "") : "";
|
||
let match = null;
|
||
let matchStrength = null;
|
||
if (commit) {
|
||
const bucket = byCommitLine.get(commit + "|" + line) || [];
|
||
for (const e of bucket) {
|
||
if (pathsCompatible(e.path, path)) { match = e.f; matchStrength = "commit"; break; }
|
||
}
|
||
}
|
||
if (!match) {
|
||
const bucket = byLine.get(line) || [];
|
||
for (const e of bucket) {
|
||
if (pathsCompatible(e.path, path)) { match = e.f; matchStrength = "path-line"; break; }
|
||
}
|
||
}
|
||
if (!match) continue;
|
||
const kf = match.finding || {};
|
||
fg.kingfisher_enrichment = {
|
||
match_strength: matchStrength,
|
||
kingfisher_rule_id: (match.rule || {}).id || "",
|
||
kingfisher_rule_name: (match.rule || {}).name || "",
|
||
kingfisher_fingerprint: kf.fingerprint || "",
|
||
validation: kf.validation || null,
|
||
validate_command: kf.validate_command || "",
|
||
revoke_command: kf.revoke_command || "",
|
||
confidence: kf.confidence || "",
|
||
};
|
||
if (kf.git_metadata && (!fg.git_metadata || !fg.git_metadata.file || !fg.git_metadata.file.url)) {
|
||
fg.git_metadata = Object.assign({}, fg.git_metadata || {});
|
||
if (kf.git_metadata.file && kf.git_metadata.file.url) {
|
||
fg.git_metadata.file = Object.assign({}, fg.git_metadata.file || {}, {
|
||
url: kf.git_metadata.file.url,
|
||
});
|
||
}
|
||
if (kf.git_metadata.repository && kf.git_metadata.repository.url) {
|
||
fg.git_metadata.repository = Object.assign({}, fg.git_metadata.repository || {}, {
|
||
url: kf.git_metadata.repository.url,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return findingList;
|
||
}
|
||
|
||
function deduplicateAccessMap(list) {
|
||
const seen = new Set();
|
||
const result = [];
|
||
for (const entry of list) {
|
||
const fp = entry.fingerprint || "";
|
||
if (fp && seen.has(fp)) continue;
|
||
if (fp) seen.add(fp);
|
||
result.push(entry);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function finalizeRender(t0) {
|
||
currentPage = 1;
|
||
currentFilter = "";
|
||
validationFilter = "all";
|
||
sourceFilter = "all";
|
||
searchInput.value = "";
|
||
validationSelect.value = "all";
|
||
sourceSelect.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 formatViewerImportSource(viewerImport) {
|
||
const tools = toArray(viewerImport && viewerImport.tools).map((tool) => String(tool)).filter(Boolean);
|
||
if (tools.length === 0) return "";
|
||
return viewerImport && viewerImport.imported ? "Imported: " + tools.join(" + ") : tools.join(" + ");
|
||
}
|
||
|
||
function getAccessMapEmptyMessage() {
|
||
if (scanMetadata.imported) {
|
||
return "Imported reports do not include Kingfisher access-map data. Re-scan with <code>kingfisher scan --access-map</code> for blast-radius mapping.";
|
||
}
|
||
return "No Access Map entries in this report. Run a scan with <code>--access-map</code> to map credential blast radius.";
|
||
}
|
||
|
||
function getImportNoticeHtml(importNotes) {
|
||
const notes = toArray(importNotes).map((note) => String(note)).filter(Boolean);
|
||
if (!notes.length) return "";
|
||
return `<strong>Imported report limits:</strong> ${notes.map((note) => escapeHtml(note)).join(" ")}`;
|
||
}
|
||
|
||
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 : {};
|
||
const viewerImport = reportMeta.viewer_import && typeof reportMeta.viewer_import === "object"
|
||
? reportMeta.viewer_import
|
||
: buildViewerImportMetadata("Kingfisher", {});
|
||
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);
|
||
}
|
||
|
||
scanMetadata.reportSource = formatViewerImportSource(viewerImport);
|
||
scanMetadata.imported = Boolean(viewerImport.imported);
|
||
scanMetadata.importNotes = toArray(viewerImport.notes).map((note) => String(note)).filter(Boolean);
|
||
scanMetadata.capabilities = {
|
||
accessMapSupported: viewerImport.access_map_supported !== false,
|
||
validateCommandSupported: viewerImport.validate_command_supported !== false,
|
||
revokeCommandSupported: viewerImport.revoke_command_supported !== false,
|
||
};
|
||
|
||
// 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 sourceWrap = document.getElementById("meta-source-wrap");
|
||
const source = document.getElementById("meta-source");
|
||
const versionWrap = document.getElementById("meta-version-wrap");
|
||
const version = document.getElementById("meta-version");
|
||
const importNote = document.getElementById("import-note");
|
||
|
||
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.reportSource) {
|
||
sourceWrap.style.display = "";
|
||
source.textContent = scanMetadata.reportSource;
|
||
} else {
|
||
sourceWrap.style.display = "none";
|
||
}
|
||
|
||
if (scanMetadata.version) {
|
||
versionWrap.style.display = "";
|
||
version.textContent = scanMetadata.version;
|
||
} else {
|
||
versionWrap.style.display = "none";
|
||
}
|
||
|
||
if (importNote) {
|
||
const importHtml = getImportNoticeHtml(scanMetadata.importNotes);
|
||
if (importHtml) {
|
||
importNote.classList.remove("hidden");
|
||
importNote.innerHTML = importHtml;
|
||
} else {
|
||
importNote.classList.add("hidden");
|
||
importNote.innerHTML = "";
|
||
}
|
||
}
|
||
|
||
updateSidebarSummary();
|
||
}
|
||
|
||
function updateSidebarSummary() {
|
||
const sourceEl = document.getElementById("sidebar-summary-source");
|
||
const targetEl = document.getElementById("sidebar-summary-target");
|
||
const totalEl = document.getElementById("sidebar-summary-total");
|
||
const activeEl = document.getElementById("sidebar-summary-active");
|
||
const identitiesEl = document.getElementById("sidebar-summary-identities");
|
||
const accessEl = document.getElementById("sidebar-summary-access");
|
||
if (!sourceEl || !targetEl || !totalEl || !activeEl || !identitiesEl || !accessEl) return;
|
||
|
||
const totalFindings = findings.length;
|
||
const validationCounts = calculateValidationCounts();
|
||
const totalIdentities = (accessMap || []).length;
|
||
const sourceText = scanMetadata.reportSource || (totalFindings ? "Kingfisher" : "No report loaded");
|
||
const targetText = scanMetadata.target ||
|
||
(totalFindings ? "Ready to inspect findings, validation state, and blast radius." : "Load a report to explore findings and blast radius.");
|
||
|
||
sourceEl.textContent = sourceText;
|
||
targetEl.textContent = targetText;
|
||
totalEl.textContent = totalFindings.toString();
|
||
activeEl.textContent = String(validationCounts.active || 0);
|
||
identitiesEl.textContent = totalIdentities.toString();
|
||
accessEl.textContent = totalIdentities > 0 ? "Yes" : "No";
|
||
}
|
||
|
||
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();
|
||
|
||
const enrichedCount = findings.reduce(
|
||
(n, f) => n + (f && f.finding && f.finding.kingfisher_enrichment ? 1 : 0),
|
||
0
|
||
);
|
||
const enrichedEl = document.getElementById("stat-enriched");
|
||
const enrichedCard = document.getElementById("stat-enriched-card");
|
||
if (enrichedEl) enrichedEl.textContent = enrichedCount.toString();
|
||
if (enrichedCard) {
|
||
if (enrichedCount > 0) enrichedCard.classList.remove("hidden");
|
||
else enrichedCard.classList.add("hidden");
|
||
}
|
||
|
||
const dr = duplicatesRemoved || { total: 0, kingfisher: 0, trufflehog: 0, gitleaks: 0 };
|
||
const dedupPanel = document.getElementById("dedup-panel");
|
||
const kfCount = document.getElementById("dedup-count-kingfisher");
|
||
const thCount = document.getElementById("dedup-count-trufflehog");
|
||
const glCount = document.getElementById("dedup-count-gitleaks");
|
||
if (kfCount) kfCount.textContent = (dr.kingfisher || 0).toLocaleString();
|
||
if (thCount) thCount.textContent = (dr.trufflehog || 0).toLocaleString();
|
||
if (glCount) glCount.textContent = (dr.gitleaks || 0).toLocaleString();
|
||
if (dedupPanel) {
|
||
if (dr.total > 0) dedupPanel.classList.remove("hidden");
|
||
else dedupPanel.classList.add("hidden");
|
||
}
|
||
|
||
renderStatusChart(validationCounts);
|
||
renderSourceChart(calculateSourceCounts());
|
||
renderSourceDetectorCharts();
|
||
renderDetectorBreakdown();
|
||
updateSidebarSummary();
|
||
}
|
||
|
||
function getFindingSourceTool(finding) {
|
||
const importInfo = finding && finding.viewer_import && typeof finding.viewer_import === "object"
|
||
? finding.viewer_import
|
||
: null;
|
||
const sourceTool = importInfo && importInfo.source_tool
|
||
? String(importInfo.source_tool).trim().toLowerCase()
|
||
: "";
|
||
if (sourceTool === "gitleaks") return "gitleaks";
|
||
if (sourceTool === "trufflehog") return "trufflehog";
|
||
return "kingfisher";
|
||
}
|
||
|
||
function getFilteredSortedFindings() {
|
||
const filterLower = currentFilter.toLowerCase();
|
||
const validation = validationFilter;
|
||
const source = sourceFilter;
|
||
|
||
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 sourceTool = getFindingSourceTool(finding);
|
||
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 (source !== "all" && sourceTool !== source) {
|
||
return false;
|
||
}
|
||
|
||
if (validation === "active") {
|
||
return normalizedStatus === "active";
|
||
} else if (validation === "inactive") {
|
||
return normalizedStatus === "inactive";
|
||
} else if (validation === "not_attempted") {
|
||
return normalizedStatus === "not_attempted";
|
||
} else if (validation === "canary") {
|
||
return normalizedStatus === "canary";
|
||
}
|
||
|
||
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 "source":
|
||
va = getFindingSourceTool(fa);
|
||
vb = getFindingSourceTool(fb);
|
||
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";
|
||
}
|
||
|
||
if (normalized === "canary token (skipped)" || normalized === "canary") {
|
||
return "canary";
|
||
}
|
||
|
||
return "unknown";
|
||
}
|
||
|
||
function calculateValidationCounts(list = findings) {
|
||
const counts = { active: 0, inactive: 0, not_attempted: 0, canary: 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 calculateSourceCounts(list = findings) {
|
||
const counts = { kingfisher: 0, gitleaks: 0, trufflehog: 0 };
|
||
(list || []).forEach((f) => {
|
||
const finding = f && f.finding ? f.finding : {};
|
||
const source = getFindingSourceTool(finding);
|
||
counts[source] = (counts[source] || 0) + 1;
|
||
});
|
||
return counts;
|
||
}
|
||
|
||
function normalizeRuleFamilyId(ruleId) {
|
||
const normalized = String(ruleId || "").trim();
|
||
if (!normalized) return "";
|
||
return normalized.replace(/\.\d+$/, "").replace(/^kingfisher\./, "");
|
||
}
|
||
|
||
function getFindingDetectorLabel(entry) {
|
||
const rule = entry && entry.rule ? entry.rule : {};
|
||
const finding = entry && entry.finding ? entry.finding : {};
|
||
const source = getFindingSourceTool(finding);
|
||
|
||
if (source === "kingfisher") {
|
||
return normalizeRuleFamilyId(rule.id) || String(rule.name || rule.id || "Unknown");
|
||
}
|
||
|
||
const importInfo = finding.viewer_import && typeof finding.viewer_import === "object"
|
||
? finding.viewer_import
|
||
: null;
|
||
return String(
|
||
(importInfo && (importInfo.source_rule_name || importInfo.source_rule_id)) ||
|
||
rule.name ||
|
||
rule.id ||
|
||
"Unknown"
|
||
);
|
||
}
|
||
|
||
function getDetectorBreakdownEntries(list = findings) {
|
||
const grouped = new Map();
|
||
const workingList = detectorBreakdownActiveOnly
|
||
? (list || []).filter((entry) => {
|
||
const finding = entry && entry.finding ? entry.finding : {};
|
||
const normalizedStatus = normalizeValidationStatus(
|
||
finding.validation && finding.validation.status ? finding.validation.status : ""
|
||
);
|
||
return normalizedStatus === "active";
|
||
})
|
||
: (list || []);
|
||
|
||
workingList.forEach((entry) => {
|
||
const finding = entry && entry.finding ? entry.finding : {};
|
||
const source = getFindingSourceTool(finding);
|
||
const normalizedStatus = normalizeValidationStatus(
|
||
finding.validation && finding.validation.status ? finding.validation.status : ""
|
||
);
|
||
const label = getFindingDetectorLabel(entry);
|
||
|
||
const key = source + "::" + label;
|
||
if (!grouped.has(key)) {
|
||
grouped.set(key, {
|
||
key,
|
||
source,
|
||
label,
|
||
count: 0,
|
||
active: 0,
|
||
});
|
||
}
|
||
const current = grouped.get(key);
|
||
current.count += 1;
|
||
if (normalizedStatus === "active") current.active += 1;
|
||
});
|
||
|
||
const entries = Array.from(grouped.values());
|
||
const dirFactor = detectorBreakdownSortDirection === "asc" ? 1 : -1;
|
||
entries.sort((a, b) => {
|
||
let va;
|
||
let vb;
|
||
switch (detectorBreakdownSortField) {
|
||
case "label":
|
||
va = (a.label || "").toLowerCase();
|
||
vb = (b.label || "").toLowerCase();
|
||
break;
|
||
case "source":
|
||
va = getSourceDisplayName(a.source).toLowerCase();
|
||
vb = getSourceDisplayName(b.source).toLowerCase();
|
||
break;
|
||
case "active":
|
||
va = Number(a.active || 0);
|
||
vb = Number(b.active || 0);
|
||
break;
|
||
case "count":
|
||
default:
|
||
va = Number(a.count || 0);
|
||
vb = Number(b.count || 0);
|
||
break;
|
||
}
|
||
|
||
if (typeof va === "number" && typeof vb === "number") {
|
||
if (va < vb) return -1 * dirFactor;
|
||
if (va > vb) return 1 * dirFactor;
|
||
return a.label.localeCompare(b.label);
|
||
}
|
||
|
||
if (va < vb) return -1 * dirFactor;
|
||
if (va > vb) return 1 * dirFactor;
|
||
return Number(b.count || 0) - Number(a.count || 0);
|
||
});
|
||
|
||
return entries;
|
||
}
|
||
|
||
function getChartColor(index) {
|
||
const palette = [
|
||
"#0ea5e9",
|
||
"#8b5cf6",
|
||
"#22c55e",
|
||
"#f97316",
|
||
"#ec4899",
|
||
"#eab308",
|
||
"#14b8a6",
|
||
"#ef4444",
|
||
];
|
||
return palette[index % palette.length];
|
||
}
|
||
|
||
function getSourceDetectorChartEntries(list = findings) {
|
||
const groupedBySource = new Map();
|
||
(list || []).forEach((entry) => {
|
||
const finding = entry && entry.finding ? entry.finding : {};
|
||
const source = getFindingSourceTool(finding);
|
||
const label = getFindingDetectorLabel(entry);
|
||
if (!groupedBySource.has(source)) groupedBySource.set(source, new Map());
|
||
const detectorCounts = groupedBySource.get(source);
|
||
detectorCounts.set(label, (detectorCounts.get(label) || 0) + 1);
|
||
});
|
||
|
||
return ["kingfisher", "gitleaks", "trufflehog"]
|
||
.filter((source) => groupedBySource.has(source))
|
||
.map((source) => {
|
||
const countsMap = groupedBySource.get(source);
|
||
const sortedEntries = Array.from(countsMap.entries())
|
||
.map(([label, count]) => ({ label, count }))
|
||
.sort((a, b) => {
|
||
if (b.count !== a.count) return b.count - a.count;
|
||
return a.label.localeCompare(b.label);
|
||
});
|
||
|
||
const topEntries = sortedEntries.slice(0, 6);
|
||
const otherCount = sortedEntries.slice(6).reduce((sum, entry) => sum + entry.count, 0);
|
||
if (otherCount > 0) topEntries.push({ label: "Other", count: otherCount });
|
||
|
||
const dataPoints = topEntries.map((entry, index) => ({
|
||
key: entry.label,
|
||
label: entry.label,
|
||
color: entry.label === "Other" ? "#94a3b8" : getChartColor(index),
|
||
}));
|
||
const counts = {};
|
||
topEntries.forEach((entry) => {
|
||
counts[entry.label] = entry.count;
|
||
});
|
||
|
||
return {
|
||
source,
|
||
sourceLabel: getSourceDisplayName(source),
|
||
detectorCount: sortedEntries.length,
|
||
counts,
|
||
dataPoints,
|
||
};
|
||
});
|
||
}
|
||
|
||
function toggleDetectorBreakdownSort(field) {
|
||
if (detectorBreakdownSortField === field) {
|
||
detectorBreakdownSortDirection = detectorBreakdownSortDirection === "asc" ? "desc" : "asc";
|
||
} else {
|
||
detectorBreakdownSortField = field;
|
||
detectorBreakdownSortDirection = field === "label" || field === "source" ? "asc" : "desc";
|
||
}
|
||
detectorBreakdownPage = 1;
|
||
renderDetectorBreakdown();
|
||
}
|
||
|
||
function getDetectorBreakdownSortIndicator(field) {
|
||
if (detectorBreakdownSortField !== field) return "";
|
||
return detectorBreakdownSortDirection === "asc" ? "▲" : "▼";
|
||
}
|
||
|
||
function getSourceBadgeClass(source) {
|
||
if (source === "gitleaks") return "badge-github";
|
||
if (source === "trufflehog") return "badge-gcp";
|
||
return "badge-aws";
|
||
}
|
||
|
||
function getSourceDisplayName(source) {
|
||
if (source === "gitleaks") return "Gitleaks";
|
||
if (source === "trufflehog") return "TruffleHog";
|
||
return "Kingfisher";
|
||
}
|
||
|
||
function renderDonutChart(canvas, legendEl, dataPoints, counts, emptyLabel) {
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
|
||
const total = dataPoints.reduce((sum, entry) => sum + (counts[entry.key] || 0), 0);
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
const style = getComputedStyle(document.documentElement);
|
||
const surfaceColor = style.getPropertyValue("--surface") || "#0d1424";
|
||
const textColor = style.getPropertyValue("--text-main") || "#e5e7eb";
|
||
|
||
const radius = Math.min(canvas.width, canvas.height) / 2 - 10;
|
||
const centerX = canvas.width / 2;
|
||
const centerY = canvas.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(emptyLabel, 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 (legendEl) {
|
||
legendEl.innerHTML = "";
|
||
dataPoints.forEach((entry) => {
|
||
if ((counts[entry.key] || 0) === 0) return;
|
||
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);
|
||
legendEl.appendChild(row);
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderStatusChart(counts) {
|
||
const palette = {
|
||
active: "#22c55e",
|
||
inactive: "#f97316",
|
||
not_attempted: "#38bdf8",
|
||
canary: "#a855f7",
|
||
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: "canary", label: "Canary Token", color: palette.canary },
|
||
{ key: "unknown", label: "Unknown", color: palette.unknown },
|
||
];
|
||
renderDonutChart(statusChartCanvas, statusLegend, dataPoints, counts, "No validation data yet");
|
||
}
|
||
|
||
function renderSourceChart(counts) {
|
||
const dataPoints = [
|
||
{ key: "kingfisher", label: "Kingfisher", color: "#0e7c56" },
|
||
{ key: "gitleaks", label: "Gitleaks", color: "#2563eb" },
|
||
{ key: "trufflehog", label: "TruffleHog", color: "#7c3aed" },
|
||
];
|
||
const loadedSources = dataPoints.filter((entry) => (counts[entry.key] || 0) > 0);
|
||
renderDonutChart(
|
||
sourceChartCanvas,
|
||
sourceLegend,
|
||
loadedSources,
|
||
counts,
|
||
"No source data yet",
|
||
);
|
||
}
|
||
|
||
function renderSourceDetectorCharts() {
|
||
if (!sourceDetectorCharts) return;
|
||
const chartEntries = getSourceDetectorChartEntries(findings);
|
||
if (!chartEntries.length) {
|
||
sourceDetectorCharts.innerHTML = '<div class="breakdown-empty">No findings loaded yet.</div>';
|
||
return;
|
||
}
|
||
|
||
sourceDetectorCharts.innerHTML = "";
|
||
chartEntries.forEach((entry, index) => {
|
||
const card = document.createElement("div");
|
||
card.className = "chart-card";
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = 260;
|
||
canvas.height = 220;
|
||
canvas.id = `source-detector-chart-${index}`;
|
||
|
||
const legendWrap = document.createElement("div");
|
||
legendWrap.className = "chart-legend";
|
||
|
||
const heading = document.createElement("div");
|
||
heading.style.marginBottom = "10px";
|
||
|
||
const titleRow = document.createElement("div");
|
||
titleRow.className = "chart-legend-item";
|
||
titleRow.style.fontWeight = "700";
|
||
titleRow.textContent = `${entry.sourceLabel} detector mix`;
|
||
|
||
const subtitleRow = document.createElement("div");
|
||
subtitleRow.className = "chart-legend-item";
|
||
subtitleRow.style.color = "var(--text-muted)";
|
||
subtitleRow.textContent = `${entry.detectorCount} detector families${entry.detectorCount > 6 ? ", top 6 plus Other" : ""}`;
|
||
|
||
heading.appendChild(titleRow);
|
||
heading.appendChild(subtitleRow);
|
||
legendWrap.appendChild(heading);
|
||
|
||
const legend = document.createElement("div");
|
||
legend.className = "chart-legend";
|
||
card.appendChild(canvas);
|
||
legendWrap.appendChild(legend);
|
||
card.appendChild(legendWrap);
|
||
sourceDetectorCharts.appendChild(card);
|
||
|
||
renderDonutChart(canvas, legend, entry.dataPoints, entry.counts, "No detector data yet");
|
||
});
|
||
}
|
||
|
||
function renderDetectorBreakdown() {
|
||
if (!detectorBreakdown) return;
|
||
const entries = getDetectorBreakdownEntries(findings);
|
||
const pageSize = 25;
|
||
const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
|
||
detectorBreakdownPage = Math.min(Math.max(1, detectorBreakdownPage), totalPages);
|
||
|
||
if (detectorBreakdownPrev) detectorBreakdownPrev.disabled = detectorBreakdownPage <= 1;
|
||
if (detectorBreakdownNext) detectorBreakdownNext.disabled = detectorBreakdownPage >= totalPages;
|
||
|
||
if (!entries.length) {
|
||
if (detectorBreakdownPageInfo) detectorBreakdownPageInfo.textContent = "0 of 0";
|
||
detectorBreakdown.innerHTML = `<div class="breakdown-empty">${
|
||
detectorBreakdownActiveOnly ? "No active findings loaded yet." : "No findings loaded yet."
|
||
}</div>`;
|
||
return;
|
||
}
|
||
|
||
const pageStart = (detectorBreakdownPage - 1) * pageSize;
|
||
const pageEntries = entries.slice(pageStart, pageStart + pageSize);
|
||
const rangeStart = pageStart + 1;
|
||
const rangeEnd = Math.min(pageStart + pageEntries.length, entries.length);
|
||
if (detectorBreakdownPageInfo) {
|
||
detectorBreakdownPageInfo.textContent = `${rangeStart}-${rangeEnd} of ${entries.length}`;
|
||
}
|
||
|
||
const rows = pageEntries.map((entry) => `
|
||
<tr>
|
||
<td>
|
||
<div class="breakdown-name">${escapeHtml(entry.label)}</div>
|
||
</td>
|
||
<td><span class="badge ${getSourceBadgeClass(entry.source)}">${escapeHtml(getSourceDisplayName(entry.source))}</span></td>
|
||
<td class="breakdown-count">${entry.count}</td>
|
||
<td class="breakdown-count">${entry.active}</td>
|
||
</tr>
|
||
`).join("");
|
||
|
||
detectorBreakdown.innerHTML = `
|
||
<table class="breakdown-table">
|
||
<thead>
|
||
<tr>
|
||
<th><button class="breakdown-sort-btn" type="button" onclick="toggleDetectorBreakdownSort('label')">Detector <span class="breakdown-sort-indicator">${getDetectorBreakdownSortIndicator("label")}</span></button></th>
|
||
<th><button class="breakdown-sort-btn" type="button" onclick="toggleDetectorBreakdownSort('source')">Source <span class="breakdown-sort-indicator">${getDetectorBreakdownSortIndicator("source")}</span></button></th>
|
||
<th><button class="breakdown-sort-btn" type="button" onclick="toggleDetectorBreakdownSort('count')">${detectorBreakdownActiveOnly ? "Active findings" : "Findings"} <span class="breakdown-sort-indicator">${getDetectorBreakdownSortIndicator("count")}</span></button></th>
|
||
<th><button class="breakdown-sort-btn" type="button" onclick="toggleDetectorBreakdownSort('active')">Active <span class="breakdown-sort-indicator">${getDetectorBreakdownSortIndicator("active")}</span></button></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
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",
|
||
"source_tool",
|
||
"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 || "",
|
||
getFindingSourceDisplayName(finding),
|
||
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, canary: 2, not_attempted: 3, unknown: 4 };
|
||
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 sourceTool = getFindingSourceDisplayName(finding);
|
||
const statusColor = normalizedStatus === "active" ? "#dc2626" : normalizedStatus === "inactive" ? "#f97316" : "#6b7280";
|
||
return `
|
||
<tr>
|
||
<td>${escapeHtml(rule.name || rule.id || "")}</td>
|
||
<td>${escapeHtml(sourceTool)}</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="8">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.reportSource) metaLines.push(`<strong>Source:</strong> ${escapeHtml(scanMetadata.reportSource)}`);
|
||
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.` : ""}
|
||
${counts.canary > 0 ? `${counts.canary} ${counts.canary !== 1 ? "were" : "was"} canary token${counts.canary !== 1 ? "s" : ""} (skipped).` : ""}
|
||
${scanMetadata.imported ? `Imported reports do not include Kingfisher access-map data or command workflows.` : ""}
|
||
</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:#a855f7"></span> Canary Token: ${counts.canary || 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%;">Tool</th>
|
||
<th style="width:10%;">Fingerprint</th>
|
||
<th style="width:18%;">File Path</th>
|
||
<th style="width:12%;">Status</th>
|
||
<th style="width:9%;">Confidence</th>
|
||
<th style="width:5%;">Line</th>
|
||
<th style="width:21%;">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 & 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="6" 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 enrichedChip = finding.kingfisher_enrichment
|
||
? `<span class="chip-enriched" title="Enriched with Kingfisher validation and revoke data">Enriched</span>`
|
||
: "";
|
||
|
||
const sourceTool = getFindingSourceTool(finding);
|
||
const sourceBadge = `<span class="badge ${getSourceBadgeClass(sourceTool)}">${escapeHtml(getSourceDisplayName(sourceTool))}</span>`;
|
||
|
||
const tr = document.createElement("tr");
|
||
tr.innerHTML = `
|
||
<td>
|
||
<div style="font-weight:600">${escapeHtml(ruleName)}${enrichedChip}</div>
|
||
<div style="font-size:11px; color:var(--text-muted)">${escapeHtml(rule.id || "")}</div>
|
||
</td>
|
||
<td>${sourceBadge}</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 importInfo = finding.viewer_import && typeof finding.viewer_import === "object" ? finding.viewer_import : null;
|
||
|
||
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 sourceEl = document.getElementById("fd-source");
|
||
if (sourceEl) {
|
||
sourceEl.textContent = importInfo && importInfo.source_tool ? importInfo.source_tool : (scanMetadata.reportSource || "Kingfisher");
|
||
}
|
||
|
||
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 importNoteEl = document.getElementById("fd-import-note");
|
||
if (importNoteEl) {
|
||
if (importInfo && scanMetadata.imported) {
|
||
const toolName = escapeHtml(importInfo.source_tool || "Imported report");
|
||
const note = scanMetadata.importNotes && scanMetadata.importNotes.length
|
||
? scanMetadata.importNotes.join(" ")
|
||
: "This finding was imported into the viewer and does not include Kingfisher-native access-map or command metadata.";
|
||
importNoteEl.classList.remove("hidden");
|
||
importNoteEl.innerHTML = `<strong>${toolName} import:</strong> ${escapeHtml(note)}`;
|
||
} else {
|
||
importNoteEl.classList.add("hidden");
|
||
importNoteEl.innerHTML = "";
|
||
}
|
||
}
|
||
|
||
renderKingfisherEnrichment(finding);
|
||
|
||
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 && (!importInfo || scanMetadata.capabilities.validateCommandSupported)) {
|
||
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 && (!importInfo || scanMetadata.capabilities.revokeCommandSupported)) {
|
||
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;
|
||
const repoObj = finding.git_metadata.repository || {};
|
||
const repoUrl = repoObj.url || "";
|
||
const commitId = finding.git_metadata.commit && finding.git_metadata.commit.id
|
||
? finding.git_metadata.commit.id
|
||
: "";
|
||
return buildGitFileUrl(repoUrl, commitId, finding.path || "", finding.line);
|
||
}
|
||
|
||
function getFindingSourceDisplayName(finding) {
|
||
return getSourceDisplayName(getFindingSourceTool(finding || {}));
|
||
}
|
||
|
||
function wireKfEnrichmentCopyButton(wrapId, codeId, btnId, cmd) {
|
||
const wrap = document.getElementById(wrapId);
|
||
const code = document.getElementById(codeId);
|
||
const btn = document.getElementById(btnId);
|
||
if (!wrap || !code || !btn) return;
|
||
if (!cmd) {
|
||
wrap.classList.add("hidden");
|
||
code.textContent = "";
|
||
btn.onclick = null;
|
||
return;
|
||
}
|
||
wrap.classList.remove("hidden");
|
||
code.textContent = cmd;
|
||
btn.onclick = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(cmd);
|
||
btn.classList.add("copied");
|
||
const label = btn.querySelector("span");
|
||
if (label) label.textContent = "Copied!";
|
||
setTimeout(() => {
|
||
btn.classList.remove("copied");
|
||
const l = btn.querySelector("span");
|
||
if (l) l.textContent = "Copy";
|
||
}, 2000);
|
||
} catch (err) {
|
||
console.error("Failed to copy:", err);
|
||
}
|
||
};
|
||
}
|
||
|
||
function renderKingfisherEnrichment(finding) {
|
||
const box = document.getElementById("fd-kf-enrichment");
|
||
if (!box) return;
|
||
const enr = finding && finding.kingfisher_enrichment;
|
||
if (!enr) {
|
||
box.classList.add("hidden");
|
||
return;
|
||
}
|
||
box.classList.remove("hidden");
|
||
|
||
const strengthEl = document.getElementById("fd-kf-enrichment-strength");
|
||
if (strengthEl) {
|
||
strengthEl.textContent = enr.match_strength === "commit"
|
||
? "Strong match (commit + file + line)"
|
||
: "Partial match (file + line only)";
|
||
}
|
||
|
||
const statusRaw = enr.validation && enr.validation.status ? String(enr.validation.status) : "Unknown";
|
||
const normalized = normalizeValidationStatus(statusRaw);
|
||
const badgeClass = normalized === "active" ? "active" : normalized === "inactive" ? "inactive" : "unknown";
|
||
const validationEl = document.getElementById("fd-kf-validation");
|
||
if (validationEl) {
|
||
validationEl.innerHTML = `<span class="status-badge ${badgeClass}">${escapeHtml(statusRaw)}</span>`;
|
||
}
|
||
|
||
const resEl = document.getElementById("fd-kf-validation-res");
|
||
if (resEl) {
|
||
if (enr.validation && enr.validation.response) {
|
||
resEl.classList.remove("hidden");
|
||
resEl.textContent = enr.validation.response;
|
||
} else {
|
||
resEl.classList.add("hidden");
|
||
resEl.textContent = "";
|
||
}
|
||
}
|
||
|
||
wireKfEnrichmentCopyButton("fd-kf-validate-wrap", "fd-kf-validate-cmd", "fd-kf-validate-copy", enr.validate_command);
|
||
wireKfEnrichmentCopyButton("fd-kf-revoke-wrap", "fd-kf-revoke-cmd", "fd-kf-revoke-copy", enr.revoke_command);
|
||
|
||
const matchKeyEl = document.getElementById("fd-kf-match-key");
|
||
if (matchKeyEl) {
|
||
matchKeyEl.textContent = enr.match_strength === "commit"
|
||
? "commit + file + line"
|
||
: "file + line (no commit context)";
|
||
}
|
||
const ruleEl = document.getElementById("fd-kf-rule");
|
||
if (ruleEl) {
|
||
ruleEl.textContent = enr.kingfisher_rule_id || enr.kingfisher_rule_name || "(unknown rule)";
|
||
}
|
||
}
|
||
|
||
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 importInfo = fd.viewer_import && typeof fd.viewer_import === "object" ? fd.viewer_import : null;
|
||
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 (importInfo && scanMetadata.imported) {
|
||
return {
|
||
level: isActive ? "low" : "none",
|
||
text: `This finding was imported from ${escapeHtml(importInfo.source_tool || "an external tool")}. Imported reports do not carry Kingfisher access-map data, so blast radius cannot be derived here. Re-scan with <code>kingfisher scan --access-map</code> for native mapping.`,
|
||
};
|
||
}
|
||
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 sourceTool = getFindingSourceDisplayName(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>Tool</label><div>${escapeHtml(sourceTool)}</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, canary: 2, not_attempted: 3, unknown: 4 };
|
||
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);
|
||
const sourceTool = getFindingSourceDisplayName(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>Tool:</strong> ${escapeHtml(sourceTool)}</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;">${getAccessMapEmptyMessage()}</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>
|