diff --git a/docs/validator-doctor.md b/docs/validator-doctor.md new file mode 100644 index 0000000..a3f2957 --- /dev/null +++ b/docs/validator-doctor.md @@ -0,0 +1,29 @@ +# Validator environment doctor + +Run the doctor before an audit to check whether the current Node.js environment exposes the file-opening capabilities required by the JSON validator CLIs: + +```sh +node skills/security-audit/check-environment.cjs +``` + +The command reports the Node.js version, operating system, architecture, and whether `fs.constants.O_NOFOLLOW` and `fs.constants.O_NONBLOCK` are non-zero integers. This is the same availability rule used by the validators. The doctor reports `PASS` and exits `0` when both capabilities are available. It reports `FAIL` and exits `1` when either capability is missing, zero, or not an integer. + +The protected flags are required because the validators fail closed rather than read input files without no-follow and nonblocking protections. If the check fails on native Windows, run Node.js inside Linux or WSL. The doctor reports the installed Node.js version but does not impose a minimum version. + +For automation, request JSON output: + +```sh +node skills/security-audit/check-environment.cjs --json +``` + +The JSON document has a versioned, stable top-level structure. `ok` is the overall result, `runtime` identifies the environment, `capabilities` describes each flag, and `missingCapabilities` lists requirements that failed. The command keeps the same `0` or `1` result exit code in JSON mode. + +For usage information: + +```sh +node skills/security-audit/check-environment.cjs --help +``` + +Help exits `0`. Unknown options or extra arguments exit `2`. + +This command checks only the JSON validators' file-reading prerequisites. It does not verify the complete security-audit environment, an OS-enforced security sandbox, network isolation, resource limits, or safe scratch-directory configuration. It has no third-party dependencies, does not access the network, and does not install software or change system configuration. diff --git a/skills/security-audit/check-environment.cjs b/skills/security-audit/check-environment.cjs new file mode 100644 index 0000000..17ec9d7 --- /dev/null +++ b/skills/security-audit/check-environment.cjs @@ -0,0 +1,123 @@ +const fs = require("node:fs"); + +const SCOPE = "JSON validator file-reading prerequisites only"; +const REQUIREMENT = "non-zero integer"; +const FLAG_NAMES = ["O_NOFOLLOW", "O_NONBLOCK"]; + +function flagType(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function inspectFlag(value) { + return { + available: Number.isInteger(value) && value !== 0, + requirement: REQUIREMENT, + type: flagType(value), + value: Number.isInteger(value) ? value : null, + }; +} + +function inspectEnvironment(options = {}) { + const constants = options.constants || fs.constants; + const runtime = { + nodeVersion: options.nodeVersion || process.version, + platform: options.platform || process.platform, + arch: options.arch || process.arch, + }; + const capabilities = { + O_NOFOLLOW: inspectFlag(constants.O_NOFOLLOW), + O_NONBLOCK: inspectFlag(constants.O_NONBLOCK), + }; + const missingCapabilities = FLAG_NAMES.filter((name) => !capabilities[name].available); + const ok = missingCapabilities.length === 0; + + return { + schemaVersion: 1, + ok, + scope: SCOPE, + runtime, + capabilities, + missingCapabilities, + conclusion: ok + ? "The JSON validator CLIs have the required protected file-opening flags." + : "The JSON validator CLIs cannot run because they refuse to read input without both protected file-opening flags.", + recommendation: ok + ? null + : "Run Node.js in Linux or WSL, where Node.js normally exposes O_NOFOLLOW and O_NONBLOCK.", + limitation: "This result does not verify the complete audit environment or a security sandbox.", + }; +} + +function displayValue(check) { + if (check.type === "undefined") return "undefined"; + if (check.value !== null) return `${check.value} (${check.type})`; + return `a value of type ${check.type}`; +} + +function formatText(report) { + const lines = [ + "Validator environment doctor", + `Node.js: ${report.runtime.nodeVersion}`, + `OS: ${report.runtime.platform}`, + `Architecture: ${report.runtime.arch}`, + ]; + + for (const name of FLAG_NAMES) { + const check = report.capabilities[name]; + const status = check.available ? "PASS" : "FAIL"; + lines.push(`${name}: ${status} (expected ${check.requirement}; got ${displayValue(check)})`); + } + + lines.push(`${report.ok ? "PASS" : "FAIL"}: ${report.conclusion}`); + if (report.recommendation) lines.push(`Recommendation: ${report.recommendation}`); + lines.push(`Scope: ${report.scope}. ${report.limitation}`); + return `${lines.join("\n")}\n`; +} + +function formatHelp() { + return [ + "Usage: node skills/security-audit/check-environment.cjs [--json | --help]", + "", + "Check whether Node.js exposes the protected file-opening flags required by the JSON validators.", + "", + "Options:", + " --json Print a stable, machine-readable JSON result.", + " --help Show this help message.", + "", + ].join("\n"); +} + +function main(argv = process.argv.slice(2), options = {}) { + const writeOut = options.writeOut || ((text) => process.stdout.write(text)); + const writeError = options.writeError || ((text) => process.stderr.write(text)); + + if (argv.length === 1 && argv[0] === "--help") { + writeOut(formatHelp()); + return 0; + } + if (argv.length > 1 || (argv.length === 1 && argv[0] !== "--json")) { + writeError("Invalid arguments. Use --help for usage.\n"); + return 2; + } + + const report = inspectEnvironment(options.environment); + if (argv[0] === "--json") { + writeOut(`${JSON.stringify(report, null, 2)}\n`); + } else { + writeOut(formatText(report)); + } + return report.ok ? 0 : 1; +} + +module.exports = { + FLAG_NAMES, + SCOPE, + formatHelp, + formatText, + inspectEnvironment, + main, +}; + +if (require.main === module) process.exitCode = main(); diff --git a/skills/security-audit/check-environment.test.cjs b/skills/security-audit/check-environment.test.cjs new file mode 100644 index 0000000..c5dc4f8 --- /dev/null +++ b/skills/security-audit/check-environment.test.cjs @@ -0,0 +1,158 @@ +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); +const { + inspectEnvironment, + main, +} = require("./check-environment.cjs"); + +const doctorPath = path.join(__dirname, "check-environment.cjs"); +const CLI_TIMEOUT_MS = 5000; +const TEST_RUNTIME = { + nodeVersion: "v-test", + platform: "test-os", + arch: "test-arch", +}; + +function runMain(argv, constants) { + let stdout = ""; + let stderr = ""; + const status = main(argv, { + environment: { ...TEST_RUNTIME, constants }, + writeOut(text) { stdout += text; }, + writeError(text) { stderr += text; }, + }); + return { status, stdout, stderr }; +} + +function runCli(args = []) { + return spawnSync(process.execPath, [doctorPath, ...args], { + encoding: "utf8", + timeout: CLI_TIMEOUT_MS, + }); +} + +function cliOutput(result) { + return `${result.stdout}${result.stderr}`; +} + +test("reports both required capabilities as available", () => { + const report = inspectEnvironment({ + ...TEST_RUNTIME, + constants: { O_NOFOLLOW: 131072, O_NONBLOCK: 2048 }, + }); + + assert.equal(report.ok, true); + assert.deepEqual(report.missingCapabilities, []); + assert.deepEqual(report.runtime, TEST_RUNTIME); + assert.deepEqual(report.capabilities.O_NOFOLLOW, { + available: true, + requirement: "non-zero integer", + type: "number", + value: 131072, + }); + assert.equal(report.recommendation, null); +}); + +const invalidCases = [ + ["O_NOFOLLOW is missing", { O_NONBLOCK: 2048 }, "O_NOFOLLOW", "undefined"], + ["O_NONBLOCK is missing", { O_NOFOLLOW: 131072 }, "O_NONBLOCK", "undefined"], + ["O_NOFOLLOW is zero", { O_NOFOLLOW: 0, O_NONBLOCK: 2048 }, "O_NOFOLLOW", "number"], + ["O_NONBLOCK is zero", { O_NOFOLLOW: 131072, O_NONBLOCK: 0 }, "O_NONBLOCK", "number"], + ["O_NOFOLLOW has the wrong type", { O_NOFOLLOW: "131072", O_NONBLOCK: 2048 }, "O_NOFOLLOW", "string"], + ["O_NONBLOCK has the wrong type", { O_NOFOLLOW: 131072, O_NONBLOCK: {} }, "O_NONBLOCK", "object"], +]; + +for (const [name, constants, missingName, expectedType] of invalidCases) { + test(`fails when ${name}`, () => { + const report = inspectEnvironment({ ...TEST_RUNTIME, constants }); + assert.equal(report.ok, false); + assert.deepEqual(report.missingCapabilities, [missingName]); + assert.equal(report.capabilities[missingName].available, false); + assert.equal(report.capabilities[missingName].type, expectedType); + assert.match(report.conclusion, /cannot run because they refuse to read input/); + assert.match(report.recommendation, /Linux or WSL/); + }); +} + +test("text output reports runtime, checks, scope, and a passing exit code", () => { + const result = runMain([], { O_NOFOLLOW: 1, O_NONBLOCK: 2 }); + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /Node\.js: v-test/); + assert.match(result.stdout, /OS: test-os/); + assert.match(result.stdout, /Architecture: test-arch/); + assert.match(result.stdout, /O_NOFOLLOW: PASS/); + assert.match(result.stdout, /O_NONBLOCK: PASS/); + assert.match(result.stdout, /does not verify the complete audit environment or a security sandbox/); +}); + +test("text output explains missing capabilities and exits 1", () => { + const result = runMain([], { O_NOFOLLOW: 1 }); + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /O_NONBLOCK: FAIL/); + assert.match(result.stdout, /cannot run because they refuse to read input/); + assert.match(result.stdout, /Run Node\.js in Linux or WSL/); +}); + +test("JSON output is stable, parseable, and uses the check result as its exit code", () => { + const first = runMain(["--json"], { O_NOFOLLOW: 1, O_NONBLOCK: 2 }); + const second = runMain(["--json"], { O_NOFOLLOW: 1, O_NONBLOCK: 2 }); + assert.equal(first.status, 0); + assert.equal(first.stdout, second.stdout); + assert.equal(first.stderr, ""); + const parsed = JSON.parse(first.stdout); + assert.equal(parsed.schemaVersion, 1); + assert.equal(parsed.ok, true); + assert.equal(parsed.scope, "JSON validator file-reading prerequisites only"); + assert.deepEqual(parsed.missingCapabilities, []); + + const failed = runMain(["--json"], { O_NOFOLLOW: 1, O_NONBLOCK: 0 }); + assert.equal(failed.status, 1); + assert.deepEqual(JSON.parse(failed.stdout).missingCapabilities, ["O_NONBLOCK"]); +}); + +test("help exits 0 without running the check", () => { + const result = runMain(["--help"], {}); + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /^Usage:/); + assert.match(result.stdout, /--json/); + assert.match(result.stdout, /--help/); +}); + +test("invalid arguments exit 2 and write only to stderr", () => { + for (const args of [["--unknown"], ["--json", "extra"], ["--help", "--json"]]) { + const result = runMain(args, { O_NOFOLLOW: 1, O_NONBLOCK: 2 }); + assert.equal(result.status, 2); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /Invalid arguments/); + } +}); + +test("real CLI text invocation reflects the current platform", () => { + const expected = inspectEnvironment(); + const result = runCli(); + assert.equal(result.error, undefined, cliOutput(result)); + assert.equal(result.status, expected.ok ? 0 : 1, cliOutput(result)); + assert.match(result.stdout, new RegExp(`Node\\.js: ${process.version.replaceAll(".", "\\.")}`)); + assert.match(result.stdout, new RegExp(`OS: ${process.platform}`)); + assert.match(result.stdout, new RegExp(`Architecture: ${process.arch}`)); +}); + +test("real CLI JSON, help, and invalid-argument invocations use documented exit codes", () => { + const expected = inspectEnvironment(); + const jsonResult = runCli(["--json"]); + assert.equal(jsonResult.status, expected.ok ? 0 : 1, cliOutput(jsonResult)); + assert.deepEqual(JSON.parse(jsonResult.stdout), expected); + + const helpResult = runCli(["--help"]); + assert.equal(helpResult.status, 0, cliOutput(helpResult)); + assert.match(helpResult.stdout, /^Usage:/); + + const invalidResult = runCli(["--unknown"]); + assert.equal(invalidResult.status, 2, cliOutput(invalidResult)); + assert.match(invalidResult.stderr, /Invalid arguments/); +});