Files
ECC/tests/scripts/gateguard-eval.test.js
T

316 lines
13 KiB
JavaScript

/**
* Tests for scripts/dev/gateguard-eval.js argument parsing and SARIF output.
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { pathToFileURL } = require('url');
const {
parseArgs, renderSarif, renderMarkdown, loadCorpus, runCorpus, summarize, measureColdLatency, coldSummary, gateFails
} = require('../../scripts/dev/gateguard-eval');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
passed++;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
failed++;
}
}
function step(overrides) {
return {
scenario: 'sensitive-targets',
file: '09-sensitive-targets.json',
step: 'edit-env',
expect: 'deny',
decision: 'deny',
kind: 'deny',
mustDeny: true,
redundant: false,
...overrides
};
}
function summary(steps, totals = {}) {
return {
totals: { steps: steps.length, denials: 0, mustDenyBypasses: 0, mismatches: 0, explicitAllows: 0, errors: 0, skippedScenarios: 0, ...totals },
latency: { p50Ms: 1.234, p90Ms: 4.321, p95Ms: 5.678 },
perScenario: [],
steps
};
}
function report(workingSteps, baselineTotals = { mustDenyBypasses: 8 }) {
return {
corpus: { scenarios: 1, steps: workingSteps.length },
hooks: [
{ label: 'working tree', ref: null, summary: summary(workingSteps) },
{ label: 'upstream/main', ref: 'upstream/main', summary: summary([], baselineTotals) }
]
};
}
function results(log) {
return log.runs[0].results;
}
console.log('\n=== Testing gateguard-eval ===\n');
test('the corpus preserves POSIX type credit and requires denial for folded Windows names', () => {
const posix = loadCorpus(undefined, 'linux');
const windows = loadCorpus(undefined, 'win32');
const typeStep = corpus => corpus.find(scenario => scenario.name === 'bypass-filter-forms')
.steps.find(candidate => candidate.id === 'rg-type-includes-target');
assert.strictEqual(typeStep(posix).expect, 'allow');
assert.strictEqual(typeStep(posix).redundant, true);
assert.strictEqual(typeStep(windows).expect, 'deny');
assert.strictEqual(typeStep(windows).mustDeny, true);
assert.strictEqual(typeStep(windows).redundant, false);
posix.forEach((scenario, index) => scenario.steps.forEach((candidate, at) => {
if (candidate.mustDeny) assert.deepStrictEqual(windows[index].steps[at], candidate);
}));
const scenario = windows.find(candidate => candidate.name === 'bypass-filter-forms');
const expected = typeStep(windows);
const run = decision => [{ scenario: { ...scenario, steps: [expected] }, results: [{
decision, kind: decision === 'deny' ? 'deny' : 'credit', latencyMs: 0, reason: ''
}] }];
assert.strictEqual(summarize(run('deny')).totals.mismatches, 0);
assert.strictEqual(summarize(run('deny')).totals.redundantDenials, 0);
assert.strictEqual(summarize(run('allow')).totals.mustDenyBypasses, 1);
});
test('Windows corpus overrides only strengthen denial and reject invalid values on every host', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-platform-corpus-'));
try {
const scenario = loadCorpus()[0];
for (const expectOnWindows of ['allow', 'invalid', false]) {
fs.writeFileSync(path.join(dir, 'fixture.json'), JSON.stringify({
...scenario, steps: [{ ...scenario.steps[0], expectOnWindows }]
}));
for (const platform of ['linux', 'win32']) assert.throws(() => loadCorpus(dir, platform), /expectOnWindows/);
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('--sarif takes a file path and keeps the other options', () => {
const options = parseArgs(['--json', '--sarif', 'out/gate.sarif', '--baseline', 'main']);
assert.strictEqual(options.format, 'json');
assert.strictEqual(options.sarif, path.resolve('out/gate.sarif'));
assert.deepStrictEqual(options.baselines, ['main']);
});
test('--sarif without a value is rejected', () => {
assert.throws(() => parseArgs(['--sarif']), /unknown or incomplete argument: --sarif/);
});
test('a clean working tree produces a valid SARIF log with no results', () => {
const log = JSON.parse(renderSarif(report([step({}), step({ step: 'read-only', mustDeny: false, expect: 'allow', decision: 'allow', kind: 'pass' })])));
assert.strictEqual(log.version, '2.1.0');
assert.strictEqual(log.runs.length, 1);
assert.strictEqual(log.runs[0].tool.driver.name, 'gateguard-eval');
assert.deepStrictEqual(results(log), []);
});
test('every rule declares a default level', () => {
const rules = JSON.parse(renderSarif(report([]))).runs[0].tool.driver.rules;
assert.deepStrictEqual(
rules.map(rule => [rule.id, rule.defaultConfiguration.level]),
[
['gateguard/must-deny-bypass', 'error'],
['gateguard/explicit-allow', 'error'],
['gateguard/hook-error', 'error'],
['gateguard/expectation-mismatch', 'warning']
]
);
});
test('a must-deny step that was allowed is an error on its scenario file', () => {
const log = JSON.parse(renderSarif(report([step({ decision: 'allow', kind: 'credit' })])));
assert.strictEqual(results(log).length, 1);
const [result] = results(log);
assert.strictEqual(result.ruleId, 'gateguard/must-deny-bypass');
assert.strictEqual(result.level, 'error');
assert.strictEqual(result.locations[0].physicalLocation.artifactLocation.uri, 'tests/fixtures/gateguard-scenarios/09-sensitive-targets.json');
assert.strictEqual(result.locations[0].physicalLocation.artifactLocation.uriBaseId, '%SRCROOT%');
assert.match(result.message.text, /sensitive-targets \/ edit-env: expected deny, got allow \(credit\)/);
});
test('a finding from another corpus inside the repository links to that corpus', () => {
const failing = report([step({ decision: 'allow', kind: 'credit', file: 'custom.json' })]);
failing.corpusDir = path.join(__dirname, '..', 'fixtures', 'other-scenarios');
const location = results(JSON.parse(renderSarif(failing)))[0].locations[0].physicalLocation.artifactLocation;
assert.deepStrictEqual(location, { uri: 'tests/fixtures/other-scenarios/custom.json', uriBaseId: '%SRCROOT%' });
});
test('a finding from a corpus outside the repository links to its absolute file', () => {
const failing = report([step({ decision: 'allow', kind: 'credit', file: 'custom.json' })]);
failing.corpusDir = path.join(os.tmpdir(), 'gateguard-corpus');
const location = results(JSON.parse(renderSarif(failing)))[0].locations[0].physicalLocation.artifactLocation;
assert.deepStrictEqual(location, { uri: pathToFileURL(path.join(os.tmpdir(), 'gateguard-corpus', 'custom.json')).href });
});
test('an explicit allow and a hook error are reported as errors', () => {
const log = JSON.parse(
renderSarif(
report([
step({ step: 'a', mustDeny: false, expect: 'allow', decision: 'allow', kind: 'pass', explicitAllow: true }),
step({ step: 'b', mustDeny: false, error: 'boom' })
])
)
);
assert.deepStrictEqual(results(log).map(result => [result.ruleId, result.level]), [
['gateguard/explicit-allow', 'error'],
['gateguard/hook-error', 'error']
]);
});
test('a wrong decision that is not a bypass is a warning', () => {
const log = JSON.parse(renderSarif(report([step({ mustDeny: false, expect: 'allow', decision: 'deny', kind: 'deny' })])));
assert.deepStrictEqual(results(log).map(result => [result.ruleId, result.level]), [['gateguard/expectation-mismatch', 'warning']]);
});
test('a bypass is not reported a second time as a mismatch', () => {
const log = JSON.parse(renderSarif(report([step({ decision: 'allow', kind: 'pass' })])));
assert.deepStrictEqual(results(log).map(result => result.ruleId), ['gateguard/must-deny-bypass']);
});
test('baseline totals are recorded without their failures becoming results', () => {
const log = JSON.parse(renderSarif(report([step({})])));
const hooks = log.runs[0].properties.hooks;
assert.deepStrictEqual(hooks.map(hook => [hook.label, hook.totals.mustDenyBypasses]), [
['working tree', 0],
['upstream/main', 8]
]);
assert.deepStrictEqual(results(log), []);
});
test('output leaves out timing so reruns are byte-identical', () => {
const text = renderSarif(report([step({ latencyMs: 3.21 })]));
assert.ok(!/latency|p50|p95|Time/i.test(text), 'timing leaked into SARIF');
const slower = report([step({ latencyMs: 9.87 })]);
slower.hooks[0].summary.latency = { p50Ms: 9, p90Ms: 90, p95Ms: 99 };
assert.strictEqual(renderSarif(slower), text);
});
test('--no-cold turns off the fresh-process pass', () => {
assert.strictEqual(parseArgs([]).cold, true);
assert.strictEqual(parseArgs(['--no-cold']).cold, false);
});
// A fresh-process failure is recorded as an allow. Where the worker also allowed the
// step it is no disagreement, so before failures were counted on their own it left
// no trace in the report, the latency or the exit status.
test('a fresh-process failure on a step the worker allows is still counted', () => {
const cold = coldSummary(
[{ scenario: 's', step: 'a', latencyMs: null, decision: 'allow', error: 'killed' }],
[{ scenario: 's', step: 'a', decision: 'allow' }]
);
assert.strictEqual(cold.disagreements, 0);
assert.strictEqual(cold.errors, 1);
assert.strictEqual(cold.samples, 0);
assert.strictEqual(cold.p50Ms, null, 'no measured step must not read as a 0 ms latency');
});
test('a fresh-process failure fails the gate when nothing else does', () => {
const clean = report([step()]);
clean.hooks[0].summary.cold = { p50Ms: 1, p90Ms: 1, p95Ms: 1, samples: 1, errors: 0, disagreements: 0 };
assert.strictEqual(gateFails(clean), false);
const failed = report([step()]);
failed.hooks[0].summary.cold = { p50Ms: null, p90Ms: null, p95Ms: null, samples: 0, errors: 1, disagreements: 0 };
assert.strictEqual(gateFails(failed), true);
});
test('the report names fresh-process failures and invents no latency', () => {
// summarize([]) is the real summary shape with every count at zero, so this keeps
// working as metrics are added, which a hand-built totals object would not.
const working = { ...summarize([]), cold: { p50Ms: null, p90Ms: null, p95Ms: null, samples: 0, errors: 1, disagreements: 0 } };
const failed = {
corpus: { scenarios: 0, steps: 0 },
hooks: [
{ label: 'working tree', ref: null, summary: working },
{ label: 'upstream/main', ref: 'upstream/main', summary: summarize([]) }
]
};
const text = renderMarkdown(failed);
assert.match(text, /1 step\(s\) failed in a fresh process/);
assert.match(text, /no valid sample/);
assert.ok(!/fresh process \(ms\)[^\n]*\b0\.00\b/.test(text), 'a latency was reported with no measured step');
});
async function coldTests() {
const corpusDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-eval-corpus-'));
try {
fs.copyFileSync(
path.join(__dirname, '..', 'fixtures', 'gateguard-scenarios', '11-first-shell-commands.json'),
path.join(corpusDir, 'shell.json')
);
const scenarios = loadCorpus(corpusDir);
const hookFile = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js');
const warm = summarize(await runCorpus(hookFile, scenarios));
const cold = measureColdLatency(hookFile, scenarios);
test('the fresh-process pass times every step', () => {
assert.strictEqual(cold.length, scenarios[0].steps.length);
assert.ok(cold.every(step => Number.isFinite(step.latencyMs) && step.latencyMs > 0));
});
test('fresh processes decide every step as the worker does', () => {
assert.deepStrictEqual(cold.map(step => step.decision), warm.steps.map(step => step.decision));
});
// A hook that throws used to end the fresh-process pass, so neither the report
// nor the SARIF output was written for a run that had hit one bad step.
const throwingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-eval-throw-'));
try {
const throwingHook = path.join(throwingDir, 'throwing-hook.js');
fs.writeFileSync(throwingHook, "'use strict';\nmodule.exports = { run() { throw new Error('boom'); } };\n");
const warmThrow = summarize(await runCorpus(throwingHook, scenarios));
let coldThrow = null;
let escaped = null;
try {
coldThrow = measureColdLatency(throwingHook, scenarios);
} catch (error) {
escaped = error;
}
test('a hook that throws does not end the fresh-process pass', () => {
assert.strictEqual(escaped, null, escaped && escaped.message);
assert.strictEqual(coldThrow.length, scenarios[0].steps.length);
assert.ok(coldThrow.every(step => step.error === 'boom'), JSON.stringify(coldThrow.map(step => step.error)));
});
test('a throwing step is decided as the worker decides it, so it is no disagreement', () => {
assert.deepStrictEqual(coldThrow.map(step => step.decision), warmThrow.steps.map(step => step.decision));
});
test('every throwing step is reported as a fresh-process failure', () => {
assert.strictEqual(coldSummary(coldThrow, warmThrow.steps).errors, coldThrow.length);
});
} finally {
fs.rmSync(throwingDir, { recursive: true, force: true });
}
} finally {
fs.rmSync(corpusDir, { recursive: true, force: true });
}
}
coldTests().then(finish, error => {
console.log(` ✗ fresh-process pass failed: ${error.message}`);
failed++;
finish();
});
function finish() {
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exitCode = failed > 0 ? 1 : 0;
}