@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/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
- out += `${f.decision.toUpperCase().padEnd(6)} ${identity}${commit}\n ${loc}\n ${f.reason}\n`;
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
- if (f.remediation?.length) out += ` fix: ${f.remediation[0]}\n`;
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
- out += `${findings.length} findings: ${block} block, ${review} review\n`;
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
 
@@ -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
- matched = engine.checkContent(entry.path, blob.toString('utf8'));
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
- if (this.scopedAllow.some((entry) => (
55
- entry.target === target
56
- && entry.ruleId === rule.id
57
- && entry.transition === context.transition
58
- && entry.newObjectId === context.objectId
59
- && entry.newMode === context.mode
60
- ))) return 'allow';
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 }));