@reneturcios/hookcheck 0.1.6 → 0.1.7
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 +4 -2
- package/dist/core.d.ts +28 -22
- package/dist/core.js +638 -24
- 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 |
|
|
@@ -28,14 +29,14 @@ Reports in October 2026 described AI agents orchestrating attacks and a self-pro
|
|
|
28
29
|
Classification per hook:
|
|
29
30
|
|
|
30
31
|
- **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.
|
|
32
|
+
- **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
33
|
- **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
34
|
|
|
34
35
|
Verdict rules:
|
|
35
36
|
|
|
36
37
|
- **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
38
|
- **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.
|
|
39
|
+
- **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
40
|
|
|
40
41
|
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
42
|
|
|
@@ -53,6 +54,7 @@ Honest boundaries, pinned by tests in test/hardening.test.ts:
|
|
|
53
54
|
- 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
55
|
- 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
56
|
- Nested package.json inventory is bounded: the root package.json plus nested package.json files whose directory sits at most 4 levels below the root, visited in sorted order, capped at 200 nested files (the cap is reported as a warning when hit). node_modules, .git, dot directories, and symlinked directories are never entered, so package.json files deeper than the depth bound, or anywhere under node_modules, are not scanned.
|
|
57
|
+
- Plugin hooks inventory is bounded the same way: `hooks/hooks.json` and `hooks/hooks-cursor.json` files whose plugin root sits at most 4 levels below the scan root, visited in sorted order, capped at 200 files (the cap is reported as a warning when hit), with node_modules, .git, dot directories, and symlinked directories never entered. Plugin hook files deeper than the bound are not scanned. 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
58
|
- 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
59
|
- 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
60
|
- 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.7";
|
|
4
4
|
interface RuleDef {
|
|
5
5
|
id: string;
|
|
6
6
|
pattern: RegExp;
|
|
@@ -58,27 +58,6 @@ 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
63
|
* Nested package.json discovery (P-032). Install scripts in workspace
|
|
@@ -94,6 +73,33 @@ export declare function computeVerdict(findings: HookFinding[]): Verdict;
|
|
|
94
73
|
*/
|
|
95
74
|
export declare const PACKAGE_MAX_DEPTH = 4;
|
|
96
75
|
export declare const PACKAGE_MAX_NESTED = 200;
|
|
76
|
+
/**
|
|
77
|
+
* Claude Code plugin hook files (P-060, BLOCKER). Plugins distribute
|
|
78
|
+
* hooks in a hooks/ directory at the plugin root: hooks/hooks.json at
|
|
79
|
+
* the repo root for a single plugin repo (obra/superpowers), or
|
|
80
|
+
* plugins/<name>/hooks/hooks.json inside a marketplace or monorepo
|
|
81
|
+
* (anthropics/claude-code carries 9 such files, wshobson/agents 2 among
|
|
82
|
+
* 92 plugin directories). Before v0.1.7 none of these files was scanned
|
|
83
|
+
* at any depth: a SessionStart curl pipe in hooks/hooks.json scanned
|
|
84
|
+
* CLEAN with total 0 while the identical payload in .claude/settings.json
|
|
85
|
+
* verdicts COMPROMISED-PATTERN. The file carries the same schema as the
|
|
86
|
+
* hooks key of .claude/settings.json (an events map of command hooks:
|
|
87
|
+
* SessionStart, PreToolUse, PostToolUse, Stop, UserPromptSubmit, and
|
|
88
|
+
* the rest of the Claude Code event set), so commands are classified
|
|
89
|
+
* exactly as settings hooks are. The sibling hooks/hooks-cursor.json
|
|
90
|
+
* variant shipped by the same plugins (superpowers) carries the same
|
|
91
|
+
* command object shape under Cursor event names, so it is inventoried
|
|
92
|
+
* the same way.
|
|
93
|
+
*
|
|
94
|
+
* The walk mirrors the nested package.json discipline (P-032): sorted
|
|
95
|
+
* order, node_modules, .git, and dot directories never entered,
|
|
96
|
+
* symlinked directories never followed, and a cap with a warning. The
|
|
97
|
+
* depth bound applies to the plugin root (at most PLUGIN_HOOKS_MAX_DEPTH
|
|
98
|
+
* directories below the scan root); the hooks directory itself sits one
|
|
99
|
+
* level below its plugin root.
|
|
100
|
+
*/
|
|
101
|
+
export declare const PLUGIN_HOOKS_MAX_DEPTH = 4;
|
|
102
|
+
export declare const PLUGIN_HOOKS_MAX_FILES = 200;
|
|
97
103
|
/**
|
|
98
104
|
* Empty estate note (P-014, hookcheck half). A scan that inventories
|
|
99
105
|
* nothing must say so next to the verdict, in JSON as well as in the
|