keywarden-ssh 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
keywarden/ui_page.py ADDED
@@ -0,0 +1,665 @@
1
+ """The page keywarden ui serves. One file, no external requests: no fonts, scripts, or images from anywhere else."""
2
+
3
+ from __future__ import annotations
4
+
5
+ PAGE = r"""<!doctype html>
6
+ <html lang="en">
7
+ <head>
8
+ <meta charset="utf-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <meta name="referrer" content="no-referrer">
11
+ <meta name="color-scheme" content="light dark">
12
+ <title>Keywarden</title>
13
+ <style nonce="__NONCE__">
14
+ /* Layout: a soft colour field behind everything; a floating glass toolbar, a floating glass sidebar of grouped settings, and glass cards for results. Follows the system light or dark appearance. */
15
+ :root {
16
+ --base: #eef1f7;
17
+ --blob-1: rgba(88, 140, 255, .55); --blob-2: rgba(130, 96, 255, .38); --blob-3: rgba(64, 200, 210, .40); --blob-4: rgba(255, 150, 190, .30);
18
+ --glass: rgba(255,255,255,.52); --glass-strong: rgba(255,255,255,.72); --glass-soft: rgba(255,255,255,.34);
19
+ --glass-border: rgba(255,255,255,.75); --glass-edge: rgba(15,23,42,.08); --glass-shine: rgba(255,255,255,.9);
20
+ --fill: rgba(120,130,150,.14); --fill-strong: rgba(120,130,150,.24);
21
+ --separator: rgba(30,40,60,.10); --separator-strong: rgba(30,40,60,.22);
22
+ --label: #111827; --secondary: #4b5563; --tertiary: #6b7280;
23
+ --blue: #0a6cff; --blue-2: #3d8bff; --blue-soft: rgba(10,108,255,.14);
24
+ --red: #ff3b30; --orange: #ff9500; --green: #34c759; --indigo: #5856d6;
25
+ --red-text: #c4001a; --orange-text: #b54700; --green-text: #1f7a35; --indigo-text: #3634a3;
26
+ --shadow: 0 1px 1px rgba(15,23,42,.04), 0 8px 28px rgba(15,23,42,.08);
27
+ --blur: saturate(180%) blur(26px);
28
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI Variable Text", "Segoe UI", system-ui, "Helvetica Neue", Arial, sans-serif;
29
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, "Cascadia Mono", Consolas, monospace;
30
+ --radius: 18px;
31
+ color-scheme: light;
32
+ }
33
+ @media (prefers-color-scheme: dark) {
34
+ :root {
35
+ --base: #0b0f1a;
36
+ --blob-1: rgba(40, 90, 255, .45); --blob-2: rgba(120, 70, 255, .35); --blob-3: rgba(0, 170, 190, .30); --blob-4: rgba(255, 70, 140, .20);
37
+ --glass: rgba(28,32,44,.50); --glass-strong: rgba(36,40,54,.70); --glass-soft: rgba(255,255,255,.06);
38
+ --glass-border: rgba(255,255,255,.14); --glass-edge: rgba(0,0,0,.35); --glass-shine: rgba(255,255,255,.18);
39
+ --fill: rgba(255,255,255,.08); --fill-strong: rgba(255,255,255,.14);
40
+ --separator: rgba(255,255,255,.08); --separator-strong: rgba(255,255,255,.2);
41
+ --label: #f3f4f6; --secondary: #b4bac6; --tertiary: #8b92a1;
42
+ --blue: #3d8bff; --blue-2: #6aa6ff; --blue-soft: rgba(61,139,255,.2);
43
+ --red: #ff453a; --orange: #ff9f0a; --green: #30d158; --indigo: #7d7aff;
44
+ --red-text: #ff7a72; --orange-text: #ffb648; --green-text: #5ee08a; --indigo-text: #b3b1ff;
45
+ --shadow: 0 1px 1px rgba(0,0,0,.25), 0 10px 32px rgba(0,0,0,.35);
46
+ color-scheme: dark;
47
+ }
48
+ }
49
+ * { box-sizing: border-box; }
50
+ [hidden] { display: none !important; }
51
+ html, body { margin: 0; }
52
+ body { background: var(--base); color: var(--label); font: 14px/1.45 var(--font); -webkit-font-smoothing: antialiased; letter-spacing: -.005em; min-height: 100vh; }
53
+ .backdrop { position: fixed; inset: -10%; z-index: -1; pointer-events: none;
54
+ background:
55
+ radial-gradient(38% 42% at 12% 18%, var(--blob-1), transparent 70%),
56
+ radial-gradient(34% 40% at 88% 12%, var(--blob-2), transparent 70%),
57
+ radial-gradient(40% 44% at 78% 82%, var(--blob-3), transparent 70%),
58
+ radial-gradient(30% 34% at 22% 88%, var(--blob-4), transparent 70%),
59
+ var(--base);
60
+ filter: blur(30px); }
61
+ body::before { content: ""; position: fixed; inset: 0 0 auto; height: env(safe-area-inset-top, 0px); z-index: 21; background: var(--base); }
62
+ button, input, textarea { font: inherit; color: inherit; }
63
+ .mono { font-family: var(--mono); }
64
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--blue) 55%, transparent); outline-offset: 1px; border-radius: 10px; }
65
+
66
+ /* Glass surfaces: frosted fill, a bright top edge, a faint outer edge, and soft depth. */
67
+ .glass, .toolbar-inner, .sidebar, .card, .stat, .host, .table-wrap, .empty, .hit, .error {
68
+ background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
69
+ border: 1px solid var(--glass-border); box-shadow: inset 0 1px 0 var(--glass-shine), 0 0 0 .5px var(--glass-edge), var(--shadow);
70
+ }
71
+
72
+ /* Toolbar */
73
+ .toolbar { position: sticky; top: env(safe-area-inset-top, 0px); z-index: 20; padding: 12px max(16px, env(safe-area-inset-right, 0px)) 0 max(16px, env(safe-area-inset-left, 0px)); }
74
+ .toolbar-inner { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; padding: 8px 10px 8px 12px; border-radius: 18px; background: var(--glass-strong); }
75
+ .app { display: flex; align-items: center; gap: 10px; min-width: 0; }
76
+ .app-icon { width: 30px; height: 30px; border-radius: 8px; background: linear-gradient(160deg, #5aa2ff 0%, #0a5cff 55%, #4b2bd6 100%); display: grid; place-items: center; color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,.45), 0 2px 6px rgba(10,92,255,.35); flex: none; }
77
+ .app-name { font-weight: 600; font-size: 15px; }
78
+ .app-version { color: var(--tertiary); font-size: 12px; }
79
+ .toolbar-end { display: flex; justify-content: flex-end; gap: 8px; }
80
+ @media (max-width: 900px) { .toolbar-inner { grid-template-columns: 1fr auto; } .toolbar .segmented-wrap { grid-column: 1 / -1; grid-row: 2; justify-self: start; } }
81
+
82
+ /* Controls */
83
+ .segmented { display: inline-flex; background: var(--fill); border-radius: 12px; padding: 3px; gap: 2px; box-shadow: inset 0 1px 2px rgba(0,0,0,.06); }
84
+ .segmented button { border: 0; background: transparent; border-radius: 9px; padding: 5px 14px; font-size: 13px; font-weight: 500; color: var(--label); cursor: pointer; white-space: nowrap; }
85
+ .segmented button[aria-selected=true], .segmented button[aria-pressed=true] { background: var(--glass-strong); box-shadow: inset 0 1px 0 var(--glass-shine), 0 1px 4px rgba(0,0,0,.12); }
86
+ .btn { border: 1px solid var(--glass-border); border-radius: 10px; padding: 6px 12px; background: var(--glass-soft); font-size: 13px; font-weight: 500; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; white-space: nowrap; box-shadow: inset 0 1px 0 var(--glass-shine); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur); }
87
+ .btn:hover:not(:disabled) { background: var(--glass-strong); }
88
+ .btn:disabled { opacity: .45; cursor: default; }
89
+ .btn.primary { background: linear-gradient(180deg, var(--blue-2), var(--blue)); color: #fff; border-color: transparent; justify-content: center; padding: 8px 14px; font-size: 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,.35), 0 4px 14px color-mix(in srgb, var(--blue) 35%, transparent); }
90
+ .btn.primary:hover:not(:disabled) { background: linear-gradient(180deg, var(--blue-2), var(--blue)); filter: brightness(1.08); }
91
+ .btn.plain { background: transparent; border-color: transparent; box-shadow: none; color: var(--blue); padding: 6px 4px; backdrop-filter: none; -webkit-backdrop-filter: none; }
92
+ .btn svg { flex: none; }
93
+
94
+ /* Layout */
95
+ .layout { display: grid; grid-template-columns: 344px minmax(0, 1fr); gap: 16px; padding: 16px max(16px, env(safe-area-inset-right, 0px)) 32px max(16px, env(safe-area-inset-left, 0px)); align-items: start; }
96
+ @media (max-width: 900px) { .layout { grid-template-columns: minmax(0, 1fr); } }
97
+ .sidebar { border-radius: 22px; padding: 18px 14px 20px; display: grid; gap: 20px; align-content: start; position: sticky; top: calc(env(safe-area-inset-top, 0px) + 80px); max-height: calc(100vh - 96px); overflow-y: auto; }
98
+ @media (max-width: 900px) { .sidebar { position: static; max-height: none; } }
99
+ .content { padding: 8px 4px 32px; min-width: 0; display: grid; gap: 18px; align-content: start; }
100
+
101
+ /* Sidebar groups */
102
+ .group { display: grid; gap: 7px; min-width: 0; }
103
+ .group-title { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--secondary); padding: 0 10px; display: flex; justify-content: space-between; gap: 8px; }
104
+ .group-title span + span { text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--tertiary); }
105
+ .card { border-radius: 14px; overflow: hidden; background: var(--glass-soft); box-shadow: inset 0 1px 0 var(--glass-shine), 0 0 0 .5px var(--glass-edge); }
106
+ .card.pad { padding: 12px; display: grid; gap: 10px; }
107
+ .foot { font-size: 12px; color: var(--secondary); padding: 0 10px; margin: 0; }
108
+ textarea, input[type=text], input[type=number] { width: 100%; background: var(--fill); border: 1px solid transparent; border-radius: 10px; padding: 8px 10px; font-size: 13px; }
109
+ textarea { font: 12px/1.5 var(--mono); min-height: 112px; resize: vertical; }
110
+ textarea:focus, input[type=text]:focus, input[type=number]:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); background: var(--glass-strong); }
111
+ .actions { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
112
+ .pushset { display: grid; gap: 2px; font: 11.5px/1.5 var(--mono); color: var(--secondary); min-width: 0; }
113
+ .pushset div { overflow-wrap: anywhere; }
114
+ .lab-note { margin: 0; color: var(--secondary); font-size: 12.5px; }
115
+
116
+ .list { display: grid; }
117
+ .list > * + * { border-top: 1px solid var(--separator); }
118
+ .option { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; align-items: center; padding: 10px 12px; cursor: pointer; }
119
+ .option .text { display: grid; gap: 1px; min-width: 0; }
120
+ .option small { color: var(--secondary); font-size: 12px; }
121
+ .option .sub { grid-column: 1 / -1; margin-top: 6px; }
122
+ .option input[type=radio] { appearance: none; -webkit-appearance: none; width: 16px; height: 16px; margin: 0; border-radius: 50%; border: 1.5px solid var(--separator-strong); background: var(--glass-strong); }
123
+ .option input[type=radio]:checked { border: 5px solid var(--blue); background: #fff; }
124
+ .option input[type=checkbox] { appearance: none; -webkit-appearance: none; width: 36px; height: 22px; border-radius: 11px; background: var(--fill-strong); position: relative; margin: 0; cursor: pointer; transition: background .2s; flex: none; box-shadow: inset 0 1px 2px rgba(0,0,0,.12); }
125
+ .option input[type=checkbox]::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.25); transition: transform .2s; }
126
+ .option input[type=checkbox]:checked { background: var(--green); }
127
+ .option input[type=checkbox]:checked::after { transform: translateX(14px); }
128
+ .field { display: grid; gap: 4px; padding: 10px 12px; }
129
+ .field label { font-size: 13px; }
130
+ .field small { color: var(--secondary); font-size: 12px; }
131
+ .field.inline { grid-template-columns: 1fr 72px; align-items: center; }
132
+ .field.inline small { grid-column: 1 / -1; }
133
+ input[type=file] { font-size: 12px; color: var(--secondary); }
134
+ input[type=file]::file-selector-button { border: 1px solid var(--glass-border); border-radius: 8px; background: var(--glass-strong); padding: 4px 10px; margin-right: 8px; cursor: pointer; color: var(--label); }
135
+ details.card summary { cursor: pointer; padding: 10px 12px; list-style: none; display: flex; align-items: center; justify-content: space-between; font-size: 13px; }
136
+ details.card summary::after { content: ""; width: 7px; height: 7px; border-right: 1.5px solid var(--tertiary); border-bottom: 1.5px solid var(--tertiary); transform: rotate(-45deg); transition: transform .15s; }
137
+ details.card[open] summary::after { transform: rotate(45deg); }
138
+ details.card summary::-webkit-details-marker { display: none; }
139
+ details.card .inside { padding: 0 12px 12px; display: grid; gap: 10px; }
140
+ .help { margin: 0; color: var(--secondary); font-size: 12.5px; }
141
+
142
+ /* Summary */
143
+ .heading { display: flex; justify-content: space-between; align-items: end; gap: 16px; flex-wrap: wrap; padding: 0 4px; }
144
+ .heading h1 { margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -.025em; }
145
+ .heading p { margin: 2px 0 0; color: var(--secondary); font-size: 13px; }
146
+ .stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
147
+ @media (max-width: 1100px) { .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
148
+ @media (max-width: 520px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
149
+ .stat { border-radius: var(--radius); padding: 13px 16px; display: grid; gap: 2px; }
150
+ .stat span { color: var(--secondary); font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 6px; }
151
+ .stat span::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--tertiary); box-shadow: 0 0 8px currentColor; }
152
+ .stat.c-red span::before { background: var(--red); color: var(--red); } .stat.c-orange span::before { background: var(--orange); color: var(--orange); } .stat.c-blue span::before { background: var(--blue); color: var(--blue); }
153
+ .stat b { font-size: 30px; font-weight: 600; letter-spacing: -.025em; font-variant-numeric: tabular-nums; }
154
+ .filters { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
155
+
156
+ /* Hosts */
157
+ .hosts { display: grid; gap: 12px; }
158
+ .host { border-radius: var(--radius); overflow: hidden; }
159
+ .host-head { width: 100%; display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 1px 12px; align-items: center; padding: 13px 16px; background: none; border: 0; text-align: left; cursor: pointer; }
160
+ .host-head:hover { background: var(--glass-soft); }
161
+ .host-head .name { font-size: 15px; font-weight: 600; }
162
+ .host-head .role { color: var(--secondary); font-weight: 400; margin-left: 8px; font-size: 13px; }
163
+ .host-head .meta { grid-column: 2; color: var(--secondary); font-size: 12px; overflow-wrap: anywhere; }
164
+ .count { font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 999px; white-space: nowrap; }
165
+ .count.sev-fail { background: color-mix(in srgb, var(--red) 16%, transparent); color: var(--red-text); }
166
+ .count.sev-warn { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange-text); }
167
+ .count.sev-pass { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green-text); }
168
+ .chev { width: 14px; height: 14px; color: var(--tertiary); transition: transform .2s; }
169
+ .host[data-open=true] .chev { transform: rotate(180deg); }
170
+ .findings { border-top: 1px solid var(--separator); background: var(--glass-soft); }
171
+ .finding { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 3px 12px; padding: 11px 16px 12px; }
172
+ .finding + .finding, .passes { border-top: 1px solid var(--separator); }
173
+ .finding .title { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; font-size: 14px; font-weight: 500; }
174
+ .finding .detail { grid-column: 2; color: var(--secondary); font-size: 12.5px; line-height: 1.5; overflow-wrap: anywhere; }
175
+ .badge { font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange-text); }
176
+ .badge.acpt { background: color-mix(in srgb, var(--indigo) 16%, transparent); color: var(--indigo-text); }
177
+ .badge.code { background: transparent; color: var(--tertiary); font: 11px var(--mono); padding: 0; }
178
+ .controls { grid-column: 2; display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
179
+ .ctl { font: 10.5px/1.6 var(--mono); padding: 0 6px; border-radius: 6px; background: var(--fill); color: var(--secondary); white-space: nowrap; }
180
+ .ctl b { font-weight: 600; color: var(--label); font-family: var(--font); font-size: 10px; letter-spacing: .02em; margin-right: 3px; }
181
+ .ico { width: 18px; height: 18px; flex: none; margin-top: 1px; filter: drop-shadow(0 1px 2px rgba(0,0,0,.15)); }
182
+ .sev-fail { color: var(--red); } .sev-warn { color: var(--orange); } .sev-pass { color: var(--green); } .sev-accepted { color: var(--indigo); }
183
+ .passes summary { cursor: pointer; padding: 10px 16px; color: var(--blue); font-size: 13px; list-style: none; }
184
+ .passes summary::-webkit-details-marker { display: none; }
185
+ .passes[open] summary { border-bottom: 1px solid var(--separator); }
186
+
187
+ /* States */
188
+ .empty { border-radius: 22px; padding: 56px 24px; text-align: center; display: grid; gap: 6px; justify-items: center; color: var(--secondary); }
189
+ .empty b { color: var(--label); font-size: 18px; font-weight: 600; }
190
+ .empty .glyph { width: 52px; height: 52px; border-radius: 14px; background: linear-gradient(160deg, #5aa2ff 0%, #0a5cff 55%, #4b2bd6 100%); color: #fff; display: grid; place-items: center; margin-bottom: 8px; box-shadow: inset 0 1px 0 rgba(255,255,255,.45), 0 6px 18px rgba(10,92,255,.35); }
191
+ .empty .glyph .ico { width: 26px; height: 26px; filter: none; }
192
+ .empty .btn { margin-top: 12px; }
193
+ .error { background: color-mix(in srgb, var(--red) 12%, var(--glass)); color: var(--label); border-radius: 14px; padding: 10px 14px; font-size: 13px; }
194
+ .info { color: var(--secondary); font-size: 13px; margin: 0; }
195
+ .notes { display: grid; gap: 4px; padding: 0 4px; }
196
+ .notes .lab-note { font-size: 12px; color: var(--tertiary); }
197
+
198
+ /* Table */
199
+ .table-tools { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; color: var(--secondary); font-size: 13px; padding: 0 4px; }
200
+ .table-tools .option { padding: 0; grid-template-columns: auto auto; }
201
+ .table-wrap { overflow-x: auto; border-radius: var(--radius); }
202
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
203
+ th, td { text-align: left; padding: 9px 14px; white-space: nowrap; }
204
+ thead th { font-size: 12px; font-weight: 500; color: var(--secondary); border-bottom: 1px solid var(--separator); }
205
+ tbody tr:nth-child(even) td { background: var(--glass-soft); }
206
+ tbody tr:hover td { background: var(--fill); }
207
+ th button { background: none; border: 0; padding: 0; cursor: pointer; color: inherit; font-weight: inherit; }
208
+ td.fp, td.mono { font-family: var(--mono); font-size: 12px; }
209
+ .pill { font-size: 11.5px; font-weight: 600; padding: 1px 9px; border-radius: 999px; background: var(--fill); color: var(--secondary); }
210
+ .pill.bad { background: color-mix(in srgb, var(--red) 16%, transparent); color: var(--red-text); }
211
+ .pill.ok { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green-text); }
212
+ .pill.warnp { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange-text); }
213
+
214
+ /* Find a key */
215
+ .find-form { display: grid; gap: 10px; max-width: 760px; }
216
+ .find-form > .card { background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur); box-shadow: inset 0 1px 0 var(--glass-shine), 0 0 0 .5px var(--glass-edge), var(--shadow); border-radius: var(--radius); }
217
+ .hits { display: grid; gap: 10px; }
218
+ .hit { border-radius: var(--radius); padding: 12px 16px; display: grid; gap: 3px; }
219
+ .hit .where { font-weight: 600; }
220
+ .hit .mono { color: var(--tertiary); font-size: 12px; overflow-wrap: anywhere; }
221
+ .spin { display: inline-block; width: 13px; height: 13px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: s .8s linear infinite; }
222
+ @keyframes s { to { transform: rotate(360deg); } }
223
+ @media (prefers-reduced-motion: reduce) { .spin, .chev, .option input::after, details.card summary::after { animation: none; transition: none; } }
224
+ /* Without backdrop blur, fall back to near-solid panels so text stays readable. */
225
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
226
+ :root { --glass: rgba(255,255,255,.92); --glass-strong: rgba(255,255,255,.96); --glass-soft: rgba(255,255,255,.7); }
227
+ @media (prefers-color-scheme: dark) { :root { --glass: rgba(28,32,44,.94); --glass-strong: rgba(36,40,54,.96); --glass-soft: rgba(255,255,255,.06); } }
228
+ }
229
+ .legal { color: var(--tertiary); font-size: 11.5px; padding: 0 10px; margin: 0; }
230
+ </style>
231
+ </head>
232
+ <body>
233
+ <div class="backdrop" aria-hidden="true"></div>
234
+ <header class="toolbar">
235
+ <div class="toolbar-inner">
236
+ <div class="app">
237
+ <div class="app-icon" aria-hidden="true"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4.5"/><path d="M11.2 11.8 20 3"/><path d="M16 7l3 3"/><path d="M18.5 4.5l2 2"/></svg></div>
238
+ <span class="app-name">Keywarden</span><span class="app-version">__VERSION__</span>
239
+ </div>
240
+ <div class="segmented-wrap">
241
+ <div class="segmented" role="tablist" aria-label="Views">
242
+ <button role="tab" aria-selected="true" data-view="findings" type="button">Findings</button>
243
+ <button role="tab" aria-selected="false" data-view="review" type="button">Access Review</button>
244
+ <button role="tab" aria-selected="false" data-view="find" type="button">Find a Key</button>
245
+ </div>
246
+ </div>
247
+ <div class="toolbar-end">
248
+ <button class="btn" id="export-json" type="button" disabled title="Download the findings as JSON. Key bodies are not included.">
249
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>Export
250
+ </button>
251
+ <button class="btn" id="export-csv" type="button" disabled title="Download the access review table as CSV">CSV</button>
252
+ <button class="btn" id="export-evidence" type="button" disabled title="Download an evidence report (HTML) with CIS, NIST, PCI DSS, and NERC CIP mapping">Evidence</button>
253
+ </div>
254
+ </div>
255
+ </header>
256
+
257
+ <div class="layout">
258
+ <aside class="sidebar" aria-label="Check settings">
259
+ <section class="group" aria-labelledby="push-title">
260
+ <div class="group-title"><span id="push-title">Public keys you push</span><span>Session only</span></div>
261
+ <div class="card pad">
262
+ <textarea id="allow" aria-label="Public keys you push" spellcheck="false" autocomplete="off" placeholder="ssh-ed25519 AAAA… ops@example"></textarea>
263
+ <div class="actions">
264
+ <button class="btn primary" id="run" type="button">Run Checks</button>
265
+ <button class="btn" id="lab" type="button">Use Lab Keys</button>
266
+ </div>
267
+ <p class="lab-note" id="push-note" hidden></p>
268
+ <div class="pushset" id="pushset"></div>
269
+ </div>
270
+ <p class="foot">One key per line. The text is fingerprinted and cleared when you run. Only fingerprints stay in this window.</p>
271
+ <p class="foot"><button class="btn plain" id="drop" type="button">Clear Keys</button></p>
272
+ </section>
273
+
274
+ <section class="group" aria-labelledby="target-title">
275
+ <div class="group-title"><span id="target-title">Check</span></div>
276
+ <div class="card list" role="radiogroup" aria-labelledby="target-title">
277
+ <label class="option"><span class="text"><span>Lab hosts</span><small>Six example hosts</small></span><input type="radio" name="target" value="lab" id="t-lab" checked></label>
278
+ <label class="option"><span class="text"><span>This machine</span><small>Run as root or with passwordless sudo</small></span><input type="radio" name="target" value="local" id="t-local"></label>
279
+ <label class="option"><span class="text"><span>One host</span><small>SSH in batch mode</small></span><input type="radio" name="target" value="host" id="t-host">
280
+ <input class="sub" type="text" id="host" aria-label="Host" placeholder="deploy@app-web-03.example" autocomplete="off" spellcheck="false"></label>
281
+ <label class="option"><span class="text"><span>Ansible inventory</span><small>INI file on this machine</small></span><input type="radio" name="target" value="inventory" id="t-inv">
282
+ <input class="sub" type="text" id="inventory" aria-label="Inventory path" placeholder="~/ansible/hosts.ini" autocomplete="off" spellcheck="false"></label>
283
+ <label class="option"><span class="text"><span>Trust new host keys</span><small>Off: an unknown host key fails the host</small></span><input type="checkbox" id="accept-new"></label>
284
+ </div>
285
+ </section>
286
+
287
+ <section class="group" aria-labelledby="checks-title">
288
+ <div class="group-title"><span id="checks-title">Checks</span></div>
289
+ <div class="card list">
290
+ <label class="option"><span class="text"><span>Flag keys outside the push</span><small>Anything authorized you did not push</small></span><input type="checkbox" id="flag-outside" checked></label>
291
+ <label class="option"><span class="text"><span>Flag weak algorithms</span><small>DSA, and RSA under 3072 bits</small></span><input type="checkbox" id="flag-weak" checked></label>
292
+ <div class="field inline"><label for="stale">Stale after (days)</label><input type="number" id="stale" min="1" max="3650" value="90"><small>Counted only when the host's logs go back that far</small></div>
293
+ <div class="field"><label for="accounts">Extra accounts</label><input type="text" id="accounts" placeholder="postgres, svc-backup" autocomplete="off" spellcheck="false"></div>
294
+ <div class="field"><label for="waivers">Waivers file</label><input type="text" id="waivers" placeholder="~/keywarden.toml" autocomplete="off" spellcheck="false"></div>
295
+ <div class="field"><label for="prior">Earlier report</label><input type="file" id="prior" accept=".json,application/json"><small>Marks what regressed since then. Read in this window only.</small></div>
296
+ </div>
297
+ </section>
298
+
299
+ <section class="group">
300
+ <div class="group-title"><span>Offline</span></div>
301
+ <details class="card" id="paste-panel">
302
+ <summary>Check pasted collector output</summary>
303
+ <div class="inside">
304
+ <p class="help">Run the collector on the host and paste what it prints. Don't save that output; it contains public keys.</p>
305
+ <button class="btn" id="copy-collector" type="button">Copy Collector Script</button>
306
+ <textarea id="observation" aria-label="Collector output" spellcheck="false" autocomplete="off" placeholder="KEYWARDEN 3&#10;HOST …"></textarea>
307
+ <button class="btn primary" id="run-paste" type="button">Check Paste</button>
308
+ </div>
309
+ </details>
310
+ </section>
311
+ <p class="legal">Runs on this machine and talks only to the hosts you check. Open source, MIT.</p>
312
+ </aside>
313
+
314
+ <main class="content" id="results" aria-label="Results">
315
+ <div class="heading">
316
+ <div><h1 id="page-title">Findings</h1><p id="page-sub">No hosts checked yet</p></div>
317
+ <div id="filters"></div>
318
+ </div>
319
+ <div id="message"></div>
320
+ <div class="stats" id="stats"></div>
321
+ <div id="view-findings"></div>
322
+ <div id="view-review" hidden></div>
323
+ <div id="view-find" hidden>
324
+ <div class="find-form">
325
+ <p class="info">Find every host and account that still accepts a key, for offboarding or a lost laptop. Uses the hosts selected under Check.</p>
326
+ <div class="card list">
327
+ <div class="field"><label for="find-fp">Fingerprint</label><input type="text" id="find-fp" placeholder="SHA256:…" autocomplete="off" spellcheck="false"></div>
328
+ <div class="field"><label for="find-key">Or paste a public key</label><textarea id="find-key" spellcheck="false" autocomplete="off" placeholder="ssh-ed25519 AAAA… alice@laptop"></textarea></div>
329
+ </div>
330
+ <div><button class="btn primary" id="find" type="button">Find Key</button></div>
331
+ </div>
332
+ <div class="hits" id="hits"></div>
333
+ </div>
334
+ <div class="notes" id="notes"></div>
335
+ </main>
336
+ </div>
337
+
338
+ <script nonce="__NONCE__">
339
+ "use strict";
340
+ const TOKEN = "__TOKEN__";
341
+ history.replaceState(null, "", "/");
342
+
343
+ const $ = (id) => document.getElementById(id);
344
+ const state = { push: [], lab: false, report: null, csv: "", evidence: "", filter: "work", view: "findings", sort: { key: "host", dir: 1 }, onlyRoot: false, onlyStale: false, prior: null, busy: false };
345
+
346
+ // Build elements with text nodes only. Values from hosts never reach innerHTML.
347
+ function h(tag, attrs, ...kids) {
348
+ const el = document.createElement(tag);
349
+ for (const [k, v] of Object.entries(attrs || {})) {
350
+ if (v === false || v == null) continue;
351
+ if (k === "class") el.className = v;
352
+ else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
353
+ else el.setAttribute(k, v === true ? "" : String(v));
354
+ }
355
+ for (const kid of kids.flat()) {
356
+ if (kid == null || kid === false) continue;
357
+ el.append(kid instanceof Node ? kid : document.createTextNode(String(kid)));
358
+ }
359
+ return el;
360
+ }
361
+ // Static icon markup only; nothing from a host goes in here.
362
+ const ICONS = {
363
+ fail: '<circle cx="12" cy="12" r="10" fill="currentColor"/><path d="M8.5 8.5l7 7M15.5 8.5l-7 7" stroke="#fff" stroke-width="2" stroke-linecap="round"/>',
364
+ warn: '<path d="M12 2.8 22 20.5H2z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M12 9.5v5" stroke="#fff" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="17.6" r="1.2" fill="#fff"/>',
365
+ pass: '<circle cx="12" cy="12" r="10" fill="currentColor"/><path d="M7.5 12.3l3 3 6-6.3" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
366
+ accepted: '<circle cx="12" cy="12" r="10" fill="currentColor"/><path d="M8 12h8" stroke="#fff" stroke-width="2" stroke-linecap="round"/>',
367
+ chevron: '<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
368
+ shield: '<path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/><path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
369
+ };
370
+ function icon(name, cls) {
371
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
372
+ svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none");
373
+ svg.setAttribute("aria-hidden", "true"); svg.setAttribute("class", cls || "ico");
374
+ svg.innerHTML = ICONS[name];
375
+ return svg;
376
+ }
377
+
378
+ async function api(path, body) {
379
+ const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json", "X-Keywarden-Token": TOKEN }, body: JSON.stringify(body || {}), cache: "no-store" });
380
+ const data = await response.json().catch(() => ({ error: "The server sent something unreadable." }));
381
+ if (!response.ok) throw new Error(data.error || "The check failed.");
382
+ return data;
383
+ }
384
+ function showError(text) { $("message").replaceChildren(text ? h("div", { class: "error", role: "alert" }, text) : ""); }
385
+ function showInfo(text) { $("message").replaceChildren(h("p", { class: "info", role: "status" }, text)); }
386
+
387
+ function selectedTarget() { return document.querySelector('input[name="target"]:checked').value; }
388
+ function baseBody(target) {
389
+ return {
390
+ target,
391
+ host: $("host").value.trim(),
392
+ inventoryPath: $("inventory").value.trim(),
393
+ acceptNew: $("accept-new").checked,
394
+ accounts: $("accounts").value.split(/[\s,]+/).filter(Boolean),
395
+ };
396
+ }
397
+
398
+ function setBusy(busy, label) {
399
+ state.busy = busy;
400
+ for (const id of ["run", "run-paste", "find", "lab"]) $(id).disabled = busy;
401
+ $("run").replaceChildren(busy ? h("span", { class: "spin", "aria-hidden": "true" }) : "", busy ? (label || " Checking…") : "Run Checks");
402
+ }
403
+
404
+ async function runCheck(target) {
405
+ if (state.busy) return;
406
+ showError("");
407
+ const allowText = $("allow").value;
408
+ const body = Object.assign(baseBody(target), {
409
+ allowText,
410
+ allowFingerprints: allowText.trim() ? [] : state.push,
411
+ flagOutside: $("flag-outside").checked,
412
+ flagWeak: $("flag-weak").checked,
413
+ staleDays: parseInt($("stale").value, 10) || 90,
414
+ waiversPath: $("waivers").value.trim(),
415
+ prior: state.prior,
416
+ });
417
+ if (target === "paste") body.observation = $("observation").value;
418
+ // The key text leaves the page now. It is not kept here, sent again, or saved.
419
+ $("allow").value = "";
420
+ if (target === "paste") $("observation").value = "";
421
+ setBusy(true, target === "inventory" ? " Checking hosts…" : " Checking…");
422
+ try {
423
+ const data = await api("/api/check", body);
424
+ state.report = data.report; state.csv = data.csv; state.evidence = data.evidence;
425
+ state.push = (data.report.push || []).map((k) => ({ fingerprint: k.fingerprint, type: k.type, bits: k.bits, comment: k.comment }));
426
+ state.lab = data.lab;
427
+ $("export-json").disabled = false; $("export-csv").disabled = false; $("export-evidence").disabled = false;
428
+ renderPush(); renderAll(data.notes || []);
429
+ } catch (error) {
430
+ showError(error.message);
431
+ } finally {
432
+ setBusy(false);
433
+ }
434
+ }
435
+
436
+ function renderPush() {
437
+ const note = $("push-note");
438
+ const labPush = state.lab && state.push.length && state.push.every((k) => /@latch$|^ci-bot$/.test(k.comment));
439
+ note.hidden = !state.push.length;
440
+ note.textContent = labPush ? "Comparing against the lab keys (ops, deploy, ci), not yours." : (state.push.length ? `Comparing against ${state.push.length} key${state.push.length === 1 ? "" : "s"}. Fingerprints only.` : "");
441
+ $("pushset").replaceChildren(...state.push.map((k) => h("div", {}, `${k.fingerprint.slice(0, 19)}… ${k.comment || k.type}`)));
442
+ }
443
+
444
+ function stats() {
445
+ const hosts = state.report ? state.report.hosts : [];
446
+ const keys = hosts.flatMap((host) => host.keys || []);
447
+ const failed = hosts.reduce((n, host) => n + host.findings.filter((f) => f.severity === "fail").length, 0);
448
+ const tiles = [
449
+ ["Hosts", hosts.length, "c-blue"],
450
+ ["Failing hosts", hosts.filter((host) => host.posture === "fail").length, "c-red"],
451
+ ["Failed checks", failed, "c-red"],
452
+ ["Keys to root", keys.filter((k) => k.reach === "root" || k.reach === "root-nopasswd").length, "c-orange"],
453
+ ["Stale keys", keys.filter((k) => k.usage === "stale").length, "c-orange"],
454
+ ];
455
+ $("stats").hidden = !state.report;
456
+ $("stats").replaceChildren(...tiles.map(([label, value, cls]) => h("div", { class: "stat " + cls }, h("span", {}, label), h("b", {}, value))));
457
+ }
458
+
459
+ const ORDER = { fail: 0, warn: 1, accepted: 2, pass: 3 };
460
+ function when(iso) {
461
+ if (!iso) return "";
462
+ const date = new Date(iso);
463
+ return isNaN(date) ? iso : date.toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
464
+ }
465
+
466
+ function findingRow(item) {
467
+ const sev = item.severity;
468
+ return h("div", { class: "finding" },
469
+ h("span", { class: "sev-" + sev }, icon(sev)),
470
+ h("div", { class: "title" },
471
+ h("span", {}, item.title),
472
+ item.drift ? h("span", { class: "badge" }, item.drift === "new-keys" ? "New keys" : item.drift.charAt(0).toUpperCase() + item.drift.slice(1)) : "",
473
+ sev === "accepted" ? h("span", { class: "badge acpt" }, "Accepted") : "",
474
+ h("span", { class: "badge code" }, item.code)),
475
+ h("div", { class: "detail" }, item.detail),
476
+ (item.controls || []).length && sev !== "pass" ? h("div", { class: "controls", "aria-label": "Related controls" },
477
+ item.controls.map((c) => h("span", { class: "ctl", title: c.name }, h("b", {}, FRAMEWORK_LABEL[c.framework] || c.framework), c.id))) : "");
478
+ }
479
+ const FRAMEWORK_LABEL = { cis: "CIS", nist: "NIST", pci: "PCI", cip: "CIP" };
480
+
481
+ function hostCard(host, open) {
482
+ const fails = host.findings.filter((f) => f.severity === "fail").length;
483
+ const warns = host.findings.filter((f) => f.severity === "warn").length;
484
+ const sorted = [...host.findings].sort((a, b) => ORDER[a.severity] - ORDER[b.severity]);
485
+ const shown = sorted.filter((f) => f.severity !== "pass");
486
+ const passes = sorted.filter((f) => f.severity === "pass");
487
+ const card = h("article", { class: "host", "data-open": open ? "true" : "false" });
488
+ const list = h("div", { class: "findings", hidden: !open },
489
+ shown.map(findingRow),
490
+ passes.length ? h("details", { class: "passes" }, h("summary", {}, `Show ${passes.length} passing check${passes.length === 1 ? "" : "s"}`), passes.map(findingRow)) : "");
491
+ const sev = host.posture === "fail" ? "fail" : host.posture === "warn" ? "warn" : "pass";
492
+ const count = fails ? `${fails} failed` : warns ? `${warns} to review` : "Passed";
493
+ const head = h("button", { class: "host-head", type: "button", "aria-expanded": open ? "true" : "false", onclick: () => {
494
+ const now = card.dataset.open !== "true";
495
+ card.dataset.open = String(now); list.hidden = !now; head.setAttribute("aria-expanded", String(now));
496
+ } },
497
+ h("span", { class: "sev-" + sev }, icon(sev, "ico")),
498
+ h("span", {}, h("span", { class: "name" }, host.name), host.roleLabel ? h("span", { class: "role" }, host.roleLabel) : ""),
499
+ h("span", { class: "count sev-" + sev }, count),
500
+ icon("chevron", "chev"),
501
+ h("span", { class: "meta" }, [host.address, host.observedAt ? "Checked " + when(host.observedAt) : "", host.authLog && host.authLog !== "unknown" ? "Logs: " + host.authLog : ""].filter(Boolean).join(" · ")));
502
+ card.append(head, list);
503
+ return card;
504
+ }
505
+
506
+ function emptyState() {
507
+ return h("div", { class: "empty" },
508
+ h("div", { class: "glyph" }, icon("shield", "ico")),
509
+ h("b", {}, "Check who can get in"),
510
+ h("span", {}, "Paste the public keys you push, choose what to check, and run. Keys are fingerprinted and never stored."),
511
+ h("button", { class: "btn", type: "button", onclick: () => { $("lab").click(); setTimeout(() => runCheck("lab"), 150); } }, "Try the Lab Hosts"));
512
+ }
513
+
514
+ function renderFilters() {
515
+ const box = $("filters");
516
+ if (!state.report || state.view !== "findings") { box.replaceChildren(); return; }
517
+ const hosts = state.report.hosts;
518
+ const needs = hosts.filter((host) => host.posture !== "pass").length;
519
+ const clear = hosts.length - needs;
520
+ box.replaceChildren(h("div", { class: "segmented", role: "group", "aria-label": "Filter hosts" },
521
+ [["work", `Needs Work (${needs})`], ["all", `All (${hosts.length})`], ["clear", `Passed (${clear})`]].map(([key, label]) =>
522
+ h("button", { type: "button", "aria-pressed": state.filter === key ? "true" : "false", onclick: () => { state.filter = key; renderFindings(); renderFilters(); } }, label))));
523
+ }
524
+
525
+ function renderFindings() {
526
+ const view = $("view-findings");
527
+ if (!state.report) { view.replaceChildren(emptyState()); return; }
528
+ const hosts = state.report.hosts;
529
+ const pick = state.filter === "work" ? hosts.filter((x) => x.posture !== "pass") : state.filter === "clear" ? hosts.filter((x) => x.posture === "pass") : hosts;
530
+ const rank = (host) => (host.posture === "fail" ? 0 : host.posture === "warn" ? 1 : 2);
531
+ const ordered = [...pick].sort((a, b) => rank(a) - rank(b));
532
+ view.replaceChildren(ordered.length
533
+ ? h("div", { class: "hosts" }, ordered.map((host, index) => hostCard(host, index === 0 && host.posture !== "pass")))
534
+ : h("div", { class: "empty" }, h("b", {}, state.filter === "work" ? "Every host passed" : "No host passed every check yet")));
535
+ }
536
+
537
+ const COLUMNS = [["host", "Host"], ["account", "Account"], ["fingerprint", "Fingerprint"], ["reach", "Reach"], ["usage", "Usage"], ["lastUsed", "Last Used"], ["from", "From"], ["inAllow", "In Push"], ["comment", "Comment"]];
538
+ function cell(row, key) {
539
+ if (key === "from") return (row.options.from || []).join(", ") || "Any";
540
+ if (key === "inAllow") return row.inAllow == null ? "–" : row.inAllow ? "Yes" : "No";
541
+ if (key === "lastUsed") return row.lastUsed ? row.lastUsed.slice(0, 10) : "–";
542
+ return row[key] == null || row[key] === "" ? "–" : String(row[key]);
543
+ }
544
+ function renderReview() {
545
+ const view = $("view-review");
546
+ if (!state.report) { view.replaceChildren(emptyState()); return; }
547
+ let rows = state.report.hosts.flatMap((host) => host.keys || []);
548
+ if (state.onlyRoot) rows = rows.filter((r) => r.reach === "root" || r.reach === "root-nopasswd");
549
+ if (state.onlyStale) rows = rows.filter((r) => r.usage === "stale");
550
+ const { key, dir } = state.sort;
551
+ rows = [...rows].sort((a, b) => cell(a, key).localeCompare(cell(b, key), undefined, { numeric: true }) * dir);
552
+ const toggle = (id, label, value, set) => h("label", { class: "option" }, h("span", {}, label), h("input", { type: "checkbox", id, checked: value, onchange: (e) => { set(e.target.checked); renderReview(); } }));
553
+ const tools = h("div", { class: "table-tools" },
554
+ toggle("only-root", "Only keys that reach root", state.onlyRoot, (v) => { state.onlyRoot = v; }),
555
+ toggle("only-stale", "Only stale keys", state.onlyStale, (v) => { state.onlyStale = v; }),
556
+ h("span", {}, `${rows.length} key${rows.length === 1 ? "" : "s"}`));
557
+ const pillFor = (k, v) => {
558
+ if (k === "reach") return h("span", { class: "pill " + (v === "root" || v === "root-nopasswd" ? "bad" : v === "root-password" ? "warnp" : "") }, v);
559
+ if (k === "usage") return h("span", { class: "pill " + (v === "stale" ? "bad" : v === "fresh" ? "ok" : "warnp") }, v);
560
+ return v;
561
+ };
562
+ const table = h("table", {},
563
+ h("thead", {}, h("tr", {}, COLUMNS.map(([k, label]) => h("th", { scope: "col", "aria-sort": key === k ? (dir > 0 ? "ascending" : "descending") : "none" },
564
+ h("button", { type: "button", onclick: () => { state.sort = { key: k, dir: state.sort.key === k ? -state.sort.dir : 1 }; renderReview(); } }, label + (key === k ? (dir > 0 ? " ▲" : " ▼") : "")))))),
565
+ h("tbody", {}, rows.map((row) => h("tr", {}, COLUMNS.map(([k]) => h("td", { class: k === "fingerprint" ? "fp" : (k === "from" || k === "lastUsed" ? "mono" : "") }, pillFor(k, cell(row, k))))))));
566
+ view.replaceChildren(h("div", { class: "hosts" }, tools, h("div", { class: "table-wrap" }, table)));
567
+ }
568
+
569
+ const TITLES = { findings: "Findings", review: "Access Review", find: "Find a Key" };
570
+ function renderHeading() {
571
+ $("page-title").textContent = TITLES[state.view];
572
+ const r = state.report;
573
+ $("page-sub").textContent = r ? `${r.hosts.length} host${r.hosts.length === 1 ? "" : "s"} checked ${when(r.generatedAt)}${state.lab ? " · lab data" : ""}` : "No hosts checked yet";
574
+ }
575
+
576
+ function renderAll(notes) {
577
+ stats(); renderFindings(); renderReview(); renderFilters(); renderHeading();
578
+ const lines = [...(notes || [])];
579
+ if (state.report && state.lab) lines.push("Lab hosts are a fixed example observation: what the collector reports back for sshd, sudo rights, logins, and fingerprints. app-web-03 and ci-runner regressed against the previous run.");
580
+ $("notes").replaceChildren(...lines.map((line) => h("p", { class: "lab-note" }, line)));
581
+ }
582
+
583
+ function showView(name) {
584
+ state.view = name;
585
+ for (const tab of document.querySelectorAll('[role="tab"]')) tab.setAttribute("aria-selected", String(tab.dataset.view === name));
586
+ for (const v of ["findings", "review", "find"]) $("view-" + v).hidden = v !== name;
587
+ $("stats").hidden = !state.report || name === "find";
588
+ renderFilters(); renderHeading();
589
+ }
590
+
591
+ async function findKey() {
592
+ if (state.busy) return;
593
+ showError("");
594
+ const body = Object.assign(baseBody(selectedTarget()), { fingerprints: [$("find-fp").value.trim()].filter(Boolean), keyText: $("find-key").value });
595
+ $("find-key").value = "";
596
+ setBusy(true);
597
+ $("find").replaceChildren(h("span", { class: "spin", "aria-hidden": "true" }), " Searching…");
598
+ try {
599
+ const data = await api("/api/where", body);
600
+ const hits = data.found.map((hit) => h("div", { class: "hit" },
601
+ h("div", { class: "where" }, `${hit.host} · ${hit.account}`),
602
+ h("div", {}, `${hit.how}. Reach: ${hit.reach}${hit.reachVia ? " (" + hit.reachVia + ")" : ""}. From: ${hit.from.length ? hit.from.join(", ") : "any address"}. Last used: ${hit.lastUsed ? when(hit.lastUsed) : "no login in the logs"}.`),
603
+ h("div", { class: "mono" }, hit.fingerprint)));
604
+ const blind = data.notChecked.map((line) => h("p", { class: "lab-note" }, line));
605
+ $("hits").replaceChildren(hits.length ? h("p", { class: "info" }, `Found in ${hits.length} place${hits.length === 1 ? "" : "s"}.`) : h("div", { class: "empty" }, h("b", {}, "Not found on any host that could be read")), ...hits, ...blind);
606
+ } catch (error) {
607
+ showError(error.message);
608
+ } finally {
609
+ setBusy(false);
610
+ $("find").replaceChildren("Find Key");
611
+ }
612
+ }
613
+
614
+ function download(name, type, text) {
615
+ const url = URL.createObjectURL(new Blob([text], { type }));
616
+ const link = h("a", { href: url, download: name });
617
+ document.body.append(link); link.click(); link.remove();
618
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
619
+ }
620
+ const stamp = () => new Date().toISOString().slice(0, 16).replace(/[:T]/g, "-");
621
+
622
+ $("run").addEventListener("click", () => runCheck(selectedTarget()));
623
+ $("run-paste").addEventListener("click", () => runCheck("paste"));
624
+ $("find").addEventListener("click", findKey);
625
+ $("lab").addEventListener("click", async () => {
626
+ try {
627
+ const data = await api("/api/lab-keys");
628
+ $("allow").value = data.keys.join("\n");
629
+ $("t-lab").checked = true;
630
+ } catch (error) { showError(error.message); }
631
+ });
632
+ $("drop").addEventListener("click", () => {
633
+ state.push = []; state.lab = false; $("allow").value = ""; renderPush();
634
+ showInfo("Keys cleared. Paste keys again before the next run.");
635
+ });
636
+ $("export-json").addEventListener("click", () => state.report && download(`keywarden-${stamp()}.json`, "application/json", JSON.stringify(state.report, null, 2)));
637
+ $("export-csv").addEventListener("click", () => state.csv && download(`keywarden-access-review-${stamp()}.csv`, "text/csv", state.csv));
638
+ $("export-evidence").addEventListener("click", () => state.evidence && download(`keywarden-evidence-${stamp()}.html`, "text/html", state.evidence));
639
+ $("copy-collector").addEventListener("click", async (event) => {
640
+ const button = event.currentTarget;
641
+ try {
642
+ const data = await api("/api/collector");
643
+ await navigator.clipboard.writeText("sh -s -- root <<'KEYWARDEN'\n" + data.script + "\nKEYWARDEN\n");
644
+ button.textContent = "Copied";
645
+ } catch (error) { button.textContent = "Copy failed"; }
646
+ setTimeout(() => { button.textContent = "Copy Collector Script"; }, 2000);
647
+ });
648
+ $("prior").addEventListener("change", async (event) => {
649
+ const file = event.target.files[0];
650
+ state.prior = null;
651
+ if (!file) return;
652
+ try { state.prior = JSON.parse(await file.text()); showError(""); }
653
+ catch { showError("That file is not a Keywarden JSON report."); event.target.value = ""; }
654
+ });
655
+ for (const tab of document.querySelectorAll('[role="tab"]')) tab.addEventListener("click", () => showView(tab.dataset.view));
656
+ for (const id of ["host", "inventory"]) $(id).addEventListener("focus", () => { $(id === "host" ? "t-host" : "t-inv").checked = true; });
657
+ renderPush(); renderAll([]);
658
+ </script>
659
+ </body>
660
+ </html>
661
+ """
662
+
663
+
664
+ def render_page(nonce: str, token: str, version: str) -> str:
665
+ return PAGE.replace("__NONCE__", nonce).replace("__TOKEN__", token).replace("__VERSION__", version)