push-sentinel 0.1.0
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/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/cli.js +128 -0
- package/hook-template.sh +26 -0
- package/package.json +29 -0
- package/src/install.js +108 -0
- package/src/patterns.js +51 -0
- package/src/reporter.js +49 -0
- package/src/scan.js +266 -0
- package/src/utils.js +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pmaind
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# push-sentinel
|
|
2
|
+
|
|
3
|
+
Warns you if secrets are in your git commits before push.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Scans the commits you are about to push for potential secrets (API keys, private keys, tokens) and prints a warning. **It does not block the push by default** — it is a safety net, not a gatekeeper.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx push-sentinel install
|
|
13
|
+
```
|
|
14
|
+
|
|
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
|
+
[push-sentinel] ⚠ Potential secrets found:
|
|
25
|
+
|
|
26
|
+
[HIGH] src/config.ts:12
|
|
27
|
+
AKIAIO...
|
|
28
|
+
→ Risk: Full access to AWS resources. Attacker can create/delete instances, incur charges, or exfiltrate data.
|
|
29
|
+
→ To ignore this line: push-sentinel ignore src/config.ts:12
|
|
30
|
+
|
|
31
|
+
Push continues. Double-check before sharing.
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Manual scan
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npx push-sentinel scan
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Block push on HIGH findings
|
|
41
|
+
|
|
42
|
+
To treat HIGH severity findings as blocking errors, edit `.git/hooks/pre-push` and change the scan line to:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npx push-sentinel scan --local-sha "$local_sha" --remote-sha "$remote_sha" --block-on-high
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Suppressing false positives
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
# Ignore a specific line
|
|
52
|
+
push-sentinel ignore src/config.ts:12
|
|
53
|
+
|
|
54
|
+
# Ignore all matches of a pattern name
|
|
55
|
+
push-sentinel ignore --pattern OPENAI_API_KEY
|
|
56
|
+
|
|
57
|
+
# List current ignore rules
|
|
58
|
+
push-sentinel ignore --list
|
|
59
|
+
|
|
60
|
+
# Remove a rule
|
|
61
|
+
push-sentinel ignore --remove OPENAI_API_KEY
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Rules are saved to `.push-sentinel-ignore` in the repo root. Add it to `.gitignore` or commit it — your choice.
|
|
65
|
+
|
|
66
|
+
## Detected patterns
|
|
67
|
+
|
|
68
|
+
| Pattern | Severity |
|
|
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 |
|
|
78
|
+
|
|
79
|
+
## Non-blocking design
|
|
80
|
+
|
|
81
|
+
push-sentinel warns — it does not block — because:
|
|
82
|
+
|
|
83
|
+
- Blocking creates friction that causes developers to skip or uninstall the hook
|
|
84
|
+
- A warning seen at push time is still early enough to catch accidental leaks
|
|
85
|
+
- Use `--block-on-high` if you want stricter enforcement for HIGH-severity findings
|
|
86
|
+
|
|
87
|
+
## Uninstall
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
npx push-sentinel uninstall
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The original `pre-push` hook (if any) is automatically restored.
|
|
94
|
+
|
|
95
|
+
## Requirements
|
|
96
|
+
|
|
97
|
+
- Node.js >= 16
|
|
98
|
+
- No additional dependencies
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { install, uninstall } = require('../src/install');
|
|
5
|
+
const { scan } = require('../src/scan');
|
|
6
|
+
const { report, hasHighFindings } = require('../src/reporter');
|
|
7
|
+
const { getRepoRoot } = require('../src/utils');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const [, , command, ...args] = process.argv;
|
|
12
|
+
|
|
13
|
+
function getIgnoreFilePath() {
|
|
14
|
+
return path.join(getRepoRoot(), '.push-sentinel-ignore');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readIgnoreFile() {
|
|
18
|
+
const p = getIgnoreFilePath();
|
|
19
|
+
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeIgnoreFile(content) {
|
|
23
|
+
fs.writeFileSync(getIgnoreFilePath(), content, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function handleIgnore(args) {
|
|
27
|
+
if (args.includes('--list')) {
|
|
28
|
+
const content = readIgnoreFile();
|
|
29
|
+
if (!content.trim()) {
|
|
30
|
+
console.log('[push-sentinel] No ignore rules set.');
|
|
31
|
+
} else {
|
|
32
|
+
console.log('[push-sentinel] Ignore rules:');
|
|
33
|
+
console.log(content);
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (args.includes('--remove')) {
|
|
39
|
+
const idx = args.indexOf('--remove');
|
|
40
|
+
const target = args[idx + 1];
|
|
41
|
+
if (!target) {
|
|
42
|
+
console.error('[push-sentinel] Usage: push-sentinel ignore --remove <pattern>');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
const lines = readIgnoreFile().split('\n').filter((l) => l.trim() !== target);
|
|
46
|
+
writeIgnoreFile(lines.join('\n'));
|
|
47
|
+
console.log(`[push-sentinel] Removed ignore rule: ${target}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (args.includes('--pattern')) {
|
|
52
|
+
const idx = args.indexOf('--pattern');
|
|
53
|
+
const pattern = args[idx + 1];
|
|
54
|
+
if (!pattern) {
|
|
55
|
+
console.error('[push-sentinel] Usage: push-sentinel ignore --pattern <PATTERN_NAME>');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
const content = readIgnoreFile();
|
|
59
|
+
if (content.split('\n').some((l) => l.trim() === pattern)) {
|
|
60
|
+
console.log(`[push-sentinel] Pattern already ignored: ${pattern}`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
writeIgnoreFile(content + (content.endsWith('\n') || !content ? '' : '\n') + pattern + '\n');
|
|
64
|
+
console.log(`[push-sentinel] Added ignore pattern: ${pattern}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const target = args[0];
|
|
69
|
+
if (!target) {
|
|
70
|
+
console.error('[push-sentinel] Usage: push-sentinel ignore <file>:<line>');
|
|
71
|
+
console.error(' push-sentinel ignore --pattern <PATTERN_NAME>');
|
|
72
|
+
console.error(' push-sentinel ignore --list');
|
|
73
|
+
console.error(' push-sentinel ignore --remove <entry>');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
const content = readIgnoreFile();
|
|
77
|
+
if (content.split('\n').some((l) => l.trim() === target)) {
|
|
78
|
+
console.log(`[push-sentinel] Already ignored: ${target}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
writeIgnoreFile(content + (content.endsWith('\n') || !content ? '' : '\n') + target + '\n');
|
|
82
|
+
console.log(`[push-sentinel] Added ignore rule: ${target}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
switch (command) {
|
|
86
|
+
case 'install':
|
|
87
|
+
install();
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case 'uninstall':
|
|
91
|
+
uninstall();
|
|
92
|
+
break;
|
|
93
|
+
|
|
94
|
+
case 'scan': {
|
|
95
|
+
// --block-on-high: exit 1 if any HIGH severity findings are detected, blocking the push
|
|
96
|
+
const blockOnHigh = args.includes('--block-on-high');
|
|
97
|
+
// --local-sha / --remote-sha: passed by the pre-push hook from git's stdin
|
|
98
|
+
const localShaIdx = args.indexOf('--local-sha');
|
|
99
|
+
const remoteShaIdx = args.indexOf('--remote-sha');
|
|
100
|
+
const localSha = localShaIdx !== -1 ? args[localShaIdx + 1] : undefined;
|
|
101
|
+
const remoteSha = remoteShaIdx !== -1 ? args[remoteShaIdx + 1] : undefined;
|
|
102
|
+
const findings = scan(localSha, remoteSha);
|
|
103
|
+
report(findings);
|
|
104
|
+
if (blockOnHigh && hasHighFindings(findings)) {
|
|
105
|
+
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
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
process.exit(0);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case 'ignore':
|
|
113
|
+
handleIgnore(args);
|
|
114
|
+
break;
|
|
115
|
+
|
|
116
|
+
default:
|
|
117
|
+
console.log('push-sentinel: Warns you if secrets are in your git diff before push.\n');
|
|
118
|
+
console.log('Usage:');
|
|
119
|
+
console.log(' push-sentinel install Install pre-push hook');
|
|
120
|
+
console.log(' push-sentinel uninstall Remove pre-push hook');
|
|
121
|
+
console.log(' push-sentinel scan Manually run secret scan (warn only)');
|
|
122
|
+
console.log(' push-sentinel scan --block-on-high Block push if HIGH findings detected');
|
|
123
|
+
console.log(' push-sentinel ignore <file:line> Ignore a specific finding');
|
|
124
|
+
console.log(' push-sentinel ignore --pattern <NAME> Ignore a pattern');
|
|
125
|
+
console.log(' push-sentinel ignore --list Show all ignore rules');
|
|
126
|
+
console.log(' push-sentinel ignore --remove <entry> Remove an ignore rule');
|
|
127
|
+
break;
|
|
128
|
+
}
|
package/hook-template.sh
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# push-sentinel
|
|
3
|
+
#
|
|
4
|
+
# Git passes pushed ref information via stdin, one line per ref being pushed:
|
|
5
|
+
# <local-ref> <local-sha1> <remote-ref> <remote-sha1>
|
|
6
|
+
#
|
|
7
|
+
# We read each line and pass the SHAs explicitly so push-sentinel can compute
|
|
8
|
+
# the exact range of commits being pushed (including new branch / first push).
|
|
9
|
+
|
|
10
|
+
EXIT_CODE=0
|
|
11
|
+
|
|
12
|
+
while read local_ref local_sha remote_ref remote_sha; do
|
|
13
|
+
npx push-sentinel scan --local-sha "$local_sha" --remote-sha "$remote_sha"
|
|
14
|
+
RESULT=$?
|
|
15
|
+
if [ $RESULT -ne 0 ]; then
|
|
16
|
+
EXIT_CODE=$RESULT
|
|
17
|
+
fi
|
|
18
|
+
done
|
|
19
|
+
|
|
20
|
+
if [ $EXIT_CODE -ne 0 ]; then
|
|
21
|
+
exit $EXIT_CODE
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
if [ -f "$(git rev-parse --git-dir)/hooks/pre-push.local" ]; then
|
|
25
|
+
"$(git rev-parse --git-dir)/hooks/pre-push.local" "$@"
|
|
26
|
+
fi
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "push-sentinel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Warns you if secrets are in your git diff before push.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"push-sentinel": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"src/",
|
|
11
|
+
"hook-template.sh"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"git",
|
|
15
|
+
"pre-push",
|
|
16
|
+
"secrets",
|
|
17
|
+
"security",
|
|
18
|
+
"hook"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/Pmaind/pre-push-secrets.git"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=16"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {}
|
|
29
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const SENTINEL_MARKER = '# push-sentinel';
|
|
8
|
+
|
|
9
|
+
function getGitDir() {
|
|
10
|
+
try {
|
|
11
|
+
return execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error('Not inside a git repository.');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getHookPath(gitDir) {
|
|
18
|
+
return path.join(gitDir, 'hooks', 'pre-push');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getLocalHookPath(gitDir) {
|
|
22
|
+
return path.join(gitDir, 'hooks', 'pre-push.local');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Wrapper hook content (spec 4.2)
|
|
26
|
+
// Reads git's stdin (one line per ref being pushed) and passes each SHA pair
|
|
27
|
+
// to push-sentinel so it can compute the exact commit range being pushed.
|
|
28
|
+
function hookContent() {
|
|
29
|
+
return `#!/bin/sh
|
|
30
|
+
${SENTINEL_MARKER}
|
|
31
|
+
#
|
|
32
|
+
# Git passes pushed ref information via stdin, one line per ref:
|
|
33
|
+
# <local-ref> <local-sha1> <remote-ref> <remote-sha1>
|
|
34
|
+
# We forward the SHAs so push-sentinel can determine exactly which commits
|
|
35
|
+
# are being pushed, including new branches (remote-sha = 0000...0000).
|
|
36
|
+
|
|
37
|
+
EXIT_CODE=0
|
|
38
|
+
|
|
39
|
+
while read local_ref local_sha remote_ref remote_sha; do
|
|
40
|
+
npx push-sentinel scan --local-sha "$local_sha" --remote-sha "$remote_sha"
|
|
41
|
+
RESULT=$?
|
|
42
|
+
if [ $RESULT -ne 0 ]; then
|
|
43
|
+
EXIT_CODE=$RESULT
|
|
44
|
+
fi
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
if [ $EXIT_CODE -ne 0 ]; then
|
|
48
|
+
exit $EXIT_CODE
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
if [ -f "$(git rev-parse --git-dir)/hooks/pre-push.local" ]; then
|
|
52
|
+
"$(git rev-parse --git-dir)/hooks/pre-push.local" "$@"
|
|
53
|
+
fi
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function install() {
|
|
58
|
+
const gitDir = getGitDir();
|
|
59
|
+
const hookPath = getHookPath(gitDir);
|
|
60
|
+
const localPath = getLocalHookPath(gitDir);
|
|
61
|
+
|
|
62
|
+
// Idempotency: already installed
|
|
63
|
+
if (fs.existsSync(hookPath)) {
|
|
64
|
+
const existing = fs.readFileSync(hookPath, 'utf8');
|
|
65
|
+
if (existing.includes(SENTINEL_MARKER)) {
|
|
66
|
+
console.log('[push-sentinel] Already installed.');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Preserve existing hook as pre-push.local (spec 4.2)
|
|
70
|
+
fs.renameSync(hookPath, localPath);
|
|
71
|
+
fs.chmodSync(localPath, 0o755);
|
|
72
|
+
console.log('[push-sentinel] Existing pre-push hook preserved as pre-push.local.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fs.writeFileSync(hookPath, hookContent(), 'utf8');
|
|
76
|
+
fs.chmodSync(hookPath, 0o755);
|
|
77
|
+
console.log('[push-sentinel] Installed pre-push hook.');
|
|
78
|
+
console.log('[push-sentinel] Tip: to block pushes on HIGH findings, edit the hook to use: npx push-sentinel scan --block-on-high');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function uninstall() {
|
|
82
|
+
const gitDir = getGitDir();
|
|
83
|
+
const hookPath = getHookPath(gitDir);
|
|
84
|
+
const localPath = getLocalHookPath(gitDir);
|
|
85
|
+
|
|
86
|
+
if (!fs.existsSync(hookPath)) {
|
|
87
|
+
console.log('[push-sentinel] No pre-push hook found.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const content = fs.readFileSync(hookPath, 'utf8');
|
|
92
|
+
if (!content.includes(SENTINEL_MARKER)) {
|
|
93
|
+
console.log('[push-sentinel] push-sentinel hook not found. Nothing to remove.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fs.unlinkSync(hookPath);
|
|
98
|
+
|
|
99
|
+
// Restore pre-push.local if it exists (spec 4.2)
|
|
100
|
+
if (fs.existsSync(localPath)) {
|
|
101
|
+
fs.renameSync(localPath, hookPath);
|
|
102
|
+
console.log('[push-sentinel] Uninstalled. Original pre-push hook restored.');
|
|
103
|
+
} else {
|
|
104
|
+
console.log('[push-sentinel] Uninstalled.');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { install, uninstall };
|
package/src/patterns.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Detection patterns per design spec section 2.2
|
|
4
|
+
const PATTERNS = [
|
|
5
|
+
{
|
|
6
|
+
name: 'Private Key',
|
|
7
|
+
severity: 'HIGH',
|
|
8
|
+
// Covers RSA, EC, OPENSSH, DSA, ECDSA, and PKCS#8 (PRIVATE KEY)
|
|
9
|
+
regex: /-----BEGIN (RSA |EC |OPENSSH |DSA |ECDSA )?PRIVATE KEY-----/,
|
|
10
|
+
risk: 'Full server/certificate takeover. Attacker can impersonate your server or decrypt all traffic.',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'AWS Access Key',
|
|
14
|
+
severity: 'HIGH',
|
|
15
|
+
regex: /AKIA[0-9A-Z]{16}/,
|
|
16
|
+
risk: 'Full access to AWS resources. Attacker can create/delete instances, incur charges, or exfiltrate data.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'GitHub Token',
|
|
20
|
+
severity: 'HIGH',
|
|
21
|
+
// ghp_ tokens are 36 chars; github_pat_ tokens are longer — require at least 36 chars after prefix
|
|
22
|
+
regex: /ghp_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9_]{36,}/,
|
|
23
|
+
risk: 'Full read/write access to your GitHub repositories, including deletion.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
// Must appear before OpenAI: sk-ant-... also matches the broader sk- pattern
|
|
27
|
+
name: 'Anthropic API Key',
|
|
28
|
+
severity: 'MEDIUM',
|
|
29
|
+
regex: /sk-ant-[a-zA-Z0-9\-]{32,}/,
|
|
30
|
+
risk: 'Unauthorized API usage billed to your account. Possible data access.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'OpenAI API Key',
|
|
34
|
+
severity: 'MEDIUM',
|
|
35
|
+
// Matches sk-proj-..., sk-...-..., and legacy sk-... formats (dash included in character class)
|
|
36
|
+
regex: /sk-[a-zA-Z0-9\-]{32,}/,
|
|
37
|
+
risk: 'Unauthorized API usage billed to your account. Possible data access.',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'Generic API Key',
|
|
41
|
+
severity: 'LOW',
|
|
42
|
+
regex: /[Aa][Pp][Ii]_?[Kk][Ee][Yy]\s*=\s*["']?([^\s"']{16,})/,
|
|
43
|
+
risk: 'Depends on the service. May allow unauthorized access or actions.',
|
|
44
|
+
captureGroup: 1,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// AWS Secret Key is detected by variable name filter + high entropy (no fixed prefix pattern)
|
|
49
|
+
// handled in scan.js via variable name filter + entropy check
|
|
50
|
+
|
|
51
|
+
module.exports = { PATTERNS };
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MAX_FINDINGS = 10;
|
|
4
|
+
|
|
5
|
+
// Show only the first 6 characters and mask the rest to minimise log exposure
|
|
6
|
+
function maskValue(value) {
|
|
7
|
+
if (!value) return '(staged file)';
|
|
8
|
+
const show = Math.min(6, value.length);
|
|
9
|
+
return value.slice(0, show) + 'x'.repeat(Math.max(0, value.length - show)) + '...';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function severityLabel(severity) {
|
|
13
|
+
return `[${severity}]`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function report(findings) {
|
|
17
|
+
if (findings.length === 0) {
|
|
18
|
+
process.stderr.write('[push-sentinel] \u2713 No secrets detected.\n');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const shown = findings.slice(0, MAX_FINDINGS);
|
|
23
|
+
const extra = findings.length - shown.length;
|
|
24
|
+
|
|
25
|
+
process.stderr.write('[push-sentinel] \u26a0 Potential secrets found:\n\n');
|
|
26
|
+
|
|
27
|
+
for (const f of shown) {
|
|
28
|
+
const location = f.lineNum ? `${f.file}:${f.lineNum}` : f.file;
|
|
29
|
+
process.stderr.write(` ${severityLabel(f.severity)} ${location}\n`);
|
|
30
|
+
process.stderr.write(` ${maskValue(f.matchedValue)}\n`);
|
|
31
|
+
process.stderr.write(` \u2192 Risk: ${f.risk}\n`);
|
|
32
|
+
if (f.lineNum) {
|
|
33
|
+
process.stderr.write(` \u2192 To ignore this line: push-sentinel ignore ${location}\n`);
|
|
34
|
+
}
|
|
35
|
+
process.stderr.write('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (extra > 0) {
|
|
39
|
+
process.stderr.write(` + ${extra} more finding(s) not shown.\n\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.stderr.write(' Push continues. Double-check before sharing.\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasHighFindings(findings) {
|
|
46
|
+
return findings.some((f) => f.severity === 'HIGH');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { report, hasHighFindings };
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { PATTERNS } = require('./patterns');
|
|
7
|
+
const { getRepoRoot } = require('./utils');
|
|
8
|
+
|
|
9
|
+
// Zero SHA: indicates a new branch with no history on the remote
|
|
10
|
+
const ZERO_SHA = '0000000000000000000000000000000000000000';
|
|
11
|
+
|
|
12
|
+
// Variable name keywords that must appear on the line (false positive filter, spec 2.3 rule ①)
|
|
13
|
+
const VAR_NAME_KEYWORDS = /API|SECRET|TOKEN|KEY|PASSWORD/i;
|
|
14
|
+
|
|
15
|
+
// Shannon entropy calculation (spec 2.3 rule ②)
|
|
16
|
+
function shannonEntropy(str) {
|
|
17
|
+
const freq = {};
|
|
18
|
+
for (const c of str) {
|
|
19
|
+
freq[c] = (freq[c] || 0) + 1;
|
|
20
|
+
}
|
|
21
|
+
let h = 0;
|
|
22
|
+
const len = str.length;
|
|
23
|
+
for (const count of Object.values(freq)) {
|
|
24
|
+
const p = count / len;
|
|
25
|
+
h -= p * Math.log2(p);
|
|
26
|
+
}
|
|
27
|
+
return h;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check if a candidate string is high-entropy (spec 2.3 rule ②)
|
|
31
|
+
function isHighEntropy(candidate) {
|
|
32
|
+
if (candidate.length < 16) return false;
|
|
33
|
+
if (/^(.)\1+$/.test(candidate)) return false;
|
|
34
|
+
return shannonEntropy(candidate) >= 3.5;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Load .push-sentinel-ignore (spec 2.3 / 2.4)
|
|
38
|
+
function loadIgnoreRules(repoRoot) {
|
|
39
|
+
const ignoreFile = path.join(repoRoot, '.push-sentinel-ignore');
|
|
40
|
+
if (!fs.existsSync(ignoreFile)) return { lines: [], patterns: [] };
|
|
41
|
+
|
|
42
|
+
const content = fs.readFileSync(ignoreFile, 'utf8');
|
|
43
|
+
const lines = [];
|
|
44
|
+
const patterns = [];
|
|
45
|
+
|
|
46
|
+
for (const raw of content.split('\n')) {
|
|
47
|
+
const line = raw.trim();
|
|
48
|
+
if (!line || line.startsWith('#')) continue;
|
|
49
|
+
if (/[:\/\\*?]/.test(line) || line.includes('.')) {
|
|
50
|
+
lines.push(line);
|
|
51
|
+
} else {
|
|
52
|
+
patterns.push(line.toUpperCase());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { lines, patterns };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check if a file (regardless of line) matches any ignore rule
|
|
59
|
+
function matchesIgnoreFile(filePath, ignoreLines) {
|
|
60
|
+
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
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check if a file:line matches any ignore rule (simple glob support)
|
|
74
|
+
function matchesIgnoreLine(filePath, lineNum, ignoreLines) {
|
|
75
|
+
const target = `${filePath}:${lineNum}`;
|
|
76
|
+
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
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Check if the line content is a test/fake/example dummy (spec 2.3)
|
|
90
|
+
function isDummyValue(line) {
|
|
91
|
+
return /\b(test_|fake_|example_|dummy_|placeholder)/i.test(line);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Build the git log command args for the range of commits being pushed.
|
|
95
|
+
// localSha/remoteSha come from the pre-push hook stdin (see hook-template.sh).
|
|
96
|
+
// When called manually (no SHAs), falls back to @{u}..HEAD then last commit.
|
|
97
|
+
function getDiffArgs(localSha, remoteSha) {
|
|
98
|
+
if (localSha && remoteSha) {
|
|
99
|
+
if (remoteSha === ZERO_SHA) {
|
|
100
|
+
// New branch: scan commits not yet reachable from any remote
|
|
101
|
+
return ['log', '--not', '--remotes', '-p', localSha];
|
|
102
|
+
}
|
|
103
|
+
return ['log', `${remoteSha}..${localSha}`, '-p'];
|
|
104
|
+
}
|
|
105
|
+
// Manual scan: use upstream range, or fall back to last commit
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getFileListArgs(localSha, remoteSha) {
|
|
110
|
+
if (localSha && remoteSha) {
|
|
111
|
+
if (remoteSha === ZERO_SHA) {
|
|
112
|
+
return ['log', '--not', '--remotes', '--name-only', '--format=', localSha];
|
|
113
|
+
}
|
|
114
|
+
return ['diff', '--name-only', `${remoteSha}..${localSha}`];
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function runGit(args) {
|
|
120
|
+
const result = spawnSync('git', args, { encoding: 'utf8', stdio: 'pipe' });
|
|
121
|
+
return result.status === 0 ? (result.stdout || '') : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getPushDiff(localSha, remoteSha) {
|
|
125
|
+
const args = getDiffArgs(localSha, remoteSha);
|
|
126
|
+
if (args) {
|
|
127
|
+
const out = runGit(args);
|
|
128
|
+
if (out !== null) return out;
|
|
129
|
+
}
|
|
130
|
+
// Fallback for manual scan
|
|
131
|
+
return runGit(['log', '@{u}..HEAD', '-p']) ?? runGit(['log', '-1', '-p', 'HEAD']) ?? '';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getPushedFileList(localSha, remoteSha) {
|
|
135
|
+
const args = getFileListArgs(localSha, remoteSha);
|
|
136
|
+
if (args) {
|
|
137
|
+
const out = runGit(args);
|
|
138
|
+
if (out !== null) return out.split('\n').map((f) => f.trim()).filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
// Fallback for manual scan
|
|
141
|
+
const out = runGit(['diff', '--name-only', '@{u}..HEAD'])
|
|
142
|
+
?? runGit(['diff', '--name-only', 'HEAD~1..HEAD'])
|
|
143
|
+
?? '';
|
|
144
|
+
return out.split('\n').map((f) => f.trim()).filter(Boolean);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Parse diff output into added lines: { file, lineNum, content }
|
|
148
|
+
function parseDiffAddedLines(diff) {
|
|
149
|
+
const results = [];
|
|
150
|
+
let currentFile = null;
|
|
151
|
+
let lineNum = 0;
|
|
152
|
+
|
|
153
|
+
for (const line of diff.split('\n')) {
|
|
154
|
+
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
|
|
155
|
+
if (fileMatch) {
|
|
156
|
+
currentFile = fileMatch[1];
|
|
157
|
+
lineNum = 0;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
161
|
+
if (hunkMatch) {
|
|
162
|
+
lineNum = parseInt(hunkMatch[1], 10) - 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!currentFile) continue;
|
|
166
|
+
|
|
167
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
168
|
+
lineNum++;
|
|
169
|
+
results.push({ file: currentFile, lineNum, content: line.slice(1) });
|
|
170
|
+
} else if (!line.startsWith('-')) {
|
|
171
|
+
lineNum++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return results;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// localSha / remoteSha: passed from the pre-push hook via --local-sha / --remote-sha flags.
|
|
178
|
+
// When omitted (manual scan), falls back to upstream-based diff.
|
|
179
|
+
function scan(localSha, remoteSha) {
|
|
180
|
+
// Ref deletion (git push origin :branch): local_sha is all zeros.
|
|
181
|
+
// There are no commits to scan — return immediately to avoid falling back
|
|
182
|
+
// to @{u}..HEAD and producing spurious warnings.
|
|
183
|
+
if (localSha === ZERO_SHA) return [];
|
|
184
|
+
const repoRoot = getRepoRoot();
|
|
185
|
+
const ignore = loadIgnoreRules(repoRoot);
|
|
186
|
+
const findings = [];
|
|
187
|
+
|
|
188
|
+
// .env file check (spec 2.2 / 2.3 rule ③)
|
|
189
|
+
// Note: files appearing in the push diff are tracked by git.
|
|
190
|
+
// We do NOT skip based on .gitignore — a tracked file in .gitignore can still leak secrets.
|
|
191
|
+
const pushedFiles = getPushedFileList(localSha, remoteSha);
|
|
192
|
+
for (const f of pushedFiles) {
|
|
193
|
+
if (f === '.env' || f.endsWith('/.env') || /^\.env(\.|$)/.test(path.basename(f))) {
|
|
194
|
+
if (!matchesIgnoreFile(f, ignore.lines)) {
|
|
195
|
+
findings.push({
|
|
196
|
+
file: f,
|
|
197
|
+
lineNum: null,
|
|
198
|
+
matchedValue: null,
|
|
199
|
+
severity: 'MEDIUM',
|
|
200
|
+
patternName: '.env file',
|
|
201
|
+
risk: 'Committing a .env file may expose multiple secrets at once.',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const diff = getPushDiff(localSha, remoteSha);
|
|
208
|
+
if (!diff) return findings;
|
|
209
|
+
|
|
210
|
+
const addedLines = parseDiffAddedLines(diff);
|
|
211
|
+
|
|
212
|
+
for (const { file, lineNum, content } of addedLines) {
|
|
213
|
+
// Files appearing in a push diff are tracked; we do NOT skip based on .gitignore.
|
|
214
|
+
if (matchesIgnoreLine(file, lineNum, ignore.lines)) continue;
|
|
215
|
+
if (isDummyValue(content)) continue;
|
|
216
|
+
|
|
217
|
+
let matched = false;
|
|
218
|
+
for (const pattern of PATTERNS) {
|
|
219
|
+
const skipVarFilter = ['Private Key', 'AWS Access Key', 'GitHub Token', 'OpenAI API Key', 'Anthropic API Key'];
|
|
220
|
+
if (!skipVarFilter.includes(pattern.name) && !VAR_NAME_KEYWORDS.test(content)) continue;
|
|
221
|
+
|
|
222
|
+
if (ignore.patterns.includes(pattern.name.toUpperCase().replace(/\s+/g, '_'))) continue;
|
|
223
|
+
const lineUpper = content.toUpperCase();
|
|
224
|
+
if (ignore.patterns.some((p) => lineUpper.includes(p))) continue;
|
|
225
|
+
|
|
226
|
+
const match = content.match(pattern.regex);
|
|
227
|
+
if (!match) continue;
|
|
228
|
+
|
|
229
|
+
const candidate = pattern.captureGroup ? match[pattern.captureGroup] : match[0];
|
|
230
|
+
if (pattern.name === 'Generic API Key' && !isHighEntropy(candidate)) continue;
|
|
231
|
+
|
|
232
|
+
findings.push({
|
|
233
|
+
file,
|
|
234
|
+
lineNum,
|
|
235
|
+
matchedValue: candidate,
|
|
236
|
+
severity: pattern.severity,
|
|
237
|
+
patternName: pattern.name,
|
|
238
|
+
risk: pattern.risk,
|
|
239
|
+
});
|
|
240
|
+
matched = true;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (matched) continue;
|
|
245
|
+
|
|
246
|
+
// AWS Secret Key: variable name + high entropy (no fixed prefix)
|
|
247
|
+
// isDummyValue and matchesIgnoreLine are already checked above
|
|
248
|
+
if (VAR_NAME_KEYWORDS.test(content) && /AWS.*SECRET|SECRET.*AWS/i.test(content)) {
|
|
249
|
+
const valueMatch = content.match(/[a-zA-Z0-9/+=]{40}/);
|
|
250
|
+
if (valueMatch && isHighEntropy(valueMatch[0])) {
|
|
251
|
+
findings.push({
|
|
252
|
+
file,
|
|
253
|
+
lineNum,
|
|
254
|
+
matchedValue: valueMatch[0],
|
|
255
|
+
severity: 'HIGH',
|
|
256
|
+
patternName: 'AWS Secret Key',
|
|
257
|
+
risk: 'Full access to AWS resources. Attacker can create/delete instances, incur charges, or exfiltrate data.',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return findings;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { scan };
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
function getRepoRoot() {
|
|
6
|
+
try {
|
|
7
|
+
return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
8
|
+
} catch {
|
|
9
|
+
return process.cwd();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { getRepoRoot };
|