From 9743a9eb2cd5d74f4434a84d971b5ee138da9d98 Mon Sep 17 00:00:00 2001 From: sai-adarsh Date: Wed, 29 Jul 2026 12:21:09 +0530 Subject: [PATCH 01/15] Harden network checks --- package.json | 2 +- scripts/check-dependencies.mjs | 62 +++++++++++++ scripts/check-source-contracts.mjs | 22 ++--- scripts/lib/retry.mjs | 28 ++++++ scripts/lib/source-fetch.mjs | 75 ++++++++++++++++ tests/check-dependencies.test.mjs | 135 +++++++++++++++++++++++++++++ tests/retry.test.mjs | 93 ++++++++++++++++++++ tests/site-contract.test.mjs | 2 +- tests/source-fetch.test.mjs | 86 ++++++++++++++++++ 9 files changed, 486 insertions(+), 19 deletions(-) create mode 100644 scripts/check-dependencies.mjs create mode 100644 scripts/lib/retry.mjs create mode 100644 scripts/lib/source-fetch.mjs create mode 100644 tests/check-dependencies.test.mjs create mode 100644 tests/retry.test.mjs create mode 100644 tests/source-fetch.test.mjs diff --git a/package.json b/package.json index 244dd44..6134457 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "serve": "docusaurus serve", "typecheck": "tsc", "test": "node --test tests/*.test.mjs", - "check:deps": "npm ls brace-expansion serialize-javascript uuid --all --silent && npm audit --omit=dev", + "check:deps": "node scripts/check-dependencies.mjs", "check:source": "node scripts/check-source-contracts.mjs", "check": "npm run typecheck && npm test && npm run check:deps && npm run check:source && npm run build" }, diff --git a/scripts/check-dependencies.mjs b/scripts/check-dependencies.mjs new file mode 100644 index 0000000..5d852bc --- /dev/null +++ b/scripts/check-dependencies.mjs @@ -0,0 +1,62 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; + +import { retry } from "./lib/retry.mjs"; + +const execFileAsync = promisify(execFile); +const TREE_ARGS = [ + "ls", + "brace-expansion", + "serialize-javascript", + "uuid", + "--all", + "--silent", +]; +const AUDIT_ARGS = ["audit", "--omit=dev"]; +const NETWORK_ERROR_PATTERN = + /\b(?:EAI_AGAIN|ECONNRESET|ECONNREFUSED|ENETUNREACH|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT)\b|socket hang up|fetch failed/i; +const TRANSIENT_HTTP_PATTERN = + /\b(?:(?:HTTP|status)(?:\s+(?:status|code))?|npm error)\s*[:=]?\s*(?:408|429|5\d\d)\b|\bE5\d\d\b/i; + +async function execute(command, args) { + const { stdout, stderr } = await execFileAsync(command, args); + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); +} + +function isTransientNpmFailure(error) { + const output = [error?.code, error?.message, error?.stdout, error?.stderr] + .filter(Boolean) + .join("\n"); + return ( + NETWORK_ERROR_PATTERN.test(output) || TRANSIENT_HTTP_PATTERN.test(output) + ); +} + +export async function runDependencyChecks({ + runCommand = execute, + sleep, +} = {}) { + await runCommand("npm", TREE_ARGS); + await retry(() => runCommand("npm", AUDIT_ARGS), { + isRetryableError: isTransientNpmFailure, + ...(sleep ? { sleep } : {}), + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + await runDependencyChecks(); + } catch (error) { + if (error?.stdout) process.stdout.write(error.stdout); + if (error?.stderr) process.stderr.write(error.stderr); + if (!error?.stdout && !error?.stderr) { + console.error(error?.message || error); + } + process.exitCode = Number.isInteger(error?.code) ? error.code : 1; + } +} diff --git a/scripts/check-source-contracts.mjs b/scripts/check-source-contracts.mjs index 033c083..a489ba5 100644 --- a/scripts/check-source-contracts.mjs +++ b/scripts/check-source-contracts.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { fetchSourceText } from "./lib/source-fetch.mjs"; const RAW_BASE = "https://raw.githubusercontent.com/linkoutapp/linkout-scraper/main"; @@ -47,23 +48,10 @@ function parseJson(path, source) { } async function read(path) { - let response; - try { - response = await fetch(`${RAW_BASE}/${path}`, { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - } catch (error) { - throw new Error( - diagnostic(path, `fetch failed (${error.code || error.message})`), - { cause: error } - ); - } - assert.equal( - response.ok, - true, - diagnostic(path, `fetch returned HTTP ${response.status}`) - ); - return response.text(); + return fetchSourceText(path, { + baseUrl: RAW_BASE, + timeoutMs: FETCH_TIMEOUT_MS, + }); } async function readAll(requestedPaths, concurrency = FETCH_CONCURRENCY) { diff --git a/scripts/lib/retry.mjs b/scripts/lib/retry.mjs new file mode 100644 index 0000000..5c88c5a --- /dev/null +++ b/scripts/lib/retry.mjs @@ -0,0 +1,28 @@ +const DEFAULT_DELAYS_MS = [250, 1_000]; + +function isTransientHttpStatus(status) { + return status === 408 || status === 429 || (status >= 500 && status <= 599); +} + +export async function retry( + operation, + { + isRetryableError = () => false, + sleep = (delayMs) => + new Promise((resolve) => setTimeout(resolve, delayMs)), + delays = DEFAULT_DELAYS_MS, + } = {} +) { + const attempts = delays.length + 1; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await operation(); + } catch (error) { + const canRetry = + isTransientHttpStatus(error?.status) || isRetryableError(error); + if (!canRetry || attempt === attempts - 1) throw error; + await sleep(delays[attempt]); + } + } +} diff --git a/scripts/lib/source-fetch.mjs b/scripts/lib/source-fetch.mjs new file mode 100644 index 0000000..fb8940b --- /dev/null +++ b/scripts/lib/source-fetch.mjs @@ -0,0 +1,75 @@ +import { retry } from "./retry.mjs"; + +const DEFAULT_BASE_URL = + "https://raw.githubusercontent.com/linkoutapp/linkout-scraper/main"; +const DEFAULT_TIMEOUT_MS = 15_000; +const NETWORK_ERROR_CODES = new Set([ + "EAI_AGAIN", + "ECONNRESET", + "ECONNREFUSED", + "ENETUNREACH", + "ENOTFOUND", + "ETIMEDOUT", +]); + +function diagnostic(path, message) { + return `${path}: ${message}`; +} + +function isNetworkOrTimeoutError(error) { + return ( + error instanceof TypeError || + error?.name === "AbortError" || + error?.name === "TimeoutError" || + NETWORK_ERROR_CODES.has(error?.code) + ); +} + +export async function fetchSourceText( + path, + { + baseUrl = DEFAULT_BASE_URL, + fetchImpl = fetch, + timeoutMs = DEFAULT_TIMEOUT_MS, + timeoutSignal = AbortSignal.timeout, + sleep, + } = {} +) { + try { + return await retry( + async () => { + const response = await fetchImpl(`${baseUrl}/${path}`, { + signal: timeoutSignal(timeoutMs), + }); + if ( + typeof response?.ok !== "boolean" || + typeof response?.status !== "number" || + typeof response?.text !== "function" + ) { + throw new Error(diagnostic(path, "fetch returned a malformed response")); + } + if (!response.ok) { + throw Object.assign(new Error(`HTTP ${response.status}`), { + status: response.status, + }); + } + return await response.text(); + }, + { + isRetryableError: isNetworkOrTimeoutError, + ...(sleep ? { sleep } : {}), + } + ); + } catch (error) { + if (error?.message?.startsWith(`${path}:`)) throw error; + if (typeof error?.status === "number") { + throw new Error(diagnostic(path, `fetch returned HTTP ${error.status}`), { + cause: error, + }); + } + throw new Error( + diagnostic(path, `fetch failed (${error?.code || error?.message})`), + { cause: error } + ); + } +} diff --git a/tests/check-dependencies.test.mjs b/tests/check-dependencies.test.mjs new file mode 100644 index 0000000..137265d --- /dev/null +++ b/tests/check-dependencies.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runDependencyChecks } from "../scripts/check-dependencies.mjs"; + +test("validates the existing dependency tree before auditing production dependencies", async () => { + const commands = []; + + await runDependencyChecks({ + runCommand: async (command, args) => commands.push([command, args]), + sleep: async () => {}, + }); + + assert.deepEqual(commands, [ + [ + "npm", + ["ls", "brace-expansion", "serialize-javascript", "uuid", "--all", "--silent"], + ], + ["npm", ["audit", "--omit=dev"]], + ]); +}); + +test("retries transient npm audit network failures", async () => { + let auditAttempts = 0; + + await runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + if (auditAttempts < 3) { + throw Object.assign(new Error("request to advisory endpoint failed"), { + stderr: "npm error code ECONNRESET", + }); + } + }, + sleep: async () => {}, + }); + + assert.equal(auditAttempts, 3); +}); + +test("retries transient npm audit DNS failures", async () => { + let auditAttempts = 0; + + await runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + if (auditAttempts === 1) { + throw Object.assign(new Error("advisory endpoint unavailable"), { + stderr: "npm error code ENOTFOUND", + }); + } + }, + sleep: async () => {}, + }); + + assert.equal(auditAttempts, 2); +}); + +test("does not retry an invalid dependency tree", async () => { + let attempts = 0; + const failure = Object.assign(new Error("invalid tree"), { exitCode: 1 }); + + await assert.rejects( + runDependencyChecks({ + runCommand: async () => { + attempts += 1; + throw failure; + }, + sleep: async () => {}, + }), + (error) => error === failure + ); + assert.equal(attempts, 1); +}); + +test("does not retry audit failures caused by vulnerabilities", async () => { + let auditAttempts = 0; + const failure = Object.assign(new Error("vulnerabilities found"), { + stdout: "3 high severity vulnerabilities", + }); + + await assert.rejects( + runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + throw failure; + }, + sleep: async () => {}, + }), + (error) => error === failure + ); + assert.equal(auditAttempts, 1); +}); + +test("does not mistake a vulnerability count for an HTTP failure", async () => { + let auditAttempts = 0; + const failure = Object.assign(new Error("vulnerabilities found"), { + stdout: "500 vulnerabilities", + }); + + await assert.rejects( + runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + throw failure; + }, + sleep: async () => {}, + }), + (error) => error === failure + ); + assert.equal(auditAttempts, 1); +}); + +test("retries transient advisory HTTP failures", async () => { + let auditAttempts = 0; + + await runDependencyChecks({ + runCommand: async (_command, args) => { + if (args[0] !== "audit") return; + auditAttempts += 1; + if (auditAttempts === 1) { + throw Object.assign(new Error("503 Service Unavailable"), { + stderr: "npm error 503 Service Unavailable", + }); + } + }, + sleep: async () => {}, + }); + + assert.equal(auditAttempts, 2); +}); diff --git a/tests/retry.test.mjs b/tests/retry.test.mjs new file mode 100644 index 0000000..21dd94d --- /dev/null +++ b/tests/retry.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { retry } from "../scripts/lib/retry.mjs"; + +function httpError(status) { + return Object.assign(new Error(`HTTP ${status}`), { status }); +} + +test("returns an eventual success within three attempts", async () => { + let attempts = 0; + + const result = await retry( + async () => { + attempts += 1; + if (attempts < 3) throw httpError(503); + return "ok"; + }, + { sleep: async () => {} } + ); + + assert.equal(result, "ok"); + assert.equal(attempts, 3); +}); + +test("throws after three exhausted attempts", async () => { + let attempts = 0; + const failure = httpError(503); + + await assert.rejects( + retry( + async () => { + attempts += 1; + throw failure; + }, + { sleep: async () => {} } + ), + (error) => error === failure + ); + assert.equal(attempts, 3); +}); + +for (const status of [408, 429, 500, 502, 503, 504]) { + test(`retries HTTP ${status}`, async () => { + let attempts = 0; + + await retry( + async () => { + attempts += 1; + if (attempts === 1) throw httpError(status); + }, + { sleep: async () => {} } + ); + + assert.equal(attempts, 2); + }); +} + +test("does not retry permanent HTTP 404 failures", async () => { + let attempts = 0; + const failure = httpError(404); + + await assert.rejects( + retry( + async () => { + attempts += 1; + throw failure; + }, + { sleep: async () => {} } + ), + (error) => error === failure + ); + assert.equal(attempts, 1); +}); + +for (const kind of ["timeout", "network"]) { + test(`retries caller-classified ${kind} errors`, async () => { + let attempts = 0; + + await retry( + async () => { + attempts += 1; + if (attempts === 1) throw Object.assign(new Error(kind), { kind }); + }, + { + isRetryableError: (error) => error.kind === kind, + sleep: async () => {}, + } + ); + + assert.equal(attempts, 2); + }); +} diff --git a/tests/site-contract.test.mjs b/tests/site-contract.test.mjs index 2aed4e8..e4342a4 100644 --- a/tests/site-contract.test.mjs +++ b/tests/site-contract.test.mjs @@ -136,7 +136,7 @@ test('patched transitive dependencies are explicitly scoped', () => { ); assert.equal( packageJson.scripts?.['check:deps'], - 'npm ls brace-expansion serialize-javascript uuid --all --silent && npm audit --omit=dev', + 'node scripts/check-dependencies.mjs', ); }); diff --git a/tests/source-fetch.test.mjs b/tests/source-fetch.test.mjs new file mode 100644 index 0000000..a5ee74f --- /dev/null +++ b/tests/source-fetch.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { fetchSourceText } from "../scripts/lib/source-fetch.mjs"; + +function response(status, body = "") { + return { + ok: status >= 200 && status < 300, + status, + text: async () => body, + }; +} + +test("retries a transient source response and returns its eventual body", async () => { + let attempts = 0; + + const body = await fetchSourceText("README.md", { + fetchImpl: async () => { + attempts += 1; + return attempts === 1 ? response(503) : response(200, "contents"); + }, + sleep: async () => {}, + }); + + assert.equal(body, "contents"); + assert.equal(attempts, 2); +}); + +test("reports a permanent source response without retrying", async () => { + let attempts = 0; + + await assert.rejects( + fetchSourceText("missing.md", { + fetchImpl: async () => { + attempts += 1; + return response(404); + }, + sleep: async () => {}, + }), + /missing\.md: fetch returned HTTP 404/ + ); + assert.equal(attempts, 1); +}); + +test("retries thrown network failures with per-file diagnostics", async () => { + let attempts = 0; + + await assert.rejects( + fetchSourceText("package.json", { + fetchImpl: async () => { + attempts += 1; + throw new TypeError("fetch failed"); + }, + sleep: async () => {}, + }), + /package\.json: fetch failed \(fetch failed\)/ + ); + assert.equal(attempts, 3); +}); + +test("fails clearly when fetch returns a malformed response", async () => { + await assert.rejects( + fetchSourceText("README.md", { + fetchImpl: async () => ({ ok: true }), + sleep: async () => {}, + }), + /README\.md: fetch returned a malformed response/ + ); +}); + +test("preserves the 15 second timeout for every attempt", async () => { + const timeoutCalls = []; + + await fetchSourceText("README.md", { + fetchImpl: async (_url, options) => { + assert.equal(options.signal, "timeout-15000"); + return response(200); + }, + timeoutSignal: (timeoutMs) => { + timeoutCalls.push(timeoutMs); + return `timeout-${timeoutMs}`; + }, + }); + + assert.deepEqual(timeoutCalls, [15_000]); +}); From 368ec98f9c59b1e6c2a153ad67fdfed4f62a003a Mon Sep 17 00:00:00 2001 From: sai-adarsh Date: Wed, 29 Jul 2026 12:31:27 +0530 Subject: [PATCH 02/15] Harden dependency launcher --- scripts/check-dependencies.mjs | 135 +++++++++++++++++++++++++++--- tests/check-dependencies.test.mjs | 128 +++++++++++++++++++++++++++- tests/source-fetch.test.mjs | 12 ++- 3 files changed, 259 insertions(+), 16 deletions(-) diff --git a/scripts/check-dependencies.mjs b/scripts/check-dependencies.mjs index 5d852bc..27dd9e7 100644 --- a/scripts/check-dependencies.mjs +++ b/scripts/check-dependencies.mjs @@ -1,10 +1,10 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { spawn } from "node:child_process"; import { pathToFileURL } from "node:url"; import { retry } from "./lib/retry.mjs"; -const execFileAsync = promisify(execFile); +const COMMAND_TIMEOUT_MS = 15_000; +const OUTPUT_TAIL_LIMIT = 64 * 1024; const TREE_ARGS = [ "ls", "brace-expansion", @@ -19,10 +19,119 @@ const NETWORK_ERROR_PATTERN = const TRANSIENT_HTTP_PATTERN = /\b(?:(?:HTTP|status)(?:\s+(?:status|code))?|npm error)\s*[:=]?\s*(?:408|429|5\d\d)\b|\bE5\d\d\b/i; -async function execute(command, args) { - const { stdout, stderr } = await execFileAsync(command, args); - if (stdout) process.stdout.write(stdout); - if (stderr) process.stderr.write(stderr); +function appendTail(current, chunk) { + return `${current}${chunk}`.slice(-OUTPUT_TAIL_LIMIT); +} + +function commandError(args, code, signal, stdout, stderr, timeoutMs) { + const detail = + code === "ETIMEDOUT" + ? `timed out after ${timeoutMs}ms` + : `exited with ${signal ? `signal ${signal}` : `code ${code}`}`; + return Object.assign(new Error(`npm ${args.join(" ")} ${detail}`), { + code, + signal, + stdout, + stderr, + }); +} + +export function createNpmRunner({ + platform = process.platform, + spawnImpl = spawn, + timeoutMs = COMMAND_TIMEOUT_MS, + setTimer = setTimeout, + clearTimer = clearTimeout, + stdout = process.stdout, + stderr = process.stderr, +} = {}) { + const executable = platform === "win32" ? "npm.cmd" : "npm"; + + return (args) => + new Promise((resolve, reject) => { + const child = spawnImpl(executable, args, { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdoutTail = ""; + let stderrTail = ""; + let settled = false; + let timedOut = false; + + child.stdout.on("data", (chunk) => { + stdout.write(chunk); + stdoutTail = appendTail(stdoutTail, chunk); + }); + child.stderr.on("data", (chunk) => { + stderr.write(chunk); + stderrTail = appendTail(stderrTail, chunk); + }); + + const finish = (callback, value) => { + if (settled) return; + settled = true; + clearTimer(timer); + callback(value); + }; + const timer = setTimer(() => { + if (settled) return; + timedOut = true; + try { + if (!child.kill("SIGKILL")) { + finish( + reject, + commandError( + args, + "ETIMEDOUT", + null, + stdoutTail, + stderrTail, + timeoutMs + ) + ); + } + } catch { + finish( + reject, + commandError( + args, + "ETIMEDOUT", + null, + stdoutTail, + stderrTail, + timeoutMs + ) + ); + } + }, timeoutMs); + + child.once("error", (error) => { + Object.assign(error, { stdout: stdoutTail, stderr: stderrTail }); + finish(reject, error); + }); + child.once("close", (code, signal) => { + if (timedOut) { + finish( + reject, + commandError( + args, + "ETIMEDOUT", + signal, + stdoutTail, + stderrTail, + timeoutMs + ) + ); + } else if (code === 0) { + finish(resolve); + } else { + finish( + reject, + commandError(args, code, signal, stdoutTail, stderrTail, timeoutMs) + ); + } + }); + }); } function isTransientNpmFailure(error) { @@ -35,11 +144,15 @@ function isTransientNpmFailure(error) { } export async function runDependencyChecks({ - runCommand = execute, + runCommand, + runnerOptions, sleep, } = {}) { - await runCommand("npm", TREE_ARGS); - await retry(() => runCommand("npm", AUDIT_ARGS), { + const execute = runCommand + ? (args) => runCommand("npm", args) + : createNpmRunner(runnerOptions); + await execute(TREE_ARGS); + await retry(() => execute(AUDIT_ARGS), { isRetryableError: isTransientNpmFailure, ...(sleep ? { sleep } : {}), }); @@ -52,8 +165,6 @@ if ( try { await runDependencyChecks(); } catch (error) { - if (error?.stdout) process.stdout.write(error.stdout); - if (error?.stderr) process.stderr.write(error.stderr); if (!error?.stdout && !error?.stderr) { console.error(error?.message || error); } diff --git a/tests/check-dependencies.test.mjs b/tests/check-dependencies.test.mjs index 137265d..a017ee2 100644 --- a/tests/check-dependencies.test.mjs +++ b/tests/check-dependencies.test.mjs @@ -1,7 +1,133 @@ import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; import test from "node:test"; -import { runDependencyChecks } from "../scripts/check-dependencies.mjs"; +import { + createNpmRunner, + runDependencyChecks, +} from "../scripts/check-dependencies.mjs"; + +function fakeChild({ code = 0, stderr = "", stdout = "" } = {}) { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = () => true; + queueMicrotask(() => { + if (stdout) child.stdout.write(stdout); + if (stderr) child.stderr.write(stderr); + child.emit("close", code, null); + }); + return child; +} + +for (const [platform, executable] of [ + ["darwin", "npm"], + ["win32", "npm.cmd"], +]) { + test(`real launcher uses ${executable} without a shell on ${platform}`, async () => { + const launches = []; + const runCommand = createNpmRunner({ + platform, + spawnImpl: (command, args, options) => { + launches.push({ command, args, options }); + return fakeChild(); + }, + stderr: { write() {} }, + stdout: { write() {} }, + }); + + await runCommand(["audit", "--omit=dev"]); + + assert.deepEqual(launches, [ + { + command: executable, + args: ["audit", "--omit=dev"], + options: { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }, + }, + ]); + }); +} + +test("real launcher kills and retries a stalled audit attempt", async () => { + let auditAttempts = 0; + const launches = []; + const killed = []; + const timeoutCalls = []; + + await runDependencyChecks({ + runnerOptions: { + platform: "linux", + spawnImpl: (_command, args) => { + launches.push(args); + if (args[0] !== "audit") { + return fakeChild(); + } + auditAttempts += 1; + if (auditAttempts > 1) return fakeChild(); + + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = (signal) => { + killed.push(signal); + queueMicrotask(() => child.emit("close", null, signal)); + return true; + }; + return child; + }, + setTimer: (callback, timeoutMs) => { + timeoutCalls.push(timeoutMs); + const timer = { active: true }; + queueMicrotask(() => { + if (timer.active) callback(); + }); + return timer; + }, + clearTimer(timer) { + timer.active = false; + }, + stderr: { write() {} }, + stdout: { write() {} }, + }, + sleep: async () => {}, + }); + + assert.deepEqual( + launches.map((args) => args[0]), + ["ls", "audit", "audit"] + ); + assert.deepEqual(killed, ["SIGKILL"]); + assert.deepEqual(timeoutCalls, [15_000, 15_000, 15_000]); +}); + +test("real launcher preserves subprocess output and nonzero exit code", async () => { + const written = { stderr: "", stdout: "" }; + const runCommand = createNpmRunner({ + spawnImpl: () => + fakeChild({ + code: 1, + stdout: "audit report\n", + stderr: "vulnerabilities found\n", + }), + stderr: { write: (chunk) => (written.stderr += chunk) }, + stdout: { write: (chunk) => (written.stdout += chunk) }, + }); + + await assert.rejects(runCommand(["audit", "--omit=dev"]), (error) => { + assert.equal(error.code, 1); + assert.equal(error.stdout, "audit report\n"); + assert.equal(error.stderr, "vulnerabilities found\n"); + return true; + }); + assert.deepEqual(written, { + stdout: "audit report\n", + stderr: "vulnerabilities found\n", + }); +}); test("validates the existing dependency tree before auditing production dependencies", async () => { const commands = []; diff --git a/tests/source-fetch.test.mjs b/tests/source-fetch.test.mjs index a5ee74f..758f3dc 100644 --- a/tests/source-fetch.test.mjs +++ b/tests/source-fetch.test.mjs @@ -69,18 +69,24 @@ test("fails clearly when fetch returns a malformed response", async () => { }); test("preserves the 15 second timeout for every attempt", async () => { + let attempts = 0; + const signals = []; const timeoutCalls = []; await fetchSourceText("README.md", { fetchImpl: async (_url, options) => { - assert.equal(options.signal, "timeout-15000"); + signals.push(options.signal); + attempts += 1; + if (attempts === 1) throw new TypeError("fetch failed"); return response(200); }, timeoutSignal: (timeoutMs) => { timeoutCalls.push(timeoutMs); - return `timeout-${timeoutMs}`; + return { timeoutMs }; }, + sleep: async () => {}, }); - assert.deepEqual(timeoutCalls, [15_000]); + assert.deepEqual(timeoutCalls, [15_000, 15_000]); + assert.notEqual(signals[0], signals[1]); }); From 6a9fc49458bb46abab5fe2698b835c2a35036063 Mon Sep 17 00:00:00 2001 From: sai-adarsh Date: Wed, 29 Jul 2026 12:35:38 +0530 Subject: [PATCH 03/15] Fix Windows npm launcher --- scripts/check-dependencies.mjs | 58 ++++++++++++++++++--- tests/check-dependencies.test.mjs | 84 ++++++++++++++++++++++++++++--- 2 files changed, 127 insertions(+), 15 deletions(-) diff --git a/scripts/check-dependencies.mjs b/scripts/check-dependencies.mjs index 27dd9e7..5ddbed7 100644 --- a/scripts/check-dependencies.mjs +++ b/scripts/check-dependencies.mjs @@ -14,6 +14,10 @@ const TREE_ARGS = [ "--silent", ]; const AUDIT_ARGS = ["audit", "--omit=dev"]; +const WINDOWS_FALLBACK_COMMANDS = new Map([ + [JSON.stringify(TREE_ARGS), `npm.cmd ${TREE_ARGS.join(" ")}`], + [JSON.stringify(AUDIT_ARGS), `npm.cmd ${AUDIT_ARGS.join(" ")}`], +]); const NETWORK_ERROR_PATTERN = /\b(?:EAI_AGAIN|ECONNRESET|ECONNREFUSED|ENETUNREACH|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT)\b|socket hang up|fetch failed/i; const TRANSIENT_HTTP_PATTERN = @@ -36,7 +40,33 @@ function commandError(args, code, signal, stdout, stderr, timeoutMs) { }); } +function npmInvocation( + args, + { environment, nodeExecutable, platform } +) { + if (environment.npm_execpath) { + return { + args: [environment.npm_execpath, ...args], + executable: nodeExecutable, + }; + } + if (platform !== "win32") { + return { args, executable: "npm" }; + } + + const command = WINDOWS_FALLBACK_COMMANDS.get(JSON.stringify(args)); + if (!command) { + throw new Error("unsupported npm command for Windows fallback"); + } + return { + args: ["/d", "/s", "/c", command], + executable: environment.ComSpec || environment.COMSPEC || "cmd.exe", + }; +} + export function createNpmRunner({ + environment = process.env, + nodeExecutable = process.execPath, platform = process.platform, spawnImpl = spawn, timeoutMs = COMMAND_TIMEOUT_MS, @@ -45,11 +75,15 @@ export function createNpmRunner({ stdout = process.stdout, stderr = process.stderr, } = {}) { - const executable = platform === "win32" ? "npm.cmd" : "npm"; + return (npmArgs) => { + const invocation = npmInvocation(npmArgs, { + environment, + nodeExecutable, + platform, + }); - return (args) => - new Promise((resolve, reject) => { - const child = spawnImpl(executable, args, { + return new Promise((resolve, reject) => { + const child = spawnImpl(invocation.executable, invocation.args, { shell: false, stdio: ["ignore", "pipe", "pipe"], }); @@ -81,7 +115,7 @@ export function createNpmRunner({ finish( reject, commandError( - args, + npmArgs, "ETIMEDOUT", null, stdoutTail, @@ -94,7 +128,7 @@ export function createNpmRunner({ finish( reject, commandError( - args, + npmArgs, "ETIMEDOUT", null, stdoutTail, @@ -114,7 +148,7 @@ export function createNpmRunner({ finish( reject, commandError( - args, + npmArgs, "ETIMEDOUT", signal, stdoutTail, @@ -127,11 +161,19 @@ export function createNpmRunner({ } else { finish( reject, - commandError(args, code, signal, stdoutTail, stderrTail, timeoutMs) + commandError( + npmArgs, + code, + signal, + stdoutTail, + stderrTail, + timeoutMs + ) ); } }); }); + }; } function isTransientNpmFailure(error) { diff --git a/tests/check-dependencies.test.mjs b/tests/check-dependencies.test.mjs index a017ee2..d0035a0 100644 --- a/tests/check-dependencies.test.mjs +++ b/tests/check-dependencies.test.mjs @@ -21,13 +21,12 @@ function fakeChild({ code = 0, stderr = "", stdout = "" } = {}) { return child; } -for (const [platform, executable] of [ - ["darwin", "npm"], - ["win32", "npm.cmd"], -]) { - test(`real launcher uses ${executable} without a shell on ${platform}`, async () => { +for (const platform of ["darwin", "win32"]) { + test(`real launcher uses Node and npm_execpath on ${platform}`, async () => { const launches = []; const runCommand = createNpmRunner({ + environment: { npm_execpath: "/opt/npm/bin/npm-cli.js" }, + nodeExecutable: "/opt/node/bin/node", platform, spawnImpl: (command, args, options) => { launches.push({ command, args, options }); @@ -41,8 +40,8 @@ for (const [platform, executable] of [ assert.deepEqual(launches, [ { - command: executable, - args: ["audit", "--omit=dev"], + command: "/opt/node/bin/node", + args: ["/opt/npm/bin/npm-cli.js", "audit", "--omit=dev"], options: { shell: false, stdio: ["ignore", "pipe", "pipe"], @@ -52,6 +51,75 @@ for (const [platform, executable] of [ }); } +test("real launcher falls back to the npm executable on POSIX", async () => { + const launches = []; + const runCommand = createNpmRunner({ + environment: {}, + platform: "linux", + spawnImpl: (command, args, options) => { + launches.push({ command, args, options }); + return fakeChild(); + }, + stderr: { write() {} }, + stdout: { write() {} }, + }); + + await runCommand(["audit", "--omit=dev"]); + + assert.deepEqual(launches, [ + { + command: "npm", + args: ["audit", "--omit=dev"], + options: { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }, + }, + ]); +}); + +test("real launcher uses ComSpec with a controlled command on Windows fallback", async () => { + const launches = []; + const runCommand = createNpmRunner({ + environment: { ComSpec: "C:\\Windows\\System32\\cmd.exe" }, + platform: "win32", + spawnImpl: (command, args, options) => { + launches.push({ command, args, options }); + return fakeChild(); + }, + stderr: { write() {} }, + stdout: { write() {} }, + }); + + await runCommand(["audit", "--omit=dev"]); + + assert.deepEqual(launches, [ + { + command: "C:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", "npm.cmd audit --omit=dev"], + options: { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }, + }, + ]); +}); + +test("Windows fallback rejects npm arguments outside the controlled checks", () => { + const runCommand = createNpmRunner({ + environment: {}, + platform: "win32", + spawnImpl: () => { + assert.fail("must not spawn an unapproved Windows command"); + }, + }); + + assert.throws( + () => runCommand(["exec", "untrusted"]), + /unsupported npm command/ + ); +}); + test("real launcher kills and retries a stalled audit attempt", async () => { let auditAttempts = 0; const launches = []; @@ -60,6 +128,7 @@ test("real launcher kills and retries a stalled audit attempt", async () => { await runDependencyChecks({ runnerOptions: { + environment: {}, platform: "linux", spawnImpl: (_command, args) => { launches.push(args); @@ -107,6 +176,7 @@ test("real launcher kills and retries a stalled audit attempt", async () => { test("real launcher preserves subprocess output and nonzero exit code", async () => { const written = { stderr: "", stdout: "" }; const runCommand = createNpmRunner({ + environment: {}, spawnImpl: () => fakeChild({ code: 1, From a9b156118eecebe45bb26d248189aba3e687c636 Mon Sep 17 00:00:00 2001 From: sai-adarsh Date: Wed, 29 Jul 2026 12:38:39 +0530 Subject: [PATCH 04/15] Require npm launcher on Windows --- scripts/check-dependencies.mjs | 15 +++--------- tests/check-dependencies.test.mjs | 38 ++++++------------------------- 2 files changed, 10 insertions(+), 43 deletions(-) diff --git a/scripts/check-dependencies.mjs b/scripts/check-dependencies.mjs index 5ddbed7..cb8752d 100644 --- a/scripts/check-dependencies.mjs +++ b/scripts/check-dependencies.mjs @@ -14,10 +14,6 @@ const TREE_ARGS = [ "--silent", ]; const AUDIT_ARGS = ["audit", "--omit=dev"]; -const WINDOWS_FALLBACK_COMMANDS = new Map([ - [JSON.stringify(TREE_ARGS), `npm.cmd ${TREE_ARGS.join(" ")}`], - [JSON.stringify(AUDIT_ARGS), `npm.cmd ${AUDIT_ARGS.join(" ")}`], -]); const NETWORK_ERROR_PATTERN = /\b(?:EAI_AGAIN|ECONNRESET|ECONNREFUSED|ENETUNREACH|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT)\b|socket hang up|fetch failed/i; const TRANSIENT_HTTP_PATTERN = @@ -54,14 +50,9 @@ function npmInvocation( return { args, executable: "npm" }; } - const command = WINDOWS_FALLBACK_COMMANDS.get(JSON.stringify(args)); - if (!command) { - throw new Error("unsupported npm command for Windows fallback"); - } - return { - args: ["/d", "/s", "/c", command], - executable: environment.ComSpec || environment.COMSPEC || "cmd.exe", - }; + throw new Error( + "npm_execpath is required on Windows; run this checker through npm" + ); } export function createNpmRunner({ diff --git a/tests/check-dependencies.test.mjs b/tests/check-dependencies.test.mjs index d0035a0..8c6c0c9 100644 --- a/tests/check-dependencies.test.mjs +++ b/tests/check-dependencies.test.mjs @@ -78,46 +78,22 @@ test("real launcher falls back to the npm executable on POSIX", async () => { ]); }); -test("real launcher uses ComSpec with a controlled command on Windows fallback", async () => { - const launches = []; +test("Windows without npm_execpath fails immediately without spawning", () => { + let spawnCalls = 0; const runCommand = createNpmRunner({ environment: { ComSpec: "C:\\Windows\\System32\\cmd.exe" }, platform: "win32", - spawnImpl: (command, args, options) => { - launches.push({ command, args, options }); - return fakeChild(); - }, - stderr: { write() {} }, - stdout: { write() {} }, - }); - - await runCommand(["audit", "--omit=dev"]); - - assert.deepEqual(launches, [ - { - command: "C:\\Windows\\System32\\cmd.exe", - args: ["/d", "/s", "/c", "npm.cmd audit --omit=dev"], - options: { - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }, - }, - ]); -}); - -test("Windows fallback rejects npm arguments outside the controlled checks", () => { - const runCommand = createNpmRunner({ - environment: {}, - platform: "win32", spawnImpl: () => { - assert.fail("must not spawn an unapproved Windows command"); + spawnCalls += 1; + return fakeChild(); }, }); assert.throws( - () => runCommand(["exec", "untrusted"]), - /unsupported npm command/ + () => runCommand(["audit", "--omit=dev"]), + /npm_execpath is required on Windows/ ); + assert.equal(spawnCalls, 0); }); test("real launcher kills and retries a stalled audit attempt", async () => { From a94629494b58237f25adb930e037aaa362d2973d Mon Sep 17 00:00:00 2001 From: sai-adarsh Date: Wed, 29 Jul 2026 12:44:01 +0530 Subject: [PATCH 05/15] Add deployment health check --- package.json | 1 + scripts/check-deployment.mjs | 27 +++++ scripts/lib/deployment-health.mjs | 195 ++++++++++++++++++++++++++++++ tests/deployment-health.test.mjs | 187 ++++++++++++++++++++++++++++ 4 files changed, 410 insertions(+) create mode 100644 scripts/check-deployment.mjs create mode 100644 scripts/lib/deployment-health.mjs create mode 100644 tests/deployment-health.test.mjs diff --git a/package.json b/package.json index 6134457..266b8b7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "serve": "docusaurus serve", "typecheck": "tsc", "test": "node --test tests/*.test.mjs", + "check:deploy": "node scripts/check-deployment.mjs", "check:deps": "node scripts/check-dependencies.mjs", "check:source": "node scripts/check-source-contracts.mjs", "check": "npm run typecheck && npm test && npm run check:deps && npm run check:source && npm run build" diff --git a/scripts/check-deployment.mjs b/scripts/check-deployment.mjs new file mode 100644 index 0000000..cf1b696 --- /dev/null +++ b/scripts/check-deployment.mjs @@ -0,0 +1,27 @@ +import { pathToFileURL } from "node:url"; + +import { checkDeployment } from "./lib/deployment-health.mjs"; + +export async function runDeploymentCheck({ + check = checkDeployment, + stdout = process.stdout, + stderr = process.stderr, +} = {}) { + try { + const result = await check(); + stdout.write( + `Deployment healthy: ${result.hostname} ${result.endpoints.join(" ")}\n` + ); + return 0; + } catch (error) { + stderr.write(`Deployment unhealthy: ${error?.message || error}\n`); + return 1; + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + process.exitCode = await runDeploymentCheck(); +} diff --git a/scripts/lib/deployment-health.mjs b/scripts/lib/deployment-health.mjs new file mode 100644 index 0000000..ee131a2 --- /dev/null +++ b/scripts/lib/deployment-health.mjs @@ -0,0 +1,195 @@ +import { lookup as dnsLookup } from "node:dns"; +import https from "node:https"; +import tls from "node:tls"; +import { promisify } from "node:util"; + +const DEFAULT_TARGET = "https://docs.linkout.space"; +const DEFAULT_TIMEOUT_MS = 5_000; +const ENDPOINTS = ["/", "/robots.txt", "/sitemap.xml"]; +const REQUIRED_CANONICAL = "https://docs.linkout.space/"; +const lookupAll = promisify(dnsLookup); + +function errorDetail(error) { + return [error?.code, error?.message].filter(Boolean).join(": ") || String(error); +} + +function parseCertificateDate(value, label) { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) { + throw new Error(`TLS certificate has an invalid ${label} date`); + } + return timestamp; +} + +export function validateCertificate( + certificate, + hostname, + { now = new Date(), checkServerIdentity = tls.checkServerIdentity } = {} +) { + const validFrom = parseCertificateDate(certificate?.valid_from, "valid-from"); + const validTo = parseCertificateDate(certificate?.valid_to, "valid-to"); + const nowTimestamp = now.getTime(); + + if (nowTimestamp < validFrom) { + throw new Error( + `TLS certificate is not valid until ${certificate.valid_from}` + ); + } + if (nowTimestamp > validTo) { + throw new Error(`TLS certificate expired at ${certificate.valid_to}`); + } + + const hostnameError = checkServerIdentity(hostname, certificate); + if (hostnameError) { + throw new Error( + `TLS certificate hostname mismatch: ${hostnameError.message}`, + { cause: hostnameError } + ); + } +} + +export function createTlsInspector({ + connect = tls.connect, + timeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + return (hostname, { port = 443 } = {}) => + new Promise((resolve, reject) => { + const socket = connect({ + host: hostname, + port, + rejectUnauthorized: true, + servername: hostname, + }); + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + callback(value); + }; + const timer = setTimeout(() => { + const error = Object.assign( + new Error(`TLS handshake timed out after ${timeoutMs}ms`), + { code: "ETIMEDOUT" } + ); + finish(reject, error); + }, timeoutMs); + + socket.once("secureConnect", () => finish(resolve, socket.getPeerCertificate())); + socket.once("error", (error) => finish(reject, error)); + }); +} + +export function createHttpsRequester({ requestImpl = https.request } = {}) { + return (url, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) => + new Promise((resolve, reject) => { + const request = requestImpl( + url, + { + rejectUnauthorized: true, + servername: url.hostname, + }, + (response) => { + const chunks = []; + response.setEncoding("utf8"); + response.on("data", (chunk) => chunks.push(chunk)); + response.once("end", () => + resolve({ + body: chunks.join(""), + statusCode: response.statusCode, + }) + ); + } + ); + + request.setTimeout(timeoutMs, () => { + request.destroy( + Object.assign(new Error(`timed out after ${timeoutMs}ms`), { + code: "ETIMEDOUT", + }) + ); + }); + request.once("error", reject); + request.end(); + }); +} + +function containsRequiredCanonical(body) { + const linkTags = body.match(/]*>/gi) || []; + return linkTags.some( + (tag) => + /\brel\s*=\s*["'][^"']*\bcanonical\b[^"']*["']/i.test(tag) && + new RegExp( + `\\bhref\\s*=\\s*["']${REQUIRED_CANONICAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`, + "i" + ).test(tag) + ); +} + +export async function checkDeployment({ + target = DEFAULT_TARGET, + timeoutMs = DEFAULT_TIMEOUT_MS, + lookup = (hostname) => lookupAll(hostname, { all: true }), + inspectTls = createTlsInspector({ timeoutMs }), + request = createHttpsRequester(), + now = new Date(), +} = {}) { + const baseUrl = new URL(target); + const hostname = baseUrl.hostname; + let addresses; + + try { + addresses = await lookup(hostname); + } catch (error) { + throw new Error(`${hostname}: DNS lookup failed (${errorDetail(error)})`, { + cause: error, + }); + } + if (!Array.isArray(addresses) || addresses.length === 0) { + throw new Error(`${hostname}: DNS lookup returned no addresses`); + } + + let certificate; + try { + certificate = await inspectTls(hostname, { + port: baseUrl.port ? Number(baseUrl.port) : 443, + timeoutMs, + }); + validateCertificate(certificate, hostname, { now }); + } catch (error) { + throw new Error( + `${hostname}: TLS validation failed (${errorDetail(error)})`, + { cause: error } + ); + } + + for (const endpoint of ENDPOINTS) { + const url = new URL(endpoint, baseUrl); + let response; + try { + response = await request(url, { timeoutMs }); + } catch (error) { + throw new Error( + `${endpoint}: request failed (${errorDetail(error)})`, + { cause: error } + ); + } + if ( + !Number.isInteger(response?.statusCode) || + response.statusCode < 200 || + response.statusCode >= 300 + ) { + throw new Error(`${endpoint}: returned HTTP ${response?.statusCode ?? "unknown"}`); + } + if (endpoint === "/" && !containsRequiredCanonical(response.body || "")) { + throw new Error(`/: missing canonical ${REQUIRED_CANONICAL}`); + } + } + + return { + addresses: addresses.map(({ address }) => address), + endpoints: [...ENDPOINTS], + hostname, + }; +} diff --git a/tests/deployment-health.test.mjs b/tests/deployment-health.test.mjs new file mode 100644 index 0000000..095cc55 --- /dev/null +++ b/tests/deployment-health.test.mjs @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + checkDeployment, + validateCertificate, +} from "../scripts/lib/deployment-health.mjs"; + +const TARGET = "https://docs.linkout.space"; +const VALID_CERTIFICATE = { + valid_from: "Jan 01 00:00:00 2026 GMT", + valid_to: "Jan 01 00:00:00 2027 GMT", + subjectaltname: "DNS:docs.linkout.space", +}; + +function healthyDependencies(overrides = {}) { + return { + lookup: async () => [{ address: "192.0.2.1", family: 4 }], + inspectTls: async () => VALID_CERTIFICATE, + request: async (url) => ({ + body: + url.pathname === "/" + ? '' + : "ok", + statusCode: 200, + }), + now: new Date("2026-07-29T00:00:00Z"), + ...overrides, + }; +} + +test("reports a DNS lookup failure with the hostname", async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + lookup: async () => { + throw Object.assign(new Error("query failed"), { code: "ENOTFOUND" }); + }, + }), + }), + /docs\.linkout\.space: DNS lookup failed \(ENOTFOUND: query failed\)/ + ); +}); + +test("rejects an empty DNS result", async () => { + await assert.rejects( + checkDeployment({ ...healthyDependencies({ lookup: async () => [] }) }), + /docs\.linkout\.space: DNS lookup returned no addresses/ + ); +}); + +test("rejects an expired certificate", () => { + assert.throws( + () => + validateCertificate(VALID_CERTIFICATE, "docs.linkout.space", { + now: new Date("2027-01-01T00:00:01Z"), + }), + /TLS certificate expired/ + ); +}); + +test("rejects a certificate that is not yet valid", () => { + assert.throws( + () => + validateCertificate(VALID_CERTIFICATE, "docs.linkout.space", { + now: new Date("2025-12-31T23:59:59Z"), + }), + /TLS certificate is not valid until/ + ); +}); + +test("rejects a certificate hostname mismatch", () => { + assert.throws( + () => + validateCertificate( + { ...VALID_CERTIFICATE, subjectaltname: "DNS:other.example" }, + "docs.linkout.space", + { now: new Date("2026-07-29T00:00:00Z") } + ), + /TLS certificate hostname mismatch/ + ); +}); + +test("reports TLS and CA verification failures", async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + inspectTls: async () => { + throw Object.assign(new Error("unable to verify the first certificate"), { + code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + }); + }, + }), + }), + /docs\.linkout\.space: TLS validation failed \(UNABLE_TO_VERIFY_LEAF_SIGNATURE: unable to verify the first certificate\)/ + ); +}); + +test("reports an endpoint timeout with its path", async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + request: async (url) => { + if (url.pathname === "/robots.txt") { + throw Object.assign(new Error("timed out after 5000ms"), { + code: "ETIMEDOUT", + }); + } + return { body: '', statusCode: 200 }; + }, + }), + }), + /\/robots\.txt: request failed \(ETIMEDOUT: timed out after 5000ms\)/ + ); +}); + +for (const endpoint of ["/", "/robots.txt", "/sitemap.xml"]) { + test(`rejects a non-2xx response from ${endpoint}`, async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + request: async (url) => ({ + body: + url.pathname === "/" + ? '' + : "", + statusCode: url.pathname === endpoint ? 503 : 200, + }), + }), + }), + new RegExp(`${endpoint.replace(/[/.]/g, "\\$&")}: returned HTTP 503`) + ); + }); +} + +test("rejects a homepage without the required canonical URL", async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + request: async () => ({ body: "", statusCode: 200 }), + }), + }), + /\/: missing canonical https:\/\/docs\.linkout\.space\// + ); +}); + +test("checks the default hostname and all required endpoints successfully", async () => { + const lookedUp = []; + const tlsHosts = []; + const requested = []; + + const result = await checkDeployment({ + ...healthyDependencies({ + lookup: async (hostname) => { + lookedUp.push(hostname); + return [{ address: "2001:db8::1", family: 6 }]; + }, + inspectTls: async (hostname) => { + tlsHosts.push(hostname); + return VALID_CERTIFICATE; + }, + request: async (url, options) => { + requested.push({ url: url.href, timeoutMs: options.timeoutMs }); + return { + body: + url.pathname === "/" + ? '' + : "ok", + statusCode: 204, + }; + }, + }), + }); + + assert.deepEqual(lookedUp, ["docs.linkout.space"]); + assert.deepEqual(tlsHosts, ["docs.linkout.space"]); + assert.deepEqual(requested, [ + { url: `${TARGET}/`, timeoutMs: 5_000 }, + { url: `${TARGET}/robots.txt`, timeoutMs: 5_000 }, + { url: `${TARGET}/sitemap.xml`, timeoutMs: 5_000 }, + ]); + assert.deepEqual(result, { + addresses: ["2001:db8::1"], + endpoints: ["/", "/robots.txt", "/sitemap.xml"], + hostname: "docs.linkout.space", + }); +}); From fc695ff8ebbc3fedb42073108a5e0c625a0e2c07 Mon Sep 17 00:00:00 2001 From: sai-adarsh Date: Wed, 29 Jul 2026 12:53:23 +0530 Subject: [PATCH 06/15] Harden deployment response checks --- scripts/lib/deployment-health.mjs | 144 ++++++++++++++++++++++------ tests/deployment-health.test.mjs | 152 ++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 28 deletions(-) diff --git a/scripts/lib/deployment-health.mjs b/scripts/lib/deployment-health.mjs index ee131a2..6e4ba80 100644 --- a/scripts/lib/deployment-health.mjs +++ b/scripts/lib/deployment-health.mjs @@ -5,17 +5,42 @@ import { promisify } from "node:util"; const DEFAULT_TARGET = "https://docs.linkout.space"; const DEFAULT_TIMEOUT_MS = 5_000; +const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; const ENDPOINTS = ["/", "/robots.txt", "/sitemap.xml"]; const REQUIRED_CANONICAL = "https://docs.linkout.space/"; const lookupAll = promisify(dnsLookup); +const MONTHS = new Map( + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + .map((month, index) => [month, index]) +); function errorDetail(error) { return [error?.code, error?.message].filter(Boolean).join(": ") || String(error); } function parseCertificateDate(value, label) { - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) { + const match = + /^([A-Z][a-z]{2})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})\s+GMT$/.exec( + value || "" + ); + const month = MONTHS.get(match?.[1]); + const parts = match?.slice(2).map(Number); + if (!match || month === undefined) { + throw new Error(`TLS certificate has an invalid ${label} date`); + } + + const [day, hour, minute, second, year] = parts; + const timestamp = Date.UTC(year, month, day, hour, minute, second); + const parsed = new Date(timestamp); + if ( + year < 1000 || + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month || + parsed.getUTCDate() !== day || + parsed.getUTCHours() !== hour || + parsed.getUTCMinutes() !== minute || + parsed.getUTCSeconds() !== second + ) { throw new Error(`TLS certificate has an invalid ${label} date`); } return timestamp; @@ -28,7 +53,10 @@ export function validateCertificate( ) { const validFrom = parseCertificateDate(certificate?.valid_from, "valid-from"); const validTo = parseCertificateDate(certificate?.valid_to, "valid-to"); - const nowTimestamp = now.getTime(); + const nowTimestamp = now instanceof Date ? now.getTime() : Number.NaN; + if (!Number.isFinite(nowTimestamp)) { + throw new Error("TLS certificate validation current time is invalid"); + } if (nowTimestamp < validFrom) { throw new Error( @@ -81,50 +109,110 @@ export function createTlsInspector({ }); } -export function createHttpsRequester({ requestImpl = https.request } = {}) { +export function createHttpsRequester({ + requestImpl = https.request, + maxBodyBytes = DEFAULT_MAX_BODY_BYTES, + setTimer = setTimeout, + clearTimer = clearTimeout, +} = {}) { return (url, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) => new Promise((resolve, reject) => { - const request = requestImpl( + let request; + let response; + let responseCompleted = false; + let settled = false; + let deadline; + + const finish = (callback, value, { destroy = false } = {}) => { + if (settled) return; + settled = true; + clearTimer(deadline); + if (destroy) { + response?.destroy(); + request?.destroy(); + } + callback(value); + }; + const fail = (message, cause) => { + const error = new Error(message, cause ? { cause } : undefined); + finish(reject, error, { destroy: true }); + }; + + request = requestImpl( url, { rejectUnauthorized: true, servername: url.hostname, }, - (response) => { + (incomingResponse) => { + response = incomingResponse; const chunks = []; + let bodyBytes = 0; response.setEncoding("utf8"); - response.on("data", (chunk) => chunks.push(chunk)); - response.once("end", () => - resolve({ + response.on("data", (chunk) => { + if (settled) return; + bodyBytes += Buffer.byteLength(chunk); + if (bodyBytes > maxBodyBytes) { + fail(`response exceeded ${maxBodyBytes} bytes`); + return; + } + chunks.push(chunk); + }); + response.once("error", (error) => + fail(`response failed (${error?.message || error})`, error) + ); + response.once("aborted", () => fail("response aborted")); + response.once("close", () => { + if (!responseCompleted) { + fail("response closed before completion"); + } + }); + response.once("end", () => { + responseCompleted = true; + finish(resolve, { body: chunks.join(""), statusCode: response.statusCode, - }) - ); + }); + }); } ); - request.setTimeout(timeoutMs, () => { - request.destroy( - Object.assign(new Error(`timed out after ${timeoutMs}ms`), { - code: "ETIMEDOUT", - }) - ); - }); - request.once("error", reject); + deadline = setTimer(() => { + fail(`request deadline exceeded after ${timeoutMs}ms`); + }, timeoutMs); + request.once("error", (error) => + fail(error?.message || String(error), error) + ); request.end(); }); } +function parseAttributes(tag) { + const attributes = new Map(); + const source = tag.replace(/^$/, ""); + const attributePattern = + /([^\s"'=<>`]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + let match; + while ((match = attributePattern.exec(source))) { + const name = match[1].toLowerCase(); + if (!attributes.has(name)) { + attributes.set(name, match[2] ?? match[3] ?? match[4] ?? ""); + } + } + return attributes; +} + function containsRequiredCanonical(body) { - const linkTags = body.match(/]*>/gi) || []; - return linkTags.some( - (tag) => - /\brel\s*=\s*["'][^"']*\bcanonical\b[^"']*["']/i.test(tag) && - new RegExp( - `\\bhref\\s*=\\s*["']${REQUIRED_CANONICAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`, - "i" - ).test(tag) - ); + const markup = body.replace(//g, ""); + const linkTags = markup.match(/)[^>]*>/gi) || []; + return linkTags.some((tag) => { + const attributes = parseAttributes(tag); + const relTokens = (attributes.get("rel") || "").split(/\s+/); + return ( + relTokens.some((token) => token.toLowerCase() === "canonical") && + attributes.get("href") === REQUIRED_CANONICAL + ); + }); } export async function checkDeployment({ diff --git a/tests/deployment-health.test.mjs b/tests/deployment-health.test.mjs index 095cc55..2f6962b 100644 --- a/tests/deployment-health.test.mjs +++ b/tests/deployment-health.test.mjs @@ -1,8 +1,10 @@ import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; import test from "node:test"; import { checkDeployment, + createHttpsRequester, validateCertificate, } from "../scripts/lib/deployment-health.mjs"; @@ -29,6 +31,26 @@ function healthyDependencies(overrides = {}) { }; } +function fakeHttpsExchange({ statusCode = 200 } = {}) { + const response = new EventEmitter(); + response.statusCode = statusCode; + response.destroyed = false; + response.setEncoding = () => {}; + response.destroy = () => { + response.destroyed = true; + }; + + const request = new EventEmitter(); + request.destroyed = false; + request.setTimeout = () => {}; + request.destroy = () => { + request.destroyed = true; + }; + request.end = () => {}; + + return { request, response }; +} + test("reports a DNS lookup failure with the hostname", async () => { await assert.rejects( checkDeployment({ @@ -81,6 +103,28 @@ test("rejects a certificate hostname mismatch", () => { ); }); +test("rejects impossible certificate dates instead of normalizing them", () => { + assert.throws( + () => + validateCertificate( + { ...VALID_CERTIFICATE, valid_from: "Feb 30 00:00:00 2026 GMT" }, + "docs.linkout.space", + { now: new Date("2026-07-29T00:00:00Z") } + ), + /TLS certificate has an invalid valid-from date/ + ); +}); + +test("rejects an invalid current time", () => { + assert.throws( + () => + validateCertificate(VALID_CERTIFICATE, "docs.linkout.space", { + now: new Date("invalid"), + }), + /current time is invalid/ + ); +}); + test("reports TLS and CA verification failures", async () => { await assert.rejects( checkDeployment({ @@ -144,6 +188,114 @@ test("rejects a homepage without the required canonical URL", async () => { ); }); +for (const fakeCanonical of [ + '', + '', +]) { + test(`rejects canonical lookalike: ${fakeCanonical.slice(0, 24)}`, async () => { + await assert.rejects( + checkDeployment({ + ...healthyDependencies({ + request: async () => ({ body: fakeCanonical, statusCode: 200 }), + }), + }), + /\/: missing canonical https:\/\/docs\.linkout\.space\// + ); + }); +} + +test("accepts canonical attributes in either order, case, and quote style", async () => { + await checkDeployment({ + ...healthyDependencies({ + request: async (url) => ({ + body: + url.pathname === "/" + ? "" + : "ok", + statusCode: 200, + }), + }), + }); +}); + +test("enforces an absolute HTTPS response deadline during slow-drip data", async () => { + const { request, response } = fakeHttpsExchange(); + let deadline; + const requester = createHttpsRequester({ + requestImpl: (_url, _options, onResponse) => { + request.end = () => onResponse(response); + return request; + }, + setTimer: (callback) => { + deadline = callback; + return {}; + }, + clearTimer: () => {}, + }); + + const pending = requester(new URL(`${TARGET}/robots.txt`), { timeoutMs: 25 }); + response.emit("data", "still arriving"); + assert.equal(typeof deadline, "function"); + deadline(); + + await assert.rejects( + pending, + /request deadline exceeded after 25ms/ + ); + assert.equal(request.destroyed, true); + assert.equal(response.destroyed, true); +}); + +test("rejects and destroys an oversized HTTPS response", async () => { + const { request, response } = fakeHttpsExchange(); + const requester = createHttpsRequester({ + maxBodyBytes: 4, + requestImpl: (_url, _options, onResponse) => { + request.end = () => { + onResponse(response); + response.emit("data", "12345"); + response.emit("end"); + }; + return request; + }, + }); + + await assert.rejects( + requester(new URL(`${TARGET}/sitemap.xml`)), + /response exceeded 4 bytes/ + ); + assert.equal(request.destroyed, true); + assert.equal(response.destroyed, true); +}); + +for (const [event, expected] of [ + ["error", /response failed \(stream failed\)/], + ["aborted", /response aborted/], + ["close", /response closed before completion/], +]) { + test(`rejects cleanly when the HTTPS response emits ${event}`, async () => { + const { request, response } = fakeHttpsExchange(); + const requester = createHttpsRequester({ + requestImpl: (_url, _options, onResponse) => { + request.end = () => { + onResponse(response); + if (event === "error") { + response.emit(event, new Error("stream failed")); + } else { + response.emit(event); + } + response.emit("end"); + }; + return request; + }, + }); + + await assert.rejects(requester(new URL(`${TARGET}/`)), expected); + assert.equal(request.destroyed, true); + assert.equal(response.destroyed, true); + }); +} + test("checks the default hostname and all required endpoints successfully", async () => { const lookedUp = []; const tlsHosts = []; From 03864c3ad9055ea195dcde4e5be9849ede2b9397 Mon Sep 17 00:00:00 2001 From: sai-adarsh Date: Wed, 29 Jul 2026 12:59:01 +0530 Subject: [PATCH 07/15] Validate canonical document context --- scripts/lib/deployment-health.mjs | 118 ++++++++++++++++++++++++++++-- tests/deployment-health.test.mjs | 79 ++++++++++++++++---- 2 files changed, 174 insertions(+), 23 deletions(-) diff --git a/scripts/lib/deployment-health.mjs b/scripts/lib/deployment-health.mjs index 6e4ba80..110e594 100644 --- a/scripts/lib/deployment-health.mjs +++ b/scripts/lib/deployment-health.mjs @@ -9,6 +9,16 @@ const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; const ENDPOINTS = ["/", "/robots.txt", "/sitemap.xml"]; const REQUIRED_CANONICAL = "https://docs.linkout.space/"; const lookupAll = promisify(dnsLookup); +const IGNORED_CANONICAL_CONTEXTS = new Set([ + "iframe", + "noembed", + "noscript", + "script", + "style", + "template", + "textarea", + "xmp", +]); const MONTHS = new Map( ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] .map((month, index) => [month, index]) @@ -202,17 +212,111 @@ function parseAttributes(tag) { return attributes; } +function readMarkupTag(markup, start) { + let quote; + let end = start + 1; + for (; end < markup.length; end += 1) { + const character = markup[end]; + if (quote) { + if (character === quote) quote = undefined; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === ">") { + break; + } + } + if (end === markup.length) return undefined; + + let source = markup.slice(start + 1, end).trim(); + const closing = source.startsWith("/"); + if (closing) source = source.slice(1).trimStart(); + const nameMatch = /^([A-Za-z][A-Za-z0-9:-]*)/.exec(source); + if (!nameMatch) { + return { end: end + 1 }; + } + return { + closing, + end: end + 1, + name: nameMatch[1].toLowerCase(), + raw: markup.slice(start, end + 1), + selfClosing: /\/\s*$/.test(source), + }; +} + +function skipComment(markup, start) { + const end = markup.indexOf("-->", start + 4); + return end === -1 ? markup.length : end + 3; +} + +function skipIgnoredContext(markup, start, contextName) { + let cursor = start; + let depth = 1; + while (cursor < markup.length) { + const tagStart = markup.indexOf("<", cursor); + if (tagStart === -1) return markup.length; + if (markup.startsWith("/g, ""); - const linkTags = markup.match(/)[^>]*>/gi) || []; - return linkTags.some((tag) => { - const attributes = parseAttributes(tag); + let cursor = 0; + let inHead = false; + while (cursor < body.length) { + const tagStart = body.indexOf("<", cursor); + if (tagStart === -1) return false; + if (body.startsWith("', - '', + ``, + `", start + 4); - return end === -1 ? markup.length : end + 3; -} - -function skipIgnoredContext(markup, start, contextName) { - let cursor = start; - let depth = 1; - while (cursor < markup.length) { - const tagStart = markup.indexOf("<", cursor); - if (tagStart === -1) return markup.length; - if (markup.startsWith("