@boyingliu01/xp-gate 0.33.0 → 0.33.2

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 (48) hide show
  1. package/gate-3.sh +48 -7
  2. package/gate-8.sh +32 -3
  3. package/gate-9.sh +14 -2
  4. package/gates/common-process.ts +178 -0
  5. package/gates/common.ts +208 -0
  6. package/gates/gate-3.ts +144 -0
  7. package/gates/gate-7.ts +147 -0
  8. package/gates/gate-8.ts +92 -0
  9. package/gates/gate-9.ts +269 -0
  10. package/gates/pbt-detect.ts +163 -0
  11. package/gates/python-health.ts +266 -0
  12. package/gates/test-layers.ts +312 -0
  13. package/hooks/gate-3.sh +48 -7
  14. package/hooks/gate-8.sh +32 -3
  15. package/hooks/gate-9.sh +14 -2
  16. package/hooks/pre-commit +51 -2
  17. package/lib/__tests__/check-normalize-exit.test.js +24 -0
  18. package/lib/__tests__/shared-utils.test.js +21 -0
  19. package/lib/__tests__/uninstall.test.js +18 -0
  20. package/lib/check.js +27 -4
  21. package/lib/gate-runner.js +6 -1
  22. package/lib/mattpocock-license-verdict.json +1 -1
  23. package/lib/shared-utils.js +6 -1
  24. package/lib/uninstall.js +21 -1
  25. package/mock-policy/AGENTS.md +2 -2
  26. package/mutation/AGENTS.md +2 -2
  27. package/package.json +2 -1
  28. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  29. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  30. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  31. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  32. package/plugins/dsh/package.json +1 -1
  33. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  34. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  35. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  36. package/plugins/opencode/package.json +1 -1
  37. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  38. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  39. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  40. package/plugins/qoder/plugin.json +1 -1
  41. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  42. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  43. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  44. package/principles/AGENTS.md +2 -2
  45. package/scripts/sync-package-content.js +6 -1
  46. package/skills/delphi-review/AGENTS.md +2 -2
  47. package/skills/sprint-flow/AGENTS.md +2 -2
  48. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Gate 7: IaC Security Scanning (Terraform, Kubernetes, Docker, PowerShell DSC)
3
+ * Detects security issues in Infrastructure as Code files.
4
+ * TypeScript rewrite of githooks/adapters/gate-7.sh for cross-platform Node.js CLI.
5
+ */
6
+ import path from 'path';
7
+ import fs from 'fs';
8
+ import {
9
+ GateInput, GateOutput, isToolAvailable, runTool, recordAudit, filterIaCFiles,
10
+ getChangedFiles, detectProjectLang,
11
+ } from './common';
12
+
13
+ export function runGate7(input: GateInput): GateOutput {
14
+ const startMs = Date.now();
15
+ const cwd = input.cwd || process.cwd();
16
+ const messages: string[] = [];
17
+
18
+ messages.push('');
19
+ messages.push('→ Gate 7: IaC Security Scanning (Terraform, Kubernetes, Docker)...');
20
+
21
+ // Filter to IaC files (includes PowerShell DSC per design)
22
+ const iacFiles = filterIaCFiles(input.changedFiles);
23
+
24
+ if (iacFiles.length === 0) {
25
+ messages.push('✅ PASSED - No IaC files detected in changes.');
26
+ recordAudit('gate-7', 'iac-security', 'PASS', 0, startMs);
27
+ return { status: 'PASS', messages, exitCode: 0 };
28
+ }
29
+
30
+ // checkov is the PREFERRED engine (see the module header and the fallback
31
+ // message below), so it is tried FIRST. #650: the adapter lookup used to run
32
+ // before this and SKIP the gate whenever a consumer project had no vendored
33
+ // `githooks/adapters/iac.sh` -- even with checkov installed and on PATH. A
34
+ // security gate that skips because an optional helper script is absent, while
35
+ // the real scanner is available, reports "nothing to check" about code that was
36
+ // never checked. The bash adapter is the FALLBACK, so it is consulted after.
37
+ const checkov = isToolAvailable('checkov', cwd);
38
+ if (checkov.available) {
39
+ return runCheckov(checkov.path, checkov.via, iacFiles, cwd, startMs, messages);
40
+ }
41
+
42
+ // Fallback: run the bash IaC adapter via bash
43
+ messages.push('⚠️ checkov not installed — falling back to bash IaC adapter');
44
+ messages.push(' Install: pip install checkov');
45
+
46
+ return runBashAdapter(iacFiles, cwd, startMs, messages);
47
+ }
48
+
49
+ /**
50
+ * Fallback path: hand the IaC files to the project's bash adapter.
51
+ *
52
+ * Extracted from `runGate7` so the primary (checkov) path stays readable and the
53
+ * entry point stays inside the long-function limit. A missing adapter is a SKIP,
54
+ * never a PASS: no scan ran, so there is no evidence to report as clean.
55
+ */
56
+ function runBashAdapter(
57
+ iacFiles: string[],
58
+ cwd: string,
59
+ startMs: number,
60
+ messages: string[]
61
+ ): GateOutput {
62
+ const adapterPath = findIaCAdapter(cwd);
63
+ if (!adapterPath) {
64
+ messages.push('ℹ️ SKIP - IaC adapter not found');
65
+ messages.push('⏭️ SKIPPED - IaC security (no IaC adapter found)');
66
+ recordAudit('gate-7', 'iac-security', 'SKIP', 0, startMs);
67
+ return { status: 'SKIP', messages, exitCode: 0 };
68
+ }
69
+
70
+ const result = runTool('bash', [adapterPath, ...iacFiles], { cwd });
71
+ const combinedOutput = result.stdout + result.stderr;
72
+
73
+ if (result.exitCode === 0) {
74
+ messages.push(combinedOutput);
75
+ messages.push('✅ PASSED - IaC security scan.');
76
+ recordAudit('gate-7', 'iac-security', 'PASS', 0, startMs);
77
+ return { status: 'PASS', messages, exitCode: 0 };
78
+ }
79
+
80
+ messages.push(combinedOutput);
81
+ messages.push('');
82
+ messages.push('❌ BLOCKED - IaC security issues detected');
83
+ messages.push('Fix the security issues above before committing.');
84
+ messages.push('Tip: Install checkov for comprehensive IaC scanning: pip install checkov');
85
+ recordAudit('gate-7', 'iac-security', 'FAIL', result.exitCode, startMs);
86
+ return { status: 'FAIL', messages, exitCode: 1 };
87
+ }
88
+
89
+ function runCheckov(
90
+ cmdPath: string,
91
+ via: string,
92
+ files: string[],
93
+ cwd: string,
94
+ startMs: number,
95
+ messages: string[]
96
+ ): GateOutput {
97
+ const checkovCmd = via === 'npx' ? 'npx' : cmdPath;
98
+ const checkovArgs = via === 'npx' ? ['-y', 'checkov'] : [];
99
+
100
+ // Detect config file
101
+ const configPath = path.join(cwd, '.checkov.yaml');
102
+ if (fs.existsSync(configPath)) {
103
+ checkovArgs.push('--config-file', configPath);
104
+ }
105
+
106
+ // Run checkov on the changed IaC files
107
+ checkovArgs.push('--file', ...files, '--quiet', '--compact');
108
+
109
+ const result = runTool(checkovCmd, checkovArgs, { cwd });
110
+ const combinedOutput = result.stdout + result.stderr;
111
+
112
+ if (result.exitCode === 0) {
113
+ messages.push(combinedOutput);
114
+ messages.push('✅ PASSED - IaC security scan.');
115
+ recordAudit('gate-7', 'iac-security', 'PASS', 0, startMs);
116
+ return { status: 'PASS', messages, exitCode: 0 };
117
+ }
118
+
119
+ messages.push(combinedOutput);
120
+ messages.push('');
121
+ messages.push('❌ BLOCKED - IaC security issues detected');
122
+ messages.push('Fix the security issues above before committing.');
123
+ recordAudit('gate-7', 'iac-security', 'FAIL', result.exitCode, startMs);
124
+ return { status: 'FAIL', messages, exitCode: 1 };
125
+ }
126
+
127
+ function findIaCAdapter(cwd: string): string | null {
128
+ const candidates = [
129
+ path.join(cwd, 'githooks', 'adapters', 'iac.sh'),
130
+ path.join(cwd, 'githooks', 'adapters', 'iac', 'iac.sh'),
131
+ ];
132
+ for (const c of candidates) {
133
+ if (fs.existsSync(c)) return c;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ // ── CLI Entry Point ──
139
+ if (process.argv.includes('--run')) {
140
+ const cwdIdx = process.argv.indexOf('--cwd');
141
+ const cwd = cwdIdx >= 0 ? process.argv[cwdIdx + 1] : process.cwd();
142
+ const changedFiles = getChangedFiles(cwd);
143
+ const projectLang = detectProjectLang(cwd);
144
+ const result = runGate7({ changedFiles, projectLang, cwd });
145
+ result.messages.forEach(m => console.log(m));
146
+ process.exit(result.exitCode);
147
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Gate 8: Secret Scanning (gitleaks)
3
+ * Detects secrets (API keys, passwords, tokens) in staged files.
4
+ * TypeScript rewrite of githooks/adapters/gate-8.sh for cross-platform Node.js CLI.
5
+ */
6
+ import path from 'path';
7
+ import fs from 'fs';
8
+ import {
9
+ GateInput, GateOutput, isToolAvailable, runTool, recordAudit, getTempDir,
10
+ getChangedFiles, detectProjectLang,
11
+ } from './common';
12
+
13
+ export function runGate8(input: GateInput): GateOutput {
14
+ const startMs = Date.now();
15
+ const cwd = input.cwd || process.cwd();
16
+ const messages: string[] = [];
17
+
18
+ messages.push('');
19
+ messages.push('→ Gate 8: Secret scanning (gitleaks)...');
20
+
21
+ // Tool detection
22
+ const tool = isToolAvailable('gitleaks', cwd);
23
+ if (!tool.available) {
24
+ messages.push(' ℹ️ gitleaks not installed — secret scanning unavailable');
25
+ messages.push(' Install: brew install gitleaks (macOS) | winget install gitleaks (Windows)');
26
+ messages.push(' ⏭️ SKIPPED - Secret scanning (gitleaks not installed)');
27
+ recordAudit('gate-8', 'secret-scanning', 'SKIP', 0, startMs);
28
+ return { status: 'SKIP', messages, exitCode: 0 };
29
+ }
30
+
31
+ const gitleaksCmd = tool.via === 'npx' ? 'npx' : tool.path;
32
+ const gitleaksArgs = tool.via === 'npx' ? ['-y', 'gitleaks'] : [];
33
+
34
+ // Config detection
35
+ const configPath = findGitleaksConfig(cwd);
36
+ if (configPath) {
37
+ gitleaksArgs.push(`--config=${configPath}`);
38
+ }
39
+
40
+ // Cross-platform temp path (fixes /tmp issue on Windows)
41
+ const reportPath = path.join(getTempDir(), 'gitleaks-report.json');
42
+
43
+ // Run gitleaks on staged changes
44
+ gitleaksArgs.push('git', '--pre-commit', '--redact', '--no-banner',
45
+ '--report-format=json', `--report-path=${reportPath}`);
46
+
47
+ const result = runTool(gitleaksCmd, gitleaksArgs, { cwd });
48
+
49
+ if (result.exitCode === 0) {
50
+ messages.push(' ✅ PASSED - No secrets detected.');
51
+ recordAudit('gate-8', 'secret-scanning', 'PASS', 0, startMs);
52
+ return { status: 'PASS', messages, exitCode: 0 };
53
+ }
54
+
55
+ if (result.exitCode === 1) {
56
+ // Secrets found
57
+ messages.push(result.stdout);
58
+ messages.push('');
59
+ messages.push('❌ BLOCKED - Secrets detected in staged files.');
60
+ messages.push('');
61
+ messages.push('Remediation options:');
62
+ messages.push(' 1. Remove the secret and use environment variables instead');
63
+ messages.push(' 2. Add a false positive to .gitleaks.toml allowlist');
64
+ messages.push(' 3. Use git secret or vault for sensitive data');
65
+ messages.push('');
66
+ messages.push('See: https://github.com/gitleaks/gitleaks');
67
+ recordAudit('gate-8', 'secret-scanning', 'FAIL', 1, startMs);
68
+ return { status: 'FAIL', messages, exitCode: 1 };
69
+ }
70
+
71
+ // Runtime error
72
+ messages.push(` ⚠️ gitleaks exited with code ${result.exitCode} - skipping gate`);
73
+ messages.push(' ✅ Secret Scanning (SKIP, gitleaks error)');
74
+ recordAudit('gate-8', 'secret-scanning', 'SKIP', result.exitCode, startMs);
75
+ return { status: 'SKIP', messages, exitCode: 0 };
76
+ }
77
+
78
+ function findGitleaksConfig(cwd: string): string | null {
79
+ const configPath = path.join(cwd, '.gitleaks.toml');
80
+ return fs.existsSync(configPath) ? configPath : null;
81
+ }
82
+
83
+ // ── CLI Entry Point ──
84
+ if (process.argv.includes('--run')) {
85
+ const cwdIdx = process.argv.indexOf('--cwd');
86
+ const cwd = cwdIdx >= 0 ? process.argv[cwdIdx + 1] : process.cwd();
87
+ const changedFiles = getChangedFiles(cwd);
88
+ const projectLang = detectProjectLang(cwd);
89
+ const result = runGate8({ changedFiles, projectLang, cwd });
90
+ result.messages.forEach(m => console.log(m));
91
+ process.exit(result.exitCode);
92
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Gate 9: Semgrep SAST Security Scan
3
+ * Detects security vulnerabilities in staged files.
4
+ * For PowerShell projects: uses PSScriptAnalyzer security rules as alternative.
5
+ * TypeScript rewrite of githooks/adapters/gate-9.sh for cross-platform Node.js CLI.
6
+ */
7
+ import {
8
+ GateInput, GateOutput, isToolAvailable, runTool, recordAudit,
9
+ filterSemgrepFiles, detectPowerShell, isPowerShellProject, runPowerShellTool,
10
+ getChangedFiles, detectProjectLang,
11
+ } from './common';
12
+
13
+ export function runGate9(input: GateInput): GateOutput {
14
+ const startMs = Date.now();
15
+ const messages: string[] = [];
16
+
17
+ messages.push('');
18
+ messages.push('→ Gate 9: Semgrep SAST Security Scan...');
19
+
20
+ // PowerShell project: use PSScriptAnalyzer security rules
21
+ if (isPowerShellProject(input.projectLang)) {
22
+ return runPowerShellSAST(input, startMs, messages);
23
+ }
24
+
25
+ // Standard path: semgrep
26
+ const tool = isToolAvailable('semgrep', input.cwd);
27
+ if (!tool.available) {
28
+ messages.push(' ⚠️ WARN - semgrep not installed — SAST scanning unavailable');
29
+ messages.push(' Install: brew install semgrep (macOS) | pip install semgrep (Linux/Windows)');
30
+ messages.push(' Pre-cache rules: semgrep --config=p/security-audit');
31
+ messages.push(' Gate 9: SAST Security (WARN, semgrep not installed)');
32
+ recordAudit('gate-9', 'sast-security', 'WARN', 0, startMs);
33
+ return { status: 'WARN', messages, exitCode: 0 };
34
+ }
35
+
36
+ const semgrepCmd = tool.via === 'npx' ? 'npx' : tool.path;
37
+ const semgrepArgs = tool.via === 'npx' ? ['-y', 'semgrep'] : [];
38
+
39
+ // Filter to semgrep-supported language files
40
+ const semgrepFiles = filterSemgrepFiles(input.changedFiles);
41
+ if (semgrepFiles.length === 0) {
42
+ messages.push(' ⏭️ SKIPPED - SAST (no supported language files changed)');
43
+ recordAudit('gate-9', 'sast-security', 'SKIP', 0, startMs);
44
+ return { status: 'SKIP', messages, exitCode: 0 };
45
+ }
46
+
47
+ // Run semgrep with JSON output
48
+ semgrepArgs.push('scan', '--config=p/security-audit', '--json', '--disable-version-check', ...semgrepFiles);
49
+
50
+ const result = runTool(semgrepCmd, semgrepArgs, { cwd: input.cwd });
51
+
52
+ // #518 (same defect class as the bash gate-10.sh fix): the exit code does NOT
53
+ // carry the verdict. semgrep exits 0 even with ERROR-severity findings, and
54
+ // its exit-code semantics drift across versions/platforms. The verdict comes
55
+ // from the JSON; the exit code only tells whether the tool itself ran.
56
+ return parseSemgrepResults(result.stdout, result.exitCode, result.stderr, startMs, messages);
57
+ }
58
+
59
+ // #518: warnings-only vocabulary. Everything OUTSIDE this list — including
60
+ // semgrep's ERROR tier, the legacy CRITICAL/HIGH tiers, and any unknown or
61
+ // missing severity — counts as BLOCKING: a security gate must fail closed on
62
+ // tiers it does not recognize.
63
+ const NONBLOCKING_SEVERITIES = ['WARNING', 'INFO', 'MEDIUM', 'LOW'];
64
+ const MAX_DETAIL_FINDINGS = 5;
65
+ const MAX_FINDING_MESSAGE_LENGTH = 80;
66
+
67
+ interface SemgrepFinding {
68
+ check_id?: string;
69
+ path?: string;
70
+ start?: { line?: number };
71
+ extra?: { severity?: string; message?: string };
72
+ }
73
+
74
+ function pushStderrDiagnostics(messages: string[], stderr: string): void {
75
+ if (!stderr) {
76
+ return;
77
+ }
78
+ messages.push(' ── what semgrep reported ──');
79
+ messages.push(...stderr.split('\n').slice(-10).map(l => ` ${l}`));
80
+ }
81
+
82
+ function countBySeverity(results: SemgrepFinding[]): { blocking: number; warnings: number } {
83
+ let blocking = 0;
84
+ let warnings = 0;
85
+ results.forEach(r => {
86
+ const s = (r.extra?.severity || '').toUpperCase();
87
+ if (NONBLOCKING_SEVERITIES.includes(s)) {
88
+ warnings++;
89
+ } else {
90
+ blocking++;
91
+ }
92
+ });
93
+ return { blocking, warnings };
94
+ }
95
+
96
+ function findingDetailLines(results: SemgrepFinding[]): string[] {
97
+ const lines: string[] = [];
98
+ results.slice(0, MAX_DETAIL_FINDINGS).forEach(r => {
99
+ const severity = (r.extra?.severity || 'UNKNOWN').toUpperCase();
100
+ const ruleId = r.check_id || 'unknown';
101
+ const filePath = r.path || 'unknown';
102
+ const line = r.start?.line || '?';
103
+ const msg = (r.extra?.message || '').slice(0, MAX_FINDING_MESSAGE_LENGTH);
104
+ lines.push(` [${severity}] ${ruleId}`);
105
+ lines.push(` ${filePath}:${line} → ${msg}`);
106
+ lines.push('');
107
+ });
108
+ return lines;
109
+ }
110
+
111
+ function renderSemgrepBlock(
112
+ blocking: number,
113
+ warnings: number,
114
+ findingLines: string[],
115
+ startMs: number,
116
+ messages: string[]
117
+ ): GateOutput {
118
+ messages.push('');
119
+ messages.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
120
+ messages.push(' GATE 9: Semgrep Security Gate');
121
+ messages.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
122
+ messages.push(` CRITICAL/HIGH/ERROR: ${blocking} ❌ BLOCKED`);
123
+ messages.push(` MEDIUM/LOW/WARNING/INFO: ${warnings} ⚠️ warning`);
124
+ messages.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
125
+ messages.push(' ❌ BLOCKED — blocking-severity vulnerability found');
126
+ messages.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
127
+ messages.push(...findingLines);
128
+ messages.push(" Run 'semgrep scan --config=p/security-audit' to review all findings.");
129
+ recordAudit('gate-9', 'sast-security', 'FAIL', blocking, startMs);
130
+ return { status: 'FAIL', messages, exitCode: 1 };
131
+ }
132
+
133
+ function parseSemgrepResults(
134
+ jsonOutput: string,
135
+ exitCode: number,
136
+ stderr: string,
137
+ startMs: number,
138
+ messages: string[]
139
+ ): GateOutput {
140
+ let data: { results?: SemgrepFinding[]; errors?: Array<{ message?: string }> };
141
+ try {
142
+ data = JSON.parse(jsonOutput);
143
+ } catch {
144
+ // Fail closed (#518): unparseable output cannot be judged — SKIP, never PASS.
145
+ messages.push(' ⚠️ semgrep output unparseable or verdict fields missing — cannot determine verdict');
146
+ pushStderrDiagnostics(messages, stderr);
147
+ messages.push(' ⏭️ SKIPPED - SAST (semgrep output unparseable)');
148
+ recordAudit('gate-9', 'sast-security', 'SKIP', exitCode, startMs);
149
+ return { status: 'SKIP', messages, exitCode: 0 };
150
+ }
151
+
152
+ const results: SemgrepFinding[] = data.results || [];
153
+ const scanErrors = data.errors || [];
154
+
155
+ // Nothing scanned to a verdict: tool failure (nonzero exit) or scan errors on
156
+ // every input. Must neither block nor claim a pass (#475 contract, #518 R2:
157
+ // exit 0 + results:[] + errors:[{...}] used to fall through to PASS).
158
+ if (results.length === 0 && (scanErrors.length > 0 || exitCode !== 0)) {
159
+ messages.push(` ⚠️ semgrep exit=${exitCode}, scan errors=${scanErrors.length}, findings=${results.length} — nothing to judge`);
160
+ pushStderrDiagnostics(messages, stderr);
161
+ messages.push(' ⏭️ SKIPPED - SAST (semgrep runtime error)');
162
+ recordAudit('gate-9', 'sast-security', 'SKIP', exitCode, startMs);
163
+ return { status: 'SKIP', messages, exitCode: 0 };
164
+ }
165
+
166
+ const { blocking, warnings } = countBySeverity(results);
167
+ const findingLines = findingDetailLines(results);
168
+
169
+ if (blocking > 0) {
170
+ return renderSemgrepBlock(blocking, warnings, findingLines, startMs, messages);
171
+ }
172
+
173
+ messages.push('');
174
+ messages.push(' ✅ PASSED - No blocking vulnerabilities');
175
+ if (warnings > 0) {
176
+ messages.push(` ⚠️ ${warnings} non-blocking findings (warnings only)`);
177
+ messages.push(...findingLines);
178
+ }
179
+ recordAudit('gate-9', 'sast-security', 'PASS', 0, startMs);
180
+ return { status: 'PASS', messages, exitCode: 0 };
181
+ }
182
+
183
+ const PSSCRIPTANALYZER_SECURITY_RULES = [
184
+ 'PSAvoidUsingInvokeExpression',
185
+ 'PSAvoidUsingConvertToSecureStringWithPlainText',
186
+ 'PSAvoidUsingPlainTextForPassword',
187
+ 'PSAvoidUsingUserNameAndPasswordParams',
188
+ 'PSAvoidUsingWMICmdlet',
189
+ 'PSAvoidUsingWriteHost',
190
+ ].join(',');
191
+
192
+ function buildPSScriptAnalyzerCommand(psFiles: string[]): string {
193
+ const fileList = psFiles.map(f => `'${f}'`).join(',');
194
+ return `Invoke-ScriptAnalyzer -Path ${fileList} -IncludeRule ${PSSCRIPTANALYZER_SECURITY_RULES} -ErrorAction SilentlyContinue | ConvertTo-Json`;
195
+ }
196
+
197
+ function renderPowerShellFindings(
198
+ findings: Array<{ RuleName?: string; ScriptName?: string; Line?: number; Message?: string }>,
199
+ startMs: number,
200
+ messages: string[]
201
+ ): GateOutput {
202
+ messages.push('');
203
+ messages.push('❌ BLOCKED - PSScriptAnalyzer security findings:');
204
+ findings.slice(0, MAX_DETAIL_FINDINGS).forEach(f => {
205
+ messages.push(` [${f.RuleName || 'UNKNOWN'}] ${f.ScriptName || '?'}:${f.Line || '?'} → ${(f.Message || '').slice(0, MAX_FINDING_MESSAGE_LENGTH)}`);
206
+ });
207
+ recordAudit('gate-9', 'sast-security', 'FAIL', findings.length, startMs);
208
+ return { status: 'FAIL', messages, exitCode: 1 };
209
+ }
210
+
211
+ /**
212
+ * PowerShell project SAST using PSScriptAnalyzer security rules.
213
+ * Replaces semgrep which doesn't support .ps1 files.
214
+ */
215
+ function runPowerShellSAST(
216
+ input: GateInput,
217
+ startMs: number,
218
+ messages: string[]
219
+ ): GateOutput {
220
+ const ps = detectPowerShell();
221
+ if (!ps.available) {
222
+ messages.push(' ⚠️ WARN - PowerShell not available — SAST scanning unavailable');
223
+ messages.push(' Gate 9: SAST Security (WARN, PowerShell not installed)');
224
+ recordAudit('gate-9', 'sast-security', 'WARN', 0, startMs);
225
+ return { status: 'WARN', messages, exitCode: 0 };
226
+ }
227
+
228
+ const psFiles = input.changedFiles.filter(f => f.endsWith('.ps1') || f.endsWith('.psm1'));
229
+ if (psFiles.length === 0) {
230
+ messages.push(' ⏭️ SKIPPED - SAST (no PowerShell files changed)');
231
+ recordAudit('gate-9', 'sast-security', 'SKIP', 0, startMs);
232
+ return { status: 'SKIP', messages, exitCode: 0 };
233
+ }
234
+
235
+ messages.push(' Running PSScriptAnalyzer security rules...');
236
+
237
+ const result = runPowerShellTool(ps, buildPSScriptAnalyzerCommand(psFiles), { cwd: input.cwd });
238
+
239
+ if (result.exitCode !== 0) {
240
+ messages.push(' ⚠️ PSScriptAnalyzer security check failed, skipping');
241
+ recordAudit('gate-9', 'sast-security', 'WARN', 0, startMs);
242
+ return { status: 'WARN', messages, exitCode: 0 };
243
+ }
244
+
245
+ try {
246
+ const findings = result.stdout.trim() ? JSON.parse(result.stdout.trim()) : [];
247
+ const arr = Array.isArray(findings) ? findings : [findings];
248
+ if (arr.length > 0) {
249
+ return renderPowerShellFindings(arr, startMs, messages);
250
+ }
251
+ } catch {
252
+ // JSON parse failure
253
+ }
254
+
255
+ messages.push(' ✅ PASSED - No PowerShell security vulnerabilities found.');
256
+ recordAudit('gate-9', 'sast-security', 'PASS', 0, startMs);
257
+ return { status: 'PASS', messages, exitCode: 0 };
258
+ }
259
+
260
+ // ── CLI Entry Point ──
261
+ if (process.argv.includes('--run')) {
262
+ const cwdIdx = process.argv.indexOf('--cwd');
263
+ const cwd = cwdIdx >= 0 ? process.argv[cwdIdx + 1] : process.cwd();
264
+ const changedFiles = getChangedFiles(cwd);
265
+ const projectLang = detectProjectLang(cwd);
266
+ const result = runGate9({ changedFiles, projectLang, cwd });
267
+ result.messages.forEach(m => console.log(m));
268
+ process.exit(result.exitCode);
269
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Property-Based Testing Detection — reports PBT framework usage in the project.
3
+ * Implements #337: PBT analytics (report mode, does NOT block).
4
+ */
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+
8
+ // ── Types ──
9
+
10
+ export interface PBTReport {
11
+ detected: boolean;
12
+ frameworks: string[];
13
+ testFiles: string[];
14
+ pbtTestFiles: string[];
15
+ coverage: number;
16
+ messages: string[];
17
+ }
18
+
19
+ // ── PBT Framework Patterns ──
20
+
21
+ /** Import patterns that indicate PBT usage. */
22
+ const PBT_IMPORT_PATTERNS = [
23
+ { name: 'fast-check', patterns: [/from\s+['"]fast-check['"]/, /require\(['"]fast-check['"]\)/, /import.*fc.*from/] },
24
+ { name: 'jsverify', patterns: [/from\s+['"]jsverify['"]/, /require\(['"]jsverify['"]\)/] },
25
+ { name: 'jest-property', patterns: [/expect\(\)\.property/, /jest-property/] },
26
+ { name: 'ava-fast-check', patterns: [/ava.*fast-check/, /test\.property/] },
27
+ ];
28
+
29
+ /** PBT API usage patterns (even without explicit import). */
30
+ const PBT_API_PATTERNS = [
31
+ /fc\.property\(/,
32
+ /fc\.assert\(/,
33
+ /fc\.check\(/,
34
+ /fc\.sample\(/,
35
+ /fc\.quickcheck\(/,
36
+ /jsc\.property\(/,
37
+ /\.forAll\(/,
38
+ /property\(.*arb/,
39
+ ];
40
+
41
+ // ── Scanner ──
42
+
43
+ /** Directories that never hold test sources worth scanning. */
44
+ const SKIPPED_DIRS = new Set(['node_modules', '.git', 'dist']);
45
+
46
+ /** Test-file suffixes recognised by the PBT scanner. */
47
+ const TEST_SUFFIXES = ['.test.ts', '.test.js', '.spec.ts', '.spec.js'];
48
+
49
+ function isTestFileName(name: string): boolean {
50
+ return TEST_SUFFIXES.some(suffix => name.endsWith(suffix));
51
+ }
52
+
53
+ /** Collect test files under `dir`, descending into subdirectories. */
54
+ function scanDirectory(dir: string, basePath?: string): string[] {
55
+ if (!fs.existsSync(dir)) return [];
56
+
57
+ const results: string[] = [];
58
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
59
+ if (entry.isDirectory()) {
60
+ if (!SKIPPED_DIRS.has(entry.name)) {
61
+ results.push(...scanDirectory(path.join(dir, entry.name), basePath ?? dir));
62
+ }
63
+ continue;
64
+ }
65
+ if (isTestFileName(entry.name)) results.push(path.join(dir, entry.name));
66
+ }
67
+ return results;
68
+ }
69
+
70
+ function detectPBTInFile(filePath: string): { hasPBT: boolean; frameworks: string[] } {
71
+ try {
72
+ const content = fs.readFileSync(filePath, 'utf-8');
73
+ const foundFrameworks: string[] = [];
74
+
75
+ // Check import patterns
76
+ for (const fw of PBT_IMPORT_PATTERNS) {
77
+ if (fw.patterns.some(p => p.test(content))) {
78
+ foundFrameworks.push(fw.name);
79
+ }
80
+ }
81
+
82
+ // Check API usage patterns
83
+ if (foundFrameworks.length === 0) {
84
+ for (const pattern of PBT_API_PATTERNS) {
85
+ if (pattern.test(content)) {
86
+ foundFrameworks.push('unknown-pbt');
87
+ break;
88
+ }
89
+ }
90
+ }
91
+
92
+ return { hasPBT: foundFrameworks.length > 0, frameworks: [...new Set(foundFrameworks)] };
93
+ } catch {
94
+ return { hasPBT: false, frameworks: [] };
95
+ }
96
+ }
97
+
98
+ // ── Main Analysis ──
99
+
100
+ /**
101
+ * Analyze PBT usage in the project.
102
+ */
103
+ export function detectPBT(cwd?: string): PBTReport {
104
+ const dir = cwd || process.cwd();
105
+ const srcDir = path.join(dir, 'src');
106
+ const messages: string[] = [];
107
+
108
+ // Find all test files
109
+ const testFiles = scanDirectory(srcDir);
110
+ const pbtTestFiles: string[] = [];
111
+ const allFrameworks = new Set<string>();
112
+
113
+ for (const testFile of testFiles) {
114
+ const result = detectPBTInFile(testFile);
115
+ if (result.hasPBT) {
116
+ pbtTestFiles.push(path.relative(dir, testFile).replace(/\\/g, '/'));
117
+ result.frameworks.forEach(fw => allFrameworks.add(fw));
118
+ }
119
+ }
120
+
121
+ const totalTests = testFiles.length;
122
+ const coverage = totalTests > 0 ? Math.round((pbtTestFiles.length / totalTests) * 100) : 0;
123
+ const detected = pbtTestFiles.length > 0;
124
+
125
+ // Build messages
126
+ messages.push('');
127
+ messages.push('━━━ Property-Based Testing Report ━━━');
128
+ messages.push('');
129
+ messages.push(` Test files: ${totalTests}`);
130
+ messages.push(` PBT files: ${pbtTestFiles.length} (${coverage}%)`);
131
+ messages.push(` Frameworks: ${detected ? [...allFrameworks].join(', ') : 'none detected'}`);
132
+ messages.push('');
133
+
134
+ if (detected) {
135
+ messages.push(' ✅ PBT usage detected:');
136
+ pbtTestFiles.forEach(f => messages.push(` - ${f}`));
137
+ } else if (totalTests > 0) {
138
+ messages.push(' ℹ️ No property-based testing detected.');
139
+ messages.push(' Consider adding fast-check for boundary condition coverage:');
140
+ messages.push(' npm install -D fast-check');
141
+ messages.push(' import fc from "fast-check";');
142
+ messages.push(' test("property: reverse is idempotent", () => {');
143
+ messages.push(' fc.assert(fc.property(fc.array(fc.integer()), arr => {');
144
+ messages.push(' expect(arr.reverse().reverse()).toEqual(arr);');
145
+ messages.push(' }));');
146
+ messages.push(' });');
147
+ } else {
148
+ messages.push(' ℹ️ No test files found.');
149
+ }
150
+ messages.push('');
151
+
152
+ return { detected, frameworks: [...allFrameworks], testFiles, pbtTestFiles, coverage, messages };
153
+ }
154
+
155
+ // ── CLI Entry Point ──
156
+
157
+ if (process.argv.includes('--run')) {
158
+ const cwdIdx = process.argv.indexOf('--cwd');
159
+ const cwd = cwdIdx >= 0 ? process.argv[cwdIdx + 1] : process.cwd();
160
+ const report = detectPBT(cwd);
161
+ report.messages.forEach(m => console.log(m));
162
+ process.exit(0);
163
+ }