2026-09-22 13:58:28 +02:00
|
|
|
|
<!doctype html>
|
|
|
|
|
|
<html lang="en">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
2026-09-22 14:33:03 +02:00
|
|
|
|
<title>Bucketeer · CT resource monitor</title>
|
|
|
|
|
|
<link rel="icon" href="/assets/favicon.png">
|
|
|
|
|
|
<script>/* set theme before first paint to avoid a flash */
|
|
|
|
|
|
try{var t=localStorage.getItem('bucketeer-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}</script>
|
2026-09-22 13:58:28 +02:00
|
|
|
|
<style>
|
2026-09-22 14:33:03 +02:00
|
|
|
|
/* Tokens from DESIGN.md. Neutral-first, no shadows, system fonts, light only. */
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--primary:#262626; --on-primary:#fff; --primary-hover:#404040; --primary-active:#171717;
|
|
|
|
|
|
--text-primary:#000; --text-secondary:#525252; --text-muted:#737373;
|
|
|
|
|
|
--canvas:#fff; --surface-subtle:#fafafa; --surface-control:#f2f2f2; --surface-hover:#ededed;
|
|
|
|
|
|
--border:#e5e5e5; --border-strong:#d4d4d4; --focus:#2563eb;
|
|
|
|
|
|
--cap-bg:#eef2ff; --cap-text:#4338ca; --size-bg:#e0f2fe; --size-text:#075985;
|
|
|
|
|
|
--err-bg:#fef2f2; --err-text:#b91c1c; --ok-bg:#f0fdf4; --ok-text:#166534;
|
|
|
|
|
|
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
|
|
|
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
|
|
|
|
--page-gutter:16px;
|
|
|
|
|
|
color-scheme: light dark;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* Dark palette (DESIGN.md specifies light only; this is an explicit product
|
|
|
|
|
|
decision). Neutral-first, same hierarchy, contrast-preserving. */
|
|
|
|
|
|
[data-theme=dark]{
|
|
|
|
|
|
--primary:#e7e9ee; --on-primary:#14161a; --primary-hover:#d3d7de; --primary-active:#ffffff;
|
|
|
|
|
|
--text-primary:#f4f5f7; --text-secondary:#b3b9c4; --text-muted:#858d9a;
|
|
|
|
|
|
--canvas:#0f1115; --surface-subtle:#171a20; --surface-control:#1c2027; --surface-hover:#252b35;
|
|
|
|
|
|
--border:#2a2f39; --border-strong:#3a414e; --focus:#69a8ff;
|
|
|
|
|
|
--cap-bg:#1e2440; --cap-text:#aab5ff; --size-bg:#0e2a3a; --size-text:#7cc4ff;
|
|
|
|
|
|
--err-bg:#3a1616; --err-text:#ffb4b4; --ok-bg:#0f2a1a; --ok-text:#7ee2a8;
|
|
|
|
|
|
}
|
|
|
|
|
|
*,*::before,*::after{box-sizing:border-box}
|
|
|
|
|
|
body{margin:0;background:var(--canvas);color:var(--text-primary);font:400 16px/24px var(--font);-webkit-font-smoothing:antialiased}
|
|
|
|
|
|
h1{margin:0;font:500 24px/32px var(--font);letter-spacing:-.01em}
|
|
|
|
|
|
a{color:inherit}
|
|
|
|
|
|
.site-shell,.search-layout{width:calc(100% - 2*var(--page-gutter));margin-inline:auto}
|
|
|
|
|
|
.site-shell{max-width:1280px}
|
|
|
|
|
|
.search-layout{max-width:768px}
|
|
|
|
|
|
@media (min-width:640px){:root{--page-gutter:24px}}
|
|
|
|
|
|
@media (min-width:1024px){:root{--page-gutter:32px}}
|
|
|
|
|
|
|
|
|
|
|
|
header{border-bottom:1px solid var(--border)}
|
|
|
|
|
|
.header{height:72px;display:flex;align-items:center;gap:24px}
|
|
|
|
|
|
.brand{display:flex;align-items:center;gap:8px;font:500 18px/28px var(--font);text-decoration:none}
|
|
|
|
|
|
.brand-logo{width:33px;height:30px;display:block;
|
|
|
|
|
|
background:url('/assets/logo-light.webp') center/contain no-repeat}
|
|
|
|
|
|
[data-theme=dark] .brand-logo{background-image:url('/assets/logo-dark.webp')}
|
|
|
|
|
|
.theme-btn{width:40px;height:40px;display:grid;place-items:center;background:transparent;border:1px solid var(--border);
|
|
|
|
|
|
border-radius:9999px;color:var(--text-secondary);cursor:pointer;padding:0}
|
|
|
|
|
|
.theme-btn:hover{background:var(--surface-hover);color:var(--text-primary)}
|
|
|
|
|
|
.theme-btn svg{width:18px;height:18px;display:block}
|
|
|
|
|
|
nav{display:flex;gap:16px;font:400 18px/28px var(--font)}
|
|
|
|
|
|
nav a{text-decoration:none;color:var(--text-secondary)}
|
|
|
|
|
|
nav a[aria-current=page]{color:var(--text-primary);text-decoration:underline;text-underline-offset:6px}
|
|
|
|
|
|
.header-right{margin-left:auto;display:flex;align-items:center;gap:12px}
|
|
|
|
|
|
.live{display:inline-flex;align-items:center;gap:6px;font:400 14px/20px var(--font);color:var(--text-muted)}
|
|
|
|
|
|
.dot{width:8px;height:8px;border-radius:9999px;background:var(--ok-text)}
|
|
|
|
|
|
.dot.paused{background:var(--text-muted)}
|
|
|
|
|
|
|
|
|
|
|
|
main{padding:48px 0 80px}
|
|
|
|
|
|
.query{position:relative;display:flex;align-items:center}
|
|
|
|
|
|
.query input{width:100%;height:48px;border:1px solid transparent;border-radius:9999px;background:var(--surface-control);
|
|
|
|
|
|
color:var(--text-primary);font:400 16px/24px var(--font);padding:0 48px 0 20px}
|
|
|
|
|
|
.query input::placeholder{color:var(--text-secondary)}
|
|
|
|
|
|
.query input:focus-visible{outline:2px solid var(--focus);outline-offset:3px;background:var(--canvas)}
|
|
|
|
|
|
.query .clear{position:absolute;right:4px;border:0;background:transparent;color:var(--text-secondary);
|
|
|
|
|
|
width:40px;height:40px;border-radius:9999px;cursor:pointer;font-size:18px;display:none}
|
|
|
|
|
|
.query .clear.show{display:block}
|
|
|
|
|
|
.query .clear:hover{background:var(--surface-hover);color:var(--text-primary)}
|
|
|
|
|
|
|
|
|
|
|
|
.controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:16px}
|
|
|
|
|
|
.filters{display:flex;gap:8px;flex-wrap:wrap}
|
|
|
|
|
|
.chip{height:32px;padding:0 12px;border:1px solid var(--border-strong);border-radius:9999px;
|
|
|
|
|
|
background:var(--surface-control);color:var(--text-secondary);font:400 14px/20px var(--font);
|
|
|
|
|
|
cursor:pointer;display:inline-flex;align-items:center;gap:6px}
|
|
|
|
|
|
.chip:hover{background:var(--surface-hover);color:var(--text-primary)}
|
|
|
|
|
|
.chip[aria-pressed=true]{background:var(--primary);border-color:var(--primary);color:var(--on-primary)}
|
|
|
|
|
|
.sort{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--text-secondary);font:400 14px/20px var(--font)}
|
|
|
|
|
|
select{height:40px;border:1px solid var(--border-strong);border-radius:9999px;background:var(--canvas);
|
|
|
|
|
|
color:var(--text-primary);font:400 14px/20px var(--font);padding:0 12px}
|
2026-09-22 14:47:49 +02:00
|
|
|
|
.scan{display:flex;gap:8px;margin-top:16px}
|
|
|
|
|
|
.scan input{flex:1;height:40px;border:1px solid var(--border-strong);border-radius:9999px;background:var(--surface-control);
|
|
|
|
|
|
color:var(--text-primary);font:400 14px/20px var(--font);padding:0 16px}
|
|
|
|
|
|
.scan input:focus-visible{outline:2px solid var(--focus);outline-offset:3px;background:var(--canvas)}
|
|
|
|
|
|
.scan button{min-height:40px;padding:0 20px;border-radius:9999px;border:1px solid var(--primary);
|
|
|
|
|
|
background:var(--primary);color:var(--on-primary);font:500 14px/20px var(--font);cursor:pointer}
|
|
|
|
|
|
.notice{margin-top:16px;padding:12px;border-radius:8px;background:var(--surface-subtle);border:1px solid var(--border)}
|
|
|
|
|
|
.notice-ok{background:var(--ok-bg);border-color:transparent}
|
|
|
|
|
|
.samples{margin-top:8px;border-top:1px solid var(--border);padding-top:8px}
|
|
|
|
|
|
.sample{color:var(--text-secondary);font-size:13px;overflow-wrap:anywhere}
|
2026-09-22 14:33:03 +02:00
|
|
|
|
.summary{display:flex;gap:16px;flex-wrap:wrap;margin-top:24px;color:var(--text-muted);font:400 14px/20px var(--font)}
|
|
|
|
|
|
.summary strong{color:var(--text-primary);font-weight:500}
|
|
|
|
|
|
|
|
|
|
|
|
ul.result-list{list-style:none;margin:0;padding:0}
|
|
|
|
|
|
/* Native virtualization: off-screen rows are not laid out or painted. */
|
|
|
|
|
|
.result-row{padding-block:24px;border-bottom:1px solid var(--border);
|
|
|
|
|
|
content-visibility:auto;contain-intrinsic-size:auto 128px}
|
|
|
|
|
|
.result-head{display:flex;align-items:flex-start;gap:12px;min-width:0}
|
|
|
|
|
|
.result-title{font:500 20px/28px var(--font);color:var(--text-primary);text-decoration:none;overflow-wrap:anywhere}
|
|
|
|
|
|
.result-title:hover{text-decoration:underline}
|
|
|
|
|
|
.result-description{margin:4px 0 0;color:var(--text-secondary);font:400 16px/24px var(--font);overflow-wrap:anywhere}
|
|
|
|
|
|
.badge-group{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
|
|
|
|
|
|
.result-meta{display:flex;gap:8px 16px;flex-wrap:wrap;margin-top:12px;color:var(--text-muted);font:400 14px/20px var(--font)}
|
|
|
|
|
|
.badge{font:500 12px/16px var(--font);padding:2px 6px;border-radius:4px;background:var(--surface-control);color:var(--text-secondary);white-space:nowrap}
|
|
|
|
|
|
.badge-cap{background:var(--cap-bg);color:var(--cap-text)}
|
|
|
|
|
|
.badge-size{background:var(--size-bg);color:var(--size-text)}
|
|
|
|
|
|
.badge-ok{background:var(--ok-bg);color:var(--ok-text)}
|
|
|
|
|
|
.badge-err{background:var(--err-bg);color:var(--err-text)}
|
|
|
|
|
|
.mono{font-family:var(--mono);font-size:14px}
|
|
|
|
|
|
|
|
|
|
|
|
.empty{padding:32px 0;color:var(--text-secondary)}
|
|
|
|
|
|
.error{padding:12px;border-radius:8px;background:var(--err-bg);color:var(--err-text);font:400 14px/20px var(--font)}
|
|
|
|
|
|
.skeleton{height:16px;border-radius:6px;background:var(--surface-control);margin:12px 0}
|
|
|
|
|
|
.skeleton.w60{width:60%}.skeleton.w40{width:40%}
|
|
|
|
|
|
.more{display:flex;justify-content:center;margin-top:24px}
|
|
|
|
|
|
.more button{min-height:40px;padding:10px 20px;border-radius:9999px;border:1px solid var(--border-strong);
|
|
|
|
|
|
background:var(--canvas);color:var(--text-primary);font:500 14px/20px var(--font);cursor:pointer}
|
|
|
|
|
|
.more button:hover{background:var(--surface-hover)}
|
|
|
|
|
|
.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
|
|
|
|
|
|
|
|
|
|
footer{color:var(--text-muted);font:400 14px/20px var(--font);padding:0 0 64px}
|
|
|
|
|
|
footer p{max-width:768px;margin-inline:auto}
|
|
|
|
|
|
|
|
|
|
|
|
:where(a,button,input,select,[tabindex]):focus-visible{outline:2px solid var(--focus);outline-offset:3px}
|
|
|
|
|
|
@media (pointer:coarse){.chip,.clear,.more button{min-height:44px}.chip{min-width:44px}}
|
|
|
|
|
|
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
2026-09-22 13:58:28 +02:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<header>
|
2026-09-22 14:33:03 +02:00
|
|
|
|
<div class="site-shell header">
|
|
|
|
|
|
<a class="brand" href="/"><span class="brand-logo" role="img" aria-label="Bucketeer"></span>Bucketeer</a>
|
|
|
|
|
|
<nav aria-label="Primary">
|
|
|
|
|
|
<a href="?view=assets" id="nav-assets" aria-current="page">Assets</a>
|
|
|
|
|
|
<a href="?view=contents" id="nav-contents">Contents</a>
|
|
|
|
|
|
<a href="/metrics">Metrics</a>
|
|
|
|
|
|
<a href="/healthz">Health</a>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
<div class="header-right">
|
|
|
|
|
|
<span class="live" id="live"><span class="dot" id="livedot"></span><span id="livetext">live · 15s</span></span>
|
|
|
|
|
|
<button class="theme-btn" id="themebtn" type="button" aria-label="Switch to dark mode"></button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-09-22 13:58:28 +02:00
|
|
|
|
</header>
|
2026-09-22 14:33:03 +02:00
|
|
|
|
|
2026-09-22 13:58:28 +02:00
|
|
|
|
<main>
|
2026-09-22 14:33:03 +02:00
|
|
|
|
<div class="search-layout">
|
|
|
|
|
|
<h1 class="visually-hidden">Cloud resources discovered in Certificate Transparency</h1>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="query">
|
|
|
|
|
|
<label class="visually-hidden" for="q">Search</label>
|
|
|
|
|
|
<input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search hostname, bucket, database…">
|
|
|
|
|
|
<button class="clear" id="clear" type="button" aria-label="Clear search">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-22 14:47:49 +02:00
|
|
|
|
<form class="scan" id="scanform">
|
|
|
|
|
|
<label class="visually-hidden" for="scan">Scan a host or URL</label>
|
|
|
|
|
|
<input id="scan" type="text" autocomplete="off" spellcheck="false"
|
|
|
|
|
|
placeholder="Scan a host or URL (bucket, Azure container path, custom domain)…">
|
|
|
|
|
|
<button type="submit">Scan</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<div id="scanout" hidden></div>
|
|
|
|
|
|
|
2026-09-22 14:33:03 +02:00
|
|
|
|
<div class="controls" id="asset-controls">
|
|
|
|
|
|
<div class="filters" id="filters" role="group" aria-label="Filter by exposure">
|
|
|
|
|
|
<button class="chip" data-exposure="" aria-pressed="true">All</button>
|
|
|
|
|
|
<button class="chip" data-exposure="public_read" aria-pressed="false">Public read</button>
|
|
|
|
|
|
<button class="chip" data-exposure="reachable" aria-pressed="false">Reachable</button>
|
|
|
|
|
|
<button class="chip" data-exposure="auth_required" aria-pressed="false">Auth required</button>
|
|
|
|
|
|
<button class="chip" data-exposure="not_found" aria-pressed="false">Not found</button>
|
|
|
|
|
|
<button class="chip" data-exposure="private" aria-pressed="false">Private</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sort">
|
|
|
|
|
|
<label for="sort">Sort</label>
|
|
|
|
|
|
<select id="sort">
|
|
|
|
|
|
<option value="exposure">Exposure</option>
|
|
|
|
|
|
<option value="last_seen">Recently seen</option>
|
|
|
|
|
|
<option value="first_seen">Newest</option>
|
|
|
|
|
|
<option value="provider">Provider</option>
|
|
|
|
|
|
<option value="hostname">Hostname</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="summary" id="summary" aria-live="polite"></div>
|
|
|
|
|
|
<div id="state"></div>
|
|
|
|
|
|
<ul class="result-list" id="results"></ul>
|
|
|
|
|
|
<div class="more" id="more" hidden><button id="morebtn" type="button">Load more</button></div>
|
2026-09-22 13:58:28 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</main>
|
2026-09-22 14:33:03 +02:00
|
|
|
|
|
2026-09-22 13:58:28 +02:00
|
|
|
|
<footer>
|
2026-09-22 14:33:03 +02:00
|
|
|
|
<p>
|
|
|
|
|
|
A detected endpoint is not automatically accessible. <span class="badge badge-ok">public_read</span>
|
|
|
|
|
|
means an anonymous read succeeded at probe time and readable resources are indexed below (keys and
|
|
|
|
|
|
sizes only, never contents). Databases are only reported <span class="badge badge-size">reachable</span>
|
|
|
|
|
|
unless an unauthenticated protocol read was explicitly enabled; no credentials are ever sent.
|
|
|
|
|
|
</p>
|
2026-09-22 13:58:28 +02:00
|
|
|
|
</footer>
|
2026-09-22 14:33:03 +02:00
|
|
|
|
|
2026-09-22 13:58:28 +02:00
|
|
|
|
<script>
|
2026-09-22 14:33:03 +02:00
|
|
|
|
const PAGE = 100, REFRESH_MS = 15000;
|
2026-09-22 13:58:28 +02:00
|
|
|
|
const $ = (id) => document.getElementById(id);
|
2026-09-22 14:33:03 +02:00
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
|
|
|
|
const state = {
|
|
|
|
|
|
view: params.get('view') === 'contents' ? 'contents' : 'assets',
|
|
|
|
|
|
q: params.get('q') || '',
|
|
|
|
|
|
exposure: params.get('exposure') || '',
|
|
|
|
|
|
sort: params.get('sort') || 'exposure',
|
|
|
|
|
|
assets: [], assetsTotal: 0, assetsOffset: 0,
|
|
|
|
|
|
entries: [], entriesTotal: 0, entriesOffset: 0,
|
|
|
|
|
|
loading: false, abort: null, timer: null, countdown: REFRESH_MS / 1000, ok: true,
|
2026-09-22 13:58:28 +02:00
|
|
|
|
};
|
2026-09-22 14:33:03 +02:00
|
|
|
|
$('q').value = state.q;
|
|
|
|
|
|
$('sort').value = state.sort;
|
|
|
|
|
|
$('clear').classList.toggle('show', state.q.length > 0);
|
|
|
|
|
|
document.querySelectorAll('#filters .chip').forEach((c) =>
|
|
|
|
|
|
c.setAttribute('aria-pressed', String(c.dataset.exposure === state.exposure)));
|
|
|
|
|
|
|
|
|
|
|
|
const SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/></svg>';
|
|
|
|
|
|
const MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
|
|
|
|
|
|
const currentTheme = () => document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
|
|
|
|
|
function setTheme(t, persist) {
|
|
|
|
|
|
document.documentElement.setAttribute('data-theme', t);
|
|
|
|
|
|
if (persist) { try { localStorage.setItem('bucketeer-theme', t); } catch (e) {} }
|
|
|
|
|
|
$('themebtn').innerHTML = t === 'dark' ? SUN : MOON;
|
|
|
|
|
|
$('themebtn').setAttribute('aria-label', t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
|
|
|
|
|
|
}
|
|
|
|
|
|
setTheme(currentTheme(), false);
|
|
|
|
|
|
$('themebtn').onclick = () => setTheme(currentTheme() === 'dark' ? 'light' : 'dark', true);
|
|
|
|
|
|
if (window.matchMedia) matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
|
|
|
|
try { if (!localStorage.getItem('bucketeer-theme')) setTheme(e.matches ? 'dark' : 'light', false); } catch (_) {}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const esc = (s) => (s ?? '').toString().replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
|
|
|
|
const key = (a) => a.provider + '|' + a.service + '|' + a.provider_hostname;
|
|
|
|
|
|
const badge = (v, cls) => `<span class="badge ${cls || ''}">${esc(v)}</span>`;
|
|
|
|
|
|
function exposureBadge(v) {
|
|
|
|
|
|
const cls = v === 'public_read' ? 'badge-ok' : v === 'reachable' ? 'badge-size'
|
|
|
|
|
|
: (v === 'not_found' || v === 'private') ? 'badge-err' : '';
|
|
|
|
|
|
return badge(v || 'unknown', cls);
|
|
|
|
|
|
}
|
2026-09-22 13:58:28 +02:00
|
|
|
|
function ago(ts) {
|
2026-09-22 14:33:03 +02:00
|
|
|
|
if (!ts) return null;
|
2026-09-22 13:58:28 +02:00
|
|
|
|
const s = Math.max(0, (Date.now() - new Date(ts)) / 1000);
|
|
|
|
|
|
if (s < 60) return Math.floor(s) + 's ago';
|
2026-09-22 14:33:03 +02:00
|
|
|
|
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
|
|
|
|
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
|
|
|
|
return Math.floor(s / 86400) + 'd ago';
|
2026-09-22 13:58:28 +02:00
|
|
|
|
}
|
2026-09-22 14:33:03 +02:00
|
|
|
|
function bytes(n) {
|
|
|
|
|
|
if (!n) return '';
|
|
|
|
|
|
const u = ['B','KB','MB','GB','TB']; let i = 0, v = n;
|
|
|
|
|
|
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
|
|
|
|
|
return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i];
|
|
|
|
|
|
}
|
|
|
|
|
|
function timeEl(ts, label) {
|
|
|
|
|
|
const a = ago(ts);
|
|
|
|
|
|
return a ? `<span>${label} <time datetime="${esc(ts)}">${a}</time></span>` : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
function assetRow(a) {
|
|
|
|
|
|
const meta = [];
|
|
|
|
|
|
if (a.region) meta.push(`<span>${esc(a.region)}</span>`);
|
|
|
|
|
|
if (a.hostname && a.hostname !== a.provider_hostname) meta.push(`<span>CT host <span class="mono">${esc(a.hostname)}</span></span>`);
|
|
|
|
|
|
meta.push(timeEl(a.last_seen, 'seen'));
|
|
|
|
|
|
meta.push(timeEl(a.probed_at, 'probed'));
|
|
|
|
|
|
return `<li class="result-row">
|
|
|
|
|
|
<div class="result-head">
|
|
|
|
|
|
<a class="result-title" href="https://${esc(a.provider_hostname)}/" target="_blank" rel="noopener noreferrer nofollow">${esc(a.provider_hostname)}</a>
|
|
|
|
|
|
${exposureBadge(a.exposure_state)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${a.exposure_detail ? `<p class="result-description">${esc(a.exposure_detail)}</p>` : ''}
|
|
|
|
|
|
<div class="badge-group">
|
|
|
|
|
|
${badge(a.provider, 'badge-cap')}${badge(a.service, 'badge-size')}
|
2026-09-22 14:47:49 +02:00
|
|
|
|
${a.listable ? badge('listable', 'badge-ok') : (a.object_readable ? badge('object readable', 'badge-ok') : '')}
|
2026-09-22 14:33:03 +02:00
|
|
|
|
${a.resource_identifier ? badge(a.resource_identifier) : ''}
|
|
|
|
|
|
${a.public_semantic && a.public_semantic !== 'none' ? badge(a.public_semantic) : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="result-meta">${meta.filter(Boolean).join('')}</div>
|
|
|
|
|
|
</li>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
function entryRow(e) {
|
|
|
|
|
|
const meta = [timeEl(e.indexed_at, 'indexed')];
|
|
|
|
|
|
if (e.size) meta.push(`<span>${bytes(e.size)}</span>`);
|
|
|
|
|
|
if (e.content_type) meta.push(`<span>${esc(e.content_type)}</span>`);
|
|
|
|
|
|
return `<li class="result-row">
|
|
|
|
|
|
<div class="result-head">
|
|
|
|
|
|
<span class="result-title mono">${esc(e.name)}</span>
|
|
|
|
|
|
${badge(e.kind)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="badge-group">
|
|
|
|
|
|
${badge(e.provider, 'badge-cap')}${badge(e.service, 'badge-size')}
|
|
|
|
|
|
<span class="badge mono">${esc(e.provider_hostname)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="result-meta">${meta.join('')}</div>
|
|
|
|
|
|
</li>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
function render() {
|
|
|
|
|
|
const ul = $('results');
|
|
|
|
|
|
if (state.view === 'assets') {
|
|
|
|
|
|
ul.innerHTML = state.assets.map(assetRow).join('');
|
|
|
|
|
|
if (!state.assets.length) showEmpty('No assets match.');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ul.innerHTML = state.entries.map(entryRow).join('');
|
|
|
|
|
|
if (!state.entries.length) showEmpty('No indexed contents match. Only resources confirmed publicly readable are indexed.');
|
|
|
|
|
|
}
|
|
|
|
|
|
const loaded = state.view === 'assets' ? state.assets.length : state.entries.length;
|
|
|
|
|
|
const total = state.view === 'assets' ? state.assetsTotal : state.entriesTotal;
|
|
|
|
|
|
$('more').hidden = loaded >= total;
|
|
|
|
|
|
$('morebtn').textContent = `Load more (${loaded} of ${total})`;
|
|
|
|
|
|
}
|
|
|
|
|
|
function showEmpty(msg) {
|
|
|
|
|
|
$('state').innerHTML = `<div class="empty">${esc(msg)}${state.q ? '' : ''}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
async function fetchJSON(url, signal) {
|
|
|
|
|
|
const r = await fetch(url, { signal });
|
|
|
|
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
|
|
|
|
return r.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
async function loadAssets(reset) {
|
|
|
|
|
|
if (reset) { state.assets = []; state.assetsOffset = 0; }
|
|
|
|
|
|
const p = new URLSearchParams({ q: state.q, exposure: state.exposure, sort: state.sort,
|
|
|
|
|
|
limit: String(PAGE), offset: String(state.assetsOffset) });
|
|
|
|
|
|
const data = await fetchJSON('api/assets?' + p);
|
|
|
|
|
|
mergeAssets(data.assets || []);
|
|
|
|
|
|
state.assetsTotal = data.total || 0;
|
|
|
|
|
|
state.assetsOffset = state.assets.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
function mergeAssets(rows) {
|
|
|
|
|
|
const seen = new Set(state.assets.map(key));
|
|
|
|
|
|
for (const a of rows) { const k = key(a); if (!seen.has(k)) { seen.add(k); state.assets.push(a); } }
|
|
|
|
|
|
if (state.sort === 'hostname') state.assets.sort((a, b) => a.provider_hostname.localeCompare(b.provider_hostname));
|
|
|
|
|
|
}
|
|
|
|
|
|
async function loadEntries(reset) {
|
|
|
|
|
|
if (reset) { state.entries = []; state.entriesOffset = 0; }
|
|
|
|
|
|
const p = new URLSearchParams({ q: state.q, limit: String(PAGE), offset: String(state.entriesOffset) });
|
|
|
|
|
|
const data = await fetchJSON('api/entries?' + p);
|
|
|
|
|
|
const seen = new Set(state.entries.map((e) => e.id));
|
|
|
|
|
|
for (const e of (data.entries || [])) if (!seen.has(e.id)) state.entries.push(e);
|
|
|
|
|
|
state.entriesTotal = data.total || 0;
|
|
|
|
|
|
state.entriesOffset = state.entries.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
async function reload(showSkeleton) {
|
|
|
|
|
|
if (state.abort) state.abort.abort();
|
|
|
|
|
|
const ac = new AbortController();
|
|
|
|
|
|
state.abort = ac;
|
|
|
|
|
|
if (showSkeleton) $('state').innerHTML = '<div class="skeleton w60"></div><div class="skeleton w40"></div><div class="skeleton w60"></div>';
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (state.view === 'assets') await loadAssets(true); else await loadEntries(true);
|
|
|
|
|
|
$('state').innerHTML = '';
|
|
|
|
|
|
render();
|
|
|
|
|
|
await loadStats();
|
|
|
|
|
|
setLive(true);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (e.name === 'AbortError') return;
|
|
|
|
|
|
$('state').innerHTML = `<div class="error">Could not load data (${esc(e.message)}). Retrying in 15s.</div>`;
|
|
|
|
|
|
setLive(false);
|
2026-09-22 13:58:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-22 14:33:03 +02:00
|
|
|
|
async function loadStats() {
|
|
|
|
|
|
const s = await fetchJSON('api/stats');
|
|
|
|
|
|
const readable = (s.by_exposure || []).find((x) => x.label === 'public_read');
|
|
|
|
|
|
$('summary').innerHTML =
|
|
|
|
|
|
`<span><strong>${s.total ?? 0}</strong> assets</span>` +
|
|
|
|
|
|
`<span><strong>${readable ? readable.count : 0}</strong> publicly readable</span>` +
|
|
|
|
|
|
`<span><strong>${s.reachable ?? 0}</strong> reachable endpoints</span>` +
|
|
|
|
|
|
`<span><strong>${s.entries ?? 0}</strong> indexed items</span>`;
|
2026-09-22 13:58:28 +02:00
|
|
|
|
}
|
2026-09-22 14:33:03 +02:00
|
|
|
|
function setLive(ok) {
|
|
|
|
|
|
state.ok = ok;
|
|
|
|
|
|
$('livedot').classList.toggle('paused', !ok);
|
|
|
|
|
|
$('livetext').textContent = (ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
|
|
|
|
|
|
}
|
|
|
|
|
|
function syncURL() {
|
|
|
|
|
|
const p = new URLSearchParams();
|
|
|
|
|
|
p.set('view', state.view);
|
|
|
|
|
|
if (state.q) p.set('q', state.q);
|
|
|
|
|
|
if (state.view === 'assets' && state.exposure) p.set('exposure', state.exposure);
|
|
|
|
|
|
if (state.view === 'assets' && state.sort !== 'exposure') p.set('sort', state.sort);
|
|
|
|
|
|
history.replaceState(null, '', '?' + p);
|
|
|
|
|
|
$('nav-assets').setAttribute('aria-current', state.view === 'assets' ? 'page' : 'false');
|
|
|
|
|
|
$('nav-contents').setAttribute('aria-current', state.view === 'contents' ? 'page' : 'false');
|
|
|
|
|
|
$('asset-controls').hidden = state.view !== 'assets';
|
|
|
|
|
|
$('q').placeholder = state.view === 'assets' ? 'Search hostname, bucket, database…' : 'Search indexed contents…';
|
|
|
|
|
|
}
|
2026-09-22 14:47:49 +02:00
|
|
|
|
function scanResult(r) {
|
|
|
|
|
|
if (r.error) return `<div class="error">${esc(r.host || r.target)}: ${esc(r.error)}</div>`;
|
|
|
|
|
|
const samples = (r.sample || []).map((s) => `<div class="mono sample">${esc(s)}</div>`).join('');
|
|
|
|
|
|
const chain = r.cname_chain && r.cname_chain.length > 1
|
|
|
|
|
|
? `<div class="result-meta"><span>chain: ${r.cname_chain.map(esc).join(' → ')}</span></div>` : '';
|
|
|
|
|
|
return `<div class="notice ${r.exposure === 'public_read' ? 'notice-ok' : ''}">
|
|
|
|
|
|
<div class="result-head">
|
|
|
|
|
|
<span class="result-title mono">${esc(r.host)}</span>${exposureBadge(r.exposure)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${r.detail ? `<p class="result-description">${esc(r.detail)}</p>` : ''}
|
|
|
|
|
|
<div class="badge-group">${badge(r.provider, 'badge-cap')}${badge(r.service, 'badge-size')}${badge((r.entries || 0) + ' entries')}</div>
|
|
|
|
|
|
${chain}${samples ? `<div class="samples">${samples}</div>` : ''}
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
$('scanform').addEventListener('submit', async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const target = $('scan').value.trim();
|
|
|
|
|
|
if (!target) return;
|
|
|
|
|
|
const out = $('scanout');
|
|
|
|
|
|
out.hidden = false;
|
|
|
|
|
|
out.innerHTML = '<div class="notice"><div class="skeleton w40"></div></div>';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await fetch('api/scan', {
|
|
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target }),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
|
|
|
|
const rows = await resp.json() || [];
|
|
|
|
|
|
out.innerHTML = rows.map(scanResult).join('');
|
|
|
|
|
|
if (rows.some((r) => r.entries > 0 || r.exposure === 'public_read')) reload(false);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
out.innerHTML = `<div class="error">Scan failed: ${esc(err.message)}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-22 14:33:03 +02:00
|
|
|
|
function setView(v) { state.view = v; syncURL(); reload(true); }
|
|
|
|
|
|
|
|
|
|
|
|
$('nav-assets').onclick = (e) => { e.preventDefault(); setView('assets'); };
|
|
|
|
|
|
$('nav-contents').onclick = (e) => { e.preventDefault(); setView('contents'); };
|
|
|
|
|
|
$('morebtn').onclick = async () => {
|
|
|
|
|
|
if (state.loading) return;
|
|
|
|
|
|
state.loading = true; $('morebtn').disabled = true;
|
|
|
|
|
|
try { if (state.view === 'assets') await loadAssets(false); else await loadEntries(false); render(); }
|
|
|
|
|
|
finally { state.loading = false; $('morebtn').disabled = false; }
|
|
|
|
|
|
};
|
|
|
|
|
|
let debounce;
|
|
|
|
|
|
$('q').addEventListener('input', () => {
|
|
|
|
|
|
state.q = $('q').value.trim();
|
|
|
|
|
|
$('clear').classList.toggle('show', state.q.length > 0);
|
|
|
|
|
|
syncURL();
|
|
|
|
|
|
clearTimeout(debounce);
|
|
|
|
|
|
debounce = setTimeout(() => reload(false), 250);
|
|
|
|
|
|
});
|
|
|
|
|
|
$('clear').onclick = () => { $('q').value = ''; state.q = ''; $('clear').classList.remove('show'); syncURL(); reload(true); $('q').focus(); };
|
|
|
|
|
|
document.querySelectorAll('#filters .chip').forEach((c) => c.onclick = () => {
|
|
|
|
|
|
state.exposure = c.dataset.exposure;
|
|
|
|
|
|
document.querySelectorAll('#filters .chip').forEach((x) => x.setAttribute('aria-pressed', String(x.dataset.exposure === state.exposure)));
|
|
|
|
|
|
syncURL(); reload(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
$('sort').onchange = () => { state.sort = $('sort').value; syncURL(); reload(true); };
|
|
|
|
|
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) reload(false); });
|
|
|
|
|
|
|
|
|
|
|
|
function schedule() {
|
|
|
|
|
|
clearInterval(state.timer);
|
|
|
|
|
|
state.timer = setInterval(() => {
|
|
|
|
|
|
if (document.hidden) return;
|
|
|
|
|
|
state.countdown--;
|
|
|
|
|
|
if (state.countdown <= 0) { state.countdown = REFRESH_MS / 1000; reload(false); }
|
|
|
|
|
|
$('livetext').textContent = (state.ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
|
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
syncURL();
|
|
|
|
|
|
reload(true);
|
|
|
|
|
|
schedule();
|
2026-09-22 13:58:28 +02:00
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|