From 138758ce66407db55087d6e6008c953cd3ef013a Mon Sep 17 00:00:00 2001 From: seoulpro Date: Tue, 28 Jul 2026 02:12:36 +0900 Subject: [PATCH 1/2] fix(web): wait for verified telemetry --- CHANGELOG.md | 2 ++ app/Embertop.tsx | 49 +++++++++++++++++++++----------- app/globals.css | 4 +-- app/useTelemetry.ts | 18 +----------- tests/rendered-html.test.mjs | 16 +++++++++++ tests/web-accessibility.test.mjs | 49 ++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 36 deletions(-) create mode 100644 tests/web-accessibility.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f5552f9..df66673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to this project are documented here. The format follows contents and Embertop's emitted-event redaction. - Made SSH preview examples bind explicitly to loopback and documented direct terminal, web-tunnel, service-status, journal, and `doctor` checks. +- Replaced fabricated first-load readings with an explicit waiting state and + improved muted-text contrast. ## [0.3.0] - 2026-07-28 diff --git a/app/Embertop.tsx b/app/Embertop.tsx index 67aaa6c..f4b65f7 100644 --- a/app/Embertop.tsx +++ b/app/Embertop.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { EMPTY_METRICS } from "@/lib/telemetry"; import { FireCanvas } from "./FireCanvas"; import { useCampfireAudio, @@ -18,7 +19,7 @@ function Reading({ }: { label: string; caption: string; - value: number; + value: number | null; progress: number | null; tone?: "flame" | "ember"; }) { @@ -30,15 +31,15 @@ function Reading({ {caption} - {Math.round(value)} - % + {value == null ? "—" : Math.round(value)} + {value == null ? null : %} @@ -180,8 +181,10 @@ export function Embertop() { }); const frame = paused ? pausedSnapshot.frame : liveFrame; const recentVisits = paused ? pausedSnapshot.recentVisits : liveRecentVisits; + const metrics = frame?.metrics ?? EMPTY_METRICS; + const visits = frame?.visits ?? []; - useCampfireAudio(soundEnabled, frame.metrics.cpu); + useCampfireAudio(soundEnabled, metrics.cpu); const toggleFocus = useCallback(() => { setFocusMode((current) => !current); @@ -235,8 +238,8 @@ export function Embertop() { return (
embertop - {frame.site} + {frame?.site ?? "awaiting telemetry"}
@@ -284,15 +287,25 @@ export function Embertop() {

Last 60 seconds

- {frame.metrics.requestsPerMinute} requests - · - load {frame.metrics.load1.toFixed(2)} + {frame ? ( + <> + {frame.metrics.requestsPerMinute} requests + · + load {frame.metrics.load1.toFixed(2)} + + ) : ( + "awaiting first reading" + )}
{traffic.total === 0 ? ( // An idle server is the usual case; it should say so once.
-

No requests in the last minute

+

+ {frame + ? "No requests in the last minute" + : "Waiting for telemetry."} +

) : ( @@ -327,7 +340,9 @@ export function Embertop() {

addresses and query strings dropped

{recentVisits.length === 0 ? ( -

Waiting for the next spark.

+

+ {frame ? "Waiting for the next spark." : "Waiting for telemetry."} +

) : (
    {/* The list is clipped by its flex box, so it simply fills diff --git a/app/globals.css b/app/globals.css index 1ba70ea..6ffe40c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -7,8 +7,8 @@ --bg: #0a0a0b; --fg: #eae8e5; --fg-2: #9b9894; - --fg-3: #6e6b67; - --fg-4: #4a4744; + --fg-3: #898581; + --fg-4: #807c77; --rule: rgba(255, 255, 255, 0.1); --rule-soft: rgba(255, 255, 255, 0.055); --ember: #ff7a3c; diff --git a/app/useTelemetry.ts b/app/useTelemetry.ts index ee3df37..4eba959 100644 --- a/app/useTelemetry.ts +++ b/app/useTelemetry.ts @@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from "react"; import { - EMPTY_METRICS, normalizeFrame, type TelemetryFrame, type VisitEvent, @@ -12,20 +11,6 @@ import type { TrafficMix } from "@/lib/telemetry"; type ConnectionState = "connecting" | "connected" | "reconnecting"; -const INITIAL_FRAME: TelemetryFrame = { - schema: 1, - sequence: 0, - at: new Date(0).toISOString(), - source: "live", - site: "your-server", - metrics: { - ...EMPTY_METRICS, - cpu: 18, - memory: 52, - load1: 0.42, - }, - visits: [], -}; const CLOCK_FORMATTER = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", @@ -34,7 +19,7 @@ const CLOCK_FORMATTER = new Intl.DateTimeFormat("en-GB", { }); export function useTelemetry() { - const [frame, setFrame] = useState(INITIAL_FRAME); + const [frame, setFrame] = useState(null); const [connection, setConnection] = useState("connecting"); const [recentVisits, setRecentVisits] = useState([]); @@ -46,7 +31,6 @@ export function useTelemetry() { const stream = new EventSource(`${pagePath}/api/stream`); const seen = new Set(); - stream.onopen = () => setConnection("connected"); stream.onmessage = (event) => { try { const normalized = normalizeFrame(JSON.parse(event.data)); diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index fbe7fff..68d1ba4 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -137,6 +137,14 @@ test("server-renders the complete Embertop experience", async () => { assert.match(html, /load/); assert.match(html, /Requests/); assert.match(html, /addresses and query strings dropped/); + assert.match(html, /awaiting telemetry/i); + assert.match(html, /awaiting first reading/i); + assert.equal( + html.match(/class="reading-value">—<\/span>/g)?.length, + 2, + "initial readings should be visibly unavailable", + ); + assert.doesNotMatch(html, /your-server|load 0\.42/i); assert.match(html, /Just the fire/); assert.match(html, /]*>M<\/kbd>/i); assert.match(html, /]*>Space<\/kbd>/i); @@ -171,6 +179,14 @@ test("keeps telemetry credentials server-only", async () => { telemetryClient, /new EventSource\(`\$\{pagePath\}\/api\/stream`\)/, ); + assert.match( + telemetryClient, + /useState\(null\)/, + ); + assert.doesNotMatch( + telemetryClient, + /INITIAL_FRAME|stream\.onopen|cpu:\s*18|memory:\s*52/, + ); assert.doesNotMatch( `${client}\n${telemetryClient}`, /EMBERTOP_UPSTREAM_TOKEN|NEXT_PUBLIC_/, diff --git a/tests/web-accessibility.test.mjs b/tests/web-accessibility.test.mjs new file mode 100644 index 0000000..34553fd --- /dev/null +++ b/tests/web-accessibility.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +function channel(value) { + const normalized = value / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function luminance(hex) { + const [red, green, blue] = hex + .slice(1) + .match(/../g) + .map((part) => channel(Number.parseInt(part, 16))); + return red * 0.2126 + green * 0.7152 + blue * 0.0722; +} + +function contrast(first, second) { + const [lighter, darker] = [luminance(first), luminance(second)].sort( + (left, right) => right - left, + ); + return (lighter + 0.05) / (darker + 0.05); +} + +function color(css, variable) { + const value = css.match( + new RegExp(`--${variable}:\\s*(#[0-9a-f]{6})`, "i"), + )?.[1]; + assert.ok(value, `expected --${variable} to be a six-digit hex color`); + return value; +} + +test("muted interface text meets normal-text contrast", async () => { + const css = await readFile( + new URL("../app/globals.css", import.meta.url), + "utf8", + ); + const background = color(css, "bg"); + + for (const variable of ["fg-3", "fg-4"]) { + const ratio = contrast(color(css, variable), background); + assert.ok( + ratio >= 4.5, + `--${variable} contrast is ${ratio.toFixed(2)}:1; expected at least 4.5:1`, + ); + } +}); From a5dcd37507cd045e7e2c357cca0d2600259c0a1d Mon Sep 17 00:00:00 2001 From: seoulpro Date: Tue, 28 Jul 2026 02:12:59 +0900 Subject: [PATCH 2/2] ci: audit development dependencies --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 2 + CONTRIBUTING.md | 2 +- SECURITY.md | 13 ++++ package.json | 1 + scripts/audit-dependencies.mjs | 131 +++++++++++++++++++++++++++++++++ 6 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 scripts/audit-dependencies.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc73178..601b701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,8 @@ jobs: - name: Type-check run: npm run typecheck - - name: Audit production dependencies - run: npm audit --omit=dev --audit-level=high + - name: Audit dependency policy + run: npm run audit:dependencies - name: Test web and CLI run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index df66673..e6c066f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ All notable changes to this project are documented here. The format follows terminal, web-tunnel, service-status, journal, and `doctor` checks. - Replaced fabricated first-load readings with an explicit waiting state and improved muted-text contrast. +- Made dependency auditing cover recognized development-tool advisories + without weakening the runtime gate. ## [0.3.0] - 2026-07-28 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38c388f..c9ad994 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ Before opening a pull request: npm run typecheck npm run lint npm test -npm audit --omit=dev +npm run audit:dependencies ``` `npm test` builds the web app first, so it takes a minute. diff --git a/SECURITY.md b/SECURITY.md index ae91302..5832566 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,3 +45,16 @@ UI can reveal traffic patterns even when individual visitors are anonymized. Never commit `.env` files or real collector configuration. Rotate `EMBERTOP_COLLECTOR_TOKEN`, `EMBERTOP_UPSTREAM_TOKEN`, and `EMBERTOP_METRICS_TOKEN` after any suspected disclosure. + +## Dependency audits + +`npm run audit:dependencies` blocks high- or critical-severity findings in +runtime dependencies and any unrecognized high- or critical-severity finding +in development dependencies. + +The policy temporarily recognizes +[GHSA-mh99-v99m-4gvg](https://github.com/advisories/GHSA-mh99-v99m-4gvg) +in ESLint's development-only glob stack. Embertop does not pass untrusted glob +patterns to this tooling, and the affected packages are not installed by +production-only installs. The exception should be removed as soon as the +Next.js ESLint plugin tree supports a patched dependency chain. diff --git a/package.json b/package.json index 1c2ce96..f6d89c7 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "test:unit": "node --test tests/collector.test.mjs tests/cli.test.mjs", "typecheck": "tsc --noEmit", "lint": "eslint .", + "audit:dependencies": "node scripts/audit-dependencies.mjs", "cli": "node bin/embertop.mjs", "collector": "node bin/embertop.mjs serve", "prepack": "npm run typecheck && npm run lint && npm test" diff --git a/scripts/audit-dependencies.mjs b/scripts/audit-dependencies.mjs new file mode 100644 index 0000000..3b38e78 --- /dev/null +++ b/scripts/audit-dependencies.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; + +const SIGNIFICANT_SEVERITIES = new Set(["high", "critical"]); +const ALLOWED_DEVELOPMENT_ADVISORIES = new Map([ + [ + "https://github.com/advisories/GHSA-mh99-v99m-4gvg", + "brace-expansion in ESLint's development-only glob stack", + ], +]); + +function runAudit(arguments_) { + const command = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = spawnSync(command, ["audit", ...arguments_, "--json"], { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); + + if (result.error) throw result.error; + + let report; + try { + report = JSON.parse(result.stdout); + } catch { + const detail = result.stderr.trim() || result.stdout.trim(); + throw new Error(`npm audit did not return valid JSON${detail ? `: ${detail}` : ""}`); + } + + if (report.error) { + throw new Error( + `npm audit failed: ${report.error.summary ?? report.error.message ?? "unknown error"}`, + ); + } + + return report; +} + +function significantFindings(report) { + return Object.entries(report.vulnerabilities ?? {}).filter(([, finding]) => + SIGNIFICANT_SEVERITIES.has(finding.severity), + ); +} + +function rootAdvisories(report, packageName, seen = new Set()) { + if (seen.has(packageName)) return []; + seen.add(packageName); + + const finding = report.vulnerabilities?.[packageName]; + if (!finding) return []; + + return finding.via.flatMap((via) => { + if (typeof via === "string") { + return rootAdvisories(report, via, seen); + } + return [ + { + name: via.name, + severity: via.severity, + url: via.url, + }, + ]; + }); +} + +function auditDependencies() { + const runtimeReport = runAudit(["--omit=dev"]); + const runtimeFindings = significantFindings(runtimeReport); + if (runtimeFindings.length > 0) { + throw new Error( + `High or critical runtime vulnerabilities: ${runtimeFindings + .map(([name]) => name) + .join(", ")}`, + ); + } + console.log("Runtime dependency audit passed."); + + const fullReport = runAudit([]); + const developmentFindings = significantFindings(fullReport); + if (developmentFindings.length === 0) { + console.log("Development dependency audit passed."); + return; + } + + const rootFindings = new Map(); + const unexplained = []; + let hasCriticalFinding = false; + + for (const [packageName, finding] of developmentFindings) { + if (finding.severity === "critical") hasCriticalFinding = true; + const roots = rootAdvisories(fullReport, packageName); + if (roots.length === 0) unexplained.push(packageName); + for (const root of roots) rootFindings.set(root.url, root); + } + + const unexpected = [...rootFindings.values()].filter( + (finding) => + finding.severity !== "high" || + !ALLOWED_DEVELOPMENT_ADVISORIES.has(finding.url), + ); + + if (hasCriticalFinding || unexplained.length > 0 || unexpected.length > 0) { + const details = [ + ...unexplained.map((name) => `unresolved dependency chain: ${name}`), + ...unexpected.map( + (finding) => `${finding.name} (${finding.severity}): ${finding.url}`, + ), + ]; + throw new Error( + `High or critical development vulnerabilities require review:\n${details.join("\n")}`, + ); + } + + for (const finding of rootFindings.values()) { + console.warn( + `Recognized development-only advisory: ${ + ALLOWED_DEVELOPMENT_ADVISORIES.get(finding.url) + } (${finding.url}).`, + ); + } + console.warn( + `${developmentFindings.length} affected development dependency entries; runtime dependencies remain clear.`, + ); +} + +try { + auditDependencies(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}