diff --git a/src/cli/commands/scan.ts b/src/cli/commands/scan.ts index eb261ce..55ce6bf 100644 --- a/src/cli/commands/scan.ts +++ b/src/cli/commands/scan.ts @@ -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'; @@ -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 ', 'Write JSON output to file') .option('-q, --quiet', 'Suppress terminal output') .option('--severity ', 'Minimum severity to report (critical|high|medium|low)') @@ -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; @@ -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; diff --git a/src/reporters/junit.ts b/src/reporters/junit.ts new file mode 100644 index 0000000..7914ccf --- /dev/null +++ b/src/reporters/junit.ts @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function reportJunit(result: ScanResult, outputPath?: string): string { + // Group findings by domain + const byDomain = new Map(); + 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[] = [ + '', + ``, + ]; + + for (const [domain, findings] of byDomain) { + const suiteFailures = findings.filter( + f => f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium', + ).length; + + lines.push(` `); + + for (const finding of findings) { + const isFail = finding.severity === 'critical' || finding.severity === 'high' || finding.severity === 'medium'; + lines.push(` `); + + 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(` `); + lines.push(` ${escapeXml(body)}`); + lines.push(' '); + } + + lines.push(' '); + } + + lines.push(' '); + } + + lines.push(''); + + const xml = lines.join('\n'); + + if (outputPath) { + fs.writeFileSync(outputPath, xml, 'utf-8'); + } + + return xml; +} diff --git a/tests/unit/junit-reporter.test.ts b/tests/unit/junit-reporter.test.ts new file mode 100644 index 0000000..fc934ec --- /dev/null +++ b/tests/unit/junit-reporter.test.ts @@ -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 { + 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(''); + expect(xml).toContain(' { + 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 + expect((xml.match(/ { + 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(/ { + const result = makeScanResult([ + makeFinding({ title: 'Finding with & "chars"' }), + ]); + const xml = reportJunit(result); + + expect(xml).toContain('<special>'); + expect(xml).toContain('&'); + expect(xml).toContain('"chars"'); + // Must be valid XML (no unescaped < > & in content) + expect(xml).not.toMatch(/]*>[^<]*<[^/!][^<]*<\/failure>/); + }); + + it('handles empty findings', () => { + const result = makeScanResult([]); + const xml = reportJunit(result); + + expect(xml).toContain('tests="0" failures="0"'); + expect(xml).not.toContain(' { + 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('