Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ 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.
- Made dependency auditing cover recognized development-tool advisories
without weakening the runtime gate.

## [0.3.0] - 2026-07-28

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
49 changes: 32 additions & 17 deletions app/Embertop.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,7 +19,7 @@ function Reading({
}: {
label: string;
caption: string;
value: number;
value: number | null;
progress: number | null;
tone?: "flame" | "ember";
}) {
Expand All @@ -30,15 +31,15 @@ function Reading({
<em>{caption}</em>
</span>
<span className="reading-value">
{Math.round(value)}
<span className="unit">%</span>
{value == null ? "—" : Math.round(value)}
{value == null ? null : <span className="unit">%</span>}
</span>
</div>
<div className={`gauge gauge-${tone}`} aria-hidden="true">
<span
style={{
width: `${progress == null ? 0 : Math.max(1.5, progress)}%`,
opacity: progress == null ? 0.2 : 1,
opacity: progress == null ? 0 : 1,
}}
/>
</div>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -235,8 +238,8 @@ export function Embertop() {
return (
<main className={`app ${focusMode ? "is-focus" : ""}`} data-paused={paused}>
<FireCanvas
metrics={frame.metrics}
visits={frame.visits}
metrics={metrics}
visits={visits}
paused={paused}
reducedMotion={reducedMotion}
layout={focusMode ? "focus" : "default"}
Expand All @@ -245,7 +248,7 @@ export function Embertop() {
<header className="bar bar-top">
<div className="identity">
<span className="wordmark">embertop</span>
<span className="site">{frame.site}</span>
<span className="site">{frame?.site ?? "awaiting telemetry"}</span>
</div>
<div className="status">
<span
Expand All @@ -266,14 +269,14 @@ export function Embertop() {
<Reading
label="CPU"
caption="flame"
value={frame.metrics.cpu}
progress={frame.metrics.cpu}
value={frame?.metrics.cpu ?? null}
progress={frame?.metrics.cpu ?? null}
/>
<Reading
label="Memory"
caption="embers"
value={frame.metrics.memory}
progress={frame.metrics.memory}
value={frame?.metrics.memory ?? null}
progress={frame?.metrics.memory ?? null}
tone="ember"
/>
</div>
Expand All @@ -284,15 +287,25 @@ export function Embertop() {
<div className="bands-head">
<h2>Last 60 seconds</h2>
<span>
{frame.metrics.requestsPerMinute} requests
<em> · </em>
load {frame.metrics.load1.toFixed(2)}
{frame ? (
<>
{frame.metrics.requestsPerMinute} requests
<em> · </em>
load {frame.metrics.load1.toFixed(2)}
</>
) : (
"awaiting first reading"
)}
</span>
</div>
{traffic.total === 0 ? (
// An idle server is the usual case; it should say so once.
<div className="band">
<p className="band-keys band-quiet">No requests in the last minute</p>
<p className="band-keys band-quiet">
{frame
? "No requests in the last minute"
: "Waiting for telemetry."}
</p>
<div className="band-track" />
</div>
) : (
Expand Down Expand Up @@ -327,7 +340,9 @@ export function Embertop() {
<p>addresses and query strings dropped</p>
</div>
{recentVisits.length === 0 ? (
<p className="feed-empty">Waiting for the next spark.</p>
<p className="feed-empty">
{frame ? "Waiting for the next spark." : "Waiting for telemetry."}
</p>
) : (
<ol className="feed-list">
{/* The list is clipped by its flex box, so it simply fills
Expand Down
4 changes: 2 additions & 2 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 1 addition & 17 deletions app/useTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { useEffect, useRef, useState } from "react";
import {
EMPTY_METRICS,
normalizeFrame,
type TelemetryFrame,
type VisitEvent,
Expand All @@ -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",
Expand All @@ -34,7 +19,7 @@ const CLOCK_FORMATTER = new Intl.DateTimeFormat("en-GB", {
});

export function useTelemetry() {
const [frame, setFrame] = useState<TelemetryFrame>(INITIAL_FRAME);
const [frame, setFrame] = useState<TelemetryFrame | null>(null);
const [connection, setConnection] =
useState<ConnectionState>("connecting");
const [recentVisits, setRecentVisits] = useState<VisitEvent[]>([]);
Expand All @@ -46,7 +31,6 @@ export function useTelemetry() {
const stream = new EventSource(`${pagePath}/api/stream`);
const seen = new Set<string>();

stream.onopen = () => setConnection("connected");
stream.onmessage = (event) => {
try {
const normalized = normalizeFrame(JSON.parse(event.data));
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
131 changes: 131 additions & 0 deletions scripts/audit-dependencies.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
Loading