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
13 changes: 12 additions & 1 deletion src/cli/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { reportTerminal } from '../../reporters/terminal.js';
import { reportJson } from '../../reporters/json.js';
import { reportHtml } from '../../reporters/html.js';
import { reportSarif } from '../../reporters/sarif.js';
import { reportJunit } from '../../reporters/junit.js';
import { reportComplianceHtml, SUPPORTED_STANDARDS } from '../../reporters/compliance-html.js';
import { loadConfig } from '../../config/loader.js';
import { createSpinner } from '../ui.js';
Expand All @@ -19,6 +20,7 @@ export const scanCommand = new Command('scan')
.option('--json', 'Output as JSON')
.option('--html [file]', 'Output as HTML report')
.option('--sarif [file]', 'Output as SARIF 2.1.0')
.option('--junit [file]', 'Output as JUnit XML for CI integration')
.option('-o, --output <file>', 'Write JSON output to file')
.option('-q, --quiet', 'Suppress terminal output')
.option('--severity <level>', 'Minimum severity to report (critical|high|medium|low)')
Expand Down Expand Up @@ -47,6 +49,7 @@ export const scanCommand = new Command('scan')
json?: boolean;
html?: string | boolean;
sarif?: string | boolean;
junit?: string | boolean;
output?: string;
quiet?: boolean;
severity?: string;
Expand Down Expand Up @@ -211,7 +214,15 @@ export const scanCommand = new Command('scan')
result.findings = allFindings.filter(f => (confidenceOrder[f.confidence] ?? 2) <= minLevel);
const hiddenLowConfidence = allFindings.length - result.findings.length;

if (options.sarif) {
if (options.junit) {
const junitPath = typeof options.junit === 'string' ? options.junit : undefined;
const junit = reportJunit(result, junitPath);
if (!junitPath) {
console.log(junit);
} else if (!options.quiet) {
console.log(`JUnit XML report written to: ${junitPath}`);
}
} else if (options.sarif) {
const sarifPath = typeof options.sarif === 'string'
? options.sarif
: undefined;
Expand Down
69 changes: 69 additions & 0 deletions src/reporters/junit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as fs from 'node:fs';
import type { ScanResult } from '../types/score.js';
import type { Finding } from '../types/finding.js';

function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

export function reportJunit(result: ScanResult, outputPath?: string): string {
// Group findings by domain
const byDomain = new Map<string, Finding[]>();
for (const finding of result.findings) {
const domain = finding.domain;
if (!byDomain.has(domain)) {
byDomain.set(domain, []);
}
byDomain.get(domain)!.push(finding);
}

const totalTests = result.findings.length;
const totalFailures = result.findings.filter(
f => f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium',
).length;

const lines: string[] = [
'<?xml version="1.0" encoding="UTF-8"?>',
`<testsuites name="g0-scan" tests="${totalTests}" failures="${totalFailures}">`,
];

for (const [domain, findings] of byDomain) {
const suiteFailures = findings.filter(
f => f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium',
).length;

lines.push(` <testsuite name="${escapeXml(domain)}" tests="${findings.length}" failures="${suiteFailures}">`);

for (const finding of findings) {
const isFail = finding.severity === 'critical' || finding.severity === 'high' || finding.severity === 'medium';
lines.push(` <testcase name="${escapeXml(finding.ruleId)}" classname="${escapeXml(domain)}">`);

if (isFail) {
const message = escapeXml(finding.title);
const body = `File: ${finding.location.file}:${finding.location.line}\nRule: ${finding.ruleId} - ${finding.title}${finding.remediation ? `\nRemediation: ${finding.remediation}` : ''}`;
lines.push(` <failure message="${message}" type="${escapeXml(finding.severity)}">`);
lines.push(` ${escapeXml(body)}`);
lines.push(' </failure>');
}

lines.push(' </testcase>');
}

lines.push(' </testsuite>');
}

lines.push('</testsuites>');

const xml = lines.join('\n');

if (outputPath) {
fs.writeFileSync(outputPath, xml, 'utf-8');
}

return xml;
}
143 changes: 143 additions & 0 deletions tests/unit/junit-reporter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest';
import { reportJunit } from '../../src/reporters/junit.js';
import type { ScanResult } from '../../src/types/score.js';
import type { Finding } from '../../src/types/finding.js';

function makeFinding(overrides?: Partial<Finding>): Finding {
return {
id: 'AA-GI-001-0',
ruleId: 'AA-GI-001',
title: 'Test finding',
description: 'Test description',
severity: 'high',
confidence: 'high',
domain: 'goal-integrity',
location: { file: 'agent.py', line: 10, snippet: 'some code here' },
remediation: 'Fix it',
standards: { owaspAgentic: ['ASI01'] },
...overrides,
};
}

function makeScanResult(findings: Finding[]): ScanResult {
return {
findings,
score: {
overall: 75,
grade: 'C' as const,
domains: [],
},
graph: {
rootPath: '/test',
primaryFramework: 'langchain',
secondaryFrameworks: [],
agents: [],
tools: [],
prompts: [],
files: { all: [], python: [], typescript: [], javascript: [], configs: [] },
models: [],
vectorDBs: [],
mcpServers: [],
},
timestamp: '2025-01-01T00:00:00.000Z',
duration: 1000,
};
}

describe('JUnit XML Reporter', () => {
it('generates valid JUnit XML structure', () => {
const result = makeScanResult([makeFinding()]);
const xml = reportJunit(result);

expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain('<testsuites name="g0-scan"');
expect(xml).toContain('tests="1"');
expect(xml).toContain('failures="1"');
expect(xml).toContain('<testsuite name="goal-integrity"');
expect(xml).toContain('<testcase name="AA-GI-001"');
expect(xml).toContain('classname="goal-integrity"');
});

it('marks high/critical/medium as failures, low/info as pass', () => {
const result = makeScanResult([
makeFinding({ id: 'f1', ruleId: 'R-001', severity: 'critical', domain: 'tool-safety' }),
makeFinding({ id: 'f2', ruleId: 'R-002', severity: 'high', domain: 'tool-safety' }),
makeFinding({ id: 'f3', ruleId: 'R-003', severity: 'medium', domain: 'tool-safety' }),
makeFinding({ id: 'f4', ruleId: 'R-004', severity: 'low', domain: 'tool-safety' }),
makeFinding({ id: 'f5', ruleId: 'R-005', severity: 'info', domain: 'tool-safety' }),
]);
const xml = reportJunit(result);

expect(xml).toContain('tests="5" failures="3"');
// critical, high, medium have <failure>
expect((xml.match(/<failure /g) || []).length).toBe(3);
});

it('groups findings by domain into testsuites', () => {
const result = makeScanResult([
makeFinding({ id: 'f1', ruleId: 'R-001', domain: 'tool-safety' }),
makeFinding({ id: 'f2', ruleId: 'R-002', domain: 'goal-integrity' }),
makeFinding({ id: 'f3', ruleId: 'R-003', domain: 'tool-safety' }),
]);
const xml = reportJunit(result);

expect((xml.match(/<testsuite /g) || []).length).toBe(2);
expect(xml).toContain('<testsuite name="tool-safety" tests="2"');
expect(xml).toContain('<testsuite name="goal-integrity" tests="1"');
});

it('escapes XML special characters', () => {
const result = makeScanResult([
makeFinding({ title: 'Finding with <special> & "chars"' }),
]);
const xml = reportJunit(result);

expect(xml).toContain('&lt;special&gt;');
expect(xml).toContain('&amp;');
expect(xml).toContain('&quot;chars&quot;');
// Must be valid XML (no unescaped < > & in content)
expect(xml).not.toMatch(/<failure[^>]*>[^<]*<[^/!][^<]*<\/failure>/);
});

it('handles empty findings', () => {
const result = makeScanResult([]);
const xml = reportJunit(result);

expect(xml).toContain('tests="0" failures="0"');
expect(xml).not.toContain('<testsuite ');
});

it('includes file location and remediation in failure body', () => {
const result = makeScanResult([
makeFinding({
ruleId: 'AA-TS-003',
title: 'Unsandboxed tool call',
location: { file: 'tools.ts', line: 18 },
remediation: 'Add sandbox wrapper',
}),
]);
const xml = reportJunit(result);

expect(xml).toContain('tools.ts:18');
expect(xml).toContain('AA-TS-003');
expect(xml).toContain('Add sandbox wrapper');
});

it('writes to file when outputPath is provided', async () => {
const fs = await import('node:fs');
const os = await import('node:os');
const path = await import('node:path');
const tmpFile = path.join(os.tmpdir(), `junit-test-${Date.now()}.xml`);

try {
const result = makeScanResult([makeFinding()]);
reportJunit(result, tmpFile);

const content = fs.readFileSync(tmpFile, 'utf-8');
expect(content).toContain('<?xml version="1.0"');
expect(content).toContain('<testsuites');
} finally {
fs.unlinkSync(tmpFile);
}
});
});