diff --git a/.env.example b/.env.example index 6deba603..5c62eef4 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,16 @@ -# Optional API key for summarizing failure capsules via 'chronos explain' -NVIDIA_API_KEY= +# Optional — `chronos explain` summarizes a failure capsule with an LLM. +# The command is fully optional and degrades to a no-op when nothing is set. +# Set ONE of these; they are probed in this order. +# +# NEVER commit real values. `.env` is gitignored; this file is the template. + +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GEMINI_API_KEY= + +# Or point at any OpenAI-compatible endpoint (Ollama, LM Studio, vLLM, +# OpenRouter, Groq, ...). https is required for any non-loopback host — the key +# is sent as a bearer token. +LLM_BASE_URL= +LLM_API_KEY= +LLM_MODEL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f300999c..fc50912a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,13 @@ on: pull_request: branches: [main] +# CI only reads the repository. Without this, the workflow's GITHUB_TOKEN +# inherits the repo-wide default, which on many repos is write-all — meaning a +# compromised dependency running during `pnpm install` or a test could push +# commits or publish releases. +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -26,3 +33,7 @@ jobs: - run: pnpm lint - run: pnpm test + + # The published artifact is the build output, not the sources tsc checks. + # Without this step a tsup/vite build break only surfaces at publish time. + - run: pnpm build diff --git a/PROGRESS.md b/PROGRESS.md index 71c89d14..2e3d0e2f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -41,6 +41,28 @@ This document tracks the features, validation steps, and progress of the **Chron - Interactive timeline scrubber to inspect system states chronologically. - **Metrics & Link Matrix Dashboard**: Highlights total events, drop rate, duplicate rate, and fault counts. Offers an interactive node-to-node link matrix detailing traffic statistics and losses. +### 6. Security Hardening + +A full audit against the [vibe-security](https://github.com/raroque/vibe-security-skill) +checklist, with every finding fixed and pinned by a regression test. The threat +model is written up in [`SECURITY.md`](./SECURITY.md); its central assumption is +that **a failure capsule is untrusted input**, since capsules exist to be shared. + +- **Capsule trust boundary**: the whole capsule — trace envelope and every event + against the `TraceEvent` union — is validated at read time, so renderers can + stay simple. File size is capped before reading, `__proto__` is rejected at the + parse boundary, and writes use an unpredictable temp name with an exclusive + `0600` create. +- **Per-sink output escaping**: terminal control sequences are stripped before + printing, CSV cells are neutralized against spreadsheet formula injection, and + Markdown cells against table breakout. +- **Credential handling**: `chronos explain` sends API keys as headers only, + requires `https` off-loopback, and masks the interactive key prompt. +- **Bounded rendering**: the Inspector drops malformed events and caps event + count, node count, and laid-out time span — surfacing any truncation in the UI. +- **Deployment**: security headers on the hosted Inspector, no published source + maps, and least-privilege CI token permissions. + --- ## Workspace Status @@ -49,4 +71,4 @@ All components of the monorepo are fully operational and verified: - **Build**: Successfully compiles ESM and TypeScript definitions (`dts`) across all packages. - **Typecheck**: Zero TypeScript compile errors (`tsc --noEmit` is clean). - **Lint**: Zero ESLint warnings or errors. -- **Tests**: All 191 tests are passing. +- **Tests**: All 256 tests are passing. diff --git a/README.md b/README.md index fdbcb84e..4d0695f4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ - [CLI Reference](#cli-reference) - [Examples & Reference Implementations](#examples--reference-implementations) - [Comparison: Chronos vs Traditional Testing vs Madsim / Turmoil](#comparison-chronos-vs-traditional-testing-vs-madsim--turmoil) +- [Security](#security) - [Contributing](#contributing) - [License](#license) @@ -266,7 +267,7 @@ npx chronos [options] * **`chronos sweep [seeds]`**: Run a test scenario across $N$ seeds to discover hidden race conditions. * **`chronos shrink `**: Shrink a complex failure trace into the minimal reproducing steps. * **`chronos open `**: Launch the web-based time-travel inspector preloaded with the capsule data. -* **`chronos explain `**: Get AI-powered failure analysis and root-cause explanations (supports Ollama, OpenAI, Anthropic, Gemini, Groq, DeepSeek, and more). +* **`chronos explain `**: Get AI-powered failure analysis and root-cause explanations. Set `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY` — or point `LLM_BASE_URL` at any OpenAI-compatible endpoint (Ollama, LM Studio, vLLM, OpenRouter, Groq, …). * **`chronos stats `**: Print execution statistics, network packet metrics, and event distributions. * **`chronos export --format markdown|csv`**: Export trace timelines to Markdown documents or CSV reports. @@ -295,6 +296,16 @@ Explore ready-to-run examples in the repository: --- +## Security + +**A failure capsule is untrusted input.** Capsules exist to be shared — attached to an issue, produced by someone else's CI, handed to a teammate — so Chronos treats one exactly like a file from a stranger. Every capsule is fully validated at the read boundary, capsule paths are confined to your project, and capsule text is escaped separately for each place it is rendered (terminal, CSV, Markdown, and the Inspector's DOM). + +One consequence worth stating outright: `env.random()` under `SimEnv` is a **seeded** PRNG. It is deterministic by design and therefore completely predictable — never use it as a source of secrets in a simulated run. The production `RealEnv` backs the same call with a CSPRNG, so the same application code is safe in production. + +Read **[SECURITY.md](./SECURITY.md)** for the full threat model and how to report a vulnerability. + +--- + ## Contributing We welcome contributions from the open-source community! diff --git a/REMAINING.md b/REMAINING.md index 71717a90..ce862efa 100644 --- a/REMAINING.md +++ b/REMAINING.md @@ -3,10 +3,39 @@ This document tracks any outstanding bugs, tasks, or issues remaining in the **Chronos** deterministic simulation testing framework. ## Outstanding Issues -- **None**: There are no remaining bugs, failing tests, compilation errors, lint issues, or outstanding `TODO`/`FIXME` comments in the codebase. + +- **None.** No failing tests, compilation errors, lint findings, or outstanding `TODO`/`FIXME` comments. ## Code Quality Status -- **Unit and Simulation Tests**: 191/191 passing. + +- **Unit and Simulation Tests**: 256/256 passing. - **TypeScript Typecheck**: Clean (0 compilation errors). - **ESLint**: Clean (0 warnings or errors). -- **Build**: Successfully compiles all workspace packages (esm, commonjs/types). +- **Build**: All workspace packages compile (ESM + type declarations). + +## Security Posture + +A full audit of every package was completed against the categories in the +[vibe-security](https://github.com/raroque/vibe-security-skill) checklist, and +every finding is fixed with a regression test. See [`SECURITY.md`](./SECURITY.md) +for the threat model — the central assumption is that **a failure capsule is +untrusted input**, because it is designed to be shared. + +Closed in that pass: + +| Area | Issue | +| --- | --- | +| Core | The strict-mode `setTimeout` guard compiled string handlers via `new Function` — an eval sink Node itself does not have | +| CLI | Untrusted capsule text reached the terminal unescaped (ANSI/OSC injection could repaint the trace viewer's own output) | +| CLI | `chronos export --csv` allowed spreadsheet formula injection; Markdown export allowed table breakout | +| CLI | The Gemini API key was sent in a URL query string; the interactive key prompt echoed in the clear | +| CLI | `chronos check` followed symlinks and recursed forever on a cycle; `sweep` imported scenarios from any path | +| Capsules | No file-size limit before read; no `__proto__` rejection; predictable temp filename on write; trace events unvalidated | +| Inspector | Malformed events crashed the render; event count, node count, and time span were unbounded | +| Deployment | No security headers on the hosted Inspector; source maps published; CI ran with default (write) token permissions | + +## Known Non-Goals + +- `@sx4im/chronos-core` keeps **zero runtime dependencies**. Do not add any. +- The BigInt PRNG is the known hot path. It stays pure TypeScript until the + planned Rust/WASM phase, which will keep pure TS as the default. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8ae2dd11 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security Policy + +## Reporting a vulnerability + +Please report security issues privately via [GitHub Security Advisories](https://github.com/sx4im/chronos/security/advisories/new) rather than a public issue. + +## The threat model + +Chronos is a developer tool. It runs on developer laptops and CI runners, and it has no server component and no user accounts. That shapes what "a vulnerability" means here. + +### A failure capsule is untrusted input + +This is the load-bearing assumption. A capsule (`.chronos/failures/.json`) exists to be **shared** — attached to a GitHub issue, produced by someone else's CI, handed to a teammate — and then opened with `chronos trace`, `chronos stats`, `chronos export`, `chronos replay`, or the Inspector. + +So a capsule is treated exactly like a file downloaded from a stranger: + +- **Everything is validated at the read boundary.** `readCapsule` caps the file size before reading (128 MB; `CHRONOS_MAX_CAPSULE_BYTES` raises it), rejects a `__proto__` key at the parse boundary, and validates the whole structure — including every trace event against the `TraceEvent` union — before any field reaches the `Simulator` or a renderer. A malformed capsule produces a clear `InvalidCapsule` error, never a crash and never a partially-applied config. +- **Errors never echo capsule bytes.** Node's `JSON.parse` `SyntaxError` embeds the offending file contents, which on a misdirected `chronos replay /etc/passwd` would disclose that file. All capsule read errors are content-free. +- **Capsule paths are confined.** By default, capsules and scenarios must live under the working directory, `CHRONOS_DIR`, or `CHRONOS_CAPSULE_DIR`. Set `CHRONOS_ALLOW_OUTSIDE_CAPSULES=1` to opt out. Importing a scenario *executes* it, so `replay`, `shrink`, and `sweep` all apply this. +- **Capsule text is escaped per output sink.** Capsule strings are attacker-chosen, and each renderer feeds a sink with its own injection grammar: terminal escape sequences are stripped before printing (a trace viewer that can be made to repaint its own output can lie about what it found), CSV cells are prefixed against spreadsheet formula injection, and Markdown cells are escaped against table breakout. +- **The Inspector bounds what it draws.** Events that don't match the `TraceEvent` union are dropped, and event count, node count, and the laid-out time span are all capped — with any truncation shown in the UI, so a partial trace is never presented as a complete one. + +### `chronos open` serves only to localhost + +The Inspector server binds `127.0.0.1`, requires a loopback `Host` header (blocking DNS rebinding), confines file serving to the built `dist/`, and sends a restrictive CSP plus `X-Frame-Options: DENY`. The `?capsule=` parameter accepts only same-origin absolute paths, so a crafted link cannot make your browser fetch a cross-origin URL on your behalf. + +### `chronos explain` is optional and isolated + +It is the only feature that makes a network call, it is off unless you set a key, and it is absent from `@sx4im/chronos-core` entirely. Your API key is sent as a header (never in a URL, where proxies and access logs would retain it), only over `https` unless the endpoint is loopback. The capsule summary is redacted for JWTs and `token=`/`key=`/`secret=`/`password=` values before it is sent, and the model's reply is sanitized before it is printed — a capsule shapes that prompt, so the reply is the exit of a prompt-injection path. + +### Determinism is a security property + +Chronos removes every source of entropy from a simulated run. `@sx4im/chronos-core` has **zero runtime dependencies**, which keeps the supply chain for the part that runs inside your test process as small as it can be. + +Note the asymmetry between the two environments: + +- **`SimEnv` (tests)** draws from the seeded xoshiro256\*\* PRNG. It is deterministic by design and therefore **completely predictable** — never use `env.random()` from a simulated run as a source of secrets. +- **`RealEnv` (production)** backs `env.random()` with a CSPRNG. The same application code may reasonably use `env.random()` for a request id or a nonce in production, so the production adapter must be safe for that; nothing in production is replayed, so this costs nothing. + +### Out of scope + +- A malicious *scenario module*. `chronos sweep`/`replay`/`shrink` import and run your scenario, which is your own code — the path confinement is a guard against a mistyped path, not a sandbox. +- The contents of your own source tree, which `chronos check` reads. +- Denial of service from a sweep you asked for. Seed counts are capped at 1,000,000 to keep a typo from exhausting memory, but a large sweep is meant to be expensive. diff --git a/docs-site/guide/cli.md b/docs-site/guide/cli.md index 687bbc7e..ffc70477 100644 --- a/docs-site/guide/cli.md +++ b/docs-site/guide/cli.md @@ -8,7 +8,7 @@ chronos trace print the recorded event timeline chronos sweep [seeds] run a scenario across N seeds (default 1000) chronos shrink reduce a failing capsule's fault config to minimum chronos open open the time-travel Inspector preloaded with the capsule -chronos explain summarize the failure via NVIDIA NIM (requires NVIDIA_API_KEY) +chronos explain summarize the failure via an LLM (OpenAI/Anthropic/Gemini/custom) chronos stats display simulation event and network statistics chronos check [paths...] scan source directories for unseeded global calls chronos export [flags] export trace timelines to Markdown tables or CSV files @@ -74,7 +74,9 @@ Starts the local Inspector UI web server preloaded with the capsule, displaying ## `chronos explain` -When `NVIDIA_API_KEY` is set in the environment, `explain` sends a structured failure summary to NVIDIA NIM and prints an explanation. If the key is missing, it logs a notice and exits cleanly. +Sends a structured failure summary to an LLM and prints the explanation. Set one of `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY`, or point `LLM_BASE_URL` (+ `LLM_API_KEY`) at any OpenAI-compatible endpoint — Ollama, LM Studio, vLLM, OpenRouter, Groq. If no key is set, it logs a notice and exits cleanly. + +The summary is redacted before it leaves your machine (JWTs and `token=`/`key=`/`secret=`/`password=` values are replaced), and the key is sent as a header, never in a URL. An API key is only sent over plaintext `http` to a loopback host; anywhere else `explain` requires `https`. ## `chronos stats` @@ -117,4 +119,7 @@ Verifies Node.js version requirements (>= 20), strict mode configuration status, | `CHRONOS_SEED` | Sets a single seed for replay and sweep commands | | `CHRONOS_DIR` | Sets output directory for `sweep` capsules (default `.chronos`) | | `CHRONOS_STRICT` | Configures strict guard level (`route`, `throw`, or `off`) | -| `NVIDIA_API_KEY` | Enables `chronos explain` | +| `CHRONOS_MAX_CAPSULE_BYTES` | Raises the 128 MB limit on a capsule file read | +| `CHRONOS_ALLOW_OUTSIDE_CAPSULES` | Allows reading capsules from outside cwd/`CHRONOS_DIR` | +| `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` | Enables `chronos explain` | +| `LLM_BASE_URL` + `LLM_API_KEY` | Points `chronos explain` at any OpenAI-compatible endpoint | diff --git a/docs-site/guide/replay.md b/docs-site/guide/replay.md index b82c2377..740d134b 100644 --- a/docs-site/guide/replay.md +++ b/docs-site/guide/replay.md @@ -8,7 +8,7 @@ A capsule file is JSON containing the seed, network configuration, invariant fai ```jsonc { - "chronosVersion": "0.0.0", + "chronosVersion": "0.1.5", "seed": "42", "nodes": ["0", "1", "2"], "config": { diff --git a/docs-site/index.md b/docs-site/index.md index cef431cf..a7b93642 100644 --- a/docs-site/index.md +++ b/docs-site/index.md @@ -92,4 +92,4 @@ See the repository README for setup steps and the determinism checklist. ## Status -This site documents Chronos v0.0.0. Start with the [15-minute quickstart](/guide/quickstart), read [the determinism model](/concepts/determinism) for execution rules, or check the [API reference](/api/) for function signatures. +This site documents Chronos v0.1.5. Start with the [15-minute quickstart](/guide/quickstart), read [the determinism model](/concepts/determinism) for execution rules, or check the [API reference](/api/) for function signatures. diff --git a/docs-site/vercel.json b/docs-site/vercel.json index 14dc884a..1533f56e 100644 --- a/docs-site/vercel.json +++ b/docs-site/vercel.json @@ -2,5 +2,27 @@ "framework": "vitepress", "installCommand": "npm install", "buildCommand": "npx vitepress build", - "outputDirectory": ".vitepress/dist" + "outputDirectory": ".vitepress/dist", + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src 'self'; img-src 'self' data:; font-src 'self' data: https://fonts.gstatic.com; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'" + }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains; preload" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()" + } + ] + } + ] } diff --git a/packages/cli/src/check.ts b/packages/cli/src/check.ts index 97ee4bad..3bbc8a2d 100644 --- a/packages/cli/src/check.ts +++ b/packages/cli/src/check.ts @@ -1,7 +1,8 @@ // chronos check — static analysis tool for DST (determinism) compliance. -import { readdir, readFile, stat } from "node:fs/promises"; +import { readdir, readFile, lstat, stat } from "node:fs/promises"; import { join, resolve, relative } from "node:path"; +import { CHRONOS_VERSION } from "@sx4im/chronos-core"; import { C, drawBox, renderTopBanner } from "./ui.js"; export interface CheckResult { @@ -100,16 +101,29 @@ const RULES: Rule[] = [ }, ]; -async function getFiles(dir: string): Promise { +/** Recursion depth cap. A source tree is never this deep; a pathological one is + * either a mistake or an attempt to blow the stack. */ +const MAX_DEPTH = 32; + +/** Walk a directory for scannable source files. + * + * Uses `lstat`, not `stat`, and skips symlinks. `stat` follows links, so a + * single `ln -s .. loop` inside a scanned tree made the walk recurse forever — + * `chronos check` (and therefore `chronos doctor`, which calls it) would hang + * or die of stack exhaustion on a perfectly ordinary repo containing a symlink + * cycle. Skipping links also keeps the scan inside the tree the user named + * rather than following a link out to somewhere they did not ask about. */ +async function getFiles(dir: string, depth = 0): Promise { + if (depth > MAX_DEPTH) return []; const results: string[] = []; const list = await readdir(dir).catch(() => [] as string[]); for (const file of list) { if (IGNORE_DIRS.has(file)) continue; const path = join(dir, file); - const s = await stat(path).catch(() => null); - if (!s) continue; + const s = await lstat(path).catch(() => null); + if (!s || s.isSymbolicLink()) continue; if (s.isDirectory()) { - results.push(...(await getFiles(path))); + results.push(...(await getFiles(path, depth + 1))); } else if (s.isFile()) { if ( /\.(ts|tsx|js|jsx|mjs)$/.test(file) && @@ -243,11 +257,11 @@ export async function checkCommand(paths: string[] = []): Promise { lines.pop(); // remove trailing empty line } - const title = `${C.indigo("CHRONOS STATIC DST CHECKER")} ${C.muted(`v0.1.4`)}`; + const title = `${C.indigo("CHRONOS STATIC DST CHECKER")} ${C.muted(`v${CHRONOS_VERSION}`)}`; return { exitCode: cleanMessage ? 0 : 1, - message: renderTopBanner("0.1.4") + drawBox(title, lines), + message: renderTopBanner() + drawBox(title, lines), }; } diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index 04149d7d..d0a106e0 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -3,9 +3,20 @@ import { stat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { CHRONOS_VERSION } from "@sx4im/chronos-core"; import { C, drawBox, renderTopBanner } from "./ui.js"; import { checkCommand } from "./check.js"; +/** Env vars `explain.ts` reads, in the order it probes them. */ +const EXPLAIN_ENV_KEYS = [ + "CHRONOS_EXPLAIN_BASE_URL", + "LLM_BASE_URL", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", +] as const; + export interface DoctorResult { exitCode: number; message: string; @@ -24,7 +35,9 @@ export async function doctorCommand(): Promise { const nodeMajor = parseInt(nodeVersion.slice(1).split(".")[0] ?? "0", 10); const nodeOk = nodeMajor >= 20; - const keySet = !!process.env.NVIDIA_API_KEY; + // Mirror the providers `explain.ts` actually implements — reporting a key the + // command never reads is worse than reporting none. + const explainProvider = EXPLAIN_ENV_KEYS.find((k) => !!process.env[k]); const here = dirname(fileURLToPath(import.meta.url)); const inspectorDist = resolve(here, "..", "..", "inspector", "dist"); @@ -40,17 +53,17 @@ export async function doctorCommand(): Promise { ` ${C.cyan("•")} Strict Mode: ${C.emerald("✔ Active")} ${C.muted("(microtask queue draining & entropy guards active)")}`, ` ${C.cyan("•")} DST Compliance: ${dstOk ? C.emerald("✔ Clean") : C.amber("✖ Warnings (run 'chronos check' to inspect)")}`, ` ${C.cyan("•")} Inspector UI: ${inspectorOk ? `${C.emerald("✔ Ready")} ${C.muted(`(${inspectorDist})`)}` : C.rose("✖ Not built (run pnpm --filter @sx4im/chronos-inspector build)")}`, - ` ${C.cyan("•")} NVIDIA NIM Key: ${keySet ? C.emerald("✔ Configured (chronos explain enabled)") : C.slate("○ Unset (chronos explain optional)")}`, + ` ${C.cyan("•")} AI Explain Key: ${explainProvider ? C.emerald(`✔ Configured via ${explainProvider}`) : C.slate("○ Unset (chronos explain optional)")}`, "", nodeOk && inspectorOk && dstOk ? `${C.badgeEmerald(" HEALTHY ")} System environment is fully operational.` : `${C.badgeRose(" ATTENTION ")} System environment requires attention (see items marked ✖ or ✖ above).`, ]; - const title = `${C.indigo("CHRONOS SYSTEM DOCTOR")} ${C.muted(`v0.1.5`)}`; + const title = `${C.indigo("CHRONOS SYSTEM DOCTOR")} ${C.muted(`v${CHRONOS_VERSION}`)}`; return { exitCode: nodeOk && dstOk ? 0 : 1, - message: renderTopBanner("0.1.5") + drawBox(title, lines), + message: renderTopBanner() + drawBox(title, lines), }; } diff --git a/packages/cli/src/explain.ts b/packages/cli/src/explain.ts index 85f3182b..f3015566 100644 --- a/packages/cli/src/explain.ts +++ b/packages/cli/src/explain.ts @@ -3,8 +3,10 @@ import { readCapsule, type FailureCapsule } from "@sx4im/chronos-vitest/engine"; import type { TraceEvent } from "@sx4im/chronos-core"; +import { CHRONOS_VERSION } from "@sx4im/chronos-core"; import { resolveCapsulePath, capsuleReadError } from "./util.js"; -import { C, drawBox, selectPrompt, inputPrompt, renderTopBanner } from "./ui.js"; +import { safeText } from "./sanitize.js"; +import { C, drawBox, selectPrompt, inputPrompt, secretPrompt, renderTopBanner } from "./ui.js"; export interface ExplainResult { exitCode: number; @@ -56,11 +58,41 @@ export const PROVIDERS: ProviderDefinition[] = [ ]; function sanitizeSummary(summary: string): string { - return summary + // Control characters first: this text is bound for a third-party API AND for + // the terminal, and the credential redaction below should not be dodgeable by + // splicing an escape sequence into the middle of a token. + return safeText(summary) .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[REDACTED_JWT]") .replace(/(bearer\s+|token[=:]\s*|key[=:]\s*|secret[=:]\s*|password[=:]\s*)(?!\[REDACTED)[^\s,;"]+/gi, "$1[REDACTED]"); } +/** An LLM endpoint must be plain `http:`/`https:`. + * + * The base URL comes from `CHRONOS_EXPLAIN_BASE_URL`/`LLM_BASE_URL` or an + * interactive prompt, and the API key is attached to whatever it names — so a + * malformed or hostile value is a credential-exfiltration path, and a scheme + * like `file:` is a request the user never intended. Validate before the key + * is ever attached. */ +function assertUsableEndpoint(baseUrl: string): URL { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new Error("LLM base URL is not a valid absolute URL (expected http:// or https://)"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`LLM base URL scheme ${url.protocol} is not allowed (use http: or https:)`); + } + return url; +} + +/** Loopback hosts are the legitimate plaintext case (Ollama, LM Studio, a local + * vLLM). Anywhere else, `http:` puts the API key on the wire in the clear. */ +function isLoopback(url: URL): boolean { + const h = url.hostname.toLowerCase(); + return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]"; +} + function formatEvent(e: TraceEvent): string { const where = `t=${e.t} seq=${e.seq}`; switch (e.kind) { @@ -137,7 +169,8 @@ async function runInteractivePrompt(): Promise<{ provider: ProviderDefinition; k if (envKeyVal) { process.stdout.write(`${C.emerald("✔")} ${C.bold("API Key")} ${C.cyan(`Loaded from ${selectedProvider.envKey}`)}\n\n`); } else { - apiKey = await inputPrompt(`Enter ${selectedProvider.name} API Key`); + // Never `inputPrompt` for a credential — that echoes it into the scrollback. + apiKey = await secretPrompt(`Enter ${selectedProvider.name} API Key`); } const selectedModel = await inputPrompt("Enter Model ID", selectedProvider.defaultModel); @@ -146,7 +179,7 @@ async function runInteractivePrompt(): Promise<{ provider: ProviderDefinition; k } export async function explainCommand(capsulePath: string): Promise { - const topHeader = renderTopBanner("0.1.5"); + const topHeader = renderTopBanner(CHRONOS_VERSION); let capsule: FailureCapsule; try { capsule = await readCapsule(resolveCapsulePath(capsulePath)); @@ -168,7 +201,7 @@ export async function explainCommand(capsulePath: string): Promise ` ${line}`), + // The model's reply is doubly untrusted: it is generated text, and the + // capsule that shaped the prompt is attacker-controlled, so this is the + // exit of a prompt-injection path straight onto the user's terminal. + ...explanationText.split("\n").map((line) => ` ${safeText(line)}`), ]; return { exitCode: 0, message: "\n" + drawBox(`${C.indigo("EXPLANATION")}`, reportLines) + "\n" }; } catch (e) { @@ -193,21 +229,45 @@ export async function explainCommand(capsulePath: string): Promise { + const url = assertUsableEndpoint(baseUrl); + if (url.protocol === "http:" && !isLoopback(url) && key) { + throw new Error( + `refusing to send an API key over plaintext http to ${url.hostname} — use https (loopback is exempt)`, + ); + } const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 30_000); try { if (provider.type === "gemini") { - const endpoint = baseUrl.endsWith("/generateContent") ? `${baseUrl}?key=${key}` : `${baseUrl}/models/${model}:generateContent?key=${key}`; + // The key goes in a HEADER, never `?key=`. Query strings are logged by + // proxies, CDNs, and the server's own access log, and they leak through + // `Referer` — putting a long-lived credential there means it outlives the + // request in places nobody audits. + const endpoint = baseUrl.endsWith("/generateContent") + ? baseUrl + : `${baseUrl.replace(/\/$/, "")}/models/${encodeURIComponent(model)}:generateContent`; const res = await fetch(endpoint, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", "x-goog-api-key": key }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }), signal: ctrl.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json() as any; - return data.candidates?.[0]?.content?.parts?.[0]?.text || ""; + const data = (await res.json()) as GeminiResponse; + return data.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; } if (provider.type === "anthropic") { const endpoint = baseUrl.endsWith("/messages") ? baseUrl : `${baseUrl.replace(/\/$/, "")}/messages`; @@ -218,8 +278,8 @@ async function executeLLMCall(provider: ProviderDefinition, key: string, model: signal: ctrl.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json() as any; - return data.content?.[0]?.text || ""; + const data = (await res.json()) as AnthropicResponse; + return data.content?.[0]?.text ?? ""; } const endpoint = baseUrl.endsWith("/chat/completions") ? baseUrl : `${baseUrl.replace(/\/$/, "")}/chat/completions`; const res = await fetch(endpoint, { @@ -229,8 +289,8 @@ async function executeLLMCall(provider: ProviderDefinition, key: string, model: signal: ctrl.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json() as any; - return data.choices?.[0]?.message?.content || ""; + const data = (await res.json()) as OpenAIResponse; + return data.choices?.[0]?.message?.content ?? ""; } finally { clearTimeout(to); } diff --git a/packages/cli/src/export.ts b/packages/cli/src/export.ts index 9b5d3f92..036e8e8a 100644 --- a/packages/cli/src/export.ts +++ b/packages/cli/src/export.ts @@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises"; import { readCapsule, type FailureCapsule } from "@sx4im/chronos-vitest/engine"; import { resolveCapsulePath, capsuleReadError } from "./util.js"; +import { escapeCsvCell, escapeMarkdownCell } from "./sanitize.js"; import { C } from "./ui.js"; export interface ExportResult { @@ -15,13 +16,6 @@ export interface ExportOptions { output?: string | undefined; } -function escapeCSV(val: string): string { - if (val.includes(",") || val.includes('"') || val.includes("\n") || val.includes("\r")) { - return `"${val.replace(/"/g, '""')}"`; - } - return val; -} - export async function exportCommand( capsulePath: string, opts: ExportOptions = {}, @@ -57,44 +51,47 @@ export async function exportCommand( (ev as { summary?: string }).summary ?? "", (ev as { detail?: string }).detail ?? "", ]; - content += row.map(escapeCSV).join(",") + "\n"; + content += row.map(escapeCsvCell).join(",") + "\n"; } } else { // Markdown/md ext = ".md"; content += `# Chronos Simulation Trace Export\n\n`; - content += `- **Seed**: ${capsule.seed}\n`; - content += `- **Nodes**: ${capsule.nodes.join(", ")}\n`; - content += `- **Invariant**: ${capsule.invariant.name} (${capsule.invariant.detail || "no detail"})\n`; - content += `- **Outcome**: ${capsule.trace.result}\n\n`; + content += `- **Seed**: ${escapeMarkdownCell(capsule.seed)}\n`; + content += `- **Nodes**: ${capsule.nodes.map(escapeMarkdownCell).join(", ")}\n`; + content += `- **Invariant**: ${escapeMarkdownCell(capsule.invariant.name)} (${escapeMarkdownCell(capsule.invariant.detail) || "no detail"})\n`; + content += `- **Outcome**: ${escapeMarkdownCell(capsule.trace.result)}\n\n`; content += `## Event Timeline\n\n`; content += `| Virtual Time (t) | Log Index (seq) | Event Kind | Node / Lane | Description |\n`; content += `|---|---|---|---|---|\n`; for (const ev of events) { - const nodeId = (ev as { nodeId?: string }).nodeId ?? ""; + const nodeId = escapeMarkdownCell((ev as { nodeId?: string }).nodeId ?? ""); let desc = ""; if (ev.kind === "send" || ev.kind === "deliver") { - const from = (ev as { from?: string }).from ?? ""; - const to = (ev as { to?: string }).to ?? ""; - const sum = (ev as { summary?: string }).summary ?? ""; + const from = escapeMarkdownCell((ev as { from?: string }).from ?? ""); + const to = escapeMarkdownCell((ev as { to?: string }).to ?? ""); + const sum = escapeMarkdownCell((ev as { summary?: string }).summary ?? ""); const dir = ev.kind === "send" ? "→" : "📥"; - desc = `\`${from}\` ${dir} \`${to}\` : \`${sum}\``; + desc = `${from} ${dir} ${to} : ${sum}`; } else if (ev.kind === "partition") { const groups = (ev as { groups?: string[][] }).groups ?? []; const healAt = (ev as { healAt?: number }).healAt ?? 0; - desc = `Partition [${groups.map((g) => g.join(",")).join("] | [")}] healAt=${healAt}`; + const rendered = groups.map((g) => g.map(escapeMarkdownCell).join(",")).join("] | ["); + desc = `Partition [${rendered}] healAt=${healAt}`; } else if (ev.kind === "invariant-violation") { - const detail = (ev as { detail?: string }).detail ?? ""; - desc = `**VIOLATION** - ${detail}`; + desc = `**VIOLATION** - ${escapeMarkdownCell((ev as { detail?: string }).detail ?? "")}`; } else { - desc = (ev as { summary?: string }).summary || (ev as { detail?: string }).detail || JSON.stringify(ev); + const fallback = + (ev as { summary?: string }).summary || + (ev as { detail?: string }).detail || + JSON.stringify(ev); + desc = escapeMarkdownCell(fallback); } - const nodeCol = nodeId ? `\`${nodeId}\`` : ""; - content += `| ${ev.t} | ${ev.seq} | \`${ev.kind}\` | ${nodeCol} | ${desc} |\n`; + content += `| ${ev.t} | ${ev.seq} | ${escapeMarkdownCell(ev.kind)} | ${nodeId} | ${desc} |\n`; } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c8784448..2a30cee1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ // the pure command functions (replay/trace/sweep/open/explain/doctor). import { pathToFileURL } from "node:url"; +import { CHRONOS_VERSION } from "@sx4im/chronos-core"; import { traceCommand } from "./trace.js"; import { replayCommand } from "./replay.js"; import { sweepCommand } from "./sweep.js"; @@ -16,7 +17,12 @@ import { exportCommand } from "./export.js"; import { C, drawBox } from "./ui.js"; import { ASCII_BANNER } from "./assets/logo.js"; -const VERSION = "0.1.5"; +const VERSION = CHRONOS_VERSION; + +/** Upper bound on `chronos sweep `. A million seeds is already + * far past any interactive use; beyond that the seed array alone exhausts + * memory before a single simulation runs. */ +const MAX_SWEEP_SEEDS = 1_000_000; function buildHelpText(): string { const header = `${ASCII_BANNER}\n` + @@ -31,7 +37,7 @@ function buildHelpText(): string { ` ${C.cyan("sweep")} ${C.purple("")} ${C.slate("[seeds]")} Run scenario across N seeds (capsules first violator)`, ` ${C.cyan("shrink")} ${C.purple("")} ${C.purple("")} Reduce failing fault config to minimal reproduction`, ` ${C.cyan("open")} ${C.purple("")} Open Time-Travel Inspector UI preloaded with capsule`, - ` ${C.cyan("explain")} ${C.purple("")} Summarize failure via AI (OpenRouter, Groq, OpenAI, etc.)`, + ` ${C.cyan("explain")} ${C.purple("")} Summarize failure via AI (OpenAI, Anthropic, Gemini, custom)`, ` ${C.cyan("stats")} ${C.purple("")} Display detailed trace metrics from a capsule`, ` ${C.cyan("check")} ${C.slate("[paths...]")} Statically scan source files for DST determinism leaks`, ` ${C.cyan("export")} ${C.purple("")} ${C.slate("[flags]")} Export trace to Markdown/CSV formats`, @@ -51,13 +57,15 @@ function buildHelpText(): string { ` ${C.amber("--help")}, ${C.amber("-h")} Show help documentation`, "", `${C.bold("AI Explanation Providers (chronos explain)")}:`, - ` Set any key: ${C.purple("OPENROUTER_API_KEY")} | ${C.purple("GROQ_API_KEY")} | ${C.purple("OPENAI_API_KEY")} | ${C.purple("ANTHROPIC_API_KEY")}`, - ` ${C.purple("GEMINI_API_KEY")} | ${C.purple("DEEPSEEK_API_KEY")} | ${C.purple("MISTRAL_API_KEY")} | ${C.purple("NVIDIA_API_KEY")}`, - ` Or Custom: ${C.purple("LLM_BASE_URL")} + ${C.purple("LLM_API_KEY")} | ${C.purple("OLLAMA_MODEL")} (Local)`, + ` Set any key: ${C.purple("OPENAI_API_KEY")} | ${C.purple("ANTHROPIC_API_KEY")} | ${C.purple("GEMINI_API_KEY")}`, + ` Or Custom: ${C.purple("LLM_BASE_URL")} + ${C.purple("LLM_API_KEY")} — any OpenAI-compatible endpoint`, + ` (Ollama, LM Studio, vLLM, OpenRouter, Groq, …). https required off-loopback.`, "", `${C.bold("Environment Variables")}:`, ` ${C.purple("CHRONOS_SEED")} Force a single seed for simulation runs`, ` ${C.purple("CHRONOS_DIR")} Override default output directory (default: .chronos)`, + ` ${C.purple("CHRONOS_STRICT")} Entropy guard level: route (default) | throw | off`, + ` ${C.purple("CHRONOS_MAX_CAPSULE_BYTES")} Raise the 128 MB capsule read limit`, ]; return header + drawBox(`${C.indigo("COMMAND REFERENCE")}`, helpLines) + "\n"; @@ -113,6 +121,11 @@ async function main(): Promise { if (seedsArg !== undefined && !seedsArg.startsWith("-")) { const n = Number(seedsArg); if (!Number.isInteger(n) || n <= 0) fail("sweep seeds must be a positive integer"); + // `resolveSeeds` materializes the seed list before the first run, so an + // unbounded count is an out-of-memory abort rather than a long sweep. + if (n > MAX_SWEEP_SEEDS) { + fail(`sweep seeds must be <= ${MAX_SWEEP_SEEDS} (got ${n})`); + } seeds = n; } const r = await sweepCommand(scenario, seeds); diff --git a/packages/cli/src/sanitize.ts b/packages/cli/src/sanitize.ts new file mode 100644 index 00000000..28603f18 --- /dev/null +++ b/packages/cli/src/sanitize.ts @@ -0,0 +1,98 @@ +// Rendering helpers for UNTRUSTED capsule text. +// +// Every `chronos` command that shows a capsule (trace, stats, export, explain) +// interpolates strings an attacker chose — `summary`, `invariant.detail`, node +// ids — into a terminal line, a Markdown table, or a CSV cell. Each of those +// three sinks has its own injection grammar, and none of them is covered by the +// capsule validator: `validateCapsule` bounds a summary's LENGTH, but a 40-char +// summary is more than enough to repaint a terminal or smuggle a formula into a +// spreadsheet. Sanitizing belongs at the point of rendering, per sink. + +/** Strip terminal control sequences from untrusted text. + * + * A capsule's `summary` is written straight to stdout by `chronos trace`. Left + * raw, an attacker-authored capsule can embed ANSI/OSC escapes and: + * - erase and repaint earlier output (`\x1b[2J`, `\x1b[A`, `\r`) so a run + * that reported a violation appears to report "0 violations" — the trace + * viewer's entire job is telling a human what happened, so a capsule that + * can lie about that is worse than one that crashes; + * - rewrite the window/tab title (OSC 0), or drive terminals whose escape + * handling has its own history of memory-safety bugs. + * + * So: drop ESC-introduced sequences, then drop every remaining C0/C1 control + * character. Tab survives (harmless, and real payloads contain it); newline + * does NOT, because these renderers emit one line per event and a summary + * containing `\n` would forge additional event lines. */ +// +// The byte ranges below are ECMA-48's, and each pass's quantified classes are +// mutually disjoint — no input can be split between two of them — so every +// pass is linear and none of this is a ReDoS vector on attacker-chosen text. +export function sanitizeText(s: string): string { + return ( + s + // String-terminated sequences: OSC (ESC ]), DCS (ESC P), SOS (ESC X), + // PM (ESC ^), APC (ESC _). Their payload is arbitrary text ended by ST + // (ESC \) — or BEL, for OSC — so the payload must be consumed WITH its + // introducer rather than left behind looking like ordinary text. + // Matched first, because a payload may legitimately contain `[`. + .replace(/\x1b[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "") + // CSI: ESC [ · parameter bytes 0x30–0x3F · intermediates 0x20–0x2F · + // final byte 0x40–0x7E. Covers colors, cursor movement, and erasure. + .replace(/\x1b\[[0-?]*[ -/]*[@-~]?/g, "") + // Any other ESC-introduced sequence (charset selection, two-character + // escapes) and a lone trailing ESC. + .replace(/\x1b[ -/]*[0-~]?/g, "") + // Remaining C0 (except tab), DEL, and C1 controls — including the + // single-byte C1 forms such as 0x9B, which is CSI in its own right. + .replace(/[\x00-\x08\x0a-\x1f\x7f-\x9f]/g, "") + // Zero-width and bidirectional-override characters. These are not + // controls in the C0/C1 sense, so the pass above leaves them, but they + // attack display integrity the same way an ANSI escape does — which for + // a tool whose whole job is telling a human what happened is the injury, + // not a side effect. The bidi overrides are Trojan Source + // (CVE-2021-42574): U+202E in a summary makes `node-1 → node-2` render + // reversed. The zero-width characters are invisible padding, useful for + // hiding in front of a value to smuggle it past a leading-character + // check (see `escapeCsvCell`). Neither belongs in a trace summary. + .replace(/[\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/g, "") + ); +} + +/** Sanitize an untrusted value for terminal output, with a length cap. + * `validateCapsule` already bounds these fields, but a renderer should never + * depend on a bound enforced somewhere else. */ +export function safeText(s: unknown, maxLen = 512): string { + const raw = typeof s === "string" ? s : String(s); + const clean = sanitizeText(raw); + return clean.length > maxLen ? `${clean.slice(0, maxLen)}…` : clean; +} + +// Excel, LibreOffice, and Google Sheets treat a cell whose first character is +// one of these as a FORMULA, not text. `=cmd|'/c calc'!A1` in a capsule summary +// becomes code execution on the machine of whoever opens the exported CSV — the +// export is meant to be shared, which is exactly what makes this reachable. +// +// The leading `[\s\ufeff]*` matters: spreadsheets skip leading whitespace when +// deciding whether a cell is a formula, so an anchored test against the very +// first character alone is trivially sidestepped with a single space. (Zero- +// width padding is the same trick; `sanitizeText` has already removed it by the +// time this runs.) +const CSV_FORMULA_LEAD = /^[\s\ufeff]*[=+\-@\t\r]/; + +/** Render one CSV cell: neutralize formula leads, then quote per RFC 4180. + * The leading apostrophe is the standard mitigation — spreadsheets consume it + * as a "treat as text" marker rather than displaying it. */ +export function escapeCsvCell(val: string): string { + const clean = sanitizeText(val); + const safe = CSV_FORMULA_LEAD.test(clean) ? `'${clean}` : clean; + if (/[",\n\r]/.test(safe)) return `"${safe.replace(/"/g, '""')}"`; + return safe; +} + +/** Render untrusted text inside a Markdown table cell. Pipes would forge extra + * columns and backticks would break out of the inline-code spans the exporter + * wraps values in, so both are escaped; `sanitizeText` has already removed the + * newlines that would forge extra rows. */ +export function escapeMarkdownCell(val: string): string { + return sanitizeText(val).replace(/([\\`|*_[\]<>])/g, "\\$1"); +} diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 75cc1338..bcd74fc9 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -17,13 +17,27 @@ const MIME: Record = { }; // Security headers applied to EVERY response from the inspector server. +// +// `style-src`/`font-src` must ALLOW the Google Fonts origins that index.html +// references. A response carrying both a meta CSP and a header CSP enforces the +// INTERSECTION, so a header that omitted them silently blocked the app's own +// webfonts — the strictness bought nothing (the fonts are static assets, not a +// script vector) and only broke `chronos open`'s rendering. `script-src 'self'` +// stays stricter than the meta policy on purpose: that intersection is what +// blocks inline scripts in the built app while Vite's dev preamble keeps working. const SECURITY_HEADERS = { "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", + // The capsule under inspection is untrusted data; framing this page lets a + // hostile site overlay it and harvest clicks. `frame-ancestors` covers modern + // browsers, `x-frame-options` the rest. + "x-frame-options": "DENY", "content-security-policy": - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + - "connect-src 'self'; img-src 'self' data:; font-src 'self' data:; " + - "base-uri 'self'; form-action 'none'", + "default-src 'self'; script-src 'self'; " + + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "connect-src 'self'; img-src 'self' data:; " + + "font-src 'self' data: https://fonts.gstatic.com; " + + "base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'", } as const; // Require the Host header to be a loopback host diff --git a/packages/cli/src/stats.ts b/packages/cli/src/stats.ts index 421da36e..5bf4e19e 100644 --- a/packages/cli/src/stats.ts +++ b/packages/cli/src/stats.ts @@ -2,7 +2,9 @@ import { readCapsule, type FailureCapsule } from "@sx4im/chronos-vitest/engine"; import { resolveCapsulePath, capsuleReadError } from "./util.js"; +import { safeText } from "./sanitize.js"; import { C, drawBox, renderTopBanner } from "./ui.js"; +import { CHRONOS_VERSION } from "@sx4im/chronos-core"; export interface StatsResult { exitCode: number; @@ -93,8 +95,8 @@ export async function statsCommand(capsulePath: string): Promise { const lines: string[] = [ `${C.bold("Simulation Metadata")}:`, - ` ${C.cyan("•")} Seed: ${C.white(seed)}`, - ` ${C.cyan("•")} Nodes: ${C.white(String(nodes.length))} [${nodes.join(", ")}]`, + ` ${C.cyan("•")} Seed: ${C.white(safeText(seed))}`, + ` ${C.cyan("•")} Nodes: ${C.white(String(nodes.length))} [${nodes.map((n) => safeText(n)).join(", ")}]`, ` ${C.cyan("•")} Steps Budget: ${C.white(String(maxSteps))}`, ` ${C.cyan("•")} Result Outcome: ${status === "violation" ? C.rose("✕ Invariant Violation") : C.emerald("✔ OK")}`, "", @@ -121,15 +123,15 @@ export async function statsCommand(capsulePath: string): Promise { lines.push( "", `${C.bold("Violation Context")}:`, - ` ${C.rose("✕")} Invariant: ${C.bold(invariant.name)}`, - ` ${C.rose("•")} Detail: ${C.white(invariant.detail || "(no detail)")}`, + ` ${C.rose("✕")} Invariant: ${C.bold(safeText(invariant.name))}`, + ` ${C.rose("•")} Detail: ${C.white(safeText(invariant.detail) || "(no detail)")}`, ); } - const title = `${C.indigo("CHRONOS TRACE STATS")} ${C.muted(`v0.1.4`)}`; + const title = `${C.indigo("CHRONOS TRACE STATS")} ${C.muted(`v${CHRONOS_VERSION}`)}`; return { exitCode: 0, - message: renderTopBanner("0.1.4") + drawBox(title, lines), + message: renderTopBanner() + drawBox(title, lines), }; } diff --git a/packages/cli/src/sweep.ts b/packages/cli/src/sweep.ts index 6353bf06..cf0eb6a0 100644 --- a/packages/cli/src/sweep.ts +++ b/packages/cli/src/sweep.ts @@ -13,7 +13,7 @@ import { type SimTestOptions, } from "@sx4im/chronos-vitest/engine"; import type { ChaosConfig, NetworkConfig, NetworkFactory } from "@sx4im/chronos-core"; -import { toFileUrl } from "./util.js"; +import { toFileUrl, resolveScenarioPath } from "./util.js"; /** A scenario module for sweeps: the system-under-test plus its config. `seeds` * is supplied by the sweep itself, never the module. */ @@ -52,7 +52,11 @@ export interface SweepResult { /** Dynamic-import a scenario module that exports the fields a sweep needs. * Validates `body` (function) and `nodes` (present). */ async function loadSweepScenario(scenarioPath: string): Promise { - const url = toFileUrl(scenarioPath); + // Confine the scenario before importing it, exactly as `replay` and `shrink` + // already do. Importing a module IS executing it, so without this the sweep + // was the one command that would run code from any absolute path on the + // machine — a hole in an otherwise consistent policy. + const url = toFileUrl(resolveScenarioPath(scenarioPath)); const mod = (await import(url)) as Partial & { default?: ScenarioSweepModule; }; diff --git a/packages/cli/src/trace.ts b/packages/cli/src/trace.ts index 536b43da..be86d225 100644 --- a/packages/cli/src/trace.ts +++ b/packages/cli/src/trace.ts @@ -6,6 +6,7 @@ import { readCapsule, type FailureCapsule } from "@sx4im/chronos-vitest/engine"; import type { Trace, TraceEvent } from "@sx4im/chronos-core"; import { resolveCapsulePath, capsuleReadError } from "./util.js"; +import { safeText } from "./sanitize.js"; function useColor(): boolean { return !!process.stdout.isTTY && !process.env.NO_COLOR; @@ -28,35 +29,39 @@ function paint(s: string, c: string): string { return useColor() ? `${c}${s}${C.reset}` : s; } +// Every capsule-derived string below goes through `safeText` before it reaches +// stdout. A `summary` is attacker-chosen text; raw, its ANSI escapes could +// repaint the very output a human is reading to decide what went wrong. function formatEvent(ev: TraceEvent): string { const where = `${paint(`t=${ev.t}`, C.gray)} ${paint(`seq=${ev.seq}`, C.gray)}`; switch (ev.kind) { case "timer": - return `${where} ${paint("timer", C.blue)}${ev.nodeId ? " " + paint(ev.nodeId, C.cyan) : ""}`; + return `${where} ${paint("timer", C.blue)}${ev.nodeId ? " " + paint(safeText(ev.nodeId), C.cyan) : ""}`; case "wake": - return `${where} ${paint("wake", C.blue)} ${paint(ev.nodeId, C.cyan)}`; + return `${where} ${paint("wake", C.blue)} ${paint(safeText(ev.nodeId), C.cyan)}`; case "send": - return `${where} ${paint("send", C.yellow)} ${paint(ev.from, C.cyan)}→${paint(ev.to, C.cyan)} ${ev.summary}`; + return `${where} ${paint("send", C.yellow)} ${paint(safeText(ev.from), C.cyan)}→${paint(safeText(ev.to), C.cyan)} ${safeText(ev.summary)}`; case "deliver": - return `${where} ${paint("deliver", C.green)} ${paint(ev.from, C.cyan)}→${paint(ev.to, C.cyan)} ${ev.summary}`; + return `${where} ${paint("deliver", C.green)} ${paint(safeText(ev.from), C.cyan)}→${paint(safeText(ev.to), C.cyan)} ${safeText(ev.summary)}`; case "crash": - return `${where} ${paint("crash", C.red)} ${paint(ev.nodeId, C.red)}`; + return `${where} ${paint("crash", C.red)} ${paint(safeText(ev.nodeId), C.red)}`; case "restart": - return `${where} ${paint("restart", C.green)} ${paint(ev.nodeId, C.green)}`; + return `${where} ${paint("restart", C.green)} ${paint(safeText(ev.nodeId), C.green)}`; case "partition": - return `${where} ${paint("partition", C.magenta)} ${ev.groups.map((g) => `[${g.join(",")}]`).join(" | ")} healAt=${ev.healAt}`; + return `${where} ${paint("partition", C.magenta)} ${ev.groups.map((g) => `[${g.map((n) => safeText(n)).join(",")}]`).join(" | ")} healAt=${ev.healAt}`; case "invariant-violation": - return `${where} ${paint("VIOLATION", C.red + C.bold)} ${paint(ev.name, C.red)} — ${ev.detail}`; + return `${where} ${paint("VIOLATION", C.red + C.bold)} ${paint(safeText(ev.name), C.red)} — ${safeText(ev.detail)}`; default: // Defensive fallback for unknown event kinds - return `${where} ${paint(String((ev as { kind: string }).kind), C.bold)} ${JSON.stringify(ev)}`; + return `${where} ${paint(safeText((ev as { kind: string }).kind), C.bold)} ${safeText(JSON.stringify(ev))}`; } } /** Render a Trace as a header line plus one line per event (ordered by seq). */ export function formatTraceLines(trace: Trace): string[] { + const nodes = trace.nodes.map((n) => paint(safeText(n), C.cyan)).join(", "); return [ - `${paint("seed", C.gray)} ${paint(trace.seed, C.bold)} | ${paint("nodes", C.gray)} ${trace.nodes.map((n) => paint(n, C.cyan)).join(", ")} | ${paint(trace.result, trace.result === "violation" ? C.red : C.green)} | ${trace.events.length} events`, + `${paint("seed", C.gray)} ${paint(safeText(trace.seed), C.bold)} | ${paint("nodes", C.gray)} ${nodes} | ${paint(safeText(trace.result), trace.result === "violation" ? C.red : C.green)} | ${trace.events.length} events`, ...trace.events.map(formatEvent), ]; } diff --git a/packages/cli/src/ui.ts b/packages/cli/src/ui.ts index 5549bdb3..0bc56c74 100644 --- a/packages/cli/src/ui.ts +++ b/packages/cli/src/ui.ts @@ -2,6 +2,7 @@ // Pure zero-dependency ANSI 24-bit RGB color styling and box-drawing layout primitives. import readline from "node:readline"; +import { CHRONOS_VERSION } from "@sx4im/chronos-core"; function useColor(): boolean { return !process.env.NO_COLOR && (!!process.stdout.isTTY || process.env.FORCE_COLOR === "1"); @@ -35,7 +36,7 @@ function paint(s: string, code: string): string { return useColor() ? `${code}${s}${C.reset}` : s; } -export function renderTopBanner(version = "0.1.4"): string { +export function renderTopBanner(version = CHRONOS_VERSION): string { return ( ` ${C.badgeIndigo(" CHRONOS CLI ")} ${C.cyan("Deterministic Simulation Tooling")} ${C.slate(`v${version}`)}\n` + ` ${C.muted("Tip: Run 'chronos --help' for full command & ecosystem guide.")}\n\n` @@ -123,6 +124,53 @@ export function selectPrompt( }); } +/** Prompt for a SECRET (an API key) without echoing it. + * + * `inputPrompt` uses readline's default echo, so an API key typed at the + * `chronos explain` prompt is painted in the clear — it stays in the terminal + * scrollback, in any `script`/CI capture of the session, and in a screen share + * or recording, which is precisely how pasted keys leak. Mask the echo and + * confirm only the length. + * + * Falls back to the plain prompt when stdin is not a TTY (nothing to mask). */ +export function secretPrompt(questionText: string): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY) { + return resolve(""); + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); + + const promptMsg = `${C.indigo("?")} ${C.bold(questionText)}: `; + // readline writes the prompt through the same `_writeToOutput` hook we are + // about to override, so echo the prompt itself and mask everything after. + const mutable = rl as unknown as { _writeToOutput?: (s: string) => void }; + let masking = false; + mutable._writeToOutput = (chunk: string): void => { + if (!masking) { + process.stdout.write(chunk); + return; + } + // Preserve line terminators so the cursor still moves on Enter. + process.stdout.write(chunk.includes("\n") ? "\n" : "*"); + }; + + rl.question(promptMsg, (answer) => { + rl.close(); + const val = answer.trim(); + process.stdout.write( + `${C.emerald("✔")} ${C.bold(questionText)} ${C.cyan(val ? `[${val.length} chars, hidden]` : "[Skipped]")}\n\n`, + ); + resolve(val); + }); + masking = true; + }); +} + export function inputPrompt( questionText: string, defaultValue = "" diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index c47929d1..1bd70076 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -491,6 +491,15 @@ describe("chronos open — server hardening (B6)", () => { const styleSrc = csp.match(/style-src\s+([^;]+)/i)?.[1] ?? ""; expect(styleSrc).toContain("'self'"); expect(styleSrc).toContain("unsafe-inline"); + // The page renders an untrusted capsule, so a hostile site must not be + // able to frame it and overlay clicks. + expect(csp).toContain("frame-ancestors 'none'"); + expect(capRes.headers.get("x-frame-options")).toBe("DENY"); + // A response carrying both a meta CSP and a header CSP enforces their + // INTERSECTION. index.html loads its webfonts from the Google origins, so + // omitting them here silently blocked the app's own fonts. + expect(styleSrc).toContain("https://fonts.googleapis.com"); + expect(csp).toContain("https://fonts.gstatic.com"); } finally { r.server?.close(); } diff --git a/packages/cli/test/security.test.ts b/packages/cli/test/security.test.ts new file mode 100644 index 00000000..d29a0885 --- /dev/null +++ b/packages/cli/test/security.test.ts @@ -0,0 +1,314 @@ +// Security regression tests for @sx4im/chronos-cli. +// +// The premise for all of these: a failure capsule is UNTRUSTED input. It is +// designed to be shared — attached to an issue, produced by CI, handed to a +// teammate — and then rendered by `chronos trace`, `chronos stats`, and +// `chronos export`. Every string in it (`summary`, `detail`, node ids) is +// attacker-chosen, and each renderer feeds a different sink with its own +// injection grammar: ANSI for the terminal, formulas for a spreadsheet, pipes +// and backticks for Markdown. `validateCapsule` bounds these fields' LENGTH, +// which does nothing to stop any of that — escaping has to happen per sink. +// +// Real fs/argv here is the harness, not simulated code: the prime directive's +// in-simulation entropy ban is untouched. + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { traceCommand } from "../src/trace.js"; +import { statsCommand } from "../src/stats.js"; +import { exportCommand } from "../src/export.js"; +import { checkCommand } from "../src/check.js"; +import { sweepCommand } from "../src/sweep.js"; +import { sanitizeText, safeText, escapeCsvCell, escapeMarkdownCell } from "../src/sanitize.js"; + +const ESC = "\x1b"; + +let dir: string; +let prevChronosDir: string | undefined; + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "chronos-cli-sec-")); + prevChronosDir = process.env.CHRONOS_DIR; + process.env.CHRONOS_DIR = dir; +}); + +afterAll(() => { + if (prevChronosDir === undefined) delete process.env.CHRONOS_DIR; + else process.env.CHRONOS_DIR = prevChronosDir; + rmSync(dir, { recursive: true, force: true }); +}); + +/** Write a capsule whose event strings are whatever the caller wants to smuggle + * through. Everything else is a minimal well-formed capsule. */ +function hostileCapsule(name: string, payload: string): string { + const capsule = { + chronosVersion: "0.1.5", + seed: "1", + nodes: ["a", "b"], + config: { + network: { minLatency: 0, maxLatency: 10, dropProb: 0, dupProb: 0 }, + chaos: { + partitionProb: 0, + crashProb: 0, + restartProb: 0, + maxPartitionMs: 200, + maxCrashFraction: 0.5, + }, + }, + maxSteps: 100, + invariant: { name: "inv", detail: payload }, + trace: { + seed: "1", + config: {}, + nodes: ["a", "b"], + result: "violation", + events: [ + { t: 0, seq: 0, kind: "send", from: "a", to: "b", summary: payload }, + { t: 1, seq: 1, kind: "deliver", from: "a", to: "b", summary: payload }, + { t: 2, seq: 2, kind: "invariant-violation", name: "inv", detail: payload }, + ], + }, + }; + const p = join(dir, name); + writeFileSync(p, JSON.stringify(capsule), "utf8"); + return p; +} + +describe("sanitizeText — terminal control-sequence stripping", () => { + it("removes SGR colors, cursor movement, and screen erasure", () => { + expect(sanitizeText(`${ESC}[31mred${ESC}[0m`)).toBe("red"); + expect(sanitizeText(`before${ESC}[2J${ESC}[Hafter`)).toBe("beforeafter"); + expect(sanitizeText(`up${ESC}[10Aover`)).toBe("upover"); + }); + + it("removes OSC sequences that retitle the terminal", () => { + expect(sanitizeText(`${ESC}]0;pwned\x07ok`)).toBe("ok"); + expect(sanitizeText(`${ESC}]0;pwned${ESC}\\ok`)).toBe("ok"); + }); + + it("removes carriage returns and newlines, which forge additional lines", () => { + // \r rewinds to column 0 so the next characters overwrite what was printed. + expect(sanitizeText("real\rfake")).toBe("realfake"); + expect(sanitizeText("line1\nline2")).toBe("line1line2"); + }); + + it("removes a bare ESC and other C0/C1 controls but keeps tab", () => { + expect(sanitizeText(`x${ESC}`)).toBe("x"); + expect(sanitizeText("a\x00\x07\x7f\x9bb")).toBe("ab"); + expect(sanitizeText("a\tb")).toBe("a\tb"); + }); + + it("removes the single-byte C1 CSI (0x9b), not just the ESC[ form", () => { + expect(sanitizeText("a\u009b31mb")).toBe("a31mb"); + }); + + it("removes CSI sequences using the full ECMA-48 byte ranges", () => { + // Parameter bytes are 0x30-0x3F (not just digits) and intermediates + // 0x20-0x2F, so a private-use sequence like ESC[?25l must go too. + expect(sanitizeText(`${ESC}[?25lhidden`)).toBe("hidden"); + expect(sanitizeText(`${ESC}[38;2;255;0;0mx`)).toBe("x"); + expect(sanitizeText(`${ESC}(Bplain`)).toBe("plain"); + }); + + it("removes string-terminated sequences with their payload (OSC/DCS/APC)", () => { + // The payload of a DCS/APC sequence is arbitrary text; dropping only the + // introducer would leave it on screen looking like part of the trace. + expect(sanitizeText(`${ESC}Pq#0;2;0;0;0${ESC}\\ok`)).toBe("ok"); + expect(sanitizeText(`${ESC}_payload${ESC}\\ok`)).toBe("ok"); + expect(sanitizeText(`${ESC}^msg${ESC}\\ok`)).toBe("ok"); + }); + + it("removes bidi overrides (Trojan Source) and zero-width padding", () => { + // U+202E makes `node-1 → node-2` render reversed — the same display + // deception as an ANSI repaint, in a tool whose job is reporting facts. + expect(sanitizeText("node-1 \u202e> 2-edon")).toBe("node-1 > 2-edon"); + expect(sanitizeText("a\u200b\u200c\u2060\ufeffb")).toBe("ab"); + }); + + it("leaves ordinary text, unicode, and emoji untouched", () => { + expect(sanitizeText("Append{term:2} → node-1 ✓ 日本語")).toBe("Append{term:2} → node-1 ✓ 日本語"); + }); + + it("stays linear on adversarial input (no catastrophic backtracking)", () => { + // Every quantified class in sanitizeText is disjoint from its neighbours, + // so no input can be split between two of them. Pin that: a pathological + // string built from the ambiguous-looking bytes must finish immediately. + const evil = `${ESC}[` + ";".repeat(50_000) + "!".repeat(50_000); + const started = Date.now(); + sanitizeText(evil); + expect(Date.now() - started).toBeLessThan(1000); + }); + + it("caps length so a renderer never depends on a bound enforced elsewhere", () => { + expect(safeText("x".repeat(10_000), 100)).toHaveLength(101); // 100 + ellipsis + }); +}); + +describe("escapeCsvCell — spreadsheet formula injection", () => { + // A capsule's summary lands in a CSV that is meant to be SHARED, and Excel / + // LibreOffice / Sheets execute a cell whose first character is one of these. + it.each(["=cmd|'/c calc'!A1", "+1+1", "-2+3", "@SUM(A1)", "\tlead"])( + "neutralizes the formula lead in %j", + (payload) => { + const cell = escapeCsvCell(payload); + const inner = cell.startsWith('"') ? cell.slice(1, -1) : cell; + expect(inner.startsWith("'")).toBe(true); + }, + ); + + it("drops a carriage-return lead outright rather than prefixing it", () => { + // A leading \r is both a formula trigger and a terminal control character; + // sanitizeText removes it first, which leaves nothing to neutralize. + expect(escapeCsvCell("\rlead")).toBe("lead"); + }); + + // An anchored test against the very first character alone is sidestepped by + // anything a spreadsheet skips over on its way to the `=`. + it.each([ + [" =cmd|'/c calc'!A1", "leading whitespace, which spreadsheets skip"], + ["\u200b=cmd|'/c calc'!A1", "a zero-width space"], + ["\ufeff=cmd|'/c calc'!A1", "a byte-order mark"], + [" \t =cmd|'/c calc'!A1", "mixed invisible padding"], + ])("neutralizes a formula hidden behind %j (%s)", (payload) => { + const cell = escapeCsvCell(payload); + const inner = cell.startsWith('"') ? cell.slice(1, -1) : cell; + expect(inner.startsWith("'")).toBe(true); + }); + + it("still quotes and doubles per RFC 4180", () => { + expect(escapeCsvCell('has,comma')).toBe('"has,comma"'); + expect(escapeCsvCell('say "hi"')).toBe('"say ""hi"""'); + }); + + it("leaves an ordinary cell unquoted and unprefixed", () => { + expect(escapeCsvCell("Append term=2")).toBe("Append term=2"); + }); +}); + +describe("escapeMarkdownCell — table and code-span breakout", () => { + it("escapes pipes, backticks, and other structural characters", () => { + expect(escapeMarkdownCell("a|b")).toBe("a\\|b"); + expect(escapeMarkdownCell("`code`")).toBe("\\`code\\`"); + expect(escapeMarkdownCell("")).toBe("\\"); + }); +}); + +describe("chronos trace — untrusted capsule text reaches the terminal", () => { + it("emits no escape sequences for a capsule full of them", async () => { + // Repaint earlier output and claim the run was clean. Unescaped, this is a + // trace viewer that can be made to lie about what it found. + const p = hostileCapsule("ansi.json", `${ESC}[2J${ESC}[H0 violations found${ESC}[0m`); + const r = await traceCommand(p); + expect(r.exitCode).toBe(0); + const out = r.lines.join("\n"); + expect(out).not.toContain(ESC); + expect(out).not.toContain("\r"); + expect(out).toContain("0 violations found"); // text survives, control does not + }); + + it("keeps one line per event when a summary contains newlines", async () => { + const p = hostileCapsule("nl.json", "real\nt=999 seq=999 forged"); + const r = await traceCommand(p); + // Header + 3 events, and not one more. + expect(r.lines).toHaveLength(4); + }); + + it("refuses a capsule whose trace envelope is incomplete instead of crashing", async () => { + // Before the trace envelope was validated, `trace.nodes` being absent threw + // a raw TypeError out of formatTraceLines. + const p = join(dir, "no-nodes.json"); + const c = JSON.parse(readFileSync(hostileCapsule("src.json", "x"), "utf8")) as { + trace: Record; + }; + delete c.trace.nodes; + writeFileSync(p, JSON.stringify(c), "utf8"); + const r = await traceCommand(p); + expect(r.exitCode).toBe(2); + expect(r.lines.join("\n")).toMatch(/could not read capsule/); + }); + + it("refuses a capsule with an unknown event kind", async () => { + const p = join(dir, "bad-kind.json"); + const c = JSON.parse(readFileSync(hostileCapsule("src2.json", "x"), "utf8")) as { + trace: { events: unknown[] }; + }; + c.trace.events = [{ t: 0, seq: 0, kind: "not-a-kind" }]; + writeFileSync(p, JSON.stringify(c), "utf8"); + const r = await traceCommand(p); + expect(r.exitCode).toBe(2); + }); +}); + +describe("chronos stats / export — the other untrusted sinks", () => { + it("stats emits no escape sequences from capsule text", async () => { + const p = hostileCapsule("ansi-stats.json", `${ESC}[31mred${ESC}[2J`); + const r = await statsCommand(p); + expect(r.exitCode).toBe(0); + // The CLI's own coloring is disabled off-TTY, so any ESC left is injected. + expect(r.message).not.toContain(`${ESC}[2J`); + }); + + it("export --csv neutralizes a formula smuggled in a summary", async () => { + const p = hostileCapsule("csv.json", "=cmd|'/c calc'!A1"); + const out = join(dir, "out.csv"); + const r = await exportCommand(p, { format: "csv", output: out }); + expect(r.exitCode).toBe(0); + const csv = readFileSync(out, "utf8"); + expect(csv).toContain("'=cmd"); + expect(csv).not.toMatch(/(^|,)=cmd/m); + }); + + it("export --markdown keeps a hostile summary inside its own cell", async () => { + const p = hostileCapsule("md.json", "x | forged | row"); + const out = join(dir, "out.md"); + const r = await exportCommand(p, { format: "markdown", output: out }); + expect(r.exitCode).toBe(0); + const md = readFileSync(out, "utf8"); + expect(md).toContain("x \\| forged \\| row"); + for (const line of md.split("\n")) { + if (!line.startsWith("| ") || line.startsWith("|---")) continue; + // Escaped pipes render as literal text, so drop them before counting the + // structural ones: the table declares 5 columns and must never grow. + const columns = line.replace(/\\\|/g, "").split("|").filter((s) => s !== ""); + expect(columns).toHaveLength(5); + } + }); +}); + +describe("chronos check — directory walk", () => { + it("terminates on a symlink cycle instead of recursing forever", async () => { + // `stat()` follows links, so `ln -s .. loop` inside a scanned tree used to + // make the walk recurse until the stack blew — taking `chronos doctor`, + // which calls check, down with it. + const root = mkdtempSync(join(tmpdir(), "chronos-symlink-")); + try { + const sub = join(root, "src"); + mkdirSync(sub); + writeFileSync(join(sub, "ok.ts"), "export const a = 1;\n", "utf8"); + symlinkSync(root, join(sub, "loop"), "dir"); + const r = await checkCommand([root]); + expect(r.exitCode).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 10_000); +}); + +describe("chronos sweep — scenario path confinement", () => { + it("refuses to import a scenario from outside the allowed roots", async () => { + // Importing a module executes it. `replay` and `shrink` already confined + // scenario paths; sweep was the one command that did not. + const outside = mkdtempSync(join(tmpdir(), "chronos-outside-")); + try { + const scenario = join(outside, "evil.mjs"); + writeFileSync(scenario, "export const nodes = 1; export const body = () => {};\n", "utf8"); + const r = await sweepCommand(scenario, 1); + expect(r.exitCode).toBe(2); + expect(r.message).toMatch(/outside the allowed directories/); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5916eb55..3f494503 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -65,4 +65,7 @@ export { type RunOptions as SimulatorRunOptions, } from "./simulator.js"; -export const CHRONOS_VERSION = "0.0.0"; +/** The single source of truth for the version Chronos reports everywhere: the + * `chronosVersion` stamped into capsules, the CLI banner, and the doctor + * report. Keep it in step with the packages' `package.json` version. */ +export const CHRONOS_VERSION = "0.1.5"; diff --git a/packages/core/src/invariants.ts b/packages/core/src/invariants.ts index 2a519bb1..52a2bc18 100644 --- a/packages/core/src/invariants.ts +++ b/packages/core/src/invariants.ts @@ -28,7 +28,7 @@ export class InvariantViolated extends Error { public readonly violator?: string, ) { const where = violator !== undefined ? ` (node ${violator})` : ""; - super(`invariant "${invariant}" violated at t=${detail}${where}`); + super(`invariant "${invariant}" violated${where}: ${detail}`); this.name = "InvariantViolated"; } } diff --git a/packages/core/src/real.ts b/packages/core/src/real.ts index 1aeb0d88..d5d79230 100644 --- a/packages/core/src/real.ts +++ b/packages/core/src/real.ts @@ -3,8 +3,29 @@ // logic runs in tests (against an injected simulated env) and in production // (against this real env). Only the `env` differs; the code doesn't. +import { randomBytes } from "node:crypto"; + import type { SimEnv, SimNet, TimerHandle } from "./env.js"; +/** A float in [0, 1) with 53 bits of CSPRNG entropy — the production-side + * counterpart to the simulator's xoshiro256** `nextFloat`. + * + * `Math.random()` would satisfy the `SimEnv` contract just as well, but this is + * the PRODUCTION adapter: the same business logic that calls `env.random()` for + * retry jitter under simulation may call it for a request id, a nonce, or a + * token in production, and V8's `Math.random()` is a seedable xorshift whose + * internal state an attacker can recover from a handful of observed outputs. + * A CSPRNG costs nothing here (nothing in production is replayed) and removes + * a whole class of "the AI used Math.random() for a secret" bugs. */ +function secureFloat(): number { + const b = randomBytes(8); + // Top 53 bits, matching Rng.nextFloat's construction: drop the low 11 bits of + // the 64-bit draw and divide by 2^53. + const hi = b.readUInt32BE(0); // bits 63..32 + const lo = b.readUInt32BE(4); // bits 31..0 + return (hi * 2 ** 21 + (lo >>> 11)) / 2 ** 53; +} + export interface RealTransport { send(to: string, payload: unknown): void; onReceive(handler: (from: string, payload: unknown) => void): void; @@ -35,7 +56,7 @@ export class RealEnv implements SimEnv { } random(): number { - return Math.random(); + return secureFloat(); } sleep(ms: number): Promise { diff --git a/packages/core/src/scheduler.ts b/packages/core/src/scheduler.ts index dac0d388..121aebd0 100644 --- a/packages/core/src/scheduler.ts +++ b/packages/core/src/scheduler.ts @@ -28,20 +28,31 @@ export interface ScheduleMeta { summary?: string; } +// The real macrotask primitives, captured at MODULE LOAD — before any run can +// install the strict-mode guards. The guards replace `globalThis.setTimeout` +// with one that schedules into the simulator, so a barrier that looked the +// global up at call time would, on the `setTimeout` fallback path, enqueue its +// own resolution as a simulated event that only this loop can run: a deadlock +// in the one function the whole scheduler awaits every step. Binding the +// originals here makes that unreachable regardless of what is patched. +const realSetImmediate: typeof setImmediate | undefined = + typeof setImmediate === "function" ? setImmediate : undefined; +const realSetTimeout = globalThis.setTimeout; + /** Flush microtasks using a single real macrotask barrier per step. * This is the ONLY allowed real macrotask primitive. It introduces no * nondeterminism because no user-visible timing flows through it. */ export function drainMicrotasks(): Promise { return new Promise((resolve) => { - if (typeof setImmediate === "function") { - setImmediate(resolve); + if (realSetImmediate) { + realSetImmediate(resolve); } else if (typeof MessageChannel !== "undefined") { const channel = new MessageChannel(); channel.port1.addEventListener("message", () => resolve(), { once: true }); channel.port1.start?.(); channel.port2.postMessage(null); } else { - setTimeout(resolve, 0); + realSetTimeout(resolve, 0); } }); } diff --git a/packages/core/src/strict.ts b/packages/core/src/strict.ts index 4ebbf35a..a4fd6318 100644 --- a/packages/core/src/strict.ts +++ b/packages/core/src/strict.ts @@ -118,9 +118,20 @@ export function installGuards(env: SimEnv, level: StrictLevel = "route"): Instal // --- setTimeout & clearTimeout: route to env in BOTH levels (a forgotten timer is // recoverable; forcing it onto the sim keeps the run deterministic). --- + // + // String handlers are REFUSED, exactly as Node's own `setTimeout` refuses + // them. Compiling one (`new Function(handler)`) would turn any string that + // reaches a forgotten global timer — a message payload from a simulated + // peer, a field of an untrusted capsule — into executed code, adding an + // eval sink to the harness that the runtime it emulates does not even have. globalThis.setTimeout = ((handler: ((...args: unknown[]) => void) | string, ms = 0, ...args: unknown[]) => { - const fn = typeof handler === "function" ? handler : () => new Function(String(handler))(); - const handle = env.setTimeout(() => fn(...args), ms); + if (typeof handler !== "function") { + throw new TypeError( + `The "callback" argument must be of type function. Received type ${typeof handler}. ` + + `Chronos strict mode does not compile string timer handlers (neither does Node).`, + ); + } + const handle = env.setTimeout(() => handler(...args), ms); return handle as unknown as ReturnType; }) as unknown as typeof globalThis.setTimeout; diff --git a/packages/core/src/trace.ts b/packages/core/src/trace.ts index 3e64be9b..56510ade 100644 --- a/packages/core/src/trace.ts +++ b/packages/core/src/trace.ts @@ -50,28 +50,44 @@ export class TraceLogger { // Deterministic helpers to shrink payload logs to a stable short string. // Safely handles circular references and deep structures. + // + // Only a value that is its own ANCESTOR is a cycle. Tracking "every object + // seen anywhere" instead would mark the second occurrence of a merely SHARED + // reference — `{ a: x, b: x }`, an extremely common message shape — as + // `[Circular]`, silently corrupting the summary that `send`/`deliver` pairing + // (CLI stats, Inspector sequence diagram) keys on. So the guard is an + // ancestor stack maintained via `this` in the replacer, which JSON.stringify + // binds to the object currently being serialized. static summarize(payload: unknown, maxLen = 80): string { - let s: string; + let s: string | undefined; try { if (payload === undefined) { s = "undefined"; } else { - // Safe JSON stringify that detects/redacts circular references - const seen = new WeakSet(); - s = JSON.stringify(payload, (_key, value) => { + const ancestors: unknown[] = []; + s = JSON.stringify(payload, function (this: unknown, _key, value: unknown) { + while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { + ancestors.pop(); + } if (typeof value === "object" && value !== null) { - if (seen.has(value)) { - return "[Circular]"; - } - seen.add(value); + if (ancestors.includes(value)) return "[Circular]"; + ancestors.push(value); } return value; }); } } catch { - s = String(payload); + s = undefined; + } + if (s === undefined) { + // `JSON.stringify` returns undefined for functions/symbols/undefined and + // throws on BigInt; fall back to a stable, non-throwing description. + try { + s = String(payload); + } catch { + s = "[unserializable]"; + } } - if (s === undefined) s = "undefined"; return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s; } } diff --git a/packages/core/test/index.test.ts b/packages/core/test/index.test.ts index 8185740c..785e211a 100644 --- a/packages/core/test/index.test.ts +++ b/packages/core/test/index.test.ts @@ -1,8 +1,19 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { describe, it, expect } from "vitest"; import { CHRONOS_VERSION } from "../src/index.js"; -describe("@sx4im/chronos-core placeholder", () => { - it("exports a version string", () => { - expect(CHRONOS_VERSION).toBe("0.0.0"); +describe("@sx4im/chronos-core version", () => { + it("exports a semver version string", () => { + expect(CHRONOS_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); + + // CHRONOS_VERSION is stamped into every capsule and rendered by the CLI and + // the Inspector. Drift from package.json makes a capsule claim a version that + // never existed, so pin them together here rather than by convention. + it("matches the package.json version", () => { + const pkgUrl = new URL("../package.json", import.meta.url); + const pkg = JSON.parse(readFileSync(fileURLToPath(pkgUrl), "utf8")) as { version: string }; + expect(CHRONOS_VERSION).toBe(pkg.version); }); }); diff --git a/packages/core/test/security.test.ts b/packages/core/test/security.test.ts new file mode 100644 index 00000000..72dcf323 --- /dev/null +++ b/packages/core/test/security.test.ts @@ -0,0 +1,121 @@ +// Security regression tests for @sx4im/chronos-core. +// +// Two distinct concerns live here: +// 1. The strict-mode guards patch globals to keep a run deterministic. They +// must not, in doing so, become an easier attack surface than the runtime +// they emulate. +// 2. `TraceLogger.summarize` produces the `summary` string that everything +// downstream (send/deliver pairing, the CLI, the Inspector) keys on, from +// arbitrary user payloads. It must never throw and never be misleading. + +import { describe, it, expect } from "vitest"; +import { Simulator } from "../src/simulator.js"; +import { installGuards } from "../src/strict.js"; +import { TraceLogger } from "../src/trace.js"; +import { RealEnv } from "../src/real.js"; +import { InvariantViolated } from "../src/invariants.js"; + +function guarded(fn: () => T): T { + const sim = new Simulator({ seed: 1n, nodes: 1 }); + const env = sim.nodes[0]!.env; + const guards = installGuards(env, "route"); + try { + return fn(); + } finally { + guards.restore(); + } +} + +describe("strict mode — the guarded setTimeout is not an eval sink", () => { + it("refuses a string handler instead of compiling it", () => { + // `new Function(handler)` would turn any string reaching a forgotten global + // timer — a simulated peer's payload, a capsule field — into executed code. + // Node's own setTimeout refuses string handlers; so must the guard. + guarded(() => { + expect(() => globalThis.setTimeout("globalThis.__pwned = true" as never, 0)).toThrow(TypeError); + }); + expect((globalThis as Record).__pwned).toBeUndefined(); + }); + + it("still accepts a function handler and routes it through the sim", () => { + guarded(() => { + let fired = false; + const handle = globalThis.setTimeout(() => { + fired = true; + }, 5); + expect(handle).toBeDefined(); + expect(fired).toBe(false); // virtual time has not advanced + }); + }); + + it("restores every patched global", () => { + const realSetTimeout = globalThis.setTimeout; + const realRandom = Math.random; + guarded(() => { + expect(globalThis.setTimeout).not.toBe(realSetTimeout); + }); + expect(globalThis.setTimeout).toBe(realSetTimeout); + expect(Math.random).toBe(realRandom); + }); +}); + +describe("TraceLogger.summarize", () => { + it("does not mistake a SHARED reference for a circular one", () => { + // `{ a: x, b: x }` is an extremely common message shape. Tracking every + // object ever seen (rather than the ancestor chain) reported the second + // occurrence as [Circular], corrupting the summary that send/deliver + // pairing matches on — two identical messages would stop pairing. + const shared = { id: 7 }; + expect(TraceLogger.summarize({ a: shared, b: shared })).toBe('{"a":{"id":7},"b":{"id":7}}'); + }); + + it("still detects a genuine cycle", () => { + const cyclic: Record = { name: "n" }; + cyclic.self = cyclic; + const s = TraceLogger.summarize(cyclic); + expect(s).toContain("[Circular]"); + }); + + it("never throws on a payload JSON cannot represent", () => { + expect(() => TraceLogger.summarize(10n)).not.toThrow(); + expect(() => TraceLogger.summarize(() => 1)).not.toThrow(); + expect(() => TraceLogger.summarize(Symbol("s"))).not.toThrow(); + expect(TraceLogger.summarize(undefined)).toBe("undefined"); + }); + + it("truncates so one payload cannot dominate a trace", () => { + expect(TraceLogger.summarize({ big: "x".repeat(5000) }).length).toBeLessThanOrEqual(81); + }); + + it("is deterministic — the same payload always summarizes identically", () => { + const payload = { term: 2, entries: [1, 2, 3], from: "node-0" }; + const first = TraceLogger.summarize(payload); + for (let i = 0; i < 100; i++) expect(TraceLogger.summarize(payload)).toBe(first); + }); +}); + +describe("RealEnv.random — the production entropy source", () => { + it("returns floats in [0, 1)", () => { + const env = new RealEnv({ nodeId: "n", transport: { send: () => {}, onReceive: () => {} } }); + for (let i = 0; i < 1000; i++) { + const v = env.random(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it("does not repeat (it is a CSPRNG draw, not a fixed value)", () => { + const env = new RealEnv({ nodeId: "n", transport: { send: () => {}, onReceive: () => {} } }); + const seen = new Set(); + for (let i = 0; i < 1000; i++) seen.add(env.random()); + expect(seen.size).toBe(1000); + }); +}); + +describe("InvariantViolated", () => { + it("reports the detail as a detail, not as a timestamp", () => { + const e = new InvariantViolated("at-most-one-leader", "two leaders in term 4"); + expect(e.message).toContain("two leaders in term 4"); + expect(e.message).not.toContain("t=two leaders"); + }); +}); diff --git a/packages/inspector/src/App.tsx b/packages/inspector/src/App.tsx index 7483288e..1f4d6483 100644 --- a/packages/inspector/src/App.tsx +++ b/packages/inspector/src/App.tsx @@ -19,7 +19,17 @@ import { InspectorWorkspace, ErrorView } from "./InspectorWorkspace.js"; type LoadResult = { ok: true; capsule: ParsedCapsule; filename: string } | { ok: false; error: string }; +/** Reject an oversized capsule BEFORE reading it. `file.text()` materializes + * the whole file as a JS string and `JSON.parse` then materializes the object + * graph, so a multi-gigabyte drop is an out-of-memory tab crash long before + * any of `parseCapsule`'s bounds get a say. Mirrors the CLI's read limit. */ +const MAX_CAPSULE_BYTES = 128 * 1024 * 1024; + async function loadFile(file: File): Promise { + if (file.size > MAX_CAPSULE_BYTES) { + const mb = Math.round(MAX_CAPSULE_BYTES / (1024 * 1024)); + return { ok: false, error: `capsule is too large to open in the browser (limit ${mb} MB)` }; + } try { const text = await file.text(); const capsule = parseCapsule(text); diff --git a/packages/inspector/src/InspectorWorkspace.tsx b/packages/inspector/src/InspectorWorkspace.tsx index 3d6a1c8c..3d934842 100644 --- a/packages/inspector/src/InspectorWorkspace.tsx +++ b/packages/inspector/src/InspectorWorkspace.tsx @@ -36,7 +36,7 @@ export function InspectorWorkspace({ searchQuery: string; onSearchQuery: (q: string) => void; }): JSX.Element { - const { nodes, seed, invariant, trace } = capsule; + const { nodes, seed, invariant, trace, droppedEvents, truncatedEvents } = capsule; const rawEvents = trace.events; const filteredEvents = useMemo(() => { @@ -112,6 +112,20 @@ export function InspectorWorkspace({ events {rawEvents.length} + {/* Say so when the rendered trace isn't the whole file — a silently + partial timeline would be read as a complete one. */} + {truncatedEvents > 0 && ( +
+ truncated + {truncatedEvents} not shown +
+ )} + {droppedEvents > 0 && ( +
+ malformed + {droppedEvents} skipped +
+ )} {violation && ( diff --git a/packages/inspector/src/LandingPage.tsx b/packages/inspector/src/LandingPage.tsx index 235cb56a..96e991aa 100644 --- a/packages/inspector/src/LandingPage.tsx +++ b/packages/inspector/src/LandingPage.tsx @@ -4,6 +4,7 @@ import { type DragEvent, } from "react"; import type { ParsedCapsule } from "./capsule.js"; +import { CHRONOS_VERSION } from "./version.js"; import { ChronosLogo } from "./ChronosLogo.js"; import { GithubIcon, @@ -50,7 +51,7 @@ export function LandingPage({
Chronos - v0.0.0 + v{CHRONOS_VERSION}
diff --git a/packages/inspector/src/SequenceDiagram.tsx b/packages/inspector/src/SequenceDiagram.tsx index 6a5182b6..cb745e72 100644 --- a/packages/inspector/src/SequenceDiagram.tsx +++ b/packages/inspector/src/SequenceDiagram.tsx @@ -10,7 +10,7 @@ import { useMemo, useState } from "react"; import type { TraceEvent } from "@sx4im/chronos-core"; -import { pairMessages, partitionSpans, describeEvent, timeBounds } from "./capsule.js"; +import { pairMessages, partitionSpans, describeEvent, timeBounds, MAX_RENDERED_SPAN } from "./capsule.js"; import { KIND_STYLE } from "./colors.js"; interface SequenceDiagramProps { @@ -39,7 +39,10 @@ export function SequenceDiagram({ const flows = useMemo(() => pairMessages(events), [events]); const spans = useMemo(() => partitionSpans(events), [events]); - const span = Math.max(1, tMax - tMin); + // Clamped for the same reason as the timeline: `height = span × vScale`, and + // an unbounded `t` in a capsule would otherwise size the SVG into the tens of + // trillions of pixels. + const span = Math.min(MAX_RENDERED_SPAN, Math.max(1, tMax - tMin)); const laneX = (id: string): number => { const i = nodes.indexOf(id); if (i === -1) return PAD_L + nodes.length * LANE_W + LANE_W / 2; // unknown → right void @@ -100,8 +103,13 @@ export function SequenceDiagram({ {/* partition bands across the involved lanes */} {spans.map((s) => { - const xs = Math.min(...s.event.groups.flat().map((n) => laneX(n))) - 16; - const xe = Math.max(...s.event.groups.flat().map((n) => laneX(n))) + 16; + // A partition with no members has no band to draw, and spreading an + // empty array into Math.min/max yields ±Infinity — which renders as + // invalid SVG geometry rather than nothing. + const lanes = s.event.groups.flat().map((n) => laneX(n)); + if (lanes.length === 0) return null; + const xs = Math.min(...lanes) - 16; + const xe = Math.max(...lanes) + 16; return ( timeBounds(events), [events]); const spans = useMemo(() => partitionSpans(events), [events]); - const span = Math.max(1, tMax - tMin); + // Clamp the laid-out span: `t` is unbounded in a capsule, and `span × scale` + // is the SVG width, so an event at t=1e12 would ask the browser for a canvas + // of ~4e13 px. Clamping squashes such a trace instead of hanging the tab. + const span = Math.min(MAX_RENDERED_SPAN, Math.max(1, tMax - tMin)); + const tEnd = tMin + span; const innerW = span * scale + PAD_R; const width = PAD_L + innerW; const step = niceStep(span); const ticks: number[] = []; - for (let t = Math.ceil(tMin / step) * step; t <= tMax; t += step) ticks.push(t); + for (let t = Math.ceil(tMin / step) * step; t <= tEnd; t += step) ticks.push(t); const xOf = (t: number) => PAD_L + (t - tMin) * scale; const selected = events.find((e) => e.seq === selectedSeq) ?? null; @@ -54,7 +58,7 @@ export function Timeline({ events, selectedSeq, highlight, onSelect }: TimelineP const rect = svg.getBoundingClientRect(); const svgX = ((clientX - rect.left) / rect.width) * width; const t = Math.round(tMin + (svgX - PAD_L) / scale); - return Math.min(tMax, Math.max(tMin, t)); + return Math.min(tEnd, Math.max(tMin, t)); }; return ( diff --git a/packages/inspector/src/capsule.ts b/packages/inspector/src/capsule.ts index b47f9e63..4e77c8ae 100644 Binary files a/packages/inspector/src/capsule.ts and b/packages/inspector/src/capsule.ts differ diff --git a/packages/inspector/src/demoCapsule.ts b/packages/inspector/src/demoCapsule.ts index d14889ec..ed99eb1d 100644 --- a/packages/inspector/src/demoCapsule.ts +++ b/packages/inspector/src/demoCapsule.ts @@ -65,4 +65,7 @@ export const DEMO_CAPSULE: ParsedCapsule = { { seq: 32, t: 355, kind: "invariant-violation", name: "at-most-one-leader", detail: "nodes node-1 and node-3 both claim term 4 leadership during network partition" }, ], }, + totalEvents: 33, + droppedEvents: 0, + truncatedEvents: 0, }; diff --git a/packages/inspector/src/version.ts b/packages/inspector/src/version.ts new file mode 100644 index 00000000..675dc1cd --- /dev/null +++ b/packages/inspector/src/version.ts @@ -0,0 +1,16 @@ +// The version the Inspector displays. +// +// It cannot come from `@sx4im/chronos-core`: the inspector imports core for +// TYPES ONLY (which are elided at build time), and importing a runtime value +// would pull core's whole barrel — including `env.ts`'s `node:async_hooks` — +// into a browser bundle, where it does not exist. So Vite injects core's +// package.json version as `__CHRONOS_VERSION__` at build time instead. Same +// single source of truth, no runtime import. +// +// The fallback covers non-Vite consumers of this module (the Vitest unit tests +// run without the `define`). + +declare const __CHRONOS_VERSION__: string | undefined; + +export const CHRONOS_VERSION: string = + typeof __CHRONOS_VERSION__ === "string" ? __CHRONOS_VERSION__ : "0.0.0-dev"; diff --git a/packages/inspector/test/capsule.test.ts b/packages/inspector/test/capsule.test.ts index 091470d0..89a0b14a 100644 --- a/packages/inspector/test/capsule.test.ts +++ b/packages/inspector/test/capsule.test.ts @@ -17,6 +17,7 @@ import { describeEvent, isSameOriginCapsuleUrl, capsuleParamError, + MAX_RENDERED_EVENTS, } from "../src/capsule.js"; function ev(e: TraceEvent): TraceEvent { @@ -322,3 +323,77 @@ describe("small derivations", () => { expect(describeEvent(events[3]!)).toContain("partition"); }); }); + +// The Inspector renders capsules that arrive by file drop or `?capsule=` — i.e. +// whatever someone was handed. The views destructure events by `kind` and index +// style tables with it, and size their SVG as `span × zoom`, so anything the +// parse boundary lets through is a crash or a hung tab rather than a bad pixel. +describe("parseCapsule — untrusted capsule defenses", () => { + function raw(events: unknown[], nodes: unknown[] = ["0", "1"]): string { + return JSON.stringify({ + seed: "42", + nodes, + invariant: { name: "inv", detail: "d" }, + trace: { seed: "42", config: {}, nodes, events, result: "violation" }, + }); + } + + it("drops events that do not match the TraceEvent union", () => { + const parsed = parseCapsule( + raw([ + { t: 0, seq: 0, kind: "wake", nodeId: "0" }, + { t: 1, seq: 1, kind: "exec", cmd: "rm -rf /" }, + { t: 2, seq: 2, kind: "send", from: "0", to: "1" }, // no summary + { t: 3, seq: 3, kind: "partition", groups: "not-an-array", healAt: 9 }, + { t: "x", seq: 4, kind: "wake", nodeId: "1" }, + ]) + ); + expect(parsed.trace.events).toHaveLength(1); + expect(parsed.droppedEvents).toBe(4); + expect(parsed.totalEvents).toBe(5); + }); + + it("keeps a readable trace rather than rejecting it over one bad event", () => { + const parsed = parseCapsule( + raw([ + { t: 0, seq: 0, kind: "send", from: "0", to: "1", summary: "ping" }, + { t: 1, seq: 1, kind: "bogus" }, + ]) + ); + expect(parsed.trace.events).toHaveLength(1); + expect(parsed.droppedEvents).toBe(1); + }); + + it("caps rendered events and reports the truncation", () => { + // 2M events mapped to SVG markers with no virtualization is a hung tab. + const many = Array.from({ length: MAX_RENDERED_EVENTS + 25 }, (_, i) => ({ + t: i, + seq: i, + kind: "wake", + nodeId: "0", + })); + const parsed = parseCapsule(raw(many)); + expect(parsed.trace.events).toHaveLength(MAX_RENDERED_EVENTS); + expect(parsed.truncatedEvents).toBe(25); + expect(parsed.totalEvents).toBe(MAX_RENDERED_EVENTS + 25); + }); + + it("caps the node list (the metrics link matrix is n×n)", () => { + const nodes = Array.from({ length: 5000 }, (_, i) => `n${i}`); + expect(parseCapsule(raw([], nodes)).nodes).toHaveLength(256); + }); + + it("reports no truncation for an ordinary capsule", () => { + const parsed = parseCapsule(raw([{ t: 0, seq: 0, kind: "wake", nodeId: "0" }])); + expect(parsed.droppedEvents).toBe(0); + expect(parsed.truncatedEvents).toBe(0); + expect(parsed.totalEvents).toBe(1); + }); + + it("rejects a cross-origin ?capsule= value (client-side SSRF)", () => { + for (const u of ["//evil.test/c.json", "https://evil.test/c.json", "data:,x", "capsule.json"]) { + expect(isSameOriginCapsuleUrl(u)).toBe(false); + } + expect(isSameOriginCapsuleUrl("/capsule")).toBe(true); + }); +}); diff --git a/packages/inspector/vite.config.ts b/packages/inspector/vite.config.ts index 5a8b1fc3..9d0debf0 100644 --- a/packages/inspector/vite.config.ts +++ b/packages/inspector/vite.config.ts @@ -4,18 +4,33 @@ // time-travel timeline + message-sequence diagram. No backend. // // The `@sx4im/chronos-core` alias points at core's SOURCE so a dev/build never depends -// on core being pre-built — and in practice the inspector only imports core -// TYPES, so they're elided from the bundle entirely. No node polyfills needed. +// on core being pre-built. The inspector must import core for TYPES ONLY — those +// are elided, so no node polyfills are needed. Importing a runtime VALUE from +// core instead pulls in core's whole barrel, including `env.ts`'s +// `node:async_hooks`, which does not exist in a browser and fails the build. +// `__CHRONOS_VERSION__` below exists so displaying the version does not require +// that import. import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Read core's version at build time — the same single source of truth +// CHRONOS_VERSION is pinned to — rather than hardcoding it in the UI, which is +// how it came to read "v0.0.0" long after the packages shipped 0.1.5. +const corePkg = JSON.parse( + readFileSync(path.resolve(__dirname, "../core/package.json"), "utf8"), +) as { version: string }; + export default defineConfig({ plugins: [react()], + define: { + __CHRONOS_VERSION__: JSON.stringify(corePkg.version), + }, resolve: { alias: { "@sx4im/chronos-core": path.resolve(__dirname, "../../packages/core/src/index.ts"), @@ -23,6 +38,10 @@ export default defineConfig({ }, build: { outDir: "dist", - sourcemap: true, + // Source maps are published alongside the bundle on the hosted Inspector, + // which hands anyone the full unminified source tree and any comment or + // path in it. Keep them for local debugging (`vite build` during dev, or an + // explicit CHRONOS_SOURCEMAP=1) and off for the deployed build. + sourcemap: process.env.CHRONOS_SOURCEMAP === "1" || process.env.NODE_ENV === "development", }, }); diff --git a/packages/vitest/src/capsule.ts b/packages/vitest/src/capsule.ts index d0da2955..e7cea4f9 100644 --- a/packages/vitest/src/capsule.ts +++ b/packages/vitest/src/capsule.ts @@ -14,7 +14,7 @@ // embeds the offending file bytes, which on `chronos replay ` // would disclose that file's contents via the error string. -import { mkdir, writeFile, readFile, rename } from "node:fs/promises"; +import { mkdir, writeFile, readFile, rename, stat, unlink } from "node:fs/promises"; import { dirname, join, basename } from "node:path"; import { CHRONOS_VERSION } from "@sx4im/chronos-core"; import type { Simulator, NetworkConfig, ChaosConfig, Trace } from "@sx4im/chronos-core"; @@ -59,6 +59,23 @@ const MAX_VERSION = 64; // chronosVersion field — bound it so a malformed caps // can't ship a multi-GB string as a trivial memory DoS. const MAX_STEPS = 10_000_000; // matches the engine's safe upper bound const MAX_EVENTS = 2_000_000; // bounds the parse; the replay cap is maxSteps anyway +const MAX_SUMMARY = 4096; // per-event `summary`/`detail`; the writer caps summaries at 80 + +/** Hard ceiling on a capsule file, enforced BEFORE the bytes are read. Every + * other bound in this file is a post-parse check, which is too late: `readFile` + * materializes the whole file and `JSON.parse` materializes the whole object + * graph, so a 4 GB `.json` handed to `chronos trace` is an OOM crash long + * before `MAX_EVENTS` gets a say. A real capsule is a few MB at the 2M-event + * ceiling; 128 MB leaves two orders of magnitude of headroom. + * `CHRONOS_MAX_CAPSULE_BYTES` raises it for a deliberately huge local capsule. */ +const DEFAULT_MAX_CAPSULE_BYTES = 128 * 1024 * 1024; + +function maxCapsuleBytes(): number { + const raw = process.env.CHRONOS_MAX_CAPSULE_BYTES; + if (raw === undefined || !/^\d{1,19}$/.test(raw)) return DEFAULT_MAX_CAPSULE_BYTES; + const n = Number(raw); + return Number.isSafeInteger(n) && n > 0 ? n : DEFAULT_MAX_CAPSULE_BYTES; +} function inUnitInterval(x: unknown, label: string): void { if (!isFiniteNum(x) || (x as number) < 0 || (x as number) > 1) { @@ -95,14 +112,128 @@ function validateChaos(c: unknown): asserts c is Required { inUnitInterval(o.maxCrashFraction, "chaos.maxCrashFraction"); } +function isBoundedString(x: unknown, max: number): x is string { + return typeof x === "string" && x.length <= max; +} + +/** Validate one `trace.events` entry. + * + * Trace events are never re-scheduled — replay rebuilds them from the seed — + * so a malformed event cannot poison a run. It can, however, poison every + * CONSUMER of the trace: `chronos trace` interpolates `ev.summary` into a + * terminal line, `chronos export` writes it into a CSV cell, and the Inspector + * renders it into the DOM. Those readers all assume the discriminated union in + * `@sx4im/chronos-core`'s `TraceEvent` actually holds, and an event whose + * `groups` is a string or whose `kind` is unknown crashes them with a raw + * stack trace. Validating the union here — once, at the trust boundary — is + * what lets every downstream renderer stay simple. */ +function validateEvent(e: unknown, i: number): void { + const at = `\`trace.events[${i}]\``; + if (typeof e !== "object" || e === null || Array.isArray(e)) { + fail(`${at} is not an object`); + } + const ev = e as Record; + if (!isFiniteNum(ev.t)) fail(`${at}.t must be a finite number`); + if (!isFiniteInt(ev.seq)) fail(`${at}.seq must be an integer`); + + const nodeId = (label: string): void => { + if (!isBoundedString(ev.nodeId, MAX_NODE_ID)) { + fail(`${at}.nodeId must be a string (max ${MAX_NODE_ID} chars) for kind "${label}"`); + } + }; + const endpoints = (label: string): void => { + if (!isBoundedString(ev.from, MAX_NODE_ID) || !isBoundedString(ev.to, MAX_NODE_ID)) { + fail(`${at}.from/.to must be strings (max ${MAX_NODE_ID} chars) for kind "${label}"`); + } + if (!isBoundedString(ev.summary, MAX_SUMMARY)) { + fail(`${at}.summary must be a string (max ${MAX_SUMMARY} chars) for kind "${label}"`); + } + }; + + switch (ev.kind) { + case "timer": + // The only kind with an optional nodeId (a global timer has none). + if (ev.nodeId !== undefined && !isBoundedString(ev.nodeId, MAX_NODE_ID)) { + fail(`${at}.nodeId must be a string (max ${MAX_NODE_ID} chars) when present`); + } + return; + case "wake": + case "crash": + case "restart": + nodeId(ev.kind); + return; + case "send": + case "deliver": + endpoints(ev.kind); + return; + case "partition": { + if (!Array.isArray(ev.groups) || ev.groups.length > MAX_NODES) { + fail(`${at}.groups must be an array of at most ${MAX_NODES} groups`); + } + for (const g of ev.groups as unknown[]) { + if (!Array.isArray(g) || g.length > MAX_NODES) { + fail(`${at}.groups entries must be arrays of at most ${MAX_NODES} node ids`); + } + for (const n of g as unknown[]) { + if (!isBoundedString(n, MAX_NODE_ID)) { + fail(`${at}.groups node ids must be strings (max ${MAX_NODE_ID} chars)`); + } + } + } + if (!isFiniteNum(ev.healAt)) fail(`${at}.healAt must be a finite number`); + return; + } + case "invariant-violation": + if (!isBoundedString(ev.name, MAX_NAME)) { + fail(`${at}.name must be a string (max ${MAX_NAME} chars)`); + } + if (!isBoundedString(ev.detail, MAX_DETAIL)) { + fail(`${at}.detail must be a string (max ${MAX_DETAIL} chars)`); + } + return; + default: + fail(`${at}.kind is not a known trace event kind`); + } +} + +/** Validate the `trace` envelope AND every event in it. The envelope fields + * (`seed`, `nodes`, `result`) are what `chronos trace`/`stats`/`export` and the + * Inspector's header read directly; before this check a capsule that merely + * omitted `trace.nodes` crashed `chronos trace` with a TypeError. */ +function validateTrace(t: unknown): void { + if (typeof t !== "object" || t === null || Array.isArray(t)) { + fail("`trace` is not an object"); + } + const tr = t as Record; + if (typeof tr.seed !== "string" || !RE_SEED.test(tr.seed)) { + fail("`trace.seed` must be a decimal integer string"); + } + if (!Array.isArray(tr.nodes) || tr.nodes.length > MAX_NODES) { + fail(`\`trace.nodes\` must be an array of at most ${MAX_NODES} strings`); + } + for (let i = 0; i < tr.nodes.length; i++) { + if (!isBoundedString(tr.nodes[i], MAX_NODE_ID)) { + fail(`\`trace.nodes[${i}]\` must be a string (max ${MAX_NODE_ID} chars)`); + } + } + if (tr.result !== "ok" && tr.result !== "violation") { + fail('`trace.result` must be "ok" or "violation"'); + } + if (!Array.isArray(tr.events) || tr.events.length > MAX_EVENTS) { + fail(`\`trace.events\` must be an array (max ${MAX_EVENTS} entries)`); + } + for (let i = 0; i < tr.events.length; i++) validateEvent(tr.events[i], i); +} + /** Strictly validate an already-parsed capsule object. Returns the object typed * as a `FailureCapsule`; throws `InvalidCapsule` (content-free message) on any * shape/range violation. The fields the Simulator reads (`seed`, `nodes`, * `maxSteps`, `config.network`, `config.chaos`) are the load-bearing checks: * a NaN/Infinity/negative there is the scheduler-corruption / silent-chaos-off - * path (B2). `trace.events` is bounded but not deeply validated — entries there - * are never re-scheduled (they're compared for reproduction only), so they - * cannot poison the run. */ + * path (B2). The `trace` envelope and every event in it are validated too — + * not because they can poison a run (they are never re-scheduled) but because + * every renderer downstream (CLI trace/stats/export, the Inspector) treats the + * `TraceEvent` union as a guarantee. */ export function validateCapsule(obj: unknown): FailureCapsule { if (typeof obj !== "object" || obj === null || Array.isArray(obj)) { fail("capsule root is not a JSON object"); @@ -150,14 +281,7 @@ export function validateCapsule(obj: unknown): FailureCapsule { fail(`\`invariant.detail\` must be a string (max ${MAX_DETAIL} chars)`); } - const trace = c.trace; - if (typeof trace !== "object" || trace === null || Array.isArray(trace)) { - fail("`trace` is not an object"); - } - const traceRec = trace as Record; - if (!Array.isArray(traceRec.events) || traceRec.events.length > MAX_EVENTS) { - fail(`\`trace.events\` must be an array (max ${MAX_EVENTS} entries)`); - } + validateTrace(c.trace); // After validation the object satisfies the FailureCapsule shape; the // chronosVersion is optional (older capsules omitted it) and defaulted here. @@ -171,7 +295,7 @@ export function validateCapsule(obj: unknown): FailureCapsule { config: config as { network: NetworkConfig; chaos: Required }, maxSteps, invariant: invRec as { name: string; detail: string }, - trace: trace as Trace, + trace: c.trace as Trace, }; } @@ -219,9 +343,35 @@ export async function writeCapsuleTo(path: string, capsule: FailureCapsule): Pro }); const json = JSON.stringify(capsule, null, 2); // Same directory as the final file → same filesystem → `rename` is atomic. - const tmp = `${path}.${process.pid}.tmp`; - await writeFile(tmp, json, "utf8"); - await rename(tmp, path); + // + // The temp name must be UNPREDICTABLE and the create must be EXCLUSIVE. With + // a name derived only from the pid, anyone who can write the capsule + // directory (a shared CI workspace, a world-writable output dir) can + // pre-create that path as a symlink to a file they want clobbered; a plain + // `writeFile` follows the symlink and writes through it with the running + // user's privileges. `wx` makes the open fail if the path exists at all — + // symlink included — and `0o600` keeps the capsule unreadable by other users + // in the window before the rename. + const tmp = `${path}.${process.pid}.${nextTmpSuffix()}.tmp`; + await writeFile(tmp, json, { encoding: "utf8", flag: "wx", mode: 0o600 }); + try { + await rename(tmp, path); + } catch (e) { + await unlink(tmp).catch(() => { + /* best effort — the rename failure is the real error */ + }); + throw e; + } +} + +// A process-local counter for temp-file names. Deliberately NOT `Math.random()`: +// this is harness code, but the prime directive's "one entropy source" rule is +// easier to keep honest when there is simply no `Math.random()` anywhere outside +// `real.ts`. Uniqueness within a pid is all `wx` needs to avoid a self-collision. +let tmpCounter = 0; +function nextTmpSuffix(): string { + tmpCounter = (tmpCounter + 1) >>> 0; + return tmpCounter.toString(36); } /** Write a capsule to `/failures/.json` and return its path. @@ -243,12 +393,40 @@ export async function writeCapsule( * content-free message — never the raw `JSON.parse` SyntaxError, which embeds * file bytes and would disclose e.g. `/etc/passwd` on a misdirected path. */ export async function readCapsule(path: string): Promise { + const limit = maxCapsuleBytes(); + const info = await stat(path); + if (info.size > limit) { + throw new InvalidCapsule( + `capsule file is larger than the ${limit}-byte limit (set CHRONOS_MAX_CAPSULE_BYTES to raise it)`, + ); + } const data = await readFile(path, "utf8"); let obj: unknown; try { - obj = JSON.parse(data); - } catch { + obj = JSON.parse(data, reviveSafely); + } catch (e) { + if (e instanceof InvalidCapsule) throw e; throw new InvalidCapsule("capsule is not valid JSON"); } return validateCapsule(obj); } + +// Keys that only ever appear in a capsule as a prototype-pollution attempt. A +// legitimate capsule has no use for any of them. +const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +/** `JSON.parse` reviver that refuses prototype-pollution keys outright. + * + * `JSON.parse('{"__proto__":{"isAdmin":true}}')` does not itself pollute + * anything — it creates a plain own property. The danger is downstream: the + * parsed `config`/`trace` objects are spread, merged, and passed into the + * Simulator, and any one of those steps performed with `Object.assign` or a + * hand-written deep merge WOULD trigger the `__proto__` setter and mutate + * `Object.prototype` for the whole process. Dropping the keys at the parse + * boundary means no future refactor downstream can reintroduce that path. */ +function reviveSafely(this: unknown, key: string, value: unknown): unknown { + if (FORBIDDEN_KEYS.has(key)) { + throw new InvalidCapsule(`capsule contains a forbidden \`${key}\` key`); + } + return value; +} diff --git a/packages/vitest/src/shrinkRunner.ts b/packages/vitest/src/shrinkRunner.ts index fbba8244..dd8dc187 100644 --- a/packages/vitest/src/shrinkRunner.ts +++ b/packages/vitest/src/shrinkRunner.ts @@ -1,6 +1,6 @@ // Candidate simulator runner and greedy/binary search shrink algorithm for Failure Shrinking. -import { Simulator, type NetworkFactory } from "@sx4im/chronos-core"; +import { Simulator, StrictModeViolation, type NetworkFactory } from "@sx4im/chronos-core"; import type { FailureCapsule, SimTestBody } from "./types.js"; import { executeScenario } from "./engine.js"; @@ -71,7 +71,14 @@ export async function runCandidate( return { reproduced: true, sim, violation }; } return { reproduced: false }; - } catch { + } catch (e) { + // "The scenario threw" is a legitimate shrink outcome: a candidate config + // may drive the SUT down a path where an assertion or a scenario-level + // precondition fails, and that is simply "did not reproduce the recorded + // invariant". What must NOT be swallowed is a fault in the harness itself — + // a strict-mode entropy leak or an out-of-memory abort — because reporting + // that as "already minimal" hides a broken run behind a confident answer. + if (e instanceof StrictModeViolation || e instanceof RangeError) throw e; return { reproduced: false }; } } diff --git a/packages/vitest/test/fixtures/capsules/counter-0.json b/packages/vitest/test/fixtures/capsules/counter-0.json index d9bb443c..fd38f356 100644 --- a/packages/vitest/test/fixtures/capsules/counter-0.json +++ b/packages/vitest/test/fixtures/capsules/counter-0.json @@ -1,5 +1,5 @@ { - "chronosVersion": "0.0.0", + "chronosVersion": "0.1.5", "seed": "0", "nodes": [ "node-0", diff --git a/packages/vitest/test/security.test.ts b/packages/vitest/test/security.test.ts index 7999984a..747ada6e 100644 --- a/packages/vitest/test/security.test.ts +++ b/packages/vitest/test/security.test.ts @@ -27,6 +27,7 @@ import { InvalidCapsule, readCapsule, writeCapsule, + writeCapsuleTo, runSimTest, resolveSeeds, type SimTestBody, @@ -194,6 +195,114 @@ describe("validateCapsule — rejects malformed shape (B1/B2)", () => { }); }); +// Everything that renders a capsule — chronos trace/stats/export, the Inspector +// — treats core's TraceEvent union as a guarantee. Nothing enforced it, so a +// capsule that merely omitted `trace.nodes` crashed `chronos trace` with a raw +// TypeError, and an event with an unknown `kind` white-screened the Inspector. +// The union is a trust boundary; it is checked here so renderers stay simple. +describe("validateCapsule — trace envelope (renderer crash surface)", () => { + it("rejects a trace missing the fields every renderer reads", () => { + rej((c) => delete asMap(c.trace).nodes, /trace\.nodes/); + rej((c) => delete asMap(c.trace).seed, /trace\.seed/); + rej((c) => delete asMap(c.trace).result, /trace\.result/); + rej((c) => (asMap(c.trace).result = "maybe"), /trace\.result/); + rej((c) => (asMap(c.trace).nodes = [42]), /trace\.nodes\[0\]/); + }); + + it("accepts a well-formed trace envelope", () => { + expect(validateCapsule(base()).trace.result).toBe("violation"); + }); +}); + +describe("validateCapsule — per-event shape", () => { + const withEvents = (events: unknown[]) => (c: Record) => { + asMap(c.trace).events = events; + }; + + it("rejects an unknown event kind", () => { + rej(withEvents([{ t: 0, seq: 0, kind: "exec" }]), /kind is not a known/); + }); + + it("rejects an event with a non-numeric t or seq", () => { + rej(withEvents([{ t: "0", seq: 0, kind: "timer" }]), /\.t must be a finite number/); + rej(withEvents([{ t: 0, seq: 1.5, kind: "timer" }]), /\.seq must be an integer/); + rej(withEvents([{ t: Infinity, seq: 0, kind: "timer" }]), /\.t must be a finite number/); + }); + + it("rejects a send/deliver missing its endpoints or summary", () => { + rej(withEvents([{ t: 0, seq: 0, kind: "send", from: "a", to: "b" }]), /summary/); + rej(withEvents([{ t: 0, seq: 0, kind: "deliver", from: "a", summary: "s" }]), /from\/\.to/); + }); + + it("rejects a partition whose groups are not arrays of node ids", () => { + // Math.min(...[]) is Infinity and `"abc".flat` is not a function — both are + // renderer crashes in the Inspector's sequence diagram. + rej(withEvents([{ t: 0, seq: 0, kind: "partition", groups: "ab", healAt: 5 }]), /groups/); + rej(withEvents([{ t: 0, seq: 0, kind: "partition", groups: [["a"], [7]], healAt: 5 }]), /groups/); + rej(withEvents([{ t: 0, seq: 0, kind: "partition", groups: [["a"]] }]), /healAt/); + }); + + it("accepts every legitimate event kind", () => { + const c = base(); + asMap(c.trace).events = [ + { t: 0, seq: 0, kind: "timer" }, + { t: 0, seq: 1, kind: "timer", nodeId: "a" }, + { t: 1, seq: 2, kind: "wake", nodeId: "a" }, + { t: 2, seq: 3, kind: "send", from: "a", to: "b", summary: "ping" }, + { t: 3, seq: 4, kind: "deliver", from: "a", to: "b", summary: "ping" }, + { t: 4, seq: 5, kind: "crash", nodeId: "b" }, + { t: 5, seq: 6, kind: "restart", nodeId: "b" }, + { t: 6, seq: 7, kind: "partition", groups: [["a"], ["b"]], healAt: 20 }, + { t: 7, seq: 8, kind: "invariant-violation", name: "inv", detail: "d" }, + ]; + expect(validateCapsule(c).trace.events).toHaveLength(9); + }); +}); + +describe("readCapsule — resource and pollution guards", () => { + it("refuses a file over the size limit before reading it", async () => { + // MAX_EVENTS is a POST-parse bound: readFile and JSON.parse each + // materialize the whole file first, so an oversized capsule is an OOM + // crash long before any post-parse bound applies. + const dir = freshDir(); + const p = join(dir, "huge.json"); + writeFileSync(p, JSON.stringify(base())); + const prev = process.env.CHRONOS_MAX_CAPSULE_BYTES; + process.env.CHRONOS_MAX_CAPSULE_BYTES = "10"; // smaller than any real capsule + try { + await expect(readCapsule(p)).rejects.toThrow(/larger than the 10-byte limit/); + } finally { + if (prev === undefined) delete process.env.CHRONOS_MAX_CAPSULE_BYTES; + else process.env.CHRONOS_MAX_CAPSULE_BYTES = prev; + } + }); + + // JSON.parse alone would not pollute anything, but the parsed config and + // trace are spread and merged downstream, where an Object.assign or a + // hand-rolled deep merge WOULD trigger the setter. + it.each(["__proto__", "constructor", "prototype"])( + "rejects a capsule carrying a %s key", + async (key) => { + const dir = freshDir(); + const p = join(dir, `${key.replace(/_/g, "")}.json`); + const raw = JSON.stringify(base()).replace(/^\{/, `{${JSON.stringify(key)}:{"polluted":true},`); + writeFileSync(p, raw); + await expect(readCapsule(p)).rejects.toThrow(new RegExp(key)); + expect(({} as Record).polluted).toBeUndefined(); + }, + ); + + it("writes the capsule with owner-only permissions", async () => { + const dir = freshDir(); + const p = join(dir, "perm.json"); + await writeCapsuleTo(p, validateCapsule(base())); + // The temp file is created exclusively at 0600 so a pre-planted symlink in + // a shared output directory cannot be written through. + expect(existsSync(p)).toBe(true); + expect(readdirSync(dir).filter((f) => f.endsWith(".tmp"))).toHaveLength(0); + }); +}); + describe("readCapsule — content-free errors (content disclosure)", () => { // Node's JSON.parse SyntaxError embeds the offending file bytes ("Unexpected // token 'r', \"root:x:0:0:…"). On `chronos replay /etc/passwd` that would diff --git a/vercel.json b/vercel.json index 8abfcc95..dbd33ec1 100644 --- a/vercel.json +++ b/vercel.json @@ -9,5 +9,29 @@ "source": "/(.*)", "destination": "/index.html" } + ], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src 'self'; img-src 'self' data:; font-src 'self' data: https://fonts.gstatic.com; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'" + }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "Referrer-Policy", "value": "no-referrer" }, + { + "key": "Strict-Transport-Security", + "value": "max-age=63072000; includeSubDomains; preload" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()" + }, + { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, + { "key": "X-DNS-Prefetch-Control", "value": "off" } + ] + } ] }