@rigour-labs/core 3.0.0 → 3.0.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.
- package/dist/gates/hallucinated-imports.d.ts +18 -2
- package/dist/gates/hallucinated-imports.js +139 -14
- package/dist/gates/hallucinated-imports.test.d.ts +1 -0
- package/dist/gates/hallucinated-imports.test.js +288 -0
- package/dist/gates/security-patterns-owasp.test.d.ts +1 -0
- package/dist/gates/security-patterns-owasp.test.js +171 -0
- package/dist/gates/security-patterns.d.ts +4 -0
- package/dist/gates/security-patterns.js +100 -0
- package/dist/hooks/checker.d.ts +23 -0
- package/dist/hooks/checker.js +222 -0
- package/dist/hooks/checker.test.d.ts +1 -0
- package/dist/hooks/checker.test.js +132 -0
- package/dist/hooks/index.d.ts +9 -0
- package/dist/hooks/index.js +8 -0
- package/dist/hooks/standalone-checker.d.ts +15 -0
- package/dist/hooks/standalone-checker.js +106 -0
- package/dist/hooks/templates.d.ts +22 -0
- package/dist/hooks/templates.js +232 -0
- package/dist/hooks/types.d.ts +34 -0
- package/dist/hooks/types.js +21 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/templates/index.js +7 -0
- package/dist/types/index.d.ts +54 -0
- package/dist/types/index.js +13 -0
- package/package.json +1 -1
|
@@ -33,6 +33,10 @@ export interface SecurityPatternsConfig {
|
|
|
33
33
|
hardcoded_secrets?: boolean;
|
|
34
34
|
insecure_randomness?: boolean;
|
|
35
35
|
command_injection?: boolean;
|
|
36
|
+
redos?: boolean;
|
|
37
|
+
overly_permissive?: boolean;
|
|
38
|
+
unsafe_output?: boolean;
|
|
39
|
+
missing_input_validation?: boolean;
|
|
36
40
|
block_on_severity?: 'critical' | 'high' | 'medium' | 'low';
|
|
37
41
|
}
|
|
38
42
|
export declare class SecurityPatternsGate extends Gate {
|
|
@@ -131,6 +131,98 @@ const VULNERABILITY_PATTERNS = [
|
|
|
131
131
|
cwe: 'CWE-78',
|
|
132
132
|
languages: ['ts', 'js']
|
|
133
133
|
},
|
|
134
|
+
// ReDoS — Denial of Service via regex (OWASP #7)
|
|
135
|
+
{
|
|
136
|
+
type: 'redos',
|
|
137
|
+
regex: /new RegExp\s*\([^)]*(?:req\.|params|query|body|input|user)/g,
|
|
138
|
+
severity: 'high',
|
|
139
|
+
description: 'Dynamic regex from user input — potential ReDoS',
|
|
140
|
+
cwe: 'CWE-1333',
|
|
141
|
+
languages: ['ts', 'js']
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: 'redos',
|
|
145
|
+
regex: /\(\?:[^)]*\+[^)]*\)\+|\([^)]*\*[^)]*\)\+|\(\.\*\)\{/g,
|
|
146
|
+
severity: 'medium',
|
|
147
|
+
description: 'Regex with nested quantifiers — potential ReDoS',
|
|
148
|
+
cwe: 'CWE-1333',
|
|
149
|
+
languages: ['ts', 'js', 'py']
|
|
150
|
+
},
|
|
151
|
+
// Overly Permissive Code (OWASP #9)
|
|
152
|
+
{
|
|
153
|
+
type: 'overly_permissive',
|
|
154
|
+
regex: /cors\s*\(\s*\{[^}]*origin\s*:\s*(?:true|['"`]\*['"`])/g,
|
|
155
|
+
severity: 'high',
|
|
156
|
+
description: 'CORS wildcard origin — allows any domain',
|
|
157
|
+
cwe: 'CWE-942',
|
|
158
|
+
languages: ['ts', 'js']
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
type: 'overly_permissive',
|
|
162
|
+
regex: /(?:listen|bind)\s*\(\s*(?:\d+\s*,\s*)?['"`]0\.0\.0\.0['"`]/g,
|
|
163
|
+
severity: 'medium',
|
|
164
|
+
description: 'Binding to 0.0.0.0 exposes service to all interfaces',
|
|
165
|
+
cwe: 'CWE-668',
|
|
166
|
+
languages: ['ts', 'js', 'py', 'go']
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
type: 'overly_permissive',
|
|
170
|
+
regex: /chmod\s*\(\s*[^,]*,\s*['"`]?(?:0o?)?777['"`]?\s*\)/g,
|
|
171
|
+
severity: 'high',
|
|
172
|
+
description: 'chmod 777 — world-readable/writable permissions',
|
|
173
|
+
cwe: 'CWE-732',
|
|
174
|
+
languages: ['ts', 'js', 'py']
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
type: 'overly_permissive',
|
|
178
|
+
regex: /(?:Access-Control-Allow-Origin|x-powered-by)['"`,\s:]+\*/gi,
|
|
179
|
+
severity: 'high',
|
|
180
|
+
description: 'Wildcard Access-Control-Allow-Origin header',
|
|
181
|
+
cwe: 'CWE-942',
|
|
182
|
+
languages: ['ts', 'js', 'py']
|
|
183
|
+
},
|
|
184
|
+
// Unsafe Output Handling (OWASP #6)
|
|
185
|
+
{
|
|
186
|
+
type: 'unsafe_output',
|
|
187
|
+
regex: /res\.(?:send|write|end)\s*\(\s*(?:req\.|params|query|body|input|user)/g,
|
|
188
|
+
severity: 'high',
|
|
189
|
+
description: 'Reflecting user input in response without sanitization',
|
|
190
|
+
cwe: 'CWE-79',
|
|
191
|
+
languages: ['ts', 'js']
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
type: 'unsafe_output',
|
|
195
|
+
regex: /\$\{[^}]*(?:req\.|params|query|body|input|user)[^}]*\}.*(?:html|template|render)/gi,
|
|
196
|
+
severity: 'high',
|
|
197
|
+
description: 'User input interpolated into template/HTML output',
|
|
198
|
+
cwe: 'CWE-79',
|
|
199
|
+
languages: ['ts', 'js', 'py']
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
type: 'unsafe_output',
|
|
203
|
+
regex: /eval\s*\(\s*(?:req\.|params|query|body|input|user)/g,
|
|
204
|
+
severity: 'critical',
|
|
205
|
+
description: 'eval() with user input — code injection',
|
|
206
|
+
cwe: 'CWE-94',
|
|
207
|
+
languages: ['ts', 'js', 'py']
|
|
208
|
+
},
|
|
209
|
+
// Missing Input Validation (OWASP #8)
|
|
210
|
+
{
|
|
211
|
+
type: 'missing_input_validation',
|
|
212
|
+
regex: /JSON\.parse\s*\(\s*(?:req\.body|request\.body|body|data|input)\s*\)/g,
|
|
213
|
+
severity: 'medium',
|
|
214
|
+
description: 'JSON.parse on raw input without schema validation',
|
|
215
|
+
cwe: 'CWE-20',
|
|
216
|
+
languages: ['ts', 'js']
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
type: 'missing_input_validation',
|
|
220
|
+
regex: /(?:as\s+any|:\s*any)\s*(?:[;,)\]}])/g,
|
|
221
|
+
severity: 'medium',
|
|
222
|
+
description: 'Type assertion to "any" bypasses type safety',
|
|
223
|
+
cwe: 'CWE-20',
|
|
224
|
+
languages: ['ts']
|
|
225
|
+
},
|
|
134
226
|
];
|
|
135
227
|
export class SecurityPatternsGate extends Gate {
|
|
136
228
|
config;
|
|
@@ -145,6 +237,10 @@ export class SecurityPatternsGate extends Gate {
|
|
|
145
237
|
hardcoded_secrets: config.hardcoded_secrets ?? true,
|
|
146
238
|
insecure_randomness: config.insecure_randomness ?? true,
|
|
147
239
|
command_injection: config.command_injection ?? true,
|
|
240
|
+
redos: config.redos ?? true,
|
|
241
|
+
overly_permissive: config.overly_permissive ?? true,
|
|
242
|
+
unsafe_output: config.unsafe_output ?? true,
|
|
243
|
+
missing_input_validation: config.missing_input_validation ?? true,
|
|
148
244
|
block_on_severity: config.block_on_severity ?? 'high',
|
|
149
245
|
};
|
|
150
246
|
}
|
|
@@ -178,6 +274,10 @@ export class SecurityPatternsGate extends Gate {
|
|
|
178
274
|
case 'hardcoded_secrets': return this.config.hardcoded_secrets;
|
|
179
275
|
case 'insecure_randomness': return this.config.insecure_randomness;
|
|
180
276
|
case 'command_injection': return this.config.command_injection;
|
|
277
|
+
case 'redos': return this.config.redos;
|
|
278
|
+
case 'overly_permissive': return this.config.overly_permissive;
|
|
279
|
+
case 'unsafe_output': return this.config.unsafe_output;
|
|
280
|
+
case 'missing_input_validation': return this.config.missing_input_validation;
|
|
181
281
|
default: return true;
|
|
182
282
|
}
|
|
183
283
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight per-file checker for hook integration.
|
|
3
|
+
*
|
|
4
|
+
* Runs a fast subset of Rigour gates on individual files,
|
|
5
|
+
* designed to complete in <200ms for real-time hook feedback.
|
|
6
|
+
*
|
|
7
|
+
* Used by all tool-specific hooks (Claude, Cursor, Cline, Windsurf).
|
|
8
|
+
*
|
|
9
|
+
* @since v3.0.0
|
|
10
|
+
*/
|
|
11
|
+
import type { HookCheckerResult } from './types.js';
|
|
12
|
+
interface CheckerOptions {
|
|
13
|
+
cwd: string;
|
|
14
|
+
files: string[];
|
|
15
|
+
timeout_ms?: number;
|
|
16
|
+
block_on_failure?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Run fast gates on a set of files.
|
|
20
|
+
* Returns structured JSON for hook consumers.
|
|
21
|
+
*/
|
|
22
|
+
export declare function runHookChecker(options: CheckerOptions): Promise<HookCheckerResult>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight per-file checker for hook integration.
|
|
3
|
+
*
|
|
4
|
+
* Runs a fast subset of Rigour gates on individual files,
|
|
5
|
+
* designed to complete in <200ms for real-time hook feedback.
|
|
6
|
+
*
|
|
7
|
+
* Used by all tool-specific hooks (Claude, Cursor, Cline, Windsurf).
|
|
8
|
+
*
|
|
9
|
+
* @since v3.0.0
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import yaml from 'yaml';
|
|
14
|
+
import { ConfigSchema } from '../types/index.js';
|
|
15
|
+
const JS_TS_PATTERN = /\.(ts|tsx|js|jsx|mts|mjs)$/;
|
|
16
|
+
/**
|
|
17
|
+
* Load rigour config from cwd, falling back to defaults.
|
|
18
|
+
*/
|
|
19
|
+
async function loadConfig(cwd) {
|
|
20
|
+
const configPath = path.join(cwd, 'rigour.yml');
|
|
21
|
+
if (await fs.pathExists(configPath)) {
|
|
22
|
+
const raw = yaml.parse(await fs.readFile(configPath, 'utf-8'));
|
|
23
|
+
return ConfigSchema.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
return ConfigSchema.parse({ version: 1 });
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a file path to absolute, read its content, and return metadata.
|
|
29
|
+
*/
|
|
30
|
+
async function resolveFile(filePath, cwd) {
|
|
31
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
32
|
+
if (!(await fs.pathExists(absPath))) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const content = await fs.readFile(absPath, 'utf-8');
|
|
36
|
+
const relPath = path.relative(cwd, absPath);
|
|
37
|
+
return { absPath, relPath, content };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Run all fast gates on a single file's content.
|
|
41
|
+
*/
|
|
42
|
+
function checkFile(content, relPath, cwd, config) {
|
|
43
|
+
const failures = [];
|
|
44
|
+
const lines = content.split('\n');
|
|
45
|
+
// Gate 1: File size
|
|
46
|
+
const maxLines = config.gates.max_file_lines ?? 500;
|
|
47
|
+
if (lines.length > maxLines) {
|
|
48
|
+
failures.push({
|
|
49
|
+
gate: 'file-size',
|
|
50
|
+
file: relPath,
|
|
51
|
+
message: `File has ${lines.length} lines (max: ${maxLines})`,
|
|
52
|
+
severity: 'medium',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const isJsTs = JS_TS_PATTERN.test(relPath);
|
|
56
|
+
// Gate 2: Hallucinated imports (JS/TS only)
|
|
57
|
+
if (isJsTs) {
|
|
58
|
+
checkHallucinatedImports(content, relPath, cwd, failures);
|
|
59
|
+
}
|
|
60
|
+
// Gate 3: Promise safety (JS/TS only)
|
|
61
|
+
if (isJsTs) {
|
|
62
|
+
checkPromiseSafety(lines, relPath, failures);
|
|
63
|
+
}
|
|
64
|
+
// Gate 4: Security patterns (all languages)
|
|
65
|
+
checkSecurityPatterns(lines, relPath, failures);
|
|
66
|
+
return failures;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Run fast gates on a set of files.
|
|
70
|
+
* Returns structured JSON for hook consumers.
|
|
71
|
+
*/
|
|
72
|
+
export async function runHookChecker(options) {
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
const { cwd, files, timeout_ms = 5000 } = options;
|
|
75
|
+
const failures = [];
|
|
76
|
+
try {
|
|
77
|
+
const config = await loadConfig(cwd);
|
|
78
|
+
const deadline = start + timeout_ms;
|
|
79
|
+
for (const filePath of files) {
|
|
80
|
+
if (Date.now() > deadline) {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
const resolved = await resolveFile(filePath, cwd);
|
|
84
|
+
if (!resolved) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const fileFailures = checkFile(resolved.content, resolved.relPath, cwd, config);
|
|
88
|
+
failures.push(...fileFailures);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
status: failures.length > 0 ? 'fail' : 'pass',
|
|
92
|
+
failures,
|
|
93
|
+
duration_ms: Date.now() - start,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
98
|
+
return {
|
|
99
|
+
status: 'error',
|
|
100
|
+
failures: [{
|
|
101
|
+
gate: 'hook-checker',
|
|
102
|
+
file: '',
|
|
103
|
+
message: `Hook checker error: ${msg}`,
|
|
104
|
+
severity: 'medium',
|
|
105
|
+
}],
|
|
106
|
+
duration_ms: Date.now() - start,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Check for imports of non-existent relative files.
|
|
112
|
+
*/
|
|
113
|
+
function checkHallucinatedImports(content, relPath, cwd, failures) {
|
|
114
|
+
const importRegex = /(?:import\s+.*\s+from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g;
|
|
115
|
+
let match;
|
|
116
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
117
|
+
const specifier = match[1] || match[2];
|
|
118
|
+
if (!specifier || !specifier.startsWith('.')) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const dir = path.dirname(path.join(cwd, relPath));
|
|
122
|
+
const resolved = path.resolve(dir, specifier);
|
|
123
|
+
const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '/index.ts', '/index.js'];
|
|
124
|
+
const exists = extensions.some(ext => {
|
|
125
|
+
try {
|
|
126
|
+
return fs.existsSync(resolved + ext);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
if (!exists) {
|
|
133
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
134
|
+
failures.push({
|
|
135
|
+
gate: 'hallucinated-imports',
|
|
136
|
+
file: relPath,
|
|
137
|
+
message: `Import '${specifier}' does not resolve to an existing file`,
|
|
138
|
+
severity: 'high',
|
|
139
|
+
line: lineNum,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Check for common async/promise safety issues.
|
|
146
|
+
*/
|
|
147
|
+
function checkPromiseSafety(lines, relPath, failures) {
|
|
148
|
+
for (let i = 0; i < lines.length; i++) {
|
|
149
|
+
const line = lines[i];
|
|
150
|
+
checkUnsafeJsonParse(line, lines, i, relPath, failures);
|
|
151
|
+
checkUnhandledFetch(line, lines, i, relPath, failures);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function checkUnsafeJsonParse(line, lines, i, relPath, failures) {
|
|
155
|
+
if (!/JSON\.parse\s*\(/.test(line)) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const contextStart = Math.max(0, i - 5);
|
|
159
|
+
const context = lines.slice(contextStart, i + 1).join('\n');
|
|
160
|
+
if (!/try\s*\{/.test(context)) {
|
|
161
|
+
failures.push({
|
|
162
|
+
gate: 'promise-safety',
|
|
163
|
+
file: relPath,
|
|
164
|
+
message: 'JSON.parse() without try/catch — crashes on malformed input',
|
|
165
|
+
severity: 'medium',
|
|
166
|
+
line: i + 1,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function checkUnhandledFetch(line, lines, i, relPath, failures) {
|
|
171
|
+
if (!/\bfetch\s*\(/.test(line) || /\.catch\b/.test(line) || /await/.test(line)) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const contextEnd = Math.min(lines.length, i + 3);
|
|
175
|
+
const afterContext = lines.slice(i, contextEnd).join('\n');
|
|
176
|
+
const beforeContext = lines.slice(Math.max(0, i - 5), i + 1).join('\n');
|
|
177
|
+
if (!/\.catch\b/.test(afterContext) && !/try\s*\{/.test(beforeContext)) {
|
|
178
|
+
failures.push({
|
|
179
|
+
gate: 'promise-safety',
|
|
180
|
+
file: relPath,
|
|
181
|
+
message: 'fetch() without error handling',
|
|
182
|
+
severity: 'medium',
|
|
183
|
+
line: i + 1,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Check for critical security patterns.
|
|
189
|
+
*/
|
|
190
|
+
function checkSecurityPatterns(lines, relPath, failures) {
|
|
191
|
+
const isTestFile = /\.(test|spec|example|mock)\./i.test(relPath);
|
|
192
|
+
for (let i = 0; i < lines.length; i++) {
|
|
193
|
+
const line = lines[i];
|
|
194
|
+
checkHardcodedSecrets(line, i, relPath, isTestFile, failures);
|
|
195
|
+
checkCommandInjection(line, i, relPath, failures);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function checkHardcodedSecrets(line, i, relPath, isTestFile, failures) {
|
|
199
|
+
if (isTestFile) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (/(?:api[_-]?key|secret|password|token)\s*[:=]\s*['"][A-Za-z0-9+/=]{20,}['"]/i.test(line)) {
|
|
203
|
+
failures.push({
|
|
204
|
+
gate: 'security-patterns',
|
|
205
|
+
file: relPath,
|
|
206
|
+
message: 'Possible hardcoded secret or API key',
|
|
207
|
+
severity: 'critical',
|
|
208
|
+
line: i + 1,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function checkCommandInjection(line, i, relPath, failures) {
|
|
213
|
+
if (/(?:exec|spawn|execSync|spawnSync)\s*\(.*\$\{/.test(line)) {
|
|
214
|
+
failures.push({
|
|
215
|
+
gate: 'security-patterns',
|
|
216
|
+
file: relPath,
|
|
217
|
+
message: 'Potential command injection: user input in shell command',
|
|
218
|
+
severity: 'critical',
|
|
219
|
+
line: i + 1,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the hooks fast-checker module.
|
|
3
|
+
* Verifies all 4 fast gates: file-size, hallucinated-imports, promise-safety, security-patterns.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { runHookChecker } from './checker.js';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import yaml from 'yaml';
|
|
11
|
+
describe('runHookChecker', () => {
|
|
12
|
+
let testDir;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-checker-test-'));
|
|
15
|
+
// Write minimal rigour.yml
|
|
16
|
+
fs.writeFileSync(path.join(testDir, 'rigour.yml'), yaml.stringify({
|
|
17
|
+
version: 1,
|
|
18
|
+
gates: { max_file_lines: 50 },
|
|
19
|
+
}));
|
|
20
|
+
// Write package.json for import resolution
|
|
21
|
+
fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({
|
|
22
|
+
name: 'test-proj',
|
|
23
|
+
dependencies: { express: '^4.0.0' },
|
|
24
|
+
}));
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
it('should return pass for clean files', async () => {
|
|
30
|
+
const filePath = path.join(testDir, 'clean.ts');
|
|
31
|
+
fs.writeFileSync(filePath, 'export const x = 1;\n');
|
|
32
|
+
const result = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
33
|
+
expect(result.status).toBe('pass');
|
|
34
|
+
expect(result.failures).toHaveLength(0);
|
|
35
|
+
expect(result.duration_ms).toBeGreaterThanOrEqual(0);
|
|
36
|
+
});
|
|
37
|
+
it('should detect file size violations', async () => {
|
|
38
|
+
const filePath = path.join(testDir, 'big.ts');
|
|
39
|
+
const lines = Array.from({ length: 100 }, (_, i) => `export const v${i} = ${i};`);
|
|
40
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
41
|
+
const result = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
42
|
+
expect(result.status).toBe('fail');
|
|
43
|
+
expect(result.failures.some(f => f.gate === 'file-size')).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
it('should detect hardcoded secrets', async () => {
|
|
46
|
+
const filePath = path.join(testDir, 'auth.ts');
|
|
47
|
+
fs.writeFileSync(filePath, `
|
|
48
|
+
const api_key = "abcdefghijklmnopqrstuvwxyz123456";
|
|
49
|
+
`);
|
|
50
|
+
const result = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
51
|
+
expect(result.status).toBe('fail');
|
|
52
|
+
expect(result.failures.some(f => f.gate === 'security-patterns')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it('should detect command injection patterns', async () => {
|
|
55
|
+
const filePath = path.join(testDir, 'cmd.ts');
|
|
56
|
+
fs.writeFileSync(filePath, `
|
|
57
|
+
import { exec } from 'child_process';
|
|
58
|
+
exec(\`rm -rf \${userInput}\`);
|
|
59
|
+
`);
|
|
60
|
+
const result = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
61
|
+
expect(result.status).toBe('fail');
|
|
62
|
+
expect(result.failures.some(f => f.gate === 'security-patterns' && f.message.includes('command injection'))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
it('should detect JSON.parse without try/catch', async () => {
|
|
65
|
+
const filePath = path.join(testDir, 'parse.ts');
|
|
66
|
+
fs.writeFileSync(filePath, `
|
|
67
|
+
const data = JSON.parse(input);
|
|
68
|
+
console.log(data);
|
|
69
|
+
`);
|
|
70
|
+
const result = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
71
|
+
expect(result.status).toBe('fail');
|
|
72
|
+
expect(result.failures.some(f => f.gate === 'promise-safety')).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
it('should skip non-existent files gracefully', async () => {
|
|
75
|
+
const result = await runHookChecker({
|
|
76
|
+
cwd: testDir,
|
|
77
|
+
files: ['/does/not/exist.ts'],
|
|
78
|
+
});
|
|
79
|
+
expect(result.status).toBe('pass');
|
|
80
|
+
expect(result.failures).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
it('should handle multiple files', async () => {
|
|
83
|
+
const cleanFile = path.join(testDir, 'clean.ts');
|
|
84
|
+
fs.writeFileSync(cleanFile, 'export const x = 1;\n');
|
|
85
|
+
const badFile = path.join(testDir, 'bad.ts');
|
|
86
|
+
fs.writeFileSync(badFile, `const password = "supersecretpassword123456";`);
|
|
87
|
+
const result = await runHookChecker({
|
|
88
|
+
cwd: testDir,
|
|
89
|
+
files: [cleanFile, badFile],
|
|
90
|
+
});
|
|
91
|
+
expect(result.status).toBe('fail');
|
|
92
|
+
expect(result.failures.length).toBeGreaterThan(0);
|
|
93
|
+
});
|
|
94
|
+
it('should handle missing config gracefully', async () => {
|
|
95
|
+
// Remove rigour.yml
|
|
96
|
+
fs.unlinkSync(path.join(testDir, 'rigour.yml'));
|
|
97
|
+
const filePath = path.join(testDir, 'test.ts');
|
|
98
|
+
fs.writeFileSync(filePath, 'export const x = 1;\n');
|
|
99
|
+
const result = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
100
|
+
expect(result.status).toBe('pass');
|
|
101
|
+
});
|
|
102
|
+
it('should complete within timeout', async () => {
|
|
103
|
+
const filePath = path.join(testDir, 'test.ts');
|
|
104
|
+
fs.writeFileSync(filePath, 'export const x = 1;\n');
|
|
105
|
+
const result = await runHookChecker({
|
|
106
|
+
cwd: testDir,
|
|
107
|
+
files: [filePath],
|
|
108
|
+
timeout_ms: 5000,
|
|
109
|
+
});
|
|
110
|
+
expect(result.duration_ms).toBeLessThan(5000);
|
|
111
|
+
});
|
|
112
|
+
it('should detect hallucinated relative imports', async () => {
|
|
113
|
+
const filePath = path.join(testDir, 'app.ts');
|
|
114
|
+
fs.writeFileSync(filePath, `
|
|
115
|
+
import { helper } from './nonexistent-module';
|
|
116
|
+
`);
|
|
117
|
+
const result = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
118
|
+
expect(result.status).toBe('fail');
|
|
119
|
+
expect(result.failures.some(f => f.gate === 'hallucinated-imports')).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
it('should not flag existing relative imports', async () => {
|
|
122
|
+
const helperPath = path.join(testDir, 'helper.ts');
|
|
123
|
+
fs.writeFileSync(helperPath, 'export const help = true;\n');
|
|
124
|
+
const filePath = path.join(testDir, 'app.ts');
|
|
125
|
+
fs.writeFileSync(filePath, `
|
|
126
|
+
import { help } from './helper';
|
|
127
|
+
`);
|
|
128
|
+
const result = await runHookChecker({ cwd: testDir, files: [filePath] });
|
|
129
|
+
const importFailures = result.failures.filter(f => f.gate === 'hallucinated-imports');
|
|
130
|
+
expect(importFailures).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks module — multi-tool hook integration for Rigour.
|
|
3
|
+
*
|
|
4
|
+
* @since v3.0.0
|
|
5
|
+
*/
|
|
6
|
+
export { runHookChecker } from './checker.js';
|
|
7
|
+
export { generateHookFiles } from './templates.js';
|
|
8
|
+
export type { HookTool, HookConfig, HookCheckerResult } from './types.js';
|
|
9
|
+
export { DEFAULT_HOOK_CONFIG, FAST_GATE_IDS } from './types.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone hook checker entry point.
|
|
4
|
+
*
|
|
5
|
+
* Can be invoked two ways:
|
|
6
|
+
* 1. CLI args: node rigour-hook-checker.js --files path/to/file.ts
|
|
7
|
+
* 2. Stdin: echo '{"file_path":"x.ts"}' | node rigour-hook-checker.js --stdin
|
|
8
|
+
*
|
|
9
|
+
* Exit codes:
|
|
10
|
+
* 0 = pass (or warn-only mode)
|
|
11
|
+
* 2 = block (fail + block_on_failure enabled)
|
|
12
|
+
*
|
|
13
|
+
* @since v3.0.0
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone hook checker entry point.
|
|
4
|
+
*
|
|
5
|
+
* Can be invoked two ways:
|
|
6
|
+
* 1. CLI args: node rigour-hook-checker.js --files path/to/file.ts
|
|
7
|
+
* 2. Stdin: echo '{"file_path":"x.ts"}' | node rigour-hook-checker.js --stdin
|
|
8
|
+
*
|
|
9
|
+
* Exit codes:
|
|
10
|
+
* 0 = pass (or warn-only mode)
|
|
11
|
+
* 2 = block (fail + block_on_failure enabled)
|
|
12
|
+
*
|
|
13
|
+
* @since v3.0.0
|
|
14
|
+
*/
|
|
15
|
+
import { runHookChecker } from './checker.js';
|
|
16
|
+
const EMPTY_RESULT = JSON.stringify({ status: 'pass', failures: [], duration_ms: 0 });
|
|
17
|
+
/**
|
|
18
|
+
* Read all of stdin as a string.
|
|
19
|
+
*/
|
|
20
|
+
async function readStdin() {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
for await (const chunk of process.stdin) {
|
|
23
|
+
chunks.push(chunk);
|
|
24
|
+
}
|
|
25
|
+
return Buffer.concat(chunks).toString('utf-8').trim();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse file paths from stdin JSON payload.
|
|
29
|
+
*
|
|
30
|
+
* Supports multiple formats from different tools:
|
|
31
|
+
* Cursor: { file_path, old_content, new_content }
|
|
32
|
+
* Windsurf: { file_path, content }
|
|
33
|
+
* Cline: { toolName, toolInput: { path } }
|
|
34
|
+
* Array: { files: ["a.ts", "b.ts"] }
|
|
35
|
+
*/
|
|
36
|
+
function parseStdinFiles(input) {
|
|
37
|
+
if (!input) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const payload = JSON.parse(input);
|
|
42
|
+
// Array format
|
|
43
|
+
if (Array.isArray(payload.files)) {
|
|
44
|
+
return payload.files;
|
|
45
|
+
}
|
|
46
|
+
// Direct file_path (Cursor/Windsurf)
|
|
47
|
+
if (payload.file_path) {
|
|
48
|
+
return [payload.file_path];
|
|
49
|
+
}
|
|
50
|
+
// Cline format: { toolInput: { path } }
|
|
51
|
+
if (payload.toolInput?.path) {
|
|
52
|
+
return [payload.toolInput.path];
|
|
53
|
+
}
|
|
54
|
+
if (payload.toolInput?.file_path) {
|
|
55
|
+
return [payload.toolInput.file_path];
|
|
56
|
+
}
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Not JSON — treat each line as a file path
|
|
61
|
+
return input.split('\n').map(l => l.trim()).filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Parse file paths from CLI --files argument.
|
|
66
|
+
*/
|
|
67
|
+
function parseCliFiles(args) {
|
|
68
|
+
const filesIdx = args.indexOf('--files');
|
|
69
|
+
if (filesIdx === -1 || !args[filesIdx + 1]) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
return args[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Log failures to stderr in human-readable format.
|
|
76
|
+
*/
|
|
77
|
+
function logFailures(failures) {
|
|
78
|
+
for (const f of failures) {
|
|
79
|
+
const loc = f.line ? `:${f.line}` : '';
|
|
80
|
+
process.stderr.write(`[rigour/${f.gate}] ${f.file}${loc}: ${f.message}\n`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function main() {
|
|
84
|
+
const args = process.argv.slice(2);
|
|
85
|
+
const cwd = process.cwd();
|
|
86
|
+
const files = args.includes('--stdin')
|
|
87
|
+
? parseStdinFiles(await readStdin())
|
|
88
|
+
: parseCliFiles(args);
|
|
89
|
+
if (files.length === 0) {
|
|
90
|
+
process.stdout.write(EMPTY_RESULT);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const result = await runHookChecker({ cwd, files });
|
|
94
|
+
process.stdout.write(JSON.stringify(result));
|
|
95
|
+
if (result.status === 'fail') {
|
|
96
|
+
logFailures(result.failures);
|
|
97
|
+
}
|
|
98
|
+
// Exit 2 = block signal for tools that respect it
|
|
99
|
+
if (result.status === 'fail' && args.includes('--block')) {
|
|
100
|
+
process.exit(2);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
main().catch((err) => {
|
|
104
|
+
process.stderr.write(`Rigour hook checker fatal error: ${err.message}\n`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook configuration templates for each AI coding tool.
|
|
3
|
+
*
|
|
4
|
+
* Each template generates the tool-native config format:
|
|
5
|
+
* - Claude Code: .claude/settings.json (PostToolUse matcher)
|
|
6
|
+
* - Cursor: .cursor/hooks.json (afterFileEdit event)
|
|
7
|
+
* - Cline: .clinerules/hooks/PostToolUse (executable script)
|
|
8
|
+
* - Windsurf: .windsurf/hooks.json (post_write_code event)
|
|
9
|
+
*
|
|
10
|
+
* @since v3.0.0
|
|
11
|
+
*/
|
|
12
|
+
import type { HookTool } from './types.js';
|
|
13
|
+
export interface GeneratedHookFile {
|
|
14
|
+
path: string;
|
|
15
|
+
content: string;
|
|
16
|
+
executable?: boolean;
|
|
17
|
+
description: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Generate hook config files for a specific tool.
|
|
21
|
+
*/
|
|
22
|
+
export declare function generateHookFiles(tool: HookTool, checkerPath: string): GeneratedHookFile[];
|