@rmyndharis/aimhooman 0.1.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.
- package/.agents/rules/aimhooman.md +53 -0
- package/.claude-plugin/marketplace.json +23 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.clinerules/aimhooman.md +53 -0
- package/.codex-plugin/plugin.json +37 -0
- package/.cursor/rules/aimhooman.mdc +59 -0
- package/.gemini/settings.json +7 -0
- package/.github/copilot-instructions.md +53 -0
- package/.github/hooks/aimhooman.json +22 -0
- package/.kiro/steering/aimhooman.md +53 -0
- package/.windsurf/rules/aimhooman.md +57 -0
- package/AGENTS.md +53 -0
- package/CHANGELOG.md +153 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/CONTRIBUTING.md +144 -0
- package/GEMINI.md +53 -0
- package/LICENSE +21 -0
- package/README.md +472 -0
- package/SECURITY.md +74 -0
- package/bin/aimhooman.mjs +1589 -0
- package/docs/design/agent-portability.md +73 -0
- package/docs/design/frictionless-enforcement.md +135 -0
- package/docs/hosts.json +164 -0
- package/docs/logo/aimhooman-logo.png +0 -0
- package/docs/logo/aimhooman.png +0 -0
- package/hooks/hooks.json +29 -0
- package/package.json +77 -0
- package/rules/attribution.json +142 -0
- package/rules/markers.json +88 -0
- package/rules/paths.json +407 -0
- package/rules/secrets.json +90 -0
- package/schemas/overrides.schema.json +134 -0
- package/schemas/project-policy.schema.json +12 -0
- package/schemas/rule-pack.schema.json +126 -0
- package/schemas/scan-report.schema.json +165 -0
- package/skills/aimhooman/SKILL.md +65 -0
- package/src/args.mjs +72 -0
- package/src/atomic-write.mjs +285 -0
- package/src/codex-manifest.mjs +65 -0
- package/src/exclude.mjs +125 -0
- package/src/git-environment.mjs +5 -0
- package/src/git-path.mjs +5 -0
- package/src/githooks.mjs +813 -0
- package/src/gitx.mjs +721 -0
- package/src/history-scan.mjs +295 -0
- package/src/hook.mjs +2602 -0
- package/src/policy-resolver.mjs +200 -0
- package/src/report.mjs +63 -0
- package/src/rules.mjs +509 -0
- package/src/ruleset-text.mjs +29 -0
- package/src/scan-session.mjs +152 -0
- package/src/scan-target.mjs +697 -0
- package/src/scan.mjs +325 -0
- package/src/state.mjs +360 -0
package/src/rules.mjs
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { normalizeGitPath } from './git-path.mjs';
|
|
5
|
+
|
|
6
|
+
const RULES_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'rules');
|
|
7
|
+
const KINDS = new Set(['path', 'message', 'code']);
|
|
8
|
+
const ACTIONS = new Set(['allow', 'review', 'block']);
|
|
9
|
+
const AUTOFIXES = new Set(['remove-whole-line']);
|
|
10
|
+
const PROFILES = ['clean', 'strict', 'compliance'];
|
|
11
|
+
const RULE_FIELDS = new Set([
|
|
12
|
+
'id', 'version', 'provider', 'category', 'confidence', 'kind', 'autofix',
|
|
13
|
+
'match', 'actions', 'reason', 'remediation', 'references',
|
|
14
|
+
]);
|
|
15
|
+
const MAX_LOCAL_CONTENT_PATTERNS = 32;
|
|
16
|
+
const MAX_LOCAL_PATTERN_LENGTH = 512;
|
|
17
|
+
const MAX_LOCAL_PATTERN_TOTAL = 4096;
|
|
18
|
+
const MAX_LOCAL_PATH_PATTERNS = 32;
|
|
19
|
+
const MAX_LOCAL_GLOB_LENGTH = 512;
|
|
20
|
+
const MAX_LOCAL_GLOB_TOTAL = 4096;
|
|
21
|
+
export const MAX_LOCAL_MATCH_INPUT = 16_384;
|
|
22
|
+
const MAX_LOCAL_QUANTIFIERS = 16;
|
|
23
|
+
|
|
24
|
+
// RulePackError makes a loader failure actionable without requiring callers to
|
|
25
|
+
// parse a raw JSON/RegExp exception. `source` is either "builtin" or "local".
|
|
26
|
+
export class RulePackError extends Error {
|
|
27
|
+
constructor(source, file, code, detail, cause) {
|
|
28
|
+
const label = source === 'builtin' ? basename(file) : file;
|
|
29
|
+
super(`${source} rule pack "${label}": ${detail}`);
|
|
30
|
+
this.name = 'RulePackError';
|
|
31
|
+
this.source = source;
|
|
32
|
+
this.file = file;
|
|
33
|
+
this.code = code;
|
|
34
|
+
if (cause) this.cause = cause;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The name is retained for compatibility with existing internal callers. The
|
|
39
|
+
// returned matcher exposes RegExp's test() shape, but evaluates the glob with
|
|
40
|
+
// dynamic programming so repeated wildcards cannot trigger backtracking.
|
|
41
|
+
// Supports ** (any depth, including zero), * (within a segment), ? (one non-slash),
|
|
42
|
+
// and [class] character classes (POSIX [] for a literal ] member, e.g. []x]).
|
|
43
|
+
export function globToRegExp(glob) {
|
|
44
|
+
const tokens = [];
|
|
45
|
+
for (let i = 0; i < glob.length;) {
|
|
46
|
+
const c = glob[i];
|
|
47
|
+
if (c === '*') {
|
|
48
|
+
if (glob[i + 1] === '*') {
|
|
49
|
+
if (glob[i + 2] === '/') {
|
|
50
|
+
tokens.push({ type: 'globstar-slash' });
|
|
51
|
+
i += 3;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
tokens.push({ type: 'globstar' });
|
|
55
|
+
i += 2;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
tokens.push({ type: 'star' });
|
|
59
|
+
i += 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (c === '?') {
|
|
63
|
+
tokens.push({ type: 'any' });
|
|
64
|
+
i += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (c === '[') {
|
|
68
|
+
let j = i + 1;
|
|
69
|
+
if (glob[j] === '!') j += 1;
|
|
70
|
+
// POSIX glob: a ] right after [ (or [!) is a literal member, not the
|
|
71
|
+
// class closer, so []x] matches ] or x.
|
|
72
|
+
if (glob[j] === ']') j += 1;
|
|
73
|
+
const end = glob.indexOf(']', j);
|
|
74
|
+
if (end < 0) {
|
|
75
|
+
tokens.push({ type: 'literal', value: '[' });
|
|
76
|
+
i += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let body = glob.slice(i + 1, end);
|
|
80
|
+
if (body.startsWith('!')) body = '^' + body.slice(1);
|
|
81
|
+
// Escape backslashes and ] in the class body so a trailing \ cannot
|
|
82
|
+
// escape the closing ] (unterminated RegExp → whole local pack
|
|
83
|
+
// rejected) and a literal ] member is unambiguous.
|
|
84
|
+
const escaped = body.replace(/\\/g, '\\\\').replace(/]/g, '\\]');
|
|
85
|
+
tokens.push({ type: 'class', regexp: new RegExp(`^[${escaped}]$`) });
|
|
86
|
+
i = end + 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
tokens.push({ type: 'literal', value: c });
|
|
90
|
+
i += 1;
|
|
91
|
+
}
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
test(value) {
|
|
94
|
+
return matchGlob(tokens, String(value));
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function matchGlob(tokens, input) {
|
|
100
|
+
let previous = new Uint8Array(input.length + 1);
|
|
101
|
+
previous[0] = 1;
|
|
102
|
+
for (const token of tokens) {
|
|
103
|
+
const next = new Uint8Array(input.length + 1);
|
|
104
|
+
if (token.type === 'star' || token.type === 'globstar') {
|
|
105
|
+
for (let index = 0; index <= input.length; index++) {
|
|
106
|
+
if (previous[index]) next[index] = 1;
|
|
107
|
+
if (index < input.length && next[index]
|
|
108
|
+
&& (token.type === 'globstar' || input[index] !== '/')) {
|
|
109
|
+
next[index + 1] = 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} else if (token.type === 'globstar-slash') {
|
|
113
|
+
let canStart = false;
|
|
114
|
+
for (let index = 0; index <= input.length; index++) {
|
|
115
|
+
if (previous[index]) next[index] = 1;
|
|
116
|
+
if (index === 0) continue;
|
|
117
|
+
if (previous[index - 1]) canStart = true;
|
|
118
|
+
if (canStart && input[index - 1] === '/') next[index] = 1;
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
for (let index = 0; index < input.length; index++) {
|
|
122
|
+
if (!previous[index]) continue;
|
|
123
|
+
const character = input[index];
|
|
124
|
+
if ((token.type === 'literal' && character === token.value)
|
|
125
|
+
|| (token.type === 'any' && character !== '/')
|
|
126
|
+
|| (token.type === 'class' && token.regexp.test(character))) {
|
|
127
|
+
next[index + 1] = 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
previous = next;
|
|
132
|
+
}
|
|
133
|
+
return previous[input.length] === 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// compileContent builds a RegExp from a rule pattern. JavaScript has no inline
|
|
137
|
+
// (?i) flag, so a leading "(?i)" is stripped and mapped to the 'i' flag.
|
|
138
|
+
function compileContent(pattern, context) {
|
|
139
|
+
let flags = '';
|
|
140
|
+
let src = pattern;
|
|
141
|
+
if (src.startsWith('(?i)')) {
|
|
142
|
+
flags = 'i';
|
|
143
|
+
src = src.slice(4);
|
|
144
|
+
}
|
|
145
|
+
if (context.source === 'local') validateLocalPattern(src);
|
|
146
|
+
return new RegExp(src, flags);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Local expressions run inside Git hooks, so reject the common unbounded-work
|
|
150
|
+
// shapes before compiling them. Built-in packs are maintained with the package
|
|
151
|
+
// and are not subject to these compatibility limits.
|
|
152
|
+
function validateLocalPattern(pattern) {
|
|
153
|
+
if (pattern.length > MAX_LOCAL_PATTERN_LENGTH) {
|
|
154
|
+
throw new Error(`pattern exceeds ${MAX_LOCAL_PATTERN_LENGTH} characters`);
|
|
155
|
+
}
|
|
156
|
+
// Ignore escaped-backslash pairs first: `\\1` is a literal backslash + 1,
|
|
157
|
+
// not a backreference. Only a backslash that survives on its own before a
|
|
158
|
+
// digit/k< is a real backreference.
|
|
159
|
+
const withoutEscapedBackslashes = pattern.replace(/\\\\/g, '');
|
|
160
|
+
if (/\\(?:[1-9]|k<)/.test(withoutEscapedBackslashes)) {
|
|
161
|
+
throw new Error('backreferences are not allowed in local patterns');
|
|
162
|
+
}
|
|
163
|
+
assertFlatLocalPattern(pattern);
|
|
164
|
+
|
|
165
|
+
let inClass = false;
|
|
166
|
+
let quantifiers = 0;
|
|
167
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
168
|
+
const char = pattern[i];
|
|
169
|
+
if (char === '\\') {
|
|
170
|
+
i += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (inClass) {
|
|
174
|
+
if (char === ']') inClass = false;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (char === '[') {
|
|
178
|
+
inClass = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const repeat = repeatAt(pattern, i);
|
|
182
|
+
if (repeat) {
|
|
183
|
+
quantifiers += 1;
|
|
184
|
+
if (quantifiers > MAX_LOCAL_QUANTIFIERS) {
|
|
185
|
+
throw new Error(`local patterns may contain at most ${MAX_LOCAL_QUANTIFIERS} quantifiers`);
|
|
186
|
+
}
|
|
187
|
+
// assertFlatLocalPattern already rejected groups, alternation, and
|
|
188
|
+
// lookaround; the check below rejects every variable quantifier
|
|
189
|
+
// (*, +, ?, {n,}, {n,m}). Only fixed {n} repeats survive, and fixed
|
|
190
|
+
// repeats cannot backtrack. Local patterns are therefore restricted
|
|
191
|
+
// to literals, character classes, and fixed {n} repeats.
|
|
192
|
+
if (repeat.minimum !== repeat.maximum) {
|
|
193
|
+
throw new Error('variable quantifiers are not allowed in local patterns; use a fixed {n} repeat');
|
|
194
|
+
}
|
|
195
|
+
if (Number.isFinite(repeat.maximum) && repeat.maximum > 1_000) {
|
|
196
|
+
throw new Error('repeat bounds above 1000 are not allowed in local patterns');
|
|
197
|
+
}
|
|
198
|
+
i += repeat.length - 1;
|
|
199
|
+
if (pattern[i + 1] === '?') i += 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Local expressions use a flat subset whose work can be bounded by the atom
|
|
205
|
+
// and quantifier limits below. JavaScript cannot interrupt one RegExp match, so
|
|
206
|
+
// nested backtracking constructs are rejected instead of timed heuristically.
|
|
207
|
+
function assertFlatLocalPattern(pattern) {
|
|
208
|
+
let inClass = false;
|
|
209
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
210
|
+
const character = pattern[index];
|
|
211
|
+
if (character === '\\') {
|
|
212
|
+
index += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (inClass) {
|
|
216
|
+
if (character === ']') inClass = false;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (character === '[') {
|
|
220
|
+
inClass = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (character === '(' || character === ')' || character === '|') {
|
|
224
|
+
throw new Error('groups, alternation, and lookaround are not allowed in local patterns');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function repeatAt(pattern, index) {
|
|
230
|
+
const char = pattern[index];
|
|
231
|
+
if (char === '*') return { length: 1, minimum: 0, maximum: Infinity };
|
|
232
|
+
if (char === '+') return { length: 1, minimum: 1, maximum: Infinity };
|
|
233
|
+
if (char === '?') return { length: 1, minimum: 0, maximum: 1 };
|
|
234
|
+
if (char !== '{') return null;
|
|
235
|
+
const match = /^\{(\d+)(?:,(\d*))?\}/.exec(pattern.slice(index));
|
|
236
|
+
if (!match) return null;
|
|
237
|
+
const maximum = match[2] === undefined
|
|
238
|
+
? Number(match[1])
|
|
239
|
+
: match[2] === '' ? Infinity : Number(match[2]);
|
|
240
|
+
return { length: match[0].length, minimum: Number(match[1]), maximum };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isObject(value) {
|
|
244
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function nonEmptyString(value) {
|
|
248
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function validateStringArray(value, field, ruleLabel, required) {
|
|
252
|
+
if (value === undefined && !required) return [];
|
|
253
|
+
if (!Array.isArray(value) || (required && value.length === 0)) {
|
|
254
|
+
throw new Error(`${ruleLabel} ${field} must be ${required ? 'a non-empty' : 'an'} array of strings`);
|
|
255
|
+
}
|
|
256
|
+
if (value.some((item) => !nonEmptyString(item))) {
|
|
257
|
+
throw new Error(`${ruleLabel} ${field} must contain only non-empty strings`);
|
|
258
|
+
}
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateLocalPathPatterns(paths, except, label) {
|
|
263
|
+
const patterns = [...paths, ...except];
|
|
264
|
+
if (patterns.length > MAX_LOCAL_PATH_PATTERNS) {
|
|
265
|
+
throw new Error(`${label} path scopes may contain at most ${MAX_LOCAL_PATH_PATTERNS} local globs`);
|
|
266
|
+
}
|
|
267
|
+
if (patterns.some((pattern) => pattern.length > MAX_LOCAL_GLOB_LENGTH)) {
|
|
268
|
+
throw new Error(`${label} local globs may contain at most ${MAX_LOCAL_GLOB_LENGTH} characters`);
|
|
269
|
+
}
|
|
270
|
+
const total = patterns.reduce((sum, pattern) => sum + pattern.length, 0);
|
|
271
|
+
if (total > MAX_LOCAL_GLOB_TOTAL) {
|
|
272
|
+
throw new Error(`${label} path scopes exceed the ${MAX_LOCAL_GLOB_TOTAL}-character local limit`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateRule(rule, index, context) {
|
|
277
|
+
const fallback = `rule at index ${index}`;
|
|
278
|
+
if (!isObject(rule)) throw new Error(`${fallback} must be an object`);
|
|
279
|
+
const label = nonEmptyString(rule.id) ? `rule "${rule.id}"` : fallback;
|
|
280
|
+
const unknownRuleFields = Object.keys(rule).filter((field) => !RULE_FIELDS.has(field));
|
|
281
|
+
if (unknownRuleFields.length) {
|
|
282
|
+
throw new Error(`${label} has unsupported field${unknownRuleFields.length === 1 ? '' : 's'}: ${unknownRuleFields.join(', ')}`);
|
|
283
|
+
}
|
|
284
|
+
for (const field of ['id', 'provider', 'category', 'kind', 'reason']) {
|
|
285
|
+
if (!nonEmptyString(rule[field])) throw new Error(`${label} ${field} must be a non-empty string`);
|
|
286
|
+
}
|
|
287
|
+
if (rule.version !== undefined && (!Number.isInteger(rule.version) || rule.version < 1)) {
|
|
288
|
+
throw new Error(`${label} version must be a positive integer`);
|
|
289
|
+
}
|
|
290
|
+
if (rule.confidence !== undefined && !nonEmptyString(rule.confidence)) {
|
|
291
|
+
throw new Error(`${label} confidence must be a non-empty string`);
|
|
292
|
+
}
|
|
293
|
+
if (!KINDS.has(rule.kind)) {
|
|
294
|
+
throw new Error(`${label} kind must be one of: ${[...KINDS].join(', ')}`);
|
|
295
|
+
}
|
|
296
|
+
if (!isObject(rule.match)) throw new Error(`${label} match must be an object`);
|
|
297
|
+
const matchFields = rule.kind === 'path'
|
|
298
|
+
? new Set(['paths', 'except', 'path_case'])
|
|
299
|
+
: rule.kind === 'message'
|
|
300
|
+
? new Set(['content'])
|
|
301
|
+
: new Set(['content', 'paths', 'except', 'path_case']);
|
|
302
|
+
const unknownMatchFields = Object.keys(rule.match).filter((field) => !matchFields.has(field));
|
|
303
|
+
if (unknownMatchFields.length) {
|
|
304
|
+
throw new Error(`${label} match has unsupported field${unknownMatchFields.length === 1 ? '' : 's'}: ${unknownMatchFields.join(', ')}`);
|
|
305
|
+
}
|
|
306
|
+
if (rule.kind === 'path') {
|
|
307
|
+
const paths = validateStringArray(rule.match.paths, 'match.paths', label, true);
|
|
308
|
+
const except = validateStringArray(rule.match.except, 'match.except', label, false);
|
|
309
|
+
validatePathCase(rule.match.path_case, label);
|
|
310
|
+
if (context.source === 'local') validateLocalPathPatterns(paths, except, label);
|
|
311
|
+
} else {
|
|
312
|
+
const content = validateStringArray(rule.match.content, 'match.content', label, true);
|
|
313
|
+
if (context.source === 'local') {
|
|
314
|
+
if (content.length > MAX_LOCAL_CONTENT_PATTERNS) {
|
|
315
|
+
throw new Error(`${label} match.content may contain at most ${MAX_LOCAL_CONTENT_PATTERNS} local patterns`);
|
|
316
|
+
}
|
|
317
|
+
const total = content.reduce((sum, pattern) => sum + pattern.length, 0);
|
|
318
|
+
if (total > MAX_LOCAL_PATTERN_TOTAL) {
|
|
319
|
+
throw new Error(`${label} match.content exceeds the ${MAX_LOCAL_PATTERN_TOTAL}-character local limit`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (rule.kind === 'code') {
|
|
323
|
+
const paths = validateStringArray(rule.match.paths, 'match.paths', label, false);
|
|
324
|
+
const except = validateStringArray(rule.match.except, 'match.except', label, false);
|
|
325
|
+
validatePathCase(rule.match.path_case, label);
|
|
326
|
+
if (context.source === 'local') validateLocalPathPatterns(paths, except, label);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (!isObject(rule.actions)) throw new Error(`${label} actions must be an object`);
|
|
330
|
+
const unknownActions = Object.keys(rule.actions).filter((field) => !PROFILES.includes(field));
|
|
331
|
+
if (unknownActions.length) {
|
|
332
|
+
throw new Error(`${label} actions has unsupported field${unknownActions.length === 1 ? '' : 's'}: ${unknownActions.join(', ')}`);
|
|
333
|
+
}
|
|
334
|
+
if (!PROFILES.some((profile) => rule.actions[profile] !== undefined)) {
|
|
335
|
+
throw new Error(`${label} actions must define at least one supported profile`);
|
|
336
|
+
}
|
|
337
|
+
for (const profile of PROFILES) {
|
|
338
|
+
if (rule.actions[profile] !== undefined && !ACTIONS.has(rule.actions[profile])) {
|
|
339
|
+
throw new Error(`${label} actions.${profile} must be one of: ${[...ACTIONS].join(', ')}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
validateStringArray(rule.remediation, 'remediation', label, false);
|
|
343
|
+
validateStringArray(rule.references, 'references', label, false);
|
|
344
|
+
if (rule.autofix !== undefined) {
|
|
345
|
+
if (!AUTOFIXES.has(rule.autofix)) {
|
|
346
|
+
throw new Error(`${label} autofix must be one of: ${[...AUTOFIXES].join(', ')}`);
|
|
347
|
+
}
|
|
348
|
+
if (rule.kind !== 'message') {
|
|
349
|
+
throw new Error(`${label} autofix is only valid for message rules`);
|
|
350
|
+
}
|
|
351
|
+
if (rule.confidence !== 'high' || rule.actions.clean !== 'block') {
|
|
352
|
+
throw new Error(`${label} autofix requires high confidence and actions.clean=block`);
|
|
353
|
+
}
|
|
354
|
+
if (context.source !== 'builtin') {
|
|
355
|
+
throw new Error(`${label} local rules cannot edit commit messages automatically`);
|
|
356
|
+
}
|
|
357
|
+
const exact = rule.match.content.every((pattern) => {
|
|
358
|
+
const source = pattern.startsWith('(?i)') ? pattern.slice(4) : pattern;
|
|
359
|
+
return source.startsWith('^') && source.endsWith('$');
|
|
360
|
+
});
|
|
361
|
+
if (!exact) throw new Error(`${label} autofix patterns must match a whole line`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function validatePathCase(value, label) {
|
|
366
|
+
if (value !== undefined && value !== 'sensitive' && value !== 'insensitive') {
|
|
367
|
+
throw new Error(`${label} match.path_case must be sensitive or insensitive`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// compileRule validates and precomputes regexps without mutating the parsed pack.
|
|
372
|
+
function compileRule(rule, index, context) {
|
|
373
|
+
try {
|
|
374
|
+
validateRule(rule, index, context);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
throw new RulePackError(context.source, context.file, 'INVALID_SCHEMA', error.message, error);
|
|
377
|
+
}
|
|
378
|
+
const compiled = { ...rule, match: { ...rule.match }, source: context.source };
|
|
379
|
+
try {
|
|
380
|
+
compiled.pathCaseInsensitive = rule.match.path_case === 'insensitive';
|
|
381
|
+
const pathPattern = (value) => (
|
|
382
|
+
compiled.pathCaseInsensitive ? value.toLowerCase() : value
|
|
383
|
+
);
|
|
384
|
+
compiled.pathRes = (rule.match.paths || []).map((value) => globToRegExp(pathPattern(value)));
|
|
385
|
+
compiled.exceptRes = (rule.match.except || []).map((value) => globToRegExp(pathPattern(value)));
|
|
386
|
+
compiled.contentRes = (rule.match.content || []).map((pattern) => compileContent(pattern, context));
|
|
387
|
+
} catch (error) {
|
|
388
|
+
throw new RulePackError(
|
|
389
|
+
context.source,
|
|
390
|
+
context.file,
|
|
391
|
+
'INVALID_REGEX',
|
|
392
|
+
`rule "${rule.id}" has an invalid pattern: ${error.message}`,
|
|
393
|
+
error
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return compiled;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function readPack(file, source, knownIds) {
|
|
400
|
+
let text;
|
|
401
|
+
try {
|
|
402
|
+
text = readFileSync(file, 'utf8');
|
|
403
|
+
} catch (error) {
|
|
404
|
+
throw new RulePackError(source, file, 'READ_ERROR', `cannot read file: ${error.message}`, error);
|
|
405
|
+
}
|
|
406
|
+
let parsed;
|
|
407
|
+
try {
|
|
408
|
+
parsed = JSON.parse(text);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
throw new RulePackError(source, file, 'INVALID_JSON', `invalid JSON: ${error.message}`, error);
|
|
411
|
+
}
|
|
412
|
+
if (!Array.isArray(parsed)) {
|
|
413
|
+
throw new RulePackError(source, file, 'INVALID_SCHEMA', 'pack must be a JSON array');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Compile into a temporary list so one bad rule rejects the entire pack.
|
|
417
|
+
const compiled = parsed.map((rule, index) => compileRule(rule, index, { source, file }));
|
|
418
|
+
const packIds = new Set();
|
|
419
|
+
for (const rule of compiled) {
|
|
420
|
+
if (knownIds.has(rule.id) || packIds.has(rule.id)) {
|
|
421
|
+
throw new RulePackError(
|
|
422
|
+
source,
|
|
423
|
+
file,
|
|
424
|
+
'DUPLICATE_RULE_ID',
|
|
425
|
+
`duplicate rule id "${rule.id}"`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
packIds.add(rule.id);
|
|
429
|
+
}
|
|
430
|
+
return compiled;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function appendPack(rules, knownIds, file, source) {
|
|
434
|
+
const compiled = readPack(file, source, knownIds);
|
|
435
|
+
for (const rule of compiled) {
|
|
436
|
+
knownIds.add(rule.id);
|
|
437
|
+
rules.push(rule);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function localRuleFiles(stateDir) {
|
|
442
|
+
if (!stateDir) return { files: [], error: null };
|
|
443
|
+
const dir = join(stateDir, 'rules');
|
|
444
|
+
try {
|
|
445
|
+
return {
|
|
446
|
+
files: readdirSync(dir).filter((file) => file.endsWith('.json')).sort().map((file) => join(dir, file)),
|
|
447
|
+
error: null,
|
|
448
|
+
};
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (error?.code === 'ENOENT') return { files: [], error: null };
|
|
451
|
+
return {
|
|
452
|
+
files: [],
|
|
453
|
+
error: new RulePackError('local', dir, 'READ_ERROR', `cannot read directory: ${error.message}`, error),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Loads valid rules and reports malformed local packs separately. Built-in
|
|
459
|
+
// failures remain fatal because there is no trustworthy engine without them.
|
|
460
|
+
export function loadRulesWithDiagnostics(stateDir) {
|
|
461
|
+
const rules = [];
|
|
462
|
+
const errors = [];
|
|
463
|
+
const knownIds = new Set();
|
|
464
|
+
for (const file of readdirSync(RULES_DIR).filter((file) => file.endsWith('.json')).sort()) {
|
|
465
|
+
appendPack(rules, knownIds, join(RULES_DIR, file), 'builtin');
|
|
466
|
+
}
|
|
467
|
+
const local = localRuleFiles(stateDir);
|
|
468
|
+
if (local.error) errors.push(local.error);
|
|
469
|
+
for (const file of local.files) {
|
|
470
|
+
try {
|
|
471
|
+
appendPack(rules, knownIds, file, 'local');
|
|
472
|
+
} catch (error) {
|
|
473
|
+
errors.push(error instanceof RulePackError
|
|
474
|
+
? error
|
|
475
|
+
: new RulePackError('local', file, 'UNKNOWN_ERROR', error.message, error));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return { rules, errors };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Compatibility API: callers that have not opted into diagnostics still get
|
|
482
|
+
// the original all-or-error behavior.
|
|
483
|
+
export function loadRules(stateDir) {
|
|
484
|
+
const result = loadRulesWithDiagnostics(stateDir);
|
|
485
|
+
if (result.errors.length) throw result.errors[0];
|
|
486
|
+
return result.rules;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function actionFor(rule, profile) {
|
|
490
|
+
return rule.actions[profile] || rule.actions.clean || 'review';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function matchesPath(rule, p) {
|
|
494
|
+
const candidate = rule.pathCaseInsensitive ? p.toLowerCase() : p;
|
|
495
|
+
if (!rule.pathRes.some((re) => re.test(candidate))) return false;
|
|
496
|
+
return !rule.exceptRes.some((re) => re.test(candidate));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function matchesContent(rule, line, path) {
|
|
500
|
+
if (rule.kind === 'code') {
|
|
501
|
+
const normalized = normalizeGitPath(path);
|
|
502
|
+
const candidate = rule.pathCaseInsensitive ? normalized.toLowerCase() : normalized;
|
|
503
|
+
if (rule.pathRes.length && !rule.pathRes.some((re) => re.test(candidate))) return false;
|
|
504
|
+
if (rule.exceptRes.some((re) => re.test(candidate))) return false;
|
|
505
|
+
}
|
|
506
|
+
const input = String(line);
|
|
507
|
+
if (rule.source === 'local' && input.length > MAX_LOCAL_MATCH_INPUT) return false;
|
|
508
|
+
return rule.contentRes.some((re) => re.test(input));
|
|
509
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const RULESET_START = '<!-- aimhooman:ruleset-start -->';
|
|
2
|
+
export const RULESET_END = '<!-- aimhooman:ruleset-end -->';
|
|
3
|
+
|
|
4
|
+
export function extractRuleset(text, label = 'ruleset source') {
|
|
5
|
+
const source = String(text);
|
|
6
|
+
const starts = occurrences(source, RULESET_START);
|
|
7
|
+
const ends = occurrences(source, RULESET_END);
|
|
8
|
+
if (starts.length !== 1 || ends.length !== 1 || starts[0] >= ends[0]) {
|
|
9
|
+
throw new Error(`${label} must contain one ordered aimhooman ruleset marker pair`);
|
|
10
|
+
}
|
|
11
|
+
const beginning = starts[0] + RULESET_START.length;
|
|
12
|
+
return source.slice(beginning, ends[0]).replace(/^\r?\n/, '').replace(/\r?\n$/, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function rulesetBlock(text, label) {
|
|
16
|
+
return `${RULESET_START}\n${extractRuleset(text, label)}\n${RULESET_END}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizedRuleset(text, label) {
|
|
20
|
+
return extractRuleset(text, label).replace(/\r\n/g, '\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function occurrences(text, search) {
|
|
24
|
+
const offsets = [];
|
|
25
|
+
for (let offset = text.indexOf(search); offset >= 0; offset = text.indexOf(search, offset + search.length)) {
|
|
26
|
+
offsets.push(offset);
|
|
27
|
+
}
|
|
28
|
+
return offsets;
|
|
29
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { gitEnvironment } from './git-environment.mjs';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_SCAN_LIMITS = Object.freeze({
|
|
5
|
+
maxFileBytes: 2 << 20,
|
|
6
|
+
maxTotalBytes: 64 << 20,
|
|
7
|
+
maxFindings: 1000,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export function scanEntries(repo, engine, entries, options = {}) {
|
|
11
|
+
const limits = { ...DEFAULT_SCAN_LIMITS, ...options };
|
|
12
|
+
const stats = {
|
|
13
|
+
entries: entries.length,
|
|
14
|
+
blob_files: 0,
|
|
15
|
+
objects_read: 0,
|
|
16
|
+
files_scanned: 0,
|
|
17
|
+
bytes_scanned: 0,
|
|
18
|
+
findings_total: 0,
|
|
19
|
+
findings_returned: 0,
|
|
20
|
+
skipped: {},
|
|
21
|
+
};
|
|
22
|
+
const candidates = [];
|
|
23
|
+
let selectedBytes = 0;
|
|
24
|
+
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (entry.type !== 'blob') {
|
|
27
|
+
if (entry.type && entry.type !== 'deleted') increment(stats.skipped, `type:${entry.type}`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
stats.blob_files += 1;
|
|
31
|
+
if (!entry.oid || !Number.isSafeInteger(entry.size) || entry.size < 0) {
|
|
32
|
+
increment(stats.skipped, 'metadata-unavailable');
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (entry.size === 0) {
|
|
36
|
+
increment(stats.skipped, 'empty');
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (entry.size > limits.maxFileBytes) {
|
|
40
|
+
increment(stats.skipped, 'size-limit');
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (selectedBytes + entry.size > limits.maxTotalBytes) {
|
|
44
|
+
increment(stats.skipped, 'total-byte-limit');
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
selectedBytes += entry.size;
|
|
48
|
+
candidates.push(entry);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const objectIds = [...new Set(candidates.map((entry) => entry.oid))];
|
|
52
|
+
const batch = readObjects(repo, objectIds, selectedBytes);
|
|
53
|
+
stats.objects_read = batch.objects.size;
|
|
54
|
+
for (const failure of batch.failures) increment(stats.skipped, failure.reason);
|
|
55
|
+
|
|
56
|
+
const findings = [];
|
|
57
|
+
for (const entry of candidates) {
|
|
58
|
+
const blob = batch.objects.get(entry.oid);
|
|
59
|
+
// A missing blob is already counted in batch.failures above (with a more
|
|
60
|
+
// specific reason); just skip it here.
|
|
61
|
+
if (!blob) continue;
|
|
62
|
+
// Binary detection still reads the blob. Count every examined byte so
|
|
63
|
+
// later commits cannot reset the total budget merely by using NUL data.
|
|
64
|
+
stats.bytes_scanned += blob.length;
|
|
65
|
+
let matched;
|
|
66
|
+
if (isBinary(blob)) {
|
|
67
|
+
increment(stats.skipped, 'binary');
|
|
68
|
+
// Binary classification only skips text-oriented policy rules. Secret
|
|
69
|
+
// signatures are ASCII byte sequences, so latin1 preserves a
|
|
70
|
+
// one-byte-to-one-code-unit view and prevents one NUL from hiding
|
|
71
|
+
// credential material while keeping the existing byte limits.
|
|
72
|
+
matched = engine.checkContent(entry.path, blob.toString('latin1'), {
|
|
73
|
+
categories: ['secret'],
|
|
74
|
+
});
|
|
75
|
+
} else {
|
|
76
|
+
stats.files_scanned += 1;
|
|
77
|
+
matched = engine.checkContent(entry.path, blob.toString('utf8'));
|
|
78
|
+
}
|
|
79
|
+
stats.findings_total += matched.length;
|
|
80
|
+
for (const finding of matched) {
|
|
81
|
+
if (findings.length >= limits.maxFindings) continue;
|
|
82
|
+
findings.push({
|
|
83
|
+
...finding,
|
|
84
|
+
objectId: entry.oid,
|
|
85
|
+
...(entry.commit ? { commit: entry.commit } : {}),
|
|
86
|
+
...(entry.parents?.length ? { parents: entry.parents } : {}),
|
|
87
|
+
...(entry.sourcePath ? { sourcePath: entry.sourcePath } : {}),
|
|
88
|
+
...(entry.status ? { status: entry.status } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
stats.findings_returned = findings.length;
|
|
93
|
+
if (stats.findings_total > findings.length) increment(stats.skipped, 'finding-limit');
|
|
94
|
+
for (const [reason, count] of Object.entries(engine.takeSkipped?.() || {})) {
|
|
95
|
+
stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const incompleteReasons = new Set([
|
|
99
|
+
'metadata-unavailable', 'size-limit', 'total-byte-limit', 'object-read-failed',
|
|
100
|
+
'missing-object', 'unexpected-object', 'finding-limit', 'local-input-limit',
|
|
101
|
+
]);
|
|
102
|
+
const complete = !Object.keys(stats.skipped).some((reason) => incompleteReasons.has(reason));
|
|
103
|
+
return { findings, complete, stats };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readObjects(repo, objectIds, expectedBytes = 0) {
|
|
107
|
+
const unique = [...new Set(objectIds.filter(Boolean))];
|
|
108
|
+
if (!unique.length) return { objects: new Map(), failures: [] };
|
|
109
|
+
const output = execFileSync('git', ['cat-file', '--batch'], {
|
|
110
|
+
cwd: repo.root,
|
|
111
|
+
env: gitEnvironment(),
|
|
112
|
+
input: Buffer.from(unique.join('\n') + '\n'),
|
|
113
|
+
encoding: 'buffer',
|
|
114
|
+
maxBuffer: Math.max(2 * 1024 * 1024, expectedBytes + unique.length * 256 + 1024),
|
|
115
|
+
});
|
|
116
|
+
const objects = new Map();
|
|
117
|
+
const failures = [];
|
|
118
|
+
let offset = 0;
|
|
119
|
+
for (const requested of unique) {
|
|
120
|
+
const newline = output.indexOf(0x0a, offset);
|
|
121
|
+
if (newline < 0) throw new Error('unexpected truncated output from git cat-file --batch');
|
|
122
|
+
const header = output.subarray(offset, newline).toString('utf8');
|
|
123
|
+
offset = newline + 1;
|
|
124
|
+
const fields = header.split(' ');
|
|
125
|
+
if (fields[1] === 'missing') {
|
|
126
|
+
failures.push({ oid: requested, reason: 'missing-object' });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (fields.length !== 3 || fields[1] !== 'blob' || !/^\d+$/.test(fields[2])) {
|
|
130
|
+
failures.push({ oid: requested, reason: 'unexpected-object' });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const size = Number(fields[2]);
|
|
134
|
+
const end = offset + size;
|
|
135
|
+
if (end > output.length || output[end] !== 0x0a) {
|
|
136
|
+
throw new Error('unexpected object framing from git cat-file --batch');
|
|
137
|
+
}
|
|
138
|
+
objects.set(requested, output.subarray(offset, end));
|
|
139
|
+
offset = end + 1;
|
|
140
|
+
}
|
|
141
|
+
return { objects, failures };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function increment(record, key) {
|
|
145
|
+
record[key] = (record[key] || 0) + 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isBinary(buffer) {
|
|
149
|
+
const length = Math.min(buffer.length, 8000);
|
|
150
|
+
for (let index = 0; index < length; index++) if (buffer[index] === 0) return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|