diff --git a/.coderabbit.yaml b/.coderabbit.yaml index b51482147..c9a9f7f40 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -17,7 +17,8 @@ reviews: poem: false in_progress_fortune: false auto_review: - enabled: true + # Owner, 2026-10-27: no hosted review on GitHub from Cycle 0057; the local CodeRabbit CLI reviews each cycle (AGENTS.md Delivery). + enabled: false auto_incremental_review: true auto_pause_after_reviewed_commits: 0 drafts: false diff --git a/AGENTS.md b/AGENTS.md index b86819415..865e9167a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,7 @@ Driving agent owns state, cycle selection, completion claims; GStack supplies wo - Per cycle: local CodeRabbit CLI + `open-code-review-delegate` → `/ship` as PR → required CI → separate exact-HEAD gate → merge as its own step with `--match-head-commit`. - A large cycle may use up to three PRs (`cycle100-pt1`, `cycle100-pt2`, …), each passing reviews, CI and gate alone, merged in order; the Issue closes after the last. - Codex automatic review off (no Codex cloud step). No `@coderabbitai ignore` in PR bodies (owner, 2026-10-05). +- Hosted CodeRabbit off from Cycle 0057 (owner, 2026-10-27: 「次のサイクルからGitHubのコード、Rabbitのレビューはトリガーさせないでください。」): `.coderabbit.yaml` keeps `reviews.auto_review.enabled: false` (CodeRabbit reads the PR branch's file); never post `@coderabbitai review`. **Delivery parameters.** @@ -102,7 +103,7 @@ Driving agent owns state, cycle selection, completion claims; GStack supplies wo - Third cycle still has findings (`converged:false`) → the owner's 2026-10-23 rule, feature and bug-fix cycles alike: fix every last-round finding, Minor and below included (Critical always fixed before merge, per the delivery rules); record only still-open Critical/Major defects in a GitHub Issue (next cycle's tasks); merge after required CI + exact-HEAD gate, reporting `converged:false` in the PR. This overrides no-carry for those recorded defects only. - The limit is `/ship`'s alone (owner, 2026-10-23): one round is long; full convergence would stall delivery. It records defects of `/ship` rounds the PR cannot close (e.g. a Major finding whose fix/proof needs an unknown root cause or a native check). - A Critical finding never merges open: one the PR cannot close holds the merge until fixed (cycle + delivery rules). -- Local CodeRabbit CLI and hosted CodeRabbit: no round limit; their findings follow the cycle rule at every review, as do gate, CI and native-check failures. +- Local CodeRabbit CLI: no round limit; its findings follow the cycle rule at every review, as do gate, CI and native-check failures. **Skills** (read each installed SKILL.md at invocation; this map guides, copies no workflow): @@ -144,10 +145,7 @@ Driving agent owns state, cycle selection, completion claims; GStack supplies wo - [.coderabbit.yaml](.coderabbit.yaml) = policy + exclusions: keep its guidelines current; validate with `coderabbit config validate .coderabbit.yaml`; pass it via `--config` beside extra context. - Required: local CodeRabbit CLI, findings triage, CI, `open-code-review-delegate` (OCR picks files and rules, the host reviews; each eligible file reviewed or skipped with a reason; default exclusions reported apart; no independent external review). - Review against the implementation base; record completion, source cut, covered files, findings. -- Hosted CodeRabbit (triggered on each PR; owner, 2026-10-23): - - wait for it beside CI; fix its findings under the cycle rule every review, no round limit (ship limit is `/ship`'s alone); - - rate limit or failed trigger → report "not run", never APPROVED; continue with the local review (runs every cycle anyway); - - a PR it reviewed merges only after CodeRabbit approved its exact head (owner, 2026-10-23): follow installed `/coderabbit-resolver` (each finding fixed or dispositioned with evidence; threads resolved via GraphQL `resolveReviewThread`; outside-diff findings audited), then wait for the `coderabbitai[bot]` review on that head to read APPROVED (`request_changes_workflow` in `.coderabbit.yaml` approves once threads are resolved). CHANGES_REQUESTED, no approval or approval of an earlier head → merge held, beside required CI + exact-HEAD gate. +- Hosted CodeRabbit is not triggered (owner, 2026-10-27; [Delivery](#delivery)): merge waits for no hosted review or approval. A hosted review that still appears is reported in the PR; its findings follow the cycle rule like the local review's. Run the local CodeRabbit CLI on the PR's final range before merge. - Missing/private provider context = coverage limit. - Every user-visible behavior a CHANGELOG entry names needs a sentence in the packaged README (`extension/README.md`), as `.coderabbit.yaml`'s `CHANGELOG.md` instruction checks. A fix changing user-visible behavior changes that README in the same commit; fixers' briefs say so. - Claude Code CLI review: owner-skipped; no native chat acceptance. @@ -211,10 +209,11 @@ Standing rules, stated as rules (no decision log); delivery chain and ship limit ## Development target - Until nine-project final acceptance starts, actual-editor trials use the owner-designated `nextjs-dashboard` (same repository and revision): Mac **`/Users/ryotamurakami/repository/nextjs-dashboard`**; its macOS Space **`/Users/lume/repository/nextjs-dashboard`**; Windows host **`/home/murakami/laststance/nextjs-dashboard`**. +- Exception (owner, 2026-10-27): the Node server execution form, which `nextjs-dashboard` lacks, is trialled on a pinned clone of P08 `laststance/switch-time` in the Space (`/Users/lume/qa/c57/switch-time`, revision recorded in the cycle Issue), with its documented local stack. Development evidence only; no final-row credit. - Never an easier repository, never a tenth acceptance target. Recheck its revision, settings, dirty state; tell the owner before occupying the desktop; record unsupported behavior and missing setup. Docs-only work needs no native launch. - Development results earn no nine-project credit, fill no final row. Final acceptance freezes its own targets and candidate; Stage 1 starts only when its prerequisites hold. - Use real opened repositories; isolated inputs verify mechanisms only, never as bundled demos or practical trials. Retired local trial resources are removed; old records in Git are no current proof. -- Development chats: VS Code = Copilot Chat, model **GPT6 Luna** (owner, 2026-10-21: the cheaper model); Cursor = Agent, model **Grok 4.6 high**, Fast on allowed (owner, 2026-10-24 JST: 「これからGrok4.6Hiのファストオンで大丈夫ですよ。」); after observed credit exhaustion (either host) = the installed **Codex Extension** (Claude Code extension fallback retired). +- Development chats: VS Code = Copilot Chat, model **GPT6 Luna** (owner, 2026-10-21: the cheaper model; 2026-10-27: switch to GPT-6.1 Terra or GPT-6.1 Luna once the picker lists one; search the picker for `terra` and `6.1` before each VS Code trial, absent on 2026-10-27); Cursor = Agent, model **Grok 4.6 high**, Fast on allowed (owner, 2026-10-24 JST: 「これからGrok4.6Hiのファストオンで大丈夫ですよ。」); after observed credit exhaustion (either host) = the installed **Codex Extension** (Claude Code extension fallback retired). - Model missing from the picker → that host's trial is NOT RUN, picker list recorded. - Record the actual chat and fallback reason; buy no credits; never label fallback evidence Copilot. - Final acceptance has its own standard-chat rules. Historical Claude extension observations keep their identities. diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d2b4c66..ae50b144b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [0.2.50.0] - 2026-10-27 (development candidate) + +Cycle 0057 ([Issue #139](https://github.com/laststance/codebook/issues/139)), a feature cycle, first of its three pull requests: one redaction contract masks credentials in what browser and test runs capture, before any of it reaches the chat, a saved history or Output › Codebook. The parts of the coming Node server profile (its declaration, launch and listener check) arrive here unexposed: no profile, tool or help offers them yet. Tests cover this part; no native editor trial ran on it. + +**Masked values.** + +- **Secret-named values read `[masked]`**: a variable, property or header whose name ends in a word such as `password`, `passphrase`, `pwd`, `secret`, `token`, `jwt`, `authorization`, `cookie`, `credential`, `session` or `sid`, in a key named for a secret use (`apiKey`, `privateKey`, `AWS_SECRET_ACCESS_KEY`, `signingKey`), or in `sessionId`, is masked with everything inside it, also inside an object's preview (`{apiKey: [masked], host: 'db'}`, including a member previewed as `(2) [...]`); `tokenCount`, `sortKey`, `publicKey`, `primaryKey` and `passwordPolicy` stay visible. A type inspection shows the type and keeps no real value. +- **Credential-shaped text reads `[masked]`** in captured values, debugger snapshots, test output, the development server's log, failure causes and Output › Codebook: a password in a URL (the user name stays), a JWT, a `Bearer` token, `sk-`, Stripe `sk_`/`rk_`, `ghp_`, `github_pat_`, Slack `xox…-`, Google `AIza`, `npm_`, AWS `AKIA` and `ASIA` keys and a private-key block, also when the output splits one across chunks; text that cannot be a secret is released at once, so output keeps its order around a debugger stop. +- **What is not masked**: Codebook has no detector for secret content, so a secret in a plainly named variable or in ordinary text stays visible; public keys and certificates stay visible; the editor's own debugger still shows you the real values. +- **Saved material is unchanged**: values captured after this update may read `[masked]` where they showed a real value before; studies and replays saved earlier are not rewritten. + +**Compatibility.** The MCP API stays 1.8 and the bundled skill 1.0.50. `codebook://help/errors` lists one new code, `NODE_SERVER_SOURCE_REFUSED`, which no available profile raises yet. Stored learner data, the Wiki's published format and the marker format do not change. + ## [0.2.49.0] - 2026-10-27 (development candidate) Cycle 0056 ([Issue #135](https://github.com/laststance/codebook/issues/135)), a bug-fix cycle, last of its three pull requests: a saved test replay opens in Cursor when it is chosen, the Saved studies list and the Learning summary say what is true around **Codebook: Close Study Workspace**, and the LLM Wiki view follows its folder, trusts through the editor's own command and changes its message with its rows. The native trial N56 checked part of this in the real editors, on this part's development candidates (`02acefaa`, `7ff3ab57`, `932cdae3`), not on the released build: VS Code 1.142.0 and Cursor 3.27.12 on macOS 26 (a virtual machine), with the development target `nextjs-dashboard`. It covered the test replay's opens and the Saved studies list after Close in Cursor, the trust command in both editors, and the view's message and rows and a deleted Wiki folder in VS Code; the other changes are covered by tests only. Each candidate was packaged from its own commit; the target's QA clone carried QA-owned edits that the Issue names, and the designated checkout none. N56 did not run again on the released source, which adds the review fixes made after `932cdae3`. Each step's host, candidate and result, and the hashes of its recordings (kept private), are in [Issue #135's comments](https://github.com/laststance/codebook/issues/135#issuecomment-6431339240). diff --git a/VERSION b/VERSION index 36647e2fd..cfc74af8b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.49.0 +0.2.50.0 diff --git a/extension/README.md b/extension/README.md index ec08f2f0c..0639f0178 100644 --- a/extension/README.md +++ b/extension/README.md @@ -511,6 +511,16 @@ A configured Study retains one article item with its ordered participant-specifi The run resource's `operationProgress` distinguishes accepted input from `inputDispatched`, `inputAcknowledged`, exact `continuedPause` and `afterVerified`; these transitions also invalidate older saved checkpoints. Study reports observation history and recording persistence separately. **Observation saved** does not certify **Recording saved**. A recording-context failure offers **Retry storage / refresh** without repeating application input. After restart, recording stays unverified until its saved evidence is opened and validated. +## Masked values + +Codebook masks credentials before a browser or test run's values reach the chat, a saved history or the Output › Codebook channel. A masked value reads `[masked]`. + +- **Masked by name.** A variable, property or header whose name ends in the word `password`, `passwd`, `passphrase`, `pwd`, `pass`, `secret`, `token`, `jwt`, `authorization`, `cookie`, `credential`, `session` or `sid` (plural too), in a key named for a secret use (`api`, `private`, `secret`, `access`, `signing`, `encryption`, `master`, `client`, `auth` or `session` key), or in `session id`, is masked together with everything inside it. Names are read as words in camelCase, snake_case and kebab-case: `accessToken`, `db_passwd`, `set-cookie`, `apiKey`, `AWS_SECRET_ACCESS_KEY` and `sessionId` are masked; `tokenCount`, `sortKey`, `publicKey`, `primaryKey` and `passwordPolicy` stay visible. An object's preview shows such a member as `[masked]` too (`{apiKey: [masked], host: 'db'}`), also when the preview writes it with a length or type first (`(2) [...]`, `Object {...}`). A type inspection shows the type and keeps no real value. +- **Masked by shape.** In any captured text, a password in a URL (`scheme://user:[masked]@host`; the user name stays), a JWT, a `Bearer` token, an `sk-` key, a Stripe `sk_`/`rk_` live or test key, a `ghp_` or `github_pat_` token, a Slack `xox…-` token, a Google `AIza` key, an `npm_` token, an `AKIA` or `ASIA` AWS key and a private-key block (`-----BEGIN … PRIVATE KEY-----` to its end) are replaced. Test output, the development server's log and errors are masked the same way, also when a secret arrives split across chunks, before any text is cut or stored. Only text that may still turn out to be a secret waits for the rest of its output; other text is released at once, so output keeps its order around a debugger stop. +- **Not masked.** Codebook has no detector for secret content: a secret held in a plainly named variable or printed as ordinary text stays visible. Public keys and certificates stay visible. +- **The native debugger is unchanged.** It is the editor's own and still shows you the real values. +- **Updating.** Values captured after this update may read `[masked]` where they showed a real value before. Studies and replays saved earlier are not rewritten. + ## Study workspace Codebook contributes `debug.focusEditorOnBreak: false` as the native debugger default, so a breakpoint can select its source and values without moving the keyboard out of the chat. This default applies to native debugging throughout editors where Codebook is enabled. At a stop Codebook did not cause (a step or a continue of yours, a refresh of the debug adapter, or a debug session that is not a Codebook run), Codebook gives the paused file the keyboard once, where the editor would without this default: only at the frame the editor's own stop handling focuses, or when the editor opens that frame's source for the stop. A stop the editor keeps in the background, or one at which it focuses no frame (every frame skipped as library code), moves the keyboard nowhere. Codebook also does nothing while a recording is open or when you set the setting yourself. Existing explicit User and Workspace preferences take precedence; Codebook writes no settings file. Set `debug.focusEditorOnBreak` to `true` in the ordinary editor settings if you prefer the debugger to focus source at a break. Disabling or removing Codebook removes its default contribution. @@ -559,6 +569,7 @@ Bounded installed development trials in VS Code 1.139.1 and Cursor 3.23.5 ([Cycl | A completed run shows `resuming` | Its final save of events and run record is under way: until it settles, the run has no result and no `observed`, and a failed save fails the run. Read the run again. | | Missing prerequisite | Status names the dependency/configuration key. Correct it before deliberately starting another study. | | Session capacity | A ninth client replaces the least recently seen idle session; `429` with `MCP session limit exceeded; every session has a request in flight. Retry after one completes.` is answered only while all eight have a request in flight: retry then. The replaced client's next request is answered `404` with `Unknown MCP session. Initialize a new transport, then read codebook_status.` A client `DELETE` releases its transport only. It does not stop a study. | +| `NODE_SERVER_SOURCE_REFUSED` | `codebook://help/errors` lists it for the coming Node server profile; no available profile raises it yet. | GET `/mcp` returns 405; this service provides JSON POST responses and polled resources, with no standalone notification stream. The pinned SDK client supports this. Idle transport sessions expire after five minutes; live application execution has its own deadline. diff --git a/extension/package.json b/extension/package.json index 201b01347..7386bc90f 100644 --- a/extension/package.json +++ b/extension/package.json @@ -3,7 +3,7 @@ "displayName": "codebook", "description": "Connect an external agent to source-grounded codebase studies.", "publisher": "laststance", - "version": "0.2.49", + "version": "0.2.50", "private": true, "engines": { "vscode": "^1.120.0", diff --git a/extension/scripts/tests/package.test.mjs b/extension/scripts/tests/package.test.mjs index 688fb043b..59b25af51 100644 --- a/extension/scripts/tests/package.test.mjs +++ b/extension/scripts/tests/package.test.mjs @@ -95,7 +95,7 @@ test('a real VSIX loads outside the checkout and excludes bundled applications a // Assert assert.equal(receipt.isolatedModuleLoad, true) assert.equal(receipt.isolatedWikiPageWorker, true) - assert.equal(checked.extension.version, '0.2.49') + assert.equal(checked.extension.version, '0.2.50') assert.equal(checked.extension.apiVersion, '1.8') assert.equal(checked.entries, 33) assert.equal(receipt.dependencies.length, 16) @@ -129,7 +129,15 @@ test('a real VSIX loads outside the checkout and excludes bundled applications a ) assert.ok( [...original.keys()].every( - (path) => !/(?:^|\/)(?:demo|fixture|experiments)(?:\/|$)/.test(path), + (path) => !/(?:^|\/)(?:demo|fixtures?|experiments)(?:\/|$)/.test(path), + ), + ) + // The node-server tests' HTTP server (src/tests/fixtures/node-server) never ships, under any folder name. + assert.ok( + [...original.keys()].every( + (path) => + !path.includes('node-server/server.') && + !path.endsWith('fixture-server.json'), ), ) for (const path of COMPILED_DIRECTORIES) { diff --git a/extension/src/constants.ts b/extension/src/constants.ts index f7ee5ea73..d285999b0 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -8,7 +8,7 @@ import { CAPTURE_TIMEOUT_MS } from './runtimeSupport/constants' * 1.8 changed node-test outcomes: a passing test whose history sealed complete reads `demonstrated` in `finalOutcome`, `studyOutcome` and `recordedStudyOutcome` (1.7 stopped at `partial`), its sealed history is a test replay (`recordingAvailable` and `replayAvailable`), and `nodeTest` adds `testResult`, `learningCoverage` and `learningMissing`. A node-test start's deterministic refusals arrive as the run's `error` before its first stop, a failed history seal makes `codebook_stop` answer `HISTORY_SEAL_FAILED`, and `codebook_playback` refuses a test replay with `PLAYBACK_PROFILE_UNSUPPORTED`. */ export const API_VERSION = '1.8' -export const EXTENSION_VERSION = '0.2.49' +export const EXTENSION_VERSION = '0.2.50' export const CONFIGURED_MINIMUM_EXTENSION_VERSION = '0.2.0' export const NODE_TEST_MINIMUM_EXTENSION_VERSION = '0.2.12' export const LOOPBACK_ADDRESS = '127.0.0.1' @@ -26,6 +26,10 @@ export const LARGEST_PROCESS_ID = 2 ** 31 - 1 export const PROCESS_START_QUERY_TIMEOUT_MS = 3_000 /** The most bytes the query for one process's start may print; a longer answer is no start. */ export const PROCESS_START_QUERY_MAX_BYTES = 1_024 +/** How long one query of a port's listening sockets (`lsof` or `ss`) may run before it is ended and the port's owner counts as unknown. */ +export const LISTENER_QUERY_TIMEOUT_MS = 3_000 +/** The most bytes one query of a port's listening sockets may print; a longer answer leaves the port's owner unknown. */ +export const LISTENER_QUERY_MAX_BYTES = 1_048_576 /** How far apart, in 100 ns ticks, a creation time the Windows process table names (Win32_Process CreationDate, to the microsecond) and a recorded start (to the 100 ns) may lie and still name one process: less than a microsecond ({@link readProcessStart}). */ export const WINDOWS_PROCESS_TABLE_PRECISION_TICKS = 10 /** How long a failed read of this process's own start is kept before the start is read again. */ @@ -1236,6 +1240,26 @@ export const BOOK_PREPARE_TIMEOUT_MS = 20_000 export const BOOK_SOURCE_CONCURRENCY = 4 export const REPOSITORY_BROWSER_PROFILE = 'repository-browser' export const NODE_TEST_PROFILE = 'node-test' +/** The lowest port a node-server declaration may name: a lower one needs privileges a development server does not run with, and Codebook never raises them. */ +export const NODE_SERVER_PORT_MIN = 1_024 +/** The highest TCP port. */ +export const TCP_PORT_MAX = 65_535 +/** The highest TCP port a node-server declaration may name. */ +export const NODE_SERVER_PORT_MAX = TCP_PORT_MAX +/** The longest request path, its query string included, a node-server declaration may send. */ +export const NODE_SERVER_REQUEST_PATH_LIMIT_CHARS = 2_048 +/** The largest request body, in UTF-8 bytes, a node-server declaration may send. */ +export const NODE_SERVER_REQUEST_BODY_LIMIT_BYTES = 16_384 +/** The most server output delivered but not yet settled; a server printing faster than its events settle fails its capture instead of buffering without bound. */ +export const NODE_SERVER_OUTPUT_PENDING_LIMIT_BYTES = 1_048_576 +/** How long a stopping server's debugger may take to detach before SIGTERM is sent anyway: a server handling SIGTERM waits for an attached debugger before it exits (Part A, Issue #139). */ +export const NODE_SERVER_DEBUGGER_DETACH_TIMEOUT_MS = 1_000 +/** How long a server's process group has after SIGTERM before SIGKILL, and again after SIGKILL before its close fails. */ +export const NODE_SERVER_SHUTDOWN_GRACE_MS = 5_000 +/** How much of an owned process's output is kept: its first bytes for the inspector announcement, its last ones as the redacted tail. */ +export const PROCESS_OUTPUT_LIMIT_BYTES = 32_768 +/** The longest text of one stamped output event of an owned process. */ +export const PROCESS_OUTPUT_EVENT_LIMIT_CHARS = 4_096 /** The command id of the status item's Save learning replay and its Retry; registered by the extension, run by a click. */ export const SAVE_LEARNING_REPLAY_COMMAND = 'codebook.saveLearningReplay' /** The command id of the status item's Open replay and Open retained history. */ @@ -1532,7 +1556,7 @@ export const REPOSITORY_APPLICATION_LOG_LIMIT_BYTES = 262_144 export const REPOSITORY_APPLICATION_EXIT_TIMEOUT_MS = 10_000 /** Next's own development port, used when the dev script declares no port. */ export const DEVELOPMENT_SERVER_DEFAULT_PORT = 3000 -export const DEVELOPMENT_SERVER_MAX_PORT = 65_535 +export const DEVELOPMENT_SERVER_MAX_PORT = TCP_PORT_MAX /** Bounds one loopback connect that asks whether something already listens on the project's port; a connect that times out proves nothing (Windows reports a refused loopback connect only after its SYN retries, 1 to 2 s), so the bind probe decides. */ export const DEVELOPMENT_PORT_PROBE_CONNECT_TIMEOUT_MS = 500 /** Bounds the wait for a port that Codebook's own earlier run is still releasing (its owned process group is exiting) before the start is refused as that run's. */ @@ -1878,3 +1902,21 @@ export const SUMMARY_CHOICE_DROPPED_REASON_COMPOSITION_LEFT = /** What the learner's notice for a dropped Learning summary choice starts with, before the reason. */ export const SUMMARY_CHOICE_DROPPED_NOTICE_PREFIX = 'Codebook did not carry out your choice: ' + +/** The text that replaces a secret value or a secret-named value in every captured fact, variable, snapshot, output and cause ({@link redactSecretText}). */ +export const REDACTION_MASK = '[masked]' + +/** The most characters of an incomplete trailing token or private-key block an output redactor holds back; past it the held part is released masked ({@link createOutputRedactor}). 8 KiB. */ +export const OUTPUT_REDACTION_HOLD_LIMIT_CHARS = 8_192 + +/** The characters kept back when an over-long run shows no secret shape is released, so a secret marker cut by the release is still seen whole ({@link createOutputRedactor}). */ +export const OUTPUT_REDACTION_CARRY_CHARS = 16 + +/** The characters of a dropped private-key body kept to find an END marker split across chunks ({@link createOutputRedactor}); longer than the longest END marker its bounded label allows (about 106 characters). */ +export const OUTPUT_REDACTION_END_MARKER_TAIL_CHARS = 128 + +/** How far into a debugger preview value its type name or length prefix is looked for ({@link redactValuePreview}); a longer type name is read as a bare value. */ +export const PREVIEW_VALUE_PREFIX_LIMIT_CHARS = 128 + +/** How much of an oversized private-key block an output redactor drops before it ends the block at the next line break, so a header without an END marker cannot silence the channel ({@link createOutputRedactor}); four times a 4096-bit RSA key's PEM. */ +export const OUTPUT_REDACTION_BLOCK_DROP_LIMIT_CHARS = 16_384 diff --git a/extension/src/errors.ts b/extension/src/errors.ts index 87cacdde6..5125d7914 100644 --- a/extension/src/errors.ts +++ b/extension/src/errors.ts @@ -49,6 +49,8 @@ export const ERROR_RECOVERY = { 'Correct the named configuration field against the selected immutable article and supported bounds before a new deliberate start. Reuse an accepted key only with identical input.', HISTORY_SEAL_FAILED: 'The run is stopped and its resources are released; nothing reruns. Do not call codebook_stop again and do not claim saved learning. Tell the learner the save failed; its recorded part, when readable, opens from Saved studies. Start a new run only if the learner asks.', + NODE_SERVER_SOURCE_REFUSED: + "Correct the field the message names so its real path lies inside the opened repository: packageRoot a folder, entry a file inside it, and for a TypeScript entry the repository's own tsx installed by the repository's install command. Nothing was started. Start again once with a new key after the fix; the same refusal again means the prerequisite is still unmet, so tell the learner what is missing instead of retrying.", PLAYBACK_PROFILE_UNSUPPORTED: 'codebook_playback controls browser recordings only. Do not start, continue or stop a run to open a test replay; ask the learner to open it.', } as const diff --git a/extension/src/nodeServerConfigSchemas.ts b/extension/src/nodeServerConfigSchemas.ts new file mode 100644 index 000000000..18c3f5a2e --- /dev/null +++ b/extension/src/nodeServerConfigSchemas.ts @@ -0,0 +1,149 @@ +import { z } from 'zod' + +import { + CONFIGURED_OBSERVATION_LIMIT, + NODE_SERVER_PORT_MAX, + NODE_SERVER_PORT_MIN, + REPOSITORY_CONFIG_LIMIT_BYTES, +} from './constants' +import { nodeTestStopSchema } from './nodeTestConfigSchemas' +import { + nodeTestStopInspectionsSchema, + repositoryBookSelectionSchema, + repositorySourceStopSchema, +} from './repositoryConfigSchemas' +import { repositoryPathSchema } from './sourceSchemas' +import { addStopInspectionIssues } from './utils/addStopInspectionIssues' +import { describeNodeServerRequestBodyIssues } from './utils/describeNodeServerRequestBodyIssues' +import { nodeServerRequestPathProblem } from './utils/nodeServerRequestPathProblem' + +/** The entry files node runs: TypeScript ones through the repository's own tsx, JavaScript ones directly. */ +const ENTRY_EXTENSION = /\.(?:ts|mts|cts|js|mjs|cjs)$/ +/** The entry extensions that need tsx, since plain node runs JavaScript only. */ +const TYPESCRIPT_ENTRY_EXTENSION = /\.(?:ts|mts|cts)$/ +const PORT_EXPECTATION = `expected an integer from ${NODE_SERVER_PORT_MIN} to ${NODE_SERVER_PORT_MAX}, the port the server's own code listens on.` +const CONTENT_TYPES = [ + 'application/json', + 'text/plain', + 'application/x-www-form-urlencoded', +] as const + +const entrySchema = repositoryPathSchema + .refine( + (path) => ENTRY_EXTENSION.test(path), + 'expected a .ts, .mts, .cts, .js, .mjs or .cjs file relative to packageRoot.', + ) + // node receives the entry as a bare argument; a leading dash would be read as an option such as --inspect. + .refine( + (path) => !path.startsWith('-'), + 'expected an entry that does not begin with "-", which node would read as an option.', + ) + +const requestSchema = z + .strictObject({ + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], { + error: 'expected GET, POST, PUT, PATCH or DELETE.', + }), + path: z.string().superRefine((path, context) => { + const problem = nodeServerRequestPathProblem(path) + if (problem) + context.addIssue({ + code: 'custom', + message: `expected a path on the server starting with "/", ${problem}.`, + }) + }), + contentType: z + .enum(CONTENT_TYPES, { + error: `expected ${CONTENT_TYPES.join(', ')}.`, + }) + .optional(), + body: z.string().optional(), + }) + .superRefine((request, context) => { + for (const { field, message } of describeNodeServerRequestBodyIssues( + request, + CONTENT_TYPES, + )) + context.addIssue({ code: 'custom', path: [field], message }) + }) + +/** + * Bounds one node-server run before anything is spawned: the repository's own server entry (run by node, optionally through the repository's tsx), the port the server's own code listens on, one declared HTTP request, and the stops with their inspections. + * No command, environment, port assignment or node option can be declared: Codebook starts only `node [--import ] ` in `packageRoot` and checks the declared port. + * The loader defaults from the entry's extension (TypeScript → `tsx`, JavaScript → `node`); a TypeScript entry declared with `node` is refused. + * Parsed by the node-server start; its issues are worded by {@link describeNodeServerConfigurationIssues}. + * @example + * nodeServerConfigSchema.parse({ version: 1, adapter: 'node-server', book, entry: 'src/server.ts', port: 4100, request: { method: 'GET', path: '/api/healthz' }, source, inspections }) + * // => { ..., packageRoot: '.', loader: 'tsx', ... } + */ +export const nodeServerConfigSchema = z + .strictObject({ + version: z.literal(1), + adapter: z.literal('node-server'), + book: repositoryBookSelectionSchema, + packageRoot: z + .union([z.literal('.'), repositoryPathSchema]) + .default('.') + .describe( + 'The folder the server runs in, relative to the repository: "." or a package folder such as "apps/api".', + ), + entry: entrySchema.describe( + 'The server entry file, relative to packageRoot, as its package runs it (for example "src/server.ts").', + ), + loader: z + .enum(['tsx', 'node']) + .optional() + .describe( + 'How node runs the entry: "tsx" through the repository\'s own tsx, "node" for a JavaScript entry. Defaults from the entry\'s extension.', + ), + port: z + .number(PORT_EXPECTATION) + .int(PORT_EXPECTATION) + .min(NODE_SERVER_PORT_MIN, PORT_EXPECTATION) + .max(NODE_SERVER_PORT_MAX, PORT_EXPECTATION) + .describe( + "The port the server's own code listens on. Codebook never sets PORT or any variable; it only checks this port.", + ), + request: requestSchema, + source: repositorySourceStopSchema, + inspections: nodeTestStopInspectionsSchema, + additionalStops: z + .array(nodeTestStopSchema) + .max( + CONFIGURED_OBSERVATION_LIMIT - 1, + `expected at most ${CONFIGURED_OBSERVATION_LIMIT - 1} additional stops.`, + ) + .optional(), + }) + .superRefine((configuration, context) => { + if ( + configuration.loader === 'node' && + TYPESCRIPT_ENTRY_EXTENSION.test(configuration.entry) + ) + context.addIssue({ + code: 'custom', + path: ['loader'], + message: + 'expected "tsx" for a TypeScript entry; plain node runs JavaScript entries only.', + }) + // Every requested stop needs a concrete value; type-only output cannot prove the server's runtime state. + addStopInspectionIssues(configuration, context) + if ( + Buffer.byteLength(JSON.stringify(configuration)) > + REPOSITORY_CONFIG_LIMIT_BYTES + ) + context.addIssue({ + code: 'custom', + message: `expected a configuration of at most ${REPOSITORY_CONFIG_LIMIT_BYTES} bytes.`, + }) + }) + .transform((configuration) => ({ + ...configuration, + loader: + configuration.loader ?? + (TYPESCRIPT_ENTRY_EXTENSION.test(configuration.entry) + ? ('tsx' as const) + : ('node' as const)), + })) + +export type NodeServerConfiguration = z.infer diff --git a/extension/src/nodeTestConfigSchemas.ts b/extension/src/nodeTestConfigSchemas.ts index ba79463d1..1f6f92807 100644 --- a/extension/src/nodeTestConfigSchemas.ts +++ b/extension/src/nodeTestConfigSchemas.ts @@ -12,7 +12,7 @@ import { nodeTestStopInspectionsSchema, } from './repositoryConfigSchemas' import { repositoryHashSchema, repositoryPathSchema } from './sourceSchemas' -import { missingValueInspectionMessage } from './utils/missingValueInspectionMessage' +import { addStopInspectionIssues } from './utils/addStopInspectionIssues' const testFileSchema = repositoryPathSchema .refine( @@ -29,7 +29,8 @@ const testTitleSchema = z .min(1) .max(BOOK_TITLE_LIMIT_CHARS) .regex(/^[^\x00-\x1f\x7f]+$/) -const nodeTestStopSchema = repositorySourceStopSchema.extend({ +/** One additional stop of a Node profile: a cited source position, the book anchor it explains and its inspections; shared by {@link nodeTestConfigSchema} and {@link nodeServerConfigSchema}. */ +export const nodeTestStopSchema = repositorySourceStopSchema.extend({ anchorIndex: repositoryBookSelectionSchema.shape.anchorIndex, inspections: nodeTestStopInspectionsSchema, }) @@ -74,25 +75,7 @@ export const nodeTestConfigSchema = z message: 'Cite the test with testTitles only.', }) // Every requested stop needs a concrete value; type-only output cannot prove the test's runtime state. The refusal names the stop and what it declares. - for (const [index, inspections] of [ - configuration.inspections, - ...(configuration.additionalStops ?? []).map((stop) => stop.inspections), - ].entries()) { - const missingValueInspection = missingValueInspectionMessage( - inspections, - index - ? `configuration.additionalStops[${index - 1}].inspections` - : 'configuration.inspections', - ) - if (!missingValueInspection) continue - context.addIssue({ - code: 'custom', - path: index - ? ['additionalStops', index - 1, 'inspections'] - : ['inspections'], - message: missingValueInspection, - }) - } + addStopInspectionIssues(configuration, context) if ( Buffer.byteLength(JSON.stringify(configuration)) > REPOSITORY_CONFIG_LIMIT_BYTES diff --git a/extension/src/tests/captureProcessOutput.test.ts b/extension/src/tests/captureProcessOutput.test.ts new file mode 100644 index 000000000..3639f769f --- /dev/null +++ b/extension/src/tests/captureProcessOutput.test.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import { PassThrough } from 'node:stream' +import { test } from 'node:test' + +import { + captureProcessOutput, + type ProcessOutputEvent, +} from '../utils/captureProcessOutput' + +/** + * Builds the two output streams of a child process, as {@link captureProcessOutput} reads them. + * @returns The streams, typed as the capture's input. + * @example const child = fakeChild() + */ +function fakeChild(): { + stdout: PassThrough + stderr: PassThrough +} { + return { stdout: new PassThrough(), stderr: new PassThrough() } +} + +/** + * Lets every queued stream callback run, so written chunks reach the capture before the next assertion. + * @example await flushStreams() + */ +async function flushStreams(): Promise { + for (let round = 0; round < 3; round++) + await new Promise((resolve) => setImmediate(resolve)) +} + +// Value: protects=a capture whose delivery stalls or throws fails with the caller's own words instead of buffering without bound or losing the cause; fails_when=output past pendingLimitBytes is queued anyway, or a rejected onOutput leaves failure() empty; why_new=no test drives captureProcessOutput's failure paths, launchNodeTest/launchNodeServer only run it healthy; seam=none; no_claim=the owners' reaction to the failure (mocked streams; unit only) +test('output that outpaces a stalled delivery or whose delivery throws fails the capture with the caller wording', async () => { + // Arrange + const stalledChild = fakeChild() + const stalledEvents: ProcessOutputEvent[] = [] + const stalled = captureProcessOutput(stalledChild, { + startedAt: process.hrtime.bigint(), + // A delivery that never settles keeps its bytes pending. + onOutput: (event) => { + stalledEvents.push(event) + return new Promise(() => {}) + }, + pendingLimitBytes: 10, + overflowMessage: 'test capture failed: output outpaced its delivery.', + failurePrefix: 'test capture failed', + }) + const throwingChild = fakeChild() + const throwing = captureProcessOutput(throwingChild, { + startedAt: process.hrtime.bigint(), + onOutput: () => { + throw new Error('disk full') + }, + pendingLimitBytes: 1_024, + overflowMessage: 'unused', + failurePrefix: 'test capture failed', + }) + // Act + stalledChild.stdout.write('aaaaaaa\n') + await flushStreams() + stalledChild.stdout.write('bbbbbbb\n') + await flushStreams() + throwingChild.stdout.write('hello\n') + await flushStreams() + await throwing.settled() + // Assert + assert.equal( + stalled.failure()?.message, + 'test capture failed: output outpaced its delivery.', + ) + assert.deepEqual( + stalledEvents.map((event) => event.text), + ['aaaaaaa\n'], + ) + assert.equal( + throwing.failure()?.message, + 'test capture failed: Error: disk full', + ) +}) + +// Value: protects=output events are cut by characters, never inside a surrogate pair, in order and stamped with the channel; fails_when=an emoji at the 4,096-character cut is split into lone surrogates or the sequence repeats/skips; why_new=no test feeds a chunk longer than one event; seam=none; no_claim=byte-exact tail truncation (not covered here; mocked streams; unit only) +test('an output chunk longer than one event is cut before an emoji that would straddle the limit', async () => { + // Arrange + const child = fakeChild() + const events: ProcessOutputEvent[] = [] + const capture = captureProcessOutput(child, { + startedAt: process.hrtime.bigint(), + onOutput: (event) => { + events.push(event) + }, + pendingLimitBytes: 1_048_576, + overflowMessage: 'unused', + failurePrefix: 'test capture failed', + }) + // Act + child.stderr.write(`${'a'.repeat(4_095)}😀bbb\n`) + await flushStreams() + await capture.settled() + // Assert + assert.deepEqual( + events.map((event) => ({ + sequence: event.sequence, + channel: event.channel, + text: event.text, + })), + [ + { sequence: 1, channel: 'stderr', text: 'a'.repeat(4_095) }, + { sequence: 2, channel: 'stderr', text: '😀bbb\n' }, + ], + ) + assert.equal(capture.failure(), undefined) +}) + +// Value: protects=the capture keeps only the first 32 KiB raw (for the inspector announcement) and the last 32 KiB redacted (for causes), whatever the process prints; fails_when=a cap is removed, or the tail keeps the head instead of the end; why_new=no test prints more than 32 KiB through the shared capture; seam=none; no_claim=multibyte characters at the cut (mocked streams; unit only) +test('a process that prints more than 32 KiB keeps its first 32 KiB for the announcement and its last 32 KiB as the tail', async () => { + // Arrange + const child = fakeChild() + const capture = captureProcessOutput(child, { + startedAt: process.hrtime.bigint(), + pendingLimitBytes: 1_048_576, + overflowMessage: 'unused', + failurePrefix: 'test capture failed', + }) + // Act + child.stdout.write(`HEAD\n${'x\n'.repeat(20_000)}TAIL\n`) + await flushStreams() + // Assert + assert.equal(Buffer.byteLength(capture.announcement()), 32_768) + assert.equal(capture.announcement().startsWith('HEAD\n'), true) + assert.equal(capture.announcement().includes('TAIL'), false) + assert.equal(Buffer.byteLength(capture.tail()), 32_768) + assert.equal(capture.tail().endsWith('x\nTAIL\n'), true) + assert.equal(capture.tail().includes('HEAD'), false) +}) + +// Value: protects=output keeps its arrival order across channels (pt1 Step 11 Codex structured finding: a partial line held by the redactor was delivered after later output, moving it past a debugger stop); fails_when=a plain partial line is delivered after the other channel's later line; why_new=no test interleaved channels around a partial line; seam=none; no_claim=a partial line ending in a possible secret prefix, which still waits +test('a partial plain line on stdout is delivered before a later stderr line', async () => { + // Arrange + const child = fakeChild() + const events: ProcessOutputEvent[] = [] + captureProcessOutput(child, { + startedAt: process.hrtime.bigint(), + onOutput: (event) => { + events.push(event) + }, + pendingLimitBytes: 1_048_576, + overflowMessage: 'unused', + failurePrefix: 'test capture failed', + }) + // Act + child.stdout.write('before') + await flushStreams() + child.stderr.write('later\n') + await flushStreams() + child.stdout.write('\n') + await flushStreams() + // Assert + assert.deepEqual( + events.map((event) => [event.channel, event.text]), + [ + ['stdout', 'before'], + ['stderr', 'later\n'], + ['stdout', '\n'], + ], + ) +}) diff --git a/extension/src/tests/configuredApplication.test.ts b/extension/src/tests/configuredApplication.test.ts index c9e12e82e..c4c845afb 100644 --- a/extension/src/tests/configuredApplication.test.ts +++ b/extension/src/tests/configuredApplication.test.ts @@ -843,3 +843,233 @@ process.on('SIGTERM', () => { code: 'ENOENT', }) }) + +// Value: protects=the redaction contract on the repository-browser dev server's output, which the run journals in application.closed and quotes in failure messages (pt1 red-team finding); fails_when=a credential the server prints, even split across chunks, reaches the journal or the readiness failure; why_new=no test fed the dev server's log through redaction; seam=the launcher run in an isolated module context with a stub child, as the other tests here; no_claim=a real next dev process +test('a credential the dev server prints, split across chunks, reads [masked] in the closed journal and in a readiness failure', async (context) => { + // Arrange + const filename = join(__dirname, '../utils/launchDevelopmentServer.js'), + nativeRequire = createRequire(filename) + const exports: Partial = {}, + journal: { type: string; detail: unknown }[] = [] + const token = ['ghp', '0123456789abcdefghijKLMNOPqrstuvwxyz'].join('_') + const child = Object.assign(new EventEmitter(), { + pid: 42000, + exitCode: null as number | null, + signalCode: null, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }) + let groupAlive = true + context.mock.method( + process, + 'kill', + (_pid: number, signal?: string | number): true => { + if (signal === 'SIGTERM') { + groupAlive = false + child.emit('exit') + } + if (signal === 0 && !groupAlive) + throw Object.assign(new Error('missing group'), { code: 'ESRCH' }) + return true + }, + ) + runInNewContext( + await readFile(filename, 'utf8'), + { + exports, + AbortSignal, + Buffer, + process: { env: {} }, + require(name: string) { + if (name === 'node:child_process') return { spawn: () => child } + if (name.endsWith('/utils/waitUntil')) + return { + waitUntil: async (read: () => unknown): Promise => { + const value = await read() + if (!value) throw new Error('not ready') + return value + }, + } + return nativeRequire(name) + }, + }, + { filename }, + ) + assert.ok(exports.launchDevelopmentServer) + const server = exports.launchDevelopmentServer( + '/isolated-target', + '/node', + (type, detail) => journal.push({ type, detail }), + false, + 3000, + ) + child.stdout.emit('data', Buffer.from(`using token ${token.slice(0, 12)}`)) + child.stdout.emit('data', Buffer.from(`${token.slice(12)} for the api\n`)) + child.exitCode = 1 + // Act + const failure = await server + .ready(new AbortController().signal) + .catch((error: unknown) => error) + await server.close() + // Assert + // The launcher runs in its own realm, so its Error is read by its message, not by instanceof. + const failureMessage = + typeof failure === 'object' && failure !== null + ? Reflect.get(failure, 'message') + : undefined + assert.equal( + failureMessage, + 'Application exited: 1: using token [masked] for the api\n', + ) + const closed = journal.find((entry) => entry.type === 'application.closed') + assert.equal( + JSON.stringify(closed?.detail).includes('using token [masked] for the api'), + true, + ) + assert.equal(JSON.stringify(journal).includes(token.slice(4, 20)), false) +}) + +// Value: protects=the dev server's stderr is redacted too, and a credential still held at a stream's end is masked when the stream ends (pt1 pass 2 testing finding); fails_when=stderr bypasses the redactor or the end flush releases the held token raw; why_new=the first redaction test drives stdout and the exit path only; seam=the launcher run in an isolated module context with a stub child; no_claim=a real next dev process +test('a credential the dev server prints last on stderr, without a line end, reads [masked] once the stream ends', async (context) => { + // Arrange + const filename = join(__dirname, '../utils/launchDevelopmentServer.js'), + nativeRequire = createRequire(filename) + const exports: Partial = {}, + journal: { type: string; detail: unknown }[] = [] + const token = ['ghp', 'ZYXWVUTSRQPONMLKJIHGFEDCBA9876543210'].join('_') + const child = Object.assign(new EventEmitter(), { + pid: 42000, + exitCode: null, + signalCode: null, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }) + let groupAlive = true + context.mock.method( + process, + 'kill', + (_pid: number, signal?: string | number): true => { + if (signal === 'SIGTERM') { + groupAlive = false + child.emit('exit') + } + if (signal === 0 && !groupAlive) + throw Object.assign(new Error('missing group'), { code: 'ESRCH' }) + return true + }, + ) + runInNewContext( + await readFile(filename, 'utf8'), + { + exports, + AbortSignal, + Buffer, + process: { env: {} }, + require(name: string) { + if (name === 'node:child_process') return { spawn: () => child } + if (name.endsWith('/utils/waitUntil')) + return { + waitUntil: async (read: () => unknown): Promise => { + const value = await read() + if (!value) throw new Error('not ready') + return value + }, + } + return nativeRequire(name) + }, + }, + { filename }, + ) + assert.ok(exports.launchDevelopmentServer) + const server = exports.launchDevelopmentServer( + '/isolated-target', + '/node', + (type, detail) => journal.push({ type, detail }), + false, + 3000, + ) + // Act + child.stderr.emit( + 'data', + Buffer.from(`fatal: bad credential ${token.slice(0, 9)}`), + ) + child.stderr.emit('data', Buffer.from(token.slice(9))) + child.stderr.emit('end') + await server.close() + // Assert + const closed = journal.find((entry) => entry.type === 'application.closed') + assert.equal( + JSON.stringify(closed?.detail).includes('fatal: bad credential [masked]'), + true, + ) + assert.equal(JSON.stringify(journal).includes(token.slice(4, 20)), false) +}) + +// Value: protects=the closed journal carries the dev server's last held output, masked, even when the process exits before its stream ends (pt1 Step 11 Codex finding: the held tail was published only on the stream's end, after the journal); fails_when=the journal lacks the held text or carries it raw; why_new=the earlier tests end the stream before close; seam=the launcher run in an isolated module context with a stub child; no_claim=a real next dev process +test('the dev server output still held when it exits reaches the closed journal masked, before its stream ends', async (context) => { + // Arrange + const filename = join(__dirname, '../utils/launchDevelopmentServer.js'), + nativeRequire = createRequire(filename) + const exports: Partial = {}, + journal: { type: string; detail: unknown }[] = [] + const child = Object.assign(new EventEmitter(), { + pid: 42000, + exitCode: null, + signalCode: null, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }) + let groupAlive = true + context.mock.method( + process, + 'kill', + (_pid: number, signal?: string | number): true => { + if (signal === 'SIGTERM') { + groupAlive = false + child.emit('exit') + } + if (signal === 0 && !groupAlive) + throw Object.assign(new Error('missing group'), { code: 'ESRCH' }) + return true + }, + ) + runInNewContext( + await readFile(filename, 'utf8'), + { + exports, + AbortSignal, + Buffer, + process: { env: {} }, + require(name: string) { + if (name === 'node:child_process') return { spawn: () => child } + if (name.endsWith('/utils/waitUntil')) + return { + waitUntil: async (read: () => unknown): Promise => { + const value = await read() + if (!value) throw new Error('not ready') + return value + }, + } + return nativeRequire(name) + }, + }, + { filename }, + ) + assert.ok(exports.launchDevelopmentServer) + const server = exports.launchDevelopmentServer( + '/isolated-target', + '/node', + (type, detail) => journal.push({ type, detail }), + false, + 3000, + ) + // Act + child.stderr.emit('data', Buffer.from(`fatal: ECONNRESET token ${'ghp'}_abc`)) + await server.close() + // Assert + const closed = journal.find((entry) => entry.type === 'application.closed') + assert.equal( + JSON.stringify(closed?.detail).includes('fatal: ECONNRESET token [masked]'), + true, + ) +}) diff --git a/extension/src/tests/fixtures/node-server/server.ts b/extension/src/tests/fixtures/node-server/server.ts new file mode 100644 index 000000000..0d170b656 --- /dev/null +++ b/extension/src/tests/fixtures/node-server/server.ts @@ -0,0 +1,67 @@ +// Test-only HTTP server for the node-server launcher and run tests; it never ships (package.test.mjs checks the VSIX). +// Like a repository's own server, it reads its settings from the folder it runs in (fixture-server.json there), never from Codebook. +// The tests copy its compiled file into a new folder, so it imports only Node.js built-ins. +import { readFileSync } from 'node:fs' +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http' + +const settings: unknown = JSON.parse( + readFileSync('fixture-server.json', 'utf8'), +) + +/** + * Reads one setting of fixture-server.json. + * @param name - The setting's name. + * @returns Its value, or `undefined` when the file does not set it. + * @example setting('port') // => 4100 + */ +function setting(name: string): unknown { + return typeof settings === 'object' && settings !== null + ? Reflect.get(settings, name) + : undefined +} + +const mode = String(setting('mode') ?? 'listen') +if (mode === 'exit-before-listen') { + console.error('fixture cannot start: exit-before-listen') + process.exit(3) +} + +/** + * Answers one request: /health with a status (/log-credential also logs a credential-shaped token), /environment with the names (never the values) of the variables the server inherited, anything else with 404. + * @param request - The incoming request. + * @param response - The response to write. + * @example GET /health // => 200 {"status":"ok"} + */ +function answer(request: IncomingMessage, response: ServerResponse): void { + const requestPath = request.url + console.log(`fixture request ${request.method} ${requestPath}`) + // A server that logs a credential, split over two writes as a stream may deliver it. + if (requestPath === '/log-credential') { + process.stdout.write('fixture using token ghp_0123456789') + process.stdout.write('abcdefghijKLMNOP and more\n') + } + const body = + requestPath === '/health' || requestPath === '/log-credential' + ? { status: 'ok' } + : requestPath === '/environment' + ? { names: Object.keys(process.env).sort() } + : undefined + response.writeHead(body ? 200 : 404, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(body ?? { error: 'not found' })) +} + +const server = createServer(answer) +const port = Number(setting('port')) +const host = String(setting('host') ?? '127.0.0.1') +if (mode === 'never-listen') setInterval(() => undefined, 60_000) +else + server.listen(port, host, () => + console.log(`fixture listening on ${host}:${port}`), + ) + +// A graceful shutdown, as switch-time's server.ts has: with a debugger still attached, node waits for it before exiting. +process.on('SIGTERM', () => server.close(() => process.exit(0))) diff --git a/extension/src/tests/keepUtf8Tail.test.ts b/extension/src/tests/keepUtf8Tail.test.ts new file mode 100644 index 000000000..bd7b68ec3 --- /dev/null +++ b/extension/src/tests/keepUtf8Tail.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { keepUtf8Tail } from '../utils/keepUtf8Tail' + +// Value: protects=a bounded output tail starts at a whole character (pt1 pass 2 red-team appendix: a byte cut inside a character put U+FFFD at the tail's start); fails_when=a cut inside a multibyte character yields U+FFFD or more bytes than the limit; why_new=the tails of captureProcessOutput and the dev server's published log now cut through this; seam=none; no_claim=grapheme clusters (cut by code points) +test('an output tail cut inside a multibyte character starts at the next whole character within the byte limit', () => { + // Arrange + const text = 'log: café ok' + // Act + const fitting = keepUtf8Tail(text, 64) + const cutInsideE = keepUtf8Tail(text, 4) + const cutBeforeE = keepUtf8Tail(text, 5) + // Assert + assert.equal(fitting, 'log: café ok') + assert.equal(cutInsideE, ' ok') + assert.equal(cutBeforeE, 'é ok') +}) diff --git a/extension/src/tests/launchNodeServer.test.ts b/extension/src/tests/launchNodeServer.test.ts new file mode 100644 index 000000000..741424b7a --- /dev/null +++ b/extension/src/tests/launchNodeServer.test.ts @@ -0,0 +1,353 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { copyFile, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { get } from 'node:http' +import { connect, createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' + +import { z } from 'zod' + +import { Cdp } from '../runtimeSupport/cdp' +import { waitUntil } from '../runtimeSupport/utils/waitUntil' +import type { ProcessOutputEvent } from '../utils/captureProcessOutput' +import { isProcessGroupGone } from '../utils/isProcessGroupGone' +import { launchNodeServer } from '../utils/launchNodeServer' +import { resolveNodeServerLaunch } from '../utils/resolveNodeServerLaunch' + +/** The test-only HTTP server as compiled beside this test; it never ships. */ +const FIXTURE_SERVER = join(__dirname, 'fixtures/node-server/server.js') + +/** The start journal entry's facts as the launcher records them. */ +const startedDetailSchema = z.looseObject({ + inheritedVariables: z.array(z.string()), +}) + +/** A loopback port that was free a moment ago, for the fixture server to listen on. */ +async function freePort(): Promise { + const probe = createServer() + probe.listen(0, '127.0.0.1') + await once(probe, 'listening') + const address = probe.address() + assert.ok(address && typeof address !== 'string') + probe.close() + await once(probe, 'close') + return address.port +} + +/** Copies the fixture server into a new repository folder with its own settings file, as a repository's server reads its `.env`. */ +async function fixtureRepository( + t: { after: (cleanup: () => Promise) => void }, + settings: { port: number; host: string; mode?: string }, +): Promise { + const workspace = await realpath( + await mkdtemp(join(tmpdir(), 'codebook-node-server-')), + ) + t.after(() => rm(workspace, { recursive: true, force: true })) + await copyFile(FIXTURE_SERVER, join(workspace, 'server.cjs')) + await writeFile( + join(workspace, 'fixture-server.json'), + JSON.stringify({ mode: 'listen', ...settings }), + ) + return workspace +} + +/** Says whether something accepts connections on a loopback port now. */ +async function accepts(port: number): Promise { + const socket = connect({ host: '127.0.0.1', port }) + return new Promise((resolve) => { + socket.once('connect', () => { + socket.destroy() + resolve(true) + }) + socket.once('error', () => resolve(false)) + }) +} + +/** Sends one GET on loopback and reads its status and body. */ +async function getText( + port: number, + path: string, +): Promise<{ status: number | undefined; body: string }> { + return new Promise((resolve, reject) => { + get({ host: '127.0.0.1', port, path, agent: false }, (response) => { + let body = '' + response.setEncoding('utf8') + response.on('data', (chunk: string) => (body += chunk)) + response.on('end', () => resolve({ status: response.statusCode, body })) + }).once('error', reject) + }) +} + +test('an owned server announces its loopback inspector, answers after release and closes its group with the port free', async (t) => { + // protects: the launcher loop (announced port, release, readiness, a GET, close); fails_when: any step fails or the port stays held; no_claim: pwa-node's attach (ladder level 3). + // Arrange + const port = await freePort() + const workspace = await fixtureRepository(t, { port, host: '127.0.0.1' }) + const launch = await resolveNodeServerLaunch(workspace, { + packageRoot: '.', + entry: 'server.cjs', + loader: 'node', + }) + const journal: { type: string; detail: unknown }[] = [] + const server = await launchNodeServer( + { + ...launch, + workspace, + node: { executable: process.execPath, version: process.versions.node }, + }, + (type, detail) => journal.push({ type, detail }), + ) + let inspector: Cdp | undefined + try { + // Act + const endpoint = await server.inspectorEndpoint(AbortSignal.timeout(10_000)) + inspector = await Cdp.connect(endpoint, () => undefined) + await inspector.request('Runtime.runIfWaitingForDebugger', undefined) + await waitUntil( + async () => ((await accepts(port)) ? true : undefined), + 'fixture readiness', + 10_000, + ) + const health = await getText(port, '/health') + const connected = inspector + const exit = await server.close(async () => connected.close()) + // Assert + assert.match(endpoint, /^ws:\/\/127\.0\.0\.1:[1-9]\d*\/[a-f\d-]{36}$/) + assert.deepEqual(health, { status: 200, body: '{"status":"ok"}' }) + assert.deepEqual(exit, { exitCode: 0, signal: null }) + assert.equal(isProcessGroupGone(server.processGroup!), true) + assert.equal(await accepts(port), false) + assert.equal(journal[0]?.type, 'node-server.started') + const { inheritedVariables, ...startFacts } = startedDetailSchema.parse( + journal[0]?.detail, + ) + assert.deepEqual(startFacts, { + pid: server.pid, + args: ['--inspect-brk=127.0.0.1:0', '/server.cjs'], + cwd: '', + loader: 'node', + nodeVersion: process.versions.node, + secretEnvironmentInherited: false, + }) + assert.ok(inheritedVariables.includes('PATH')) + // Node itself copies NODE_V8_COVERAGE into a child's environment while this process collects coverage (the health check's c8 run, CI's coverage upload), so the journal then truthfully names it. + const coverageVariable = process.env.NODE_V8_COVERAGE + ? ['NODE_V8_COVERAGE'] + : [] + assert.ok( + inheritedVariables.every((name) => + [ + 'PATH', + 'HOME', + 'TMPDIR', + 'SHELL', + 'LANG', + 'LC_ALL', + 'PNPM_HOME', + 'USER', + 'LOGNAME', + 'NODE_EXTRA_CA_CERTS', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + ...coverageVariable, + ].includes(name), + ), + ) + assert.deepEqual(journal.at(-1), { + type: 'node-server.closed', + detail: { pid: server.pid, exitCode: 0, signal: null, killed: false }, + }) + } finally { + inspector?.close() + await server.close() + } +}) + +test('an owned server inherits no parent variable outside the allowlist, no proxy and no NODE_ENV, and no event names them', async (t) => { + // protects: the planted-variable criterion (acceptance 5); fails_when: a planted variable, HTTP_PROXY, NODE_ENV or CI reaches the server or an event; no_claim: what the server's own .env adds. + // Arrange + const planted = { + CODEBOOK_PLANTED_VARIABLE: 'planted-value-7f3a', + HTTP_PROXY: 'http://proxy.invalid:8080', + NODE_ENV: 'production', + CI: 'true', + } + const previous = Object.fromEntries( + Object.keys(planted).map((name) => [name, process.env[name]]), + ) + Object.assign(process.env, planted) + t.after(() => { + for (const [name, value] of Object.entries(previous)) + if (value === undefined) delete process.env[name] + else process.env[name] = value + }) + const port = await freePort() + const workspace = await fixtureRepository(t, { port, host: '127.0.0.1' }) + const launch = await resolveNodeServerLaunch(workspace, { + packageRoot: '.', + entry: 'server.cjs', + loader: 'node', + }) + const journal: unknown[] = [] + const outputs: ProcessOutputEvent[] = [] + const server = await launchNodeServer( + { + ...launch, + workspace, + node: { executable: process.execPath, version: process.versions.node }, + }, + (type, detail) => journal.push({ type, detail }), + (event) => { + outputs.push(event) + }, + ) + let inspector: Cdp | undefined + try { + const endpoint = await server.inspectorEndpoint(AbortSignal.timeout(10_000)) + inspector = await Cdp.connect(endpoint, () => undefined) + await inspector.request('Runtime.runIfWaitingForDebugger', undefined) + await waitUntil( + async () => ((await accepts(port)) ? true : undefined), + 'fixture readiness', + 10_000, + ) + // Act + const environment = await getText(port, '/environment') + const connected = inspector + await server.close(async () => connected.close()) + // Assert + const names: string[] = JSON.parse(environment.body).names + for (const name of Object.keys(planted)) + assert.equal(names.includes(name), false, name) + const everything = JSON.stringify([journal, outputs]) + for (const [name, value] of Object.entries(planted)) { + assert.equal(everything.includes(value), false, value) + assert.equal(everything.includes(name), false, name) + } + } finally { + inspector?.close() + await server.close() + } +}) + +test('a credential the server prints, even split over two writes, reads [masked] in every output event and the tail', async (t) => { + // protects: the redaction contract on node-server output (task 8 through the shared capture); fails_when: any part of the token reaches an event or the tail; no_claim: secrets with no known shape. + // Arrange + const port = await freePort() + const workspace = await fixtureRepository(t, { port, host: '127.0.0.1' }) + const launch = await resolveNodeServerLaunch(workspace, { + packageRoot: '.', + entry: 'server.cjs', + loader: 'node', + }) + const outputs: ProcessOutputEvent[] = [] + const server = await launchNodeServer( + { + ...launch, + workspace, + node: { executable: process.execPath, version: process.versions.node }, + }, + () => undefined, + (event) => { + outputs.push(event) + }, + ) + let inspector: Cdp | undefined + try { + const endpoint = await server.inspectorEndpoint(AbortSignal.timeout(10_000)) + inspector = await Cdp.connect(endpoint, () => undefined) + await inspector.request('Runtime.runIfWaitingForDebugger', undefined) + await waitUntil( + async () => ((await accepts(port)) ? true : undefined), + 'fixture readiness', + 10_000, + ) + // Act + await getText(port, '/log-credential') + await waitUntil( + () => (server.output().includes('and more') ? true : undefined), + 'logged credential line', + 10_000, + ) + const connected = inspector + await server.close(async () => connected.close()) + // Assert + const printed = outputs.map((event) => event.text).join('') + assert.match(printed, /fixture using token \[masked\] and more\n/) + assert.match(server.output(), /fixture using token \[masked\] and more\n/) + for (const fragment of ['ghp_0123456789', 'abcdefghijKLMNOP']) + assert.equal(`${printed}${server.output()}`.includes(fragment), false) + } finally { + inspector?.close() + await server.close() + } +}) + +test('an owned server that exits before it listens reports its exit code and its last output', async (t) => { + // protects: SERVER_EXITED's facts (exit code, masked last output); fails_when: the exit or its output is lost; no_claim: the error code (pt2). + // Arrange + const port = await freePort() + const workspace = await fixtureRepository(t, { + port, + host: '127.0.0.1', + mode: 'exit-before-listen', + }) + const launch = await resolveNodeServerLaunch(workspace, { + packageRoot: '.', + entry: 'server.cjs', + loader: 'node', + }) + const server = await launchNodeServer( + { + ...launch, + workspace, + node: { executable: process.execPath, version: process.versions.node }, + }, + () => undefined, + ) + let inspector: Cdp | undefined + try { + const endpoint = await server.inspectorEndpoint(AbortSignal.timeout(10_000)) + inspector = await Cdp.connect(endpoint, () => undefined) + // Act + await inspector.request('Runtime.runIfWaitingForDebugger', undefined) + inspector.close() + const exit = await server.waitForExit(10_000) + // Assert + assert.deepEqual(exit, { exitCode: 3, signal: null }) + assert.deepEqual(server.exited(), { exitCode: 3, signal: null }) + assert.match(server.output(), /fixture cannot start: exit-before-listen\n$/) + } finally { + inspector?.close() + await server.close() + } +}) + +test('a server whose Node cannot be started fails its inspector wait with the start error', async (t) => { + // protects: a failed spawn surfaces at once instead of waiting 45 s; fails_when: the wait times out or succeeds; no_claim: none. + // Arrange + const port = await freePort() + const workspace = await fixtureRepository(t, { port, host: '127.0.0.1' }) + const launch = await resolveNodeServerLaunch(workspace, { + packageRoot: '.', + entry: 'server.cjs', + loader: 'node', + }) + const server = await launchNodeServer( + { + ...launch, + workspace, + node: { executable: join(workspace, 'missing-node'), version: '0.0.0' }, + }, + () => undefined, + ) + // Act + const failure = await server + .inspectorEndpoint(AbortSignal.timeout(10_000)) + .catch((error: unknown) => error) + // Assert + assert.ok(failure instanceof Error) + assert.match(failure.message, /ENOENT/) +}) diff --git a/extension/src/tests/nodeServerConfigSchemas.test.ts b/extension/src/tests/nodeServerConfigSchemas.test.ts new file mode 100644 index 000000000..cd5983bf5 --- /dev/null +++ b/extension/src/tests/nodeServerConfigSchemas.test.ts @@ -0,0 +1,310 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { nodeServerConfigSchema } from '../nodeServerConfigSchemas' +import { describeNodeServerConfigurationIssues } from '../utils/describeNodeServerConfigurationIssues' + +import { createConfiguredStudy } from './utils/createConfiguredStudy' + +/** A minimal node-server declaration: one GET to a TypeScript entry at the repository root. */ +function minimalGetDeclaration() { + const browser = createConfiguredStudy().configuration + return { + version: 1, + adapter: 'node-server', + book: browser.book, + entry: 'src/server.ts', + port: 4100, + request: { method: 'GET', path: '/api/healthz' }, + source: { + path: 'src/app.ts', + hash: browser.source.hash, + line: 54, + column: 1, + }, + inspections: [{ path: 'c.req.path', mode: 'value', required: true }], + } +} + +test('a node-server GET declaration runs its TypeScript entry from the repository root through tsx', () => { + // protects: the defaults the launcher relies on; fails_when: packageRoot or loader lose their defaults; no_claim: the loader exists on disk. + // Arrange + const declaration = minimalGetDeclaration() + // Act + const configuration = nodeServerConfigSchema.parse(declaration) + // Assert + assert.equal(configuration.packageRoot, '.') + assert.equal(configuration.loader, 'tsx') + assert.deepEqual(configuration.request, { + method: 'GET', + path: '/api/healthz', + }) +}) + +test('a node-server JavaScript entry runs with plain node unless the declaration names tsx', () => { + // protects: the loader default by entry extension; fails_when: a .mjs entry is sent through tsx by default; no_claim: tsx can run it. + // Arrange + const javascriptEntry = { ...minimalGetDeclaration(), entry: 'server.mjs' } + const javascriptThroughTsx = { ...javascriptEntry, loader: 'tsx' } + // Act + const plain = nodeServerConfigSchema.parse(javascriptEntry) + const throughTsx = nodeServerConfigSchema.parse(javascriptThroughTsx) + // Assert + assert.equal(plain.loader, 'node') + assert.equal(throughTsx.loader, 'tsx') +}) + +test('a monorepo node-server declaration names its package root and an entry relative to it', () => { + // protects: monorepo servers (switch-time apps/api); fails_when: packageRoot is refused or rewritten; no_claim: the folder exists. + // Arrange + const declaration = { ...minimalGetDeclaration(), packageRoot: 'apps/api' } + // Act + const configuration = nodeServerConfigSchema.parse(declaration) + // Assert + assert.equal(configuration.packageRoot, 'apps/api') + assert.equal(configuration.entry, 'src/server.ts') +}) + +test('a node-server POST declaration sends a JSON body with its content type', () => { + // protects: state-changing requests with a body; fails_when: a JSON body under the limit is refused; no_claim: the request is sent. + // Arrange + const declaration = { + ...minimalGetDeclaration(), + request: { + method: 'POST', + path: '/api/auth/sign-in/email', + contentType: 'application/json', + body: '{"email":"nobody@example.com","password":"not-the-password"}', + }, + } + // Act + const configuration = nodeServerConfigSchema.parse(declaration) + // Assert + assert.deepEqual(configuration.request, { + method: 'POST', + path: '/api/auth/sign-in/email', + contentType: 'application/json', + body: '{"email":"nobody@example.com","password":"not-the-password"}', + }) +}) + +test('a node-server request path keeps a query string, also one with an encoded slash', () => { + // protects: ordinary query strings; fails_when: a query is refused for the path component's rules; no_claim: the server accepts it. + // Arrange + const declaration = { + ...minimalGetDeclaration(), + request: { method: 'GET', path: '/api/post_list?page=1&next=%2Fhome' }, + } + // Act + const result = nodeServerConfigSchema.safeParse(declaration) + // Assert + assert.equal(result.success, true) +}) + +test('a node-server request cannot leave the declared server or walk its path', () => { + // protects: "no arbitrary requests": the request goes to the declared server's own path only; fails_when: any listed path parses; no_claim: the server's routing. + // Arrange + const refusedPaths = [ + 'api/healthz', + 'http://localhost:4100/api/healthz', + '//evil.example/api', + '/api/healthz#fragment', + '/api/../admin', + '/api/%2e%2e/admin', + '/api/%2E%2E/admin', + '/api%2fadmin', + '/api%5Cadmin', + '/api\\admin', + '/api/healthz\r\nX-Injected: 1', + '/api/\u0000', + '/api/health z', + '/api/café', + `/${'a'.repeat(2_048)}`, + ] + // Act + const outcomes = refusedPaths.map((path) => { + const result = nodeServerConfigSchema.safeParse({ + ...minimalGetDeclaration(), + request: { method: 'GET', path }, + }) + return { + path, + success: result.success, + issuePaths: result.success + ? [] + : result.error.issues.map((issue) => issue.path.join('.')), + } + }) + // Assert + for (const outcome of outcomes) { + assert.equal( + outcome.success, + false, + `parsed: ${JSON.stringify(outcome.path)}`, + ) + assert.deepEqual(outcome.issuePaths, ['request.path']) + } +}) + +test('a node-server request body needs a content type, stays within 16,384 bytes and never rides on a GET or DELETE', () => { + // protects: the bounded request body; fails_when: any listed body rule parses; no_claim: the server reads the body. + // Arrange + const base = minimalGetDeclaration() + const refusedRequests = [ + { method: 'GET', path: '/api/items', contentType: 'text/plain', body: 'x' }, + { + method: 'DELETE', + path: '/api/items/1', + contentType: 'text/plain', + body: 'x', + }, + { method: 'POST', path: '/api/items', body: 'x' }, + { method: 'POST', path: '/api/items', contentType: 'application/json' }, + { + method: 'PUT', + path: '/api/items/1', + contentType: 'text/plain', + body: 'あ'.repeat(5_462), + }, + ] + // Act + const outcomes = refusedRequests.map( + (request) => nodeServerConfigSchema.safeParse({ ...base, request }).success, + ) + const largestAccepted = nodeServerConfigSchema.safeParse({ + ...base, + request: { + method: 'PATCH', + path: '/api/items/1', + contentType: 'text/plain', + body: 'a'.repeat(16_384), + }, + }).success + // Assert + assert.deepEqual(outcomes, [false, false, false, false, false]) + assert.equal(largestAccepted, true) +}) + +test('a node-server port is the server own port from 1024 to 65535', () => { + // protects: "no arbitrary ports"; fails_when: a privileged, fractional or out-of-range port parses; no_claim: the server listens there. + // Arrange + const base = minimalGetDeclaration() + // Act + const outcomes = [80, 1023, 1024, 65_535, 65_536, 4100.5, 0].map( + (port) => nodeServerConfigSchema.safeParse({ ...base, port }).success, + ) + // Assert + assert.deepEqual(outcomes, [false, false, true, true, false, false, false]) +}) + +test('a node-server entry is a script inside its package root that node cannot read as an option', () => { + // protects: the launch argv; fails_when: an option-like, non-script or traversing entry parses; no_claim: the file exists. + // Arrange + const base = minimalGetDeclaration() + const refusedEntries = [ + '--inspect=0.0.0.0:9229', + '-e.ts', + 'src/server.json', + '../server.ts', + '/srv/server.ts', + 'src/server', + ] + // Act + const outcomes = refusedEntries.map( + (entry) => nodeServerConfigSchema.safeParse({ ...base, entry }).success, + ) + const acceptedExtensions = [ + 'a.ts', + 'a.mts', + 'a.cts', + 'a.js', + 'a.mjs', + 'a.cjs', + ].map((entry) => nodeServerConfigSchema.safeParse({ ...base, entry }).success) + // Assert + assert.deepEqual(outcomes, [false, false, false, false, false, false]) + assert.deepEqual(acceptedExtensions, [true, true, true, true, true, true]) +}) + +test('a TypeScript node-server entry cannot be declared to run with plain node', () => { + // protects: the loader rule; fails_when: a .ts entry with loader node parses; no_claim: node's own type stripping. + // Arrange + const declaration = { ...minimalGetDeclaration(), loader: 'node' } + // Act + const result = nodeServerConfigSchema.safeParse(declaration) + // Assert + assert.equal(result.success, false) + assert.deepEqual( + result.error?.issues.map((issue) => issue.path.join('.')), + ['loader'], + ) +}) + +test('a node-server declaration has no command, environment or arbitrary adapter', () => { + // protects: "no arbitrary commands"; fails_when: an extra field or another adapter parses; no_claim: none. + // Arrange + const base = minimalGetDeclaration() + // Act + const outcomes = [ + { ...base, command: 'pnpm dev' }, + { ...base, env: { PORT: '4100' } }, + { ...base, nodeArgs: ['--inspect=0.0.0.0'] }, + { ...base, adapter: 'shell' }, + ].map((declaration) => nodeServerConfigSchema.safeParse(declaration).success) + // Assert + assert.deepEqual(outcomes, [false, false, false, false]) +}) + +test('a node-server declaration keeps at most seven additional stops, each with a required value', () => { + // protects: the shared stop bound and the value rule; fails_when: eight additional stops or a type-only stop parse; no_claim: the stops bind. + // Arrange + const base = minimalGetDeclaration() + const additionalStop = { + ...base.source, + line: 60, + anchorIndex: 0, + inspections: [{ path: 'values', mode: 'value', required: true }], + } + const typeOnlyStop = { + ...additionalStop, + inspections: [{ path: 'values', mode: 'type', required: true }], + } + // Act + const sevenStops = nodeServerConfigSchema.safeParse({ + ...base, + additionalStops: Array.from({ length: 7 }, () => additionalStop), + }).success + const eightStops = nodeServerConfigSchema.safeParse({ + ...base, + additionalStops: Array.from({ length: 8 }, () => additionalStop), + }).success + const typeOnly = nodeServerConfigSchema.safeParse({ + ...base, + additionalStops: [typeOnlyStop], + }).success + // Assert + assert.equal(sevenStops, true) + assert.equal(eightStops, false) + assert.equal(typeOnly, false) +}) + +test('a refused node-server declaration lists every violation with its field and expected value and names the help', () => { + // protects: INVALID_CONFIGURATION's cause for node-server; fails_when: a violation is missing, unnamed or the help link is gone; no_claim: the MCP error envelope (pt2). + // Arrange + const declaration = { + ...minimalGetDeclaration(), + port: 80, + request: { method: 'GET', path: '/api/../admin' }, + } + const result = nodeServerConfigSchema.safeParse(declaration) + assert.equal(result.success, false) + // Act + const description = describeNodeServerConfigurationIssues( + result.error?.issues ?? [], + ) + // Assert + assert.equal( + description, + 'configuration.port: expected an integer from 1024 to 65535, the port the server\'s own code listens on. configuration.request.path: expected a path on the server starting with "/", without ".." segments. See codebook://help/node-server.', + ) +}) diff --git a/extension/src/tests/readPortListeners.test.ts b/extension/src/tests/readPortListeners.test.ts new file mode 100644 index 000000000..1911fd212 --- /dev/null +++ b/extension/src/tests/readPortListeners.test.ts @@ -0,0 +1,366 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { createServer } from 'node:net' +import { test } from 'node:test' + +import type { ListenerTool } from '../utils/listenerToolPath' +import { listenerToolPath } from '../utils/listenerToolPath' +import { readPortListeners } from '../utils/readPortListeners' +import type { BoundedQueryRunner } from '../utils/runBoundedQuery' + +const LSOF: ListenerTool = { kind: 'lsof', path: '/usr/sbin/lsof' } +const SS: ListenerTool = { kind: 'ss', path: '/usr/bin/ss' } + +/** + * Builds a runner that records its calls and answers the same output or rejects with the same failure, in place of `lsof` or `ss`. + * @param answer - What the tool prints, or the failure it ends with. + * @returns The runner and its recorded calls. + * @example answeringTool({ stdout: 'p1\nf3\ntIPv4\nn127.0.0.1:4100\n' }) + */ +function answeringTool(answer: { stdout: string } | Error): { + run: BoundedQueryRunner + calls: { file: string; queryArguments: string[]; timeout: number }[] +} { + const calls: { file: string; queryArguments: string[]; timeout: number }[] = + [] + return { + calls, + run: async (file, queryArguments, options) => { + calls.push({ file, queryArguments, timeout: options.timeout }) + if (answer instanceof Error) throw answer + return answer + }, + } +} + +test('the listener tool on macOS is lsof at its system path and never a Homebrew ss', async () => { + // protects: ownership answered only by system programs; fails_when: a search-path or Homebrew program is chosen; no_claim: lsof's output. + // Arrange + const looked: string[] = [] + const isProgramFile = async (path: string): Promise => { + looked.push(path) + return path === '/usr/bin/ss' || path === '/usr/bin/lsof' + } + // Act + const tool = await listenerToolPath({ platform: 'darwin', isProgramFile }) + // Assert + assert.deepEqual(tool, { kind: 'lsof', path: '/usr/bin/lsof' }) + assert.deepEqual(looked, ['/usr/sbin/lsof', '/usr/bin/lsof']) +}) + +test('the listener tool on Linux without lsof is ss at a system path, and native Windows has none', async () => { + // protects: the Linux fallback and the Windows prerequisite; fails_when: ss is skipped on Linux or a tool is named on win32; no_claim: none. + // Arrange + const isProgramFile = async (path: string): Promise => + path === '/usr/sbin/ss' + // Act + const linux = await listenerToolPath({ platform: 'linux', isProgramFile }) + const macos = await listenerToolPath({ platform: 'darwin', isProgramFile }) + const windows = await listenerToolPath({ + platform: 'win32', + isProgramFile: async () => true, + }) + // Assert + assert.deepEqual(linux, { kind: 'ss', path: '/usr/sbin/ss' }) + assert.equal(macos, undefined) + assert.equal(windows, undefined) +}) + +test('a port held by a dual-stack server and a loopback IPv4 server reads both listeners with their processes', async () => { + // protects: the IPv6-owned / IPv4-foreign case the run must refuse; fails_when: a socket record is dropped or merged; no_claim: the ownership decision (pt2). + // Arrange + const tool = answeringTool({ + stdout: 'p48633\nf18\ntIPv6\nn*:4100\np51000\nf7\ntIPv4\nn127.0.0.1:4100\n', + }) + // Act + const reading = await readPortListeners(4100, { + findTool: async () => LSOF, + run: tool.run, + }) + // Assert + assert.deepEqual(reading, { + kind: 'read', + tool: 'lsof', + listeners: [ + { family: 'IPv6', address: '*', port: 4100, pid: 48633 }, + { family: 'IPv4', address: '127.0.0.1', port: 4100, pid: 51000 }, + ], + }) + assert.deepEqual(tool.calls, [ + { + file: '/usr/sbin/lsof', + queryArguments: ['-nP', '-iTCP:4100', '-sTCP:LISTEN', '-Fptn'], + timeout: 3_000, + }, + ]) +}) + +test('lsof exiting 1 without a word reads as no listener on the port', async () => { + // protects: a free port is not "owner unknown"; fails_when: lsof's silent exit 1 becomes unknown; no_claim: none. + // Arrange + const tool = answeringTool( + Object.assign(new Error('Command failed'), { + code: 1, + stdout: '', + stderr: '', + }), + ) + // Act + const reading = await readPortListeners(4100, { + findTool: async () => LSOF, + run: tool.run, + }) + // Assert + assert.deepEqual(reading, { kind: 'read', tool: 'lsof', listeners: [] }) +}) + +test('a listener query that runs past 3 seconds leaves the port owner unknown', async () => { + // protects: PORT_OWNER_UNKNOWN's bound; fails_when: a hung tool counts as no listener; no_claim: the error code mapping (pt2). + // Arrange + const tool = answeringTool( + Object.assign(new Error('Command failed'), { + killed: true, + signal: 'SIGKILL', + code: null, + }), + ) + // Act + const reading = await readPortListeners(4100, { + findTool: async () => LSOF, + run: tool.run, + }) + // Assert + assert.deepEqual(reading, { + kind: 'unknown', + reason: 'lsof did not answer within 3 s.', + }) +}) + +test('a failing or missing listener tool leaves the port owner unknown with its reason', async () => { + // protects: unknown never passes for "no listener"; fails_when: a failure or a missing tool reads as empty; no_claim: none. + // Arrange + const failing = answeringTool( + Object.assign(new Error('Command failed'), { + code: 2, + stdout: '', + stderr: 'lsof: unsupported option\n', + }), + ) + // Act + const failed = await readPortListeners(4100, { + findTool: async () => LSOF, + run: failing.run, + }) + const missing = await readPortListeners(4100, { + findTool: async () => undefined, + }) + // Assert + assert.deepEqual(failed, { + kind: 'unknown', + reason: 'lsof failed: lsof: unsupported option.', + }) + assert.deepEqual(missing, { + kind: 'unknown', + reason: + 'No lsof lies at /usr/sbin/lsof or /usr/bin/lsof (on Linux, no ss at /usr/bin/ss, /bin/ss, /usr/sbin/ss or /sbin/ss), and Codebook starts them only from there.', + }) +}) + +test('ss on Linux reads each listening socket with its processes, and another user socket without a pid', async () => { + // protects: the Linux reading; fails_when: a forked holder, an IPv6 wildcard or a foreign user's socket is misread; no_claim: ss's exact column widths. + // Arrange + const tool = answeringTool({ + stdout: [ + 'LISTEN 0 511 *:4100 *:* users:(("node",pid=1234,fd=18),("node",pid=1240,fd=18))', + 'LISTEN 0 4096 127.0.0.1:4100 0.0.0.0:*', + 'LISTEN 0 511 [::1]:4100 [::]:* users:(("node",pid=1234,fd=19))', + '', + ].join('\n'), + }) + // Act + const reading = await readPortListeners(4100, { + findTool: async () => SS, + run: tool.run, + }) + // Assert + assert.deepEqual(reading, { + kind: 'read', + tool: 'ss', + listeners: [ + { family: 'IPv6', address: '*', port: 4100, pid: 1234 }, + { family: 'IPv6', address: '*', port: 4100, pid: 1240 }, + { family: 'IPv4', address: '127.0.0.1', port: 4100, pid: undefined }, + { family: 'IPv6', address: '::1', port: 4100, pid: 1234 }, + ], + }) + assert.deepEqual(tool.calls[0]?.queryArguments, [ + '-H', + '-l', + '-t', + '-n', + '-p', + 'sport', + '=', + ':4100', + ]) +}) + +test('a listener line the reader does not understand leaves the port owner unknown', async () => { + // protects: no ownership decision on a misread answer; fails_when: a garbled line is skipped silently; no_claim: none. + // Arrange + const tool = answeringTool({ stdout: 'p48633\nf18\ntIPv6\nn*:http\n' }) + // Act + const reading = await readPortListeners(4100, { + findTool: async () => LSOF, + run: tool.run, + }) + // Assert + assert.deepEqual(reading, { + kind: 'unknown', + reason: 'lsof printed a listener line Codebook cannot read.', + }) +}) + +test('the system listener tool names this process as the listener of a dual-stack server it started', async (t) => { + // protects: the real lsof or ss reading on the machine; fails_when: the real output no longer parses or names another process; no_claim: a foreign user's socket. + // Arrange + const tool = await listenerToolPath() + if (!tool) { + t.skip('no lsof or ss lies at a system path on this machine') + return + } + const server = createServer() + server.listen(0, '::') + await once(server, 'listening') + t.after(() => server.close()) + const address = server.address() + assert.ok(address && typeof address !== 'string') + const { port } = address + // Act + const reading = await readPortListeners(port) + // Assert + assert.equal(reading.kind, 'read') + assert.deepEqual( + reading.kind === 'read' + ? reading.listeners.map((listener) => ({ + port: listener.port, + pid: listener.pid, + })) + : [], + [{ port, pid: process.pid }], + ) +}) + +// Value: protects=bracketed IPv6 and zone-scoped listener addresses read as the bare address in the IPv6 family; fails_when=brackets or the %zone stay in the address, or the family is lost, so an owned IPv6 listener reads as foreign; why_new=existing rows cover only the wildcard, 127.0.0.1 and ::1 in ss; seam=none; no_claim=real lsof output (stubbed runner; unit only) +test('lsof reads a link-local IPv6 listener with its zone removed and a bracketed loopback unchanged', async () => { + // Arrange + const tool = answeringTool({ + stdout: 'p100\nf5\ntIPv6\nn[fe80::1%lo0]:4100\nf6\ntIPv6\nn[::1]:4100\n', + }) + // Act + const reading = await readPortListeners(4100, { + findTool: async () => LSOF, + run: tool.run, + }) + // Assert + assert.deepEqual(reading, { + kind: 'read', + tool: 'lsof', + listeners: [ + { family: 'IPv6', address: 'fe80::1', port: 4100, pid: 100 }, + { family: 'IPv6', address: '::1', port: 4100, pid: 100 }, + ], + }) +}) + +// Value: protects=a listener answer that is malformed, oversized or an lsof failure with a message never reads as "no listener" or a guessed owner; fails_when=an out-of-range port, a socket before its family, a non-numeric pid, a non-LISTEN ss row, an oversized answer or lsof exit 1 with stderr is skipped or read as empty; why_new=existing rows cover only a named-service port and exit 1 without a word; seam=none; no_claim=the port-owner error code mapping (pt2; stubbed runner) +test('a malformed, oversized or noisy listener answer leaves the port owner unknown with its own reason', async () => { + // Arrange + const unreadableLsof = (stdout: string) => ({ stdout }) + const cases: { + tool: ListenerTool + answer: { stdout: string } | Error + reason: string + }[] = [ + { + tool: LSOF, + answer: unreadableLsof('p48633\nf18\ntIPv4\nn127.0.0.1:70000\n'), + reason: 'lsof printed a listener line Codebook cannot read.', + }, + { + tool: LSOF, + answer: unreadableLsof('p48633\nf18\nn127.0.0.1:4100\n'), + reason: 'lsof printed a listener line Codebook cannot read.', + }, + { + tool: LSOF, + answer: unreadableLsof('pabc\nf18\ntIPv4\nn127.0.0.1:4100\n'), + reason: 'lsof printed a listener line Codebook cannot read.', + }, + { + tool: SS, + answer: { + stdout: 'ESTAB 0 0 127.0.0.1:4100 127.0.0.1:5000\n', + }, + reason: 'ss printed a listener line Codebook cannot read.', + }, + { + tool: LSOF, + answer: Object.assign(new Error('Command failed'), { + code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', + }), + reason: 'lsof printed more than 1048576 bytes.', + }, + { + tool: LSOF, + answer: Object.assign(new Error('Command failed'), { + code: 1, + stdout: '', + stderr: "lsof: WARNING: can't stat() fuse file system\n", + }), + reason: "lsof failed: lsof: WARNING: can't stat() fuse file system.", + }, + ] + // Act + const readings = [] + for (const { tool, answer } of cases) + readings.push( + await readPortListeners(4100, { + findTool: async () => tool, + run: answeringTool(answer).run, + }), + ) + // Assert + assert.deepEqual( + readings, + cases.map(({ reason }) => ({ kind: 'unknown', reason })), + ) +}) + +// Value: protects=an older ss that prints IPv6 local addresses without brackets (`:::4100`, `::1:4100`) still reads them in the IPv6 family; fails_when=the family is taken from a leading bracket only, so an owned IPv6 listener reads as IPv4 and the ownership check compares the wrong family; why_new=existing ss rows are all bracketed or `*`; seam=none; no_claim=every iproute2 release's exact columns (stubbed runner; unit only) +test('an older ss that prints IPv6 addresses without brackets reads them as IPv6 listeners', async () => { + // Arrange + const tool = answeringTool({ + stdout: [ + 'LISTEN 0 128 :::4100 :::* users:(("node",pid=1234,fd=18))', + 'LISTEN 0 128 ::1:4100 :::* users:(("node",pid=1234,fd=19))', + 'LISTEN 0 128 127.0.0.1:4100 0.0.0.0:* users:(("node",pid=1234,fd=20))', + '', + ].join('\n'), + }) + // Act + const reading = await readPortListeners(4100, { + findTool: async () => SS, + run: tool.run, + }) + // Assert + assert.deepEqual(reading, { + kind: 'read', + tool: 'ss', + listeners: [ + { family: 'IPv6', address: '::', port: 4100, pid: 1234 }, + { family: 'IPv6', address: '::1', port: 4100, pid: 1234 }, + { family: 'IPv4', address: '127.0.0.1', port: 4100, pid: 1234 }, + ], + }) +}) diff --git a/extension/src/tests/redactionContract.test.ts b/extension/src/tests/redactionContract.test.ts new file mode 100644 index 000000000..28685cf7c --- /dev/null +++ b/extension/src/tests/redactionContract.test.ts @@ -0,0 +1,929 @@ +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' + +import type { DebugProtocol } from '@vscode/debugprotocol' +import type { Protocol } from 'devtools-protocol' +import type * as vscode from 'vscode' + +import { OUTPUT_REDACTION_HOLD_LIMIT_CHARS } from '../constants' +import { captureReplayDebugSnapshot } from '../utils/captureReplayDebugSnapshot' +import { createOutputRedactor } from '../utils/createOutputRedactor' +import { formatCodebookDiagnostic } from '../utils/formatCodebookDiagnostic' +import { inspectConfiguredValues } from '../utils/inspectConfiguredValues' +import { inspectNodeTestVariables } from '../utils/inspectNodeTestVariables' +import { isSecretName } from '../utils/isSecretName' +import { launchNodeTest } from '../utils/launchNodeTest' +import { redactInspectedValue } from '../utils/redactInspectedValue' +import { redactSecretText } from '../utils/redactSecretText' +import { redactValuePreview } from '../utils/redactValuePreview' +import { sanitizeCause } from '../utils/sanitizeCause' +import { sourceImportBindings } from '../utils/sourceImportBindings' + +import { ROOT_MODE_EXECUTION } from './utils/rootModeExecutionPlan' + +// Credential-shaped fixtures are joined at run time, so this file holds no literal a secret scanner takes for a real credential. +const JWT = [ + 'eyJhbGciOiJIUzI1NiJ9', + 'eyJzdWIiOiIxMjM0NTY3ODkwIn0', + 'dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk', +].join('.') +const GITHUB_TOKEN = ['ghp', '0123456789abcdefghijklmnopqrstuvwxyz'].join('_') +const PEM_HEADER = ['-----BEGIN', 'PRIVATE KEY-----'].join(' ') +const PEM_BODY = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7\nabc=' +const PEM = `${PEM_HEADER}\n${PEM_BODY}\n${['-----END', 'PRIVATE KEY-----'].join(' ')}` +/** The scheme of the database URLs below, kept apart from their user and password for the same reason. */ +const POSTGRES = 'postgres://' + +// protects=the name rule on words; fails_when=accessToken or db_passwd stays visible, or tokenCount/sortKey is masked; no_claim=values in plainly named variables +test('a secret-named variable is masked by its final word while count and sort names stay visible', () => { + // Arrange + const masked = [ + 'accessToken', + 'refresh-token', + 'db_passwd', + 'clientSecret', + 'Authorization', + 'set-cookie', + 'apiKey', + 'api_key', + 'privateKey', + 'session', + 'password', + 'credentials', + 'APIKey', + ] + const visible = ['tokenCount', 'sortKey', 'passwordPolicy', 'key', 'amount'] + // Act + const maskedResults = masked.map(isSecretName) + const visibleResults = visible.map(isSecretName) + // Assert + assert.deepEqual( + maskedResults, + masked.map(() => true), + ) + assert.deepEqual( + visibleResults, + visible.map(() => false), + ) +}) + +// protects=credential names the pt1 security review found visible (secret/access/signing keys, passphrases, short password names, JWTs, session ids); fails_when=AWS_SECRET_ACCESS_KEY, secretKey, passphrase, pwd, jwt or sessionId reads its value, or publicKey/primaryKey/cacheKey/idempotencyKey is masked; no_claim=names outside these words +test('a key named for secret, access, signing or session use, a passphrase, a JWT and a session id are masked while public and index keys stay visible', () => { + // Arrange + const masked = [ + 'secretAccessKey', + 'AWS_SECRET_ACCESS_KEY', + 'secretKey', + 'accessKey', + 'signingKey', + 'encryption_key', + 'masterKey', + 'clientKey', + 'authKey', + 'sessionKey', + 'passphrase', + 'pwd', + 'dbPass', + 'jwt', + 'sessionId', + 'session_id', + 'sid', + ] + const visible = [ + 'publicKey', + 'primaryKey', + 'cacheKey', + 'idempotencyKey', + 'foreignKey', + 'userId', + ] + // Act + const maskedResults = masked.map(isSecretName) + const visibleResults = visible.map(isSecretName) + // Assert + assert.deepEqual( + maskedResults, + masked.map(() => true), + ) + assert.deepEqual( + visibleResults, + visible.map(() => false), + ) +}) + +// protects=provider keys the pt1 security review named (Stripe live/test secret and restricted keys, Slack tokens, Google API keys, npm tokens, AWS session keys); fails_when=any of them survives in captured text; no_claim=unprefixed secrets +test('Stripe, Slack, Google, npm and AWS session keys in captured text read [masked]', () => { + // Arrange + const keys = [ + ['sk', 'live', 'a1B2c3D4e5F6g7H8i9J0k1L2'].join('_'), + ['rk', 'test', 'Z9y8X7w6V5u4T3s2R1q0P9o8'].join('_'), + ['xoxb', '123456789012', '123456789012', 'AbCdEfGhIjKlMnOpQrStUvWx'].join( + '-', + ), + ['AIza', 'SyA1234567890abcdefghijklmnopqrstuv'].join(''), + ['npm', 'abcdefghijklmnopqrstuvwxyz0123456789'].join('_'), + ['ASIA', 'ABCDEFGHIJKLMNOP'].join(''), + ] + // Act + const redacted = keys.map((key) => redactSecretText(`key ${key} end`)) + // Assert + assert.deepEqual( + redacted, + keys.map(() => 'key [masked] end'), + ) +}) + +// protects=fail-closed masking only for secret shapes, not for ordinary long runs (pt1 red-team finding); fails_when=a 9 KiB run that only mentions a task- word and an https URL is masked or dropped, or a private-key header without an end drops the rest of the channel; no_claim=secrets of unknown shape +test('a long run that only mentions a URL and a task- word stays visible, and an unterminated private key no longer silences the channel', () => { + // Arrange + const benign = `task-runner:https://example.com/chunk/${'a'.repeat(9_000)}` + const benignRedactor = createOutputRedactor() + const keyRedactor = createOutputRedactor() + // Act + const benignOutput = + benignRedactor.write(benign) + + benignRedactor.write('\n') + + benignRedactor.end() + const keyOutput = + keyRedactor.write(`${PEM_HEADER}\n${'QUJD'.repeat(6_000)}`) + + keyRedactor.write( + `${' x'.repeat(10_000)}\nlistening on http://127.0.0.1:4000\n`, + ) + + keyRedactor.end() + // Assert + assert.equal(benignOutput, `${benign}\n`) + assert.equal(keyOutput.startsWith('[masked]'), true) + assert.equal(keyOutput.includes('QUJD'), false) + assert.equal(keyOutput.endsWith('listening on http://127.0.0.1:4000\n'), true) +}) + +// protects=an oversized private-key block never takes the output after it (pt1 pass 2 QA finding: a real process wrote the block and its next line in one chunk); fails_when=the line that follows the block in the same chunk is dropped with it; no_claim=lines inside the dropped block +test('a line that follows an oversized private key in the same chunk is still released', () => { + // Arrange + const redactor = createOutputRedactor() + // Act + const output = + redactor.write( + `${PEM_HEADER}\n${'QUJD'.repeat(7_500)}\nlistening on http://127.0.0.1:4000\n`, + ) + redactor.end() + // Assert + assert.equal(output, '[masked]listening on http://127.0.0.1:4000\n') +}) + +// protects=the drop limit counts only dropped key text (pt1 pass 2 security finding: the kept 80-character tail was counted again on every chunk); fails_when=a private key delivered in small chunks reaches the limit early and its body is released; no_claim=keys of unknown shape +test('a private key delivered in small chunks never releases its body', () => { + // Arrange + const body = Array.from({ length: 200 }, () => 'QUJD'.repeat(16)).join('\n') + const stream = `${PEM_HEADER}\n${body}\n${['-----END', 'PRIVATE KEY-----'].join(' ')}\ndone\n` + const redactor = createOutputRedactor() + // Act + let output = '' + for (let index = 0; index < stream.length; index += 7) + output += redactor.write(stream.slice(index, index + 7)) + output += redactor.end() + // Assert + assert.equal(output.includes('QUJD'), false) + assert.equal(output.startsWith('[masked]'), true) + assert.equal(output.endsWith('\ndone\n'), true) +}) + +// protects=a token whose drop is still pending when the stream ends releases nothing more (pt1 pass 2 state-machine cell token x end); fails_when=end() returns part of the dropped token; no_claim=none +test('a stream that ends while an oversized token is being dropped releases nothing more at its end', () => { + // Arrange + const redactor = createOutputRedactor() + const token = ['ghp', 'a'.repeat(9_000)].join('_') + // Act + const written = redactor.write(`using ${token}`) + const ended = redactor.end() + // Assert + assert.equal(written, 'using [masked]') + assert.equal(ended, '') +}) + +// protects=the streaming redactor's fail-closed trigger knows every provider prefix redactSecretText masks (pt1 pass 2 maintainability finding: the two lists are kept apart); fails_when=a provider key longer than the hold is released unmasked because the trigger lacks its prefix; no_claim=prefixes added to neither list +test('every provider key longer than the hold fails closed in streamed output', () => { + // Arrange + const keys = [ + ['sk', 'a'.repeat(9_000)].join('-'), + ['sk', 'live', 'a'.repeat(9_000)].join('_'), + ['ghp', 'a'.repeat(9_000)].join('_'), + ['github', 'pat', 'a'.repeat(9_000)].join('_'), + ['xoxb', 'a'.repeat(9_000)].join('-'), + ['AIza', 'a'.repeat(9_000)].join(''), + ['npm', 'a'.repeat(9_000)].join('_'), + ['AKIA', 'A'.repeat(9_000)].join(''), + ['ASIA', 'A'.repeat(9_000)].join(''), + ['eyJ', 'a'.repeat(9_000)].join(''), + ] + // Act + const outputs = keys.map((key) => { + const redactor = createOutputRedactor() + return redactor.write(`key ${key}`) + redactor.end() + }) + // Assert + assert.deepEqual( + outputs, + keys.map(() => 'key [masked]'), + ) +}) + +// protects=a stream that ends while an oversized private key is being dropped releases nothing more (pt1 pass 3 state-machine cell block-drop x end); fails_when=end() returns part of the dropped block; no_claim=none +test('a stream that ends while an oversized private key is being dropped releases nothing more at its end', () => { + // Arrange + const redactor = createOutputRedactor() + // Act + const written = redactor.write(`${PEM_HEADER}\n${'A'.repeat(9_000)}`) + const ended = redactor.end() + // Assert + assert.equal(written, '[masked]') + assert.equal(ended, '') +}) + +// protects=a token drop goes on across a chunk without whitespace and resumes at the first whitespace (pt1 pass 3 state-machine cell token-drop x write-continue); fails_when=the continuing chunk is released or the text after the token is lost; no_claim=none +test('an oversized token keeps being dropped across a chunk without whitespace and output resumes after it', () => { + // Arrange + const redactor = createOutputRedactor() + const token = ['ghp', 'b'.repeat(9_000)].join('_') + // Act + const first = redactor.write(`token ${token}`) + const continuing = redactor.write('c'.repeat(500)) + const resumed = redactor.write('ccc ok\n') + // Assert + assert.equal(first, 'token [masked]') + assert.equal(continuing, '') + assert.equal(resumed, ' ok\n') +}) + +// protects=the private-key END search stays linear on crafted output (pt1 Step 11 native finding: unbounded label groups backtracked quadratically); fails_when=a long run of repeated labels after -----END makes the redactor take seconds; no_claim=other regexes +test('a long run of repeated key labels after an END marker is redacted without stalling', () => { + // Arrange + const crafted = `-----END ${'PRIVATE KEY '.repeat(40_000)}` + const redactor = createOutputRedactor() + const started = performance.now() + // Act + const text = redactSecretText(crafted) + const streamed = redactor.write(`${PEM_HEADER}\n${crafted}`) + redactor.end() + const elapsedMs = performance.now() - started + // Assert + assert.equal(text, crafted) + assert.equal(streamed.includes('PRIVATE KEY PRIVATE KEY'), false) + // gstack-timing-ok: the bound separates linear (milliseconds) from the quadratic backtracking this guards (about 16 s on this input); a synchronous body cannot be stopped by a test timeout. + assert.ok(elapsedMs < 2_000, `took ${Math.round(elapsedMs)} ms`) +}) + +// protects=a private key padded past the drop limit never releases its body (pt1 Step 11 Codex finding: a key padded with spaces passed the limit at a line break and its body leaked); fails_when=key body lines after the limit are released before the END marker; no_claim=keys without an END whose body continues after a punctuated line +test('a private key padded with spaces past the drop limit keeps its body dropped until its END marker', () => { + // Arrange + const redactor = createOutputRedactor() + const end = ['-----END', 'PRIVATE KEY-----'].join(' ') + // Act + const output = + redactor.write(`${PEM_HEADER}\nMC4CAQAw${' '.repeat(16_400)}\n`) + + redactor.write(`QUJDQUJDQUJDQUJDQUJDQUJDQUJD\n${end}\ndone: ok\n`) + + redactor.end() + // Assert + assert.equal(output.includes('QUJD'), false) + assert.equal(output.includes('MC4CAQAw'), false) + assert.equal(output, '[masked]\ndone: ok\n') +}) + +// protects=after the drop limit an unterminated key ends only at a line no key body can be (one with punctuation), so plain body-like lines stay dropped; fails_when=a base64- or word-only line after the limit is released; no_claim=key bodies containing punctuation +test('after the drop limit an unterminated private key resumes at the first punctuated line only', () => { + // Arrange + const redactor = createOutputRedactor() + // Act + const output = + redactor.write(`${PEM_HEADER}\n${'QUJD'.repeat(7_500)}\n`) + + redactor.write('QUJDQUJD\nplain words line\nlistening on :4000\n') + + redactor.end() + // Assert + assert.equal(output, '[masked]listening on :4000\n') +}) + +// protects=block mode only for a private-key header (pt1 Step 11 native finding: any -----BEGIN text in a long token switched to block mode and dropped ordinary output); fails_when=a long token that merely contains -----BEGIN drops the lines after it; no_claim=none +test('a long token that merely contains a BEGIN marker is dropped only to the next whitespace', () => { + // Arrange + const redactor = createOutputRedactor() + const token = ['ghp', `${'x'.repeat(9_000)}-----BEGIN-CERT`].join('_') + // Act + const output = + redactor.write(`using ${token}`) + + redactor.write(' next line\n') + + redactor.end() + // Assert + assert.equal(output, 'using [masked] next line\n') +}) + +// protects=a URL whose username outgrows the hold keeps its password masked (pt1 Step 11 Codex finding: the scheme was released before the password arrived); fails_when=the password after a long username is released; no_claim=none +test('a URL whose username is longer than the hold never releases the password that follows', () => { + // Arrange + const redactor = createOutputRedactor() + // Act + const output = + redactor.write(`https://${'u'.repeat(9_000)}`) + + redactor.write(`:${'password123'}@host.example/path\n`) + + redactor.end() + // Assert + assert.equal(output.includes('password123'), false) + assert.equal(output.startsWith('[masked]'), true) +}) + +// protects=an AWS key right after lowercase text is held and masked like the whole-text rule masks it (pt1 Step 11 Codex finding: the two lookbehinds differed); fails_when=the key is released in pieces; no_claim=none +test('an AWS access key right after lowercase text is never released in pieces', () => { + // Arrange + const redactor = createOutputRedactor() + const key = ['AKIA', 'IOSFODNN7EXAMPLE'].join('') + // Act + const output = + redactor.write(`${'a'.repeat(8_174)}${key}`) + + redactor.write('\n') + + redactor.end() + // Assert + assert.equal(output.includes('IOSFODNN7EXAMPLE'), false) + assert.equal(output.includes(key.slice(0, 8)), false) +}) + +// protects=the carry of a released long run never splits a surrogate pair (pt1 Step 11 Codex finding: an emoji at the carry boundary became two U+FFFD); fails_when=the released text differs from the input; no_claim=grapheme clusters +test('a long run released with its carry keeps an emoji at the carry boundary whole', () => { + // Arrange + const redactor = createOutputRedactor() + const run = `${'a'.repeat(9_000)}😀${'b'.repeat(13)}gh` + // Act + const first = redactor.write(run) + const output = first + redactor.write('ost\n') + redactor.end() + // Assert + assert.equal(/[\uD800-\uDBFF]$/.test(first), false) + assert.equal(output, `${run}ost\n`) +}) + +// protects=a stream's held possible secret is masked, not released raw, when it ends or is flushed; fails_when=end() releases the start of a token; no_claim=none +test('a token start still held when the stream ends reads [masked]', () => { + // Arrange + const redactor = createOutputRedactor() + // Act + const output = redactor.write(`fatal: token ${'ghp'}_abcdef`) + redactor.end() + // Assert + assert.equal(output, 'fatal: token [masked]') +}) + +// protects=the origin a server prints last without a line break (local CodeRabbit r4: the whole URL was masked at the stream's end); fails_when=a loopback or IP host:port URL that ends the output reads [masked]; no_claim=a URL held mid-stream +test("a server's last line without a line break keeps its loopback or IP origin when the stream ends", () => { + // Arrange + const lines = [ + 'listening on http://127.0.0.1:4100', + 'listening on http://localhost:4100', + 'listening on http://[::1]:4100', + ] + // Act + const outputs = lines.map((line) => { + const redactor = createOutputRedactor() + return redactor.write(line) + redactor.end() + }) + // Assert + assert.deepEqual(outputs, [ + 'listening on http://127.0.0.1:4100', + 'listening on http://localhost:4100', + 'listening on http://[::1]:4100', + ]) +}) + +// protects=a URL whose port and path close its authority is complete at the stream's end; fails_when=it reads [masked]; no_claim=URLs without a port +test("a URL whose port and path close its authority is released whole at the stream's end", () => { + // Arrange + const redactor = createOutputRedactor() + // Act + const output = + redactor.write('GET http://db.internal:5432/app?x=1') + redactor.end() + // Assert + assert.equal(output, 'GET http://db.internal:5432/app?x=1') +}) + +// protects=a URL cut off after a user name keeps its possible password masked at the stream's end; fails_when=`user:digits` or `user:text` without a closing path is released; no_claim=none +test("a URL cut off after a user name at the stream's end still reads [masked]", () => { + // Arrange + const cutOffs = ['connect http://admin:1234', 'connect http://admin:hunt'] + // Act + const outputs = cutOffs.map((text) => { + const redactor = createOutputRedactor() + return redactor.write(text) + redactor.end() + }) + // Assert + assert.deepEqual(outputs, ['connect [masked]', 'connect [masked]']) +}) + +// protects=a type inspection never returns the variable's real value (pt1 Step 11 Codex structured finding: variables[] kept it while facts showed the type); fails_when=the kept variable of a type inspection carries a secret value; no_claim=none +test('a node test type inspection of a password keeps no real value in its returned variables', async () => { + // Arrange + const debugSession = { + customRequest: async () => ({ + variables: [ + { + name: 'password', + value: "'hunter2-value'", + type: 'string', + variablesReference: 0, + }, + ], + }), + } as unknown as Pick + // Act + const result = await inspectNodeTestVariables( + debugSession, + [{ name: 'Local', variablesReference: 1, expensive: false }], + [{ path: 'password', mode: 'type', required: true }], + sourceImportBindings(''), + AbortSignal.timeout(1_000), + ) + // Assert + assert.deepEqual( + result.facts.map((fact) => fact.display), + ['string'], + ) + assert.equal(JSON.stringify(result).includes('hunter2-value'), false) +}) + +// protects=a secret member whose preview carries a type or length prefix is masked whole (pt1 Step 11 Codex structured finding: only "(2)" was masked); fails_when=the array or object body after the prefix stays visible; no_claim=none +test('a secret member previewed with a length or type prefix is masked together with its body', () => { + // Arrange + const previews = [ + "Map(1) {'session' => (2) ['abc', 'def']}", + "{token: Array(2) ['a', 'b'], n: 1}", + "{credentials: Object {user: 'u'}, n: 1}", + ] + // Act + const redacted = previews.map((preview) => redactValuePreview(preview)) + // Assert + assert.deepEqual(redacted, [ + "Map(1) {'session' => [masked]}", + '{token: [masked], n: 1}', + '{credentials: [masked], n: 1}', + ]) +}) + +// protects=each value pattern; fails_when=a URL password, JWT, Bearer token, sk-, ghp_, github_pat_, AKIA key or PEM block survives, or the URL user name / the word Bearer is lost; no_claim=other providers' keys +test('text shaped like a credential reads [masked] and keeps its harmless words', () => { + // Arrange / Act / Assert + assert.equal( + redactSecretText(`${POSTGRES}app:pw@db:5432/x`), + `${POSTGRES}app:[masked]@db:5432/x`, + ) + assert.equal(redactSecretText(`jwt ${JWT}.`), 'jwt [masked].') + assert.equal( + redactSecretText('Authorization: Bearer abcdefgh12345'), + 'Authorization: Bearer [masked]', + ) + assert.equal( + redactSecretText('key sk-abcdefghijklmnopqrstuvwx end'), + 'key [masked] end', + ) + assert.equal(redactSecretText(`t ${GITHUB_TOKEN}`), 't [masked]') + assert.equal( + redactSecretText('github_pat_11ABCDEFG0abcdefghijklmnop_xyz'), + '[masked]', + ) + assert.equal(redactSecretText('id AKIAIOSFODNN7EXAMPLE ok'), 'id [masked] ok') + assert.equal( + redactSecretText(`before\n${PEM}\nafter`), + 'before\n[masked]\nafter', + ) + assert.equal( + redactSecretText('http://localhost:3000/a user@example.com'), + 'http://localhost:3000/a user@example.com', + ) +}) + +// protects=a JWT and a PEM block split over write calls; fails_when=any fragment of the token or the key body is released; no_claim=output without a secret shape +test('a JWT and a private key split over several output chunks never reach the released text', () => { + // Arrange + const redactor = createOutputRedactor() + const chunks = [ + 'start eyJhbGciOiJIUzI1', + 'NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4C', + 'VPmB92K27uhbUJU1p1r_wW1gFWFOEjXk done\n-----BEGIN RSA PRIV', + 'ATE KEY-----\nMIIEvQIBADANBgkq\nhkiG9w0BAQEF', + '\n-----END RSA PRIVATE KEY-----\nlast line\n', + ] + // Act + const released = + chunks.map((chunk) => redactor.write(chunk)).join('') + redactor.end() + // Assert + assert.equal(released, 'start [masked] done\n[masked]\nlast line\n') +}) + +// protects=Bearer split from its token; fails_when=the token is released after the word Bearer was released alone +test('a Bearer token that arrives in a later chunk than the word Bearer is masked', () => { + // Arrange + const redactor = createOutputRedactor() + // Act + const released = + redactor.write('Authorization: Bearer ') + + redactor.write('abcdefgh12345\nnext\n') + + redactor.end() + // Assert + assert.equal(released, 'Authorization: Bearer [masked]\nnext\n') +}) + +// protects=the 8 KiB hold limit failing closed; fails_when=an unterminated private-key body longer than the limit, or a token run that long, is released or the rest of the block leaks after the overflow +test('a private key whose end never arrives within 8 KiB is masked and its remaining body stays dropped', () => { + // Arrange + const redactor = createOutputRedactor() + const body = 'A'.repeat(OUTPUT_REDACTION_HOLD_LIMIT_CHARS) + // Act + const first = redactor.write(`${PEM_HEADER}\n${body}`) + const second = redactor.write('SECRETTAIL\n-----END PRIVATE') + const third = redactor.write(' KEY-----\nafter\n') + const rest = redactor.end() + // Assert + assert.equal(first, '[masked]') + assert.equal(second, '') + assert.equal(third, '\nafter\n') + assert.equal(rest, '') +}) + +// protects=an oversize unbroken run that may be a secret; fails_when=a run longer than 8 KiB that starts a token shape is held without bound or released unmasked +test('a possible token run longer than 8 KiB is released as [masked] and its continuation is dropped', () => { + // Arrange + const redactor = createOutputRedactor() + // Act + const first = redactor.write( + `ghp_${'x'.repeat(OUTPUT_REDACTION_HOLD_LIMIT_CHARS)}`, + ) + const second = redactor.write('yyyy zzz\n') + // Assert + assert.equal(first, '[masked]') + assert.equal(second, ' zzz\n') +}) + +// protects=a huge plain log line keeps its size (the flooding-output capture limit depends on it); fails_when=an unbroken run with no secret shape is masked or dropped, or loses characters +test('an unbroken run of 20000 plain characters is released whole after the end', () => { + // Arrange + const redactor = createOutputRedactor() + const run = 'x'.repeat(20_000) + // Act + const released = redactor.write(run) + redactor.write('END') + redactor.end() + // Assert + assert.equal(released, `${run}END`) +}) + +// protects=live output is not stalled or reordered: complete lines and a trailing plain word are released at once, and only a trailing run that may still become a secret (here a URL whose password may follow) waits for its next chunk; fails_when=a plain word is held or a URL split before its password leaks the password +test('complete output lines and a trailing plain word are released at once while a URL waiting for its password is held', () => { + // Arrange + const redactor = createOutputRedactor() + // Act + const released = redactor.write('ready on port 3000\nlistening ht') + const plainRest = redactor.write('tp\nconnect postgres:') + const urlRest = redactor.write(`//app:${'hunter2'}@db/x\n`) + // Assert + assert.equal(released, 'ready on port 3000\nlistening ht') + assert.equal(plainRest, 'tp\nconnect ') + assert.equal(urlRest, `${POSTGRES}app:[masked]@db/x\n`) +}) + +// protects=causes and the Output channel; fails_when=a token in an error cause or a diagnostic entry is logged +test('a failure cause and an Output diagnostic entry mask credentials in their text', () => { + // Arrange + const error = new Error( + `connect failed ${POSTGRES}app:hunter2@db/x with ${GITHUB_TOKEN}`, + ) + // Act + const cause = sanitizeCause(error, { storage: [] }) + const entry = formatCodebookDiagnostic( + { surface: 'X', cause: `raw ${GITHUB_TOKEN}`, stack: `at f (${JWT})` }, + { + extensionVersion: '1', + apiVersion: '1.8', + hostName: 'h', + hostVersion: '2', + }, + ) + // Assert + assert.equal( + cause, + `connect failed ${POSTGRES}app:[masked]@db/x with [masked]`, + ) + assert.equal(entry.includes('ghp_'), false) + assert.equal(entry.includes('eyJ'), false) +}) + +// protects=node-test publication: run facts and the retained debugger variable; fails_when=accessToken's real value is in a fact, in the variable list or reachable through a secret-named parent +test('a node test inspection of accessToken reads [masked] in the run facts', async () => { + // Arrange + const references = new Map([ + [ + 1, + [ + { + name: 'accessToken', + value: "'plain-looking-secret'", + variablesReference: 0, + }, + { name: 'tokenCount', value: '3', variablesReference: 0 }, + { name: 'session', value: 'Object', variablesReference: 2 }, + { name: 'note', value: `'${GITHUB_TOKEN}'`, variablesReference: 0 }, + ], + ], + [2, [{ name: 'userId', value: '7', variablesReference: 0 }]], + ]) + const debugSession = { + customRequest: async ( + _command: string, + arguments_: { variablesReference: number }, + ) => ({ variables: references.get(arguments_.variablesReference) ?? [] }), + } as unknown as Pick + // Act + const result = await inspectNodeTestVariables( + debugSession, + [{ name: 'Local', variablesReference: 1, expensive: false }], + [ + { path: 'accessToken', mode: 'value', required: true }, + { path: 'tokenCount', mode: 'value', required: true }, + { path: 'session.userId', mode: 'value', required: true }, + { path: 'note', mode: 'value', required: true }, + ], + sourceImportBindings(''), + AbortSignal.timeout(1_000), + ) + // Assert + assert.deepEqual( + result.facts.map((fact) => fact.display), + ['[masked]', '3', '[masked]', "'[masked]'"], + ) + assert.equal(JSON.stringify(result).includes('plain-looking-secret'), false) + assert.equal(JSON.stringify(result).includes('ghp_'), false) +}) + +// protects=repository-browser inspection facts; fails_when=a secret-named browser value, or a credential-shaped string, reaches the facts; no_claim=object markers stay { type } +test('a browser inspection of password reads [masked] while an object marker stays', async () => { + // Arrange + const answers: Record = { + 'form.password': 'hunter2', + 'form.name': `ada ${GITHUB_TOKEN}`, + 'form.count': 4, + 'form.session': { type: 'object' }, + } + const evaluate = async ( + parameters: Protocol.Debugger.EvaluateOnCallFrameRequest, + ): Promise => { + const path = Object.keys(answers).find((candidate) => + parameters.expression.endsWith(`(${candidate})`), + ) + return { result: { type: 'string', value: answers[path ?? ''] } } + } + // Act + const facts = await inspectConfiguredValues( + evaluate, + 'frame', + [ + { path: 'form.password', mode: 'value', required: true }, + { path: 'form.name', mode: 'value', required: true }, + { path: 'form.count', mode: 'value', required: true }, + { path: 'form.session', mode: 'value', required: true }, + ], + () => undefined, + ) + // Assert + assert.deepEqual(facts.values, { + 'value:form.password': '[masked]', + 'value:form.name': 'ada [masked]', + 'value:form.count': 4, + 'value:form.session': { type: 'object' }, + }) +}) + +// protects=debugger snapshots shared by both profiles; fails_when=a secret-named variable's value or any child is saved, or the subtree is still requested +test('a replay debugger snapshot masks a secret-named object and never reads its children', async () => { + // Arrange + const requested: number[] = [] + // Act + const snapshot = await captureReplayDebugSnapshot({ + stack: { + stackFrames: [{ id: 77, name: 'f', line: 1, column: 1 }], + totalFrames: 1, + }, + verifiedSource: { name: 'a.ts', text: 'x', hash: 'a'.repeat(64), line: 1 }, + assertCurrent() {}, + request(command, arguments_) { + if (command === 'scopes') + return Promise.resolve({ + scopes: [{ name: 'Locals', variablesReference: 1 }], + }) + requested.push(arguments_.variablesReference ?? 0) + return Promise.resolve({ + variables: + arguments_.variablesReference === 1 + ? [ + { name: 'authSession', value: 'Object', variablesReference: 2 }, + { name: 'sortKey', value: "'name'", variablesReference: 0 }, + { + name: 'url', + value: `'${POSTGRES}u:pw@h/d'`, + variablesReference: 0, + }, + ] + : [{ name: 'userId', value: '7', variablesReference: 0 }], + }) + }, + }) + // Assert + assert.deepEqual( + snapshot.variables.map((variable) => [variable.name, variable.value]), + [ + ['authSession', '[masked]'], + ['sortKey', "'name'"], + ['url', `'${POSTGRES}u:[masked]@h/d'`], + ], + ) + assert.deepEqual(requested, [1]) +}) + +// protects=secret-named members shown inside an expandable value's preview (CodeRabbit pt1 finding); fails_when=a member value of a secret-named key survives in the preview text; no_claim=secrets under plainly named keys +test('a value preview masks the members of secret-named keys and keeps the others', () => { + // Arrange + const previews = [ + "{apiKey: 'plain-secret', host: 'x'}", + "Map(2) {'authorization' => 'Bearer x', 'accept' => '*/*'}", + "{auth: {password: 'p', user: 'u'}, n: 1}", + '{session: {id: 1, role: "admin"}, n: 2}', + "['a', 'b']", + ] + // Act + const redacted = previews.map((preview) => redactValuePreview(preview)) + // Assert + assert.deepEqual(redacted, [ + "{apiKey: [masked], host: 'x'}", + "Map(2) {'authorization' => [masked], 'accept' => '*/*'}", + "{auth: {password: [masked], user: 'u'}, n: 1}", + '{session: [masked], n: 2}', + "['a', 'b']", + ]) +}) + +// protects=debugger snapshots of an expandable value whose preview names a secret member (CodeRabbit pt1 finding); fails_when=the parent's saved preview carries the secret member's value +test('a replay debugger snapshot never saves a secret member through its parent preview', async () => { + // Act + const snapshot = await captureReplayDebugSnapshot({ + stack: { + stackFrames: [{ id: 77, name: 'f', line: 1, column: 1 }], + totalFrames: 1, + }, + verifiedSource: { name: 'a.ts', text: 'x', hash: 'a'.repeat(64), line: 1 }, + assertCurrent() {}, + request(command, arguments_) { + if (command === 'scopes') + return Promise.resolve({ + scopes: [{ name: 'Locals', variablesReference: 1 }], + }) + return Promise.resolve({ + variables: + arguments_.variablesReference === 1 + ? [ + { + name: 'config', + value: "{apiKey: 'plain-secret', host: 'x'}", + variablesReference: 2, + }, + ] + : [ + { + name: 'apiKey', + value: "'plain-secret'", + variablesReference: 0, + }, + { name: 'host', value: "'x'", variablesReference: 0 }, + ], + }) + }, + }) + // Assert + assert.deepEqual( + snapshot.variables.map((variable) => [variable.name, variable.value]), + [ + ['config', "{apiKey: [masked], host: 'x'}"], + ['apiKey', '[masked]'], + ['host', "'x'"], + ], + ) + assert.equal(JSON.stringify(snapshot).includes('plain-secret'), false) +}) + +// protects=node-test facts of an object path whose preview names a secret member (CodeRabbit pt1 finding); fails_when=the fact or the kept variable carries the member's value +test('a node test inspection of an object keeps its secret member masked in the fact', async () => { + // Arrange + const debugSession = { + customRequest: async () => ({ + variables: [ + { + name: 'config', + value: "{apiKey: 'plain-secret', host: 'x'}", + variablesReference: 0, + }, + ], + }), + } as unknown as Pick + // Act + const result = await inspectNodeTestVariables( + debugSession, + [{ name: 'Local', variablesReference: 1, expensive: false }], + [{ path: 'config', mode: 'value', required: true }], + sourceImportBindings(''), + AbortSignal.timeout(1_000), + ) + // Assert + assert.deepEqual( + result.facts.map((fact) => fact.display), + ["{apiKey: [masked], host: 'x'}"], + ) + assert.equal(JSON.stringify(result).includes('plain-secret'), false) +}) + +// protects=masking before the 4,096-character event split and the 32 KiB tail cut, split across real child-process writes; fails_when=a fragment of the token is in any event or in the kept tail +test('node test output masks a token the event split and the tail cut would have broken', async () => { + // Arrange + const workspace = await mkdtemp(join(tmpdir(), 'codebook-redaction-')) + const program = join(workspace, 'runner.cjs') + await writeFile( + program, + [ + `const token = ${JSON.stringify(GITHUB_TOKEN)}`, + "process.stdout.write('x'.repeat(4090) + ' ' + token.slice(0, 12))", + 'setTimeout(() => {', + " process.stdout.write(token.slice(12) + ' ' + 'b'.repeat(32_760) + '\\n')", + " process.stdout.write(token + '\\n')", + '}, 200)', + ].join('\n'), + ) + const events: string[] = [] + const owned = await launchNodeTest( + { + vitestRoot: workspace, + execution: ROOT_MODE_EXECUTION, + program, + args: ['--inspect-brk'], + }, + process.execPath, + () => undefined, + (event) => { + events.push(event.text) + }, + ) + try { + // Act + const result = await owned.result(AbortSignal.timeout(10_000)) + // Assert + const everything = events.join('') + assert.equal( + events.every((text) => text.length <= 4_096), + true, + ) + assert.equal(everything.includes('ghp_'), false) + assert.equal(everything.includes('0123456789abcdef'), false) + assert.equal(everything.includes('[masked]'), true) + assert.equal(result.output.includes('0123456789abcdef'), false) + assert.equal(result.output.includes('[masked]'), true) + } finally { + await owned.close() + await rm(workspace, { recursive: true, force: true }) + } +}) + +// Value: protects=an inspected path is secret when any step names a secret, in dotted, bracketed, quoted and optional-chaining forms; fails_when=headers["authorization"], user?.password or items[0].token reads its value, or a tokenCount or publicKey path is masked; why_new=existing rows inspect only dotted paths; seam=none; no_claim=other providers' paths (unit only) +test('an inspected path through a bracket, quote or optional chain to a secret name reads [masked]', () => { + // Arrange + const secretPaths = [ + 'headers["authorization"]', + "headers['set-cookie']", + 'user?.password', + 'items[0].token', + 'config.apiKey', + ] + const plainPaths = ['tokenCount', 'user.name', 'items[0].publicKey'] + // Act + const secretReadings = secretPaths.map((path) => + redactInspectedValue({ mode: 'value', path }, 'hunter2'), + ) + const plainReadings = plainPaths.map((path) => + redactInspectedValue({ mode: 'value', path }, 'hunter2'), + ) + const typeReading = redactInspectedValue( + { mode: 'type', path: 'user.password' }, + 'string', + ) + const credentialInNote = redactInspectedValue( + { mode: 'value', path: 'note' }, + `see ${GITHUB_TOKEN}`, + ) + // Assert + assert.deepEqual( + secretReadings, + secretPaths.map(() => '[masked]'), + ) + assert.deepEqual( + plainReadings, + plainPaths.map(() => 'hunter2'), + ) + assert.equal(typeReading, 'string') + assert.equal(credentialInNote, 'see [masked]') +}) diff --git a/extension/src/tests/resolveNodeServerLaunch.test.ts b/extension/src/tests/resolveNodeServerLaunch.test.ts new file mode 100644 index 000000000..9713ef7c4 --- /dev/null +++ b/extension/src/tests/resolveNodeServerLaunch.test.ts @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict' +import { + mkdir, + mkdtemp, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { pathToFileURL } from 'node:url' + +import { StudyError } from '../errors' +import { resolveNodeServerLaunch } from '../utils/resolveNodeServerLaunch' + +/** Makes a private folder holding a repository folder and a folder beside it, removed after the test. */ +async function temporaryFolders(t: { + after: (cleanup: () => Promise) => void +}) { + const parent = await realpath( + await mkdtemp(join(tmpdir(), 'codebook-node-server-launch-')), + ) + t.after(() => rm(parent, { recursive: true, force: true })) + const repository = join(parent, 'repository') + const outside = join(parent, 'outside') + await mkdir(repository, { recursive: true }) + await mkdir(outside, { recursive: true }) + return { repository, outside } +} + +/** Writes a tsx package whose `.` export is `exports`, with its loader file, under `packageDirectory`. */ +async function writeTsxPackage( + packageDirectory: string, + exportsField: unknown, + loaderFile: string, +): Promise { + await mkdir(join(packageDirectory, loaderFile, '..'), { recursive: true }) + await writeFile( + join(packageDirectory, 'package.json'), + JSON.stringify({ name: 'tsx', version: '4.23.15', exports: exportsField }), + ) + await writeFile(join(packageDirectory, loaderFile), 'export {}\n') +} + +/** Writes the monorepo server entry `apps/api/src/server.ts`. */ +async function writeServerEntry(repository: string): Promise { + await mkdir(join(repository, 'apps/api/src'), { recursive: true }) + await writeFile(join(repository, 'apps/api/src/server.ts'), 'export {}\n') +} + +test('a TypeScript server in a pnpm monorepo package runs through the tsx the repository root installed', async (t) => { + // protects: S1's loader resolution (switch-time layout); fails_when: the walk up from apps/api misses the root's tsx or the realpath leaves the store; no_claim: tsx works. + // Arrange + const { repository } = await temporaryFolders(t) + await writeServerEntry(repository) + const store = join( + repository, + 'node_modules/.pnpm/tsx@4.23.15/node_modules/tsx', + ) + await writeTsxPackage(store, './dist/loader.mjs', 'dist/loader.mjs') + await symlink(store, join(repository, 'node_modules/tsx')) + // Act + const launch = await resolveNodeServerLaunch(repository, { + packageRoot: 'apps/api', + entry: 'src/server.ts', + loader: 'tsx', + }) + // Assert + assert.deepEqual(launch, { + packageRoot: join(repository, 'apps/api'), + entry: join(repository, 'apps/api/src/server.ts'), + loader: { + kind: 'tsx', + path: join(store, 'dist/loader.mjs'), + url: pathToFileURL(join(store, 'dist/loader.mjs')).href, + }, + }) +}) + +test('a tsx that exports its loader by import condition is loaded through that condition', async (t) => { + // protects: tsx releases whose `.` export is a condition object; fails_when: the require target is chosen; no_claim: other tsx versions' file names. + // Arrange + const { repository } = await temporaryFolders(t) + await writeServerEntry(repository) + const tsx = join(repository, 'apps/api/node_modules/tsx') + await writeTsxPackage( + tsx, + { '.': { require: './dist/loader.cjs', import: './dist/esm/loader.mjs' } }, + 'dist/esm/loader.mjs', + ) + // Act + const launch = await resolveNodeServerLaunch(repository, { + packageRoot: 'apps/api', + entry: 'src/server.ts', + loader: 'tsx', + }) + // Assert + assert.deepEqual(launch.loader, { + kind: 'tsx', + path: join(tsx, 'dist/esm/loader.mjs'), + url: pathToFileURL(join(tsx, 'dist/esm/loader.mjs')).href, + }) +}) + +test('a JavaScript server entry runs with plain node and needs no tsx', async (t) => { + // protects: the node loader path; fails_when: a tsx is required or searched for a JavaScript entry; no_claim: none. + // Arrange + const { repository } = await temporaryFolders(t) + await writeFile(join(repository, 'server.mjs'), 'export {}\n') + // Act + const launch = await resolveNodeServerLaunch(repository, { + packageRoot: '.', + entry: 'server.mjs', + loader: 'node', + }) + // Assert + assert.deepEqual(launch, { + packageRoot: repository, + entry: join(repository, 'server.mjs'), + loader: { kind: 'node' }, + }) +}) + +test('a TypeScript server whose repository has no tsx is refused before anything starts and is told to install', async (t) => { + // protects: NODE_SERVER_SOURCE_REFUSED for a missing tsx; fails_when: a tsx outside the repository is used or the refusal is silent; no_claim: the install command. + // Arrange + const { repository } = await temporaryFolders(t) + await writeServerEntry(repository) + // Act + const refusal = await resolveNodeServerLaunch(repository, { + packageRoot: 'apps/api', + entry: 'src/server.ts', + loader: 'tsx', + }).catch((error: unknown) => error) + // Assert + assert.ok(refusal instanceof StudyError) + assert.equal(refusal.code, 'NODE_SERVER_SOURCE_REFUSED') + assert.equal( + refusal.message, + "node-server's TypeScript entry runs through the repository's own tsx, and none is installed for apps/api (searched node_modules/tsx from apps/api up to the repository root); install the repository's dependencies.", + ) +}) + +test('a tsx linked from outside the repository, as pnpm global virtual store links it, is refused', async (t) => { + // protects: "no arbitrary programs": only a loader inside the repository runs; fails_when: the outside loader is accepted; no_claim: pnpm's actual store layout. + // Arrange + const { repository, outside } = await temporaryFolders(t) + await writeServerEntry(repository) + const globalStore = join(outside, 'store/tsx') + await writeTsxPackage(globalStore, './dist/loader.mjs', 'dist/loader.mjs') + await mkdir(join(repository, 'node_modules'), { recursive: true }) + await symlink(globalStore, join(repository, 'node_modules/tsx')) + // Act + const refusal = await resolveNodeServerLaunch(repository, { + packageRoot: 'apps/api', + entry: 'src/server.ts', + loader: 'tsx', + }).catch((error: unknown) => error) + // Assert + assert.ok(refusal instanceof StudyError) + assert.equal(refusal.code, 'NODE_SERVER_SOURCE_REFUSED') + assert.equal( + refusal.message, + "node-server's tsx for apps/api (node_modules/tsx) lies outside the opened repository by its real path, as with pnpm's enableGlobalVirtualStore; Codebook loads only a tsx inside the repository.", + ) +}) + +test('a server entry or package root that links out of the repository is refused', async (t) => { + // protects: entry and packageRoot realpath checks; fails_when: a link out of the repository is followed; no_claim: none. + // Arrange + const { repository, outside } = await temporaryFolders(t) + await writeFile(join(outside, 'server.mjs'), 'export {}\n') + await symlink(join(outside, 'server.mjs'), join(repository, 'server.mjs')) + await symlink(outside, join(repository, 'linked')) + // Act + const entryRefusal = await resolveNodeServerLaunch(repository, { + packageRoot: '.', + entry: 'server.mjs', + loader: 'node', + }).catch((error: unknown) => error) + const packageRootRefusal = await resolveNodeServerLaunch(repository, { + packageRoot: 'linked', + entry: 'server.mjs', + loader: 'node', + }).catch((error: unknown) => error) + // Assert + assert.ok(entryRefusal instanceof StudyError) + assert.equal(entryRefusal.code, 'NODE_SERVER_SOURCE_REFUSED') + assert.equal( + entryRefusal.message, + "node-server's entry server.mjs is not a file inside packageRoot . by its real path.", + ) + assert.ok(packageRootRefusal instanceof StudyError) + assert.equal(packageRootRefusal.code, 'NODE_SERVER_SOURCE_REFUSED') + assert.equal( + packageRootRefusal.message, + "node-server's packageRoot linked is not a folder inside the opened repository by its real path.", + ) +}) + +test('a server entry that does not exist is refused with the folder it was looked for in', async (t) => { + // protects: the entry check before spawning; fails_when: a missing entry reaches node; no_claim: none. + // Arrange + const { repository } = await temporaryFolders(t) + await mkdir(join(repository, 'apps/api'), { recursive: true }) + // Act + const refusal = await resolveNodeServerLaunch(repository, { + packageRoot: 'apps/api', + entry: 'src/server.ts', + loader: 'tsx', + }).catch((error: unknown) => error) + // Assert + assert.ok(refusal instanceof StudyError) + assert.equal(refusal.code, 'NODE_SERVER_SOURCE_REFUSED') + assert.equal( + refusal.message, + "node-server's entry src/server.ts is not a file inside packageRoot apps/api by its real path.", + ) +}) diff --git a/extension/src/utils/addStopInspectionIssues.ts b/extension/src/utils/addStopInspectionIssues.ts new file mode 100644 index 000000000..3807c8277 --- /dev/null +++ b/extension/src/utils/addStopInspectionIssues.ts @@ -0,0 +1,47 @@ +import type { z } from 'zod' + +import type { RepositoryInspection } from '../repositoryConfigSchemas' + +import { missingValueInspectionMessage } from './missingValueInspectionMessage' + +/** The part of a Node profile's declaration whose stops this check reads: the first stop's inspections and those of each additional stop. */ +type DeclaredStopInspections = { + inspections: readonly RepositoryInspection[] + additionalStops?: readonly { inspections: readonly RepositoryInspection[] }[] +} + +/** + * Refuses every declared stop that has no required value inspection, one issue per stop at its own field, since a stop of type inspections only cannot prove the runtime state it is declared for. + * Called by the refinements of {@link nodeTestConfigSchema} and {@link nodeServerConfigSchema}; the issue's text is {@link missingValueInspectionMessage}'s and reaches the agent as the refusal of `codebook_start`. + * @param configuration - The declaration's first stop inspections and its additional stops. + * @param context - The refinement context the issues are added to. + * @returns Nothing; each stop without a required value inspection adds one `custom` issue at `inspections` or `additionalStops..inspections`. + * @example + * addStopInspectionIssues({ inspections: [{ path: 'form', mode: 'type', required: true }] }, context) + * // adds { path: ['inspections'], message: 'configuration.inspections: this stop declares only type inspections (type:form). …' } + */ +export function addStopInspectionIssues( + configuration: DeclaredStopInspections, + context: z.RefinementCtx, +): void { + // The first stop is the declaration's own `inspections`; each additional stop follows it in order. + for (const [index, inspections] of [ + configuration.inspections, + ...(configuration.additionalStops ?? []).map((stop) => stop.inspections), + ].entries()) { + const missingValueInspection = missingValueInspectionMessage( + inspections, + index + ? `configuration.additionalStops[${index - 1}].inspections` + : 'configuration.inspections', + ) + if (!missingValueInspection) continue + context.addIssue({ + code: 'custom', + path: index + ? ['additionalStops', index - 1, 'inspections'] + : ['inspections'], + message: missingValueInspection, + }) + } +} diff --git a/extension/src/utils/bindOwnedNodeTestDebugSession.ts b/extension/src/utils/bindOwnedNodeTestDebugSession.ts index 800ddc6b0..b57c0ca93 100644 --- a/extension/src/utils/bindOwnedNodeTestDebugSession.ts +++ b/extension/src/utils/bindOwnedNodeTestDebugSession.ts @@ -1,6 +1,7 @@ import type * as vscode from 'vscode' import { z } from 'zod' +import { TCP_PORT_MAX } from '../constants' import { Cdp } from '../runtimeSupport/cdp' import { awaitResponse } from '../runtimeSupport/utils/awaitResponse' @@ -8,7 +9,7 @@ import { verifyNodeTestInspectorOwnership } from './verifyNodeTestInspectorOwner const proxySchema = z.strictObject({ host: z.enum(['127.0.0.1', 'localhost', '::1']), - port: z.number().int().min(1).max(65_535), + port: z.number().int().min(1).max(TCP_PORT_MAX), path: z.string().startsWith('/').max(512), }) diff --git a/extension/src/utils/buildNodeServerEnvironment.ts b/extension/src/utils/buildNodeServerEnvironment.ts new file mode 100644 index 000000000..cb0ab792d --- /dev/null +++ b/extension/src/utils/buildNodeServerEnvironment.ts @@ -0,0 +1,27 @@ +import { INHERITED_VARIABLES } from './buildNodeTestEnvironment' + +/** Certificate settings a server may need to reach its own services over TLS (a database or an API with a private CA); they name files, never secrets. */ +const CERTIFICATE_VARIABLES = [ + 'NODE_EXTRA_CA_CERTS', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', +] as const + +/** + * Builds the restricted environment of an owned node-server process: the basic settings Node and the repository's tools need, plus certificate locations, and nothing else. + * `NODE_ENV` and `CI` are left unset so the server's own default applies (both pinned targets default to development), proxy variables stay out so the server's own requests are not rerouted, and the server's own `.env` file, which it loads itself, is never read or written by Codebook. + * Called by {@link launchNodeServer} for the spawned server. + * @param inherited - The editor host environment to copy the allowed settings from. + * @returns Only the allowed settings present in `inherited`. + * @example + * buildNodeServerEnvironment({ PATH: '/usr/bin', HTTP_PROXY: 'http://proxy:8080', NODE_ENV: 'production', SSL_CERT_FILE: '/etc/ssl/cert.pem' }) + * // => { PATH: '/usr/bin', SSL_CERT_FILE: '/etc/ssl/cert.pem' } + */ +export function buildNodeServerEnvironment( + inherited: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = {} + for (const name of [...INHERITED_VARIABLES, ...CERTIFICATE_VARIABLES]) + if (inherited[name]) environment[name] = inherited[name] + return environment +} diff --git a/extension/src/utils/buildNodeTestEnvironment.ts b/extension/src/utils/buildNodeTestEnvironment.ts index bc7118614..20f073085 100644 --- a/extension/src/utils/buildNodeTestEnvironment.ts +++ b/extension/src/utils/buildNodeTestEnvironment.ts @@ -1,5 +1,5 @@ -/** Process settings an owned Node test may inherit; account credentials and unrelated variables stay out. */ -const INHERITED_VARIABLES = [ +/** Process settings an owned Node process (a test or a server) may inherit; account credentials and unrelated variables stay out. Shared with {@link buildNodeServerEnvironment}. */ +export const INHERITED_VARIABLES = [ 'PATH', 'HOME', 'TMPDIR', diff --git a/extension/src/utils/captureProcessOutput.ts b/extension/src/utils/captureProcessOutput.ts new file mode 100644 index 000000000..8f73cc340 --- /dev/null +++ b/extension/src/utils/captureProcessOutput.ts @@ -0,0 +1,150 @@ +import type { ChildProcess } from 'node:child_process' +import { StringDecoder } from 'node:string_decoder' + +import { + PROCESS_OUTPUT_EVENT_LIMIT_CHARS, + PROCESS_OUTPUT_LIMIT_BYTES, +} from '../constants' +import { NANOSECONDS_PER_MILLISECOND } from '../runtimeSupport/constants' + +import { createOutputRedactor } from './createOutputRedactor' +import { keepUtf8Tail } from './keepUtf8Tail' + +/** One stamped chunk of an owned process's output, already redacted: its order, its time since the launch, its channel and its text (at most {@link PROCESS_OUTPUT_EVENT_LIMIT_CHARS} characters). */ +export type ProcessOutputEvent = { + sequence: number + elapsedMs: number + channel: 'stdout' | 'stderr' + text: string +} + +/** How {@link captureProcessOutput} stamps, delivers and bounds an owned process's output. */ +export type ProcessOutputOptions = { + /** `process.hrtime.bigint()` at the launch; each event's `elapsedMs` counts from it. */ + startedAt: bigint + /** Receives each stamped chunk at once, in arrival order; its promise settles when the chunk is durable. */ + onOutput?: (event: ProcessOutputEvent) => void | Promise + /** The most bytes delivered but not yet settled; beyond it the capture fails with `overflowMessage` instead of buffering. */ + pendingLimitBytes: number + /** The failure's message when undelivered output passes `pendingLimitBytes`. */ + overflowMessage: string + /** The failure message's start when a delivery rejects, before its cause. */ + failurePrefix: string +} + +/** The captured output of one owned process. */ +export type ProcessOutputCapture = { + /** The first {@link PROCESS_OUTPUT_LIMIT_BYTES} of raw output, where the inspector announces itself; never published. */ + announcement(): string + /** The last {@link PROCESS_OUTPUT_LIMIT_BYTES} of redacted output. */ + tail(): string + /** Settles once every delivered chunk settled. */ + settled(): Promise + /** The capture's first failure, if any. */ + failure(): Error | undefined +} + +/** + * Captures an owned process's stdout and stderr: each channel is decoded as UTF-8 and redacted statefully ({@link createOutputRedactor}) before anything is cut, then kept as a bounded tail and handed over as stamped events of at most {@link PROCESS_OUTPUT_EVENT_LIMIT_CHARS} characters, in arrival order. + * The raw start of the output is kept apart, only to read the inspector's announcement. + * Called by {@link watchOwnedProcess} for the test or server an owned launcher spawned. + * @param child - The spawned process's output streams. + * @param options - Stamping, delivery and bounds ({@link ProcessOutputOptions}). + * @returns The capture's announcement, tail, settlement and failure ({@link ProcessOutputCapture}). + * @example + * const capture = captureProcessOutput(child, { startedAt: process.hrtime.bigint(), onOutput, pendingLimitBytes: 1_048_576, overflowMessage: 'Node server output capture failed: output outpaced its delivery.', failurePrefix: 'Node server output capture failed' }) + * capture.tail() // => 'fixture listening on 127.0.0.1:4100\n' + */ +export function captureProcessOutput( + child: Pick, + options: ProcessOutputOptions, +): ProcessOutputCapture { + let announcement = '', + tail = '', + failure: Error | undefined, + sequence = 0, + pendingBytes = 0, + deliveries = Promise.resolve() + const enqueue = ( + channel: ProcessOutputEvent['channel'], + text: string, + ): void => { + const deliver = options.onOutput + if (!deliver) return + const bytes = Buffer.byteLength(text) + // Output beyond what may wait for delivery fails the capture instead of buffering it behind slow deliveries. + if (pendingBytes + bytes > options.pendingLimitBytes) { + failure ??= new Error(options.overflowMessage) + return + } + pendingBytes += bytes + const event = { + sequence: ++sequence, + elapsedMs: + Number(process.hrtime.bigint() - options.startedAt) / + NANOSECONDS_PER_MILLISECOND, + channel, + text, + } + // Hand the chunk over in the step that stamps it, so a later event cannot overtake it. + let delivery: Promise + try { + delivery = Promise.resolve(deliver(event)) + } catch (cause) { + delivery = Promise.reject(cause) + } + const settled = delivery + .catch((cause: unknown) => { + failure ??= new Error(`${options.failurePrefix}: ${String(cause)}`) + }) + .finally(() => { + pendingBytes -= bytes + }) + deliveries = deliveries.then(() => settled) + } + const acceptRedacted = ( + channel: ProcessOutputEvent['channel'], + text: string, + ): void => { + if (!text) return + tail = keepUtf8Tail(tail + text, PROCESS_OUTPUT_LIMIT_BYTES) + let part = '' + // Events are cut by characters, never inside a code point. + for (const character of text) { + if (failure) return + if (part.length + character.length > PROCESS_OUTPUT_EVENT_LIMIT_CHARS) { + enqueue(channel, part) + part = '' + } + part += character + } + if (part && !failure) enqueue(channel, part) + } + for (const [channel, stream] of [ + ['stdout', child.stdout], + ['stderr', child.stderr], + ] as const) { + if (!stream) continue + const decoder = new StringDecoder('utf8') + const redactor = createOutputRedactor() + const accept = (text: string): void => { + if (text && Buffer.byteLength(announcement) < PROCESS_OUTPUT_LIMIT_BYTES) + announcement = Buffer.from(announcement + text) + .subarray(0, PROCESS_OUTPUT_LIMIT_BYTES) + .toString('utf8') + acceptRedacted(channel, redactor.write(text)) + } + stream.on('data', (chunk: Buffer) => accept(decoder.write(chunk))) + stream.on('end', () => { + accept(decoder.end()) + // What the redactor held back for a possibly incomplete secret is released, redacted, at the channel's end. + acceptRedacted(channel, redactor.end()) + }) + } + return { + announcement: () => announcement, + tail: () => tail, + settled: (): Promise => deliveries, + failure: () => failure, + } +} diff --git a/extension/src/utils/captureReplayDebugSnapshot.ts b/extension/src/utils/captureReplayDebugSnapshot.ts index df9eba048..c6e79bc03 100644 --- a/extension/src/utils/captureReplayDebugSnapshot.ts +++ b/extension/src/utils/captureReplayDebugSnapshot.ts @@ -2,6 +2,7 @@ import type { DebugProtocol } from '@vscode/debugprotocol' import { z } from 'zod' import { + REDACTION_MASK, REPLAY_DEBUG_CAPTURE_TIMEOUT_MS, REPLAY_DEBUG_MAX_DEPTH, REPLAY_DEBUG_MAX_FRAMES, @@ -15,6 +16,9 @@ import { } from '../replayDebugSchemas' import { awaitResponse } from '../runtimeSupport/utils/awaitResponse' +import { isSecretName } from './isSecretName' +import { redactSecretText } from './redactSecretText' +import { redactValuePreview } from './redactValuePreview' import { shouldCaptureReplayScope } from './shouldCaptureReplayScope' const scopeResponse = z.object({ @@ -156,14 +160,19 @@ export async function captureReplayDebugSnapshot( unavailable: 'Historical variable count limit reached.', } const index = snapshot.variables.length + // Redaction contract: a secret-named variable keeps its name and type but reads [masked], and its subtree is neither read nor saved; any other value is masked where a secret-named member shows in its preview or where it looks like a credential. Masking comes before shortening. + const isSecretVariable = isSecretName(variable.name) const saved: ReplayDebugSnapshot['variables'][number] = { - name: shorten(variable.name), - value: shorten(variable.value), + name: shorten(redactSecretText(variable.name)), + value: isSecretVariable + ? REDACTION_MASK + : shorten(redactSecretText(redactValuePreview(variable.value))), type: variable.type && shorten(variable.type), children: [], } snapshot.variables.push(saved) variables.push(index) + if (isSecretVariable) continue const nested = await captureVariables( variable.variablesReference, depth + 1, diff --git a/extension/src/utils/createOutputRedactor.ts b/extension/src/utils/createOutputRedactor.ts new file mode 100644 index 000000000..985096706 --- /dev/null +++ b/extension/src/utils/createOutputRedactor.ts @@ -0,0 +1,308 @@ +import { + OUTPUT_REDACTION_BLOCK_DROP_LIMIT_CHARS, + OUTPUT_REDACTION_CARRY_CHARS, + OUTPUT_REDACTION_END_MARKER_TAIL_CHARS, + OUTPUT_REDACTION_HOLD_LIMIT_CHARS, + REDACTION_MASK, +} from '../constants' + +import { redactSecretText } from './redactSecretText' + +/** One channel's streaming redactor: feed chunks to `write`, call `end` once at the stream's end. */ +export type OutputRedactor = { + /** Returns the redacted text that is safe to release now. */ + write(text: string): string + /** Releases whatever is still held, a possible secret masked. */ + end(): string +} + +/** A private-key block's header and end markers; the label words are bounded, so a crafted run of labels cannot make the search backtrack quadratically. */ +const PRIVATE_KEY_BEGIN = + /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY[A-Z0-9 ]{0,40}-----/ +const PRIVATE_KEY_END = + /-----END [A-Z0-9 ]{0,40}PRIVATE KEY[A-Z0-9 ]{0,40}-----/ +/** A BEGIN marker whose label has not been closed yet, at the start of the held text. */ +const UNFINISHED_BEGIN_AT_START = /^-----BEGIN(?![A-Z0-9 ]{0,100}-----)/ +/** A complete BEGIN header, its label captured. */ +const BEGIN_HEADER = /^-----BEGIN ([A-Z0-9 ]{0,100})-----/ +/** + * Where a secret this contract masks starts, anchored exactly as {@link redactSecretText} anchors it, or a URL whose authority has not ended (its password may still follow); the URL scheme is bounded to 32 characters so a long run cannot make the search quadratic. + * A held run containing one is a possible secret: it waits for more text and fails closed when it outgrows the hold. + */ +const SECRET_START = + /eyJ[A-Za-z0-9_-]|[Bb][Ee][Aa][Rr][Ee][Rr][ \t]|[A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/[^\s/@]*(?::|$)|(? false + * mayBecomeSecret('tokenghp') // => true + */ +function mayBecomeSecret(run: string): boolean { + if (SECRET_START.test(run) || TRAILING_SCHEME.test(run)) return true + // A chunk boundary inside a start leaves its first characters at the run's end. + return SECRET_START_MARKERS.some((marker) => { + for (let length = 2; length < marker.length; length++) + if (run.endsWith(marker.slice(0, length))) return true + return false + }) +} + +/** + * Finds where the text that may still grow into a secret begins; everything before it can be released. + * Why: a token, a URL password, a `Bearer` header or a PEM block can be split over chunks; called by {@link createOutputRedactor}. + * @param text - Held text plus the new chunk. + * @returns The index of the earliest possibly incomplete secret, or `text.length` when nothing needs holding. + * @example + * holdStart('ok ghp_abc') // => 3 + * holdStart('ok plain') // => 8 + */ +function holdStart(text: string): number { + let start = text.length + // A trailing run without whitespace waits only if it may still become a secret. + // Scanned backwards by hand: a regular expression would rescan a long run from every start. + let runStart = text.length + while (runStart > 0 && !/\s/.test(text.charAt(runStart - 1))) runStart-- + if (runStart < text.length && mayBecomeSecret(text.slice(runStart))) + start = runStart + // `Bearer` and its token may arrive in separate chunks. + const bearer = /\bBearer(?:[ \t]+\S*)?$/i.exec(text) + if (bearer) start = Math.min(start, bearer.index) + // A private-key block is held from its BEGIN marker until its END marker arrived. + const begin = text.lastIndexOf('-----BEGIN') + if (begin !== -1) { + const fromBegin = text.slice(begin) + const header = BEGIN_HEADER.exec(fromBegin) + const isUnfinishedHeader = header === null && !/\n/.test(fromBegin) + const isOpenPrivateKey = + header !== null && + (header[1] ?? '').includes('PRIVATE KEY') && + !PRIVATE_KEY_END.test(fromBegin) + if (isUnfinishedHeader || isOpenPrivateKey) start = Math.min(start, begin) + } + return start +} + +/** + * Finds where a released run's carry starts, never between the two halves of a surrogate pair. + * @param run - The run being released. + * @returns {@link OUTPUT_REDACTION_CARRY_CHARS} from the end, or one earlier when that would split a pair. + * @example carryStart('x'.repeat(20)) // => 4 + */ +function carryStart(run: string): number { + const cut = Math.max(0, run.length - OUTPUT_REDACTION_CARRY_CHARS) + const before = run.charCodeAt(cut - 1) + // A high surrogate just before the cut means the cut falls inside one character. + return before >= 0xd800 && before <= 0xdbff ? cut - 1 : cut +} + +/** An oversized private-key block being dropped: how many of its characters were dropped, its last characters (kept only to find an END marker split across chunks, never released or counted again), and whether the dropped text ended at a line break. */ +type DroppedBlock = { droppedChars: number; tail: string; atLineStart: boolean } + +/** + * Finds the first line of a chunk, from a given point, that no private-key body line can be (one with a character outside base64 and whitespace). + * @param chunk - The fresh text. + * @param from - Where the search may start: the drop limit inside this chunk, or `0`. + * @param atLineStart - Whether `from` is the start of a line (the dropped text before it ended at a line break). + * @returns The index where that line starts, or `undefined` when the chunk has none (an unfinished last line is judged by what it holds so far). + * @example firstPlainLine('QUJD\nready: ok\n', 0, true) // => 5 + */ +function firstPlainLine( + chunk: string, + from: number, + atLineStart: boolean, +): number | undefined { + let lineStart = atLineStart ? from : chunk.indexOf('\n', from) + 1 + // Without a line break after `from`, no line of this chunk starts past the limit. + if (lineStart === 0 && !atLineStart) return undefined + while (lineStart < chunk.length) { + const lineEnd = chunk.indexOf('\n', lineStart) + const line = chunk.slice(lineStart, lineEnd === -1 ? chunk.length : lineEnd) + if (NOT_KEY_BODY.test(line)) return lineStart + if (lineEnd === -1) return undefined + lineStart = lineEnd + 1 + } + return undefined +} + +/** + * Drops one more chunk of an oversized private-key block and finds where the output after it resumes: past its END marker, or, once {@link OUTPUT_REDACTION_BLOCK_DROP_LIMIT_CHARS} characters were dropped without one, at the first later line no key body can be. + * Only fresh text is counted and searched for that line; the kept tail serves the END search alone. Padding a key with spaces or newlines therefore cannot release its body, and a channel whose header has no END resumes at its next punctuated line. + * Why: called by {@link createOutputRedactor} while it drops an oversized block, starting with the overflowing text itself. + * @param block - The block's drop state, updated in place while the chunk still belongs to it. + * @param chunk - The new text. + * @returns The text after the block, or `undefined` while the whole chunk still belongs to it. + * @example dropBlockChunk({ droppedChars: 0, tail: '', atLineStart: false }, 'QUJD\n-----END PRIVATE KEY-----\nready\n') // => '\nready\n' + */ +function dropBlockChunk( + block: DroppedBlock, + chunk: string, +): string | undefined { + const searched = block.tail + chunk + const end = PRIVATE_KEY_END.exec(searched) + if (end) return searched.slice(end.index + end[0].length) + const remaining = OUTPUT_REDACTION_BLOCK_DROP_LIMIT_CHARS - block.droppedChars + if (remaining < chunk.length) { + const from = Math.max(0, remaining) + const resumeAt = firstPlainLine( + chunk, + from, + from === 0 && block.atLineStart, + ) + if (resumeAt !== undefined) return chunk.slice(resumeAt) + } + block.droppedChars += chunk.length + block.tail = searched.slice(-OUTPUT_REDACTION_END_MARKER_TAIL_CHARS) + block.atLineStart = chunk.endsWith('\n') + return undefined +} + +/** A URL whose numeric port and a following path, query or fragment close its authority (`http://db.internal:5432/app`): no password can follow, so the stream's end releases it. */ +const URL_CLOSED_BY_PORT = + /^[A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/(?:\[[\dA-Fa-f:.]+\]|[^\s/@:[]*):\d{1,5}(?=[/?#])/ + +/** A loopback or IP-literal URL whose numeric port ends the held run (`http://127.0.0.1:4100`): no user name is written as such a host, so the stream's end releases it. */ +const URL_ENDING_IN_HOST_PORT = + /^[A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\dA-Fa-f:.]+\]):\d{1,5}$/ + +/** + * Masks a held run from its first secret start when the stream ends, so a token cut off by the end of output is never released in part; a URL whose port already closes its authority is complete, so it is released and only what follows it is judged again. + * @param held - The text still held. + * @returns The held text redacted, with any part from a secret start on replaced by {@link REDACTION_MASK}, except a complete host:port URL. + * @example + * maskHeldStart('token ghp_abc') // => 'token [masked]' + * maskHeldStart('on http://127.0.0.1:4100') // => 'on http://127.0.0.1:4100' + * maskHeldStart('on http://admin:1234') // => 'on [masked]' + */ +function maskHeldStart(held: string): string { + const start = SECRET_START.exec(held) + if (!start) return redactSecretText(held) + const fromStart = held.slice(start.index) + const completeUrl = + URL_CLOSED_BY_PORT.exec(fromStart) ?? + URL_ENDING_IN_HOST_PORT.exec(fromStart) + // A URL that a port closes holds no password once no more output can come; only the rest may still be a cut secret. + if (completeUrl) + return ( + redactSecretText(held.slice(0, start.index + completeUrl[0].length)) + + maskHeldStart(fromStart.slice(completeUrl[0].length)) + ) + return redactSecretText(held.slice(0, start.index)) + REDACTION_MASK +} + +/** + * Makes the stateful redactor of one output channel (stdout or stderr), so a secret split over chunks is still masked before the output is cut, split or saved. + * Only a trailing run that may still become a secret waits for the next chunk (at most {@link OUTPUT_REDACTION_HOLD_LIMIT_CHARS}); past that, a run holding a secret start fails closed (released as {@link REDACTION_MASK}, the rest of that token or private-key block dropped) and any other run is released with a short carry. Called by {@link captureProcessOutput} and the development server launcher for each channel, before any truncation or event split. + * @returns A fresh {@link OutputRedactor}; one per channel, never shared. + * @example + * const redactor = createOutputRedactor() + * redactor.write('key ghp_') + redactor.write('abcdefghijklmnopqrstuvwxyz\n') + redactor.end() + * // => 'key [masked]\n' + */ +export function createOutputRedactor(): OutputRedactor { + let held = '' + // Set after an overflow: the rest of the oversized token (until whitespace) or private-key block is dropped; `held` stays empty meanwhile. + let dropping: 'token' | 'block' | undefined + const block: DroppedBlock = { droppedChars: 0, tail: '', atLineStart: false } + + /** + * Releases what can be released of `text`, holding a possible secret and failing closed past the hold limit. + * @param text - Held text plus new text, outside any drop. + * @returns The redacted text safe to release now. + * @example release('ok ghp_abc') // => 'ok ' (the token is held) + */ + const release = (text: string): string => { + const split = holdStart(text) + const released = text.slice(0, split) + held = text.slice(split) + if (held.length <= OUTPUT_REDACTION_HOLD_LIMIT_CHARS) + return redactSecretText(released) + // No secret start in the run: release it, keeping a short carry so a start cut by the release is still seen whole. + if (!SECRET_START.test(held)) { + const run = held + const cut = carryStart(run) + held = run.slice(cut) + return redactSecretText(released + run.slice(0, cut)) + } + // Fail closed: a possible secret outgrew the hold, so mask it and drop the rest of that token or block. + const overflow = held + held = '' + const masked = redactSecretText(released) + REDACTION_MASK + // Only a private-key header (or one not yet closed) opens a block; any other run is a token, dropped to the next whitespace. + if ( + !PRIVATE_KEY_BEGIN.test(overflow) && + !UNFINISHED_BEGIN_AT_START.test(overflow) + ) { + dropping = 'token' + return masked + } + dropping = 'block' + block.droppedChars = 0 + block.tail = '' + block.atLineStart = false + // The overflowing text is the block's first dropped chunk; a later line it already holds resumes the output. + const rest = dropBlockChunk(block, overflow) + if (rest === undefined) return masked + dropping = undefined + return masked + release(rest) + } + + const write = (text: string): string => { + // Finish dropping what a previous overflow began. + if (dropping === 'block') { + const rest = dropBlockChunk(block, text) + if (rest === undefined) return '' + dropping = undefined + return release(rest) + } + if (dropping === 'token') { + const tokenRest = /^\S*/.exec(text)?.[0] ?? '' + const after = text.slice(tokenRest.length) + if (!after) return '' + dropping = undefined + return release(after) + } + return release(held + text) + } + + const end = (): string => { + const rest = dropping === undefined ? held : '' + held = '' + dropping = undefined + return maskHeldStart(rest) + } + + return { write, end } +} diff --git a/extension/src/utils/describeNodeServerConfigurationIssues.ts b/extension/src/utils/describeNodeServerConfigurationIssues.ts new file mode 100644 index 000000000..63c80759d --- /dev/null +++ b/extension/src/utils/describeNodeServerConfigurationIssues.ts @@ -0,0 +1,40 @@ +import type { z } from 'zod' + +/** + * Formats a configuration field path as an agent copies it: `configuration.request.path`, `configuration.additionalStops[0].inspections`. + * @param path - The issue's path inside the configuration. + * @returns The dotted path under `configuration`, array indices in brackets. + * @example fieldPath(['additionalStops', 0, 'inspections']) // => 'configuration.additionalStops[0].inspections' + */ +function fieldPath(path: readonly PropertyKey[]): string { + return path.reduce( + (written, segment) => + typeof segment === 'number' + ? `${written}[${segment}]` + : `${written}.${String(segment)}`, + 'configuration', + ) +} + +/** + * Words every violation of a refused node-server declaration, each with its field path and the expected value or range, and names the profile's help, so that one `INVALID_CONFIGURATION` answer lets the agent fix all of them before its next start. + * Called by the node-server start when {@link nodeServerConfigSchema} refuses a declaration. + * @param issues - The schema's issues for the declaration. + * @returns One sentence per distinct violation (`configuration.: `), in the schema's order, then `See codebook://help/node-server.` + * @example + * describeNodeServerConfigurationIssues(nodeServerConfigSchema.safeParse({ ...declaration, port: 80 }).error.issues) + * // => "configuration.port: expected an integer from 1024 to 65535, the port the server's own code listens on. See codebook://help/node-server." + */ +export function describeNodeServerConfigurationIssues( + issues: readonly z.core.$ZodIssue[], +): string { + const sentences = issues.map((issue) => { + const field = fieldPath(issue.path) + // A message that already names its field (the stop inspection rule's) is not prefixed twice. + const message = issue.message.startsWith(`${field}: `) + ? issue.message.slice(field.length + 2) + : issue.message + return `${field}: ${/[.!?]$/.test(message) ? message : `${message}.`}` + }) + return [...new Set(sentences), 'See codebook://help/node-server.'].join(' ') +} diff --git a/extension/src/utils/describeNodeServerRequestBodyIssues.ts b/extension/src/utils/describeNodeServerRequestBodyIssues.ts new file mode 100644 index 000000000..7351e241c --- /dev/null +++ b/extension/src/utils/describeNodeServerRequestBodyIssues.ts @@ -0,0 +1,62 @@ +import { NODE_SERVER_REQUEST_BODY_LIMIT_BYTES } from '../constants' + +/** One rule a declared request's body breaks: the field it names and the expected value. */ +type RequestBodyIssue = { field: 'body' | 'contentType'; message: string } + +/** The methods whose declared request carries no body. */ +const METHODS_WITHOUT_BODY = ['GET', 'DELETE'] + +/** + * Lists what a declared node-server request breaks of the body rules: a body rides only on a method that carries one, always with its content type, within {@link NODE_SERVER_REQUEST_BODY_LIMIT_BYTES} of UTF-8; a content type alone describes nothing. + * Called by the request schema's refinement in `nodeServerConfigSchemas.ts`, which adds each issue at its field. + * @param request - The declared method, content type and body. + * @param contentTypes - The content types a body may declare, named when the body has none. + * @returns + * - The issues in the order the request schema reports them (method, content type, size) + * - An empty list when the body rules hold + * @example + * describeNodeServerRequestBodyIssues({ method: 'GET', body: '{}' }, ['application/json']) + * // => [{ field: 'body', message: 'expected no body for GET.' }, { field: 'contentType', message: "expected the body's content type: application/json." }] + * describeNodeServerRequestBodyIssues({ method: 'POST', contentType: 'application/json', body: '{}' }, ['application/json']) // => [] + */ +export function describeNodeServerRequestBodyIssues( + request: { method: string; contentType?: string; body?: string }, + contentTypes: readonly string[], +): RequestBodyIssue[] { + const { body } = request + // A content type alone describes nothing. + if (body === undefined) + return request.contentType === undefined + ? [] + : [ + { + field: 'contentType', + message: 'expected a contentType only beside a body.', + }, + ] + const rules: Array<{ broken: boolean; issue: RequestBodyIssue }> = [ + { + broken: METHODS_WITHOUT_BODY.includes(request.method), + issue: { + field: 'body', + message: `expected no body for ${request.method}.`, + }, + }, + { + broken: request.contentType === undefined, + issue: { + field: 'contentType', + message: `expected the body's content type: ${contentTypes.join(', ')}.`, + }, + }, + { + broken: + Buffer.byteLength(body, 'utf8') > NODE_SERVER_REQUEST_BODY_LIMIT_BYTES, + issue: { + field: 'body', + message: `expected at most ${NODE_SERVER_REQUEST_BODY_LIMIT_BYTES} bytes of UTF-8 text.`, + }, + }, + ] + return rules.filter((rule) => rule.broken).map((rule) => rule.issue) +} diff --git a/extension/src/utils/formatCodebookDiagnostic.ts b/extension/src/utils/formatCodebookDiagnostic.ts index 8b9815e42..f0ad23877 100644 --- a/extension/src/utils/formatCodebookDiagnostic.ts +++ b/extension/src/utils/formatCodebookDiagnostic.ts @@ -1,3 +1,5 @@ +import { redactSecretText } from './redactSecretText' + /** One unexpected failure on a test replay's path, as its producer reports it for the Output › Codebook channel; every text in it is already sanitized ({@link sanitizeCause}). */ export type CodebookDiagnostic = { /** The MCP error code, or the surface that failed (`save:stop-rejected`, `open:test-replay`). */ @@ -36,10 +38,13 @@ export function formatCodebookDiagnostic( ...(entry.studyId ? [`study ${entry.studyId}`] : []), ...(entry.profile ? [`profile ${entry.profile}`] : []), ].join(' · ') - return [ - identity, - `Codebook ${context.extensionVersion} (API ${context.apiVersion}) · ${context.hostName} ${context.hostVersion}`, - `Cause: ${entry.cause}`, - ...(entry.stack ? [entry.stack] : []), - ].join('\n') + // The channel is a durable log the learner copies into reports; mask secret-looking values in the whole entry. + return redactSecretText( + [ + identity, + `Codebook ${context.extensionVersion} (API ${context.apiVersion}) · ${context.hostName} ${context.hostVersion}`, + `Cause: ${entry.cause}`, + ...(entry.stack ? [entry.stack] : []), + ].join('\n'), + ) } diff --git a/extension/src/utils/inspectConfiguredValues.ts b/extension/src/utils/inspectConfiguredValues.ts index c1ce2c9ef..319c11da0 100644 --- a/extension/src/utils/inspectConfiguredValues.ts +++ b/extension/src/utils/inspectConfiguredValues.ts @@ -11,6 +11,8 @@ import { } from '../constants' import type { RepositoryConfiguration } from '../repositoryConfigSchemas' +import { redactInspectedValue } from './redactInspectedValue' + /** Captures declared bounded facts for {@link ConfiguredBrowserRun}; an object or function is captured as its `{ type }` marker, because its members are not bounded, and explicit unavailability preserves other facts while the caller gates continuation. @example await inspectConfiguredValues(evaluate, callFrameId, configuration.inspections, assertCurrent) */ export async function inspectConfiguredValues( evaluate: ( @@ -48,7 +50,7 @@ export async function inspectConfiguredValues( (inspection.mode !== 'type' || REPOSITORY_CAPTURED_TYPES.some((type) => type === value.data)) if (available && value.success) { - values[path] = value.data + values[path] = redactInspectedValue(inspection, value.data) continue } const reason = response.exceptionDetails diff --git a/extension/src/utils/inspectNodeTestVariables.ts b/extension/src/utils/inspectNodeTestVariables.ts index 1d8731b93..3daf8f35b 100644 --- a/extension/src/utils/inspectNodeTestVariables.ts +++ b/extension/src/utils/inspectNodeTestVariables.ts @@ -10,6 +10,7 @@ import { import type { RepositoryInspection } from '../repositoryConfigSchemas' import { awaitResponse } from '../runtimeSupport/utils/awaitResponse' +import { redactInspectedValue } from './redactInspectedValue' import type { ModuleDeclarationBinding, SourceImports, @@ -70,8 +71,13 @@ export async function inspectNodeTestVariables( failedPass.abort() throw error }) - const display = + // Redaction contract: a value under a secret-named step, or one shaped like a credential, reads [masked]; a type name is never a value. + const rawDisplay = inspection.mode === 'type' ? variable?.type : variable?.value + const display = + rawDisplay === undefined + ? undefined + : redactInspectedValue(inspection, rawDisplay) if ( !variable || !display || @@ -94,7 +100,18 @@ export async function inspectNodeTestVariables( display, ...(type !== undefined && { type }), }) - selected.push(variable) + // The kept debugger variable carries the masked value too, so nothing downstream sees the real one. + // A type inspection shows the type, yet its kept variable still carries a value: mask that value as a value inspection of the same path would. + selected.push({ + ...variable, + value: + inspection.mode === 'type' + ? redactInspectedValue( + { mode: 'value', path: inspection.path }, + variable.value, + ) + : display, + }) } // A lazy value whose expansion failed reads as unavailable (settledLazy); a run cancelled meanwhile still ends the pass. signal.throwIfAborted() diff --git a/extension/src/utils/isSecretName.ts b/extension/src/utils/isSecretName.ts new file mode 100644 index 000000000..fd6cbbc4a --- /dev/null +++ b/extension/src/utils/isSecretName.ts @@ -0,0 +1,65 @@ +import { splitNameIntoWords } from './splitNameIntoWords' + +/** Final words that make a name secret: `accessToken`, `db_passwd`, `set-cookie`, `dbPass`, `jwt`; `apikey`, `privatekey`, `secretkey` and `accesskey` are the one-word spellings. */ +const SECRET_FINAL_WORDS: ReadonlySet = new Set([ + 'password', + 'passwords', + 'passwd', + 'passphrase', + 'passphrases', + 'pwd', + 'pass', + 'jwt', + 'sid', + 'secret', + 'secrets', + 'token', + 'tokens', + 'authorization', + 'cookie', + 'cookies', + 'credential', + 'credentials', + 'session', + 'sessions', + 'apikey', + 'privatekey', + 'secretkey', + 'accesskey', +]) + +/** Words that, before a final `key` or `keys`, make a name secret: `apiKey`, `private_key`, `AWS_SECRET_ACCESS_KEY`, `signingKey`; `publicKey`, `primaryKey`, `sortKey` and `cacheKey` stay visible. */ +const SECRET_KEY_QUALIFIERS: ReadonlySet = new Set([ + 'api', + 'private', + 'secret', + 'access', + 'signing', + 'encryption', + 'master', + 'client', + 'auth', + 'session', +]) + +/** + * Tells whether a variable, property or header name holds a secret by its words, so the redaction contract masks its value and its whole subtree. + * Why: `tokenCount`, `sortKey` and `publicKey` must stay readable while `accessToken`, `AWS_SECRET_ACCESS_KEY` and `sessionId` must not; called by {@link isSecretPath} and the snapshot capture ({@link captureReplayDebugSnapshot}). + * @param name - One name, in camelCase, snake_case, kebab-case or dotted form. + * @returns `true` when the final word is a secret word, the name ends in a key named for a secret use (`api key`, `secret access key`, `signing key`), or in `session id`. + * @example + * isSecretName('accessToken') // => true + * isSecretName('tokenCount') // => false + */ +export function isSecretName(name: string): boolean { + const words = splitNameIntoWords(name) + const finalWord = words.at(-1) + if (finalWord === undefined) return false + if (SECRET_FINAL_WORDS.has(finalWord)) return true + const qualifier = words.at(-2) + if (qualifier === undefined) return false + // A key named for its secret use (`apiKey`, `secretAccessKey`), and a session's id, hold credentials. + if (finalWord === 'key' || finalWord === 'keys') + return SECRET_KEY_QUALIFIERS.has(qualifier) + return finalWord === 'id' && qualifier === 'session' +} diff --git a/extension/src/utils/isSecretPath.ts b/extension/src/utils/isSecretPath.ts new file mode 100644 index 000000000..fd0e23011 --- /dev/null +++ b/extension/src/utils/isSecretPath.ts @@ -0,0 +1,15 @@ +import { isSecretName } from './isSecretName' + +/** + * Tells whether any step of a declared inspection path names a secret, so the value at the end of the path is masked. + * Why: `session.user.id` lies inside a secret-named object; called by {@link inspectNodeTestVariables} and {@link inspectConfiguredValues}. + * @param path - A declared path such as `user.accessToken`, `headers["set-cookie"]` or `items[0].id`. + * @returns `true` when at least one step is a secret name ({@link isSecretName}). + * @example + * isSecretPath('auth.accessToken.length') // => true + * isSecretPath('items[0].sortKey') // => false + */ +export function isSecretPath(path: string): boolean { + // Steps are separated by dots, brackets, quotes and optional-chaining marks. + return path.split(/[.[\]'"`?]+/).some((step) => isSecretName(step)) +} diff --git a/extension/src/utils/keepUtf8Tail.ts b/extension/src/utils/keepUtf8Tail.ts new file mode 100644 index 000000000..25cf7631d --- /dev/null +++ b/extension/src/utils/keepUtf8Tail.ts @@ -0,0 +1,18 @@ +/** + * Keeps the last bytes of a text as UTF-8, cut at a character boundary, so a bounded output tail never starts with a broken character (U+FFFD) where the cut fell inside one. + * Called by {@link captureProcessOutput} and {@link launchDevelopmentServer} for the output tails they keep. + * @param text - The text whose end is kept. + * @param limitBytes - The most UTF-8 bytes to keep. + * @returns `text` itself when it fits; otherwise its end of at most `limitBytes` bytes, starting at the first whole character. + * @example + * keepUtf8Tail('abcdef', 3) // => 'def' + * keepUtf8Tail('aé', 1) // => '' (the cut falls inside é, whose two bytes do not fit) + */ +export function keepUtf8Tail(text: string, limitBytes: number): string { + const bytes = Buffer.from(text, 'utf8') + if (bytes.length <= limitBytes) return text + let start = bytes.length - limitBytes + // A UTF-8 continuation byte (10xxxxxx) is the inside of a character; the tail starts after it. + while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start++ + return bytes.subarray(start).toString('utf8') +} diff --git a/extension/src/utils/launchDevelopmentServer.ts b/extension/src/utils/launchDevelopmentServer.ts index 7fcd1183f..7ffe2c7dc 100644 --- a/extension/src/utils/launchDevelopmentServer.ts +++ b/extension/src/utils/launchDevelopmentServer.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process' import { join } from 'node:path' +import { StringDecoder } from 'node:string_decoder' import { REPOSITORY_APPLICATION_EXIT_TIMEOUT_MS, @@ -12,8 +13,10 @@ import { awaitResponse } from '../runtimeSupport/utils/awaitResponse' import { isEndpointClosed } from '../runtimeSupport/utils/isEndpointClosed' import { waitUntil } from '../runtimeSupport/utils/waitUntil' +import { createOutputRedactor } from './createOutputRedactor' import { buildDevelopmentServerEnvironment } from './developmentServerEnvironment' import { isProcessGroupGone } from './isProcessGroupGone' +import { keepUtf8Tail } from './keepUtf8Tail' import { beginReleaseOfOwnedDevelopmentPort, claimOwnedDevelopmentPort, @@ -48,7 +51,9 @@ export function launchDevelopmentServer( detached: true, stdio: ['ignore', 'pipe', 'pipe'], }) + // The raw tail serves origin detection only and is never published; `publishedLog` is its redacted counterpart, the one the journal and failure messages carry. let log = Buffer.alloc(0), + publishedLog = '', inspectorLog = Buffer.alloc(0), inspectorCandidate: string | undefined, inspectorError: Error | undefined @@ -67,9 +72,38 @@ export function launchDevelopmentServer( -REPOSITORY_APPLICATION_LOG_LIMIT_BYTES, ) } - child.stdout.on('data', receive) + // The published tail is cut back to its byte limit only once it has doubled, so a chatty server pays no copy of the whole tail per chunk. + const publish = (text: string): void => { + if (!text) return + publishedLog += text + if (publishedLog.length > 2 * REPOSITORY_APPLICATION_LOG_LIMIT_BYTES) + publishedLog = keepUtf8Tail( + publishedLog, + REPOSITORY_APPLICATION_LOG_LIMIT_BYTES, + ) + } + const publishedTail = (): string => + keepUtf8Tail(publishedLog, REPOSITORY_APPLICATION_LOG_LIMIT_BYTES) + // Each channel is redacted statefully, so a credential the server prints across chunks is masked before it is kept for publication. + // A channel's held text is flushed once: at its stream's end, or when the closed journal is written first (the process can exit before its streams end). + const flushes: (() => void)[] = [] + for (const stream of [child.stdout, child.stderr]) { + const decoder = new StringDecoder('utf8') + const redactor = createOutputRedactor() + let flushed = false + const flush = (): void => { + if (flushed) return + flushed = true + publish(redactor.write(decoder.end()) + redactor.end()) + } + stream.on('data', (chunk: Buffer) => { + receive(chunk) + publish(redactor.write(decoder.write(chunk))) + }) + stream.on('end', flush) + flushes.push(flush) + } child.stderr.on('data', (chunk: Buffer) => { - receive(chunk) if (!inspectServer || inspectorError) return inspectorLog = Buffer.concat([inspectorLog, chunk]).subarray( -REPOSITORY_APPLICATION_LOG_LIMIT_BYTES, @@ -162,7 +196,9 @@ export function launchDevelopmentServer( if (error || child.exitCode !== null) throw ( error ?? - new Error(`Application exited: ${child.exitCode}: ${log}`) + new Error( + `Application exited: ${child.exitCode}: ${publishedTail()}`, + ) ) // Process chunks may split a port; only a received delimiter proves its complete value. Only the declared port counts: another local URL in the log (an API, a proxy) is not the app's origin (F552). return new RegExp(`http://127\\.0\\.0\\.1:${port}(?=\\D)`).exec( @@ -183,7 +219,9 @@ export function launchDevelopmentServer( }) await response.body?.cancel() if (!response.ok) - throw new Error(`Application root failed: ${response.status}: ${log}`) + throw new Error( + `Application root failed: ${response.status}: ${publishedTail()}`, + ) } else { const entry = await fetch(`${origin}${entryPath}`, { redirect: 'manual', @@ -237,12 +275,13 @@ export function launchDevelopmentServer( REPOSITORY_APPLICATION_EXIT_TIMEOUT_MS, ) releaseOwnedDevelopmentPort(claim) + for (const flush of flushes) flush() record('application.closed', { pid: child.pid, origin, exitCode: child.exitCode, signal: child.signalCode, - log: log.toString('utf8'), + log: publishedTail(), }) })()) // Preserve successful release, while allowing a later stop to retry a failed signal or exit check. diff --git a/extension/src/utils/launchNodeServer.ts b/extension/src/utils/launchNodeServer.ts new file mode 100644 index 000000000..a584e830a --- /dev/null +++ b/extension/src/utils/launchNodeServer.ts @@ -0,0 +1,213 @@ +import { spawn } from 'node:child_process' + +import { + NODE_SERVER_DEBUGGER_DETACH_TIMEOUT_MS, + NODE_SERVER_OUTPUT_PENDING_LIMIT_BYTES, + NODE_SERVER_SHUTDOWN_GRACE_MS, + NODE_TEST_ATTACH_TIMEOUT_MS, +} from '../constants' +import { NANOSECONDS_PER_MILLISECOND } from '../runtimeSupport/constants' +import { awaitResponse } from '../runtimeSupport/utils/awaitResponse' +import { waitUntil } from '../runtimeSupport/utils/waitUntil' + +import { buildNodeServerEnvironment } from './buildNodeServerEnvironment' +import type { ProcessOutputEvent } from './captureProcessOutput' +import { isProcessGroupGone } from './isProcessGroupGone' +import { readNodeInspectorEndpoint } from './readNodeInspectorEndpoint' +import { repositoryPosixPath } from './repositoryPosixPath' +import type { ResolvedNodeServerLaunch } from './resolveNodeServerLaunch' +import { signalProcessGroup } from './signalProcessGroup' +import { watchOwnedProcess } from './watchOwnedProcess' + +/** The inspector option every server launch carries: a pause before the first line, on loopback, at a port node chooses and announces. */ +const INSPECTOR_OPTION = '--inspect-brk=127.0.0.1:0' + +/** What a node-server launch runs: the checked real paths ({@link ResolvedNodeServerLaunch}), the opened repository they lie in, and the external Node. */ +export type NodeServerLaunchRequest = ResolvedNodeServerLaunch & { + /** The opened repository's real root; the journal names paths relative to it. */ + workspace: string + /** The external Node executable and its version. */ + node: { executable: string; version: string } +} + +/** How an owned server process ended. */ +export type NodeServerExit = { + exitCode: number | null + signal: NodeJS.Signals | null +} + +/** + * Writes a launch path as the journal names it: inside the repository as `/`. + * @param workspace - The repository's real root. + * @param path - A real path inside it. + * @returns The sanitized path. + * @example + * journalPath('/repo', '/repo/apps/api/src/server.ts') // => '/apps/api/src/server.ts' + * journalPath('/repo', '/repo') // => '' + */ +function journalPath(workspace: string, path: string): string { + const inside = repositoryPosixPath(workspace, path) + // The repository root itself reads as the bare placeholder. + return inside === '.' ? '' : `/${inside}` +} + +/** + * Starts a repository's own server entry as `node --inspect-brk=127.0.0.1:0 [--import ] ` in its package root, in a process group of its own, with the restricted environment of {@link buildNodeServerEnvironment}; Codebook never sets the port or any other variable, and never reads the server's `.env`. + * The server stays paused before its first line until a debugger releases it; readiness and listener ownership are the run's checks. + * Called by the node-server run after {@link resolveNodeServerLaunch} checked the real paths. + * @param launch - The checked real paths, the repository root and the external Node ({@link NodeServerLaunchRequest}). + * @param record - The run's journal: `node-server.started` (sanitized argv, inherited variable names, loader path, Node version, `secretEnvironmentInherited: false`) and `node-server.closed`. + * @param onOutput - Receives each redacted, stamped output chunk at once, in arrival order. + * @returns The owned server: its pid and process group, output, inspector endpoint, exit and close. + * @example + * const server = await launchNodeServer({ workspace: '/repo', packageRoot: '/repo/apps/api', entry: '/repo/apps/api/src/server.ts', loader, node: { executable: '/usr/local/bin/node', version: '26.10.0' } }, record) + * const endpoint = await server.inspectorEndpoint(signal) // => 'ws://127.0.0.1:58209/543bb4f3-…' + */ +export async function launchNodeServer( + launch: NodeServerLaunchRequest, + record: (type: string, detail?: unknown) => unknown, + onOutput?: (event: ProcessOutputEvent) => void | Promise, +) { + const startedAt = process.hrtime.bigint() + const args = [ + INSPECTOR_OPTION, + ...(launch.loader.kind === 'tsx' ? ['--import', launch.loader.url] : []), + launch.entry, + ] + const environment = buildNodeServerEnvironment() + const child = spawn(launch.node.executable, args, { + cwd: launch.packageRoot, + env: environment, + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let closing: Promise | undefined + const { exit, hasExited, failure, capture } = watchOwnedProcess(child, { + startedAt, + onOutput, + pendingLimitBytes: NODE_SERVER_OUTPUT_PENDING_LIMIT_BYTES, + overflowMessage: + 'Node server output capture failed: output outpaced its delivery beyond its limit.', + failurePrefix: 'Node server output capture failed', + }) + const exitOf = (): NodeServerExit => ({ + exitCode: child.exitCode, + signal: child.signalCode, + }) + record('node-server.started', { + pid: child.pid, + args: [ + INSPECTOR_OPTION, + ...(launch.loader.kind === 'tsx' + ? ['--import', journalPath(launch.workspace, launch.loader.path)] + : []), + journalPath(launch.workspace, launch.entry), + ], + cwd: journalPath(launch.workspace, launch.packageRoot), + loader: + launch.loader.kind === 'tsx' + ? journalPath(launch.workspace, launch.loader.path) + : 'node', + nodeVersion: launch.node.version, + inheritedVariables: Object.keys(environment).sort(), + secretEnvironmentInherited: false, + }) + return { + pid: child.pid, + processGroup: child.pid, + elapsedMs: () => + Number(process.hrtime.bigint() - startedAt) / NANOSECONDS_PER_MILLISECOND, + /** The last redacted output, for a failure's cause. @example server.output() // => 'fixture listening on 127.0.0.1:4100\n' */ + output: () => capture.tail(), + /** How the process ended, or `undefined` while it runs. @example server.exited() // => { exitCode: 3, signal: null } */ + exited: (): NodeServerExit | undefined => + hasExited() ? exitOf() : undefined, + /** Waits for this process's complete inspector announcement within {@link NODE_TEST_ATTACH_TIMEOUT_MS}, never guessing an endpoint from a partial line. @example await server.inspectorEndpoint(signal) // => 'ws://127.0.0.1:58209/…' */ + async inspectorEndpoint(signal: AbortSignal): Promise { + return waitUntil( + () => { + const ended = failure() + if (ended) throw ended + if (hasExited()) + throw new Error( + `Node server exited before debugger attachment: ${child.exitCode ?? child.signalCode}`, + ) + return readNodeInspectorEndpoint(capture.announcement()) + }, + 'owned Node server inspector announcement', + NODE_TEST_ATTACH_TIMEOUT_MS, + signal, + ) + }, + /** Reports how the process ended once it exits within `timeoutMs`, or `undefined` while it still runs. @example await server.waitForExit(2_000) // => { exitCode: 0, signal: null } */ + async waitForExit(timeoutMs: number): Promise { + try { + await awaitResponse( + exit, + 'owned Node server exit', + undefined, + timeoutMs, + ) + } catch { + return undefined + } + return exitOf() + }, + /** + * Ends the whole owned process group: the debugger detaches first (a server handling SIGTERM waits for an attached debugger before it exits, and a paused one cannot run its handler), then SIGTERM, then SIGKILL after {@link NODE_SERVER_SHUTDOWN_GRACE_MS}. + * @param detachDebugger - Ends the run's debugger session for this process (detach, which resumes it); bounded by {@link NODE_SERVER_DEBUGGER_DETACH_TIMEOUT_MS} and skipped when absent. + * @returns How the process ended. + * @example await server.close(() => session.detach()) // => { exitCode: 0, signal: null } + */ + async close(detachDebugger?: () => Promise): Promise { + const attempt = (closing ??= (async () => { + if (detachDebugger) + await awaitResponse( + detachDebugger(), + 'owned Node server debugger detach', + undefined, + NODE_SERVER_DEBUGGER_DETACH_TIMEOUT_MS, + ).catch(() => undefined) + const processGroup = child.pid + // A process that never started has no group to end. + if (!processGroup) { + await capture.settled() + return exitOf() + } + signalProcessGroup(processGroup, 'SIGTERM') + const waitForRelease = async (): Promise => { + await awaitResponse( + exit, + 'owned Node server exit', + undefined, + NODE_SERVER_SHUTDOWN_GRACE_MS, + ) + await waitUntil( + () => isProcessGroupGone(processGroup), + 'owned Node server process group exit', + NODE_SERVER_SHUTDOWN_GRACE_MS, + ) + } + let killed = false + // A server or a process it started may ignore SIGTERM; the escalation covers its whole group. + try { + await waitForRelease() + } catch { + killed = true + signalProcessGroup(processGroup, 'SIGKILL') + await waitForRelease() + } + await capture.settled() + record('node-server.closed', { pid: child.pid, ...exitOf(), killed }) + return exitOf() + })()) + // Keep a finished close, while a later close retries a failed signal or exit check instead of replaying its rejection. + try { + return await attempt + } catch (cause) { + if (closing === attempt) closing = undefined + throw cause + } + }, + } +} diff --git a/extension/src/utils/launchNodeTest.ts b/extension/src/utils/launchNodeTest.ts index 64f9a861a..0d346cc1d 100644 --- a/extension/src/utils/launchNodeTest.ts +++ b/extension/src/utils/launchNodeTest.ts @@ -1,30 +1,26 @@ import { spawn } from 'node:child_process' import { createServer } from 'node:net' -import { StringDecoder } from 'node:string_decoder' import { NODE_TEST_HISTORY_LIMIT_BYTES, REPOSITORY_RUN_TIMEOUT_MS, } from '../constants' +import { NANOSECONDS_PER_MILLISECOND } from '../runtimeSupport/constants' import { awaitResponse } from '../runtimeSupport/utils/awaitResponse' import { waitUntil } from '../runtimeSupport/utils/waitUntil' import { buildNodeTestEnvironment } from './buildNodeTestEnvironment' +import type { ProcessOutputEvent } from './captureProcessOutput' import { isProcessGroupGone } from './isProcessGroupGone' import type { PreparedNodeTestLaunch } from './prepareNodeTestLaunch' import { readNodeInspectorEndpoint } from './readNodeInspectorEndpoint' import { signalProcessGroup } from './signalProcessGroup' +import { watchOwnedProcess } from './watchOwnedProcess' -const OUTPUT_LIMIT_BYTES = 32_768 -const OUTPUT_EVENT_LIMIT_CHARS = 4_096 const INSPECTOR_READY_TIMEOUT_MS = 15_000 const SHUTDOWN_TIMEOUT_MS = 5_000 -export type NodeTestOutputEvent = { - sequence: number - elapsedMs: number - channel: 'stdout' | 'stderr' - text: string -} +/** One stamped, redacted chunk of an owned test's output ({@link ProcessOutputEvent}). */ +export type NodeTestOutputEvent = ProcessOutputEvent /** Reserves a local port only while choosing one; the later Inspector identity must still be checked against the owned process group. @example const port = await availableInspectorPort() */ async function availableInspectorPort(): Promise { @@ -69,90 +65,17 @@ export async function launchNodeTest( detached: true, stdio: ['ignore', 'pipe', 'pipe'], }) - let output = '', - announcement = '', - error: Error | undefined, - exited = false, - closing: Promise | undefined, - outputSequence = 0 - let outputWrites = Promise.resolve(), - pendingOutputBytes = 0 - const exit = new Promise((resolve) => - child.once('close', () => { - exited = true - resolve() - }), - ) - child.once('error', (cause) => { - error = cause + let closing: Promise | undefined + // Each channel is redacted before the 32 KiB tail and the 4,096-character event split, so a secret never reaches either. + const { exit, hasExited, failure, capture } = watchOwnedProcess(child, { + startedAt: started, + onOutput, + // Output beyond the durable history's capacity can never be saved; fail capture instead of buffering it behind slow history writes. + pendingLimitBytes: NODE_TEST_HISTORY_LIMIT_BYTES, + overflowMessage: + 'Node test output capture failed: output outpaced its durable history beyond the history size limit.', + failurePrefix: 'Node test output capture failed', }) - for (const [channel, stream] of [ - ['stdout', child.stdout], - ['stderr', child.stderr], - ] as const) { - const decoder = new StringDecoder('utf8') - const enqueue = ( - text: string, - deliver: (event: NodeTestOutputEvent) => void | Promise, - ): void => { - const bytes = Buffer.byteLength(text) - // Output beyond the durable history's capacity can never be saved; fail capture instead of buffering it behind slow history writes. - if (pendingOutputBytes + bytes > NODE_TEST_HISTORY_LIMIT_BYTES) { - error = new Error( - 'Node test output capture failed: output outpaced its durable history beyond the history size limit.', - ) - return - } - pendingOutputBytes += bytes - const event = { - sequence: ++outputSequence, - elapsedMs: Number(process.hrtime.bigint() - started) / 1_000_000, - channel, - text, - } - // Hand the chunk over in the step that stamps it; the history serializes its own durable writes, so a debugger stop accepted later cannot overtake it. - let delivery: Promise - try { - delivery = Promise.resolve(deliver(event)) - } catch (cause) { - delivery = Promise.reject(cause) - } - const settled = delivery - .catch((cause) => { - error ??= new Error( - `Node test output capture failed: ${String(cause)}`, - ) - }) - .finally(() => { - pendingOutputBytes -= bytes - }) - outputWrites = outputWrites.then(() => settled) - } - const accept = (text: string): void => { - if (!text) return - // Keep the initial Inspector announcement and the final bounded test result separately. - if (Buffer.byteLength(announcement) < OUTPUT_LIMIT_BYTES) - announcement = Buffer.from(announcement + text) - .subarray(0, OUTPUT_LIMIT_BYTES) - .toString('utf8') - output = Buffer.from(output + text) - .subarray(-OUTPUT_LIMIT_BYTES) - .toString('utf8') - if (!onOutput || error) return - let part = '' - for (const character of text) { - if (part.length + character.length > OUTPUT_EVENT_LIMIT_CHARS) { - enqueue(part, onOutput) - if (error) return - part = '' - } - part += character - } - if (part) enqueue(part, onOutput) - } - stream.on('data', (chunk: Buffer) => accept(decoder.write(chunk))) - stream.on('end', () => accept(decoder.end())) - } record('node-test.started', { pid: child.pid, port, @@ -166,8 +89,9 @@ export async function launchNodeTest( return { pid: child.pid, processGroup: child.pid, - elapsedMs: () => Number(process.hrtime.bigint() - started) / 1_000_000, - output: () => output, + elapsedMs: () => + Number(process.hrtime.bigint() - started) / NANOSECONDS_PER_MILLISECOND, + output: () => capture.tail(), /** Waits for the launched test command's actual exit before sealing its output result. @example const result = await testRun.result(signal) */ async result(signal: AbortSignal): Promise<{ exitCode: number | null @@ -181,9 +105,14 @@ export async function launchNodeTest( signal, REPOSITORY_RUN_TIMEOUT_MS, ) - await outputWrites - if (error) throw error - return { exitCode: child.exitCode, signal: child.signalCode, output } + await capture.settled() + const ended = failure() + if (ended) throw ended + return { + exitCode: child.exitCode, + signal: child.signalCode, + output: capture.tail(), + } }, /** Reports how the process ended once it exits within `timeoutMs`, or `undefined` while it is still running. Used to explain a debugger session that ended before its first stop. @example await testRun.waitForExit(2_000) // => { exitCode: 0, signal: null } */ async waitForExit( @@ -202,14 +131,15 @@ export async function launchNodeTest( async inspectorEndpoint(signal: AbortSignal): Promise { return waitUntil( () => { - if (error || exited) + const ended = failure() + if (ended || hasExited()) throw ( - error ?? + ended ?? new Error( `Node test exited before debugger attachment: ${child.exitCode}`, ) ) - const endpoint = readNodeInspectorEndpoint(announcement) + const endpoint = readNodeInspectorEndpoint(capture.announcement()) if (!endpoint) return undefined if (Number(new URL(endpoint).port) !== port) throw new Error('Node test announced a foreign Inspector port.') @@ -225,7 +155,7 @@ export async function launchNodeTest( const attempt = (closing ??= (async () => { if (!child.pid) { await exit - await outputWrites + await capture.settled() return } const processGroup = child.pid @@ -252,7 +182,7 @@ export async function launchNodeTest( signalProcessGroup(processGroup, 'SIGKILL') await waitForRelease() } - await outputWrites + await capture.settled() record('node-test.closed', { pid: child.pid, exitCode: child.exitCode, diff --git a/extension/src/utils/listenerToolPath.ts b/extension/src/utils/listenerToolPath.ts new file mode 100644 index 000000000..8b59c500a --- /dev/null +++ b/extension/src/utils/listenerToolPath.ts @@ -0,0 +1,50 @@ +import { isProgramFile } from './isProgramFile' + +/** Where `lsof` lies on macOS and Linux. It is started by one of these absolute paths, never looked up in the search path or the current folder. */ +const LSOF_PROGRAMS = ['/usr/sbin/lsof', '/usr/bin/lsof'] +/** Where iproute2's `ss` lies on Linux. On macOS no `ss` is used: Homebrew's `ss` there is iproute2mac, a script with other output. */ +const SS_PROGRAMS = ['/usr/bin/ss', '/bin/ss', '/usr/sbin/ss', '/sbin/ss'] + +/** The program that lists a port's listening sockets, and which of the two it is (their output differs). */ +export type ListenerTool = { kind: 'lsof' | 'ss'; path: string } + +/** What {@link listenerToolPath} looks with; each is the real one unless a test names its own. */ +export type ListenerToolInputs = { + /** The system the editor runs on; `process.platform` unless a test names another. */ + platform?: NodeJS.Platform + /** Where `lsof` may lie, tried in this order; {@link LSOF_PROGRAMS} unless a test names its own. */ + lsofPrograms?: readonly string[] + /** Where `ss` may lie on Linux, tried in this order; {@link SS_PROGRAMS} unless a test names its own. */ + ssPrograms?: readonly string[] + /** Says whether a path is a program file this user may run; {@link isProgramFile} unless a test names its own. */ + isProgramFile?: (path: string) => Promise +} + +/** + * Names the program a node-server run asks which processes listen on a port, by an absolute path, so that no `lsof` or `ss` of the search path or of the opened repository answers an ownership decision in its place. + * `lsof` is preferred on every system for its one output form; on Linux without it, iproute2's `ss`. Native Windows has neither. + * Called by {@link readPortListeners} before each query, and by the node-server status to say whether a listener tool is available. + * @param inputs - The system and where the programs may lie ({@link ListenerToolInputs}); the real ones when left out. + * @returns + * - `{ kind: 'lsof', path }` for the first of `/usr/sbin/lsof`, `/usr/bin/lsof` that holds a program this user may run + * - `{ kind: 'ss', path }` on Linux without `lsof`, for the first of `/usr/bin/ss`, `/bin/ss`, `/usr/sbin/ss`, `/sbin/ss` + * - `undefined` when neither lies there (the port's owner cannot be read) + * @example + * await listenerToolPath() // => { kind: 'lsof', path: '/usr/sbin/lsof' } on macOS + * await listenerToolPath({ platform: 'linux', lsofPrograms: [] }) // => { kind: 'ss', path: '/usr/bin/ss' } + * await listenerToolPath({ platform: 'darwin', isProgramFile: async () => false }) // => undefined + */ +export async function listenerToolPath( + inputs: ListenerToolInputs = {}, +): Promise { + const platform = inputs.platform ?? process.platform + const isProgram = inputs.isProgramFile ?? isProgramFile + if (platform === 'win32') return undefined + // The first absolute path that holds a program is the one started; no other folder is looked in. + for (const candidate of inputs.lsofPrograms ?? LSOF_PROGRAMS) + if (await isProgram(candidate)) return { kind: 'lsof', path: candidate } + if (platform !== 'linux') return undefined + for (const candidate of inputs.ssPrograms ?? SS_PROGRAMS) + if (await isProgram(candidate)) return { kind: 'ss', path: candidate } + return undefined +} diff --git a/extension/src/utils/nodeServerRequestPathProblem.ts b/extension/src/utils/nodeServerRequestPathProblem.ts new file mode 100644 index 000000000..e5ae009e5 --- /dev/null +++ b/extension/src/utils/nodeServerRequestPathProblem.ts @@ -0,0 +1,31 @@ +import { NODE_SERVER_REQUEST_PATH_LIMIT_CHARS } from '../constants' + +/** + * Names the first rule a declared node-server request path breaks, so that the request can only reach a path of the declared local server and node's HTTP client never refuses or rewrites it. + * The percent-encoded dot, slash and backslash rule applies to the path before `?` only: a query string may carry an encoded slash (`?next=%2Fhome`) without walking the server's path. + * Called by the request refinement of {@link nodeServerConfigSchema}; its clause completes the sentence `expected a path on the server starting with "/", .` + * @param path - The declaration's `request.path`. + * @returns + * - `undefined` for an acceptable path + * - The clause naming the broken rule otherwise (length, printable ASCII, no URL or host, no backslash, no fragment, no encoded dot or slash, no `..` segment), checked in that order + * @example + * nodeServerRequestPathProblem('/api/post_list?page=1') // => undefined + * nodeServerRequestPathProblem('/api/../admin') // => 'without ".." segments' + * nodeServerRequestPathProblem('http://localhost/api') // => 'not a URL or "//host"' + */ +export function nodeServerRequestPathProblem(path: string): string | undefined { + if (path.length > NODE_SERVER_REQUEST_PATH_LIMIT_CHARS) + return `at most ${NODE_SERVER_REQUEST_PATH_LIMIT_CHARS} characters` + // node's HTTP client refuses spaces, control characters (CR and LF included) and characters above U+00FF; non-ASCII text is percent-encoded instead. + if (!/^[\x21-\x7e]*$/.test(path)) + return 'in printable ASCII without spaces or line breaks (percent-encode other characters)' + if (!path.startsWith('/') || path.startsWith('//')) + return 'not a URL or "//host"' + if (path.includes('\\')) return 'without "\\"' + if (path.includes('#')) return 'without a "#" fragment' + const pathBeforeQuery = path.split('?', 1)[0] ?? '' + if (/%(?:2e|2f|5c)/i.test(pathBeforeQuery)) + return 'without percent-encoded dots, slashes or backslashes (%2e, %2f, %5c) before "?"' + if (pathBeforeQuery.split('/').includes('..')) return 'without ".." segments' + return undefined +} diff --git a/extension/src/utils/parseLsofListeners.ts b/extension/src/utils/parseLsofListeners.ts new file mode 100644 index 000000000..23e75df21 --- /dev/null +++ b/extension/src/utils/parseLsofListeners.ts @@ -0,0 +1,58 @@ +import type { PortListener } from './readPortListeners' +import { splitListenerAddress } from './splitListenerAddress' + +/** The process and socket family the lines read so far open, which an `n` line completes into a listener. */ +type LsofCursor = { pid?: number; family?: PortListener['family'] } + +/** + * Applies one `lsof -F` line to the cursor, adding a listener when an `n` line completes a socket. + * @param cursor - The open process and socket family, updated in place. + * @param line - One non-empty output line: a field letter, then its value. + * @param listeners - The listeners read so far, appended to in place. + * @returns `false` for a line that is not of the expected form. + * @example readLsofLine({ pid: 48633, family: 'IPv6' }, 'n*:4100', listeners) // => true, listeners gains { family: 'IPv6', address: '*', port: 4100, pid: 48633 } + */ +function readLsofLine( + cursor: LsofCursor, + line: string, + listeners: PortListener[], +): boolean { + const value = line.slice(1) + switch (line[0]) { + case 'p': + cursor.pid = /^\d+$/.test(value) ? Number(value) : undefined + return cursor.pid !== undefined + case 'f': + // Each file descriptor is a new socket; its family follows. + cursor.family = undefined + return true + case 't': + cursor.family = value === 'IPv4' || value === 'IPv6' ? value : undefined + return cursor.family !== undefined + case 'n': { + const endpoint = splitListenerAddress(value) + if (!endpoint || cursor.pid === undefined || !cursor.family) return false + listeners.push({ family: cursor.family, ...endpoint, pid: cursor.pid }) + return true + } + default: + return false + } +} + +/** + * Reads the listening sockets `lsof -nP -iTCP: -sTCP:LISTEN -Fptn` printed: a `p` line opens a process, each `f` line one of its sockets, whose `t` (`IPv4` or `IPv6`) and `n
:` lines follow. + * Called by {@link readPortListeners} with the output of an `lsof` query. + * @param output - What `lsof` printed on its standard output. + * @returns One listener per socket, in the printed order; `undefined` when a line is not of that form (the reading is then no answer). + * @example + * parseLsofListeners('p48633\nf18\ntIPv6\nn*:4100\n') + * // => [{ family: 'IPv6', address: '*', port: 4100, pid: 48633 }] + */ +export function parseLsofListeners(output: string): PortListener[] | undefined { + const listeners: PortListener[] = [] + const cursor: LsofCursor = {} + for (const line of output.split('\n')) + if (line && !readLsofLine(cursor, line, listeners)) return undefined + return listeners +} diff --git a/extension/src/utils/parseSsListeners.ts b/extension/src/utils/parseSsListeners.ts new file mode 100644 index 000000000..629a45344 --- /dev/null +++ b/extension/src/utils/parseSsListeners.ts @@ -0,0 +1,39 @@ +import type { PortListener } from './readPortListeners' +import { splitListenerAddress } from './splitListenerAddress' + +/** + * Reads the listening sockets iproute2's `ss -H -l -t -n -p sport = :` printed: one line per socket (`LISTEN 0 511 *:4100 *:* users:(("node",pid=1234,fd=18))`), its local endpoint in the fourth column and the processes holding it in the `users:` part. + * An IPv6 endpoint holds a colon in its address, bracketed or not (older ss prints `:::4100`), or is `*` (a socket that also accepts IPv4); one without process details (a socket of another user) has no pid. + * Called by {@link readPortListeners} with the output of an `ss` query on Linux. + * @param output - What `ss` printed on its standard output. + * @returns One listener per socket and holding process, in the printed order; `undefined` when a line is not of that form. + * @example + * parseSsListeners('LISTEN 0 511 *:4100 *:* users:(("node",pid=1234,fd=18))\n') + * // => [{ family: 'IPv6', address: '*', port: 4100, pid: 1234 }] + */ +export function parseSsListeners(output: string): PortListener[] | undefined { + const listeners: PortListener[] = [] + for (const line of output.split('\n')) { + if (!line.trim()) continue + const columns = line.trim().split(/\s+/) + const localEndpoint = columns[3] + if (columns[0] !== 'LISTEN' || localEndpoint === undefined) return undefined + const endpoint = splitListenerAddress(localEndpoint) + if (!endpoint) return undefined + // An IPv6 address holds a colon, bracketed or not (older ss prints `:::4100`); `*` is a socket that also accepts IPv4. + const family = + endpoint.address === '*' || endpoint.address.includes(':') + ? 'IPv6' + : 'IPv4' + const pids = [ + ...columns + .slice(5) + .join(' ') + .matchAll(/pid=(\d+)/g), + ].map((match) => Number(match[1])) + // A socket another user holds shows no process; its pid stays unknown rather than absent. + if (!pids.length) listeners.push({ family, ...endpoint, pid: undefined }) + for (const pid of pids) listeners.push({ family, ...endpoint, pid }) + } + return listeners +} diff --git a/extension/src/utils/readPortListeners.ts b/extension/src/utils/readPortListeners.ts new file mode 100644 index 000000000..2af761351 --- /dev/null +++ b/extension/src/utils/readPortListeners.ts @@ -0,0 +1,141 @@ +import { z } from 'zod' + +import { + LISTENER_QUERY_MAX_BYTES, + LISTENER_QUERY_TIMEOUT_MS, +} from '../constants' + +import { listenerToolPath, type ListenerTool } from './listenerToolPath' +import { parseLsofListeners } from './parseLsofListeners' +import { parseSsListeners } from './parseSsListeners' +import { runBoundedQuery, type BoundedQueryRunner } from './runBoundedQuery' + +/** One socket listening on a TCP port: its family, local address (`*` for any address, which on IPv6 also accepts IPv4), port and the process holding it (`undefined` when the tool may not show another user's process). */ +export type PortListener = { + family: 'IPv4' | 'IPv6' + address: string + port: number + pid: number | undefined +} + +/** A port's listening sockets as the listener tool printed them, or why they could not be read; an unknown reading never counts as "no listener". */ +export type PortListenerReading = + | { kind: 'read'; tool: ListenerTool['kind']; listeners: PortListener[] } + | { kind: 'unknown'; reason: string } + +/** What {@link readPortListeners} reads with; each is the real one unless a test names its own. */ +export type PortListenerInputs = { + /** Names the listener tool; {@link listenerToolPath} unless a test names its own. */ + findTool?: () => Promise + /** Runs the bounded query; {@link runBoundedQuery} unless a test names its own. */ + run?: BoundedQueryRunner +} + +/** The parts of a failed `execFile` the reading tells apart: an exit code, an end by the time or size bound, and what the tool printed. */ +const queryFailureSchema = z.looseObject({ + code: z.union([z.number(), z.string()]).nullish(), + killed: z.boolean().optional(), + stdout: z.string().optional(), + stderr: z.string().optional(), +}) + +/** + * Builds the tool's arguments for one port's listening TCP sockets, numeric and with process ids. + * @param tool - The listener tool. + * @param port - The port. + * @returns The arguments `lsof` or `ss` receives. + * @example queryArguments({ kind: 'lsof', path: '/usr/sbin/lsof' }, 4100) // => ['-nP', '-iTCP:4100', '-sTCP:LISTEN', '-Fptn'] + */ +function queryArguments(tool: ListenerTool, port: number): string[] { + return tool.kind === 'lsof' + ? ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fptn'] + : ['-H', '-l', '-t', '-n', '-p', 'sport', '=', `:${port}`] +} + +/** + * Explains a query that ended without an answer, or recognizes `lsof`'s silent exit 1, which is its answer "no listener". + * @param tool - The listener tool that ran. + * @param failure - The rejection of the query. + * @returns An empty `read` for `lsof`'s silent exit 1, an `unknown` reading naming the bound or the tool's first error line otherwise. + * @example readingOfFailure({ kind: 'lsof', path: '/usr/sbin/lsof' }, Object.assign(new Error('x'), { killed: true })) // => { kind: 'unknown', reason: 'lsof did not answer within 3000 ms.' } + */ +function readingOfFailure( + tool: ListenerTool, + failure: unknown, +): PortListenerReading { + const parsed = queryFailureSchema.safeParse(failure) + const details = parsed.success ? parsed.data : {} + // An answer longer than the size bound is no answer either; Node.js ends that query too. + if (details.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') + return { + kind: 'unknown', + reason: `${tool.kind} printed more than ${LISTENER_QUERY_MAX_BYTES} bytes.`, + } + if (details.killed) + return { + kind: 'unknown', + reason: `${tool.kind} did not answer within ${LISTENER_QUERY_TIMEOUT_MS / 1_000} s.`, + } + // lsof exits 1 without a word when nothing matches; any printed word makes it a failure instead. + if ( + tool.kind === 'lsof' && + details.code === 1 && + !details.stdout && + !details.stderr + ) + return { kind: 'read', tool: tool.kind, listeners: [] } + const firstErrorLine = details.stderr?.split('\n').find(Boolean) + return { + kind: 'unknown', + reason: `${tool.kind} failed: ${firstErrorLine ?? `exit ${String(details.code ?? 'unknown')}`}.`, + } +} + +/** + * Asks the system which processes listen on a TCP port, through `lsof` (or `ss` on Linux without it) started by its absolute path, within {@link LISTENER_QUERY_TIMEOUT_MS}. + * The node-server run bases its listener-ownership decisions on this reading: the launched process must be the only listener that can accept the request's address, and an `unknown` reading refuses the request instead of passing for "no listener". + * Called by the node-server run at readiness and immediately before the request is written. + * @param port - The port the declaration names. + * @param inputs - The tool lookup and the query runner ({@link PortListenerInputs}); the real ones when left out. + * @returns + * - `{ kind: 'read', tool, listeners }`, listeners possibly empty + * - `{ kind: 'unknown', reason }` when no tool lies where Codebook starts it, the query ran past its bound, failed or printed what this reader does not understand + * @example + * await readPortListeners(4100) // => { kind: 'read', tool: 'lsof', listeners: [{ family: 'IPv6', address: '*', port: 4100, pid: 48633 }] } + */ +export async function readPortListeners( + port: number, + inputs: PortListenerInputs = {}, +): Promise { + const tool = await (inputs.findTool ?? listenerToolPath)() + if (!tool) + return { + kind: 'unknown', + reason: + 'No lsof lies at /usr/sbin/lsof or /usr/bin/lsof (on Linux, no ss at /usr/bin/ss, /bin/ss, /usr/sbin/ss or /sbin/ss), and Codebook starts them only from there.', + } + let stdout: string + try { + ;({ stdout } = await (inputs.run ?? runBoundedQuery)( + tool.path, + queryArguments(tool, port), + { + timeout: LISTENER_QUERY_TIMEOUT_MS, + maxBuffer: LISTENER_QUERY_MAX_BYTES, + killSignal: 'SIGKILL', + windowsHide: true, + env: { LC_ALL: 'C', PATH: '/usr/bin:/bin:/usr/sbin:/sbin' }, + }, + )) + } catch (failure) { + return readingOfFailure(tool, failure) + } + const listeners = + tool.kind === 'lsof' ? parseLsofListeners(stdout) : parseSsListeners(stdout) + return listeners + ? { kind: 'read', tool: tool.kind, listeners } + : { + kind: 'unknown', + reason: `${tool.kind} printed a listener line Codebook cannot read.`, + } +} diff --git a/extension/src/utils/redactInspectedValue.ts b/extension/src/utils/redactInspectedValue.ts new file mode 100644 index 000000000..e5d482e5b --- /dev/null +++ b/extension/src/utils/redactInspectedValue.ts @@ -0,0 +1,34 @@ +import type { ConfiguredObservation } from '../configuredStudySchemas' +import { REDACTION_MASK } from '../constants' +import type { RepositoryInspection } from '../repositoryConfigSchemas' + +import { isSecretPath } from './isSecretPath' +import { redactSecretText } from './redactSecretText' +import { redactValuePreview } from './redactValuePreview' + +/** + * Applies the redaction contract to one inspected value before it is saved as a fact. + * Why: a value under a secret-named step must never reach the chat or a saved history; called by {@link inspectConfiguredValues} for each available browser value and by {@link inspectNodeTestVariables} for each node-test display. + * @param inspection - The declared inspection (its mode and path). + * @param data - The captured value. + * @returns + * - a type inspection, `null` and `{ type }` markers: unchanged + * - a primitive under a secret-named step: {@link REDACTION_MASK} + * - any other string: the string with secret-named members of a preview ({@link redactValuePreview}) and credential-shaped parts ({@link redactSecretText}) masked + * @example + * redactInspectedValue({ path: 'form.password', mode: 'value' }, 'hunter2') // => '[masked]' + */ +export function redactInspectedValue< + Value extends ConfiguredObservation['values'][string], +>( + inspection: Pick, + data: Value, +): Value | string { + // A type name, null and an object marker are not secret values. + if (inspection.mode !== 'value' || data === null || typeof data === 'object') + return data + if (isSecretPath(inspection.path)) return REDACTION_MASK + return typeof data === 'string' + ? redactSecretText(redactValuePreview(data)) + : data +} diff --git a/extension/src/utils/redactSecretText.ts b/extension/src/utils/redactSecretText.ts new file mode 100644 index 000000000..4c8f3286f --- /dev/null +++ b/extension/src/utils/redactSecretText.ts @@ -0,0 +1,48 @@ +import { REDACTION_MASK } from '../constants' + +/** Private-key PEM blocks, whole; an unterminated block is masked to the end of the text. Public keys and certificates are not secrets and stay. The label words are bounded so a crafted run of labels cannot backtrack quadratically. */ +const PRIVATE_KEY_BLOCK = + /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY[A-Z0-9 ]{0,40}-----[\s\S]*?(?:-----END [A-Z0-9 ]{0,40}PRIVATE KEY[A-Z0-9 ]{0,40}-----|$)/g + +/** A URL's userinfo password: `scheme://user:password@host`; the user name stays. */ +const URL_USERINFO_PASSWORD = + /((?`; the word stays. A token of fewer than 8 characters is not taken for one. */ +const BEARER_TOKEN = /\b(Bearer[ \t]+)[A-Za-z0-9._~+/=-]{8,}/gi + +/** Provider keys by prefix: OpenAI-style `sk-`, Stripe `sk_`/`rk_` live and test keys, GitHub `ghp_`/`gho_`/`ghu_`/`ghs_`/`ghr_` and `github_pat_`, Slack `xox?-`, Google `AIza`, npm `npm_`, AWS access and session keys `AKIA`/`ASIA`. */ +const PREFIXED_KEYS = [ + /(? 'smtps://mailer:[masked]@smtp:465' + * redactSecretText('Authorization: Bearer abcdefgh12345') // => 'Authorization: Bearer [masked]' + */ +export function redactSecretText(text: string): string { + let redacted = text + .replace(PRIVATE_KEY_BLOCK, REDACTION_MASK) + .replace(URL_USERINFO_PASSWORD, `$1:${REDACTION_MASK}@`) + .replace(JSON_WEB_TOKEN, REDACTION_MASK) + .replace(BEARER_TOKEN, `$1${REDACTION_MASK}`) + for (const pattern of PREFIXED_KEYS) + redacted = redacted.replace(pattern, REDACTION_MASK) + return redacted +} diff --git a/extension/src/utils/redactValuePreview.ts b/extension/src/utils/redactValuePreview.ts new file mode 100644 index 000000000..9be17e909 --- /dev/null +++ b/extension/src/utils/redactValuePreview.ts @@ -0,0 +1,101 @@ +import { PREVIEW_VALUE_PREFIX_LIMIT_CHARS, REDACTION_MASK } from '../constants' + +import { isSecretName } from './isSecretName' + +/** A member key in a debugger's value preview, bare or quoted, followed by `:` (objects) or `=>` (maps). */ +const PREVIEW_KEY = + /(?)\s*/g +/** A type name and/or a parenthesized length that a debugger writes before a value's body (`Map(1) `, `Array(2) `, `(2) `, `Object `). */ +const VALUE_PREFIX = /^(?:[A-Za-z_$][\w$]*)?(?:\(\d+\))?\s*(?=[{[(])/ +/** Closing characters for the opening ones a preview value can start with. */ +const CLOSING: Readonly> = { + '{': '}', + '[': ']', + '(': ')', +} + +/** + * Finds where a quoted value that starts at `start` ends, past its closing quote; an unclosed one runs to the end. + * @param text - The preview. + * @param start - The index of the opening quote. + * @returns The index after the closing quote, or `text.length`. + * @example endOfQuoted("'a\\'b', x", 0) // => 6 + */ +function endOfQuoted(text: string, start: number): number { + const quote = text[start] + for (let index = start + 1; index < text.length; index++) { + if (text[index] === '\\') index++ + else if (text[index] === quote) return index + 1 + } + return text.length +} + +/** + * Finds where a bracketed value that starts at `start` ends, past its matching closing bracket, skipping quoted text; a value the preview cut off runs to the end. + * @param text - The preview. + * @param start - The index of the opening bracket. + * @returns The index after the matching bracket, or `text.length`. + * @example endOfBracketed('{id: 1}, n: 2', 0) // => 7 + */ +function endOfBracketed(text: string, start: number): number { + const opened: string[] = [] + for (let index = start; index < text.length; index++) { + const character = text.charAt(index) + if (character === "'" || character === '"' || character === '`') { + index = endOfQuoted(text, index) - 1 + continue + } + const closing = CLOSING[character] + if (closing) opened.push(closing) + else if (character === opened.at(-1)) opened.pop() + // The value ends once its own opening bracket is closed. + if (opened.length === 0) return index + 1 + } + return text.length +} + +/** + * Finds where one member value of a preview ends: a quoted string, a bracketed object, array or map (with any type name or length before it), or a bare token up to the next separator. + * @param text - The preview. + * @param start - Where the value starts, after its key's `:` or `=>`. + * @returns The index where the value ends. + * @example endOfPreviewValue("{a: 'x', b: 1}", 4) // => 7 + */ +function endOfPreviewValue(text: string, start: number): number { + const first = text.charAt(start) + if (first === "'" || first === '"' || first === '`') + return endOfQuoted(text, start) + // A type name or a length before a body (`Array(2) [...]`, `(2) [...]`, `Object {...}`) belongs to the value, which ends with that body. + const prefix = VALUE_PREFIX.exec( + text.slice(start, start + PREVIEW_VALUE_PREFIX_LIMIT_CHARS), + ) + if (prefix && prefix[0].length > 0) + return endOfBracketed(text, start + prefix[0].length) + if (CLOSING[first]) return endOfBracketed(text, start) + const bareToken = /^[^,}\])]*/.exec(text.slice(start))?.[0] ?? '' + return start + bareToken.trimEnd().length +} + +/** + * Masks the members of secret-named keys inside a debugger's value preview (`{apiKey: 'x', host: 'y'}`, `Map(1) {'authorization' => 'x'}`), so an expandable value's own text never carries what its secret-named children are masked for. + * Why: the editor's debug adapter previews an object with its members' values; the redaction contract masks a secret-named member and everything inside it, wherever its value appears. Called by {@link redactInspectedValue} and {@link captureReplayDebugSnapshot} before the credential shapes of {@link redactSecretText}. + * @param preview - A value as the debugger displays it. + * @returns The preview with each secret-named member's value replaced by {@link REDACTION_MASK}; unchanged when it names none. + * @example + * redactValuePreview("{apiKey: 'plain-secret', host: 'x'}") // => "{apiKey: [masked], host: 'x'}" + * redactValuePreview("{session: {id: 1}, n: 2}") // => '{session: [masked], n: 2}' + */ +export function redactValuePreview(preview: string): string { + let redacted = '' + let copiedUntil = 0 + for (const match of preview.matchAll(PREVIEW_KEY)) { + // A key inside a value already masked is gone with it. + if (match.index < copiedUntil) continue + const key = match[1] ?? match[2] ?? match[3] ?? '' + if (!isSecretName(key)) continue + const valueStart = match.index + match[0].length + redacted += preview.slice(copiedUntil, valueStart) + REDACTION_MASK + copiedUntil = endOfPreviewValue(preview, valueStart) + } + return redacted + preview.slice(copiedUntil) +} diff --git a/extension/src/utils/resolveNodeServerLaunch.ts b/extension/src/utils/resolveNodeServerLaunch.ts new file mode 100644 index 000000000..b22e21b7d --- /dev/null +++ b/extension/src/utils/resolveNodeServerLaunch.ts @@ -0,0 +1,239 @@ +import { readFile, realpath, stat } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +import { z } from 'zod' + +import { StudyError } from '../errors' +import type { NodeServerConfiguration } from '../nodeServerConfigSchemas' + +import { isInsideFolder } from './isInsideFolder' +import { repositoryPosixPath } from './repositoryPosixPath' + +/** How node runs the server's entry: directly, or through the repository's own tsx loader, named by its real path and the file URL `--import` receives. */ +export type NodeServerLoader = + { kind: 'node' } | { kind: 'tsx'; path: string; url: string } + +/** The real paths a node-server launch runs: the folder it starts in, its entry and its loader. */ +export type ResolvedNodeServerLaunch = { + packageRoot: string + entry: string + loader: NodeServerLoader +} + +/** The one field of a package manifest the loader lookup reads. */ +const packageManifestSchema = z.looseObject({ exports: z.unknown() }) + +/** The export conditions node's ESM loader matches when `--import` loads a package's `.` export. */ +const IMPORT_CONDITIONS = new Set(['node', 'import', 'default']) + +/** + * Builds the refusal of a node-server launch input, raised before anything is spawned. + * @param message - What is wrong, naming the field as the declaration wrote it. + * @returns The `NODE_SERVER_SOURCE_REFUSED` {@link StudyError}. + * @example refusal("node-server's entry src/server.ts is not a file inside packageRoot apps/api by its real path.") + */ +function refusal(message: string): StudyError { + return new StudyError('NODE_SERVER_SOURCE_REFUSED', message) +} + +/** + * Answers a path's real path, or `undefined` when it does not exist or cannot be read. + * @param path - Any path. + * @returns The real path, or `undefined`. + * @example await realPathOrNothing('/repo/missing') // => undefined + */ +async function realPathOrNothing(path: string): Promise { + return realpath(path).catch(() => undefined) +} + +/** + * Says whether a path is a file or a folder now; a path removed since its real path was read is neither, so the refusal names it instead of a raw error. + * @param path - A real path. + * @param kind - The kind it must be. + * @returns `true` when the path is of that kind. + * @example await isEntryOfKind('/repo/apps/api', 'folder') // => true + */ +async function isEntryOfKind( + path: string, + kind: 'file' | 'folder', +): Promise { + const entry = await stat(path).catch(() => undefined) + return kind === 'file' + ? Boolean(entry?.isFile()) + : Boolean(entry?.isDirectory()) +} + +/** + * Says whether a real path is the folder itself or lies below it. + * @param folder - The folder's real path. + * @param realPath - The real path to place. + * @returns `true` for the folder or a path inside it. + * @example isFolderOrInside('/repo', '/repo') // => true + */ +function isFolderOrInside(folder: string, realPath: string): boolean { + return realPath === folder || isInsideFolder(folder, realPath) +} + +/** + * Picks the target node's ESM loader would load for `--import ` from a package's `exports` field: the `.` export, through the first condition of {@link IMPORT_CONDITIONS} in the field's own order. + * @param exportsField - The package's `exports` value. + * @returns The package-relative target (`./dist/loader.mjs`), or `undefined` when the package exports nothing for import as `.`. + * @example + * importTarget('./dist/loader.mjs') // => './dist/loader.mjs' + * importTarget({ '.': { require: './dist/a.cjs', import: './dist/a.mjs' } }) // => './dist/a.mjs' + */ +function importTarget(exportsField: unknown): string | undefined { + if (typeof exportsField === 'string') return exportsField + // A fallback list: the first entry that resolves is node's choice. + if (Array.isArray(exportsField)) + return firstImportTarget(exportsField.values()) + if (!exportsField || typeof exportsField !== 'object') return undefined + const entries = Object.entries(exportsField) + // Subpath keys start with "."; without them the object is the `.` export's conditions. + const dotExport = entries.find(([key]) => key === '.') + if (dotExport) return importTarget(dotExport[1]) + if (entries.some(([key]) => key.startsWith('.'))) return undefined + return firstImportTarget( + entries + .filter(([condition]) => IMPORT_CONDITIONS.has(condition)) + .map(([, target]) => target), + ) +} + +/** + * Answers the first of several export candidates that resolves for import, in their given order, as node tries a fallback list or the matching conditions of an object. + * @param candidates - The candidates, in order. + * @returns The first {@link importTarget} that is not `undefined`, or `undefined`. + * @example firstImportTarget([{ require: './a.cjs' }, './a.mjs']) // => './a.mjs' + */ +function firstImportTarget(candidates: Iterable): string | undefined { + for (const candidate of candidates) { + const target = importTarget(candidate) + if (target !== undefined) return target + } + return undefined +} + +/** + * Finds the `node_modules/tsx` node would resolve from the package root, folder by folder up to the repository's root and no further. + * @param workspace - The opened repository's real root. + * @param packageRoot - The package root's real path, inside the workspace. + * @returns The `node_modules/tsx` folder as reached from its folder (a pnpm link is not yet followed), or `undefined` when none is installed in the repository. + * @example await findInstalledTsx('/repo', '/repo/apps/api') // => '/repo/node_modules/tsx' + */ +async function findInstalledTsx( + workspace: string, + packageRoot: string, +): Promise { + // Walk from the package root up to the repository root, as node's own lookup would before it leaves the repository. + for (let folder = packageRoot; ; folder = dirname(folder)) { + const candidate = join(folder, 'node_modules', 'tsx') + if (await realPathOrNothing(join(candidate, 'package.json'))) + return candidate + if (folder === workspace || !isInsideFolder(workspace, folder)) + return undefined + } +} + +/** + * Resolves the repository's own tsx loader for a TypeScript entry: the file its `.` export gives `--import`, by its real path inside the repository. + * @param workspace - The opened repository's real root. + * @param packageRoot - The package root's real path. + * @param declaredPackageRoot - `packageRoot` as the declaration wrote it, for the refusal's words. + * @returns The loader's real path and its file URL. + * @throws `NODE_SERVER_SOURCE_REFUSED` when no tsx is installed in the repository, when it lies outside the repository by its real path (pnpm's `enableGlobalVirtualStore` links it so), or when it exports no loader file inside the repository. + * @example await resolveTsxLoader('/repo', '/repo/apps/api', 'apps/api') // => { kind: 'tsx', path: '/repo/node_modules/.pnpm/tsx@4.23.15/node_modules/tsx/dist/loader.mjs', url: 'file:///repo/…/loader.mjs' } + */ +async function resolveTsxLoader( + workspace: string, + packageRoot: string, + declaredPackageRoot: string, +): Promise { + const installed = await findInstalledTsx(workspace, packageRoot) + if (!installed) + throw refusal( + `node-server's TypeScript entry runs through the repository's own tsx, and none is installed for ${declaredPackageRoot} (${declaredPackageRoot === '.' ? 'searched node_modules/tsx at the repository root' : `searched node_modules/tsx from ${declaredPackageRoot} up to the repository root`}); install the repository's dependencies.`, + ) + const installedName = repositoryPosixPath(workspace, installed) + const packageFolder = await realPathOrNothing(installed) + if (!packageFolder || !isInsideFolder(workspace, packageFolder)) + throw refusal( + `node-server's tsx for ${declaredPackageRoot} (${installedName}) lies outside the opened repository by its real path, as with pnpm's enableGlobalVirtualStore; Codebook loads only a tsx inside the repository.`, + ) + let manifest: unknown + try { + manifest = JSON.parse( + await readFile(join(packageFolder, 'package.json'), 'utf8'), + ) + } catch { + // An unreadable manifest exports nothing; the refusal below names it. + manifest = undefined + } + const parsedManifest = packageManifestSchema.safeParse(manifest) + const target = parsedManifest.success + ? importTarget(parsedManifest.data.exports) + : undefined + const loader = target + ? await realPathOrNothing(resolve(packageFolder, target)) + : undefined + // The loader file itself must also stay inside the repository and be a file. + if ( + !loader || + !isInsideFolder(workspace, loader) || + !(await isEntryOfKind(loader, 'file')) + ) + throw refusal( + `node-server's tsx for ${declaredPackageRoot} (${installedName}) exports no loader file inside the repository as "." for import; install the repository's dependencies again.`, + ) + return { kind: 'tsx', path: loader, url: pathToFileURL(loader).href } +} + +/** + * Checks a node-server declaration's launch inputs on disk before anything is spawned: the package root is a folder inside the opened repository, the entry a file inside the package root, and a TypeScript entry's loader the repository's own tsx, each by its real path. + * Called by the node-server run's preparation after the declaration parsed ({@link nodeServerConfigSchema}) and before the launch ({@link launchNodeServer}), which runs exactly these real paths. + * @param workspace - The opened repository's real root. + * @param configuration - The declaration's packageRoot, entry and (defaulted) loader. + * @returns The real package root, the real entry and the loader ({@link ResolvedNodeServerLaunch}). + * @throws `NODE_SERVER_SOURCE_REFUSED` naming the field whose real path is missing or leaves the repository, or the tsx that is missing, outside the repository or without a loader. + * @example + * await resolveNodeServerLaunch('/repo', { packageRoot: 'apps/api', entry: 'src/server.ts', loader: 'tsx' }) + * // => { packageRoot: '/repo/apps/api', entry: '/repo/apps/api/src/server.ts', loader: { kind: 'tsx', path: '/repo/node_modules/.pnpm/tsx@4.23.15/node_modules/tsx/dist/loader.mjs', url: 'file:///repo/…' } } + */ +export async function resolveNodeServerLaunch( + workspace: string, + configuration: Pick< + NodeServerConfiguration, + 'packageRoot' | 'entry' | 'loader' + >, +): Promise { + const packageRoot = await realPathOrNothing( + join(workspace, configuration.packageRoot), + ) + if ( + !packageRoot || + !isFolderOrInside(workspace, packageRoot) || + !(await isEntryOfKind(packageRoot, 'folder')) + ) + throw refusal( + `node-server's packageRoot ${configuration.packageRoot} is not a folder inside the opened repository by its real path.`, + ) + const entry = await realPathOrNothing(join(packageRoot, configuration.entry)) + if ( + !entry || + !isInsideFolder(packageRoot, entry) || + !(await isEntryOfKind(entry, 'file')) + ) + throw refusal( + `node-server's entry ${configuration.entry} is not a file inside packageRoot ${configuration.packageRoot} by its real path.`, + ) + const loader = + configuration.loader === 'tsx' + ? await resolveTsxLoader( + workspace, + packageRoot, + configuration.packageRoot, + ) + : ({ kind: 'node' } as const) + return { packageRoot, entry, loader } +} diff --git a/extension/src/utils/sanitizeCause.ts b/extension/src/utils/sanitizeCause.ts index bf00e6090..a1f3356d5 100644 --- a/extension/src/utils/sanitizeCause.ts +++ b/extension/src/utils/sanitizeCause.ts @@ -1,6 +1,7 @@ import { StudyError } from '../errors' import { messageNamingCause } from './messageNamingCause' +import { redactSecretText } from './redactSecretText' import { withoutErrorName } from './withoutErrorName' /** The private roots a cause must not show, with the words that replace them. */ @@ -129,5 +130,8 @@ export function sanitizeCause(error: unknown, roots: CauseRoots): string { hideRoots(error.causeDetail), ) : hideRoots(withoutErrorName(String(error))) - return text.replace(CONTROL_CHARACTERS, ' ').replace(/ {2,}/g, ' ').trim() + // Secret-looking values in a cause are masked last, once control characters can no longer split them. + return redactSecretText( + text.replace(CONTROL_CHARACTERS, ' ').replace(/ {2,}/g, ' ').trim(), + ) } diff --git a/extension/src/utils/splitListenerAddress.ts b/extension/src/utils/splitListenerAddress.ts new file mode 100644 index 000000000..9ec89d103 --- /dev/null +++ b/extension/src/utils/splitListenerAddress.ts @@ -0,0 +1,28 @@ +import { TCP_PORT_MAX } from '../constants' + +/** + * Splits a listening socket's local endpoint as `lsof` and `ss` print it (`*:4100`, `127.0.0.1:4100`, `[::1]:4100`, `0.0.0.0%lo:4100`) into its address and port. + * Called by {@link parseLsofListeners} and {@link parseSsListeners}. + * @param endpoint - The printed endpoint. + * @returns + * - `{ address, port }` with brackets and a `%` scope removed (`*` stays `*`) + * - `undefined` when the text after the last `:` is no port from 1 to 65535 + * @example + * splitListenerAddress('[::1]:4100') // => { address: '::1', port: 4100 } + * splitListenerAddress('*:4000') // => { address: '*', port: 4000 } + * splitListenerAddress('127.0.0.1:http') // => undefined + */ +export function splitListenerAddress( + endpoint: string, +): { address: string; port: number } | undefined { + const separator = endpoint.lastIndexOf(':') + const portText = endpoint.slice(separator + 1) + if (separator <= 0 || !/^\d{1,5}$/.test(portText)) return undefined + const port = Number(portText) + if (port < 1 || port > TCP_PORT_MAX) return undefined + const address = endpoint + .slice(0, separator) + .replace(/^\[(.*)\]$/, '$1') + .replace(/%.*$/, '') + return address ? { address, port } : undefined +} diff --git a/extension/src/utils/splitNameIntoWords.ts b/extension/src/utils/splitNameIntoWords.ts new file mode 100644 index 000000000..7c10609c4 --- /dev/null +++ b/extension/src/utils/splitNameIntoWords.ts @@ -0,0 +1,21 @@ +/** + * Splits an identifier into lowercase words at camelCase humps, `_`, `-`, `.` and spaces. + * Why: the redaction name rule matches whole words, never substrings; called by {@link isSecretName}. + * @param name - A variable, property or header name. + * @returns The words in order, lowercase; empty for a name with no letters or digits. + * @example + * splitNameIntoWords('accessToken') // => ['access', 'token'] + * splitNameIntoWords('HTTP_API_KEY') // => ['http', 'api', 'key'] + */ +export function splitNameIntoWords(name: string): string[] { + return ( + name + // A hump between a lowercase letter or digit and an uppercase letter: `accessToken`. + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + // The end of an acronym before a word: `APIKey` -> `API Key`. + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((word) => word.toLowerCase()) + ) +} diff --git a/extension/src/utils/watchOwnedProcess.ts b/extension/src/utils/watchOwnedProcess.ts new file mode 100644 index 000000000..216775ffd --- /dev/null +++ b/extension/src/utils/watchOwnedProcess.ts @@ -0,0 +1,53 @@ +import type { ChildProcess } from 'node:child_process' + +import { + captureProcessOutput, + type ProcessOutputCapture, + type ProcessOutputOptions, +} from './captureProcessOutput' + +/** What an owned launcher follows of the process it spawned: its exit, a start failure and its redacted output. */ +export type WatchedProcess = { + /** Settles when the process has closed (exited and its streams ended). */ + exit: Promise + /** Says whether the process has closed. */ + hasExited(): boolean + /** The process's start failure, else its output capture's first failure, if any. */ + failure(): Error | undefined + /** The redacted output capture ({@link captureProcessOutput}). */ + capture: ProcessOutputCapture +} + +/** + * Follows an owned process from the moment it is spawned: its close, a failure to start, and its output through the redacted capture. + * Called by {@link launchNodeTest} and {@link launchNodeServer} right after `spawn`, before any event can be missed. + * @param child - The spawned process. + * @param output - How its output is stamped, delivered and bounded ({@link ProcessOutputOptions}). + * @returns The process's exit, close state, failure and capture ({@link WatchedProcess}). + * @example + * const watched = watchOwnedProcess(child, { startedAt, onOutput, pendingLimitBytes, overflowMessage, failurePrefix }) + * await watched.exit; watched.hasExited() // => true + */ +export function watchOwnedProcess( + child: ChildProcess, + output: ProcessOutputOptions, +): WatchedProcess { + let spawnError: Error | undefined, + exited = false + const exit = new Promise((resolve) => + child.once('close', () => { + exited = true + resolve() + }), + ) + child.once('error', (cause) => { + spawnError = cause + }) + const capture = captureProcessOutput(child, output) + return { + exit, + hasExited: () => exited, + failure: () => spawnError ?? capture.failure(), + capture, + } +}