Extends posture scanner from 13 to 16 categories with three governance/compliance checks. New categories are advisory (not in CRITICAL_CATEGORIES) — existing Grade A projects remain Grade A. VERSION bumped to 6.0.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
385 lines
13 KiB
JavaScript
385 lines
13 KiB
JavaScript
// posture.test.mjs — Tests for the deterministic posture scanner
|
|
// Tests against fixtures in tests/fixtures/posture-scan/ with:
|
|
// - grade-a-project: full security config (hooks, settings, CLAUDE.md) → Grade A
|
|
// - grade-f-project: dangerous flags, poisoned memory, no hooks → Grade F
|
|
|
|
import { describe, it, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
|
import { scan } from '../../scanners/posture-scanner.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const GRADE_A_FIXTURE = resolve(__dirname, '../fixtures/posture-scan/grade-a-project');
|
|
const GRADE_F_FIXTURE = resolve(__dirname, '../fixtures/posture-scan/grade-f-project');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Grade A project — well-configured, should get Grade A
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('posture-scanner: grade-a-project', () => {
|
|
let result;
|
|
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
result = await scan(GRADE_A_FIXTURE);
|
|
});
|
|
|
|
it('returns status ok', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('has scanner name', () => {
|
|
assert.equal(result.scanner, 'posture-scanner');
|
|
});
|
|
|
|
it('has version', () => {
|
|
assert.ok(result.version, 'Expected version string');
|
|
});
|
|
|
|
it('produces Grade A', () => {
|
|
assert.equal(result.scoring.grade, 'A');
|
|
});
|
|
|
|
it('has 16 categories assessed', () => {
|
|
assert.equal(result.categories.length, 16);
|
|
});
|
|
|
|
it('has low risk score', () => {
|
|
assert.ok(result.risk.score <= 25, `Expected risk score <= 25, got ${result.risk.score}`);
|
|
});
|
|
|
|
it('verdict is ALLOW or WARNING', () => {
|
|
assert.ok(
|
|
result.risk.verdict === 'ALLOW' || result.risk.verdict === 'WARNING',
|
|
`Expected ALLOW or WARNING, got ${result.risk.verdict}`,
|
|
);
|
|
});
|
|
|
|
it('deny-first configuration is PASS', () => {
|
|
const cat = result.categories.find(c => c.id === 1);
|
|
assert.equal(cat.status, 'PASS');
|
|
});
|
|
|
|
it('secrets protection is PASS', () => {
|
|
const cat = result.categories.find(c => c.id === 2);
|
|
assert.equal(cat.status, 'PASS');
|
|
});
|
|
|
|
it('path guarding is PASS', () => {
|
|
const cat = result.categories.find(c => c.id === 3);
|
|
assert.equal(cat.status, 'PASS');
|
|
});
|
|
|
|
it('MCP trust is N/A (no MCP servers)', () => {
|
|
const cat = result.categories.find(c => c.id === 4);
|
|
assert.equal(cat.status, 'N_A');
|
|
});
|
|
|
|
it('destructive blocking is PASS', () => {
|
|
const cat = result.categories.find(c => c.id === 5);
|
|
assert.equal(cat.status, 'PASS');
|
|
});
|
|
|
|
it('sandbox configuration is PASS', () => {
|
|
const cat = result.categories.find(c => c.id === 6);
|
|
assert.equal(cat.status, 'PASS');
|
|
});
|
|
|
|
it('cognitive state security is PASS', () => {
|
|
const cat = result.categories.find(c => c.id === 10);
|
|
assert.equal(cat.status, 'PASS');
|
|
});
|
|
|
|
it('zero critical findings', () => {
|
|
assert.equal(result.counts.critical, 0);
|
|
});
|
|
|
|
it('pass rate >= 0.89', () => {
|
|
assert.ok(result.scoring.pass_rate >= 0.89, `Expected pass_rate >= 0.89, got ${result.scoring.pass_rate}`);
|
|
});
|
|
|
|
it('duration_ms is a non-negative number', () => {
|
|
assert.ok(typeof result.duration_ms === 'number');
|
|
assert.ok(result.duration_ms >= 0);
|
|
});
|
|
|
|
it('timestamp is ISO format', () => {
|
|
assert.ok(result.timestamp.match(/^\d{4}-\d{2}-\d{2}T/), 'Expected ISO timestamp');
|
|
});
|
|
|
|
it('each category has required fields', () => {
|
|
for (const cat of result.categories) {
|
|
assert.ok(cat.id, 'Category missing id');
|
|
assert.ok(cat.name, 'Category missing name');
|
|
assert.ok(cat.owasp, 'Category missing owasp');
|
|
assert.ok(cat.status, 'Category missing status');
|
|
assert.ok(typeof cat.findings_count === 'number', 'Category missing findings_count');
|
|
assert.ok(Array.isArray(cat.evidence), 'Category missing evidence array');
|
|
}
|
|
});
|
|
|
|
// v5.0 new categories
|
|
it('prompt injection hardening is PASS', () => {
|
|
const cat = result.categories.find(c => c.id === 11);
|
|
assert.equal(cat.status, 'PASS');
|
|
});
|
|
|
|
it('Rule of Two is PASS', () => {
|
|
const cat = result.categories.find(c => c.id === 12);
|
|
assert.equal(cat.status, 'PASS');
|
|
});
|
|
|
|
it('long-horizon monitoring is PASS', () => {
|
|
const cat = result.categories.find(c => c.id === 13);
|
|
assert.equal(cat.status, 'PASS');
|
|
});
|
|
|
|
it('new categories have correct names', () => {
|
|
const cat11 = result.categories.find(c => c.id === 11);
|
|
const cat12 = result.categories.find(c => c.id === 12);
|
|
const cat13 = result.categories.find(c => c.id === 13);
|
|
assert.equal(cat11.name, 'Prompt Injection Hardening');
|
|
assert.equal(cat12.name, 'Rule of Two');
|
|
assert.equal(cat13.name, 'Long-Horizon Monitoring');
|
|
});
|
|
|
|
it('new categories have OWASP mappings', () => {
|
|
const cat11 = result.categories.find(c => c.id === 11);
|
|
const cat12 = result.categories.find(c => c.id === 12);
|
|
const cat13 = result.categories.find(c => c.id === 13);
|
|
assert.ok(cat11.owasp.includes('LLM01'), 'Cat 11 should map to LLM01');
|
|
assert.ok(cat12.owasp.includes('ASI02'), 'Cat 12 should map to ASI02');
|
|
assert.ok(cat13.owasp.includes('ASI06'), 'Cat 13 should map to ASI06');
|
|
});
|
|
|
|
// v6.0 compliance categories
|
|
it('EU AI Act Compliance category exists', () => {
|
|
const cat = result.categories.find(c => c.id === 14);
|
|
assert.ok(cat, 'Category 14 should exist');
|
|
assert.equal(cat.name, 'EU AI Act Compliance');
|
|
});
|
|
|
|
it('NIST AI RMF Alignment category exists', () => {
|
|
const cat = result.categories.find(c => c.id === 15);
|
|
assert.ok(cat, 'Category 15 should exist');
|
|
assert.equal(cat.name, 'NIST AI RMF Alignment');
|
|
});
|
|
|
|
it('ISO 42001 Readiness category exists', () => {
|
|
const cat = result.categories.find(c => c.id === 16);
|
|
assert.ok(cat, 'Category 16 should exist');
|
|
assert.equal(cat.name, 'ISO 42001 Readiness');
|
|
});
|
|
|
|
it('compliance categories are PARTIAL for grade-a (has hooks+config but no reports/tests)', () => {
|
|
for (const id of [14, 15, 16]) {
|
|
const cat = result.categories.find(c => c.id === id);
|
|
assert.ok(
|
|
cat.status === 'PASS' || cat.status === 'PARTIAL',
|
|
`Category ${id} (${cat.name}) should be PASS or PARTIAL, got ${cat.status}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('compliance categories have Governance OWASP mapping', () => {
|
|
for (const id of [14, 15, 16]) {
|
|
const cat = result.categories.find(c => c.id === id);
|
|
assert.ok(cat.owasp, `Category ${id} should have owasp mapping`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Grade F project — dangerous config, poisoned memory → Grade F
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('posture-scanner: grade-f-project', () => {
|
|
let result;
|
|
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
result = await scan(GRADE_F_FIXTURE);
|
|
});
|
|
|
|
it('returns status ok', () => {
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('produces Grade F', () => {
|
|
assert.equal(result.scoring.grade, 'F');
|
|
});
|
|
|
|
it('has high risk score (>= 40)', () => {
|
|
assert.ok(result.risk.score >= 40, `Expected risk >= 40, got ${result.risk.score}`);
|
|
});
|
|
|
|
it('verdict is BLOCK or WARNING', () => {
|
|
assert.ok(
|
|
result.risk.verdict === 'BLOCK' || result.risk.verdict === 'WARNING',
|
|
`Expected BLOCK or WARNING, got ${result.risk.verdict}`,
|
|
);
|
|
});
|
|
|
|
it('deny-first configuration is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 1);
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
|
|
it('secrets protection is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 2);
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
|
|
it('destructive blocking is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 5);
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
|
|
it('sandbox configuration is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 6);
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
|
|
it('cognitive state security is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 10);
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
|
|
it('has multiple FAIL categories', () => {
|
|
const fails = result.categories.filter(c => c.status === 'FAIL');
|
|
assert.ok(fails.length >= 7, `Expected >= 7 FAIL categories, got ${fails.length}`);
|
|
});
|
|
|
|
it('has critical findings', () => {
|
|
assert.ok(result.counts.critical >= 1, `Expected >= 1 critical findings, got ${result.counts.critical}`);
|
|
});
|
|
|
|
it('has high findings', () => {
|
|
assert.ok(result.counts.high >= 1, `Expected >= 1 high findings, got ${result.counts.high}`);
|
|
});
|
|
|
|
it('findings have PST scanner prefix', () => {
|
|
const wrongPrefix = result.findings.filter(f => !f.id.startsWith('DS-PST-'));
|
|
assert.equal(
|
|
wrongPrefix.length, 0,
|
|
`All findings should have DS-PST- prefix. Wrong: ${wrongPrefix.map(f => f.id).join(', ')}`,
|
|
);
|
|
});
|
|
|
|
it('findings have required fields', () => {
|
|
for (const f of result.findings) {
|
|
assert.ok(f.id, `Finding missing id`);
|
|
assert.ok(f.scanner === 'PST', `Finding ${f.id} scanner should be PST, got ${f.scanner}`);
|
|
assert.ok(f.severity, `Finding ${f.id} missing severity`);
|
|
assert.ok(f.title, `Finding ${f.id} missing title`);
|
|
assert.ok(f.owasp, `Finding ${f.id} missing owasp`);
|
|
}
|
|
});
|
|
|
|
it('finding IDs are sequential starting from DS-PST-001', () => {
|
|
if (result.findings.length === 0) return;
|
|
assert.equal(result.findings[0].id, 'DS-PST-001');
|
|
});
|
|
|
|
it('severity counts sum to total findings', () => {
|
|
const { counts } = result;
|
|
const total = counts.critical + counts.high + counts.medium + counts.low + counts.info;
|
|
assert.equal(total, result.findings.length, 'Severity counts should sum to total findings');
|
|
});
|
|
|
|
it('pass_rate < 0.33', () => {
|
|
assert.ok(result.scoring.pass_rate < 0.33, `Expected pass_rate < 0.33, got ${result.scoring.pass_rate}`);
|
|
});
|
|
|
|
// v5.0 new categories
|
|
it('prompt injection hardening is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 11);
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
|
|
it('Rule of Two is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 12);
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
|
|
it('long-horizon monitoring is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 13);
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
|
|
// v6.0 compliance categories — grade-f has no security config → FAIL
|
|
it('EU AI Act Compliance is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 14);
|
|
assert.ok(cat, 'Category 14 should exist');
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
|
|
it('NIST AI RMF Alignment is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 15);
|
|
assert.ok(cat, 'Category 15 should exist');
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
|
|
it('ISO 42001 Readiness is FAIL', () => {
|
|
const cat = result.categories.find(c => c.id === 16);
|
|
assert.ok(cat, 'Category 16 should exist');
|
|
assert.equal(cat.status, 'FAIL');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scanner interface compliance
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('posture-scanner: interface', () => {
|
|
it('scan() accepts a path string and returns a result', async () => {
|
|
resetCounter();
|
|
const result = await scan(GRADE_A_FIXTURE);
|
|
assert.ok(result);
|
|
assert.equal(typeof result, 'object');
|
|
});
|
|
|
|
it('result has scoring block', async () => {
|
|
resetCounter();
|
|
const result = await scan(GRADE_A_FIXTURE);
|
|
assert.ok(result.scoring);
|
|
assert.ok(typeof result.scoring.pass === 'number');
|
|
assert.ok(typeof result.scoring.partial === 'number');
|
|
assert.ok(typeof result.scoring.fail === 'number');
|
|
assert.ok(typeof result.scoring.na === 'number');
|
|
assert.ok(typeof result.scoring.applicable === 'number');
|
|
assert.ok(typeof result.scoring.score === 'number');
|
|
assert.ok(typeof result.scoring.pass_rate === 'number');
|
|
assert.ok(typeof result.scoring.grade === 'string');
|
|
});
|
|
|
|
it('result has risk block', async () => {
|
|
resetCounter();
|
|
const result = await scan(GRADE_A_FIXTURE);
|
|
assert.ok(result.risk);
|
|
assert.ok(typeof result.risk.score === 'number');
|
|
assert.ok(typeof result.risk.band === 'string');
|
|
assert.ok(typeof result.risk.verdict === 'string');
|
|
});
|
|
|
|
it('result has counts block', async () => {
|
|
resetCounter();
|
|
const result = await scan(GRADE_A_FIXTURE);
|
|
assert.ok(result.counts);
|
|
assert.ok(typeof result.counts.critical === 'number');
|
|
assert.ok(typeof result.counts.high === 'number');
|
|
assert.ok(typeof result.counts.medium === 'number');
|
|
assert.ok(typeof result.counts.low === 'number');
|
|
assert.ok(typeof result.counts.info === 'number');
|
|
});
|
|
|
|
it('completes in under 2 seconds', async () => {
|
|
resetCounter();
|
|
const start = Date.now();
|
|
await scan(GRADE_A_FIXTURE);
|
|
const elapsed = Date.now() - start;
|
|
assert.ok(elapsed < 2000, `Expected < 2000ms, took ${elapsed}ms`);
|
|
});
|
|
});
|