← back to Secrets Manager
viewer.js
399 lines
#!/usr/bin/env node
/**
* Local-only web viewer for the secrets registry.
*
* Lists every secret with its digest, label, validation status, and the
* destinations cli.js wrote it to. A "Reveal" button per row makes an
* authenticated POST to read the raw value out of the master .env — that
* action requires a same-origin POST + the X-Confirm-Reveal header, so a
* stray GET / scroll never leaks anything.
*
* Hard security guards:
* - listen() binds to 127.0.0.1 only (never 0.0.0.0)
* - every handler checks req.socket.remoteAddress ∈ {127.0.0.1, ::1}
* - /api/reveal returns the value as JSON (so the browser tab is the only
* place it appears; nothing logged server-side)
* - reveal logs an entry to viewer-reveal.log with timestamp + key name
* (NOT the value) so Steve can audit which keys he viewed and when
*
* Zero deps — pure Node http module.
*
* Usage:
* node viewer.js
* open http://127.0.0.1:9698/
*/
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const PORT = parseInt(process.env.SECRETS_VIEWER_PORT, 10) || 9698;
const ROOT = __dirname;
const REGISTRY = path.join(ROOT, 'registry.json');
const MASTER_ENV = path.join(ROOT, '.env');
const REVEAL_LOG = path.join(ROOT, 'viewer-reveal.log');
const ROTATION_DOC = path.join(ROOT, 'ROTATION-CHECKLIST.md');
function isLoopback(req) {
const addr = req.socket && req.socket.remoteAddress;
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
}
function readRegistry() {
try { return JSON.parse(fs.readFileSync(REGISTRY, 'utf8')); } catch { return { secrets: {} }; }
}
function parseEnv(file) {
const out = {};
let raw;
try { raw = fs.readFileSync(file, 'utf8'); } catch { return out; }
for (const line of raw.split(/\r?\n/)) {
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq < 1) continue;
const k = line.slice(0, eq).trim();
let v = line.slice(eq + 1);
// Strip surrounding single or double quotes if perfectly balanced.
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
out[k] = v;
}
return out;
}
function htmlEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
}
function renderPage() {
const reg = readRegistry();
const rows = Object.entries(reg.secrets || {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, meta]) => {
const destCount = (meta.written_to || []).length;
const dests = (meta.written_to || []).map(htmlEsc).join('<br>');
const validatedPill = meta.validated
? `<span class="pill pill-ok">verified · ${htmlEsc(meta.verify_status || '')}</span>`
: (meta.verify_status === null || meta.verify_status === undefined
? `<span class="pill pill-unk">unverified</span>`
: `<span class="pill pill-bad">failed · ${htmlEsc(meta.verify_status)}</span>`);
return `
<tr data-key="${htmlEsc(key)}">
<td class="k">${htmlEsc(key)}</td>
<td class="lbl">${htmlEsc(meta.label || '')}</td>
<td class="dig"><code>${htmlEsc(meta.digest || '—')}</code></td>
<td class="ver">${validatedPill}</td>
<td class="dest" title="${htmlEsc((meta.written_to || []).join('\n'))}">${destCount} file${destCount === 1 ? '' : 's'}</td>
<td class="upd">${htmlEsc((meta.last_updated || '').slice(0, 10))}</td>
<td class="act">
<button type="button" class="btn-reveal" data-key="${htmlEsc(key)}">Reveal</button>
<button type="button" class="btn-copy" data-key="${htmlEsc(key)}" style="display:none">Copy</button>
<button type="button" class="btn-hide" data-key="${htmlEsc(key)}" style="display:none">Hide</button>
<div class="value-cell" style="display:none;margin-top:6px"><code class="val"></code></div>
<div class="dest-list" style="display:none;margin-top:6px">${dests}</div>
<button type="button" class="btn-dests" data-key="${htmlEsc(key)}">where?</button>
</td>
</tr>`;
}).join('');
return `<!doctype html><html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Secrets Viewer — localhost</title>
<style>
:root { --ink:#0f0e0c; --soft:#555; --faint:#999; --line:#ddd; --bg:#faf8f3; --card:#fff;
--ok:#14532d; --okBg:#d1fae5; --bad:#7f1d1d; --badBg:#fee2e2; --unk:#374151; --unkBg:#e5e7eb; }
* { box-sizing: border-box; }
body { font:14px/1.5 -apple-system, system-ui, sans-serif; color:var(--ink); background:var(--bg); margin:0; padding:24px 28px; }
h1 { font:300 28px/1.1 Georgia, serif; margin:0 0 6px; }
.sub { color:var(--soft); margin:0 0 22px; font-size:13px }
.sub code { background:#e7e3d8; padding:2px 6px; border-radius:3px; font-size:12px }
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px }
.toolbar input[type=search] { flex:1; min-width:240px; padding:8px 12px; border:1px solid var(--line); border-radius:6px; font:14px var(--sans); background:var(--card) }
.toolbar select { padding:8px 10px; border:1px solid var(--line); border-radius:6px; background:var(--card); font:13px var(--sans) }
table { width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--line); border-radius:8px; overflow:hidden }
th { text-align:left; font:11px/1 var(--sans); text-transform:uppercase; letter-spacing:.08em; color:var(--faint); padding:11px 12px; background:#f3eee2; border-bottom:1px solid var(--line) }
td { padding:10px 12px; border-top:1px solid #ece8dd; vertical-align:top; font-size:13px }
td.k { font:13px ui-monospace, Menlo, monospace; font-weight:600 }
td.lbl { color:var(--soft) }
td.dig code { font:12px ui-monospace, Menlo, monospace; color:var(--soft) }
td.upd { color:var(--faint); font-size:12px; white-space:nowrap }
td.act { white-space:nowrap }
td.act button { font:12px var(--sans); padding:5px 11px; border:1px solid var(--line); background:#f3eee2; border-radius:4px; cursor:pointer; margin-right:4px }
td.act button:hover { background:#e7e3d8 }
td.act .btn-reveal { background:var(--ink); color:#fff; border-color:var(--ink) }
td.act .btn-copy { background:#1e40af; color:#fff; border-color:#1e40af }
td.act .btn-hide { background:#7f1d1d; color:#fff; border-color:#7f1d1d }
td.act .btn-dests { background:transparent; color:var(--soft); border-color:#e7e3d8; font-size:11px }
.pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; font-weight:600 }
.pill-ok { background:var(--okBg); color:var(--ok) }
.pill-bad { background:var(--badBg); color:var(--bad) }
.pill-unk { background:var(--unkBg); color:var(--unk) }
.value-cell { background:#fffbeb; border:1px solid #fcd34d; padding:7px 9px; border-radius:4px; max-width:480px; word-break:break-all }
.value-cell code { font:12px ui-monospace, Menlo, monospace; color:var(--ink) }
.dest-list { background:#f3eee2; border:1px solid var(--line); padding:7px 10px; border-radius:4px; font:11px ui-monospace, Menlo, monospace; color:var(--soft); max-width:520px }
.stats { color:var(--soft); font-size:12px; margin-bottom:8px }
.hide { display:none !important }
footer { color:var(--faint); font-size:11px; margin-top:18px }
</style>
</head><body>
<h1>Secrets Viewer</h1>
<p class="sub">Local-only · binds to <code>127.0.0.1:${PORT}</code> · reads <code>${htmlEsc(path.basename(REGISTRY))}</code> · revealed values are logged to <code>${htmlEsc(path.basename(REVEAL_LOG))}</code></p>
<p class="sub">🔄 <a href="/rotation" style="color:#7f1d1d;font-weight:600">Items we need to rotate →</a></p>
<div class="toolbar">
<input type="search" id="q" placeholder="Filter by key or label (e.g. eleven, godaddy, shopify)" autocomplete="off">
<select id="filter">
<option value="all">All secrets</option>
<option value="ok">Verified only</option>
<option value="bad">Failed verification</option>
<option value="unk">Unverified</option>
</select>
<span class="stats" id="stats"></span>
</div>
<table>
<thead>
<tr>
<th>Key</th><th>Label</th><th>Digest</th><th>Status</th><th>Destinations</th><th>Updated</th><th>Action</th>
</tr>
</thead>
<tbody id="rows">${rows}</tbody>
</table>
<footer>Loopback-only. Every reveal click is logged to <code>${htmlEsc(REVEAL_LOG)}</code> with timestamp + key name (never the value).</footer>
<script>
(function(){
const q = document.getElementById('q');
const filter = document.getElementById('filter');
const stats = document.getElementById('stats');
const rows = Array.from(document.querySelectorAll('#rows tr'));
function refilter(){
const needle = (q.value || '').toLowerCase();
const mode = filter.value;
let visible = 0;
rows.forEach(r => {
const k = (r.querySelector('.k')?.textContent || '').toLowerCase();
const lbl = (r.querySelector('.lbl')?.textContent || '').toLowerCase();
const matches = !needle || k.includes(needle) || lbl.includes(needle);
let modeOk = true;
if (mode === 'ok') modeOk = !!r.querySelector('.pill-ok');
if (mode === 'bad') modeOk = !!r.querySelector('.pill-bad');
if (mode === 'unk') modeOk = !!r.querySelector('.pill-unk');
const show = matches && modeOk;
r.classList.toggle('hide', !show);
if (show) visible++;
});
stats.textContent = visible + ' of ' + rows.length + ' secrets';
}
q.addEventListener('input', refilter);
filter.addEventListener('change', refilter);
refilter();
document.body.addEventListener('click', async function(e){
const t = e.target;
if (!(t instanceof HTMLButtonElement)) return;
const row = t.closest('tr'); if (!row) return;
const key = t.dataset.key;
const reveal = row.querySelector('.btn-reveal');
const copy = row.querySelector('.btn-copy');
const hide = row.querySelector('.btn-hide');
const valCell = row.querySelector('.value-cell');
const valEl = row.querySelector('.val');
const destList = row.querySelector('.dest-list');
const destsBtn = row.querySelector('.btn-dests');
if (t.classList.contains('btn-reveal')) {
try {
const r = await fetch('/api/reveal/' + encodeURIComponent(key), {
method: 'POST',
headers: { 'X-Confirm-Reveal': 'yes', 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!r.ok) { alert('Reveal failed: HTTP ' + r.status); return; }
const j = await r.json();
valEl.textContent = j.value || '(not found in master .env)';
valCell.style.display = 'block';
reveal.style.display = 'none';
copy.style.display = 'inline-block';
hide.style.display = 'inline-block';
} catch (err) { alert('Reveal error: ' + err.message); }
}
if (t.classList.contains('btn-copy')) {
try { await navigator.clipboard.writeText(valEl.textContent); t.textContent = 'Copied ✓'; setTimeout(() => { t.textContent = 'Copy'; }, 1400); } catch (e) { alert('Clipboard error: ' + e.message); }
}
if (t.classList.contains('btn-hide')) {
valEl.textContent = '';
valCell.style.display = 'none';
reveal.style.display = 'inline-block';
copy.style.display = 'none';
hide.style.display = 'none';
}
if (t.classList.contains('btn-dests')) {
const open = destList.style.display !== 'none';
destList.style.display = open ? 'none' : 'block';
destsBtn.textContent = open ? 'where?' : 'hide';
}
});
})();
</script>
</body></html>`;
}
// ── Rotation view ────────────────────────────────────────────────────────────
// Renders ROTATION-CHECKLIST.md (the authoritative "items we need to rotate"
// list) as status cards. Read-only; the doc holds NO secret values (key names +
// mint URLs + route-back commands only), so this page is safe to display.
function mdInlineEsc(s) {
// input is raw markdown; escape HTML first, then apply a few inline rules.
let h = htmlEsc(s);
// protect inline code spans
const codes = [];
h = h.replace(/`([^`]+)`/g, (_, c) => { codes.push(c); return '