Full port of llm-security plugin for internal use on Windows with GitHub Copilot CLI. Protocol translation layer (copilot-hook-runner.mjs) normalizes Copilot camelCase I/O to Claude Code snake_case format — all original hook scripts run unmodified. - 8 hooks with protocol translation (stdin/stdout/exit code) - 18 SKILL.md skills (Agent Skills Open Standard) - 6 .agent.md agent definitions - 20 scanners + 14 scanner lib modules (unchanged) - 14 knowledge files (unchanged) - 39 test files including copilot-port-verify.mjs (17 tests) - Windows-ready: node:path, os.tmpdir(), process.execPath, no bash Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
278 lines
10 KiB
JavaScript
278 lines
10 KiB
JavaScript
// output.test.mjs — Tests for scanners/lib/output.mjs
|
|
// Zero external dependencies: node:test + node:assert only.
|
|
|
|
import { describe, it, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
resetCounter,
|
|
finding,
|
|
scannerResult,
|
|
envelope,
|
|
} from '../../scanners/lib/output.mjs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// finding + resetCounter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('finding', () => {
|
|
beforeEach(() => {
|
|
resetCounter();
|
|
});
|
|
|
|
it('returns an object with auto-incrementing ID in DS-SCANNER-NNN format', () => {
|
|
const f = finding({ scanner: 'UNI', severity: 'high', title: 'Test', description: 'Desc' });
|
|
assert.equal(f.id, 'DS-UNI-001');
|
|
});
|
|
|
|
it('increments ID with each call', () => {
|
|
const f1 = finding({ scanner: 'UNI', severity: 'high', title: 'A', description: 'Desc' });
|
|
const f2 = finding({ scanner: 'ENT', severity: 'medium', title: 'B', description: 'Desc' });
|
|
const f3 = finding({ scanner: 'PRM', severity: 'low', title: 'C', description: 'Desc' });
|
|
assert.equal(f1.id, 'DS-UNI-001');
|
|
assert.equal(f2.id, 'DS-ENT-002');
|
|
assert.equal(f3.id, 'DS-PRM-003');
|
|
});
|
|
|
|
it('zero-pads counter to 3 digits', () => {
|
|
for (let i = 0; i < 9; i++) {
|
|
finding({ scanner: 'UNI', severity: 'info', title: `F${i}`, description: 'x' });
|
|
}
|
|
const f10 = finding({ scanner: 'UNI', severity: 'info', title: 'F10', description: 'x' });
|
|
assert.equal(f10.id, 'DS-UNI-010');
|
|
});
|
|
|
|
it('includes all required fields', () => {
|
|
const f = finding({
|
|
scanner: 'ENT',
|
|
severity: 'critical',
|
|
title: 'High Entropy Secret',
|
|
description: 'Found a high-entropy string that looks like an API key.',
|
|
});
|
|
assert.equal(f.scanner, 'ENT');
|
|
assert.equal(f.severity, 'critical');
|
|
assert.equal(f.title, 'High Entropy Secret');
|
|
assert.equal(f.description, 'Found a high-entropy string that looks like an API key.');
|
|
});
|
|
|
|
it('sets optional fields to null when not provided', () => {
|
|
const f = finding({ scanner: 'UNI', severity: 'low', title: 'T', description: 'D' });
|
|
assert.equal(f.file, null);
|
|
assert.equal(f.line, null);
|
|
assert.equal(f.evidence, null);
|
|
assert.equal(f.owasp, null);
|
|
assert.equal(f.recommendation, null);
|
|
});
|
|
|
|
it('includes all provided optional fields', () => {
|
|
const f = finding({
|
|
scanner: 'TNT',
|
|
severity: 'high',
|
|
title: 'Taint flow',
|
|
description: 'Untrusted data flows into eval().',
|
|
file: 'src/runner.mjs',
|
|
line: 42,
|
|
evidence: 'eval(userInput)',
|
|
owasp: 'LLM01',
|
|
recommendation: 'Sanitize user input before evaluation.',
|
|
});
|
|
assert.equal(f.file, 'src/runner.mjs');
|
|
assert.equal(f.line, 42);
|
|
assert.equal(f.evidence, 'eval(userInput)');
|
|
assert.equal(f.owasp, 'LLM01');
|
|
assert.equal(f.recommendation, 'Sanitize user input before evaluation.');
|
|
});
|
|
});
|
|
|
|
describe('resetCounter', () => {
|
|
it('resets the counter so the next finding starts at 001', () => {
|
|
// Advance counter to some arbitrary position
|
|
finding({ scanner: 'UNI', severity: 'info', title: 'A', description: 'x' });
|
|
finding({ scanner: 'UNI', severity: 'info', title: 'B', description: 'x' });
|
|
finding({ scanner: 'UNI', severity: 'info', title: 'C', description: 'x' });
|
|
|
|
resetCounter();
|
|
|
|
const f = finding({ scanner: 'ENT', severity: 'low', title: 'After reset', description: 'x' });
|
|
assert.equal(f.id, 'DS-ENT-001');
|
|
});
|
|
|
|
it('can be called multiple times without error', () => {
|
|
assert.doesNotThrow(() => {
|
|
resetCounter();
|
|
resetCounter();
|
|
resetCounter();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// scannerResult
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('scannerResult', () => {
|
|
beforeEach(() => {
|
|
resetCounter();
|
|
});
|
|
|
|
it('returns an object with the expected top-level keys', () => {
|
|
const result = scannerResult('unicode-scanner', 'ok', [], 10, 42);
|
|
assert.ok('scanner' in result);
|
|
assert.ok('status' in result);
|
|
assert.ok('findings' in result);
|
|
assert.ok('counts' in result);
|
|
assert.ok('files_scanned' in result);
|
|
assert.ok('duration_ms' in result);
|
|
});
|
|
|
|
it('sets scanner name and status correctly', () => {
|
|
const result = scannerResult('entropy-scanner', 'ok', [], 5, 100);
|
|
assert.equal(result.scanner, 'entropy-scanner');
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('returns empty counts for no findings', () => {
|
|
const result = scannerResult('dep-auditor', 'ok', [], 0, 0);
|
|
assert.deepEqual(result.counts, { critical: 0, high: 0, medium: 0, low: 0, info: 0 });
|
|
});
|
|
|
|
it('counts findings by severity correctly', () => {
|
|
const f1 = finding({ scanner: 'ENT', severity: 'critical', title: 'A', description: 'x' });
|
|
const f2 = finding({ scanner: 'ENT', severity: 'high', title: 'B', description: 'x' });
|
|
const f3 = finding({ scanner: 'ENT', severity: 'high', title: 'C', description: 'x' });
|
|
const f4 = finding({ scanner: 'ENT', severity: 'medium', title: 'D', description: 'x' });
|
|
|
|
const result = scannerResult('entropy-scanner', 'ok', [f1, f2, f3, f4], 20, 300);
|
|
assert.equal(result.counts.critical, 1);
|
|
assert.equal(result.counts.high, 2);
|
|
assert.equal(result.counts.medium, 1);
|
|
assert.equal(result.counts.low, 0);
|
|
assert.equal(result.counts.info, 0);
|
|
});
|
|
|
|
it('stores findings array as provided', () => {
|
|
const f = finding({ scanner: 'UNI', severity: 'low', title: 'X', description: 'y' });
|
|
const result = scannerResult('unicode-scanner', 'ok', [f], 1, 10);
|
|
assert.equal(result.findings.length, 1);
|
|
assert.equal(result.findings[0].id, f.id);
|
|
});
|
|
|
|
it('sets files_scanned and duration_ms', () => {
|
|
const result = scannerResult('git-forensics', 'ok', [], 77, 1234);
|
|
assert.equal(result.files_scanned, 77);
|
|
assert.equal(result.duration_ms, 1234);
|
|
});
|
|
|
|
it('does not include error field when errorMsg is not provided', () => {
|
|
const result = scannerResult('taint-tracer', 'ok', [], 5, 50);
|
|
assert.ok(!('error' in result));
|
|
});
|
|
|
|
it('includes error field when errorMsg is provided', () => {
|
|
const result = scannerResult('dep-auditor', 'error', [], 0, 10, 'ENOENT: package.json not found');
|
|
assert.equal(result.error, 'ENOENT: package.json not found');
|
|
assert.equal(result.status, 'error');
|
|
});
|
|
|
|
it('handles skipped status', () => {
|
|
const result = scannerResult('network-mapper', 'skipped', [], 0, 0);
|
|
assert.equal(result.status, 'skipped');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// envelope
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('envelope', () => {
|
|
beforeEach(() => {
|
|
resetCounter();
|
|
});
|
|
|
|
it('returns an object with meta, scanners, and aggregate keys', () => {
|
|
const result = envelope('/some/path', {}, 100);
|
|
assert.ok('meta' in result);
|
|
assert.ok('scanners' in result);
|
|
assert.ok('aggregate' in result);
|
|
});
|
|
|
|
it('meta contains target, timestamp, node_version, total_duration_ms', () => {
|
|
const result = envelope('/my/project', {}, 999);
|
|
assert.equal(result.meta.target, '/my/project');
|
|
assert.ok(typeof result.meta.timestamp === 'string');
|
|
assert.ok(result.meta.timestamp.length > 0);
|
|
assert.ok(typeof result.meta.node_version === 'string');
|
|
assert.equal(result.meta.total_duration_ms, 999);
|
|
});
|
|
|
|
it('aggregate contains risk_score and verdict', () => {
|
|
const result = envelope('/project', {}, 0);
|
|
assert.ok('risk_score' in result.aggregate);
|
|
assert.ok('verdict' in result.aggregate);
|
|
});
|
|
|
|
it('aggregate has zero counts and ALLOW verdict for empty scanner results', () => {
|
|
const result = envelope('/project', {}, 0);
|
|
assert.equal(result.aggregate.total_findings, 0);
|
|
assert.equal(result.aggregate.risk_score, 0);
|
|
assert.equal(result.aggregate.verdict, 'ALLOW');
|
|
assert.deepEqual(result.aggregate.counts, { critical: 0, high: 0, medium: 0, low: 0, info: 0 });
|
|
});
|
|
|
|
it('aggregates counts from multiple scanner results', () => {
|
|
const f1 = finding({ scanner: 'UNI', severity: 'critical', title: 'A', description: 'x' });
|
|
const f2 = finding({ scanner: 'ENT', severity: 'high', title: 'B', description: 'x' });
|
|
|
|
const scanners = {
|
|
unicode: scannerResult('unicode-scanner', 'ok', [f1], 10, 50),
|
|
entropy: scannerResult('entropy-scanner', 'ok', [f2], 10, 75),
|
|
};
|
|
|
|
const result = envelope('/project', scanners, 125);
|
|
assert.equal(result.aggregate.total_findings, 2);
|
|
assert.equal(result.aggregate.counts.critical, 1);
|
|
assert.equal(result.aggregate.counts.high, 1);
|
|
});
|
|
|
|
it('computes correct risk_score from aggregated counts', () => {
|
|
// 1 critical = score 25
|
|
const f = finding({ scanner: 'ENT', severity: 'critical', title: 'C', description: 'x' });
|
|
const scanners = {
|
|
entropy: scannerResult('entropy-scanner', 'ok', [f], 5, 30),
|
|
};
|
|
const result = envelope('/project', scanners, 30);
|
|
assert.equal(result.aggregate.risk_score, 25);
|
|
});
|
|
|
|
it('returns BLOCK verdict when critical finding present', () => {
|
|
const f = finding({ scanner: 'UNI', severity: 'critical', title: 'Critical', description: 'x' });
|
|
const scanners = {
|
|
uni: scannerResult('unicode-scanner', 'ok', [f], 1, 10),
|
|
};
|
|
const result = envelope('/project', scanners, 10);
|
|
assert.equal(result.aggregate.verdict, 'BLOCK');
|
|
});
|
|
|
|
it('tracks scanner ok/error/skipped counts', () => {
|
|
const scanners = {
|
|
uni: scannerResult('unicode-scanner', 'ok', [], 5, 10),
|
|
ent: scannerResult('entropy-scanner', 'error', [], 0, 5, 'failed'),
|
|
net: scannerResult('network-mapper', 'skipped', [], 0, 0),
|
|
};
|
|
const result = envelope('/project', scanners, 15);
|
|
assert.equal(result.aggregate.scanners_ok, 1);
|
|
assert.equal(result.aggregate.scanners_error, 1);
|
|
assert.equal(result.aggregate.scanners_skipped, 1);
|
|
});
|
|
|
|
it('includes owasp_breakdown in aggregate', () => {
|
|
const result = envelope('/project', {}, 0);
|
|
assert.ok('owasp_breakdown' in result.aggregate);
|
|
});
|
|
|
|
it('passes through scanner results as-is in scanners field', () => {
|
|
const sr = scannerResult('unicode-scanner', 'ok', [], 3, 20);
|
|
const scanners = { uni: sr };
|
|
const result = envelope('/project', scanners, 20);
|
|
assert.deepEqual(result.scanners, scanners);
|
|
});
|
|
});
|