port: apply 7 upstream security/robustness patches

Ports from isolated upstream-port branch (base b3c5a4b), verified in
isolation via baseline-vs-port failure-set diff and re-verified live
(195 pass / 0 fail on affected tests):
- redact Discord bot tokens in outbound (router.ts SECRET_PATTERNS)
- block SSRF to private hosts in MoA base URL (moa.ts)
- refuse public dashboard bind without auth token (web-dashboard-server.ts)
- merge upstream .gitignore rules for python/build/secret noise
- real CPU utilization from /proc/stat instead of load avg (unified-dashboard.ts)
- width-safe placeholder for missing usage window on mobile (unified-dashboard.ts)
- bump direct deps to patch known vulnerabilities (discord.js/yaml/cron-parser)

Risky upstream commits (d5a94af phantom reset-time, patch 6 Codex usage)
intentionally skipped to avoid touching the credential-isolation tree.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-24 19:35:59 +09:00
parent b3c5a4b41b
commit c016b9c2fa
11 changed files with 393 additions and 205 deletions

View File

@@ -318,4 +318,14 @@ describe('formatOutbound with tool-call leaks', () => {
'Key: sk-ant-XXXXXXXXXXXXXXXXXXXXXXXX\nto=functions.exec_command code {"cmd":"ls"}';
expect(formatOutbound(input)).toBe('Key: [REDACTED]');
});
it('redacts a leaked Discord bot token', () => {
// Built from parts so the literal never appears in source (push protection).
const fakeToken = [
'MTA5ODc2NTQzMjEwOTg3NjU0',
'GaBcDe',
'abcdefghijklmnopqrstuvwxyz0123',
].join('.');
expect(formatOutbound(`token=${fakeToken}`)).toBe('token=[REDACTED]');
});
});

48
src/moa.test.ts Normal file
View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { assertSafeMoaBaseUrl } from './moa.js';
describe('assertSafeMoaBaseUrl', () => {
it('accepts public https endpoints', () => {
expect(() =>
assertSafeMoaBaseUrl('https://api.moonshot.cn/v1'),
).not.toThrow();
expect(() =>
assertSafeMoaBaseUrl('https://open.bigmodel.cn/api/paas/v4'),
).not.toThrow();
});
it('rejects non-http(s) schemes', () => {
expect(() => assertSafeMoaBaseUrl('file:///etc/passwd')).toThrow(
/http\(s\)/,
);
});
it('rejects loopback and localhost', () => {
expect(() => assertSafeMoaBaseUrl('http://127.0.0.1:8080')).toThrow(
/not allowed/,
);
expect(() => assertSafeMoaBaseUrl('http://localhost/v1')).toThrow(
/not allowed/,
);
});
it('rejects cloud metadata and private ranges', () => {
expect(() => assertSafeMoaBaseUrl('http://169.254.169.254/latest')).toThrow(
/not allowed/,
);
expect(() => assertSafeMoaBaseUrl('http://10.0.0.5/v1')).toThrow(
/not allowed/,
);
expect(() => assertSafeMoaBaseUrl('http://192.168.1.1/v1')).toThrow(
/not allowed/,
);
expect(() => assertSafeMoaBaseUrl('http://100.101.210.95/v1')).toThrow(
/not allowed/,
);
});
it('rejects malformed URLs', () => {
expect(() => assertSafeMoaBaseUrl('not-a-url')).toThrow(/Invalid/);
});
});

View File

@@ -44,6 +44,63 @@ function normalizeError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Reject MoA base URLs that point at loopback / private / link-local hosts.
*
* MoA reference models are external SaaS endpoints (Kimi, GLM, ...). Allowing
* an internal host lets a compromised or misconfigured settings write turn the
* server-side MoA fetch into an SSRF probe (e.g. cloud metadata at
* 169.254.169.254). Only http(s) to a non-private host is permitted.
*/
export function assertSafeMoaBaseUrl(baseUrl: string): URL {
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
throw new Error(`Invalid MoA base URL: ${baseUrl}`);
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new Error(`MoA base URL must use http(s): ${baseUrl}`);
}
if (isPrivateOrLocalHost(parsed.hostname)) {
throw new Error(
`MoA base URL host is not allowed (loopback/private/link-local): ${parsed.hostname}`,
);
}
return parsed;
}
function isPrivateOrLocalHost(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (
host === 'localhost' ||
host.endsWith('.localhost') ||
host.endsWith('.internal') ||
host.endsWith('.local')
) {
return true;
}
// IPv6 loopback / link-local / unique-local
if (host === '::1' || host === '::') return true;
if (
host.startsWith('fe80:') ||
host.startsWith('fc') ||
host.startsWith('fd')
) {
return true;
}
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
if (a === 127 || a === 0 || a === 10) return true; // loopback / this-host / private
if (a === 169 && b === 254) return true; // link-local incl. cloud metadata
if (a === 172 && b >= 16 && b <= 31) return true; // private
if (a === 192 && b === 168) return true; // private
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT / tailnet
}
return false;
}
function recordReferenceStatus(
status: Omit<MoaReferenceStatus, 'checkedAt'>,
): MoaReferenceStatus {
@@ -69,6 +126,7 @@ async function queryModel(
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
assertSafeMoaBaseUrl(model.baseUrl);
const base = model.baseUrl.replace(/\/+$/, '');
const isAnthropic = model.apiFormat === 'anthropic';

View File

@@ -42,6 +42,8 @@ const SECRET_PATTERNS: RegExp[] = [
/glpat-[A-Za-z0-9_-]{20,}/g, // GitLab PAT
/AKIA[A-Z0-9]{16}/g, // AWS Access Key
/Bearer\s+eyJ[A-Za-z0-9_-]{40,}/g, // Bearer JWT
// Discord bot token: base64 id "." 6-char ts "." 27+ char hmac
/\b[MN][A-Za-z0-9_-]{23,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/g,
];
function redactSecrets(text: string): string {

View File

@@ -8,11 +8,38 @@ import {
formatStatusHeader,
msUntilNextMinuteBoundary,
getDashboardDuplicateCleanupIntervalMs,
readCpuUtilizationPct,
renderUsageTable,
resetCpuUtilizationSample,
shouldPurgeDashboardChannelOnStart,
summarizeWatcherTasks,
} from './unified-dashboard.js';
describe('readCpuUtilizationPct', () => {
it('computes busy percentage from consecutive /proc/stat samples', () => {
resetCpuUtilizationSample();
expect(
readCpuUtilizationPct(() => 'cpu 100 0 100 800 0 0 0 0 0 0\n'),
).toBeNull();
// +100 total ticks: +30 busy, +70 idle => 30%.
expect(
readCpuUtilizationPct(() => 'cpu 115 0 115 870 0 0 0 0 0 0\n'),
).toBe(30);
});
it('counts iowait as idle and rejects malformed samples', () => {
resetCpuUtilizationSample();
expect(readCpuUtilizationPct(() => 'not-a-cpu-line\n')).toBeNull();
expect(
readCpuUtilizationPct(() => 'cpu 100 0 100 700 100 0 0 0 0 0\n'),
).toBeNull();
// +100 total, all +100 is iowait: CPU busy must be 0%, not 100%.
expect(
readCpuUtilizationPct(() => 'cpu 100 0 100 700 200 0 0 0 0 0\n'),
).toBe(0);
});
});
describe('summarizeWatcherTasks', () => {
it('counts active and paused watcher tasks only', () => {
const summary = summarizeWatcherTasks([

View File

@@ -488,6 +488,47 @@ function buildStatusContent(): string {
return `${header}\n\n${sections}`;
}
/** Previous /proc/stat sample for CPU utilization deltas. */
let lastCpuSample: { idle: number; total: number } | null = null;
/**
* Real CPU utilization percent from /proc/stat deltas between calls.
* First call (no previous sample) and non-Linux hosts return null.
* Exported for testing alongside resetCpuUtilizationSample.
*/
export function readCpuUtilizationPct(
readStat: () => string = () => fs.readFileSync('/proc/stat', 'utf-8'),
): number | null {
try {
const cpuLine = readStat()
.split('\n')
.find((line) => line.startsWith('cpu '));
if (!cpuLine) return null;
const fields = cpuLine.trim().split(/\s+/).slice(1).map(Number);
if (fields.length < 5 || fields.some((n) => !Number.isFinite(n))) {
return null;
}
// user nice system idle iowait irq softirq steal ...
const idle = fields[3] + (fields[4] ?? 0); // idle + iowait
const total = fields.reduce((a, b) => a + b, 0);
const prev = lastCpuSample;
lastCpuSample = { idle, total };
if (!prev || total <= prev.total) return null;
const totalDelta = total - prev.total;
const idleDelta = idle - prev.idle;
return Math.round(
Math.min(100, Math.max(0, ((totalDelta - idleDelta) / totalDelta) * 100)),
);
} catch {
return null;
}
}
/** Test-only: reset the CPU sample so the next read starts fresh. */
export function resetCpuUtilizationSample(): void {
lastCpuSample = null;
}
/**
* Render usage table lines from two row groups (Claude and Codex).
* Returns rendered lines including code block markers.
@@ -517,6 +558,10 @@ export function renderUsageTable(
const lines: string[] = [];
// Missing-window placeholder. Must mirror a real cell's char composition
// (5 ambiguous-width bar chars + 4 ASCII): mobile fonts render block/box
// chars double-width, so a plain '—' cell breaks column alignment.
const emptyCell = '─'.repeat(5) + ' ';
const renderRows = (rows: UsageRow[]) => {
for (const row of rows) {
if (row.error) {
@@ -529,11 +574,11 @@ export function renderUsageTable(
const h5 =
row.h5pct >= 0
? `${bar(row.h5pct)}${String(row.h5pct).padStart(3)}%`
: ' — ';
: emptyCell;
const d7 =
row.d7pct >= 0
? `${bar(row.d7pct)}${String(row.d7pct).padStart(3)}%`
: ' — ';
: emptyCell;
lines.push(`${padName(row.name)}${h5} ${d7}`);
const r5 = compactReset(row.h5reset);
const r7 = compactReset(row.d7reset);
@@ -626,7 +671,11 @@ async function buildUsageContent(): Promise<string> {
const loadAvg = os.loadavg();
const cpuCount = os.cpus().length;
const cpuPct = Math.round((loadAvg[1] / cpuCount) * 100);
// Real CPU utilization from /proc/stat deltas between renders.
// Load-average-based percent counts D-state (I/O-wait) processes, so a
// writeback storm once rendered as "CPU 3104%" despite idle CPUs.
const cpuPct = readCpuUtilizationPct() ?? 0;
const loadPerCore = loadAvg[0] / cpuCount;
const totalMem = os.totalmem();
// os.freemem() includes buffers/cache as "used" — misleading.
// Read MemAvailable from /proc/meminfo for actual available memory.
@@ -696,6 +745,12 @@ async function buildUsageContent(): Promise<string> {
lines.push('```');
lines.push(`${'CPU'.padEnd(8)}${bar(cpuPct)} ${String(cpuPct).padStart(3)}%`);
// Raw load average with core count: I/O storms (D-state pileups) show up
// here without masquerading as CPU usage. Flag when load exceeds cores.
const loadFlag = loadPerCore > 1 ? ' ▲' : '';
lines.push(
`${'Load'.padEnd(8)}${loadAvg[0] >= 100 ? loadAvg[0].toFixed(0) : loadAvg[0].toFixed(1)}/${cpuCount}cpu${loadFlag}`,
);
lines.push(
`${'Memory'.padEnd(8)}${bar(memPct)} ${String(memPct).padStart(3)}% ${memUsedGB}/${memTotalGB}GB`,
);

View File

@@ -5,7 +5,10 @@ import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import type { NewMessage, PairedTask, RegisteredGroup } from './types.js';
import { createWebDashboardHandler } from './web-dashboard-server.js';
import {
assertDashboardAuthPosture,
createWebDashboardHandler,
} from './web-dashboard-server.js';
const tempDirs: string[] = [];
@@ -45,6 +48,36 @@ afterEach(() => {
}
});
describe('assertDashboardAuthPosture', () => {
it('allows loopback binding without a token', () => {
expect(() => assertDashboardAuthPosture('127.0.0.1', '')).not.toThrow();
expect(() => assertDashboardAuthPosture('localhost', '')).not.toThrow();
});
it('allows any host when a token is set', () => {
expect(() => assertDashboardAuthPosture('0.0.0.0', 'secret')).not.toThrow();
expect(() =>
assertDashboardAuthPosture('203.0.113.5', 'secret'),
).not.toThrow();
});
it('warns but starts on a private / tailnet host without a token', () => {
expect(() =>
assertDashboardAuthPosture('100.101.210.95', ''),
).not.toThrow();
expect(() => assertDashboardAuthPosture('192.168.1.10', '')).not.toThrow();
});
it('refuses a public / all-interface bind without a token', () => {
expect(() => assertDashboardAuthPosture('0.0.0.0', '')).toThrow(
/WEB_DASHBOARD_TOKEN/,
);
expect(() => assertDashboardAuthPosture('203.0.113.5', '')).toThrow(
/WEB_DASHBOARD_TOKEN/,
);
});
});
describe('web dashboard server handler', () => {
it('serves health and overview JSON without requiring Discord', async () => {
const handler = createWebDashboardHandler({

View File

@@ -504,6 +504,54 @@ export function createWebDashboardHandler(
};
}
function isLoopbackHost(host: string): boolean {
const h = host.replace(/^\[|\]$/g, '').toLowerCase();
return h === 'localhost' || h === '::1' || /^127\./.test(h);
}
function isPrivateBindHost(host: string): boolean {
const h = host.replace(/^\[|\]$/g, '').toLowerCase();
if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) {
return true;
}
const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\./);
if (!ipv4) return false;
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
if (a === 10) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT / tailnet
return false;
}
/**
* Refuse to expose an unauthenticated dashboard on a public interface.
*
* With no token the API auth gate passes for everyone (see
* isDashboardApiAuthorized). Binding that to a public interface / all
* interfaces (0.0.0.0) would let any reachable client drive agents and restart
* the service, so we fail fast. Loopback is fine; private / tailnet binds warn
* loudly but still start so existing deployments keep running.
*/
export function assertDashboardAuthPosture(host: string, token: string): void {
if (token) return;
if (isLoopbackHost(host)) return;
if (isPrivateBindHost(host)) {
logger.warn(
{ host },
'Web dashboard is bound to a non-loopback host without WEB_DASHBOARD_TOKEN; ' +
'anyone who can reach this host can drive agents and restart the service. ' +
'Set WEB_DASHBOARD_TOKEN to require authentication.',
);
return;
}
throw new Error(
`Refusing to start web dashboard on public host "${host}" without WEB_DASHBOARD_TOKEN. ` +
'Set WEB_DASHBOARD_TOKEN or bind to 127.0.0.1.',
);
}
export function startWebDashboardServer(
opts: {
enabled?: boolean;
@@ -521,6 +569,7 @@ export function startWebDashboardServer(
const host = opts.host ?? WEB_DASHBOARD.host;
const port = opts.port ?? WEB_DASHBOARD.port;
const staticDir = opts.staticDir ?? WEB_DASHBOARD.staticDir;
assertDashboardAuthPosture(host, WEB_DASHBOARD.token);
const server = Bun.serve({
hostname: host,
port,