secure-push-check 1.0.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/README.md +218 -0
- package/bin/secure-push-check.js +3 -0
- package/package.json +41 -0
- package/src/cli.js +185 -0
- package/src/index.js +266 -0
- package/src/scanners/credentials.js +207 -0
- package/src/scanners/deps.js +160 -0
- package/src/scanners/files.js +88 -0
- package/src/scanners/gitignore.js +112 -0
- package/src/scanners/secrets.js +302 -0
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# secure-push-check
|
|
2
|
+
|
|
3
|
+
`secure-push-check` is a production-ready Node.js CLI that scans a local Git repository for common security risks before you push to GitHub.
|
|
4
|
+
|
|
5
|
+
It detects:
|
|
6
|
+
- Hardcoded secrets in text files (regex-based, configurable)
|
|
7
|
+
- Sensitive files accidentally tracked by Git
|
|
8
|
+
- Missing sensitive patterns in `.gitignore`
|
|
9
|
+
- Dependency vulnerabilities from `npm audit --json`
|
|
10
|
+
- Hardcoded credentials in JS/TS via Babel AST parsing
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- Fully async ESM implementation (`Node.js >= 18`)
|
|
15
|
+
- Modular scanner architecture under `src/scanners`
|
|
16
|
+
- Configurable behavior via `.securepushrc.json`
|
|
17
|
+
- Colored terminal output with file paths and line numbers
|
|
18
|
+
- JSON report mode for CI systems
|
|
19
|
+
- Git `pre-push` hook installation command
|
|
20
|
+
|
|
21
|
+
## Project Structure
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
.
|
|
25
|
+
├── bin
|
|
26
|
+
│ └── secure-push-check.js
|
|
27
|
+
├── src
|
|
28
|
+
│ ├── scanners
|
|
29
|
+
│ │ ├── credentials.js
|
|
30
|
+
│ │ ├── deps.js
|
|
31
|
+
│ │ ├── files.js
|
|
32
|
+
│ │ ├── gitignore.js
|
|
33
|
+
│ │ └── secrets.js
|
|
34
|
+
│ ├── cli.js
|
|
35
|
+
│ └── index.js
|
|
36
|
+
├── package.json
|
|
37
|
+
└── README.md
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
### Local project install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install --save-dev secure-push-check
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Run from source
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install
|
|
52
|
+
npm run scan
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## CLI Commands
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
secure-push-check scan
|
|
59
|
+
secure-push-check install
|
|
60
|
+
secure-push-check report --json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `scan`
|
|
64
|
+
|
|
65
|
+
Runs all checks and prints colorized output.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
secure-push-check scan
|
|
69
|
+
secure-push-check scan --json
|
|
70
|
+
secure-push-check scan --cwd /path/to/repo
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### `report`
|
|
74
|
+
|
|
75
|
+
Runs all checks and prints report output (recommended with `--json` for automation).
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
secure-push-check report --json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `install`
|
|
82
|
+
|
|
83
|
+
Installs or updates a `pre-push` hook at `.git/hooks/pre-push`.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
secure-push-check install
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
Create `.securepushrc.json` in repository root:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"ignore": ["tests/**", "fixtures/**"],
|
|
96
|
+
"allowPatterns": ["TEST_KEY_", "/^fake_/i"],
|
|
97
|
+
"severity": "high",
|
|
98
|
+
"secretPatterns": [
|
|
99
|
+
{
|
|
100
|
+
"name": "Internal Token",
|
|
101
|
+
"regex": "int_[A-Za-z0-9]{24,}",
|
|
102
|
+
"flags": "g",
|
|
103
|
+
"severity": "critical"
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Config Fields
|
|
110
|
+
|
|
111
|
+
- `ignore`: glob patterns excluded from file scanning.
|
|
112
|
+
- `allowPatterns`: strings or regex-like strings (`/pattern/flags`) to suppress known-safe matches.
|
|
113
|
+
- `severity`: push-block threshold (`low`, `moderate`, `high`, `critical`).
|
|
114
|
+
- `secretPatterns`: additional custom secret regex rules.
|
|
115
|
+
|
|
116
|
+
## Output and Exit Codes
|
|
117
|
+
|
|
118
|
+
Report sections:
|
|
119
|
+
- `❌ Critical Issues`
|
|
120
|
+
- `⚠️ Warnings`
|
|
121
|
+
- `✅ Passed Checks`
|
|
122
|
+
|
|
123
|
+
Exit code semantics:
|
|
124
|
+
- `0`: safe to push
|
|
125
|
+
- `1`: push blocked
|
|
126
|
+
|
|
127
|
+
## Example Output
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
🔍 Secure Push Check v1.0.0
|
|
131
|
+
|
|
132
|
+
❌ Critical Issues
|
|
133
|
+
- [CRITICAL] Potential OpenAI Key detected (src/config.js:18:16)
|
|
134
|
+
- [CRITICAL] Sensitive file exists and is not ignored by Git (.env)
|
|
135
|
+
|
|
136
|
+
⚠️ Warnings
|
|
137
|
+
- [HIGH] Missing sensitive ignore pattern: .env.local (.gitignore)
|
|
138
|
+
- [MODERATE] npm audit found 2 moderate vulnerabilities. (package-lock.json)
|
|
139
|
+
|
|
140
|
+
✅ Passed Checks
|
|
141
|
+
- Hardcoded Credentials Scan
|
|
142
|
+
|
|
143
|
+
Summary: critical=2, high=1, moderate=1, total=4
|
|
144
|
+
Result: BLOCKED (threshold: high)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Git Hook Workflow
|
|
148
|
+
|
|
149
|
+
Install once:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
secure-push-check install
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
During `git push`, the generated hook runs:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
secure-push-check scan
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
If scan exits non-zero, push is blocked.
|
|
162
|
+
|
|
163
|
+
## CI Usage
|
|
164
|
+
|
|
165
|
+
GitHub Actions example:
|
|
166
|
+
|
|
167
|
+
```yaml
|
|
168
|
+
name: security-push-check
|
|
169
|
+
on:
|
|
170
|
+
pull_request:
|
|
171
|
+
push:
|
|
172
|
+
branches: [main]
|
|
173
|
+
|
|
174
|
+
jobs:
|
|
175
|
+
scan:
|
|
176
|
+
runs-on: ubuntu-latest
|
|
177
|
+
steps:
|
|
178
|
+
- uses: actions/checkout@v4
|
|
179
|
+
- uses: actions/setup-node@v4
|
|
180
|
+
with:
|
|
181
|
+
node-version: "20"
|
|
182
|
+
- run: npm ci
|
|
183
|
+
- run: npx secure-push-check report --json
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Publish Instructions
|
|
187
|
+
|
|
188
|
+
1. Authenticate with npm:
|
|
189
|
+
```bash
|
|
190
|
+
npm login
|
|
191
|
+
```
|
|
192
|
+
2. Verify package:
|
|
193
|
+
```bash
|
|
194
|
+
npm pack --dry-run
|
|
195
|
+
```
|
|
196
|
+
3. Publish:
|
|
197
|
+
```bash
|
|
198
|
+
npm publish --access public
|
|
199
|
+
```
|
|
200
|
+
4. Tag release:
|
|
201
|
+
```bash
|
|
202
|
+
git tag v1.0.0
|
|
203
|
+
git push origin v1.0.0
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Contributing
|
|
207
|
+
|
|
208
|
+
1. Fork and create a feature branch.
|
|
209
|
+
2. Keep scanner modules focused and unit-testable.
|
|
210
|
+
3. Add tests for parser/regex edge cases and hook installation flows.
|
|
211
|
+
4. Open a PR with:
|
|
212
|
+
- problem statement
|
|
213
|
+
- change summary
|
|
214
|
+
- sample output
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "secure-push-check",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Production-ready CLI that scans local Git repositories for security risks before push.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"secure-push-check": "bin/secure-push-check.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"scan": "node ./bin/secure-push-check.js scan",
|
|
23
|
+
"report:json": "node ./bin/secure-push-check.js report --json",
|
|
24
|
+
"install:hook": "node ./bin/secure-push-check.js install"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"security",
|
|
28
|
+
"git",
|
|
29
|
+
"cli",
|
|
30
|
+
"secrets",
|
|
31
|
+
"audit",
|
|
32
|
+
"pre-push"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@babel/parser": "^7.27.7",
|
|
37
|
+
"chalk": "^5.4.1",
|
|
38
|
+
"commander": "^12.1.0",
|
|
39
|
+
"fast-glob": "^3.3.3"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import {
|
|
4
|
+
TOOL_VERSION,
|
|
5
|
+
installPrePushHook,
|
|
6
|
+
normalizeSeverity,
|
|
7
|
+
runScan
|
|
8
|
+
} from "./index.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} severity
|
|
12
|
+
* @returns {(text: string) => string}
|
|
13
|
+
*/
|
|
14
|
+
function severityColor(severity) {
|
|
15
|
+
switch (normalizeSeverity(severity)) {
|
|
16
|
+
case "critical":
|
|
17
|
+
return chalk.red.bold;
|
|
18
|
+
case "high":
|
|
19
|
+
return chalk.yellow.bold;
|
|
20
|
+
case "moderate":
|
|
21
|
+
return chalk.hex("#f59e0b");
|
|
22
|
+
case "low":
|
|
23
|
+
return chalk.blue;
|
|
24
|
+
default:
|
|
25
|
+
return chalk.white;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} finding
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function formatFindingLocation(finding) {
|
|
34
|
+
const base = finding.file || "unknown";
|
|
35
|
+
if (typeof finding.line === "number" && typeof finding.column === "number") {
|
|
36
|
+
return `${base}:${finding.line}:${finding.column}`;
|
|
37
|
+
}
|
|
38
|
+
if (typeof finding.line === "number") {
|
|
39
|
+
return `${base}:${finding.line}`;
|
|
40
|
+
}
|
|
41
|
+
return base;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} result
|
|
46
|
+
* @returns {void}
|
|
47
|
+
*/
|
|
48
|
+
function renderHumanReport(result) {
|
|
49
|
+
const critical = [];
|
|
50
|
+
const warnings = [];
|
|
51
|
+
|
|
52
|
+
for (const check of result.checks) {
|
|
53
|
+
for (const finding of check.findings || []) {
|
|
54
|
+
const enriched = { ...finding, checkName: check.name };
|
|
55
|
+
if (normalizeSeverity(finding.severity) === "critical") {
|
|
56
|
+
critical.push(enriched);
|
|
57
|
+
} else {
|
|
58
|
+
warnings.push(enriched);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const passedChecks = result.checks.filter((check) => check.status === "passed");
|
|
64
|
+
const skippedChecks = result.checks.filter((check) => check.status === "skipped");
|
|
65
|
+
|
|
66
|
+
console.log(chalk.cyan.bold(`\n🔍 Secure Push Check v${result.version}\n`));
|
|
67
|
+
|
|
68
|
+
console.log(chalk.red.bold("❌ Critical Issues"));
|
|
69
|
+
if (critical.length === 0) {
|
|
70
|
+
console.log(chalk.green(" ✅ None"));
|
|
71
|
+
} else {
|
|
72
|
+
for (const finding of critical) {
|
|
73
|
+
const color = severityColor(finding.severity);
|
|
74
|
+
const severityTag = color(`[${String(finding.severity).toUpperCase()}]`);
|
|
75
|
+
console.log(
|
|
76
|
+
` - ${severityTag} ${finding.message} (${formatFindingLocation(finding)})`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(chalk.yellow.bold("\n⚠️ Warnings"));
|
|
82
|
+
if (warnings.length === 0) {
|
|
83
|
+
console.log(chalk.green(" ✅ None"));
|
|
84
|
+
} else {
|
|
85
|
+
for (const finding of warnings) {
|
|
86
|
+
const color = severityColor(finding.severity);
|
|
87
|
+
const severityTag = color(`[${String(finding.severity).toUpperCase()}]`);
|
|
88
|
+
console.log(
|
|
89
|
+
` - ${severityTag} ${finding.message} (${formatFindingLocation(finding)})`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(chalk.green.bold("\n✅ Passed Checks"));
|
|
95
|
+
if (passedChecks.length === 0) {
|
|
96
|
+
console.log(chalk.yellow(" - None"));
|
|
97
|
+
} else {
|
|
98
|
+
for (const check of passedChecks) {
|
|
99
|
+
console.log(chalk.green(` - ${check.name}`));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (skippedChecks.length > 0) {
|
|
104
|
+
console.log(chalk.gray.bold("\nℹ️ Skipped Checks"));
|
|
105
|
+
for (const check of skippedChecks) {
|
|
106
|
+
console.log(chalk.gray(` - ${check.name}`));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log(
|
|
111
|
+
chalk.bold(
|
|
112
|
+
`\nSummary: critical=${result.summary.critical}, high=${result.summary.high}, moderate=${result.summary.moderate}, total=${result.summary.totalFindings}`
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
console.log(
|
|
116
|
+
result.summary.blocked
|
|
117
|
+
? chalk.red(`Result: BLOCKED (threshold: ${result.summary.severityThreshold})`)
|
|
118
|
+
: chalk.green(`Result: SAFE (threshold: ${result.summary.severityThreshold})`)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {object} options
|
|
124
|
+
* @param {boolean} options.json
|
|
125
|
+
* @param {string} options.cwd
|
|
126
|
+
* @returns {Promise<void>}
|
|
127
|
+
*/
|
|
128
|
+
async function executeScan(options) {
|
|
129
|
+
const result = await runScan({ cwd: options.cwd });
|
|
130
|
+
|
|
131
|
+
if (options.json) {
|
|
132
|
+
console.log(JSON.stringify(result, null, 2));
|
|
133
|
+
} else {
|
|
134
|
+
renderHumanReport(result);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
process.exitCode = result.summary.blocked ? 1 : 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const program = new Command();
|
|
141
|
+
program
|
|
142
|
+
.name("secure-push-check")
|
|
143
|
+
.description("Scan local Git repositories for security risks before pushing.")
|
|
144
|
+
.version(TOOL_VERSION)
|
|
145
|
+
.showHelpAfterError();
|
|
146
|
+
|
|
147
|
+
program
|
|
148
|
+
.command("scan")
|
|
149
|
+
.description("Run all checks and print a colorized report.")
|
|
150
|
+
.option("--json", "Output report as JSON.")
|
|
151
|
+
.option("--cwd <path>", "Working directory to scan.", process.cwd())
|
|
152
|
+
.action(async (options) => {
|
|
153
|
+
await executeScan({
|
|
154
|
+
json: Boolean(options.json),
|
|
155
|
+
cwd: options.cwd
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
program
|
|
160
|
+
.command("report")
|
|
161
|
+
.description("Run all checks and generate a report.")
|
|
162
|
+
.option("--json", "Output report as JSON.")
|
|
163
|
+
.option("--cwd <path>", "Working directory to scan.", process.cwd())
|
|
164
|
+
.action(async (options) => {
|
|
165
|
+
await executeScan({
|
|
166
|
+
json: Boolean(options.json),
|
|
167
|
+
cwd: options.cwd
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
program
|
|
172
|
+
.command("install")
|
|
173
|
+
.description("Install secure-push-check as a pre-push Git hook.")
|
|
174
|
+
.option("--cwd <path>", "Repository path where hook should be installed.", process.cwd())
|
|
175
|
+
.action(async (options) => {
|
|
176
|
+
const result = await installPrePushHook({ cwd: options.cwd });
|
|
177
|
+
console.log(chalk.green(`Pre-push hook ${result.operation} at ${result.hookPath}`));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
await program.parseAsync(process.argv);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { scanSecrets } from "./scanners/secrets.js";
|
|
7
|
+
import { scanSensitiveFiles } from "./scanners/files.js";
|
|
8
|
+
import { validateGitignore } from "./scanners/gitignore.js";
|
|
9
|
+
import { scanDependencies } from "./scanners/deps.js";
|
|
10
|
+
import { scanHardcodedCredentials } from "./scanners/credentials.js";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const pkg = require("../package.json");
|
|
15
|
+
|
|
16
|
+
const HOOK_MARKER_START = "# secure-push-check hook start";
|
|
17
|
+
const HOOK_MARKER_END = "# secure-push-check hook end";
|
|
18
|
+
|
|
19
|
+
const SEVERITY_RANK = {
|
|
20
|
+
low: 1,
|
|
21
|
+
moderate: 2,
|
|
22
|
+
high: 3,
|
|
23
|
+
critical: 4
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const TOOL_NAME = "secure-push-check";
|
|
27
|
+
export const TOOL_VERSION = pkg.version;
|
|
28
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
29
|
+
ignore: [],
|
|
30
|
+
allowPatterns: [],
|
|
31
|
+
severity: "high",
|
|
32
|
+
secretPatterns: []
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {unknown} severity
|
|
37
|
+
* @returns {"low" | "moderate" | "high" | "critical"}
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeSeverity(severity) {
|
|
40
|
+
const value = String(severity || "").trim().toLowerCase();
|
|
41
|
+
if (value in SEVERITY_RANK) {
|
|
42
|
+
return /** @type {"low" | "moderate" | "high" | "critical"} */ (value);
|
|
43
|
+
}
|
|
44
|
+
return "high";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {unknown} values
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
function uniqueStringList(values) {
|
|
52
|
+
if (!Array.isArray(values)) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [...new Set(values.filter((item) => typeof item === "string" && item.trim() !== "").map((item) => item.trim()))];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} repoRoot
|
|
61
|
+
* @returns {Promise<object>}
|
|
62
|
+
*/
|
|
63
|
+
export async function loadConfig(repoRoot) {
|
|
64
|
+
const configPath = path.join(repoRoot, ".securepushrc.json");
|
|
65
|
+
let parsed = {};
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const content = await fs.readFile(configPath, "utf8");
|
|
69
|
+
parsed = JSON.parse(content);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error.code !== "ENOENT") {
|
|
72
|
+
throw new Error(`Failed to parse .securepushrc.json: ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
ignore: uniqueStringList(parsed.ignore),
|
|
78
|
+
allowPatterns: uniqueStringList(parsed.allowPatterns),
|
|
79
|
+
severity: normalizeSeverity(parsed.severity || DEFAULT_CONFIG.severity),
|
|
80
|
+
secretPatterns: Array.isArray(parsed.secretPatterns) ? parsed.secretPatterns : [],
|
|
81
|
+
configPath
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} cwd
|
|
87
|
+
* @returns {Promise<string>}
|
|
88
|
+
*/
|
|
89
|
+
export async function findGitRepositoryRoot(cwd = process.cwd()) {
|
|
90
|
+
try {
|
|
91
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd });
|
|
92
|
+
const repoRoot = stdout.trim();
|
|
93
|
+
if (!repoRoot) {
|
|
94
|
+
throw new Error("Git returned an empty repository path.");
|
|
95
|
+
}
|
|
96
|
+
return repoRoot;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new Error(`Unable to resolve Git repository root from '${cwd}': ${error.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {Array<object>} checks
|
|
104
|
+
* @param {string} severityThreshold
|
|
105
|
+
* @returns {object}
|
|
106
|
+
*/
|
|
107
|
+
export function createSummary(checks, severityThreshold) {
|
|
108
|
+
const findings = checks.flatMap((check) => (Array.isArray(check.findings) ? check.findings : []));
|
|
109
|
+
const severityCounts = {
|
|
110
|
+
critical: 0,
|
|
111
|
+
high: 0,
|
|
112
|
+
moderate: 0,
|
|
113
|
+
low: 0
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (const finding of findings) {
|
|
117
|
+
const severity = normalizeSeverity(finding.severity);
|
|
118
|
+
severityCounts[severity] += 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const threshold = normalizeSeverity(severityThreshold);
|
|
122
|
+
const blocked = findings.some((finding) => SEVERITY_RANK[normalizeSeverity(finding.severity)] >= SEVERITY_RANK[threshold]);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
...severityCounts,
|
|
126
|
+
totalFindings: findings.length,
|
|
127
|
+
passedChecks: checks.filter((check) => check.status === "passed").length,
|
|
128
|
+
failedChecks: checks.filter((check) => check.status === "failed").length,
|
|
129
|
+
skippedChecks: checks.filter((check) => check.status === "skipped").length,
|
|
130
|
+
severityThreshold: threshold,
|
|
131
|
+
blocked
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {object} options
|
|
137
|
+
* @param {string} [options.cwd]
|
|
138
|
+
* @returns {Promise<object>}
|
|
139
|
+
*/
|
|
140
|
+
export async function runScan(options = {}) {
|
|
141
|
+
const cwd = options.cwd || process.cwd();
|
|
142
|
+
const repoRoot = await findGitRepositoryRoot(cwd);
|
|
143
|
+
const config = await loadConfig(repoRoot);
|
|
144
|
+
|
|
145
|
+
const scannerOptions = {
|
|
146
|
+
repoRoot,
|
|
147
|
+
ignoreGlobs: config.ignore,
|
|
148
|
+
allowPatterns: config.allowPatterns,
|
|
149
|
+
secretPatterns: config.secretPatterns
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const checks = await Promise.all([
|
|
153
|
+
scanSecrets(scannerOptions),
|
|
154
|
+
scanSensitiveFiles(scannerOptions),
|
|
155
|
+
validateGitignore(scannerOptions),
|
|
156
|
+
scanDependencies(scannerOptions),
|
|
157
|
+
scanHardcodedCredentials(scannerOptions)
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const summary = createSummary(checks, config.severity);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
tool: TOOL_NAME,
|
|
164
|
+
version: TOOL_VERSION,
|
|
165
|
+
scannedAt: new Date().toISOString(),
|
|
166
|
+
repository: repoRoot,
|
|
167
|
+
config: {
|
|
168
|
+
ignore: config.ignore,
|
|
169
|
+
allowPatterns: config.allowPatterns,
|
|
170
|
+
severity: config.severity,
|
|
171
|
+
customSecretPatterns: config.secretPatterns.length
|
|
172
|
+
},
|
|
173
|
+
checks,
|
|
174
|
+
summary
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
function createHookSnippet() {
|
|
182
|
+
return [
|
|
183
|
+
HOOK_MARKER_START,
|
|
184
|
+
"if command -v secure-push-check >/dev/null 2>&1; then",
|
|
185
|
+
" secure-push-check scan",
|
|
186
|
+
" SECURE_PUSH_CHECK_STATUS=$?",
|
|
187
|
+
"else",
|
|
188
|
+
" npx --no-install secure-push-check scan",
|
|
189
|
+
" SECURE_PUSH_CHECK_STATUS=$?",
|
|
190
|
+
"fi",
|
|
191
|
+
"if [ $SECURE_PUSH_CHECK_STATUS -ne 0 ]; then",
|
|
192
|
+
" echo \"secure-push-check blocked this push.\"",
|
|
193
|
+
" exit $SECURE_PUSH_CHECK_STATUS",
|
|
194
|
+
"fi",
|
|
195
|
+
HOOK_MARKER_END
|
|
196
|
+
].join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {string} existingContent
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
function upsertHookContent(existingContent) {
|
|
204
|
+
const snippet = createHookSnippet();
|
|
205
|
+
const content = existingContent || "";
|
|
206
|
+
|
|
207
|
+
if (content.trim() === "") {
|
|
208
|
+
return `#!/bin/sh\n\n${snippet}\n`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (content.includes(HOOK_MARKER_START) && content.includes(HOOK_MARKER_END)) {
|
|
212
|
+
const escapedStart = HOOK_MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
213
|
+
const escapedEnd = HOOK_MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
214
|
+
const blockRegex = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}\\n?`, "g");
|
|
215
|
+
return content.replace(blockRegex, `${snippet}\n`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const separator = content.endsWith("\n") ? "\n" : "\n\n";
|
|
219
|
+
return `${content}${separator}${snippet}\n`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Install or update a pre-push Git hook.
|
|
224
|
+
*
|
|
225
|
+
* @param {object} options
|
|
226
|
+
* @param {string} [options.cwd]
|
|
227
|
+
* @returns {Promise<object>}
|
|
228
|
+
*/
|
|
229
|
+
export async function installPrePushHook(options = {}) {
|
|
230
|
+
const cwd = options.cwd || process.cwd();
|
|
231
|
+
const repoRoot = await findGitRepositoryRoot(cwd);
|
|
232
|
+
const hooksDir = path.join(repoRoot, ".git", "hooks");
|
|
233
|
+
const hookPath = path.join(hooksDir, "pre-push");
|
|
234
|
+
|
|
235
|
+
await fs.mkdir(hooksDir, { recursive: true });
|
|
236
|
+
|
|
237
|
+
let existingContent = "";
|
|
238
|
+
try {
|
|
239
|
+
existingContent = await fs.readFile(hookPath, "utf8");
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error.code !== "ENOENT") {
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const nextContent = upsertHookContent(existingContent);
|
|
247
|
+
const operation = existingContent
|
|
248
|
+
? existingContent.includes(HOOK_MARKER_START)
|
|
249
|
+
? "updated"
|
|
250
|
+
: "appended"
|
|
251
|
+
: "created";
|
|
252
|
+
|
|
253
|
+
await fs.writeFile(hookPath, nextContent, "utf8");
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
await fs.chmod(hookPath, 0o755);
|
|
257
|
+
} catch {
|
|
258
|
+
// Windows filesystems may ignore chmod, which is safe for this workflow.
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
repository: repoRoot,
|
|
263
|
+
hookPath,
|
|
264
|
+
operation
|
|
265
|
+
};
|
|
266
|
+
}
|