Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/validator-doctor.md
Original file line number Diff line number Diff line change
@@ -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.
123 changes: 123 additions & 0 deletions skills/security-audit/check-environment.cjs
Original file line number Diff line number Diff line change
@@ -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();
158 changes: 158 additions & 0 deletions skills/security-audit/check-environment.test.cjs
Original file line number Diff line number Diff line change
@@ -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/);
});