mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 12:15:29 +02:00
316 lines
13 KiB
JavaScript
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;
|
|
}
|