@reneturcios/hookcheck 0.1.6 → 0.1.8
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/README.md +6 -3
- package/dist/core.d.ts +45 -32
- package/dist/core.js +1027 -266
- package/dist/core.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Reports in October 2026 described AI agents orchestrating attacks and a self-pro
|
|
|
16
16
|
| --- | --- |
|
|
17
17
|
| `package.json` | preinstall, install, postinstall, prepare scripts. The root package.json and nested workspace package.json files (for example `packages/*/package.json`, `apps/*/package.json`, any directory up to 4 levels below the root, node_modules excluded) are inventoried the same way |
|
|
18
18
|
| `.claude/settings.json` and `settings.local.json` | SessionStart, PreToolUse, and other hook commands. Also discovered: the GNU stow layout (`claude/settings.json`, no dot) and settings nested one level under a config dir (for example `config/.claude/settings.json`). A settings path that exists as a directory is reported as a warning, not silently skipped |
|
|
19
|
+
| Claude Code plugin hooks | `hooks/hooks.json` in a plugin root: at the repo root, or nested under plugin directories (for example `plugins/<name>/hooks/hooks.json`), with the plugin root at most 4 levels below the scan root. The file carries the same events map as the hooks key of `.claude/settings.json` (SessionStart, PreToolUse, PostToolUse, Stop, UserPromptSubmit, and the rest of the Claude Code event set) and its commands are classified exactly the same way. The sibling `hooks/hooks-cursor.json` variant carries the same command object shape under Cursor event names and is inventoried the same way |
|
|
19
20
|
| `.vscode/tasks.json` | tasks with runOn folderOpen, plus task inventory |
|
|
20
21
|
| `.husky/` | every hook file and its full command content |
|
|
21
22
|
| `lefthook.yml` | run commands |
|
|
@@ -25,17 +26,19 @@ Reports in October 2026 described AI agents orchestrating attacks and a self-pro
|
|
|
25
26
|
| `.github/workflows/` | run steps, as inventory, including block scalar bodies (`run: |` and `run: >`, with chomping variants). Every workflow finding carries the line number of its `run:` key |
|
|
26
27
|
| `.githooks/` | hook files |
|
|
27
28
|
|
|
29
|
+
Every surface above is discovered by one bounded recursive walk, not only at the scan root: a surface that appears inside a subdirectory (for example `packages/foo/.claude/settings.json`, `sub/.vscode/tasks.json`, or `sub/.github/workflows/ci.yml`) is inventoried from a parent scan with its real relative path in the finding, within the discovery bound documented under Limits. A surface file is inventoried exactly once however many discovery paths reach it.
|
|
30
|
+
|
|
28
31
|
Classification per hook:
|
|
29
32
|
|
|
30
33
|
- **SAFE**: matches known safe dev patterns such as test, lint, build, typecheck, husky, or lint-staged, and matches no risky rule.
|
|
31
|
-
- **RISKY**: matches at least one risky rule: network fetch, curl piped to an interpreter, base64 decode piped to an interpreter, credential store paths or credential files (matched on content and paths, never on a script filename that merely contains the word credentials), credential shaped environment names (token, secret, password, api key) or bulk environment access, base64 decoding or eval style obfuscation, inline code execution, or a remote URL. Ordinary single named environment reads (NODE_ENV, HOME, RUNNER_DEBUG) are not flagged. Neither are operations that are not reads: `env -u NAME` unsets a variable, `os.environ.setdefault(...)` writes one key with a default, and a `[ -n "$NAME" ]` test checks existence. A hook whose target is a shell script is classified by that script's contents, not by the fact that it is a shell script. Comments are stripped before classification, so a docs URL inside a comment is not a fetch, and a fetch whose every URL is loopback (a localhost health check) is not treated as network behavior. In script files (JavaScript, Python), the contents of string and regex literals are data, not behavior: a defensive guard whose pattern list quotes `eval(` or `curl | sh` performs neither, and hookcheck does not classify it as an attacker. Literals handed to exec style calls (`execSync("...")`, `os.system("...")`) are code by construction and are still matched. Two rules are about value flow, not co-occurrence: ENV-EXFIL fires only when a credential value demonstrably flows into an outbound send (a request payload, a query string, a pipe into a network tool, or printed output next to a send), and CRED-EXFIL when credential store contents are piped or posted straight into a network send. A token used as authentication (an Authorization header, the userinfo of a push URL to the repo's own host, a `--token` flag) is authentication, not exfiltration, and never fires either rule.
|
|
34
|
+
- **RISKY**: matches at least one risky rule: network fetch, curl piped to an interpreter, base64 decode piped to an interpreter, credential store paths or credential files (matched on content and paths, never on a script filename that merely contains the word credentials), credential shaped environment names (token, secret, password, api key) or bulk environment access, base64 decoding or eval style obfuscation, inline code execution, or a remote URL. Ordinary single named environment reads (NODE_ENV, HOME, RUNNER_DEBUG) are not flagged. Neither are operations that are not reads: `env -u NAME` unsets a variable, `os.environ.setdefault(...)` writes one key with a default, and a `[ -n "$NAME" ]` test checks existence. A hook whose target is a shell script is classified by that script's contents, not by the fact that it is a shell script. Comments are stripped before classification, so a docs URL inside a comment is not a fetch, and a fetch whose every URL is loopback (a localhost health check) is not treated as network behavior. In script files (JavaScript, Python), the contents of string and regex literals are data, not behavior: a defensive guard whose pattern list quotes `eval(` or `curl | sh` performs neither, and hookcheck does not classify it as an attacker. Literals handed to exec style calls (`execSync("...")`, `os.system("...")`) are code by construction and are still matched. Two rules are about value flow, not co-occurrence: ENV-EXFIL fires only when a credential value demonstrably flows into an outbound send (a request payload, a query string, a pipe into a network tool, or printed output next to a send), and CRED-EXFIL when credential store contents are piped or posted straight into a network send. A token used as authentication (an Authorization header, the userinfo of a push URL to the repo's own host, a `--token` flag) is authentication, not exfiltration, and never fires either rule. Authentication positions are recognized by shape, not only by the Authorization spelling: any curl `-H` header argument whatever its name, any dict or object entry whose key is an authentication header name (Authorization, `x-api-key`, `*-api-key`, `*-auth-token`, and friends) in a one hop script file, and any header shaped entry inside a headers construction (a dict assigned to a headers variable, a `headers=` argument, a headers property of a request options object) all count as authentication positions, while the same credential in a body, a query string, or printed output still counts as exfiltration. Fetch detection requires command position: curl or wget counts as a network fetch only when it is the invoked command (the head of a statement or pipeline segment, after sudo or env wrappers, or the head of a command string handed to an interpreter), never when the word merely names a package in an install line (`pacman -S curl`, `apt-get install curl`). In script files the remote URL rule follows the same data view as the other behavior rules: a URL that appears only inside a string literal or a comment (an error message, a documentation link, an API base constant) is data, while a URL handed to a network call (fetch, requests, http.get, urlopen, axios, sendBeacon) is a fetch target and still fires. Inline code execution (DOWNLOAD-EXEC) fires only when the evaluated content itself carries a download or remote reference; constant inline code such as a `node -e` version comparison downloads nothing and is not flagged.
|
|
32
35
|
- **UNKNOWN**: matches neither safe nor risky patterns. A local script name alone is UNKNOWN until a human reads it. When a hook runs a local file such as `node setup.mjs`, hookcheck reads that file and escalates the finding if the file itself matches risky rules. The same one hop following applies to direct script execution (`./setup.sh`), `node --require ./setup.mjs`, and package.json script chains (`postinstall: npm run other-script`). If the referenced file itself spawns a further local script, the chain runs past the one hop limit: the finding is floored at UNKNOWN, never SAFE, and the evidence names the second hop file, so the verdict lands at REVIEW for a hand read.
|
|
33
36
|
|
|
34
37
|
Verdict rules:
|
|
35
38
|
|
|
36
39
|
- **CLEAN**: every finding is SAFE, or no hooks were found. Exit code 0. When nothing was found, the report carries a note saying nothing was found to audit, in the JSON as well as the human output, so an empty estate never reads as an all-clear.
|
|
37
40
|
- **REVIEW**: at least one UNKNOWN finding, or a RISKY finding whose evidence is weak on its own (a remote URL alone, a download by itself, credential access with no network behavior). Exit code 1.
|
|
38
|
-
- **COMPROMISED-PATTERN**: at least one RISKY finding with strong evidence: fetched or decoded content executed directly as code (curl piped to an interpreter, base64 decode piped to an interpreter, eval of fetched content), a verified exfil flow (a credential value flowing into an outbound send, or credential store contents piped into a network tool), or credential store access combined with network behavior in the same finding. This is the Shai-Hulud persistence shape. Treat the repo as untrusted until proven otherwise. Exit code 1. Canonical legitimate installers that download their own binary (esbuild, puppeteer) verdict REVIEW under this calibration, not COMPROMISED-PATTERN; the download is reported as a RISKY finding with its evidence, and the top verdict no longer over-claims it. The same applies to a repo's own release automation: a step that reads its token and pushes to its own repo, posts its own workflow dispatch, or updates its own homebrew tap uses the token as authentication and verdicts REVIEW, because the credential value never flows into a payload to a third party.
|
|
41
|
+
- **COMPROMISED-PATTERN**: at least one RISKY finding with strong evidence: fetched or decoded content executed directly as code (curl piped to an interpreter, base64 decode piped to an interpreter, eval of fetched content), a verified exfil flow (a credential value flowing into an outbound send, or credential store contents piped into a network tool), or credential store access combined with network behavior in the same finding. This is the Shai-Hulud persistence shape. Treat the repo as untrusted until proven otherwise. Exit code 1. Canonical legitimate installers that download their own binary (esbuild, puppeteer) verdict REVIEW under this calibration, not COMPROMISED-PATTERN; the download is reported as a RISKY finding with its evidence, and the top verdict no longer over-claims it. The same applies to a repo's own release automation: a step that reads its token and pushes to its own repo, posts its own workflow dispatch, or updates its own homebrew tap uses the token as authentication and verdicts REVIEW, because the credential value never flows into a payload to a third party. One more calibration in the same family: a CI workflow step whose only severe evidence is a curl pipe to a named vendor's official installer over HTTPS (an installer shaped path such as /install or /uv/install.sh, or a known installer host such as astral.sh or sh.rustup.rs), with no credential, persistence, or second severe signal in the finding, verdicts REVIEW with the finding kept RISKY. The same pipe on an auto-exec hook surface (an install script, a Claude settings or plugin hook, a folderOpen task), a pipe to a non-installer source, or a pipe accompanied by any other signal still reaches COMPROMISED-PATTERN.
|
|
39
42
|
|
|
40
43
|
Every finding reports file, hook id, command text, classification, rule ids, and evidence. Malformed JSON is reported as a warning, never silently ignored. Scanning a path that does not exist, or that is a file instead of a directory, is a clean error with exit code 2.
|
|
41
44
|
|
|
@@ -52,7 +55,7 @@ Honest boundaries, pinned by tests in test/hardening.test.ts:
|
|
|
52
55
|
- Non-string package.json script values are skipped: npm only executes string script values, so an array or number in scripts cannot auto-run.
|
|
53
56
|
- Classification reads up to 200,000 characters of a hook file or a referenced local file. Beyond that depth a payload can hide; the cap exists so a hostile file cannot make the scan unbounded.
|
|
54
57
|
- GitHub workflow run steps in block scalars (`run: |`, `run: >`, and chomping variants) are parsed and classified like one line commands. Plain multi-line scalars without a block header are not joined, and reusable workflow internals are not resolved. YAML anchors, aliases, and merge keys on run steps are resolved when the anchor is defined in the same file; anchors applied to other step fields, and anchors defined in other files, are not resolved. Workflow files with CRLF line endings are parsed exactly like their LF equivalents.
|
|
55
|
-
- Nested package.json
|
|
58
|
+
- Nested discovery is one bounded walk covering every inventoried surface: package.json files, `.claude/settings.json` and `settings.local.json` (including the stow layout `claude/settings.json`), plugin `hooks/hooks.json` and `hooks/hooks-cursor.json` files, `.vscode/tasks.json`, `.github/workflows/*.yml` and `*.yaml`, `.husky/` and `.githooks/` hook files, lefthook configs (`lefthook.yml`, `lefthook.yaml`, `.lefthook.yml`), `.pre-commit-config.yaml`, `.cursor/` and `.codex/` config files, and MCP configs (`.mcp.json`, `mcp.json`, `.cursor/mcp.json`, `.windsurf/mcp.json`, `.codex/mcp.json`, and `.vscode/mcp.json`). A surface is in scope when its project directory sits at most 4 levels below the scan root (the plugin hooks directory itself sits one level below its plugin root), visited in sorted order. Each surface family inventories at most 200 nested files; the scan root files always count. node_modules, .git, dot directories, and symlinked directories are never entered, so surfaces deeper than the depth bound, or anywhere under node_modules, are not scanned. The bound is never silent: when the walk hits the depth bound with more tree remaining, the report carries a warning naming the depth bound and counts the directories just below the bound that contain hook surface files or config directories; when a family hits the file cap, a warning names the cap and the count found. A scan cut short by either bound, by the cap, or by unreadable files is partial, says so in its warnings, and never verdicts a bare CLEAN when surfaces are known to have been missed. Only the object command form of the plugin schema is inventoried; a hook entry written as a bare string instead of a command object is outside the documented schema and is not inventoried.
|
|
56
59
|
- A file or directory the scanner sets out to inventory but cannot read (for example a workflow file at mode 000) is never skipped silently: the report carries a warning naming it, and the verdict is floored at REVIEW while any part of the estate is unread, so a partial scan never presents as a complete all-clear.
|
|
57
60
|
- Custom git hook directories set through core.hooksPath are not scanned: hookcheck inventories the standard `.githooks/` directory and `.husky/`, but it does not parse git config or a committed `.gitconfig` to discover a custom core.hooksPath directory. Hooks kept only in a custom hooksPath directory are outside the inventory; audit them by scanning a tree where they sit in a standard location.
|
|
58
61
|
- Agent config coverage boundaries: `.gemini/settings.json` is not scanned yet. Template configs (for example `templates/settings.json.template`) are not scanned: they are inert until copied into place, at which point the copied file is scanned under its real name.
|
package/dist/core.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import type { Classification, HookFinding, ScanReport, Verdict } from "./types.js";
|
|
3
|
-
export declare const VERSION = "0.1.
|
|
3
|
+
export declare const VERSION = "0.1.8";
|
|
4
4
|
interface RuleDef {
|
|
5
5
|
id: string;
|
|
6
6
|
pattern: RegExp;
|
|
@@ -58,42 +58,55 @@ export declare function classifyCommand(command: string, lang?: Lang): {
|
|
|
58
58
|
rules: string[];
|
|
59
59
|
evidence: string;
|
|
60
60
|
};
|
|
61
|
-
/**
|
|
62
|
-
* Verdict calibration (Round 1, P-003 and P-004; Round 2, P-018).
|
|
63
|
-
* COMPROMISED-PATTERN requires strong evidence inside a single finding:
|
|
64
|
-
* (a) a severe rule: fetched or decoded content executed directly as
|
|
65
|
-
* code (curl piped to an interpreter, base64 decode piped to an
|
|
66
|
-
* interpreter, eval of fetched content), or a verified exfil flow
|
|
67
|
-
* (ENV-EXFIL, CRED-EXFIL), or
|
|
68
|
-
* (b) credential STORE access (CRED-ACCESS: actual credential files
|
|
69
|
-
* and stores) combined with network behavior.
|
|
70
|
-
* Round 2 narrowed path (b): in v0.1.2 and v0.1.3 any credential evidence
|
|
71
|
-
* counted, including a credential shaped env NAME next to a URL. On real
|
|
72
|
-
* estates that is the normal shape of a repo's own authenticated
|
|
73
|
-
* automation (a release step reading its token and pushing to its own
|
|
74
|
-
* repo), and it drove every one of the 15 false COMPROMISED drivers
|
|
75
|
-
* hand-read in Round 2. Env references plus network behavior are RISKY
|
|
76
|
-
* and verdict REVIEW; the verdict now needs the value to actually move
|
|
77
|
-
* (path a) or an actual credential store to be touched (path b).
|
|
78
|
-
* Weak signals alone (a remote URL, a localhost fetch, an ordinary env
|
|
79
|
-
* read, a download by itself) classify RISKY at most, and a RISKY-only
|
|
80
|
-
* estate verdicts REVIEW, never COMPROMISED-PATTERN.
|
|
81
|
-
*/
|
|
82
61
|
export declare function computeVerdict(findings: HookFinding[]): Verdict;
|
|
83
62
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
63
|
+
* Unified nested discovery (P-032, P-060, P-065). Every surface the
|
|
64
|
+
* tool inventories at the scan root is also inventoried when it
|
|
65
|
+
* appears inside a subdirectory: before v0.1.8 only nested package.json
|
|
66
|
+
* files (P-032) and plugin hooks files (P-060) were recursed, so a
|
|
67
|
+
* parent scan of a monorepo or workspace silently missed nested
|
|
68
|
+
* .claude/settings.json, .vscode/tasks.json, workflows, husky and
|
|
69
|
+
* githooks directories, lefthook and pre-commit configs, and MCP
|
|
70
|
+
* configs, and a tree holding only nested surfaces scanned CLEAN with
|
|
71
|
+
* the empty estate note attached (P-065).
|
|
72
|
+
*
|
|
73
|
+
* One bounded walk enumerates every project root (each directory at
|
|
74
|
+
* most NESTED_MAX_DEPTH levels below the scan root, the scan root
|
|
75
|
+
* itself included) plus the nested package.json and plugin hooks file
|
|
76
|
+
* lists. Per surface scanners then run once per project root with the
|
|
77
|
+
* root prefix carried into every finding file path, so findings carry
|
|
78
|
+
* their real relative paths. The walk is bounded so a hostile or
|
|
79
|
+
* giant tree cannot explode the report: directories are visited in
|
|
80
|
+
* sorted order, node_modules, .git, and dot directories are never
|
|
81
|
+
* entered, symlinked directories are never followed (a symlink loop
|
|
82
|
+
* cannot hang the scan), and each surface family inventories at most
|
|
83
|
+
* NESTED_MAX_FILES_PER_SURFACE nested files, with the cap reported as
|
|
84
|
+
* a warning when it is hit. When directories remain below the depth
|
|
85
|
+
* bound the walk says so in a warning naming the bound, and it probes
|
|
86
|
+
* the directories just below the bound (names only, no file reads) so
|
|
87
|
+
* surfaces sitting there are counted in the warning instead of being
|
|
88
|
+
* silently dropped.
|
|
94
89
|
*/
|
|
90
|
+
export declare const NESTED_MAX_DEPTH = 4;
|
|
91
|
+
export declare const NESTED_MAX_FILES_PER_SURFACE = 200;
|
|
95
92
|
export declare const PACKAGE_MAX_DEPTH = 4;
|
|
96
93
|
export declare const PACKAGE_MAX_NESTED = 200;
|
|
94
|
+
export declare const PLUGIN_HOOKS_MAX_DEPTH = 4;
|
|
95
|
+
export declare const PLUGIN_HOOKS_MAX_FILES = 200;
|
|
96
|
+
export interface Discovery {
|
|
97
|
+
/** Project root prefixes, scan root ("") first, then sorted. */
|
|
98
|
+
projectRoots: string[];
|
|
99
|
+
/** Nested package.json relative paths (scan root excluded), sorted, uncapped. */
|
|
100
|
+
packageFiles: string[];
|
|
101
|
+
/** Plugin hooks file relative paths, sorted, uncapped. */
|
|
102
|
+
pluginHookFiles: string[];
|
|
103
|
+
/** Directories just below the depth bound that were not entered. */
|
|
104
|
+
frontierDirs: number;
|
|
105
|
+
/** Frontier directories whose listing shows a hook surface file or config directory. */
|
|
106
|
+
surfacesBelowBound: number;
|
|
107
|
+
/** True when the depth bound left directories unentered. */
|
|
108
|
+
depthBoundHit: boolean;
|
|
109
|
+
}
|
|
97
110
|
/**
|
|
98
111
|
* Empty estate note (P-014, hookcheck half). A scan that inventories
|
|
99
112
|
* nothing must say so next to the verdict, in JSON as well as in the
|