@panguard-ai/panguard 1.5.4 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/cli/auth-guard.d.ts +58 -2
  2. package/dist/cli/auth-guard.d.ts.map +1 -1
  3. package/dist/cli/auth-guard.js +96 -2
  4. package/dist/cli/auth-guard.js.map +1 -1
  5. package/dist/cli/commands/audit.d.ts.map +1 -1
  6. package/dist/cli/commands/audit.js +45 -6
  7. package/dist/cli/commands/audit.js.map +1 -1
  8. package/dist/cli/commands/doctor.d.ts.map +1 -1
  9. package/dist/cli/commands/doctor.js +25 -31
  10. package/dist/cli/commands/doctor.js.map +1 -1
  11. package/dist/cli/commands/login.d.ts +19 -3
  12. package/dist/cli/commands/login.d.ts.map +1 -1
  13. package/dist/cli/commands/login.js +181 -9
  14. package/dist/cli/commands/login.js.map +1 -1
  15. package/dist/cli/commands/logout.d.ts +13 -3
  16. package/dist/cli/commands/logout.d.ts.map +1 -1
  17. package/dist/cli/commands/logout.js +63 -8
  18. package/dist/cli/commands/logout.js.map +1 -1
  19. package/dist/cli/commands/report.d.ts +27 -2
  20. package/dist/cli/commands/report.d.ts.map +1 -1
  21. package/dist/cli/commands/report.js +698 -45
  22. package/dist/cli/commands/report.js.map +1 -1
  23. package/dist/cli/commands/scan.d.ts.map +1 -1
  24. package/dist/cli/commands/scan.js +21 -1
  25. package/dist/cli/commands/scan.js.map +1 -1
  26. package/dist/cli/commands/sensor.d.ts +17 -0
  27. package/dist/cli/commands/sensor.d.ts.map +1 -0
  28. package/dist/cli/commands/sensor.js +183 -0
  29. package/dist/cli/commands/sensor.js.map +1 -0
  30. package/dist/cli/commands/setup.d.ts.map +1 -1
  31. package/dist/cli/commands/setup.js +8 -5
  32. package/dist/cli/commands/setup.js.map +1 -1
  33. package/dist/cli/commands/status.js +22 -5
  34. package/dist/cli/commands/status.js.map +1 -1
  35. package/dist/cli/commands/trap.d.ts +6 -2
  36. package/dist/cli/commands/trap.d.ts.map +1 -1
  37. package/dist/cli/commands/trap.js +71 -46
  38. package/dist/cli/commands/trap.js.map +1 -1
  39. package/dist/cli/commands/up.d.ts.map +1 -1
  40. package/dist/cli/commands/up.js +53 -2
  41. package/dist/cli/commands/up.js.map +1 -1
  42. package/dist/cli/commands/whoami.d.ts +30 -2
  43. package/dist/cli/commands/whoami.d.ts.map +1 -1
  44. package/dist/cli/commands/whoami.js +109 -16
  45. package/dist/cli/commands/whoami.js.map +1 -1
  46. package/dist/cli/device-flow.d.ts +72 -0
  47. package/dist/cli/device-flow.d.ts.map +1 -0
  48. package/dist/cli/device-flow.js +126 -0
  49. package/dist/cli/device-flow.js.map +1 -0
  50. package/dist/cli/index.js +17 -5
  51. package/dist/cli/index.js.map +1 -1
  52. package/dist/cli/interactive/actions/misc.d.ts.map +1 -1
  53. package/dist/cli/interactive/actions/misc.js +56 -14
  54. package/dist/cli/interactive/actions/misc.js.map +1 -1
  55. package/dist/cli/interactive/menu-defs.d.ts.map +1 -1
  56. package/dist/cli/interactive/menu-defs.js +8 -8
  57. package/dist/cli/interactive/menu-defs.js.map +1 -1
  58. package/dist/cli/interactive.d.ts.map +1 -1
  59. package/dist/cli/interactive.js +6 -1
  60. package/dist/cli/interactive.js.map +1 -1
  61. package/dist/cli/telemetry.d.ts +17 -0
  62. package/dist/cli/telemetry.d.ts.map +1 -1
  63. package/dist/cli/telemetry.js +33 -0
  64. package/dist/cli/telemetry.js.map +1 -1
  65. package/dist/cli/workspace-sync.d.ts +108 -0
  66. package/dist/cli/workspace-sync.d.ts.map +1 -0
  67. package/dist/cli/workspace-sync.js +199 -0
  68. package/dist/cli/workspace-sync.js.map +1 -0
  69. package/package.json +17 -12
@@ -1,59 +1,712 @@
1
1
  /**
2
- * panguard report - Compliance report generation (Coming Soon)
3
- * panguard report - 合規報告產生(即將推出)
2
+ * panguard report AI Compliance Audit Evidence report generator
3
+ *
4
+ * Reads ATR rule YAML (from @panguard-ai/agent-threat-rules node_modules) and
5
+ * produces auditor-readable reports mapping each rule to compliance framework
6
+ * articles / clauses / subcategories. This is the Enterprise tier's core
7
+ * differentiator (product "D1").
8
+ *
9
+ * Usage:
10
+ * pga report list-frameworks
11
+ * pga report summary --framework <name>
12
+ * pga report generate --framework <name> [--format md|json|pdf] [--output <path>] [--sign <key>]
13
+ *
14
+ * Every report includes a SHA-256 integrity hash computed over the
15
+ * canonical JSON representation, and optionally an HMAC-SHA256
16
+ * signature (via --sign or PANGUARD_REPORT_SIGNING_KEY env var).
17
+ * PDFs additionally write a sidecar <output>.hash file for auditor
18
+ * verification.
19
+ *
20
+ * Supported framework ids:
21
+ * owasp-agentic — OWASP Agentic Top 10 (2026)
22
+ * owasp-llm — OWASP LLM Top 10 (2025)
23
+ * eu-ai-act — EU AI Act (Regulation 2024/1689)
24
+ * colorado-ai-act — Colorado SB24-205
25
+ * nist-ai-rmf — NIST AI RMF 1.0
26
+ * iso-42001 — ISO/IEC 42001:2023
27
+ *
28
+ * @module @panguard-ai/panguard/cli/commands/report
4
29
  */
5
30
  import { Command } from 'commander';
6
- const COMING_SOON_MSG = `
7
- Compliance Report Coming Soon
8
-
9
- ISO 27001, SOC 2, and TW Cyber Security Act compliance
10
- reports are under active development.
11
-
12
- Follow progress: https://github.com/panguard-ai/panguard-ai
13
-
14
- ---
15
- 合規報告 — 即將推出
16
-
17
- ISO 27001、SOC 2、資安管理法合規報告功能開發中。
18
-
19
- 追蹤進度: https://github.com/panguard-ai/panguard-ai
20
- `;
31
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
32
+ import { dirname, join, resolve, sep } from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { createRequire } from 'node:module';
35
+ import { createHash, createHmac } from 'node:crypto';
36
+ import { c, symbols } from '@panguard-ai/core';
37
+ const require = createRequire(import.meta.url);
38
+ const FRAMEWORKS = [
39
+ {
40
+ id: 'owasp-agentic',
41
+ yamlKey: 'owasp_agentic',
42
+ name: 'OWASP Agentic Top 10 (2026)',
43
+ authority: 'OWASP Foundation',
44
+ identifierField: 'id',
45
+ coverageStatus: 'partial',
46
+ },
47
+ {
48
+ id: 'owasp-llm',
49
+ yamlKey: 'owasp_llm',
50
+ name: 'OWASP LLM Top 10 (2025)',
51
+ authority: 'OWASP Foundation',
52
+ identifierField: 'id',
53
+ coverageStatus: 'partial',
54
+ },
55
+ {
56
+ id: 'eu-ai-act',
57
+ yamlKey: 'eu_ai_act',
58
+ name: 'EU AI Act (Regulation 2024/1689)',
59
+ authority: 'European Union',
60
+ enforcementDate: '2026-08-02',
61
+ identifierField: 'article',
62
+ coverageStatus: 'planned',
63
+ },
64
+ {
65
+ id: 'colorado-ai-act',
66
+ yamlKey: 'colorado_ai_act',
67
+ name: 'Colorado AI Act (SB24-205)',
68
+ authority: 'State of Colorado',
69
+ enforcementDate: '2026-06-30',
70
+ identifierField: 'section',
71
+ coverageStatus: 'planned',
72
+ },
73
+ {
74
+ id: 'nist-ai-rmf',
75
+ yamlKey: 'nist_ai_rmf',
76
+ name: 'NIST AI Risk Management Framework 1.0',
77
+ authority: 'NIST (US Dept of Commerce)',
78
+ identifierField: 'clause',
79
+ coverageStatus: 'planned',
80
+ },
81
+ {
82
+ id: 'iso-42001',
83
+ yamlKey: 'iso_42001',
84
+ name: 'ISO/IEC 42001:2023 AIMS',
85
+ authority: 'ISO / IEC',
86
+ identifierField: 'clause',
87
+ coverageStatus: 'planned',
88
+ },
89
+ ];
90
+ /**
91
+ * Locate the agent-threat-rules rule directory.
92
+ *
93
+ * Precedence:
94
+ * 1. $PANGUARD_ATR_RULES_DIR env var — for local development against an
95
+ * ATR repo checkout (useful when hacking on new compliance metadata
96
+ * before npm publish).
97
+ * 2. Resolve the agent-threat-rules package via Node module resolution.
98
+ * Works for global installs (`npm install -g @panguard-ai/panguard`)
99
+ * because `require.resolve` finds the bundled dep from the panguard
100
+ * package's own location, NOT from the user's pwd.
101
+ * 3. Cwd-relative fallbacks (monorepo dev / customer project with ATR
102
+ * installed locally).
103
+ */
104
+ function findRulesDir() {
105
+ const envDir = process.env['PANGUARD_ATR_RULES_DIR'];
106
+ if (envDir && existsSync(envDir) && statSync(envDir).isDirectory()) {
107
+ return envDir;
108
+ }
109
+ // Walk up from THIS module's own directory looking for
110
+ // node_modules/agent-threat-rules/rules. Mirrors Node's standard module
111
+ // resolution but works on a directory subpath that ESM `import` can't
112
+ // resolve directly (the agent-threat-rules package has an `exports` field
113
+ // that hides ./package.json). This is the path that makes
114
+ // `npm install -g @panguard-ai/panguard` work regardless of customer cwd.
115
+ try {
116
+ let dir = dirname(fileURLToPath(import.meta.url));
117
+ while (dir !== sep && dir.length > 0) {
118
+ const candidate = join(dir, 'node_modules', 'agent-threat-rules', 'rules');
119
+ if (existsSync(candidate) && statSync(candidate).isDirectory()) {
120
+ return candidate;
121
+ }
122
+ const parent = dirname(dir);
123
+ if (parent === dir)
124
+ break;
125
+ dir = parent;
126
+ }
127
+ }
128
+ catch {
129
+ // Fall through to cwd-relative candidates.
130
+ }
131
+ const candidates = [
132
+ resolve(process.cwd(), 'node_modules', 'agent-threat-rules', 'rules'),
133
+ resolve(process.cwd(), 'node_modules', '.pnpm', 'node_modules', 'agent-threat-rules', 'rules'),
134
+ resolve(process.cwd(), '..', '..', 'node_modules', 'agent-threat-rules', 'rules'),
135
+ ];
136
+ for (const p of candidates) {
137
+ if (existsSync(p) && statSync(p).isDirectory())
138
+ return p;
139
+ }
140
+ return null;
141
+ }
142
+ function collectYamlFiles(dir) {
143
+ const results = [];
144
+ for (const entry of readdirSync(dir)) {
145
+ const full = join(dir, entry);
146
+ const st = statSync(full);
147
+ if (st.isDirectory()) {
148
+ results.push(...collectYamlFiles(full));
149
+ }
150
+ else if (entry.endsWith('.yaml') || entry.endsWith('.yml')) {
151
+ results.push(full);
152
+ }
153
+ }
154
+ return results;
155
+ }
156
+ function parseRule(filePath) {
157
+ // Load js-yaml lazily — only needed when report runs
158
+ let yaml;
159
+ try {
160
+ yaml = require('js-yaml');
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ let doc;
166
+ try {
167
+ const raw = readFileSync(filePath, 'utf-8');
168
+ doc = yaml.load(raw);
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ if (!doc || typeof doc !== 'object' || !doc['id'])
174
+ return null;
175
+ const tags = doc['tags'] ?? {};
176
+ const compliance = doc['compliance'] ?? {};
177
+ // Normalise compliance entries per framework into uniform shape
178
+ const normalisedCompliance = {};
179
+ for (const fw of FRAMEWORKS) {
180
+ const raw = compliance[fw.yamlKey];
181
+ if (!Array.isArray(raw))
182
+ continue;
183
+ normalisedCompliance[fw.yamlKey] = raw
184
+ .filter((e) => typeof e === 'object' && e !== null)
185
+ .map((e) => {
186
+ const identifierField = fw.identifierField;
187
+ const rawId = identifierField === 'clause' && fw.id === 'nist-ai-rmf'
188
+ ? `${String(e['function'] ?? '')}.${String(e['subcategory'] ?? '')}`
189
+ : String(e[identifierField] ?? '');
190
+ return {
191
+ identifier: rawId,
192
+ clause: typeof e['clause'] === 'string' ? e['clause'] : undefined,
193
+ clauseName: typeof e['clause_name'] === 'string' ? e['clause_name'] : undefined,
194
+ context: String(e['context'] ?? ''),
195
+ strength: e['strength'] ?? 'primary',
196
+ };
197
+ })
198
+ .filter((e) => e.identifier && e.context);
199
+ }
200
+ return {
201
+ id: String(doc['id']),
202
+ title: String(doc['title'] ?? ''),
203
+ severity: String(doc['severity'] ?? 'unknown'),
204
+ status: String(doc['status'] ?? 'unknown'),
205
+ maturity: String(doc['maturity'] ?? 'unknown'),
206
+ category: String(tags['category'] ?? 'uncategorised'),
207
+ filePath,
208
+ compliance: normalisedCompliance,
209
+ };
210
+ }
211
+ function loadAllRules() {
212
+ const rulesDir = findRulesDir();
213
+ if (!rulesDir)
214
+ return { rules: [], rulesDir: null };
215
+ const files = collectYamlFiles(rulesDir).sort();
216
+ const rules = [];
217
+ for (const f of files) {
218
+ const r = parseRule(f);
219
+ if (r)
220
+ rules.push(r);
221
+ }
222
+ return { rules, rulesDir };
223
+ }
224
+ function buildCoverage(rules, fw) {
225
+ const byIdentifier = new Map();
226
+ let mappedRules = 0;
227
+ let totalMappings = 0;
228
+ for (const r of rules) {
229
+ const entries = r.compliance[fw.yamlKey];
230
+ if (!entries || entries.length === 0)
231
+ continue;
232
+ mappedRules++;
233
+ for (const e of entries) {
234
+ totalMappings++;
235
+ const key = e.identifier;
236
+ const existing = byIdentifier.get(key) ?? { count: 0, context: [] };
237
+ existing.count++;
238
+ existing.context.push(`${r.id}: ${e.context}`);
239
+ byIdentifier.set(key, existing);
240
+ }
241
+ }
242
+ return {
243
+ framework: fw,
244
+ totalRules: rules.length,
245
+ mappedRules,
246
+ totalMappings,
247
+ byIdentifier,
248
+ };
249
+ }
250
+ function renderMarkdown(coverage, orgName) {
251
+ const fw = coverage.framework;
252
+ const today = new Date().toISOString().slice(0, 10);
253
+ const sortedIds = Array.from(coverage.byIdentifier.keys()).sort();
254
+ const coveragePercent = coverage.totalRules > 0
255
+ ? ((coverage.mappedRules / coverage.totalRules) * 100).toFixed(1)
256
+ : '0.0';
257
+ const lines = [];
258
+ lines.push(`# AI Compliance Audit Evidence Report`);
259
+ lines.push('');
260
+ lines.push(`- **Framework**: ${fw.name}`);
261
+ lines.push(`- **Authority**: ${fw.authority}`);
262
+ if (fw.enforcementDate) {
263
+ lines.push(`- **Enforcement date**: ${fw.enforcementDate}`);
264
+ }
265
+ lines.push(`- **Organisation**: ${orgName}`);
266
+ lines.push(`- **Report date**: ${today}`);
267
+ lines.push(`- **ATR rules in set**: ${coverage.totalRules}`);
268
+ lines.push(`- **Rules mapped to this framework**: ${coverage.mappedRules} (${coveragePercent}%)`);
269
+ lines.push(`- **Total mappings (rule × article)**: ${coverage.totalMappings}`);
270
+ lines.push('');
271
+ lines.push(`---`);
272
+ lines.push('');
273
+ if (coverage.mappedRules === 0) {
274
+ lines.push(`> **Status: Mapping in progress.** The \`compliance.${fw.yamlKey}\` metadata block`);
275
+ lines.push(`> has not yet been authored for any rules in this ATR release. See`);
276
+ lines.push(`> [compliance-metadata.md](https://github.com/Agent-Threat-Rule/agent-threat-rules/blob/main/spec/compliance-metadata.md)`);
277
+ lines.push(`> for the target schema and the roll-out plan.`);
278
+ lines.push('');
279
+ return lines.join('\n');
280
+ }
281
+ lines.push(`## Mapping by ${fw.identifierField}`);
282
+ lines.push('');
283
+ for (const id of sortedIds) {
284
+ const detail = coverage.byIdentifier.get(id);
285
+ if (!detail)
286
+ continue;
287
+ lines.push(`### ${id}`);
288
+ lines.push(`*${detail.count} rule${detail.count === 1 ? '' : 's'} address this control*`);
289
+ lines.push('');
290
+ for (const line of detail.context) {
291
+ lines.push(`- ${line}`);
292
+ }
293
+ lines.push('');
294
+ }
295
+ lines.push(`---`);
296
+ lines.push('');
297
+ lines.push(`## Provenance`);
298
+ lines.push('');
299
+ lines.push(`Every mapping in this report originates from an ATR rule YAML file in the public MIT-licensed repository.`);
300
+ lines.push(`Each \`compliance:\` entry is a human-authored statement reviewed against the spec in \`spec/compliance-metadata.md\`.`);
301
+ lines.push('');
302
+ lines.push(`For traceability chain: ATR rule ID → \`compliance.${fw.yamlKey}\` block → identifier → rule file in the repo.`);
303
+ lines.push('');
304
+ lines.push(`**Limitations**: this is a *rule-coverage* report — which ATR rules claim to address which framework controls. A full audit also requires *event evidence* (which detections your deployment actually triggered during the audit period). See \`pga sensor status\` and the PanGuard Enterprise audit-log export for event-level evidence.`);
305
+ return lines.join('\n');
306
+ }
307
+ function renderJson(coverage, orgName) {
308
+ const fw = coverage.framework;
309
+ const byIdentifier = {};
310
+ for (const [k, v] of coverage.byIdentifier.entries()) {
311
+ byIdentifier[k] = v;
312
+ }
313
+ return JSON.stringify({
314
+ framework: fw,
315
+ organisation: orgName,
316
+ reportDate: new Date().toISOString(),
317
+ totalRules: coverage.totalRules,
318
+ mappedRules: coverage.mappedRules,
319
+ totalMappings: coverage.totalMappings,
320
+ byIdentifier,
321
+ }, null, 2);
322
+ }
323
+ // ─── PDF rendering (D1 Sprint 5) ─────────────────────────────────────
324
+ /**
325
+ * Render the coverage report as a PDF binary.
326
+ *
327
+ * Uses pdfkit (already in dependencies). The PDF mirrors the Markdown
328
+ * structure — header metadata, per-identifier rule mappings, provenance
329
+ * footer — and includes the report integrity hash on the cover page so
330
+ * auditors can verify the document hasn't been tampered with.
331
+ *
332
+ * Returns a Promise<Buffer> so async PDF streams finish before writing.
333
+ */
334
+ async function renderPdf(coverage, orgName, integrityHash, signature) {
335
+ // Lazy-load pdfkit so CLI startup stays fast when PDF isn't needed.
336
+ // Use dynamic import so the pdfkit types come through properly even
337
+ // when the module is optional at runtime.
338
+ const pdfkitMod = (await import('pdfkit'));
339
+ const PDFDocument = pdfkitMod.default;
340
+ const doc = new PDFDocument({
341
+ size: 'A4',
342
+ margin: 50,
343
+ info: {
344
+ Title: `AI Compliance Audit Evidence — ${coverage.framework.name}`,
345
+ Author: `PanGuard AI · AI Compliance Audit Evidence Module`,
346
+ Subject: `${orgName} — ${coverage.framework.name}`,
347
+ Keywords: `ATR, compliance, ${coverage.framework.yamlKey}, agent security`,
348
+ CreationDate: new Date(),
349
+ },
350
+ });
351
+ const chunks = [];
352
+ const done = new Promise((resolvePromise, rejectPromise) => {
353
+ doc.on('data', (chunk) => chunks.push(chunk));
354
+ doc.on('end', () => resolvePromise(Buffer.concat(chunks)));
355
+ doc.on('error', (e) => rejectPromise(e));
356
+ });
357
+ const fw = coverage.framework;
358
+ const today = new Date().toISOString().slice(0, 10);
359
+ const coveragePercent = coverage.totalRules > 0
360
+ ? ((coverage.mappedRules / coverage.totalRules) * 100).toFixed(1)
361
+ : '0.0';
362
+ // Cover page
363
+ doc.fontSize(20).font('Helvetica-Bold');
364
+ doc.text('AI Compliance Audit Evidence Report');
365
+ doc.moveDown(0.5);
366
+ doc.fontSize(12).font('Helvetica');
367
+ doc.text(`${fw.name}`);
368
+ doc.moveDown(1);
369
+ doc.fontSize(10).font('Helvetica');
370
+ const meta = [
371
+ ['Framework', fw.name],
372
+ ['Authority', fw.authority],
373
+ ...(fw.enforcementDate
374
+ ? [['Enforcement date', fw.enforcementDate]]
375
+ : []),
376
+ ['Organisation', orgName],
377
+ ['Report date', today],
378
+ ['ATR rules in set', String(coverage.totalRules)],
379
+ ['Rules mapped', `${coverage.mappedRules} (${coveragePercent}%)`],
380
+ ['Total mappings', String(coverage.totalMappings)],
381
+ ];
382
+ for (const [k, v] of meta) {
383
+ doc.font('Helvetica-Bold').text(`${k}: `, { continued: true });
384
+ doc.font('Helvetica').text(v);
385
+ }
386
+ doc.moveDown(1);
387
+ doc.font('Helvetica-Bold').text('Report integrity');
388
+ doc.moveDown(0.3);
389
+ doc.font('Courier').fontSize(8).text(`sha256: ${integrityHash}`);
390
+ if (signature) {
391
+ doc.text(`hmac: ${signature}`);
392
+ }
393
+ doc.font('Helvetica').fontSize(10).moveDown(1);
394
+ if (coverage.mappedRules === 0) {
395
+ doc
396
+ .font('Helvetica-Oblique')
397
+ .text(`Status: Mapping in progress. The compliance.${fw.yamlKey} metadata block has not yet been authored for any rules in this ATR release. See spec/compliance-metadata.md for the target schema and the roll-out plan.`);
398
+ doc.end();
399
+ return done;
400
+ }
401
+ // Mapping by identifier
402
+ doc.addPage();
403
+ doc.fontSize(14).font('Helvetica-Bold').text(`Mapping by ${fw.identifierField}`);
404
+ doc.moveDown(0.5);
405
+ const sortedIds = Array.from(coverage.byIdentifier.keys()).sort();
406
+ for (const id of sortedIds) {
407
+ const detail = coverage.byIdentifier.get(id);
408
+ if (!detail)
409
+ continue;
410
+ doc.fontSize(12).font('Helvetica-Bold').text(id);
411
+ doc
412
+ .fontSize(9)
413
+ .font('Helvetica-Oblique')
414
+ .text(`${detail.count} rule${detail.count === 1 ? '' : 's'} address this control`);
415
+ doc.moveDown(0.3);
416
+ doc.fontSize(10).font('Helvetica');
417
+ for (const line of detail.context) {
418
+ doc.text(` • ${line}`, { paragraphGap: 3 });
419
+ }
420
+ doc.moveDown(0.5);
421
+ }
422
+ // Provenance footer
423
+ doc.addPage();
424
+ doc.fontSize(14).font('Helvetica-Bold').text('Provenance');
425
+ doc.moveDown(0.5);
426
+ doc.fontSize(10).font('Helvetica');
427
+ doc.text('Every mapping in this report originates from an ATR rule YAML file in the public MIT-licensed repository.');
428
+ doc.moveDown(0.3);
429
+ doc.text(`Each compliance: entry is a human-authored statement reviewed against the spec in spec/compliance-metadata.md.`);
430
+ doc.moveDown(0.3);
431
+ doc.text(`For traceability chain: ATR rule ID → compliance.${fw.yamlKey} block → identifier → rule file in the repo.`);
432
+ doc.moveDown(1);
433
+ doc.font('Helvetica-Bold').text('Limitations');
434
+ doc.moveDown(0.3);
435
+ doc
436
+ .font('Helvetica')
437
+ .text('This is a rule-coverage report — which ATR rules claim to address which framework controls. A full audit also requires event evidence (which detections your deployment actually triggered during the audit period). See pga sensor status and the PanGuard Enterprise audit-log export for event-level evidence.');
438
+ doc.moveDown(1);
439
+ doc.font('Helvetica-Bold').fontSize(9).text('Integrity chain');
440
+ doc.font('Courier').fontSize(7);
441
+ doc.text(`sha256(report-canonical-json): ${integrityHash}`);
442
+ if (signature) {
443
+ doc.text(`hmac-sha256(report): ${signature}`);
444
+ }
445
+ doc.end();
446
+ return done;
447
+ }
448
+ // ─── Integrity hashing (D1 Sprint 5) ─────────────────────────────────
449
+ /**
450
+ * Compute a deterministic SHA-256 hash of the canonical report payload.
451
+ *
452
+ * The hash is computed over the JSON representation (which is
453
+ * deterministic for our controlled data shape) so the same inputs
454
+ * always produce the same hash regardless of format. This gives
455
+ * auditors a single identifier that binds the Markdown, JSON, and
456
+ * PDF outputs of the same report together.
457
+ */
458
+ function computeReportHash(coverage, orgName, reportDate) {
459
+ const byIdentifier = {};
460
+ const sorted = Array.from(coverage.byIdentifier.keys()).sort();
461
+ for (const k of sorted) {
462
+ const v = coverage.byIdentifier.get(k);
463
+ if (v)
464
+ byIdentifier[k] = { count: v.count, context: [...v.context].sort() };
465
+ }
466
+ const canonical = JSON.stringify({
467
+ framework: coverage.framework.id,
468
+ yamlKey: coverage.framework.yamlKey,
469
+ organisation: orgName,
470
+ reportDate,
471
+ totalRules: coverage.totalRules,
472
+ mappedRules: coverage.mappedRules,
473
+ totalMappings: coverage.totalMappings,
474
+ byIdentifier,
475
+ });
476
+ return createHash('sha256').update(canonical).digest('hex');
477
+ }
478
+ /**
479
+ * Sign the report hash with an HMAC-SHA256 key.
480
+ *
481
+ * The key comes from either --sign <key> or the
482
+ * PANGUARD_REPORT_SIGNING_KEY env var. Enterprise customers receive a
483
+ * dedicated signing key so an auditor can verify that a report PDF
484
+ * actually originated from their PanGuard deployment and wasn't tampered
485
+ * with in transit or during review.
486
+ */
487
+ function signReport(hash, key) {
488
+ return createHmac('sha256', key).update(hash).digest('hex');
489
+ }
490
+ // ─── CLI wiring ──────────────────────────────────────────────────────
491
+ function resolveFramework(id) {
492
+ return FRAMEWORKS.find((f) => f.id === id) ?? null;
493
+ }
494
+ function listFrameworksAction() {
495
+ const { rules, rulesDir } = loadAllRules();
496
+ console.log('');
497
+ console.log(` ${c.bold('AI COMPLIANCE FRAMEWORKS')}`);
498
+ console.log(` ${c.dim('─'.repeat(68))}`);
499
+ if (!rulesDir) {
500
+ console.log(` ${c.caution(symbols.warn)} Could not locate the agent-threat-rules package.`);
501
+ console.log(` ${c.dim('Install it or run from a monorepo with ATR rules available.')}`);
502
+ console.log('');
503
+ return;
504
+ }
505
+ console.log(` ${c.dim(`Loaded ${rules.length} ATR rules from`)} ${c.dim(rulesDir)}`);
506
+ console.log('');
507
+ for (const fw of FRAMEWORKS) {
508
+ const coverage = buildCoverage(rules, fw);
509
+ const pct = rules.length > 0 ? ((coverage.mappedRules / rules.length) * 100).toFixed(1) : '0.0';
510
+ const state = coverage.mappedRules === 0
511
+ ? c.caution(`planned`)
512
+ : coverage.mappedRules < rules.length / 2
513
+ ? c.caution(`partial`)
514
+ : c.safe(`shipped`);
515
+ console.log(` ${c.sage(fw.id.padEnd(18))} ${fw.name}`);
516
+ console.log(` ${' '.repeat(18)} ${c.dim(`${coverage.mappedRules}/${rules.length} rules mapped (${pct}%)`)} · ${state}`);
517
+ if (fw.enforcementDate) {
518
+ console.log(` ${' '.repeat(18)} ${c.dim('Enforcement:')} ${fw.enforcementDate}`);
519
+ }
520
+ console.log('');
521
+ }
522
+ console.log(` ${c.dim('Generate a report:')} ${c.sage('pga report generate --framework <id>')}`);
523
+ console.log('');
524
+ }
525
+ function summaryAction(opts) {
526
+ const fw = opts.framework ? resolveFramework(opts.framework) : null;
527
+ if (!fw) {
528
+ console.log(` ${c.caution(symbols.warn)} --framework <id> required. Run ${c.sage('pga report list-frameworks')} to see options.`);
529
+ return;
530
+ }
531
+ const { rules } = loadAllRules();
532
+ const coverage = buildCoverage(rules, fw);
533
+ const pct = rules.length > 0 ? ((coverage.mappedRules / rules.length) * 100).toFixed(1) : '0.0';
534
+ console.log('');
535
+ console.log(` ${c.bold(fw.name)}`);
536
+ console.log(` ${c.dim('─'.repeat(68))}`);
537
+ console.log(` ${c.dim('Authority:')} ${fw.authority}`);
538
+ if (fw.enforcementDate) {
539
+ console.log(` ${c.dim('Enforcement date:')} ${fw.enforcementDate}`);
540
+ }
541
+ console.log(` ${c.dim('ATR rules in set:')} ${rules.length}`);
542
+ console.log(` ${c.dim('Rules mapped:')} ${coverage.mappedRules} (${pct}%)`);
543
+ console.log(` ${c.dim('Total mappings:')} ${coverage.totalMappings}`);
544
+ console.log('');
545
+ if (coverage.byIdentifier.size > 0) {
546
+ console.log(` ${c.bold(`Coverage by ${fw.identifierField}`)}`);
547
+ const sorted = Array.from(coverage.byIdentifier.entries()).sort((a, b) => b[1].count - a[1].count);
548
+ for (const [id, detail] of sorted.slice(0, 10)) {
549
+ console.log(` ${c.sage(id.padEnd(18))} ${c.dim(`${detail.count} rule${detail.count === 1 ? '' : 's'}`)}`);
550
+ }
551
+ if (sorted.length > 10) {
552
+ console.log(` ${c.dim(`… and ${sorted.length - 10} more`)}`);
553
+ }
554
+ }
555
+ else {
556
+ console.log(` ${c.caution('No mappings authored yet for this framework. See spec/compliance-metadata.md.')}`);
557
+ }
558
+ console.log('');
559
+ }
560
+ async function generateAction(opts) {
561
+ const fw = opts.framework ? resolveFramework(opts.framework) : null;
562
+ if (!fw) {
563
+ console.log(` ${c.caution(symbols.warn)} --framework <id> required. Run ${c.sage('pga report list-frameworks')} to see options.`);
564
+ return;
565
+ }
566
+ const rawFormat = (opts.format ?? 'md').toLowerCase();
567
+ const format = rawFormat === 'json' ? 'json' : rawFormat === 'pdf' ? 'pdf' : 'md';
568
+ if (format === 'pdf' && !opts.output) {
569
+ console.log(` ${c.caution(symbols.warn)} --output <path.pdf> is required when --format=pdf (PDFs are binary — cannot stream to stdout).`);
570
+ return;
571
+ }
572
+ const orgName = opts.org ?? 'Your Organisation';
573
+ const { rules } = loadAllRules();
574
+ const coverage = buildCoverage(rules, fw);
575
+ const reportDate = new Date().toISOString();
576
+ const hash = computeReportHash(coverage, orgName, reportDate);
577
+ const signingKey = opts.sign ?? process.env['PANGUARD_REPORT_SIGNING_KEY'];
578
+ const signature = signingKey ? signReport(hash, signingKey) : null;
579
+ if (format === 'pdf') {
580
+ // PDF is binary — must go to a file, never stdout. Validated above.
581
+ const buf = await renderPdf(coverage, orgName, hash, signature);
582
+ const pdfPath = opts.output;
583
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
584
+ writeFileSync(pdfPath, buf);
585
+ // Write sidecar .hash file with integrity metadata — auditors verify against this
586
+ const hashSidecar = `${pdfPath}.hash`;
587
+ const sidecarContent = [
588
+ `# PanGuard AI Compliance Report — integrity sidecar`,
589
+ `framework: ${fw.id}`,
590
+ `organisation: ${orgName}`,
591
+ `report_date: ${reportDate}`,
592
+ `sha256: ${hash}`,
593
+ ...(signature ? [`hmac_sha256: ${signature}`] : []),
594
+ `pdf_bytes: ${buf.length}`,
595
+ ].join('\n');
596
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
597
+ writeFileSync(hashSidecar, sidecarContent + '\n', 'utf-8');
598
+ console.log(` ${c.safe(symbols.pass)} Wrote PDF report to ${pdfPath} (${buf.length} bytes)`);
599
+ console.log(` ${c.safe(symbols.pass)} Wrote integrity sidecar to ${hashSidecar}`);
600
+ console.log(` ${c.dim('sha256:')} ${hash}`);
601
+ if (signature)
602
+ console.log(` ${c.dim('hmac-sha256:')} ${signature}`);
603
+ return;
604
+ }
605
+ const baseContent = format === 'json' ? renderJson(coverage, orgName) : renderMarkdown(coverage, orgName);
606
+ const footer = format === 'json'
607
+ ? ''
608
+ : [
609
+ '',
610
+ '---',
611
+ '',
612
+ '## Report integrity',
613
+ '',
614
+ `- \`sha256(report-canonical-json)\`: \`${hash}\``,
615
+ ...(signature ? [`- \`hmac-sha256(report)\`: \`${signature}\``] : []),
616
+ '',
617
+ `Recompute locally with \`pga report generate --framework ${fw.id}\` and compare the hash — any drift means the evidence was modified after export.`,
618
+ '',
619
+ ].join('\n');
620
+ const content = format === 'json'
621
+ ? JSON.stringify({
622
+ ...JSON.parse(baseContent),
623
+ integrity: { sha256: hash, ...(signature ? { hmac_sha256: signature } : {}) },
624
+ }, null, 2)
625
+ : baseContent + footer;
626
+ if (opts.output) {
627
+ // opts.output is a user-provided absolute or relative path we write to
628
+ writeFileSync(opts.output, content, 'utf-8'); // eslint-disable-line security/detect-non-literal-fs-filename
629
+ console.log(` ${c.safe(symbols.pass)} Wrote ${format.toUpperCase()} report to ${opts.output}`);
630
+ console.log(` ${c.dim('sha256:')} ${hash}`);
631
+ if (signature)
632
+ console.log(` ${c.dim('hmac-sha256:')} ${signature}`);
633
+ }
634
+ else {
635
+ process.stdout.write(content + '\n');
636
+ }
637
+ }
638
+ function validateAction() {
639
+ const { rules, rulesDir } = loadAllRules();
640
+ if (!rulesDir) {
641
+ console.log(` ${c.caution(symbols.warn)} Could not locate ATR rules.`);
642
+ return;
643
+ }
644
+ let errors = 0;
645
+ let mapped = 0;
646
+ let unmapped = 0;
647
+ for (const r of rules) {
648
+ const hasAny = Object.values(r.compliance).some((v) => v.length > 0);
649
+ if (hasAny)
650
+ mapped++;
651
+ else
652
+ unmapped++;
653
+ for (const fw of FRAMEWORKS) {
654
+ const entries = r.compliance[fw.yamlKey];
655
+ if (!entries)
656
+ continue;
657
+ for (const e of entries) {
658
+ if (!e.identifier) {
659
+ console.log(` ${c.critical(symbols.fail)} ${r.id}: missing ${fw.identifierField} in ${fw.yamlKey} entry`);
660
+ errors++;
661
+ }
662
+ if (!e.context || e.context.length < 20) {
663
+ console.log(` ${c.caution(symbols.warn)} ${r.id}: ${fw.yamlKey} entry for ${e.identifier} has no / short context (<20 chars)`);
664
+ }
665
+ }
666
+ }
667
+ }
668
+ console.log('');
669
+ console.log(` ${c.bold('VALIDATION SUMMARY')}`);
670
+ console.log(` ${c.dim('Rules total:')} ${rules.length}`);
671
+ console.log(` ${c.sage('Rules mapped:')} ${mapped}`);
672
+ console.log(` ${c.caution('Rules unmapped:')} ${unmapped}`);
673
+ console.log(errors > 0
674
+ ? ` ${c.critical('Errors:')} ${errors}`
675
+ : ` ${c.safe('Errors:')} 0`);
676
+ console.log('');
677
+ }
21
678
  export function reportCommand() {
22
- const cmd = new Command('report').description('[Coming Soon] Compliance report generation / [即將推出] 合規報告產生');
679
+ const cmd = new Command('report').description('AI Compliance Audit Evidence report generator');
23
680
  cmd
24
- .command('generate')
25
- .description('Generate a compliance report')
26
- .option('--framework <name>', 'Compliance framework (iso27001, soc2, tw_cyber_security_act)')
27
- .option('--language <lang>', 'Report language (en, zh-TW)')
28
- .option('--format <fmt>', 'Output format (json, pdf)')
29
- .option('--output-dir <path>', 'Output directory')
30
- .option('--org <name>', 'Organization name')
31
- .option('--input <file>', 'Findings input file (JSON)')
32
- .action(async () => {
33
- console.log(COMING_SOON_MSG);
34
- });
681
+ .command('list-frameworks')
682
+ .description('List supported compliance frameworks + coverage status')
683
+ .action(() => listFrameworksAction());
35
684
  cmd
36
685
  .command('summary')
37
- .description('Show brief compliance summary')
38
- .option('--framework <name>', 'Compliance framework')
39
- .option('--language <lang>', 'Report language')
40
- .option('--input <file>', 'Findings input file (JSON)')
41
- .action(async () => {
42
- console.log(COMING_SOON_MSG);
43
- });
686
+ .description('Show compliance coverage summary for one framework')
687
+ .option('--framework <id>', 'Framework id — see: pga report list-frameworks')
688
+ .action((opts) => summaryAction(opts));
44
689
  cmd
45
- .command('list-frameworks')
46
- .description('List supported compliance frameworks')
47
- .action(async () => {
48
- console.log(COMING_SOON_MSG);
690
+ .command('generate')
691
+ .description('Generate a Markdown, JSON, or PDF compliance evidence report')
692
+ .option('--framework <id>', 'Framework id')
693
+ .option('--format <fmt>', 'Output format: md (default) | json | pdf')
694
+ .option('--output <path>', 'Write report to file instead of stdout (required for pdf)')
695
+ .option('--org <name>', 'Organisation name for the report header')
696
+ .option('--sign <key>', 'HMAC-SHA256 key for report signing (or set PANGUARD_REPORT_SIGNING_KEY env var)')
697
+ .action(async (opts) => {
698
+ try {
699
+ await generateAction(opts);
700
+ }
701
+ catch (err) {
702
+ console.error(` ${c.critical(symbols.fail)} ${err instanceof Error ? err.message : err}`);
703
+ process.exitCode = 1;
704
+ }
49
705
  });
50
706
  cmd
51
707
  .command('validate')
52
- .description('Validate findings input file')
53
- .option('--input <file>', 'Input file path')
54
- .action(async () => {
55
- console.log(COMING_SOON_MSG);
56
- });
708
+ .description('Validate all compliance: blocks in the ATR rule set')
709
+ .action(() => validateAction());
57
710
  return cmd;
58
711
  }
59
712
  //# sourceMappingURL=report.js.map