@boyingliu01/xp-gate 0.33.0 → 0.33.1
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.
- package/gates/common-process.ts +178 -0
- package/gates/common.ts +208 -0
- package/gates/gate-3.ts +144 -0
- package/gates/gate-7.ts +126 -0
- package/gates/gate-8.ts +92 -0
- package/gates/gate-9.ts +269 -0
- package/gates/pbt-detect.ts +163 -0
- package/gates/python-health.ts +266 -0
- package/gates/test-layers.ts +312 -0
- package/lib/__tests__/check-normalize-exit.test.js +24 -0
- package/lib/__tests__/shared-utils.test.js +21 -0
- package/lib/__tests__/uninstall.test.js +18 -0
- package/lib/check.js +27 -4
- package/lib/gate-runner.js +6 -1
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-utils.js +6 -1
- package/lib/uninstall.js +21 -1
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +2 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/scripts/sync-package-content.js +6 -1
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
package/gates/gate-8.ts
ADDED
|
@@ -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
|
+
}
|
package/gates/gate-9.ts
ADDED
|
@@ -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
|
+
}
|