@rmyndharis/aimhooman 0.1.7 → 0.2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +156 -0
- package/README.md +2 -1
- package/bin/aimhooman.mjs +473 -241
- package/package.json +1 -1
- package/rules/paths.json +65 -16
- package/rules/secrets.json +11 -7
- package/src/exclude.mjs +14 -0
- package/src/githooks.mjs +89 -9
- package/src/gitx.mjs +78 -6
- package/src/hook.mjs +79 -4
- package/src/report.mjs +71 -3
- package/src/scan-session.mjs +77 -1
- package/src/scan.mjs +40 -7
package/src/report.mjs
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
// Human-facing and JSON reporting for aimhooman findings.
|
|
2
2
|
|
|
3
|
+
// HUMAN_FINDING_CAP bounds the per-invocation stderr output. A scan that fires
|
|
4
|
+
// many findings of the same rule (a vendored OpenSSL corpus can produce 99) used
|
|
5
|
+
// to emit hundreds of near-identical lines with no truncation marker. The cap
|
|
6
|
+
// prints the first HUMAN_FINDING_CAP finding blocks, then a single summary line
|
|
7
|
+
// for the rest. The JSON report (aimhooman review / --json) is never capped.
|
|
8
|
+
const HUMAN_FINDING_CAP = 20;
|
|
9
|
+
|
|
10
|
+
// UT-06: prefix -> provider for secret.provider-token findings. The rule packs
|
|
11
|
+
// every provider into one pattern set (rules/secrets.json), so the provider
|
|
12
|
+
// name is recovered from the raw matched line at report time — before the
|
|
13
|
+
// redaction below runs. Only the label is ever rendered, never the token.
|
|
14
|
+
// Mirrors the rule's patterns; keep the rule's alternation order.
|
|
15
|
+
const TOKEN_PROVIDERS = [
|
|
16
|
+
['ghp_', 'GitHub'], ['gho_', 'GitHub'], ['ghu_', 'GitHub'], ['ghs_', 'GitHub'],
|
|
17
|
+
['ghr_', 'GitHub'], ['github_pat_', 'GitHub'],
|
|
18
|
+
['glpat-', 'GitLab'],
|
|
19
|
+
['npm_', 'npm'],
|
|
20
|
+
['xoxb-', 'Slack'], ['xoxp-', 'Slack'], ['xoxa-', 'Slack'], ['xoxr-', 'Slack'], ['xoxs-', 'Slack'],
|
|
21
|
+
['sk-ant-', 'Anthropic'],
|
|
22
|
+
['sk-proj-', 'OpenAI'],
|
|
23
|
+
['AIza', 'Google'],
|
|
24
|
+
['sk_live_', 'Stripe'], ['rk_live_', 'Stripe'],
|
|
25
|
+
['hf_', 'Hugging Face'],
|
|
26
|
+
['SG.', 'SendGrid'],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function tokenProvider(text) {
|
|
30
|
+
if (!text) return undefined;
|
|
31
|
+
for (const [prefix, provider] of TOKEN_PROVIDERS) {
|
|
32
|
+
if (text.includes(prefix)) return provider;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
3
37
|
export function human(findings, tone) {
|
|
4
38
|
if (!findings.length) return '';
|
|
5
39
|
let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
|
|
@@ -8,20 +42,54 @@ export function human(findings, tone) {
|
|
|
8
42
|
for (const f of findings) {
|
|
9
43
|
if (f.decision === 'block') block += 1;
|
|
10
44
|
else if (f.decision === 'review') review += 1;
|
|
45
|
+
}
|
|
46
|
+
// Render findings in order up to the cap, then collapse the rest into one
|
|
47
|
+
// summary line. Keeps the first findings fully visible (the actionable ones)
|
|
48
|
+
// while bounding stderr for repeated-rule scans.
|
|
49
|
+
const shown = findings.slice(0, HUMAN_FINDING_CAP);
|
|
50
|
+
const hidden = findings.length - shown.length;
|
|
51
|
+
const fixesPrinted = new Set();
|
|
52
|
+
for (const f of shown) {
|
|
11
53
|
const loc = f.path
|
|
12
54
|
? `${visible(f.path)}${f.line ? `:${f.line}` : ''}`
|
|
13
55
|
: (f.line ? `commit message line ${f.line}` : '');
|
|
14
56
|
const related = (f.matchedRuleIds || []).filter((id) => id !== f.ruleId);
|
|
15
57
|
const identity = related.length ? `${f.ruleId} (+ ${related.join(', ')})` : f.ruleId;
|
|
16
58
|
const commit = f.commit ? ` [commit ${String(f.commit).slice(0, 12)}]` : '';
|
|
17
|
-
|
|
59
|
+
// UT-06: a provider-token finding used to say only "a provider access
|
|
60
|
+
// token", leaving the developer to guess which credential to revoke.
|
|
61
|
+
// Name the provider in the reason; the token itself stays redacted.
|
|
62
|
+
const provider = f.ruleId === 'secret.provider-token' ? tokenProvider(f.text) : undefined;
|
|
63
|
+
const reason = provider
|
|
64
|
+
? f.reason.replace('provider access token', `provider access token (${provider})`)
|
|
65
|
+
: f.reason;
|
|
66
|
+
out += `${f.decision.toUpperCase().padEnd(6)} ${identity}${commit}\n ${loc}\n ${reason}\n`;
|
|
18
67
|
if (f.text && f.text.trim()) {
|
|
19
68
|
out += ` > ${isSensitive(f) ? '[redacted]' : visible(f.text.trim())}\n`;
|
|
20
69
|
}
|
|
21
|
-
|
|
70
|
+
// Render the whole remediation array, not just the first entry. Several
|
|
71
|
+
// rules carry a second line (e.g. "rotate the key if it was ever exposed")
|
|
72
|
+
// that the previous single-index render dropped silently. UT-08: print a
|
|
73
|
+
// rule's fix once — a repeated rule (20 hits of the same secret rule)
|
|
74
|
+
// used to reprint the identical fix block for every finding.
|
|
75
|
+
const remedies = f.remediation || [];
|
|
76
|
+
if (remedies.length && fixesPrinted.has(f.ruleId)) {
|
|
77
|
+
out += ` fix: as above for ${f.ruleId}\n`;
|
|
78
|
+
} else {
|
|
79
|
+
if (remedies.length) fixesPrinted.add(f.ruleId);
|
|
80
|
+
for (const remedy of remedies) {
|
|
81
|
+
out += ` fix: ${remedy}\n`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
22
84
|
out += '\n';
|
|
23
85
|
}
|
|
24
|
-
|
|
86
|
+
if (hidden > 0) {
|
|
87
|
+
out += `… and ${hidden} more ${hidden === 1 ? 'finding' : 'findings'} (run 'aimhooman review' for the full list)\n\n`;
|
|
88
|
+
}
|
|
89
|
+
const findingWord = findings.length === 1 ? 'finding' : 'findings';
|
|
90
|
+
const blockWord = block === 1 ? 'block' : 'blocks';
|
|
91
|
+
const reviewWord = review === 1 ? 'review' : 'reviews';
|
|
92
|
+
out += `${findings.length} ${findingWord}: ${block} ${blockWord}, ${review} ${reviewWord}\n`;
|
|
25
93
|
return out;
|
|
26
94
|
}
|
|
27
95
|
|
package/src/scan-session.mjs
CHANGED
|
@@ -86,6 +86,11 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
86
86
|
stats.objects_read = batch.objects.size;
|
|
87
87
|
for (const failure of batch.failures) increment(stats.skipped, failure.reason);
|
|
88
88
|
|
|
89
|
+
// Precompute changed-line ranges for all text candidates in ONE batched
|
|
90
|
+
// diff per (commit, parent) pair (W4 + perf). A missing path key means
|
|
91
|
+
// "scan the whole blob" (staged/snapshot/root entries, or a diff failure).
|
|
92
|
+
const lineRanges = collectLineRanges(repo, candidates);
|
|
93
|
+
|
|
89
94
|
const findings = [];
|
|
90
95
|
for (const entry of candidates) {
|
|
91
96
|
const blob = batch.objects.get(entry.oid);
|
|
@@ -110,7 +115,9 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
110
115
|
});
|
|
111
116
|
} else {
|
|
112
117
|
stats.files_scanned += 1;
|
|
113
|
-
|
|
118
|
+
const ranges = lineRanges.get(entry.path);
|
|
119
|
+
matched = engine.checkContent(entry.path, blob.toString('utf8'),
|
|
120
|
+
ranges && ranges.length ? { lineRanges: ranges } : {});
|
|
114
121
|
}
|
|
115
122
|
stats.findings_total += matched.length;
|
|
116
123
|
for (const finding of matched) {
|
|
@@ -236,3 +243,72 @@ function isBinary(buffer) {
|
|
|
236
243
|
for (let index = 0; index < length; index++) if (buffer[index] === 0) return true;
|
|
237
244
|
return false;
|
|
238
245
|
}
|
|
246
|
+
|
|
247
|
+
// collectLineRanges builds a Map<path, ranges[]> for all entries whose content
|
|
248
|
+
// scan can be narrowed to changed hunks (W4, bug 12d-F1). It batches the work
|
|
249
|
+
// into ONE `git diff --unified=0` per (commit, parent) pair rather than one diff
|
|
250
|
+
// per file — a commit touching 50 files would otherwise pay ~650ms of per-file
|
|
251
|
+
// git subprocess startup. Entries are grouped by their first parent; a normal
|
|
252
|
+
// commit (single parent) is a single diff call covering every file.
|
|
253
|
+
//
|
|
254
|
+
// Entries without commit/parents (tracked snapshots, staged views, root
|
|
255
|
+
// commits) are omitted from the map; the caller treats a missing key as "scan
|
|
256
|
+
// the whole blob" — the safe side for a secret scanner is to scan MORE, not
|
|
257
|
+
// less, so a failure to compute hunks falls back to the pre-W4 behaviour.
|
|
258
|
+
function collectLineRanges(repo, entries) {
|
|
259
|
+
// Group text-candidate entries by their first parent to batch the diffs.
|
|
260
|
+
// Key by `${commit}\0${parent}` so a merge with multiple parents produces
|
|
261
|
+
// one diff per distinct (commit, parent) pair.
|
|
262
|
+
const groups = new Map();
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
if (entry.type === 'blob' && !entry.commit) continue; // staged/snapshot: no diff
|
|
265
|
+
const parent = entry.parents?.[0];
|
|
266
|
+
if (!entry.commit || !parent) continue; // root commit or no parent: whole-blob
|
|
267
|
+
const key = `${entry.commit}\0${parent}`;
|
|
268
|
+
if (!groups.has(key)) groups.set(key, { commit: entry.commit, parent });
|
|
269
|
+
}
|
|
270
|
+
const byPath = new Map();
|
|
271
|
+
for (const { commit, parent } of groups.values()) {
|
|
272
|
+
let output;
|
|
273
|
+
try {
|
|
274
|
+
output = execFileSync('git', [
|
|
275
|
+
'-c', 'core.quotePath=false',
|
|
276
|
+
'diff', '--unified=0', '--no-color', '--no-prefix',
|
|
277
|
+
parent, commit,
|
|
278
|
+
], {
|
|
279
|
+
cwd: repo.root,
|
|
280
|
+
env: gitEnvironment(),
|
|
281
|
+
encoding: 'utf8',
|
|
282
|
+
timeout: GIT_TIMEOUT_MS,
|
|
283
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
284
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
285
|
+
});
|
|
286
|
+
} catch {
|
|
287
|
+
continue; // diff failed → those paths stay absent → whole-blob scan
|
|
288
|
+
}
|
|
289
|
+
// Parse the multi-file diff: track the current file from the `+++ path`
|
|
290
|
+
// line, and attribute each `@@` hunk header to it. core.quotePath=false
|
|
291
|
+
// keeps spaces and non-ASCII paths literal so they match entry.path (which
|
|
292
|
+
// comes from git diff --raw -z, also unquoted). Paths containing newlines
|
|
293
|
+
// are not handled, but they fall back to a whole-blob scan (safe).
|
|
294
|
+
let currentPath = null;
|
|
295
|
+
for (const line of output.split('\n')) {
|
|
296
|
+
// With --no-prefix the +++ line is `+++ path` (no b/ prefix).
|
|
297
|
+
const plusMatch = /^\+\+\+ (.+)$/.exec(line);
|
|
298
|
+
if (plusMatch) {
|
|
299
|
+
currentPath = plusMatch[1] === '/dev/null' ? null : plusMatch[1];
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
303
|
+
if (!match || !currentPath) continue;
|
|
304
|
+
const start = Number(match[1]);
|
|
305
|
+
const count = match[2] === undefined ? 1 : Number(match[2]);
|
|
306
|
+
if (start === 0 || count === 0) continue; // pure deletion, no new content
|
|
307
|
+
const range = { start, end: start + count - 1 };
|
|
308
|
+
const existing = byPath.get(currentPath);
|
|
309
|
+
if (existing) existing.push(range);
|
|
310
|
+
else byPath.set(currentPath, [range]);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return byPath;
|
|
314
|
+
}
|
package/src/scan.mjs
CHANGED
|
@@ -51,13 +51,35 @@ export class Engine {
|
|
|
51
51
|
decide(base, target, rule, context = {}) {
|
|
52
52
|
if (this.denyPaths.has(target) || this.denyRules.has(rule.id)) return 'block';
|
|
53
53
|
if (context.transientAllowRules?.has(rule.id)) return 'allow';
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
// W11 (F2): review binding is profile-dependent.
|
|
55
|
+
// - strict: review binds the EXACT reviewed blob. A changed/copied/
|
|
56
|
+
// symlinked/mode-flipped target is NOT covered — it must be re-
|
|
57
|
+
// reviewed. This is the security property the strict lifecycle tests
|
|
58
|
+
// pin, and strict is the profile that exits 11 on review.
|
|
59
|
+
// - clean/compliance: review is ADVISORY (a stderr message, exit 0),
|
|
60
|
+
// so re-surfacing it on every edit of a reviewed agent-instruction
|
|
61
|
+
// file (CLAUDE.md, AGENTS.md) is pure friction with no security
|
|
62
|
+
// payoff — the security boundary is secret scanning, strict blocks,
|
|
63
|
+
// and reference-transaction. A LIVE-FILE review (newObjectId set)
|
|
64
|
+
// therefore persists per path + rule across edits. A TOMBSTONE
|
|
65
|
+
// review (newObjectId null — a reviewed deletion) keeps the exact
|
|
66
|
+
// match in every profile: a deletion review must not silently cover
|
|
67
|
+
// a later re-adding of the file.
|
|
68
|
+
const strictReview = this.profile === 'strict';
|
|
69
|
+
if (this.scopedAllow.some((entry) => {
|
|
70
|
+
if (entry.target !== target || entry.ruleId !== rule.id) return false;
|
|
71
|
+
if (entry.newObjectId === null) {
|
|
72
|
+
// Tombstone: exact match in every profile.
|
|
73
|
+
return entry.transition === context.transition
|
|
74
|
+
&& entry.newObjectId === context.objectId
|
|
75
|
+
&& entry.newMode === context.mode;
|
|
76
|
+
}
|
|
77
|
+
// Live-file review: exact in strict; path-anchored in clean/compliance.
|
|
78
|
+
return !strictReview
|
|
79
|
+
|| (entry.transition === context.transition
|
|
80
|
+
&& entry.newObjectId === context.objectId
|
|
81
|
+
&& entry.newMode === context.mode);
|
|
82
|
+
})) return 'allow';
|
|
61
83
|
// A rule-level allow cannot bypass the secret-category guard that the
|
|
62
84
|
// path-level allow below enforces: secret rules require an explicit
|
|
63
85
|
// --scope secret-path override on a specific path, never a blanket rule allow.
|
|
@@ -119,7 +141,18 @@ export class Engine {
|
|
|
119
141
|
const categories = Array.isArray(options.categories)
|
|
120
142
|
? new Set(options.categories)
|
|
121
143
|
: null;
|
|
144
|
+
// lineRanges narrows content scanning to changed hunks (W4, bug 12d-F1).
|
|
145
|
+
// When provided (an array of inclusive 1-based {start,end} ranges), only
|
|
146
|
+
// lines falling in a range are evaluated; this stops a file that contains
|
|
147
|
+
// a secret-bearing line ELSEWHERE (a PEM header inside a test string on
|
|
148
|
+
// line 200) from blocking a commit that only edited line 50. Findings
|
|
149
|
+
// keep their real line numbers because lineRecords already anchors them.
|
|
150
|
+
// Absent = scan every line (the pre-W4 behaviour).
|
|
151
|
+
const ranges = Array.isArray(options.lineRanges) && options.lineRanges.length
|
|
152
|
+
? options.lineRanges
|
|
153
|
+
: null;
|
|
122
154
|
for (const record of lineRecords(String(content))) {
|
|
155
|
+
if (ranges && !ranges.some((range) => record.line >= range.start && record.line <= range.end)) continue;
|
|
123
156
|
this.#markLocalInputSkip('code', p, record.text, categories);
|
|
124
157
|
const result = this.#evaluate('code', p, record.text, { categories });
|
|
125
158
|
if (result) out.push(finding(result, { path: p, line: record.line, text: record.text }));
|