diff --git a/package-lock.json b/package-lock.json index 82e86db..30c8899 100644 --- a/package-lock.json +++ b/package-lock.json @@ -883,7 +883,6 @@ "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1239,7 +1238,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1682,7 +1680,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1732,7 +1729,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2110,7 +2106,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0" @@ -2293,7 +2288,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2314,7 +2308,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2356,7 +2349,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -2542,7 +2534,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/src/cli/commands/scan.ts b/src/cli/commands/scan.ts index eb261ce..5fdd47c 100644 --- a/src/cli/commands/scan.ts +++ b/src/cli/commands/scan.ts @@ -7,6 +7,7 @@ import { reportJson } from '../../reporters/json.js'; import { reportHtml } from '../../reporters/html.js'; import { reportSarif } from '../../reporters/sarif.js'; import { reportComplianceHtml, SUPPORTED_STANDARDS } from '../../reporters/compliance-html.js'; +import { reportComplianceMarkdown } from '../../reporters/compliance-markdown.js'; import { loadConfig } from '../../config/loader.js'; import { createSpinner } from '../ui.js'; import { isRemoteUrl, parseTarget, cloneRepo } from '../../remote/clone.js'; @@ -30,6 +31,7 @@ export const scanCommand = new Command('scan') .option('--ai', 'Enable AI-powered analysis (requires ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY)') .option('--model ', 'AI model to use (e.g., claude-sonnet-4-5-20250929, gpt-5-mini, gemini-2.5-flash)') .option('--report ', `Generate compliance report (${SUPPORTED_STANDARDS.join('|')})`) + .option('--markdown [file]', 'Output compliance report as Markdown (use with --report)') .option('--upload', 'Upload results to Guard0 platform') .option('--include-tests', 'Include test files in agent graph (normally excluded)') .option('--show-all', 'Show all findings including suppressed utility-code ones') @@ -58,6 +60,7 @@ export const scanCommand = new Command('scan') ai?: boolean; model?: string; report?: string; + markdown?: string | boolean; upload?: boolean; includeTests?: boolean; showAll?: boolean; @@ -254,14 +257,28 @@ export const scanCommand = new Command('scan') // Generate compliance report if (options.report) { - const reportPath = path.join(resolvedPath, `g0-${options.report}-report.html`); - try { - reportComplianceHtml(result, options.report, reportPath); - if (!options.quiet) { - console.log(`\n Compliance report (${options.report}) written to: ${reportPath}`); + if (options.markdown != null) { + const mdPath = typeof options.markdown === 'string' + ? options.markdown + : path.join(resolvedPath, `g0-${options.report}-report.md`); + try { + reportComplianceMarkdown(result, options.report, mdPath); + if (!options.quiet) { + console.log(`\n Compliance report (${options.report}) written to: ${mdPath}`); + } + } catch (err) { + console.error(` Report generation failed: ${err instanceof Error ? err.message : err}`); + } + } else { + const reportPath = path.join(resolvedPath, `g0-${options.report}-report.html`); + try { + reportComplianceHtml(result, options.report, reportPath); + if (!options.quiet) { + console.log(`\n Compliance report (${options.report}) written to: ${reportPath}`); + } + } catch (err) { + console.error(` Report generation failed: ${err instanceof Error ? err.message : err}`); } - } catch (err) { - console.error(` Report generation failed: ${err instanceof Error ? err.message : err}`); } } diff --git a/src/reporters/compliance-html.ts b/src/reporters/compliance-html.ts index 0bd5f94..1b28d5f 100644 --- a/src/reporters/compliance-html.ts +++ b/src/reporters/compliance-html.ts @@ -156,7 +156,7 @@ function assessControl( }; } -function generateCompliance(standard: string, result: ScanResult): ComplianceResult { +export function generateCompliance(standard: string, result: ScanResult): ComplianceResult { const controls = STANDARD_CONTROLS[standard]; if (!controls) { throw new Error(`Unknown standard: ${standard}. Supported: ${Object.keys(STANDARD_CONTROLS).join(', ')}`); diff --git a/src/reporters/compliance-markdown.ts b/src/reporters/compliance-markdown.ts new file mode 100644 index 0000000..dc164f2 --- /dev/null +++ b/src/reporters/compliance-markdown.ts @@ -0,0 +1,59 @@ +import type { ScanResult } from '../types/score.js'; +import * as fs from 'node:fs'; +import { generateCompliance, SUPPORTED_STANDARDS } from './compliance-html.js'; + +export { SUPPORTED_STANDARDS }; + +function statusIcon(s: string): string { + return s === 'pass' ? '✅' : s === 'fail' ? '❌' : '⚠️'; +} + +export function reportComplianceMarkdown( + result: ScanResult, + standard: string, + outputPath: string, +): void { + const compliance = generateCompliance(standard, result); + const date = new Date().toISOString().split('T')[0]; + + const lines: string[] = []; + + lines.push(`# ${compliance.standardName} — Compliance Report`); + lines.push(''); + lines.push(`**Score:** ${result.score.overall}/100 (${result.score.grade}) `); + lines.push(`**Findings:** ${result.findings.length} `); + lines.push(`**Generated:** ${date} by Guard0`); + lines.push(''); + + // Summary + lines.push('## Summary'); + lines.push(''); + lines.push(`| Metric | Value |`); + lines.push(`|--------|-------|`); + lines.push(`| Compliance Score | ${Math.round(compliance.overallScore)}% |`); + lines.push(`| Pass | ${compliance.passCount} |`); + lines.push(`| Partial | ${compliance.partialCount} |`); + lines.push(`| Fail | ${compliance.failCount} |`); + lines.push(''); + + // Controls table + lines.push('## Controls'); + lines.push(''); + lines.push('| Status | Control | Name | Findings | Notes |'); + lines.push('|--------|---------|------|----------|-------|'); + + for (const c of compliance.controls) { + const icon = statusIcon(c.status); + const findings = c.findings.length > 0 ? c.findings.map((f) => `\`${f}\``).join(', ') : '—'; + lines.push(`| ${icon} ${c.status.toUpperCase()} | \`${c.id}\` | ${c.name} | ${findings} | ${c.notes} |`); + } + + lines.push(''); + lines.push('---'); + lines.push(''); + lines.push(`*Guard0 AI Agent Security Scanner · guard0.ai* `); + lines.push(`*Standard: ${compliance.standardName} · ${compliance.controls.length} controls assessed*`); + lines.push(''); + + fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8'); +} diff --git a/tests/unit/compliance-markdown.test.ts b/tests/unit/compliance-markdown.test.ts new file mode 100644 index 0000000..324a7fa --- /dev/null +++ b/tests/unit/compliance-markdown.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { reportComplianceMarkdown } from '../../src/reporters/compliance-markdown.js'; +import type { ScanResult } from '../../src/types/score.js'; +import type { Finding } from '../../src/types/finding.js'; + +function makeFinding(overrides: Partial = {}): Finding { + return { + id: 'f-1', + ruleId: 'AA-GI-001', + title: 'Test finding', + description: 'A test finding', + severity: 'critical', + confidence: 'high', + domain: 'goal-integrity', + location: { file: 'agent.py', line: 42, column: 0 }, + remediation: 'Fix it', + standards: {}, + ...overrides, + }; +} + +function makeScanResult(findings: Finding[] = []): ScanResult { + return { + score: { overall: 65, grade: 'C', domains: [] }, + findings, + graph: { agents: [], tools: [], edges: [], models: [] } as any, + duration: 100, + timestamp: new Date().toISOString(), + }; +} + +describe('compliance-markdown reporter', () => { + const tmpFiles: string[] = []; + + function tmpPath(): string { + const p = path.join(os.tmpdir(), `g0-test-${Date.now()}-${Math.random().toString(36).slice(2)}.md`); + tmpFiles.push(p); + return p; + } + + afterEach(() => { + for (const f of tmpFiles) { + try { fs.unlinkSync(f); } catch {} + } + tmpFiles.length = 0; + }); + + it('generates a valid markdown file for iso42001', () => { + const out = tmpPath(); + reportComplianceMarkdown(makeScanResult(), 'iso42001', out); + const md = fs.readFileSync(out, 'utf-8'); + + expect(md).toContain('# ISO 42001 AI Management System'); + expect(md).toContain('## Summary'); + expect(md).toContain('## Controls'); + expect(md).toContain('| Status | Control | Name | Findings | Notes |'); + expect(md).toContain('guard0.ai'); + }); + + it('shows pass for controls with no findings', () => { + const out = tmpPath(); + reportComplianceMarkdown(makeScanResult([]), 'iso42001', out); + const md = fs.readFileSync(out, 'utf-8'); + + expect(md).toContain('✅ PASS'); + expect(md).not.toContain('❌ FAIL'); + }); + + it('shows fail for controls with critical findings', () => { + const out = tmpPath(); + const finding = makeFinding({ severity: 'critical', domain: 'goal-integrity' }); + reportComplianceMarkdown(makeScanResult([finding]), 'iso42001', out); + const md = fs.readFileSync(out, 'utf-8'); + + expect(md).toContain('❌ FAIL'); + expect(md).toContain('`AA-GI-001`'); + }); + + it('shows partial for controls with medium findings', () => { + const out = tmpPath(); + const finding = makeFinding({ severity: 'medium', domain: 'data-leakage' }); + reportComplianceMarkdown(makeScanResult([finding]), 'iso42001', out); + const md = fs.readFileSync(out, 'utf-8'); + + expect(md).toContain('⚠️ PARTIAL'); + }); + + it('throws for unknown standard', () => { + const out = tmpPath(); + expect(() => reportComplianceMarkdown(makeScanResult(), 'unknown-standard', out)).toThrow('Unknown standard'); + }); + + it('works with all supported standards', () => { + const standards = ['owasp-agentic', 'iso42001', 'nist-ai-rmf', 'soc2', 'eu-ai-act']; + for (const std of standards) { + const out = tmpPath(); + reportComplianceMarkdown(makeScanResult(), std, out); + const md = fs.readFileSync(out, 'utf-8'); + expect(md).toContain('## Controls'); + } + }); + + it('includes score and grade', () => { + const out = tmpPath(); + reportComplianceMarkdown(makeScanResult(), 'iso42001', out); + const md = fs.readFileSync(out, 'utf-8'); + + expect(md).toContain('65/100'); + expect(md).toContain('(C)'); + }); +});