@reneturcios/hookcheck 0.1.3 → 0.1.5
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 -4
- package/dist/core.d.ts +48 -7
- package/dist/core.js +809 -27
- package/dist/core.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/package.json +41 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -28,14 +28,14 @@ Reports in October 2026 described AI agents orchestrating attacks and a self-pro
|
|
|
28
28
|
Classification per hook:
|
|
29
29
|
|
|
30
30
|
- **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. 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.
|
|
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. The flow analysis follows code references only: a variable named inside prose (help text, a banner string, a docstring) is mentioned, not used, and cannot make a send look tainted.
|
|
32
32
|
- **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`).
|
|
33
33
|
|
|
34
34
|
Verdict rules:
|
|
35
35
|
|
|
36
|
-
- **CLEAN**: every finding is SAFE, or no hooks were found. Exit code 0.
|
|
36
|
+
- **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
37
|
- **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), or credential 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.
|
|
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
39
|
|
|
40
40
|
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
41
|
|
|
@@ -45,6 +45,8 @@ Honest boundaries, pinned by tests in test/hardening.test.ts:
|
|
|
45
45
|
|
|
46
46
|
- One hop only. hookcheck follows a hook into the local file or package.json script it directly invokes, and classifies that target. It does not follow a second hop, and it does not trace data flow inside a script. A payload assembled at runtime from pieces that never appear as a recognizable pattern in any single file will not be named; the hook that runs it will usually still land in UNKNOWN or REVIEW, which is the signal to read it by hand.
|
|
47
47
|
- Pattern based, not execution based. hookcheck never runs the hooks it finds. A RISKY verdict means the command text matches a known bad shape, not that the payload was observed doing harm. A CLEAN verdict means no known shape matched, not that the repo is proven safe.
|
|
48
|
+
- Exfil detection is a static value flow check inside one finding: a credential value must visibly reach a send construct in the same command or referenced file (including through one variable assignment chain). A value laundered through several files, assembled at runtime, or sent by a tool whose arguments never name it will not be named as exfil; the finding still lands in RISKY or UNKNOWN on its other evidence. Conversely, a credential value placed in a request body or query string counts as exfil shape even to a first party host, because credentials belong in authentication positions, not payloads.
|
|
49
|
+
- Quote awareness has edges. In script files, string and regex literal contents are treated as data for the behavior rules; a payload hidden inside a string that is assembled and executed indirectly (not through a recognized exec call) can be missed. In shell text, quoted strings are command text and stay visible, except echo and printf arguments, which are output data.
|
|
48
50
|
- Remote pre-commit repos and remote MCP servers are inventoried, not verified. hookcheck cannot see inside a remote pre-commit repo, so those entries are UNKNOWN with a note to review the pinned rev. A remote MCP server URL is RISKY by construction, because the server autostarts and is remote.
|
|
49
51
|
- Duplicate JSON keys resolve last-wins, exactly as JSON.parse, Node, and npm resolve them, so the scanner sees the same value that would actually run.
|
|
50
52
|
- Non-string package.json script values are skipped: npm only executes string script values, so an array or number in scripts cannot auto-run.
|
|
@@ -73,7 +75,7 @@ hookcheck ./some-repo: COMPROMISED-PATTERN
|
|
|
73
75
|
command: node setup.mjs
|
|
74
76
|
rules: NET-FETCH, REMOTE-URL, ENV-BULK, ENV-EXFIL
|
|
75
77
|
evidence: referenced local file setup.mjs is RISKY: NET-FETCH: performs a network fetch
|
|
76
|
-
COMPROMISED-PATTERN: at least one risky hook shows strong evidence: fetched or decoded content executed as code, or credential access combined with network behavior.
|
|
78
|
+
COMPROMISED-PATTERN: at least one risky hook shows strong evidence: fetched or decoded content executed as code, credential values flowing into an outbound send, or credential store access combined with network behavior.
|
|
77
79
|
```
|
|
78
80
|
|
|
79
81
|
### MCP server
|
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.5";
|
|
4
4
|
interface RuleDef {
|
|
5
5
|
id: string;
|
|
6
6
|
pattern: RegExp;
|
|
@@ -28,24 +28,65 @@ export declare const RISKY_RULES: RuleDef[];
|
|
|
28
28
|
* are untouched.
|
|
29
29
|
*/
|
|
30
30
|
export declare function stripComments(text: string): string;
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Quote awareness (Round 2, P-019). Pattern rules describe behavior:
|
|
33
|
+
* fetching, evaluating, piping downloads into interpreters. A defensive
|
|
34
|
+
* tool that QUOTES those patterns (a guard whose pattern list contains
|
|
35
|
+
* the text eval( or curl | sh) performs none of them, and matching the
|
|
36
|
+
* quoted text produced the corpus's only severe finding in Round 2, on a
|
|
37
|
+
* security guard. So for script files (JS and Python), the behavior
|
|
38
|
+
* rules match against a code view: the contents of string and regex
|
|
39
|
+
* literals are blanked, except literals handed to exec style calls,
|
|
40
|
+
* whose contents are code by construction and are spliced back in.
|
|
41
|
+
* Credential path detection matches against a credential view that also
|
|
42
|
+
* keeps literals handed to file access calls (a credential path is
|
|
43
|
+
* normally a string argument to a read). Procedural detectors (env
|
|
44
|
+
* names, exfil value flow) run on the full text but ignore matches that
|
|
45
|
+
* start inside a data literal.
|
|
46
|
+
*
|
|
47
|
+
* Shell text is different: a quoted string in a hook command is usually
|
|
48
|
+
* the command itself (CMD='curl ... | sh'; $CMD), so shell strings stay
|
|
49
|
+
* visible. The one exception is echo and printf arguments, which are
|
|
50
|
+
* output data: a workflow step that echoes a JSON test payload into a
|
|
51
|
+
* guard is printing data, not executing it. Echo arguments are blanked
|
|
52
|
+
* in the code view unless the pipeline ends in a bare interpreter,
|
|
53
|
+
* where the printed text really is fed to an interpreter as code.
|
|
54
|
+
*/
|
|
55
|
+
export type Lang = "shell" | "js" | "python";
|
|
56
|
+
export declare function classifyCommand(command: string, lang?: Lang): {
|
|
32
57
|
classification: Classification;
|
|
33
58
|
rules: string[];
|
|
34
59
|
evidence: string;
|
|
35
60
|
};
|
|
36
61
|
/**
|
|
37
|
-
* Verdict calibration (Round 1, P-003 and P-004).
|
|
38
|
-
* requires strong evidence inside a single finding:
|
|
62
|
+
* Verdict calibration (Round 1, P-003 and P-004; Round 2, P-018).
|
|
63
|
+
* COMPROMISED-PATTERN requires strong evidence inside a single finding:
|
|
39
64
|
* (a) a severe rule: fetched or decoded content executed directly as
|
|
40
65
|
* code (curl piped to an interpreter, base64 decode piped to an
|
|
41
|
-
* interpreter, eval of fetched content), or
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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).
|
|
44
78
|
* Weak signals alone (a remote URL, a localhost fetch, an ordinary env
|
|
45
79
|
* read, a download by itself) classify RISKY at most, and a RISKY-only
|
|
46
80
|
* estate verdicts REVIEW, never COMPROMISED-PATTERN.
|
|
47
81
|
*/
|
|
48
82
|
export declare function computeVerdict(findings: HookFinding[]): Verdict;
|
|
83
|
+
/**
|
|
84
|
+
* Empty estate note (P-014, hookcheck half). A scan that inventories
|
|
85
|
+
* nothing must say so next to the verdict, in JSON as well as in the
|
|
86
|
+
* human output: CLEAN with total 0 is vacuously true and otherwise reads
|
|
87
|
+
* as an all-clear.
|
|
88
|
+
*/
|
|
89
|
+
export declare const EMPTY_ESTATE_NOTE = "Nothing was found to audit: no auto-exec hooks were inventoried under this path, so the verdict covers an empty estate. This can mean a hook-free repo, an empty directory, or a path that is not a repo root.";
|
|
49
90
|
export declare function scanHooks(root: string): ScanReport;
|
|
50
91
|
export declare function explainHook(root: string, idOrFile: string): {
|
|
51
92
|
finding: HookFinding | null;
|