@rmyndharis/aimhooman 0.1.8 → 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 +87 -0
- package/README.md +2 -1
- package/bin/aimhooman.mjs +158 -8
- package/package.json +1 -1
- package/rules/secrets.json +8 -4
- package/src/exclude.mjs +14 -0
- package/src/githooks.mjs +38 -3
- package/src/gitx.mjs +68 -6
- package/src/report.mjs +47 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,93 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-07-19
|
|
9
|
+
|
|
10
|
+
This release works through the findings of the cross-ecosystem field test
|
|
11
|
+
(8 upstream clones, 6 language loops, a local bare remote, and a private
|
|
12
|
+
GitHub sandbox). The headline: `allow` no longer says yes when it means no —
|
|
13
|
+
a path allow on a file whose content holds a secret is refused up front,
|
|
14
|
+
with the working escape hatch named in the same breath.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Tracked-tree scans (`audit`, `check --tracked`, `init --grandfather-secrets`)
|
|
19
|
+
no longer die with `spawnSync git EPIPE` on a partial clone of a repository
|
|
20
|
+
with submodule pins. A gitlink names a commit of another repository, so
|
|
21
|
+
asking `cat-file` about it triggered a promisor fetch that aborted the
|
|
22
|
+
whole metadata batch ("not our ref"). Gitlinks now skip the metadata read;
|
|
23
|
+
their type comes from the mode. Found by the OpenSSL field run, whose 11
|
|
24
|
+
submodule pins made the grandfather flag seed zero allows.
|
|
25
|
+
- `init --grandfather-secrets` now runs its one-shot tracked scan with the
|
|
26
|
+
total-byte budget raised to the env cap and the finding budget raised to
|
|
27
|
+
100,000. The commit-time defaults (64 MiB, 1,000 findings) silently missed
|
|
28
|
+
fixtures beyond them in exactly the large, fixture-heavy repositories the
|
|
29
|
+
flag exists for; the per-file budget and an explicit env override still
|
|
30
|
+
apply.
|
|
31
|
+
- `aimhooman allow <path>` on a file whose content matches a secret rule
|
|
32
|
+
(e.g. a private key inside an ordinary-looking filename) no longer reports
|
|
33
|
+
`allowed` while the commit stays blocked. The guard now runs the engine's
|
|
34
|
+
secret content rules over the file's bytes, refuses the allow, and points
|
|
35
|
+
at `--scope secret-path`. Files over the scan budget or unreadable skip
|
|
36
|
+
the check; the commit-time scanner still fails closed on those.
|
|
37
|
+
- The scan-incomplete summary now names what was skipped
|
|
38
|
+
(`skipped: size-limit=1 file`) instead of `(size-limit=1)`, which read as
|
|
39
|
+
a one-byte budget rather than a count of files.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- `aimhooman init --grandfather-secrets`: after a successful init, scan the
|
|
44
|
+
tracked tree once and write a `--scope secret-path` allow for every path
|
|
45
|
+
already tracking secret-looking material (test certs, sample keys). New
|
|
46
|
+
secrets stay blocked; only paths found in that scan are allowed. A failed
|
|
47
|
+
or incomplete scan warns without failing the init.
|
|
48
|
+
- Provider-token findings name the provider in human output
|
|
49
|
+
(`A provider access token (GitHub) must not enter Git history.`), so the
|
|
50
|
+
developer knows which credential to revoke. The token itself stays
|
|
51
|
+
redacted; the JSON report is unchanged.
|
|
52
|
+
- Secret content rules now carry a remediation line naming the fixture
|
|
53
|
+
escape hatch: `aimhooman allow <path> --scope secret-path --reason
|
|
54
|
+
"test fixture"`.
|
|
55
|
+
- The pre-commit hook now names locally-ignored AI artifacts once per set
|
|
56
|
+
change (`3 AI artifact(s) present locally are kept out of commits: ...`).
|
|
57
|
+
The prevention layer keeps them out of `git status`, which also kept their
|
|
58
|
+
exclusion silent: a `git add .` never told the developer the chat log did
|
|
59
|
+
not make the commit. The worktree walk is pathspec-pruned to the managed
|
|
60
|
+
exclude patterns, so it costs ~15ms; informational only, it never changes
|
|
61
|
+
an exit code.
|
|
62
|
+
- The reference-transaction veto now notes that the rejected commit object
|
|
63
|
+
remains in the local object store — collected by `git gc --prune=now` when
|
|
64
|
+
nothing else references it — so a secret payload is not mistaken for gone.
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
|
|
68
|
+
- Repeated findings of the same rule print the remediation once, then
|
|
69
|
+
`fix: as above for <rule id>`, instead of reprinting an identical fix
|
|
70
|
+
block for every hit.
|
|
71
|
+
- `init` output now prints `undo: aimhooman uninstall` and notes that known
|
|
72
|
+
AI artifacts are ignored locally (`git status --ignored` shows them).
|
|
73
|
+
|
|
74
|
+
### Performance
|
|
75
|
+
|
|
76
|
+
- `openRepo` resolves the repository in one `git rev-parse` call instead of
|
|
77
|
+
three; every hook spawn pays this, so it was the cheapest milliseconds on
|
|
78
|
+
the commit path. The common-dir answer is resolved against the invoking
|
|
79
|
+
cwd and canonicalized, preserving the previous spelling on symlinked
|
|
80
|
+
paths (macOS `/tmp`), and a path containing a newline falls back to one
|
|
81
|
+
flag per call.
|
|
82
|
+
- The reference-transaction dispatcher exits before the Node spawn for a
|
|
83
|
+
`prepared` transaction that moves neither a branch nor HEAD (ORIG_HEAD,
|
|
84
|
+
tags, remote-tracking refs). Such payloads carry nothing `refcheck`
|
|
85
|
+
scans — but the skip first proves the four dispatchers are still
|
|
86
|
+
installed, so a hook manager wiping them mid-operation is answered with
|
|
87
|
+
a stop, not silence. An ordinary commit's hook time drops by roughly a
|
|
88
|
+
third in the field measurement (982ms → 649ms).
|
|
89
|
+
- Installed Git hook shims export `NODE_COMPILE_CACHE` pointing at a
|
|
90
|
+
per-install directory under the state dir (removed by
|
|
91
|
+
`uninstall --purge-state`), shaving the module parse/compile cost off
|
|
92
|
+
each hook spawn. An unwritable cache dir degrades to the old cold start,
|
|
93
|
+
never to a hook failure.
|
|
94
|
+
|
|
8
95
|
## [0.1.8] - 2026-07-18
|
|
9
96
|
|
|
10
97
|
This release works through the findings of the real-world scenario report
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
11
|
<img src="https://img.shields.io/github/actions/workflow/status/rmyndharis/aimhooman/test.yml?branch=main&label=CI" alt="CI">
|
|
12
|
-
<img src="https://img.shields.io/badge/version-v0.
|
|
12
|
+
<img src="https://img.shields.io/badge/version-v0.2.0-blue" alt="v0.2.0">
|
|
13
13
|
<img src="https://img.shields.io/badge/node-%E2%89%A522.8-339933?logo=node.js&logoColor=white" alt="Node 22.8+">
|
|
14
14
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero dependencies">
|
|
15
15
|
<img src="https://img.shields.io/badge/license-MIT-111111" alt="MIT">
|
|
@@ -130,6 +130,7 @@ Guard a repository:
|
|
|
130
130
|
|
|
131
131
|
```sh
|
|
132
132
|
aimhooman init # git hooks + local excludes; normally no worktree files
|
|
133
|
+
aimhooman init --grandfather-secrets # also allow --scope secret-path for every secret already tracked (fixtures); new secrets stay blocked
|
|
133
134
|
aimhooman status
|
|
134
135
|
aimhooman uninstall # restore hooks/excludes; keep local policy state
|
|
135
136
|
aimhooman uninstall --purge-state # also delete common Git-directory state
|
package/bin/aimhooman.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
3
4
|
import { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -9,6 +10,7 @@ import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
|
|
|
9
10
|
import {
|
|
10
11
|
GitRevisionError,
|
|
11
12
|
gitConfig,
|
|
13
|
+
ignoredByPatterns,
|
|
12
14
|
introducedCommits,
|
|
13
15
|
openRepo,
|
|
14
16
|
readCommitPath,
|
|
@@ -20,10 +22,11 @@ import {
|
|
|
20
22
|
withIndexFromTree,
|
|
21
23
|
} from '../src/gitx.mjs';
|
|
22
24
|
import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
|
|
23
|
-
import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
25
|
+
import { applyExclude, inspectExclude, managedPatterns, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
24
26
|
import { effectiveHooksDir, hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
25
27
|
import { ArgumentError, parseArguments } from '../src/args.mjs';
|
|
26
28
|
import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
|
|
29
|
+
import { DEFAULT_SCAN_LIMITS } from '../src/scan-session.mjs';
|
|
27
30
|
import { resolvePolicy } from '../src/policy-resolver.mjs';
|
|
28
31
|
import { atomicWrite, withLock } from '../src/atomic-write.mjs';
|
|
29
32
|
import { commitParents, resolveCommit } from '../src/history-scan.mjs';
|
|
@@ -214,8 +217,11 @@ function emitDiagnostics(diagnostics = []) {
|
|
|
214
217
|
|
|
215
218
|
function incompleteMessage(scan) {
|
|
216
219
|
const reasons = scan.stats?.skipped || {};
|
|
220
|
+
// Each count is how many items were skipped, not the limit that fired:
|
|
221
|
+
// "(size-limit=1)" read as a one-byte budget. Name the noun so the number
|
|
222
|
+
// cannot be mistaken for the budget it tripped.
|
|
217
223
|
const skipped = Object.entries(reasons)
|
|
218
|
-
.map(([reason, count]) => `${reason}=${count}`)
|
|
224
|
+
.map(([reason, count]) => `${reason}=${count} ${skipCountNoun(reason, count)}`)
|
|
219
225
|
.join(', ');
|
|
220
226
|
// Every other reason is a size or budget the caller can shrink. A pack that
|
|
221
227
|
// will not compile is not, and the warning above already names the file and
|
|
@@ -229,7 +235,7 @@ function incompleteMessage(scan) {
|
|
|
229
235
|
: budgeted
|
|
230
236
|
? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
|
|
231
237
|
: 'reduce the target or limits and retry';
|
|
232
|
-
let message = `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
|
|
238
|
+
let message = `aimhooman: scan incomplete${skipped ? ` (skipped: ${skipped})` : ''}; ${hint}\n`;
|
|
233
239
|
const skippedPaths = scan.stats?.skippedPaths || {};
|
|
234
240
|
const pathLines = [];
|
|
235
241
|
for (const [reason, entries] of Object.entries(skippedPaths)) {
|
|
@@ -242,6 +248,17 @@ function incompleteMessage(scan) {
|
|
|
242
248
|
return message + pathLines.join('');
|
|
243
249
|
}
|
|
244
250
|
|
|
251
|
+
// Most skip reasons tally files; the three below tally something else, so the
|
|
252
|
+
// noun travels with the reason instead of reading "local-pack-error=1 file".
|
|
253
|
+
function skipCountNoun(reason, count) {
|
|
254
|
+
const noun = {
|
|
255
|
+
'finding-limit': 'finding',
|
|
256
|
+
'local-input-limit': 'input',
|
|
257
|
+
'local-pack-error': 'pack',
|
|
258
|
+
}[reason] || 'file';
|
|
259
|
+
return `${noun}${count === 1 ? '' : 's'}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
245
262
|
function formatBytes(bytes) {
|
|
246
263
|
if (bytes == null || bytes < 0) return '?';
|
|
247
264
|
if (bytes < 1024) return `${bytes} B`;
|
|
@@ -371,6 +388,38 @@ function precommitCleanMatches(repo, treeSha, profile) {
|
|
|
371
388
|
&& marker.profile === profile;
|
|
372
389
|
}
|
|
373
390
|
|
|
391
|
+
// F-E1: the prevention layer keeps AI artifacts out of `git status`, which
|
|
392
|
+
// also keeps their exclusion silent — a `git add .` never tells the developer
|
|
393
|
+
// the chat log did not make the commit. Name the artifacts once per set
|
|
394
|
+
// change. The worktree walk is pathspec-pruned to the anchored managed
|
|
395
|
+
// patterns (~15ms; a bare ignored-listing costs ~150ms on a large tree), so
|
|
396
|
+
// `**/`-prefixed duplicates are skipped: nested artifacts still get excluded,
|
|
397
|
+
// they just do not get the notice. Informational only — it must never change
|
|
398
|
+
// an exit code, so every failure inside degrades to silence.
|
|
399
|
+
const IGNORED_NOTICE_VERSION = 1;
|
|
400
|
+
function noticeIgnoredArtifacts(repo) {
|
|
401
|
+
try {
|
|
402
|
+
const patterns = managedPatterns(repo.excludeFile)
|
|
403
|
+
.filter((pattern) => !pattern.startsWith('**/'));
|
|
404
|
+
if (!patterns.length) return;
|
|
405
|
+
const paths = ignoredByPatterns(repo, patterns).sort();
|
|
406
|
+
if (!paths.length) return;
|
|
407
|
+
const hash = createHash('sha256').update(paths.join('\0')).digest('hex');
|
|
408
|
+
const statePath = join(repo.stateDir, 'ignored-notice.json');
|
|
409
|
+
let previous = null;
|
|
410
|
+
try { previous = JSON.parse(readFileSync(statePath, 'utf8')); } catch { /* first run or corrupt state */ }
|
|
411
|
+
if (previous?.version === IGNORED_NOTICE_VERSION && previous.hash === hash) return;
|
|
412
|
+
writeFileSync(statePath, JSON.stringify({ version: IGNORED_NOTICE_VERSION, hash }));
|
|
413
|
+
const shown = paths.slice(0, 5);
|
|
414
|
+
const more = paths.length - shown.length;
|
|
415
|
+
process.stderr.write(
|
|
416
|
+
`aimhooman: ${paths.length} AI artifact(s) present locally are kept out of commits: `
|
|
417
|
+
+ `${shown.map(visible).join(', ')}${more ? `, and ${more} more` : ''} `
|
|
418
|
+
+ "(they stay on disk; 'git status --ignored' lists them; shown once per set change)\n"
|
|
419
|
+
);
|
|
420
|
+
} catch { /* informational only */ }
|
|
421
|
+
}
|
|
422
|
+
|
|
374
423
|
function cmdPrecommit(args) {
|
|
375
424
|
parseNoArguments(args);
|
|
376
425
|
const repo = tryRepo();
|
|
@@ -403,6 +452,7 @@ function cmdPrecommit(args) {
|
|
|
403
452
|
// sha is stable. A missing/stale/mismatched marker makes commit-msg
|
|
404
453
|
// fall back to the full scan, so this is purely an optimization.
|
|
405
454
|
recordPrecommitClean(repo, profile);
|
|
455
|
+
noticeIgnoredArtifacts(repo);
|
|
406
456
|
return profile === 'strict' && reviews.length ? 11 : 0;
|
|
407
457
|
}
|
|
408
458
|
if (profile === 'strict') {
|
|
@@ -479,6 +529,7 @@ function cmdPrecommit(args) {
|
|
|
479
529
|
// not. The request was to commit the artifact, not to stamp the history with
|
|
480
530
|
// its message and no content.
|
|
481
531
|
if (emptied) return 10;
|
|
532
|
+
if (scan.complete) noticeIgnoredArtifacts(repo);
|
|
482
533
|
return scan.complete ? 0 : 31;
|
|
483
534
|
}
|
|
484
535
|
|
|
@@ -746,6 +797,15 @@ function cmdRefcheck(args) {
|
|
|
746
797
|
process.stderr.write(human(scan.findings, tone()));
|
|
747
798
|
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
748
799
|
process.stderr.write(`aimhooman: proposed commit ${revision} was rejected before refs changed\n`);
|
|
800
|
+
// The vetoed commit stays in the object store: the ref never
|
|
801
|
+
// moved, but the bytes — including whatever triggered the block —
|
|
802
|
+
// are still on disk. Whether gc collects it depends on other refs,
|
|
803
|
+
// so phrase the cleanup as conditional; rotating an exposed secret
|
|
804
|
+
// stays the operator's call either way.
|
|
805
|
+
process.stderr.write(
|
|
806
|
+
`aimhooman: note: the rejected commit object remains in the local object store; `
|
|
807
|
+
+ "if nothing else references it, 'git gc --prune=now' removes it when you are done inspecting\n"
|
|
808
|
+
);
|
|
749
809
|
return code;
|
|
750
810
|
}
|
|
751
811
|
}
|
|
@@ -840,8 +900,9 @@ function cmdInit(args) {
|
|
|
840
900
|
profile: { names: ['--profile'], type: 'string', choices: [...PROFILES] },
|
|
841
901
|
global: { names: ['--global'], type: 'boolean' },
|
|
842
902
|
yes: { names: ['--yes'], type: 'boolean' },
|
|
903
|
+
grandfatherSecrets: { names: ['--grandfather-secrets'], type: 'boolean' },
|
|
843
904
|
},
|
|
844
|
-
conflicts: [['profile', 'global']],
|
|
905
|
+
conflicts: [['profile', 'global'], ['grandfatherSecrets', 'global']],
|
|
845
906
|
maxPositionals: 0,
|
|
846
907
|
});
|
|
847
908
|
if (rejectUnsupportedGit()) return 20;
|
|
@@ -926,7 +987,7 @@ function cmdInit(args) {
|
|
|
926
987
|
console.error('aimhooman: not a git repository');
|
|
927
988
|
return 30;
|
|
928
989
|
}
|
|
929
|
-
|
|
990
|
+
const code = withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
|
|
930
991
|
let projectPolicy;
|
|
931
992
|
try {
|
|
932
993
|
projectPolicy = loadProjectPolicy(repo.root);
|
|
@@ -1014,11 +1075,77 @@ function cmdInit(args) {
|
|
|
1014
1075
|
console.log(`aimhooman: initialised (profile: ${profile})`);
|
|
1015
1076
|
console.log(` state: ${repo.stateDir}`);
|
|
1016
1077
|
console.log(` excludes: ${repo.excludeFile}`);
|
|
1078
|
+
console.log(` note: known AI artifacts are now ignored locally; see them with 'git status --ignored'`);
|
|
1017
1079
|
if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
|
|
1018
1080
|
if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
|
|
1019
1081
|
for (const warning of rep.warnings) console.log(` warning: ${warning}`);
|
|
1082
|
+
console.log(' undo: aimhooman uninstall');
|
|
1020
1083
|
return 0;
|
|
1021
1084
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1085
|
+
if (code === 0 && options.grandfatherSecrets) grandfatherTrackedSecrets(repo);
|
|
1086
|
+
return code;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// --grandfather-secrets: a repository that already tracks secret-looking
|
|
1090
|
+
// fixtures (test certs, sample keys) would see every commit touching one
|
|
1091
|
+
// blocked, and allowing each path by hand does not scale. After a successful
|
|
1092
|
+
// init, scan the tracked tree once and write a --scope secret-path allow for
|
|
1093
|
+
// every path with a secret finding. The posture for NEW files is unchanged —
|
|
1094
|
+
// only paths found in this scan get an allow, so a secret added after init is
|
|
1095
|
+
// still blocked. A failed or incomplete scan never fails the init itself: the
|
|
1096
|
+
// guard is already active by then, so the gap is reported as a warning.
|
|
1097
|
+
function grandfatherTrackedSecrets(repo) {
|
|
1098
|
+
let scan;
|
|
1099
|
+
try {
|
|
1100
|
+
// Commit-time budgets exist for latency, but a grandfather scan that
|
|
1101
|
+
// stops at the default 64 MiB total or 1,000 findings silently misses
|
|
1102
|
+
// fixtures in exactly the large, fixture-heavy repositories it exists
|
|
1103
|
+
// for (OpenSSL tracks ~200 MiB and its 279 key files produce more
|
|
1104
|
+
// than a thousand PEM findings). This is a one-shot, operator-invoked
|
|
1105
|
+
// scan of the operator's own repository, so the defaults here are the
|
|
1106
|
+
// same cap an env raise could reach plus a generous finding budget;
|
|
1107
|
+
// the per-file budget and an explicit env override still apply.
|
|
1108
|
+
scan = scanGitTarget(repo, {
|
|
1109
|
+
kind: 'tracked',
|
|
1110
|
+
limits: { maxTotalBytes: MAX_SCAN_LIMIT_BYTES, maxFindings: 100_000, ...scanLimits() },
|
|
1111
|
+
});
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
console.error(`aimhooman: warning: grandfather scan failed (${error.message}); no pre-existing secret paths were allowed`);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (!scan.complete) {
|
|
1117
|
+
console.error('aimhooman: warning: tracked scan was incomplete, so the grandfathered set may be partial; allow the remaining paths with --scope secret-path');
|
|
1118
|
+
}
|
|
1119
|
+
const paths = [...new Set(scan.findings
|
|
1120
|
+
.filter((finding) => finding.category === 'secret' && finding.path)
|
|
1121
|
+
.map((finding) => finding.path))].sort();
|
|
1122
|
+
if (!paths.length) {
|
|
1123
|
+
console.log('aimhooman: no tracked secret findings to grandfather');
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const { engine } = newEngineWithDiagnostics('clean', repo.stateDir);
|
|
1127
|
+
const added = withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
|
|
1128
|
+
const ov = loadOverrides(repo.stateDir);
|
|
1129
|
+
let count = 0;
|
|
1130
|
+
for (const path of paths) {
|
|
1131
|
+
const entry = {
|
|
1132
|
+
target: path,
|
|
1133
|
+
scope: 'secret-path',
|
|
1134
|
+
reason: 'pre-existing tracked fixture (grandfathered at init)',
|
|
1135
|
+
actor: gitConfig(repo.root, 'user.email'),
|
|
1136
|
+
at: new Date().toISOString(),
|
|
1137
|
+
};
|
|
1138
|
+
const sameAllow = (candidate) => (
|
|
1139
|
+
candidate.target === path && effectiveOverrideScope(candidate, engine) === 'secret-path'
|
|
1140
|
+
);
|
|
1141
|
+
if (ov.allow.some(sameAllow)) continue;
|
|
1142
|
+
ov.allow = upsert(ov.allow, entry, sameAllow);
|
|
1143
|
+
count += 1;
|
|
1144
|
+
}
|
|
1145
|
+
saveOverrides(repo.stateDir, ov);
|
|
1146
|
+
return count;
|
|
1147
|
+
});
|
|
1148
|
+
console.log(`aimhooman: grandfathered ${added} tracked secret path(s) with --scope secret-path allows; new files with secrets are still blocked`);
|
|
1022
1149
|
}
|
|
1023
1150
|
|
|
1024
1151
|
function cmdStatus(args) {
|
|
@@ -1131,6 +1258,25 @@ function cmdExplain(args) {
|
|
|
1131
1258
|
return 0;
|
|
1132
1259
|
}
|
|
1133
1260
|
|
|
1261
|
+
// The content half of the secret-allow guard in cmdOverride: run the engine's
|
|
1262
|
+
// own secret-category content rules over the target's bytes. Files over the
|
|
1263
|
+
// scanner's per-file budget, unreadable paths, and non-regular files skip it —
|
|
1264
|
+
// this guard is a UX safety net against an allow that would report success yet
|
|
1265
|
+
// leave the block in place, and the commit-time scanner still fails closed on
|
|
1266
|
+
// whatever was not checked here.
|
|
1267
|
+
function fileContentMatchesSecret(engine, repo, target) {
|
|
1268
|
+
let content;
|
|
1269
|
+
try {
|
|
1270
|
+
const file = join(repo.root, target);
|
|
1271
|
+
const stat = lstatSync(file);
|
|
1272
|
+
if (!stat.isFile() || stat.size > DEFAULT_SCAN_LIMITS.maxFileBytes) return false;
|
|
1273
|
+
content = readFileSync(file, 'utf8');
|
|
1274
|
+
} catch {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
return engine.checkContent(target, content, { categories: ['secret'] }).length > 0;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1134
1280
|
function cmdOverride(args, allow) {
|
|
1135
1281
|
const verb = allow ? 'allow' : 'deny';
|
|
1136
1282
|
const { options, positionals } = parseArguments(args, {
|
|
@@ -1162,9 +1308,13 @@ function cmdOverride(args, allow) {
|
|
|
1162
1308
|
// allow: only --scope secret-path suppresses secret findings, deliberately,
|
|
1163
1309
|
// so a local override cannot hide a possible leaked key. Without this check
|
|
1164
1310
|
// a bare `allow .env.minimal` would report success but leave the block in
|
|
1165
|
-
// place, which reads as a broken allow.
|
|
1311
|
+
// place, which reads as a broken allow. checkPaths only evaluates path
|
|
1312
|
+
// rules, so a content-shaped secret (a private key inside an
|
|
1313
|
+
// ordinary-looking filename) would slip past it; the content half of the
|
|
1314
|
+
// guard closes that hole.
|
|
1166
1315
|
if (allow && scope !== 'secret-path'
|
|
1167
|
-
&& engine.checkPaths([target]).some((finding) => finding.category === 'secret')
|
|
1316
|
+
&& (engine.checkPaths([target]).some((finding) => finding.category === 'secret')
|
|
1317
|
+
|| fileContentMatchesSecret(engine, repo, target))) {
|
|
1168
1318
|
throw new ArgumentError(
|
|
1169
1319
|
`"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
|
|
1170
1320
|
+ '(a local override must not hide a possible leaked key); '
|
|
@@ -1874,7 +2024,7 @@ function usage() {
|
|
|
1874
2024
|
process.stdout.write(`aimhooman ${VERSION} - AI works. Hoomans ship.
|
|
1875
2025
|
|
|
1876
2026
|
Usage:
|
|
1877
|
-
aimhooman init [--profile clean|strict|compliance]
|
|
2027
|
+
aimhooman init [--profile clean|strict|compliance] [--grandfather-secrets]
|
|
1878
2028
|
aimhooman init --global --yes
|
|
1879
2029
|
aimhooman check [--staged] [-m <file>|--message <file>] [--profile ...] [--json]
|
|
1880
2030
|
aimhooman check --commit <rev> | --range <base>...<head> | --tracked
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
|
|
5
5
|
"homepage": "https://github.com/rmyndharis/aimhooman#readme",
|
|
6
6
|
"repository": {
|
package/rules/secrets.json
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
},
|
|
19
19
|
"reason": "Private-key material must not enter Git history.",
|
|
20
20
|
"remediation": [
|
|
21
|
-
"Remove the key from the index, rotate it if exposed, and store it outside the repository."
|
|
21
|
+
"Remove the key from the index, rotate it if exposed, and store it outside the repository.",
|
|
22
|
+
"If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
|
|
22
23
|
]
|
|
23
24
|
},
|
|
24
25
|
{
|
|
@@ -40,7 +41,8 @@
|
|
|
40
41
|
},
|
|
41
42
|
"reason": "A service-account credential must not enter Git history.",
|
|
42
43
|
"remediation": [
|
|
43
|
-
"Remove the credential from the index, rotate it if exposed, and use the service's secret store."
|
|
44
|
+
"Remove the credential from the index, rotate it if exposed, and use the service's secret store.",
|
|
45
|
+
"If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
|
|
44
46
|
]
|
|
45
47
|
},
|
|
46
48
|
{
|
|
@@ -63,7 +65,8 @@
|
|
|
63
65
|
},
|
|
64
66
|
"reason": "AWS credential material must not enter Git history.",
|
|
65
67
|
"remediation": [
|
|
66
|
-
"Remove the credential from the index, rotate it if exposed, and use an AWS credential provider."
|
|
68
|
+
"Remove the credential from the index, rotate it if exposed, and use an AWS credential provider.",
|
|
69
|
+
"If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
|
|
67
70
|
]
|
|
68
71
|
},
|
|
69
72
|
{
|
|
@@ -86,7 +89,8 @@
|
|
|
86
89
|
},
|
|
87
90
|
"reason": "A provider access token must not enter Git history.",
|
|
88
91
|
"remediation": [
|
|
89
|
-
"Remove the token from the index, revoke or rotate it, and use the provider's secret store."
|
|
92
|
+
"Remove the token from the index, revoke or rotate it, and use the provider's secret store.",
|
|
93
|
+
"If this is an intentional fixture, run: aimhooman allow <path> --scope secret-path --reason \"test fixture\"."
|
|
90
94
|
]
|
|
91
95
|
}
|
|
92
96
|
]
|
package/src/exclude.mjs
CHANGED
|
@@ -74,6 +74,20 @@ export function inspectExclude(file, patterns) {
|
|
|
74
74
|
return { installed: true, current: missing.length === 0 && actual.size === patterns.length, missing };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// managedPatterns returns the pattern lines currently inside the managed
|
|
78
|
+
// block, or [] when the file or block is absent. Read-only.
|
|
79
|
+
export function managedPatterns(file) {
|
|
80
|
+
const existing = readExclude(file, null);
|
|
81
|
+
if (existing === null) return [];
|
|
82
|
+
const range = managedRange(existing);
|
|
83
|
+
if (!range) return [];
|
|
84
|
+
return existing
|
|
85
|
+
.slice(range.start + BEGIN.length, range.end)
|
|
86
|
+
.split('\n')
|
|
87
|
+
.map((line) => line.trim())
|
|
88
|
+
.filter((line) => line && !line.startsWith('#') && !line.startsWith('!'));
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
function stripBlock(s) {
|
|
78
92
|
const range = managedRange(s);
|
|
79
93
|
if (!range) return s;
|
package/src/githooks.mjs
CHANGED
|
@@ -651,6 +651,12 @@ function hookScript(name, cmd, cliPath, chainedPath) {
|
|
|
651
651
|
const shellPathValue = hookPathForShell(pathValue);
|
|
652
652
|
const nodeMetadata = Buffer.from(nodePath, 'utf8').toString('base64url');
|
|
653
653
|
const pathMetadata = Buffer.from(pathValue, 'utf8').toString('base64url');
|
|
654
|
+
// chainedPath is <stateDir>/chained/<name> for a repository install and
|
|
655
|
+
// <globalHooksDir>/chained/<name> for a global one, so its grandparent is
|
|
656
|
+
// always per-install state. The compile cache lives there rather than in a
|
|
657
|
+
// shared or temp location: no other repository can pollute it, and
|
|
658
|
+
// `uninstall --purge-state` removes it along with the rest of the state.
|
|
659
|
+
const compileCacheDir = join(dirname(dirname(String(chainedPath))), 'compile-cache');
|
|
654
660
|
const captureTree = name === 'commit-msg'
|
|
655
661
|
? `AIMHOOMAN_COMMIT_TREE=$(PATH="$AIMHOOMAN_PATH" git write-tree) || {
|
|
656
662
|
echo "aimhooman: cannot snapshot the would-be commit tree" >&2
|
|
@@ -690,10 +696,31 @@ function hookScript(name, cmd, cliPath, chainedPath) {
|
|
|
690
696
|
// committed/aborted fire only after refs are locked in, and refcheck can do
|
|
691
697
|
// nothing but return 0 for them (see cmdRefcheck). Short-circuit in the shell
|
|
692
698
|
// so an ordinary commit no longer pays a Node cold start for the committed
|
|
693
|
-
// phase.
|
|
694
|
-
//
|
|
699
|
+
// phase. The prepared filter goes further: a transaction that moves neither
|
|
700
|
+
// a branch nor HEAD (ORIG_HEAD, tags, remote-tracking refs) carries nothing
|
|
701
|
+
// refcheck scans, so it never spawns at all — but only after proving the
|
|
702
|
+
// guard is still there to skip. A hook manager that wipes the dispatchers
|
|
703
|
+
// during a branch-free operation (tag, fetch, stash) would otherwise go
|
|
704
|
+
// unnoticed, because after a full wipe no later hook exists to raise the
|
|
705
|
+
// alarm. Presence is exactly what a deletion removes; content integrity
|
|
706
|
+
// keeps running on every branch transaction via refcheck. Both sit after
|
|
707
|
+
// the chained-hook call, so a chained hook still sees every phase.
|
|
695
708
|
const phaseShortCircuit = name === 'reference-transaction'
|
|
696
|
-
?
|
|
709
|
+
? `case "$1" in committed|aborted) exit 0 ;; esac
|
|
710
|
+
case "$1" in prepared)
|
|
711
|
+
case "$AIMHOOMAN_REF_UPDATES" in
|
|
712
|
+
*refs/heads/*|*" HEAD"*) ;;
|
|
713
|
+
*)
|
|
714
|
+
for AIMHOOMAN_GUARD in pre-commit pre-merge-commit commit-msg reference-transaction; do
|
|
715
|
+
[ -x "$(dirname "$0")/$AIMHOOMAN_GUARD" ] || {
|
|
716
|
+
echo "aimhooman: required Git guards changed while reference-transaction was running; $AIMHOOMAN_GUARD is unavailable. The operation was stopped; run 'aimhooman init' and retry." >&2
|
|
717
|
+
exit 20
|
|
718
|
+
}
|
|
719
|
+
done
|
|
720
|
+
exit 0 ;;
|
|
721
|
+
esac ;;
|
|
722
|
+
esac
|
|
723
|
+
`
|
|
697
724
|
: '';
|
|
698
725
|
const template = `#!/bin/sh -p
|
|
699
726
|
${MARKER} (${name})
|
|
@@ -706,6 +733,7 @@ ${MARKER} (${name})
|
|
|
706
733
|
AIMHOOMAN_CLI=${shq(resolvedCliPath)}
|
|
707
734
|
AIMHOOMAN_NODE=${shq(nodePath)}
|
|
708
735
|
AIMHOOMAN_PATH=${shq(shellPathValue)}
|
|
736
|
+
AIMHOOMAN_COMPILE_CACHE=${shq(compileCacheDir)}
|
|
709
737
|
${captureTree}${captureTransaction}run_aimhooman() {
|
|
710
738
|
if [ ! -f "$AIMHOOMAN_CLI" ]; then
|
|
711
739
|
echo "aimhooman: guard unavailable (aimhooman CLI is missing); allowing this operation without protection. Reinstall aimhooman or remove the managed hooks." >&2
|
|
@@ -731,8 +759,15 @@ ${captureTree}${captureTransaction}run_aimhooman() {
|
|
|
731
759
|
unset BASH_ENV ENV CDPATH
|
|
732
760
|
PATH=$AIMHOOMAN_PATH
|
|
733
761
|
AIMHOOMAN_ACTIVE_HOOK=${shq(name)}
|
|
762
|
+
# A V8 compile cache shared by every hook spawn of this installation shaves
|
|
763
|
+
# the module parse/compile cost off each Node start. Node creates the
|
|
764
|
+
# directory when it can and silently disables the cache when it cannot, so
|
|
765
|
+
# an unwritable state directory degrades to the old cold start, never to a
|
|
766
|
+
# hook failure.
|
|
767
|
+
NODE_COMPILE_CACHE=$AIMHOOMAN_COMPILE_CACHE
|
|
734
768
|
export PATH
|
|
735
769
|
export AIMHOOMAN_ACTIVE_HOOK
|
|
770
|
+
export NODE_COMPILE_CACHE
|
|
736
771
|
"$AIMHOOMAN_NODE" "$AIMHOOMAN_CLI" "$@"
|
|
737
772
|
)
|
|
738
773
|
}
|
package/src/gitx.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
rmSync,
|
|
13
13
|
symlinkSync,
|
|
14
14
|
} from 'node:fs';
|
|
15
|
-
import { isAbsolute, join } from 'node:path';
|
|
15
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
16
16
|
import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
17
17
|
|
|
18
18
|
export class GitRevisionError extends Error {
|
|
@@ -99,7 +99,23 @@ function objectMetadata(repo, oids) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function enrichEntries(repo, entries) {
|
|
102
|
-
const metadata = objectMetadata(
|
|
102
|
+
const metadata = objectMetadata(
|
|
103
|
+
repo,
|
|
104
|
+
entries.filter((entry) => entry.mode !== '160000').map((entry) => entry.oid),
|
|
105
|
+
);
|
|
106
|
+
// Gitlinks (mode 160000) pin a commit of another repository. Enrich them
|
|
107
|
+
// from the local object store when the pin happens to be there, but never
|
|
108
|
+
// let the read fail the scan: in a partial clone asking cat-file about a
|
|
109
|
+
// pin that is not local triggers a promisor fetch that aborts the whole
|
|
110
|
+
// batch ("not our ref"), surfacing as an EPIPE. A failed read leaves the
|
|
111
|
+
// mode-derived type in place with no size.
|
|
112
|
+
try {
|
|
113
|
+
const gitlinks = objectMetadata(
|
|
114
|
+
repo,
|
|
115
|
+
entries.filter((entry) => entry.mode === '160000').map((entry) => entry.oid),
|
|
116
|
+
);
|
|
117
|
+
for (const [oid, object] of gitlinks) metadata.set(oid, object);
|
|
118
|
+
} catch { /* best effort — see above */ }
|
|
103
119
|
return entries.map((entry) => {
|
|
104
120
|
const object = metadata.get(entry.oid) || { type: null, size: null };
|
|
105
121
|
return {
|
|
@@ -146,10 +162,45 @@ function diffEntries(repo, args, renameThreshold = null) {
|
|
|
146
162
|
|
|
147
163
|
// openRepo resolves the repository containing cwd. Throws if not a repo.
|
|
148
164
|
export function openRepo(cwd = process.cwd()) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
// One rev-parse invocation, not three: this runs inside every Git hook
|
|
166
|
+
// spawn, and each git subprocess costs ~13ms of the commit-time budget.
|
|
167
|
+
// The flags print in request order, one value per line.
|
|
168
|
+
const lines = gitStr(
|
|
169
|
+
['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'],
|
|
170
|
+
cwd,
|
|
171
|
+
).split('\n');
|
|
172
|
+
if (lines.length !== 3) {
|
|
173
|
+
// A newline inside the repository path breaks the multi-flag output
|
|
174
|
+
// into extra lines; one flag per call is immune to that.
|
|
175
|
+
const root = gitStr(['rev-parse', '--show-toplevel'], cwd);
|
|
176
|
+
const gitDir = gitStr(['rev-parse', '--absolute-git-dir'], root);
|
|
177
|
+
let commonDir = gitStr(['rev-parse', '--git-common-dir'], root);
|
|
178
|
+
if (!isAbsolute(commonDir)) commonDir = join(root, commonDir);
|
|
179
|
+
return {
|
|
180
|
+
root,
|
|
181
|
+
gitDir,
|
|
182
|
+
commonDir,
|
|
183
|
+
stateDir: join(commonDir, 'aimhooman'),
|
|
184
|
+
excludeFile: join(commonDir, 'info', 'exclude'),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const [root, gitDir, commonRaw] = lines;
|
|
188
|
+
// Only --git-common-dir comes back relative, and it is relative to the cwd
|
|
189
|
+
// the command ran in, never to the toplevel. Anchor the plain-'.git' case
|
|
190
|
+
// (a main worktree from any cwd spelling) on git's own canonical root —
|
|
191
|
+
// the same spelling the three-call version produced — instead of
|
|
192
|
+
// realpath'ing through the cwd: git lengthens 8.3 names on Windows and
|
|
193
|
+
// resolves symlinked cwds on macOS, and matching it keeps one spelling
|
|
194
|
+
// per repository. Anything else (a subdir's ../.git, an uncommon layout)
|
|
195
|
+
// resolves against the invoking cwd, which is the same directory.
|
|
196
|
+
let commonDir;
|
|
197
|
+
if (isAbsolute(commonRaw)) {
|
|
198
|
+
commonDir = commonRaw;
|
|
199
|
+
} else if (commonRaw === '.git') {
|
|
200
|
+
commonDir = join(root, commonRaw);
|
|
201
|
+
} else {
|
|
202
|
+
commonDir = resolve(cwd, commonRaw);
|
|
203
|
+
}
|
|
153
204
|
return {
|
|
154
205
|
root,
|
|
155
206
|
gitDir,
|
|
@@ -177,6 +228,17 @@ export function stagedPaths(repo) {
|
|
|
177
228
|
], repo.root));
|
|
178
229
|
}
|
|
179
230
|
|
|
231
|
+
// ignoredByPatterns lists untracked, ignored paths under the given pathspecs.
|
|
232
|
+
// Passing pathspecs keeps git's directory pruning effective: a bare
|
|
233
|
+
// `ls-files --others --ignored` walks the whole worktree and costs ~150ms on
|
|
234
|
+
// a large one, while anchored pathspecs answer in ~15ms.
|
|
235
|
+
export function ignoredByPatterns(repo, pathspecs) {
|
|
236
|
+
if (!pathspecs.length) return [];
|
|
237
|
+
return nulStrings(gitBuf([
|
|
238
|
+
'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...pathspecs,
|
|
239
|
+
], repo.root));
|
|
240
|
+
}
|
|
241
|
+
|
|
180
242
|
// stagedTreeSha writes the index to a tree object and returns its SHA, the same
|
|
181
243
|
// value `git write-tree` produces in the commit-msg dispatcher. Used by the
|
|
182
244
|
// pre-commit/commit-msg marker dedup (W5): pre-commit records this sha when it
|
package/src/report.mjs
CHANGED
|
@@ -7,6 +7,33 @@
|
|
|
7
7
|
// for the rest. The JSON report (aimhooman review / --json) is never capped.
|
|
8
8
|
const HUMAN_FINDING_CAP = 20;
|
|
9
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
|
+
|
|
10
37
|
export function human(findings, tone) {
|
|
11
38
|
if (!findings.length) return '';
|
|
12
39
|
let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
|
|
@@ -21,6 +48,7 @@ export function human(findings, tone) {
|
|
|
21
48
|
// while bounding stderr for repeated-rule scans.
|
|
22
49
|
const shown = findings.slice(0, HUMAN_FINDING_CAP);
|
|
23
50
|
const hidden = findings.length - shown.length;
|
|
51
|
+
const fixesPrinted = new Set();
|
|
24
52
|
for (const f of shown) {
|
|
25
53
|
const loc = f.path
|
|
26
54
|
? `${visible(f.path)}${f.line ? `:${f.line}` : ''}`
|
|
@@ -28,15 +56,30 @@ export function human(findings, tone) {
|
|
|
28
56
|
const related = (f.matchedRuleIds || []).filter((id) => id !== f.ruleId);
|
|
29
57
|
const identity = related.length ? `${f.ruleId} (+ ${related.join(', ')})` : f.ruleId;
|
|
30
58
|
const commit = f.commit ? ` [commit ${String(f.commit).slice(0, 12)}]` : '';
|
|
31
|
-
|
|
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`;
|
|
32
67
|
if (f.text && f.text.trim()) {
|
|
33
68
|
out += ` > ${isSensitive(f) ? '[redacted]' : visible(f.text.trim())}\n`;
|
|
34
69
|
}
|
|
35
70
|
// Render the whole remediation array, not just the first entry. Several
|
|
36
71
|
// rules carry a second line (e.g. "rotate the key if it was ever exposed")
|
|
37
|
-
// that the previous single-index render dropped silently.
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
}
|
|
40
83
|
}
|
|
41
84
|
out += '\n';
|
|
42
85
|
}
|