push-sentinel 0.1.0 → 0.1.2
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 +47 -54
- package/bin/cli.js +3 -2
- package/hook-template.sh +5 -1
- package/package.json +1 -1
- package/src/install.js +5 -1
- package/src/reporter.js +6 -2
- package/src/scan.js +31 -21
package/README.md
CHANGED
|
@@ -1,88 +1,79 @@
|
|
|
1
1
|
# push-sentinel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Catches secrets in your git commits before they leave your machine.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
You've seen the stories. Someone pushes an AWS key to a public repo. Bots scrape GitHub in seconds. The bill arrives the next morning: $8,000.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
push-sentinel sits in your `pre-push` hook and warns you before that happens.
|
|
8
8
|
|
|
9
|
-
## Install
|
|
10
|
-
|
|
11
|
-
```sh
|
|
12
|
-
npx push-sentinel install
|
|
13
9
|
```
|
|
10
|
+
$ git push
|
|
14
11
|
|
|
15
|
-
This writes a `pre-push` hook to `.git/hooks/`. Any existing hook is preserved as `pre-push.local` and called automatically after the scan.
|
|
16
|
-
|
|
17
|
-
## Usage
|
|
18
|
-
|
|
19
|
-
### Automatic (after install)
|
|
20
|
-
|
|
21
|
-
The hook runs on every `git push`. No action required.
|
|
22
|
-
|
|
23
|
-
```
|
|
24
12
|
[push-sentinel] ⚠ Potential secrets found:
|
|
25
13
|
|
|
26
14
|
[HIGH] src/config.ts:12
|
|
27
15
|
AKIAIO...
|
|
28
|
-
→ Risk: Full access to AWS resources. Attacker can create/delete
|
|
16
|
+
→ Risk: Full access to AWS resources. Attacker can create/delete
|
|
17
|
+
instances, incur charges, or exfiltrate data.
|
|
29
18
|
→ To ignore this line: push-sentinel ignore src/config.ts:12
|
|
30
19
|
|
|
31
20
|
Push continues. Double-check before sharing.
|
|
32
21
|
```
|
|
33
22
|
|
|
34
|
-
|
|
23
|
+
## Install
|
|
35
24
|
|
|
36
25
|
```sh
|
|
37
|
-
npx push-sentinel
|
|
26
|
+
npx push-sentinel install
|
|
38
27
|
```
|
|
39
28
|
|
|
40
|
-
|
|
29
|
+
That's it. Runs automatically on every `git push` from now on.
|
|
41
30
|
|
|
42
|
-
|
|
31
|
+
## What it detects
|
|
43
32
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
33
|
+
| Pattern | Severity |
|
|
34
|
+
|---------|----------|
|
|
35
|
+
| Private Key (RSA, EC, OPENSSH, DSA, PKCS#8) | 🔴 HIGH |
|
|
36
|
+
| AWS Access Key (`AKIA...`) | 🔴 HIGH |
|
|
37
|
+
| AWS Secret Key (entropy-based) | 🔴 HIGH |
|
|
38
|
+
| GitHub Token (`ghp_`, `github_pat_`) | 🔴 HIGH |
|
|
39
|
+
| Anthropic API Key (`sk-ant-...`) | 🟡 MEDIUM |
|
|
40
|
+
| OpenAI API Key (`sk-...`) | 🟡 MEDIUM |
|
|
41
|
+
| Generic API Key (variable name + high entropy) | 🟢 LOW |
|
|
42
|
+
| `.env` file committed | 🟡 MEDIUM |
|
|
47
43
|
|
|
48
|
-
##
|
|
44
|
+
## False positive? Ignore it in one command
|
|
49
45
|
|
|
50
46
|
```sh
|
|
51
|
-
#
|
|
52
|
-
push-sentinel ignore
|
|
47
|
+
push-sentinel ignore src/config.ts:12 # ignore a specific line
|
|
48
|
+
push-sentinel ignore --pattern OPENAI_API_KEY # ignore a pattern everywhere
|
|
49
|
+
push-sentinel ignore --list # see all ignore rules
|
|
50
|
+
```
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
push-sentinel ignore --pattern OPENAI_API_KEY
|
|
52
|
+
Rules are saved to `.push-sentinel-ignore` in your repo root.
|
|
56
53
|
|
|
57
|
-
|
|
58
|
-
push-sentinel ignore --list
|
|
54
|
+
## Why warning-only by default?
|
|
59
55
|
|
|
60
|
-
|
|
61
|
-
push-sentinel ignore --remove OPENAI_API_KEY
|
|
62
|
-
```
|
|
56
|
+
Blocking pushes creates friction. Friction leads to `--no-verify`. A warning at push time is early enough to catch real accidents — and you'll actually leave it installed.
|
|
63
57
|
|
|
64
|
-
|
|
58
|
+
Want hard blocking for HIGH findings? Add `--block-on-high`:
|
|
65
59
|
|
|
66
|
-
|
|
60
|
+
```sh
|
|
61
|
+
# edit .git/hooks/pre-push, change the scan line to:
|
|
62
|
+
npx push-sentinel scan --local-sha "$local_sha" --remote-sha "$remote_sha" --block-on-high
|
|
63
|
+
```
|
|
67
64
|
|
|
68
|
-
|
|
69
|
-
|---------|----------|
|
|
70
|
-
| Private Key (RSA, EC, OPENSSH, DSA, PKCS#8) | HIGH |
|
|
71
|
-
| AWS Access Key (`AKIA...`) | HIGH |
|
|
72
|
-
| AWS Secret Key (entropy-based) | HIGH |
|
|
73
|
-
| GitHub Token (`ghp_`, `github_pat_`) | HIGH |
|
|
74
|
-
| Anthropic API Key (`sk-ant-...`) | MEDIUM |
|
|
75
|
-
| OpenAI API Key (`sk-...`) | MEDIUM |
|
|
76
|
-
| Generic API Key (variable name + high entropy) | LOW |
|
|
77
|
-
| `.env` file committed | MEDIUM |
|
|
65
|
+
## Manual scan
|
|
78
66
|
|
|
79
|
-
|
|
67
|
+
```sh
|
|
68
|
+
npx push-sentinel scan
|
|
69
|
+
```
|
|
80
70
|
|
|
81
|
-
|
|
71
|
+
Manual scan checks, in order:
|
|
82
72
|
|
|
83
|
-
-
|
|
84
|
-
-
|
|
85
|
-
-
|
|
73
|
+
- commits not yet pushed to your upstream
|
|
74
|
+
- staged changes
|
|
75
|
+
- unstaged working tree changes
|
|
76
|
+
- the last commit as a final fallback
|
|
86
77
|
|
|
87
78
|
## Uninstall
|
|
88
79
|
|
|
@@ -90,9 +81,11 @@ push-sentinel warns — it does not block — because:
|
|
|
90
81
|
npx push-sentinel uninstall
|
|
91
82
|
```
|
|
92
83
|
|
|
93
|
-
|
|
84
|
+
Your original `pre-push` hook is restored automatically.
|
|
94
85
|
|
|
95
|
-
##
|
|
86
|
+
## Details
|
|
96
87
|
|
|
88
|
+
- Scans only the commits being pushed — not your entire history
|
|
89
|
+
- Zero dependencies (Node.js stdlib only)
|
|
97
90
|
- Node.js >= 16
|
|
98
|
-
-
|
|
91
|
+
- Existing `pre-push` hooks are preserved and still run
|
package/bin/cli.js
CHANGED
|
@@ -100,8 +100,9 @@ switch (command) {
|
|
|
100
100
|
const localSha = localShaIdx !== -1 ? args[localShaIdx + 1] : undefined;
|
|
101
101
|
const remoteSha = remoteShaIdx !== -1 ? args[remoteShaIdx + 1] : undefined;
|
|
102
102
|
const findings = scan(localSha, remoteSha);
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
const willBlock = blockOnHigh && hasHighFindings(findings);
|
|
104
|
+
report(findings, { willBlock });
|
|
105
|
+
if (willBlock) {
|
|
105
106
|
process.stderr.write('\n[push-sentinel] Push blocked: HIGH severity secret(s) detected. Remove the secret or run `push-sentinel ignore` to suppress.\n');
|
|
106
107
|
process.exit(1);
|
|
107
108
|
}
|
package/hook-template.sh
CHANGED
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
#
|
|
7
7
|
# We read each line and pass the SHAs explicitly so push-sentinel can compute
|
|
8
8
|
# the exact range of commits being pushed (including new branch / first push).
|
|
9
|
+
# Stdin is saved and re-supplied to pre-push.local so existing hooks still work.
|
|
9
10
|
|
|
10
11
|
EXIT_CODE=0
|
|
12
|
+
STDIN_DATA=""
|
|
11
13
|
|
|
12
14
|
while read local_ref local_sha remote_ref remote_sha; do
|
|
15
|
+
STDIN_DATA="${STDIN_DATA}${local_ref} ${local_sha} ${remote_ref} ${remote_sha}
|
|
16
|
+
"
|
|
13
17
|
npx push-sentinel scan --local-sha "$local_sha" --remote-sha "$remote_sha"
|
|
14
18
|
RESULT=$?
|
|
15
19
|
if [ $RESULT -ne 0 ]; then
|
|
@@ -22,5 +26,5 @@ if [ $EXIT_CODE -ne 0 ]; then
|
|
|
22
26
|
fi
|
|
23
27
|
|
|
24
28
|
if [ -f "$(git rev-parse --git-dir)/hooks/pre-push.local" ]; then
|
|
25
|
-
"$(git rev-parse --git-dir)/hooks/pre-push.local" "$@"
|
|
29
|
+
echo "$STDIN_DATA" | "$(git rev-parse --git-dir)/hooks/pre-push.local" "$@"
|
|
26
30
|
fi
|
package/package.json
CHANGED
package/src/install.js
CHANGED
|
@@ -33,10 +33,14 @@ ${SENTINEL_MARKER}
|
|
|
33
33
|
# <local-ref> <local-sha1> <remote-ref> <remote-sha1>
|
|
34
34
|
# We forward the SHAs so push-sentinel can determine exactly which commits
|
|
35
35
|
# are being pushed, including new branches (remote-sha = 0000...0000).
|
|
36
|
+
# Stdin is saved and re-supplied to pre-push.local so existing hooks still work.
|
|
36
37
|
|
|
37
38
|
EXIT_CODE=0
|
|
39
|
+
STDIN_DATA=""
|
|
38
40
|
|
|
39
41
|
while read local_ref local_sha remote_ref remote_sha; do
|
|
42
|
+
STDIN_DATA="${'$'}{STDIN_DATA}${'$'}{local_ref} ${'$'}{local_sha} ${'$'}{remote_ref} ${'$'}{remote_sha}
|
|
43
|
+
"
|
|
40
44
|
npx push-sentinel scan --local-sha "$local_sha" --remote-sha "$remote_sha"
|
|
41
45
|
RESULT=$?
|
|
42
46
|
if [ $RESULT -ne 0 ]; then
|
|
@@ -49,7 +53,7 @@ if [ $EXIT_CODE -ne 0 ]; then
|
|
|
49
53
|
fi
|
|
50
54
|
|
|
51
55
|
if [ -f "$(git rev-parse --git-dir)/hooks/pre-push.local" ]; then
|
|
52
|
-
"$(git rev-parse --git-dir)/hooks/pre-push.local" "$@"
|
|
56
|
+
echo "$STDIN_DATA" | "$(git rev-parse --git-dir)/hooks/pre-push.local" "$@"
|
|
53
57
|
fi
|
|
54
58
|
`;
|
|
55
59
|
}
|
package/src/reporter.js
CHANGED
|
@@ -13,7 +13,7 @@ function severityLabel(severity) {
|
|
|
13
13
|
return `[${severity}]`;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function report(findings) {
|
|
16
|
+
function report(findings, { willBlock = false } = {}) {
|
|
17
17
|
if (findings.length === 0) {
|
|
18
18
|
process.stderr.write('[push-sentinel] \u2713 No secrets detected.\n');
|
|
19
19
|
return;
|
|
@@ -39,7 +39,11 @@ function report(findings) {
|
|
|
39
39
|
process.stderr.write(` + ${extra} more finding(s) not shown.\n\n`);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
if (willBlock) {
|
|
43
|
+
process.stderr.write(' Push will be blocked.\n');
|
|
44
|
+
} else {
|
|
45
|
+
process.stderr.write(' Push continues. Double-check before sharing.\n');
|
|
46
|
+
}
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
function hasHighFindings(findings) {
|
package/src/scan.js
CHANGED
|
@@ -55,33 +55,31 @@ function loadIgnoreRules(repoRoot) {
|
|
|
55
55
|
return { lines, patterns };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Check if a file path matches a single glob-style ignore rule
|
|
59
|
+
function matchesGlob(filePath, rule) {
|
|
60
|
+
if (rule.endsWith('/**')) {
|
|
61
|
+
const dir = rule.slice(0, -3);
|
|
62
|
+
return filePath.startsWith(dir + '/') || filePath === dir;
|
|
63
|
+
}
|
|
64
|
+
if (rule.endsWith('*')) {
|
|
65
|
+
return filePath.startsWith(rule.slice(0, -1));
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
// Check if a file (regardless of line) matches any ignore rule
|
|
59
71
|
function matchesIgnoreFile(filePath, ignoreLines) {
|
|
60
72
|
for (const rule of ignoreLines) {
|
|
61
|
-
if (rule === filePath) return true;
|
|
62
|
-
if (rule.endsWith('/**')) {
|
|
63
|
-
const dir = rule.slice(0, -3);
|
|
64
|
-
if (filePath.startsWith(dir + '/') || filePath === dir) return true;
|
|
65
|
-
}
|
|
66
|
-
if (rule.endsWith('*')) {
|
|
67
|
-
if (filePath.startsWith(rule.slice(0, -1))) return true;
|
|
68
|
-
}
|
|
73
|
+
if (rule === filePath || matchesGlob(filePath, rule)) return true;
|
|
69
74
|
}
|
|
70
75
|
return false;
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
// Check if a file:line matches any ignore rule
|
|
78
|
+
// Check if a file:line matches any ignore rule
|
|
74
79
|
function matchesIgnoreLine(filePath, lineNum, ignoreLines) {
|
|
75
80
|
const target = `${filePath}:${lineNum}`;
|
|
76
81
|
for (const rule of ignoreLines) {
|
|
77
|
-
if (rule === target) return true;
|
|
78
|
-
if (rule.endsWith('/**')) {
|
|
79
|
-
const dir = rule.slice(0, -3);
|
|
80
|
-
if (filePath.startsWith(dir + '/') || filePath === dir) return true;
|
|
81
|
-
}
|
|
82
|
-
if (rule.endsWith('*')) {
|
|
83
|
-
if (filePath.startsWith(rule.slice(0, -1))) return true;
|
|
84
|
-
}
|
|
82
|
+
if (rule === target || matchesGlob(filePath, rule)) return true;
|
|
85
83
|
}
|
|
86
84
|
return false;
|
|
87
85
|
}
|
|
@@ -121,14 +119,24 @@ function runGit(args) {
|
|
|
121
119
|
return result.status === 0 ? (result.stdout || '') : null;
|
|
122
120
|
}
|
|
123
121
|
|
|
122
|
+
function runGitNonEmpty(args) {
|
|
123
|
+
const out = runGit(args);
|
|
124
|
+
return out && out.trim() ? out : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
124
127
|
function getPushDiff(localSha, remoteSha) {
|
|
125
128
|
const args = getDiffArgs(localSha, remoteSha);
|
|
126
129
|
if (args) {
|
|
127
130
|
const out = runGit(args);
|
|
128
131
|
if (out !== null) return out;
|
|
129
132
|
}
|
|
130
|
-
//
|
|
131
|
-
|
|
133
|
+
// Manual scan: inspect unpushed commits first, then staged changes, then working tree,
|
|
134
|
+
// and finally fall back to the last commit if none of those produce a diff.
|
|
135
|
+
return runGitNonEmpty(['log', '@{u}..HEAD', '-p'])
|
|
136
|
+
?? runGitNonEmpty(['diff', '--cached'])
|
|
137
|
+
?? runGitNonEmpty(['diff'])
|
|
138
|
+
?? runGit(['log', '-1', '-p', 'HEAD'])
|
|
139
|
+
?? '';
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
function getPushedFileList(localSha, remoteSha) {
|
|
@@ -137,8 +145,10 @@ function getPushedFileList(localSha, remoteSha) {
|
|
|
137
145
|
const out = runGit(args);
|
|
138
146
|
if (out !== null) return out.split('\n').map((f) => f.trim()).filter(Boolean);
|
|
139
147
|
}
|
|
140
|
-
//
|
|
141
|
-
const out =
|
|
148
|
+
// Manual scan: include files from unpushed commits, staged changes, and working tree.
|
|
149
|
+
const out = runGitNonEmpty(['diff', '--name-only', '@{u}..HEAD'])
|
|
150
|
+
?? runGitNonEmpty(['diff', '--name-only', '--cached'])
|
|
151
|
+
?? runGitNonEmpty(['diff', '--name-only'])
|
|
142
152
|
?? runGit(['diff', '--name-only', 'HEAD~1..HEAD'])
|
|
143
153
|
?? '';
|
|
144
154
|
return out.split('\n').map((f) => f.trim()).filter(Boolean);
|