@rmyndharis/aimhooman 0.2.0 → 0.3.1
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 +9 -6
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.clinerules/aimhooman.md +9 -6
- package/.codex-plugin/plugin.json +3 -3
- package/.cursor/rules/aimhooman.mdc +10 -7
- package/.github/copilot-instructions.md +9 -6
- package/.kiro/steering/aimhooman.md +9 -6
- package/.windsurf/rules/aimhooman.md +9 -6
- package/AGENTS.md +9 -6
- package/CHANGELOG.md +111 -0
- package/CONTRIBUTING.md +2 -3
- package/GEMINI.md +9 -6
- package/README.md +57 -376
- package/SECURITY.md +6 -4
- package/bin/aimhooman.mjs +134 -143
- package/docs/ai-artifacts.gitignore +63 -0
- package/docs/catalog.md +41 -0
- package/docs/cli-reference.md +114 -0
- package/docs/design/frictionless-enforcement.md +27 -17
- package/docs/faq.md +60 -0
- package/docs/integrations.md +108 -0
- package/docs/policy.md +82 -0
- package/docs/secrets.md +83 -0
- package/hooks/hooks.json +1 -1
- package/package.json +10 -2
- package/rules/paths.json +0 -114
- package/schemas/overrides.schema.json +4 -1
- package/skills/aimhooman/SKILL.md +11 -8
- package/src/args.mjs +2 -7
- package/src/exclude.mjs +2 -2
- package/src/gitx.mjs +1 -13
- package/src/hook.mjs +44 -12
- package/src/report.mjs +17 -41
- package/src/scan-session.mjs +15 -22
- package/src/scan-target.mjs +10 -10
- package/src/scan.mjs +9 -24
- package/src/state.mjs +104 -15
- package/rules/secrets.json +0 -96
package/rules/paths.json
CHANGED
|
@@ -216,120 +216,6 @@
|
|
|
216
216
|
"git restore --staged <path>"
|
|
217
217
|
]
|
|
218
218
|
},
|
|
219
|
-
{
|
|
220
|
-
"id": "secret.dotenv",
|
|
221
|
-
"version": 4,
|
|
222
|
-
"provider": "generic",
|
|
223
|
-
"category": "secret",
|
|
224
|
-
"confidence": "high",
|
|
225
|
-
"kind": "path",
|
|
226
|
-
"match": {
|
|
227
|
-
"path_case": "insensitive",
|
|
228
|
-
"paths": [
|
|
229
|
-
".env",
|
|
230
|
-
"**/.env",
|
|
231
|
-
".env.*",
|
|
232
|
-
"**/.env.*"
|
|
233
|
-
],
|
|
234
|
-
"except": [
|
|
235
|
-
"**/*.example",
|
|
236
|
-
"**/*.sample",
|
|
237
|
-
"**/*.template",
|
|
238
|
-
"**/*.dist",
|
|
239
|
-
"**/*.defaults",
|
|
240
|
-
"**/*.minimal"
|
|
241
|
-
]
|
|
242
|
-
},
|
|
243
|
-
"actions": {
|
|
244
|
-
"clean": "block",
|
|
245
|
-
"strict": "block",
|
|
246
|
-
"compliance": "block"
|
|
247
|
-
},
|
|
248
|
-
"reason": "Environment files often carry secrets and should not be committed.",
|
|
249
|
-
"remediation": [
|
|
250
|
-
"git restore --staged <path>",
|
|
251
|
-
"commit a .env.example with placeholders instead"
|
|
252
|
-
]
|
|
253
|
-
},
|
|
254
|
-
{
|
|
255
|
-
"id": "secret.private-key",
|
|
256
|
-
"version": 3,
|
|
257
|
-
"provider": "generic",
|
|
258
|
-
"category": "secret",
|
|
259
|
-
"confidence": "high",
|
|
260
|
-
"kind": "path",
|
|
261
|
-
"match": {
|
|
262
|
-
"path_case": "insensitive",
|
|
263
|
-
"paths": [
|
|
264
|
-
"**/id_rsa",
|
|
265
|
-
"**/id_dsa",
|
|
266
|
-
"**/id_ecdsa",
|
|
267
|
-
"**/id_ed25519",
|
|
268
|
-
"**/*.p12",
|
|
269
|
-
"**/*.pfx"
|
|
270
|
-
]
|
|
271
|
-
},
|
|
272
|
-
"actions": {
|
|
273
|
-
"clean": "block",
|
|
274
|
-
"strict": "block",
|
|
275
|
-
"compliance": "block"
|
|
276
|
-
},
|
|
277
|
-
"reason": "A path matching a private-key filename (id_rsa, id_ed25519, *.p12, *.pfx, ...) is blocked by name — content is not inspected. If this is a real key, it must never be committed; if the name is coincidental, rename the file.",
|
|
278
|
-
"remediation": [
|
|
279
|
-
"git restore --staged <path>",
|
|
280
|
-
"if this is a real key, rotate it if it was ever exposed",
|
|
281
|
-
"if the name is coincidental (a fixture, a doc), rename the file so it no longer matches a private-key pattern"
|
|
282
|
-
]
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
"id": "secret.aws-credentials",
|
|
286
|
-
"version": 2,
|
|
287
|
-
"provider": "generic",
|
|
288
|
-
"category": "secret",
|
|
289
|
-
"confidence": "high",
|
|
290
|
-
"kind": "path",
|
|
291
|
-
"match": {
|
|
292
|
-
"path_case": "insensitive",
|
|
293
|
-
"paths": [
|
|
294
|
-
".aws/credentials",
|
|
295
|
-
"**/.aws/credentials"
|
|
296
|
-
]
|
|
297
|
-
},
|
|
298
|
-
"actions": {
|
|
299
|
-
"clean": "block",
|
|
300
|
-
"strict": "block",
|
|
301
|
-
"compliance": "block"
|
|
302
|
-
},
|
|
303
|
-
"reason": "AWS credentials must never be committed.",
|
|
304
|
-
"remediation": [
|
|
305
|
-
"git restore --staged <path>"
|
|
306
|
-
]
|
|
307
|
-
},
|
|
308
|
-
{
|
|
309
|
-
"id": "secret.claude-credentials",
|
|
310
|
-
"version": 1,
|
|
311
|
-
"provider": "claude-code",
|
|
312
|
-
"category": "secret",
|
|
313
|
-
"confidence": "high",
|
|
314
|
-
"kind": "path",
|
|
315
|
-
"match": {
|
|
316
|
-
"path_case": "insensitive",
|
|
317
|
-
"paths": [
|
|
318
|
-
".claude/.credentials.json",
|
|
319
|
-
"**/.claude/.credentials.json"
|
|
320
|
-
]
|
|
321
|
-
},
|
|
322
|
-
"actions": {
|
|
323
|
-
"clean": "block",
|
|
324
|
-
"strict": "block",
|
|
325
|
-
"compliance": "block"
|
|
326
|
-
},
|
|
327
|
-
"reason": "This file holds an OAuth access token and must never be committed.",
|
|
328
|
-
"remediation": [
|
|
329
|
-
"git restore --staged <path>",
|
|
330
|
-
"Revoke the token if it already reached a commit."
|
|
331
|
-
]
|
|
332
|
-
},
|
|
333
219
|
{
|
|
334
220
|
"id": "generic.agent-instructions",
|
|
335
221
|
"version": 2,
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
"required": ["target"],
|
|
15
15
|
"properties": {
|
|
16
16
|
"target": { "type": "string", "minLength": 1 },
|
|
17
|
-
"scope": {
|
|
17
|
+
"scope": {
|
|
18
|
+
"enum": ["path", "rule"],
|
|
19
|
+
"$comment": "v0.3.0 retired the secret-path scope with built-in secret scanning; the loader drops legacy entries with a warning instead of failing the file."
|
|
20
|
+
},
|
|
18
21
|
"reason": { "type": "string" },
|
|
19
22
|
"actor": { "type": "string" },
|
|
20
23
|
"at": { "type": "string", "format": "date-time" }
|
|
@@ -3,8 +3,8 @@ name: aimhooman
|
|
|
3
3
|
description: >
|
|
4
4
|
Keep AI tooling artifacts out of Git. Use whenever staging, committing, or
|
|
5
5
|
pushing changes: block AI session/state files (.claude/session.json,
|
|
6
|
-
.codex/history, .copilot, .cursor/session, .aider.*, .specstory, .agent)
|
|
7
|
-
|
|
6
|
+
.codex/history, .copilot, .cursor/session, .aider.*, .specstory, .agent)
|
|
7
|
+
and unwanted AI attribution in
|
|
8
8
|
commit messages (Co-authored-by an AI, "Generated with AI"). Also use when the user says
|
|
9
9
|
"aimhooman", "ship it like a hooman", or asks to clean AI residue before a commit.
|
|
10
10
|
license: MIT
|
|
@@ -24,13 +24,16 @@ Its policy:
|
|
|
24
24
|
`.copilot/*`, `.cursor/session*`, `.cursor/chats/*`, `.cursor/logs/*`,
|
|
25
25
|
`.aider.*`, `.specstory/*`, `.continue/sessions/*`, `.playwright-mcp/*`,
|
|
26
26
|
`.remember/*`, `.superpowers/*`, and `.agent/*`. The examples are not
|
|
27
|
-
exhaustive; the packaged `rules/paths.json` is the detection source of truth
|
|
28
|
-
If one is staged, unstage it and keep it out of Git
|
|
27
|
+
exhaustive; the packaged `rules/paths.json` is the detection source of truth
|
|
28
|
+
for these artifacts. If one is staged, unstage it and keep it out of Git
|
|
29
|
+
instead.
|
|
29
30
|
- Never commit secrets: a real `.env` (not `.env.example`), private keys
|
|
30
31
|
(`id_rsa` and files containing a private-key header), `.aws/credentials`,
|
|
31
32
|
`.claude/.credentials.json`, service-account keys, or a provider API key
|
|
32
33
|
(GitHub, GitLab, npm, Slack, Anthropic, OpenAI, Google, Stripe, Hugging Face,
|
|
33
|
-
SendGrid). Public certificates are allowed.
|
|
34
|
+
SendGrid). Public certificates are allowed. aimhooman does not scan for
|
|
35
|
+
secrets — this rule is on you, and a dedicated scanner such as gitleaks is
|
|
36
|
+
the right backstop for it.
|
|
34
37
|
- Never add AI attribution to commit messages: no `Co-authored-by` trailer naming
|
|
35
38
|
an AI (Claude, Copilot, Codex), no "Generated with/by <AI>" lines, no AI-service
|
|
36
39
|
noreply emails. A commit message reads as if a human wrote it.
|
|
@@ -61,7 +64,7 @@ npm test
|
|
|
61
64
|
npm run test:coverage
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
aimhooman also enforces
|
|
65
|
-
violation to fix, not a check to bypass. The rule
|
|
66
|
-
ship.
|
|
67
|
+
aimhooman also enforces the artifact and attribution rules at commit time, so
|
|
68
|
+
a blocked commit means a real violation to fix, not a check to bypass. The rule
|
|
69
|
+
is simple: AI works, hoomans ship.
|
|
67
70
|
<!-- aimhooman:ruleset-end -->
|
package/src/args.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function parseArguments(args, definition = {}) {
|
|
|
27
27
|
const inlineValue = equal > 0 ? argument.slice(equal + 1) : undefined;
|
|
28
28
|
const option = byName.get(name);
|
|
29
29
|
if (!option) throw new ArgumentError(`unknown option "${name}"`);
|
|
30
|
-
if (seen.has(option.key)
|
|
30
|
+
if (seen.has(option.key)) {
|
|
31
31
|
throw new ArgumentError(`option "${name}" may only be used once`);
|
|
32
32
|
}
|
|
33
33
|
seen.add(option.key);
|
|
@@ -49,12 +49,7 @@ export function parseArguments(args, definition = {}) {
|
|
|
49
49
|
if (option.choices && !option.choices.includes(value)) {
|
|
50
50
|
throw new ArgumentError(`invalid value for ${name}: "${value}"`);
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
if (!options[option.key]) options[option.key] = [];
|
|
54
|
-
options[option.key].push(value);
|
|
55
|
-
} else {
|
|
56
|
-
options[option.key] = value;
|
|
57
|
-
}
|
|
52
|
+
options[option.key] = value;
|
|
58
53
|
continue;
|
|
59
54
|
}
|
|
60
55
|
positionals.push(argument);
|
package/src/exclude.mjs
CHANGED
|
@@ -8,8 +8,8 @@ const END = '# <<< aimhooman managed excludes';
|
|
|
8
8
|
|
|
9
9
|
// Local AI residue kept out of `git status`. Deriving this list from the rule
|
|
10
10
|
// catalog prevents the prevention layer from drifting behind enforcement. We
|
|
11
|
-
// intentionally exclude only unambiguous tooling state/settings:
|
|
12
|
-
//
|
|
11
|
+
// intentionally exclude only unambiguous tooling state/settings: review-
|
|
12
|
+
// required and policy rules must remain visible to users.
|
|
13
13
|
const AUTO_EXCLUDE_CATEGORIES = new Set(['ephemeral-state', 'local-settings']);
|
|
14
14
|
|
|
15
15
|
export function patternsForRules(rules) {
|
package/src/gitx.mjs
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
cpSync,
|
|
4
|
-
lstatSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
mkdtempSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
realpathSync,
|
|
9
|
-
readdirSync,
|
|
10
|
-
readlinkSync,
|
|
11
|
-
renameSync,
|
|
12
|
-
rmSync,
|
|
13
|
-
symlinkSync,
|
|
14
|
-
} from 'node:fs';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
15
3
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
16
4
|
import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
17
5
|
|
package/src/hook.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { newEngineWithDiagnostics } from './scan.mjs';
|
|
|
8
8
|
import { openRepo, stagedEntries } from './gitx.mjs';
|
|
9
9
|
import { applyExclude, patternsForRules } from './exclude.mjs';
|
|
10
10
|
import { activeGitHook, installedHooks } from './githooks.mjs';
|
|
11
|
+
import { loadConfig } from './state.mjs';
|
|
11
12
|
import { visible } from './report.mjs';
|
|
12
13
|
import { extractRuleset } from './ruleset-text.mjs';
|
|
13
14
|
import { resolvePolicy } from './policy-resolver.mjs';
|
|
@@ -84,7 +85,25 @@ export function hookSessionStart() {
|
|
|
84
85
|
const repo = openRepo();
|
|
85
86
|
const policy = resolvePolicy(repo, { target: 'worktree' });
|
|
86
87
|
const { engine: eng } = newEngineWithDiagnostics(policy.profile, repo.stateDir);
|
|
87
|
-
|
|
88
|
+
const patterns = patternsForRules(eng.rules);
|
|
89
|
+
// Each refresh is housekeeping with its own silent failure, mirroring
|
|
90
|
+
// the pre-tool-use path: a read-only .git/info must not skip the
|
|
91
|
+
// worktree .gitignore refresh below, and neither may decide anything.
|
|
92
|
+
try {
|
|
93
|
+
applyExclude(repo.excludeFile, patterns);
|
|
94
|
+
} catch {
|
|
95
|
+
/* exclude refresh is best effort */
|
|
96
|
+
}
|
|
97
|
+
// A clone that opted into the committed variant gets the same refresh in
|
|
98
|
+
// its worktree .gitignore; every failure degrades silently, same as the
|
|
99
|
+
// exclude write above.
|
|
100
|
+
try {
|
|
101
|
+
if (loadConfig(repo.stateDir).gitignore?.enabled) {
|
|
102
|
+
applyExclude(join(repo.root, '.gitignore'), patterns);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
/* gitignore refresh is best effort */
|
|
106
|
+
}
|
|
88
107
|
} catch {
|
|
89
108
|
/* not a repo; nothing to exclude */
|
|
90
109
|
}
|
|
@@ -383,11 +402,23 @@ function hookPreToolUse(input) {
|
|
|
383
402
|
// above so a read-only .git/info (CI checkout, read-only volume, a
|
|
384
403
|
// repository owned by another user) cannot decide what is allowed.
|
|
385
404
|
if (repo) {
|
|
405
|
+
const patterns = patternsForRules(eng.rules);
|
|
386
406
|
try {
|
|
387
|
-
applyExclude(repo.excludeFile,
|
|
407
|
+
applyExclude(repo.excludeFile, patterns);
|
|
388
408
|
} catch (e) {
|
|
389
409
|
hygieneWarning = `could not refresh ${repo.excludeFile}: ${e.message}`;
|
|
390
410
|
}
|
|
411
|
+
// The committed variant of the block gets the same best-effort refresh
|
|
412
|
+
// when this clone opted into it; a failure there is housekeeping too,
|
|
413
|
+
// never part of the verdict.
|
|
414
|
+
try {
|
|
415
|
+
if (loadConfig(repo.stateDir).gitignore?.enabled) {
|
|
416
|
+
applyExclude(join(repo.root, '.gitignore'), patterns);
|
|
417
|
+
}
|
|
418
|
+
} catch (e) {
|
|
419
|
+
const gitignoreWarning = `could not refresh ${join(repo.root, '.gitignore')}: ${e.message}`;
|
|
420
|
+
hygieneWarning = hygieneWarning ? `${hygieneWarning}; ${gitignoreWarning}` : gitignoreWarning;
|
|
421
|
+
}
|
|
391
422
|
}
|
|
392
423
|
// A strict policy cannot make a meaningful guarantee if Git's own guards
|
|
393
424
|
// are explicitly bypassed. Deny before the shell can stage-and-commit in a
|
|
@@ -402,7 +433,7 @@ function hookPreToolUse(input) {
|
|
|
402
433
|
// A preceding command that may have changed the repository or its hooks is
|
|
403
434
|
// also a potential pre-commit bypass. Strict rejects it above; clean and
|
|
404
435
|
// compliance still need the staged-content backstop so a hook mutation
|
|
405
|
-
// cannot
|
|
436
|
+
// cannot sneak a blocked staged file past the guard.
|
|
406
437
|
const commitPrefixRisk = parsed.commands.some((candidate) => (
|
|
407
438
|
(candidate.verb === 'commit' || candidate.verb === 'unknown')
|
|
408
439
|
&& candidate.prefixRisk
|
|
@@ -426,8 +457,8 @@ function hookPreToolUse(input) {
|
|
|
426
457
|
// them. A build, a test run, ls — those leave pre-commit to answer, and
|
|
427
458
|
// refusing them taught agents to drop the `&&` gate rather than to run the
|
|
428
459
|
// command separately. hiddenBypass stays wider on purpose; it is what keeps
|
|
429
|
-
// the staged-content backstop reading the blobs, so a
|
|
430
|
-
// index still stops the commit here.
|
|
460
|
+
// the staged-content backstop reading the blobs, so a blocked file already
|
|
461
|
+
// in the index still stops the commit here.
|
|
431
462
|
const prefixHookBypass = opaqueCommitRisk
|
|
432
463
|
|| (commitPrefixRisk && (noVerify || bypassHooks || parsed.prefixHooksRisk));
|
|
433
464
|
// The same distinction, for the deny paths that ask "will anything scan this
|
|
@@ -460,7 +491,7 @@ function hookPreToolUse(input) {
|
|
|
460
491
|
const blocks = [];
|
|
461
492
|
// potentialCommit treats a command as leading to a commit. uncertainShell
|
|
462
493
|
// was too broad: it flagged any pipe, so a benign read-only pipeline
|
|
463
|
-
// (gh ... | tail) was scanned and denied as if it staged a
|
|
494
|
+
// (gh ... | tail) was scanned and denied as if it staged a blocked file.
|
|
464
495
|
// opaqueCommitHiding keeps every commit-hiding shape (subshells,
|
|
465
496
|
// substitution, script-feeds, code-executing/unlisted pipe segments) while
|
|
466
497
|
// excluding pipelines of known read-only commands.
|
|
@@ -503,8 +534,8 @@ function hookPreToolUse(input) {
|
|
|
503
534
|
// disables the real pre-commit guard, leaving this hook the only check.
|
|
504
535
|
// When the commit also stages files at commit time (-a/--all/-u/--patch, or
|
|
505
536
|
// a preceding `git add`), those files are neither in stagedPaths(repo) now
|
|
506
|
-
// nor in addPaths, so they were never scanned. Rather than let
|
|
507
|
-
//
|
|
537
|
+
// nor in addPaths, so they were never scanned. Rather than let unscanned
|
|
538
|
+
// content through, deny. (Strict already denied the bypass above.) The rare
|
|
508
539
|
// false-positive on an explicit `git add <path> && git commit --no-verify`
|
|
509
540
|
// is safe-side; drop --no-verify to proceed.
|
|
510
541
|
//
|
|
@@ -512,7 +543,8 @@ function hookPreToolUse(input) {
|
|
|
512
543
|
// a --no-verify inside the inner command, where parsed.noVerify cannot see
|
|
513
544
|
// it (the parser only inspects literal argv). Such an uncertain commit is
|
|
514
545
|
// therefore treated as a potential hook bypass for the clean/compliance
|
|
515
|
-
//
|
|
546
|
+
// staged-content backstop, so a blocked file cannot slip past the guard
|
|
547
|
+
// wrapped in eval.
|
|
516
548
|
const indexReplacement = parsed.commands.some((candidate) => (
|
|
517
549
|
candidate.verb === 'commit' && candidate.indexMutationRisk
|
|
518
550
|
));
|
|
@@ -553,9 +585,9 @@ function hookPreToolUse(input) {
|
|
|
553
585
|
}
|
|
554
586
|
// clean / compliance: advisory only (the git pre-commit unstage is the real
|
|
555
587
|
// enforcement) — except when --no-verify/core.hooksPath bypasses that hook.
|
|
556
|
-
// A bypassed guard is the one case clean cannot delegate to git, so a
|
|
557
|
-
// secret
|
|
558
|
-
//
|
|
588
|
+
// A bypassed guard is the one case clean cannot delegate to git, so a
|
|
589
|
+
// secret-category finding (a local rule pack can still declare one) is
|
|
590
|
+
// denied here; hygiene findings stay advisory.
|
|
559
591
|
const bypassed = Boolean(potentialCommit && hiddenBypass);
|
|
560
592
|
if (bypassed && blocks.some((f) => f.category === 'secret')) {
|
|
561
593
|
return emitDecision(
|
package/src/report.mjs
CHANGED
|
@@ -7,33 +7,6 @@
|
|
|
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
|
-
|
|
37
10
|
export function human(findings, tone) {
|
|
38
11
|
if (!findings.length) return '';
|
|
39
12
|
let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
|
|
@@ -56,22 +29,16 @@ export function human(findings, tone) {
|
|
|
56
29
|
const related = (f.matchedRuleIds || []).filter((id) => id !== f.ruleId);
|
|
57
30
|
const identity = related.length ? `${f.ruleId} (+ ${related.join(', ')})` : f.ruleId;
|
|
58
31
|
const commit = f.commit ? ` [commit ${String(f.commit).slice(0, 12)}]` : '';
|
|
59
|
-
|
|
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
|
+
out += `${f.decision.toUpperCase().padEnd(6)} ${identity}${commit}\n ${loc}\n ${f.reason}\n`;
|
|
67
33
|
if (f.text && f.text.trim()) {
|
|
68
34
|
out += ` > ${isSensitive(f) ? '[redacted]' : visible(f.text.trim())}\n`;
|
|
69
35
|
}
|
|
70
36
|
// Render the whole remediation array, not just the first entry. Several
|
|
71
|
-
// rules carry a second line (e.g. "
|
|
72
|
-
// that the previous single-index render
|
|
73
|
-
// rule's fix once — a repeated rule
|
|
74
|
-
// used to reprint the identical fix
|
|
37
|
+
// rules carry a second line (e.g. "or unstage if it is personal" on
|
|
38
|
+
// generic.agent-instructions) that the previous single-index render
|
|
39
|
+
// dropped silently. UT-08: print a rule's fix once — a repeated rule
|
|
40
|
+
// (20 hits of the same path rule) used to reprint the identical fix
|
|
41
|
+
// block for every finding.
|
|
75
42
|
const remedies = f.remediation || [];
|
|
76
43
|
if (remedies.length && fixesPrinted.has(f.ruleId)) {
|
|
77
44
|
out += ` fix: as above for ${f.ruleId}\n`;
|
|
@@ -111,13 +78,22 @@ export function jsonReport(findings, metadata = {}) {
|
|
|
111
78
|
return JSON.stringify({ schema_version: 1, ...metadata, findings: safe }, null, 2);
|
|
112
79
|
}
|
|
113
80
|
|
|
81
|
+
// Built-in secret scanning is gone, but a local rule pack can still declare
|
|
82
|
+
// category "secret"; findings from such a rule keep their matched text out of
|
|
83
|
+
// every report, the same courtesy the built-in rules got.
|
|
114
84
|
function isSensitive(finding) {
|
|
115
85
|
return finding?.category === 'secret'
|
|
116
86
|
|| finding?.matchedRules?.some((match) => match.category === 'secret') === true;
|
|
117
87
|
}
|
|
118
88
|
|
|
119
89
|
// Exit codes: 0 clean, 10 blocked, 11 review required, 31 incomplete scan.
|
|
120
|
-
|
|
90
|
+
// An incomplete scan stops the operation only where no later guard can vouch
|
|
91
|
+
// for the skipped content: on the strict profile, or when the caller is the
|
|
92
|
+
// final ref boundary (failClosedIncomplete). Frictionless profiles warn and
|
|
93
|
+
// continue — the reference-transaction guard still scans introduced commits
|
|
94
|
+
// with failClosedIncomplete set, so the skipped content is checked before any
|
|
95
|
+
// ref moves.
|
|
96
|
+
export function exitCode(findings, profile, complete = true, { failClosedIncomplete = false } = {}) {
|
|
121
97
|
let block = false;
|
|
122
98
|
let review = false;
|
|
123
99
|
for (const f of findings) {
|
|
@@ -125,7 +101,7 @@ export function exitCode(findings, profile, complete = true) {
|
|
|
125
101
|
else if (f.decision === 'review') review = true;
|
|
126
102
|
}
|
|
127
103
|
if (block) return 10;
|
|
128
|
-
if (!complete) return 31;
|
|
104
|
+
if (!complete && (profile === 'strict' || failClosedIncomplete)) return 31;
|
|
129
105
|
if (review && findings.some((finding) => (finding.scanProfile || profile) !== 'clean')) return 11;
|
|
130
106
|
return 0;
|
|
131
107
|
}
|
package/src/scan-session.mjs
CHANGED
|
@@ -52,10 +52,10 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Oversized files: probe the first 8 KB to separate binary from text.
|
|
55
|
-
// A binary file (PSD, WOFF, PNG)
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
55
|
+
// A binary file (PSD, WOFF, PNG) holds no text lines the content rules
|
|
56
|
+
// could match, so it skips as 'binary' (complete). A text file that
|
|
57
|
+
// exceeds the budget is a genuine 'size-limit' skip (incomplete) and the
|
|
58
|
+
// caller must raise the limit to cover it.
|
|
59
59
|
// Cap probing at 16 MiB: cat-file --batch reads the full blob into memory,
|
|
60
60
|
// so probing a 500 MB file just to check for NULs is wasteful. Files above
|
|
61
61
|
// the cap are classified as size-limit without probing.
|
|
@@ -100,25 +100,18 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
100
100
|
// Binary detection still reads the blob. Count every examined byte so
|
|
101
101
|
// later commits cannot reset the total budget merely by using NUL data.
|
|
102
102
|
stats.bytes_scanned += blob.length;
|
|
103
|
-
let matched;
|
|
104
103
|
if (isBinary(blob)) {
|
|
104
|
+
// Content rules match text lines, so a binary blob carries nothing
|
|
105
|
+
// they could fire on. Skipping it loses no coverage: the skip is
|
|
106
|
+
// recorded as 'binary', which does not mark the scan incomplete.
|
|
105
107
|
increment(stats.skipped, 'binary');
|
|
106
108
|
appendPath(stats.skippedPaths, 'binary', entry.path, blob.length);
|
|
107
|
-
|
|
108
|
-
// signatures are ASCII byte sequences, so latin1 preserves a
|
|
109
|
-
// one-byte-to-one-code-unit view and keeps the existing byte limits.
|
|
110
|
-
// Stripping NULs is what defeats hiding credential material behind
|
|
111
|
-
// them: one injected NUL breaks a signature, and a multi-byte
|
|
112
|
-
// encoding like UTF-16 injects one per character.
|
|
113
|
-
matched = engine.checkContent(entry.path, blob.toString('latin1').replace(/\0/g, ''), {
|
|
114
|
-
categories: ['secret'],
|
|
115
|
-
});
|
|
116
|
-
} else {
|
|
117
|
-
stats.files_scanned += 1;
|
|
118
|
-
const ranges = lineRanges.get(entry.path);
|
|
119
|
-
matched = engine.checkContent(entry.path, blob.toString('utf8'),
|
|
120
|
-
ranges && ranges.length ? { lineRanges: ranges } : {});
|
|
109
|
+
continue;
|
|
121
110
|
}
|
|
111
|
+
stats.files_scanned += 1;
|
|
112
|
+
const ranges = lineRanges.get(entry.path);
|
|
113
|
+
const matched = engine.checkContent(entry.path, blob.toString('utf8'),
|
|
114
|
+
ranges && ranges.length ? { lineRanges: ranges } : {});
|
|
122
115
|
stats.findings_total += matched.length;
|
|
123
116
|
for (const finding of matched) {
|
|
124
117
|
if (findings.length >= limits.maxFindings) continue;
|
|
@@ -139,7 +132,7 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
139
132
|
}
|
|
140
133
|
|
|
141
134
|
const incompleteReasons = new Set([
|
|
142
|
-
'metadata-unavailable', 'size-limit', 'total-byte-limit',
|
|
135
|
+
'metadata-unavailable', 'size-limit', 'total-byte-limit',
|
|
143
136
|
'missing-object', 'unexpected-object', 'finding-limit', 'local-input-limit',
|
|
144
137
|
]);
|
|
145
138
|
const complete = !Object.keys(stats.skipped).some((reason) => incompleteReasons.has(reason));
|
|
@@ -253,8 +246,8 @@ function isBinary(buffer) {
|
|
|
253
246
|
//
|
|
254
247
|
// Entries without commit/parents (tracked snapshots, staged views, root
|
|
255
248
|
// commits) are omitted from the map; the caller treats a missing key as "scan
|
|
256
|
-
// the whole blob" — the safe side
|
|
257
|
-
//
|
|
249
|
+
// the whole blob" — the safe side is to scan MORE, not less, so a failure to
|
|
250
|
+
// compute hunks falls back to the pre-W4 behaviour.
|
|
258
251
|
function collectLineRanges(repo, entries) {
|
|
259
252
|
// Group text-candidate entries by their first parent to batch the diffs.
|
|
260
253
|
// Key by `${commit}\0${parent}` so a merge with multiple parents produces
|
package/src/scan-target.mjs
CHANGED
|
@@ -389,8 +389,8 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
|
|
|
389
389
|
// Content scanning can target a narrower set than the path check. When
|
|
390
390
|
// contentEntries is provided (e.g. only changed files in a commit), read
|
|
391
391
|
// blobs only for those entries instead of the full snapshot. Path-based
|
|
392
|
-
// rules already ran on the full tree above, so
|
|
393
|
-
//
|
|
392
|
+
// rules already ran on the full tree above, so a path-only finding still
|
|
393
|
+
// fires even when its blob isn't re-read.
|
|
394
394
|
const contentScannable = (options.contentEntries ?? entries)
|
|
395
395
|
.filter((entry) => entry.status !== 'D' && entry.type !== 'deleted');
|
|
396
396
|
const remaining = accumulator.remaining();
|
|
@@ -405,14 +405,14 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
|
|
|
405
405
|
function scanReviewPathChanges(engine, entries, policy, accumulator, options = {}) {
|
|
406
406
|
for (const entry of entries) {
|
|
407
407
|
// A deleted path is gone, so only structural policy rules (agent
|
|
408
|
-
// instructions, project policy) can still matter: deleting a
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
// would slip past the destination scan, which only catches
|
|
413
|
-
//
|
|
414
|
-
// bytes now live) so clean-profile repair unstages the
|
|
415
|
-
// the
|
|
408
|
+
// instructions, project policy) can still matter: deleting a flagged
|
|
409
|
+
// file is hygiene, not a violation. A renamed-away path is different —
|
|
410
|
+
// its content survives under a new name, so a path-only secret-category
|
|
411
|
+
// rule from a local pack must still fire or a `git mv` to a neutral
|
|
412
|
+
// name would slip past the destination scan, which only catches
|
|
413
|
+
// content-shaped matches. That finding is reported on the destination
|
|
414
|
+
// path (where the bytes now live) so clean-profile repair unstages the
|
|
415
|
+
// blob that carries the match rather than the old name.
|
|
416
416
|
const deleted = entry.status === 'D' || entry.type === 'deleted';
|
|
417
417
|
const renamed = entry.status === 'R' && entry.sourcePath && entry.sourcePath !== entry.path;
|
|
418
418
|
if (!deleted && !renamed) continue;
|