create-qa-architect 5.13.5 → 5.14.0

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.
@@ -0,0 +1,668 @@
1
+ /**
2
+ * Vibe-Code Audit — security scan for AI-generated codebases
3
+ *
4
+ * Runs semgrep (SAST) + npm audit (CVEs) + hallucination check (Pro)
5
+ * and produces a structured Critical/High/Medium/Low report.
6
+ *
7
+ * Free: semgrep with both rule files + npm audit
8
+ * Pro: + hallucinated package detection (npm registry check)
9
+ * + --fix flag generates Claude Code prompts per finding
10
+ *
11
+ * All external process invocations use spawnSync with argument arrays (no shell).
12
+ */
13
+
14
+ 'use strict'
15
+
16
+ const fs = require('fs')
17
+ const path = require('path')
18
+ const https = require('https')
19
+ const { spawnSync } = require('child_process')
20
+ const {
21
+ hasFeature,
22
+ showUpgradeMessage,
23
+ ensureLicenseFresh,
24
+ } = require('../licensing')
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Constants
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const SEVERITY = {
31
+ CRITICAL: 'critical',
32
+ HIGH: 'high',
33
+ MEDIUM: 'medium',
34
+ LOW: 'low',
35
+ INFO: 'info',
36
+ }
37
+
38
+ const SEVERITY_ORDER = [
39
+ SEVERITY.CRITICAL,
40
+ SEVERITY.HIGH,
41
+ SEVERITY.MEDIUM,
42
+ SEVERITY.LOW,
43
+ SEVERITY.INFO,
44
+ ]
45
+
46
+ const SEMGREP_TO_SEVERITY = {
47
+ ERROR: SEVERITY.HIGH,
48
+ WARNING: SEVERITY.MEDIUM,
49
+ INFO: SEVERITY.LOW,
50
+ }
51
+
52
+ // OWASP categories that map to Critical (escalate from ERROR)
53
+ const CRITICAL_CWE = new Set([
54
+ 'CWE-89', // SQL injection
55
+ 'CWE-78', // Command injection
56
+ 'CWE-798', // Hardcoded credentials
57
+ 'CWE-639', // IDOR
58
+ 'CWE-95', // Eval injection
59
+ ])
60
+
61
+ const SEVERITY_ICON = {
62
+ [SEVERITY.CRITICAL]: '🚨',
63
+ [SEVERITY.HIGH]: '❌',
64
+ [SEVERITY.MEDIUM]: '⚠️ ',
65
+ [SEVERITY.LOW]: '💡',
66
+ [SEVERITY.INFO]: 'ℹ️ ',
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Semgrep detection
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function detectSemgrep() {
74
+ const result = spawnSync('semgrep', ['--version'], {
75
+ encoding: 'utf8',
76
+ timeout: 10_000,
77
+ stdio: ['ignore', 'pipe', 'pipe'],
78
+ shell: false,
79
+ })
80
+ if (result.error || result.status !== 0) return null
81
+ const version = (result.stdout || '').trim().split('\n')[0]
82
+ return version || 'installed'
83
+ }
84
+
85
+ function semgrepInstallHint() {
86
+ return [
87
+ '',
88
+ ' semgrep is not installed. Install it to enable code-pattern scanning:',
89
+ '',
90
+ ' pip install semgrep # Python (recommended)',
91
+ ' brew install semgrep # macOS Homebrew',
92
+ ' npm install -g @semgrep/semgrep # npm (slower)',
93
+ '',
94
+ ' Then re-run: npx create-qa-architect@latest --audit',
95
+ '',
96
+ ].join('\n')
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Semgrep runner
101
+ // ---------------------------------------------------------------------------
102
+
103
+ function runSemgrep(projectPath, ruleFiles) {
104
+ const args = ['--json', '--quiet', '--no-git-ignore']
105
+ for (const f of ruleFiles) {
106
+ args.push('--config', f)
107
+ }
108
+ args.push('.')
109
+
110
+ const result = spawnSync('semgrep', args, {
111
+ cwd: projectPath,
112
+ encoding: 'utf8',
113
+ timeout: 120_000,
114
+ stdio: ['ignore', 'pipe', 'pipe'],
115
+ shell: false,
116
+ })
117
+
118
+ if (result.error) {
119
+ if (result.error.code === 'ENOENT') return { error: 'not_installed' }
120
+ if (result.error.code === 'ETIMEDOUT') return { error: 'timeout' }
121
+ return { error: result.error.message }
122
+ }
123
+
124
+ // semgrep exits 1 when findings exist — that's normal
125
+ if (!result.stdout) return { findings: [] }
126
+
127
+ try {
128
+ const parsed = JSON.parse(result.stdout)
129
+ return { findings: parsed.results || [] }
130
+ } catch {
131
+ return { error: 'parse_error', raw: result.stdout.slice(0, 500) }
132
+ }
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Semgrep finding → structured finding
137
+ // ---------------------------------------------------------------------------
138
+
139
+ function mapSemgrepFinding(raw) {
140
+ const cwe = raw.extra?.metadata?.cwe || ''
141
+ const baseSeverity =
142
+ SEMGREP_TO_SEVERITY[raw.extra?.severity?.toUpperCase()] || SEVERITY.MEDIUM
143
+
144
+ // Escalate to CRITICAL for high-impact CWEs
145
+ const severity = CRITICAL_CWE.has(cwe) ? SEVERITY.CRITICAL : baseSeverity
146
+
147
+ const fix = raw.extra?.metadata?.fix || null
148
+ const note = raw.extra?.metadata?.note || null
149
+
150
+ return {
151
+ id: raw.check_id,
152
+ severity,
153
+ file: raw.path,
154
+ line: raw.start?.line ?? 0,
155
+ endLine: raw.end?.line ?? 0,
156
+ message: (raw.extra?.message || raw.message || '').trim(),
157
+ fix,
158
+ note,
159
+ cwe,
160
+ owasp: raw.extra?.metadata?.owasp || '',
161
+ source: 'semgrep',
162
+ }
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // npm audit runner
167
+ // ---------------------------------------------------------------------------
168
+
169
+ function runNpmAudit(projectPath) {
170
+ const pkgPath = path.join(projectPath, 'package.json')
171
+ if (!fs.existsSync(pkgPath)) return []
172
+
173
+ const result = spawnSync(
174
+ 'npm',
175
+ ['audit', '--json', '--audit-level', 'none'],
176
+ {
177
+ cwd: projectPath,
178
+ encoding: 'utf8',
179
+ timeout: 60_000,
180
+ stdio: ['ignore', 'pipe', 'pipe'],
181
+ shell: false,
182
+ }
183
+ )
184
+
185
+ if (result.error || !result.stdout) return []
186
+
187
+ try {
188
+ const data = JSON.parse(result.stdout)
189
+ const findings = []
190
+
191
+ // npm v7+ audit JSON format
192
+ const vulns = data.vulnerabilities || {}
193
+ for (const [pkgName, vuln] of Object.entries(vulns)) {
194
+ const severity = mapNpmSeverity(vuln.severity)
195
+ const via = Array.isArray(vuln.via)
196
+ ? vuln.via
197
+ .filter(v => typeof v === 'object')
198
+ .map(v => v.title || v.url || '')
199
+ .filter(Boolean)
200
+ : []
201
+ findings.push({
202
+ id: `npm-audit-${pkgName}`,
203
+ severity,
204
+ file: 'package.json',
205
+ line: 0,
206
+ message: `${pkgName}@${vuln.range || 'unknown'}: ${via[0] || vuln.severity + ' severity vulnerability'}`,
207
+ fix: vuln.fixAvailable
208
+ ? typeof vuln.fixAvailable === 'object'
209
+ ? `npm install ${vuln.fixAvailable.name}@${vuln.fixAvailable.version}`
210
+ : 'npm audit fix'
211
+ : 'No automatic fix available — check for alternative package',
212
+ cwe: '',
213
+ owasp: '',
214
+ source: 'npm-audit',
215
+ })
216
+ }
217
+ return findings
218
+ } catch {
219
+ return []
220
+ }
221
+ }
222
+
223
+ function mapNpmSeverity(severity) {
224
+ const map = {
225
+ critical: SEVERITY.CRITICAL,
226
+ high: SEVERITY.HIGH,
227
+ moderate: SEVERITY.MEDIUM,
228
+ low: SEVERITY.LOW,
229
+ info: SEVERITY.INFO,
230
+ }
231
+ return map[severity?.toLowerCase()] || SEVERITY.MEDIUM
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Hallucinated package check (Pro)
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function checkHallucinatedPackages(projectPath) {
239
+ const pkgPath = path.join(projectPath, 'package.json')
240
+ if (!fs.existsSync(pkgPath)) return Promise.resolve([])
241
+
242
+ let pkg
243
+ try {
244
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
245
+ } catch {
246
+ return Promise.resolve([])
247
+ }
248
+
249
+ const allDeps = {
250
+ ...pkg.dependencies,
251
+ ...pkg.devDependencies,
252
+ }
253
+
254
+ const packageNames = Object.keys(allDeps)
255
+ if (packageNames.length === 0) return Promise.resolve([])
256
+
257
+ // Check up to 50 packages to avoid rate limits
258
+ const toCheck = packageNames.slice(0, 50)
259
+
260
+ const checks = toCheck.map(name => checkNpmRegistry(name))
261
+
262
+ return Promise.all(checks).then(results => {
263
+ const findings = []
264
+ results.forEach((exists, i) => {
265
+ if (!exists) {
266
+ findings.push({
267
+ id: `hallucinated-package-${toCheck[i]}`,
268
+ severity: SEVERITY.CRITICAL,
269
+ file: 'package.json',
270
+ line: 0,
271
+ message: `"${toCheck[i]}" does not exist on npm registry — possible hallucinated package (slopsquatting risk)`,
272
+ fix: `Remove "${toCheck[i]}" from dependencies and find a verified replacement`,
273
+ cwe: 'CWE-1104',
274
+ owasp: 'A06:2021',
275
+ source: 'hallucination-check',
276
+ })
277
+ }
278
+ })
279
+ return findings
280
+ })
281
+ }
282
+
283
+ function checkNpmRegistry(packageName) {
284
+ return new Promise(resolve => {
285
+ // Scoped packages: @org/name → encode for URL
286
+ const encoded = encodeURIComponent(packageName)
287
+ .replace('%40', '@')
288
+ .replace('%2F', '%2F')
289
+ const url = `https://registry.npmjs.org/${encoded}`
290
+
291
+ const req = https.get(
292
+ url,
293
+ {
294
+ headers: { Accept: 'application/json' },
295
+ timeout: 8000,
296
+ },
297
+ res => {
298
+ resolve(res.statusCode !== 404)
299
+ res.resume()
300
+ }
301
+ )
302
+ req.on('error', () => resolve(true)) // Network error = assume exists (avoid false positives)
303
+ req.on('timeout', () => {
304
+ req.destroy()
305
+ resolve(true)
306
+ })
307
+ })
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Report formatting
312
+ // ---------------------------------------------------------------------------
313
+
314
+ function groupBySeverity(findings) {
315
+ const grouped = {}
316
+ for (const sev of SEVERITY_ORDER) {
317
+ grouped[sev] = []
318
+ }
319
+ for (const f of findings) {
320
+ const sev = SEVERITY_ORDER.includes(f.severity) ? f.severity : SEVERITY.LOW
321
+ grouped[sev].push(f)
322
+ }
323
+ return grouped
324
+ }
325
+
326
+ function buildHumanReport(findings, options = {}) {
327
+ const grouped = groupBySeverity(findings)
328
+ const totalCritical = grouped[SEVERITY.CRITICAL].length
329
+ const totalHigh = grouped[SEVERITY.HIGH].length
330
+ const total = findings.length
331
+
332
+ const lines = []
333
+
334
+ lines.push('')
335
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
336
+ lines.push(' QA Architect — Vibe-Code Security Audit')
337
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
338
+ lines.push('')
339
+
340
+ if (total === 0) {
341
+ lines.push(' ✅ No security issues found.')
342
+ lines.push('')
343
+ lines.push(' Run periodically as your codebase grows.')
344
+ lines.push('')
345
+ return lines.join('\n')
346
+ }
347
+
348
+ // Summary line
349
+ const verdict =
350
+ totalCritical > 0
351
+ ? '🚨 NOT SAFE TO SHIP'
352
+ : totalHigh > 0
353
+ ? '⚠️ REVIEW BEFORE SHIPPING'
354
+ : '💛 MINOR ISSUES'
355
+ lines.push(` ${verdict}`)
356
+ lines.push('')
357
+ lines.push(` Total findings: ${total}`)
358
+ if (grouped[SEVERITY.CRITICAL].length)
359
+ lines.push(` 🚨 Critical: ${grouped[SEVERITY.CRITICAL].length}`)
360
+ if (grouped[SEVERITY.HIGH].length)
361
+ lines.push(` ❌ High: ${grouped[SEVERITY.HIGH].length}`)
362
+ if (grouped[SEVERITY.MEDIUM].length)
363
+ lines.push(` ⚠️ Medium: ${grouped[SEVERITY.MEDIUM].length}`)
364
+ if (grouped[SEVERITY.LOW].length)
365
+ lines.push(` 💡 Low: ${grouped[SEVERITY.LOW].length}`)
366
+ lines.push('')
367
+
368
+ for (const sev of SEVERITY_ORDER) {
369
+ const sevFindings = grouped[sev]
370
+ if (sevFindings.length === 0) continue
371
+
372
+ const label = sev.toUpperCase()
373
+ lines.push(` ${SEVERITY_ICON[sev]} ${label} (${sevFindings.length})`)
374
+ lines.push(' ' + '─'.repeat(55))
375
+
376
+ for (const f of sevFindings) {
377
+ const loc = f.line > 0 ? `${f.file}:${f.line}` : f.file
378
+ lines.push(` ${loc}`)
379
+ lines.push(` ${f.message}`)
380
+ if (f.fix) lines.push(` → Fix: ${f.fix}`)
381
+ if (f.note) lines.push(` ℹ️ ${f.note}`)
382
+ if (options.showIds && f.id) lines.push(` [${f.id}]`)
383
+ lines.push('')
384
+ }
385
+ }
386
+
387
+ if (options.fix && findings.length > 0) {
388
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
389
+ lines.push(' CLAUDE CODE FIX PROMPTS')
390
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
391
+ lines.push('')
392
+
393
+ const toFix = findings
394
+ .filter(f => [SEVERITY.CRITICAL, SEVERITY.HIGH].includes(f.severity))
395
+ .slice(0, 10)
396
+
397
+ for (const f of toFix) {
398
+ lines.push(` ── ${f.file}${f.line > 0 ? ':' + f.line : ''} ──`)
399
+ lines.push(' Copy this prompt into Claude Code:')
400
+ lines.push('')
401
+ lines.push(' """')
402
+ lines.push(buildClaudePrompt(f))
403
+ lines.push(' """')
404
+ lines.push('')
405
+ }
406
+ }
407
+
408
+ lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
409
+ lines.push('')
410
+
411
+ return lines.join('\n')
412
+ }
413
+
414
+ function buildMarkdownReport(findings, options = {}) {
415
+ const grouped = groupBySeverity(findings)
416
+ const total = findings.length
417
+ const totalCritical = grouped[SEVERITY.CRITICAL].length
418
+ const totalHigh = grouped[SEVERITY.HIGH].length
419
+
420
+ const verdict =
421
+ totalCritical > 0
422
+ ? '🚨 **NOT SAFE TO SHIP**'
423
+ : totalHigh > 0
424
+ ? '⚠️ **REVIEW BEFORE SHIPPING**'
425
+ : total > 0
426
+ ? '💛 **MINOR ISSUES**'
427
+ : '✅ **SAFE TO SHIP**'
428
+
429
+ const lines = []
430
+
431
+ lines.push('## QA Architect — Vibe-Code Security Audit')
432
+ lines.push('')
433
+ lines.push(`**Verdict:** ${verdict}`)
434
+ lines.push('')
435
+ lines.push('| Severity | Count |')
436
+ lines.push('|---|---|')
437
+ if (grouped[SEVERITY.CRITICAL].length)
438
+ lines.push(`| 🚨 Critical | ${grouped[SEVERITY.CRITICAL].length} |`)
439
+ if (grouped[SEVERITY.HIGH].length)
440
+ lines.push(`| ❌ High | ${grouped[SEVERITY.HIGH].length} |`)
441
+ if (grouped[SEVERITY.MEDIUM].length)
442
+ lines.push(`| ⚠️ Medium | ${grouped[SEVERITY.MEDIUM].length} |`)
443
+ if (grouped[SEVERITY.LOW].length)
444
+ lines.push(`| 💡 Low | ${grouped[SEVERITY.LOW].length} |`)
445
+ if (total === 0) lines.push('| ✅ None | 0 |')
446
+ lines.push('')
447
+
448
+ for (const sev of SEVERITY_ORDER) {
449
+ const sevFindings = grouped[sev]
450
+ if (sevFindings.length === 0) continue
451
+
452
+ lines.push(
453
+ `### ${SEVERITY_ICON[sev]} ${sev.charAt(0).toUpperCase() + sev.slice(1)}`
454
+ )
455
+ lines.push('')
456
+
457
+ for (const f of sevFindings) {
458
+ const loc = f.line > 0 ? `\`${f.file}:${f.line}\`` : `\`${f.file}\``
459
+ lines.push(`**${loc}**`)
460
+ lines.push('')
461
+ lines.push(f.message)
462
+ if (f.fix) lines.push('')
463
+ if (f.fix) lines.push(`**Fix:** ${f.fix}`)
464
+ if (f.note) lines.push(`> ${f.note}`)
465
+ if (f.cwe) lines.push(`_${f.cwe}_${f.owasp ? ` · ${f.owasp}` : ''}`)
466
+ lines.push('')
467
+ }
468
+ }
469
+
470
+ if (options.fix && findings.length > 0) {
471
+ lines.push('---')
472
+ lines.push('## Claude Code Fix Prompts')
473
+ lines.push('')
474
+ const toFix = findings
475
+ .filter(f => [SEVERITY.CRITICAL, SEVERITY.HIGH].includes(f.severity))
476
+ .slice(0, 10)
477
+
478
+ for (const f of toFix) {
479
+ lines.push(`### ${f.file}${f.line > 0 ? ':' + f.line : ''}`)
480
+ lines.push('')
481
+ lines.push('```')
482
+ lines.push(buildClaudePrompt(f))
483
+ lines.push('```')
484
+ lines.push('')
485
+ }
486
+ }
487
+
488
+ return lines.join('\n')
489
+ }
490
+
491
+ function buildClaudePrompt(finding) {
492
+ const lines = [
493
+ `Fix a security issue in ${finding.file}${finding.line > 0 ? ' at line ' + finding.line : ''}.`,
494
+ '',
495
+ `Issue: ${finding.message}`,
496
+ ]
497
+ if (finding.cwe)
498
+ lines.push(
499
+ `Category: ${finding.cwe}${finding.owasp ? ' (' + finding.owasp + ')' : ''}`
500
+ )
501
+ if (finding.fix) {
502
+ lines.push('')
503
+ lines.push(`Recommended fix: ${finding.fix}`)
504
+ }
505
+ lines.push('')
506
+ lines.push('Please fix this issue while preserving existing functionality.')
507
+ return lines.join('\n')
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // Main audit orchestrator
512
+ // ---------------------------------------------------------------------------
513
+
514
+ async function runAudit(projectPath, options = {}) {
515
+ const semgrepVersion = detectSemgrep()
516
+
517
+ if (!semgrepVersion) {
518
+ return {
519
+ error: 'semgrep_not_installed',
520
+ hint: semgrepInstallHint(),
521
+ }
522
+ }
523
+
524
+ // Rule files — relative to this file's location
525
+ const semgrepDir = path.resolve(__dirname, '../../.semgrep')
526
+ const ruleFiles = [
527
+ path.join(semgrepDir, 'defensive-patterns.yaml'),
528
+ path.join(semgrepDir, 'vibe-audit-rules.yaml'),
529
+ ].filter(f => fs.existsSync(f))
530
+
531
+ if (ruleFiles.length === 0) {
532
+ return {
533
+ error: 'no_rules',
534
+ hint: 'Semgrep rule files not found. Reinstall create-qa-architect.',
535
+ }
536
+ }
537
+
538
+ // Run semgrep
539
+ const semgrepResult = runSemgrep(projectPath, ruleFiles)
540
+ if (semgrepResult.error) {
541
+ return { error: semgrepResult.error }
542
+ }
543
+
544
+ const findings = semgrepResult.findings.map(mapSemgrepFinding)
545
+
546
+ // Run npm audit
547
+ const npmFindings = runNpmAudit(projectPath)
548
+ findings.push(...npmFindings)
549
+
550
+ // Hallucination check (Pro only)
551
+ if (options.pro) {
552
+ const hallucinated = await checkHallucinatedPackages(projectPath)
553
+ findings.push(...hallucinated)
554
+ }
555
+
556
+ // Sort: critical first, then by file path
557
+ findings.sort((a, b) => {
558
+ const ai = SEVERITY_ORDER.indexOf(a.severity)
559
+ const bi = SEVERITY_ORDER.indexOf(b.severity)
560
+ if (ai !== bi) return ai - bi
561
+ return a.file.localeCompare(b.file)
562
+ })
563
+
564
+ return { findings, semgrepVersion }
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // Entry point
569
+ // ---------------------------------------------------------------------------
570
+
571
+ async function handleAudit(options = {}) {
572
+ const projectPath = options.projectPath || process.cwd()
573
+ const isJson = options.json || false
574
+ const outPath = options.outPath || null
575
+ const wantFix = options.fix || false
576
+
577
+ // Basic audit is free; only the Pro --fix path needs a license re-check.
578
+ if (wantFix) {
579
+ await ensureLicenseFresh()
580
+ }
581
+
582
+ // Feature gate: audit is free for basic, Pro for hallucination check
583
+ const isPro = hasFeature('auditPro')
584
+ const auditOptions = { pro: isPro, fix: wantFix }
585
+
586
+ if (wantFix && !isPro) {
587
+ showUpgradeMessage('Audit --fix (Claude Code prompt generation)')
588
+ // Continue without fix prompts rather than blocking
589
+ auditOptions.fix = false
590
+ }
591
+
592
+ const result = await runAudit(projectPath, auditOptions)
593
+
594
+ if (result.error === 'semgrep_not_installed') {
595
+ console.error('❌ semgrep is not installed.')
596
+ console.error(result.hint)
597
+ process.exit(1)
598
+ }
599
+
600
+ if (result.error) {
601
+ console.error(`❌ Audit error: ${result.error}`)
602
+ process.exit(1)
603
+ }
604
+
605
+ const { findings } = result
606
+
607
+ if (isJson) {
608
+ const output = JSON.stringify(
609
+ {
610
+ summary: {
611
+ total: findings.length,
612
+ critical: findings.filter(f => f.severity === SEVERITY.CRITICAL)
613
+ .length,
614
+ high: findings.filter(f => f.severity === SEVERITY.HIGH).length,
615
+ medium: findings.filter(f => f.severity === SEVERITY.MEDIUM).length,
616
+ low: findings.filter(f => f.severity === SEVERITY.LOW).length,
617
+ },
618
+ findings,
619
+ },
620
+ null,
621
+ 2
622
+ )
623
+ if (outPath) {
624
+ fs.writeFileSync(outPath, output, 'utf8')
625
+ console.log(`✅ Audit report written to ${outPath}`)
626
+ } else {
627
+ console.log(output)
628
+ }
629
+ } else {
630
+ const report = outPath
631
+ ? buildMarkdownReport(findings, auditOptions)
632
+ : buildHumanReport(findings, auditOptions)
633
+
634
+ if (outPath) {
635
+ fs.writeFileSync(outPath, report, 'utf8')
636
+ console.log(`✅ Audit report written to ${outPath}`)
637
+ // Also print summary to stdout
638
+ const total = findings.length
639
+ const critical = findings.filter(
640
+ f => f.severity === SEVERITY.CRITICAL
641
+ ).length
642
+ const high = findings.filter(f => f.severity === SEVERITY.HIGH).length
643
+ console.log(` ${total} finding(s): ${critical} critical, ${high} high`)
644
+ } else {
645
+ console.log(report)
646
+ }
647
+ }
648
+
649
+ const hasCritical = findings.some(f => f.severity === SEVERITY.CRITICAL)
650
+ const hasHigh = findings.some(f => f.severity === SEVERITY.HIGH)
651
+
652
+ if (options.noFail) {
653
+ process.exit(0)
654
+ } else if (hasCritical || hasHigh) {
655
+ process.exit(1)
656
+ } else {
657
+ process.exit(0)
658
+ }
659
+ }
660
+
661
+ module.exports = {
662
+ handleAudit,
663
+ runAudit,
664
+ mapSemgrepFinding,
665
+ groupBySeverity,
666
+ buildMarkdownReport,
667
+ buildHumanReport,
668
+ }