Skip to content

Commit a24084c

Browse files
authored
fix: upgrade cross-spawn (ReDoS), harden hook install and spawn handling (#169)
* fix: upgrade cross-spawn (ReDoS), harden hook install and spawn handling - Bump cross-spawn to ^7.0.5 and which to ^4; drop unused spawn-sync. - Fix spawnSync result checks (use status/signal/error, not .code). - Treat non-zero / null close codes from npm run spawns reliably. - Install hook via absolute path to package hook script for Yarn PnP; chmod 0755. - Hook: cd to git root before require.resolve; use exec for node. - Install: guard gitdir parse; avoid fs.existsSync(null) on missing .git. - Dev: mocha 10, assume 2, nyc; engines node>=16; stub tty in tests. - Version 1.2.3; add package-lock.json; ignore .nyc_output. Addresses GH-167, GH-160, GH-157; mitigates GH-166 (cwd / exit handling). Made-with: Cursor * chore: fold in fixes from open PRs (#127, #148, #112) Addresses long-standing open PRs whose intent fits the cross-spawn / hook-hardening cleanup: * #127 - install.js: "backuped" -> "backed up". * #148 - magit workaround: emit `unset GIT_LITERAL_PATHSPECS` in the generated `.git/hooks/pre-commit` wrapper, and also at the top of the package's `hook` script for defense-in-depth, so hooks invoked from emacs/magit behave the same as on the command line. * #112 - resilience for missing pre-commit package: when a user switches to a branch without `node_modules`, the generated wrapper now exits 0 instead of failing the commit, and the `hook` script detects an unresolvable `pre-commit` package and skips with a friendly warning instead of throwing a Node module-not-found stack. Made-with: Cursor * fix(install): handle submodules and tighten engines.node * getGitFolderPath previously recursed past a `.git` *file*, which made the existing submodule-aware gitdir parsing block unreachable -- in a submodule we silently walked up to the super-project's `.git` dir and installed the hook there instead. Return the `.git` path whether it is a file or directory. * Resolve `gitdir:` pointers against the directory containing the `.git` file (path.dirname(git)) instead of the package root, since git stores those paths relative to the file. Fixes submodules whose `.git` file is not at the package root, and linked worktrees. * Tighten engines.node to >=16.13.0 to match `which@4`'s minimum (`^16.13.0 || >=18.0.0`); the previous `>=16` allowed Node 16.0-16.12 where `which@4` will warn/fail at install time. Addresses copilot-pull-request-reviewer feedback on PR #169. Supersedes the submodule-install half of #75. Made-with: Cursor * chore: regenerate package-lock * chore(release): bump to 2.0.0 This release contains breaking changes that warrant a major bump: * Drops support for Node < 16.13 by introducing `engines.node: ">=16.13.0"` (matches `which@4`'s minimum). Previous releases declared no engine, so anything old enough to run them is now incompatible. * Production deps make jumps that raise the floor and change result shapes: `cross-spawn` ^5 -> ^7 (`spawnSync` returns `status`, not `code`), `which` 1.2.x -> ^4. The runtime `spawn-sync` dep is dropped in favor of `cross-spawn.spawnSync`. * The generated `.git/hooks/pre-commit` wrapper format is rewritten (single `exec bash <abs-hook> "\$@"` instead of the previous inline bash). Anyone parsing or scripting against the previous wrapper shape will see a different file. * Hook file mode tightened from 0777 to 0755 (CIS 6.1.10). * Submodule install location now resolves correctly to `<super>/.git/modules/<sub>/hooks` (previously the unreachable gitdir-parsing block silently caused submodule hooks to land in the super-project). Minor / patch additions in the same release (carried by the same PR): * Magit fix: generated wrapper and hook script `unset GIT_LITERAL_PATHSPECS` so hooks behave the same way under emacs. * Resilient missing-package handling: a removed `pre-commit` (e.g. branch-switch without `node_modules`) skips with a friendly warning instead of breaking the commit. * Internal: harden `index.js` spawn result checks; guard install-time gitdir parsing against null matches; spelling fix ("backuped" -> "backed up"); drop `istanbul` for `nyc`; modernize `mocha` and `assume` test deps. Made-with: Cursor
1 parent a84bdc8 commit a24084c

7 files changed

Lines changed: 2978 additions & 58 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
node_modules
22
npm-debug.log
33
coverage
4+
.nyc_output
45
.tern-port

hook

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
#!/usr/bin/env bash
22

3+
#
4+
# Defense-in-depth for magit (the .git/hooks wrapper unsets it too); ensures
5+
# hooks invoked from emacs/magit behave the same as on the CLI.
6+
# https://magit.vc/manual/magit/My-Git-hooks-work-on-the-command_002dline-but-not-inside-Magit.html
7+
#
8+
unset GIT_LITERAL_PATHSPECS
9+
310
HAS_NODE=`which node 2> /dev/null || which nodejs 2> /dev/null || which iojs 2> /dev/null`
411

512
#
@@ -37,14 +44,41 @@ elif [[ -x "$LOCAL" ]]; then
3744
BINARY="$LOCAL"
3845
fi
3946

47+
#
48+
# Run from the repository root so `require.resolve('pre-commit')` works for Yarn PnP,
49+
# and GUI git clients that invoke hooks with an unexpected cwd still resolve deps.
50+
#
51+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || REPO_ROOT=""
52+
if [[ -n "$REPO_ROOT" ]]; then
53+
cd "$REPO_ROOT" || exit 1
54+
fi
55+
56+
#
57+
# Resolve the entry point of the `pre-commit` package via Node so we work for
58+
# Yarn Plug'n'Play, hoisted, and nested layouts. If the package cannot be
59+
# resolved (e.g. the user switched to a branch with no `node_modules`, or
60+
# uninstalled `pre-commit`) skip the hook with exit 0 instead of failing the
61+
# commit -- a missing dev dependency must not block work.
62+
#
63+
RESOLVED=
64+
RESOLVE_RC=1
65+
if [[ -n "$BINARY" ]]; then
66+
RESOLVED="$("$BINARY" -e "try { console.log(require.resolve('pre-commit')); } catch (e) { process.exit(2); }" 2>/dev/null)"
67+
RESOLVE_RC=$?
68+
fi
69+
4070
#
4171
# Add --dry-run cli flag support so we can execute this hook without side effects
4272
# and see if it works in the current environment
4373
#
4474
if [[ $* == *--dry-run* ]]; then
45-
if [[ -z "$BINARY" ]]; then
75+
if [[ -z "$BINARY" ]] || [[ "$RESOLVE_RC" -ne 0 ]] || [[ -z "$RESOLVED" ]]; then
4676
exit 1
4777
fi
4878
else
49-
"$BINARY" "$("$BINARY" -e "console.log(require.resolve('pre-commit'))")"
79+
if [[ "$RESOLVE_RC" -ne 0 ]] || [[ -z "$RESOLVED" ]]; then
80+
echo "pre-commit: 'pre-commit' package is not installed; skipping hooks (run \`npm install\` to re-enable)." >&2
81+
exit 0
82+
fi
83+
exec "$BINARY" "$RESOLVED"
5084
fi

index.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
'use strict';
22

3+
//
4+
// cross-spawn.spawnSync returns the same shape as child_process.spawnSync
5+
// (`status`, not `code`).
6+
//
7+
function failedSpawn(result) {
8+
if (!result) return true;
9+
if (result.error) return true;
10+
if (result.signal) return true;
11+
return result.status !== 0;
12+
}
13+
314
var spawn = require('cross-spawn')
415
, which = require('which')
516
, path = require('path')
@@ -173,8 +184,8 @@ Hook.prototype.initialize = function initialize() {
173184
this.root = this.exec(this.git, ['rev-parse', '--show-toplevel']);
174185
this.status = this.exec(this.git, ['status', '--porcelain']);
175186

176-
if (this.status.code) return this.log(Hook.log.status, 0);
177-
if (this.root.code) return this.log(Hook.log.root, 0);
187+
if (failedSpawn(this.status)) return this.log(Hook.log.status, 0);
188+
if (failedSpawn(this.root)) return this.log(Hook.log.root, 0);
178189

179190
this.status = this.status.stdout.toString().trim();
180191
this.root = this.root.stdout.toString().trim();
@@ -229,8 +240,11 @@ Hook.prototype.run = function runner() {
229240
env: process.env,
230241
cwd: hooked.root,
231242
stdio: [0, 1, 2]
232-
}).once('close', function closed(code) {
233-
if (code) return hooked.log(hooked.format(Hook.log.failure, script, code));
243+
}).once('close', function closed(code, signal) {
244+
var exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
245+
if (exitCode !== 0) {
246+
return hooked.log(hooked.format(Hook.log.failure, script, exitCode));
247+
}
234248

235249
again(scripts);
236250
});

install.js

Lines changed: 72 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,17 @@ var fs = require('fs')
77
, path = require('path')
88
, os = require('os')
99
, hook = path.join(__dirname, 'hook')
10+
, hookAbs = path.resolve(hook)
1011
, root = path.resolve(__dirname, '..', '..')
1112
, exists = fs.existsSync || path.existsSync;
1213

14+
//
15+
// POSIX single-quoted string for embedding paths in generated shell scripts.
16+
//
17+
function shellSingleQuote(str) {
18+
return '\'' + str.replace(/'/g, '\'\\\'\'') + '\'';
19+
}
20+
1321
//
1422
// Gather the location of the possible hidden .git directory, the hooks
1523
// directory which contains all git hooks and the absolute location of the
@@ -19,41 +27,50 @@ var fs = require('fs')
1927

2028
var git = getGitFolderPath(root);
2129

22-
// Function to recursively finding .git folder
30+
//
31+
// Walk up from `currentPath` looking for `.git`. Returns the path to the `.git`
32+
// entry as soon as one is found, regardless of whether it is a directory (the
33+
// regular case) or a file (submodules, linked worktrees, where `.git` contains
34+
// `gitdir: <path>`).
35+
//
2336
function getGitFolderPath(currentPath) {
24-
var git = path.resolve(currentPath, '.git')
25-
26-
if (!exists(git) || !fs.lstatSync(git).isDirectory()) {
27-
console.log('pre-commit:');
28-
console.log('pre-commit: Not found .git folder in', git);
29-
30-
var newPath = path.resolve(currentPath, '..');
31-
32-
// Stop if we on top folder
33-
if (currentPath === newPath) {
34-
return null;
37+
var git = path.resolve(currentPath, '.git');
38+
39+
if (exists(git)) {
40+
var stat = fs.lstatSync(git);
41+
if (stat.isDirectory() || stat.isFile()) {
42+
console.log('pre-commit:');
43+
console.log('pre-commit: Found .git in', git);
44+
return git;
3545
}
36-
37-
return getGitFolderPath(newPath);
3846
}
3947

4048
console.log('pre-commit:');
41-
console.log('pre-commit: Found .git folder in', git);
42-
return git;
49+
console.log('pre-commit: No .git found in', currentPath);
50+
51+
var newPath = path.resolve(currentPath, '..');
52+
if (currentPath === newPath) return null;
53+
54+
return getGitFolderPath(newPath);
4355
}
4456

4557
//
46-
// Resolve git directory for submodules
58+
// When `.git` is a file (submodules and linked worktrees) it contains a
59+
// `gitdir: <path>` pointer to the real git directory. Paths inside that file
60+
// are resolved relative to the directory containing the `.git` file, not
61+
// relative to the package root, so we use `path.dirname(git)` as the base.
4762
//
48-
if (exists(git) && fs.lstatSync(git).isFile()) {
49-
var gitinfo = fs.readFileSync(git).toString()
50-
, gitdirmatch = /gitdir: (.+)/.exec(gitinfo)
51-
, gitdir = gitdirmatch.length == 2 ? gitdirmatch[1] : null;
52-
53-
if (gitdir !== null) {
54-
git = path.resolve(root, gitdir);
55-
hooks = path.resolve(git, 'hooks');
56-
precommit = path.resolve(hooks, 'pre-commit');
63+
if (git && fs.lstatSync(git).isFile()) {
64+
var gitinfo = fs.readFileSync(git, 'utf8')
65+
, gitdirmatch = /^gitdir:\s*(.+)$/m.exec(gitinfo)
66+
, gitdir = gitdirmatch ? gitdirmatch[1].trim() : null;
67+
68+
if (gitdir) {
69+
git = path.resolve(path.dirname(git), gitdir);
70+
} else {
71+
console.log('pre-commit:');
72+
console.log('pre-commit: .git file did not contain a gitdir pointer; aborting.');
73+
return;
5774
}
5875
}
5976

@@ -81,7 +98,7 @@ if (exists(precommit) && !fs.lstatSync(precommit).isSymbolicLink()) {
8198
console.log('pre-commit:');
8299
console.log('pre-commit: Detected an existing git pre-commit hook');
83100
fs.writeFileSync(precommit +'.old', fs.readFileSync(precommit));
84-
console.log('pre-commit: Old pre-commit hook backuped to pre-commit.old');
101+
console.log('pre-commit: Old pre-commit hook backed up to pre-commit.old');
85102
console.log('pre-commit:');
86103
}
87104

@@ -92,20 +109,34 @@ if (exists(precommit) && !fs.lstatSync(precommit).isSymbolicLink()) {
92109
try { fs.unlinkSync(precommit); }
93110
catch (e) {}
94111

95-
// Create generic precommit hook that launches this modules hook (as well
96-
// as stashing - unstashing the unstaged changes)
97-
// TODO: we could keep launching the old pre-commit scripts
98-
var hookRelativeUnixPath = hook.replace(root, '.');
99-
100-
if(os.platform() === 'win32') {
101-
hookRelativeUnixPath = hookRelativeUnixPath.replace(/[\\\/]+/g, '/');
112+
// Delegate to this package's `hook` script using an absolute path so Yarn Plug'n'Play
113+
// and other layouts without `node_modules/pre-commit` still work. The hook script
114+
// changes to the git root before resolving `pre-commit` via Node.
115+
//
116+
var hookLauncher = hookAbs;
117+
if (os.platform() === 'win32') {
118+
hookLauncher = hookLauncher.replace(/\\/g, '/');
102119
}
103120

104-
var precommitContent = '#!/usr/bin/env bash' + os.EOL
105-
+ hookRelativeUnixPath + os.EOL
106-
+ 'RESULT=$?' + os.EOL
107-
+ '[ $RESULT -ne 0 ] && exit 1' + os.EOL
108-
+ 'exit 0' + os.EOL;
121+
//
122+
// Generated wrapper:
123+
// * Unsets GIT_LITERAL_PATHSPECS so hooks invoked from magit/emacs behave the
124+
// same as on the command line. See:
125+
// https://magit.vc/manual/magit/My-Git-hooks-work-on-the-command_002dline-but-not-inside-Magit.html
126+
// * If the package's `hook` script is missing (e.g. user switched to a branch
127+
// without `node_modules`, or removed the `pre-commit` package), skip
128+
// silently with exit 0 so commits are not blocked.
129+
//
130+
var precommitContent = [
131+
'#!/usr/bin/env bash',
132+
'unset GIT_LITERAL_PATHSPECS',
133+
'HOOK=' + shellSingleQuote(hookLauncher),
134+
'if [ ! -f "$HOOK" ]; then',
135+
' exit 0',
136+
'fi',
137+
'exec bash "$HOOK" "$@"',
138+
''
139+
].join(os.EOL);
109140

110141
//
111142
// It could be that we do not have rights to this folder which could cause the
@@ -121,10 +152,10 @@ catch (e) {
121152
console.error('pre-commit:');
122153
}
123154

124-
try { fs.chmodSync(precommit, '777'); }
155+
try { fs.chmodSync(precommit, 0o755); }
125156
catch (e) {
126157
console.error('pre-commit:');
127-
console.error('pre-commit: chmod 0777 the pre-commit file in your .git/hooks folder because:');
158+
console.error('pre-commit: chmod 0755 the pre-commit file in your .git/hooks folder because:');
128159
console.error('pre-commit: '+ e.message);
129160
console.error('pre-commit:');
130161
}

0 commit comments

Comments
 (0)