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
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import fg from "fast-glob";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_IGNORES = [
|
|
6
|
+
"**/.git/**",
|
|
7
|
+
"**/node_modules/**",
|
|
8
|
+
"**/dist/**",
|
|
9
|
+
"**/build/**",
|
|
10
|
+
"**/coverage/**"
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const DEFAULT_SECRET_PATTERNS = [
|
|
14
|
+
{
|
|
15
|
+
name: "AWS Access Key ID",
|
|
16
|
+
regex: "\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b",
|
|
17
|
+
flags: "g",
|
|
18
|
+
severity: "critical"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "JWT Token",
|
|
22
|
+
regex: "\\beyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9._-]{10,}\\.[A-Za-z0-9._-]{10,}\\b",
|
|
23
|
+
flags: "g",
|
|
24
|
+
severity: "high"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "OpenAI Key",
|
|
28
|
+
regex: "\\bsk-(?:proj|live|test)?[A-Za-z0-9_-]{20,}\\b",
|
|
29
|
+
flags: "gi",
|
|
30
|
+
severity: "critical"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "Supabase Key",
|
|
34
|
+
regex: "\\bsb_(?:secret|publishable)_[A-Za-z0-9_-]{20,}\\b",
|
|
35
|
+
flags: "gi",
|
|
36
|
+
severity: "high"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "Private Access Token",
|
|
40
|
+
regex: "\\b(?:ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\\b",
|
|
41
|
+
flags: "g",
|
|
42
|
+
severity: "critical"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "Generic API Key Assignment",
|
|
46
|
+
regex: "\\b(?:api[_-]?key|token|secret)\\b\\s*[:=]\\s*['\\\"][A-Za-z0-9_\\-]{16,}['\\\"]",
|
|
47
|
+
flags: "gi",
|
|
48
|
+
severity: "high"
|
|
49
|
+
}
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const VALID_SEVERITIES = new Set(["low", "moderate", "high", "critical"]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} value
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
function normalizeSeverity(value) {
|
|
59
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
60
|
+
return VALID_SEVERITIES.has(normalized) ? normalized : "high";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} value
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function escapeRegex(value) {
|
|
68
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string[]} allowPatterns
|
|
73
|
+
* @returns {RegExp[]}
|
|
74
|
+
*/
|
|
75
|
+
function compileAllowPatterns(allowPatterns) {
|
|
76
|
+
if (!Array.isArray(allowPatterns)) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const matchers = [];
|
|
81
|
+
for (const rawPattern of allowPatterns) {
|
|
82
|
+
if (typeof rawPattern !== "string" || rawPattern.trim() === "") {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const trimmed = rawPattern.trim();
|
|
87
|
+
const regexLike = trimmed.match(/^\/(.+)\/([a-z]*)$/i);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
if (regexLike) {
|
|
91
|
+
matchers.push(new RegExp(regexLike[1], regexLike[2].replace(/g/g, "")));
|
|
92
|
+
} else {
|
|
93
|
+
matchers.push(new RegExp(escapeRegex(trimmed), "i"));
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Ignore invalid allow patterns from config and continue scanning.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return matchers;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {Array<unknown>} customPatterns
|
|
105
|
+
* @returns {Array<{name: string, severity: string, regex: RegExp}>}
|
|
106
|
+
*/
|
|
107
|
+
function compileSecretPatterns(customPatterns) {
|
|
108
|
+
const output = [];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} name
|
|
112
|
+
* @param {string} regexText
|
|
113
|
+
* @param {string} flags
|
|
114
|
+
* @param {string} severity
|
|
115
|
+
*/
|
|
116
|
+
const pushPattern = (name, regexText, flags, severity) => {
|
|
117
|
+
const normalizedFlags = flags.includes("g") ? flags : `${flags}g`;
|
|
118
|
+
try {
|
|
119
|
+
output.push({
|
|
120
|
+
name,
|
|
121
|
+
severity: normalizeSeverity(severity),
|
|
122
|
+
regex: new RegExp(regexText, normalizedFlags)
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
// Ignore invalid configured patterns and keep built-in scanning active.
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
for (const pattern of DEFAULT_SECRET_PATTERNS) {
|
|
130
|
+
pushPattern(pattern.name, pattern.regex, pattern.flags, pattern.severity);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!Array.isArray(customPatterns)) {
|
|
134
|
+
return output;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const pattern of customPatterns) {
|
|
138
|
+
if (typeof pattern === "string" && pattern.trim() !== "") {
|
|
139
|
+
pushPattern("Custom Secret Pattern", pattern, "gi", "high");
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof pattern === "object" && pattern !== null) {
|
|
144
|
+
const name = typeof pattern.name === "string" ? pattern.name : "Custom Secret Pattern";
|
|
145
|
+
const regexText = typeof pattern.regex === "string" ? pattern.regex : "";
|
|
146
|
+
const flags = typeof pattern.flags === "string" ? pattern.flags : "gi";
|
|
147
|
+
const severity = typeof pattern.severity === "string" ? pattern.severity : "high";
|
|
148
|
+
if (regexText.trim() !== "") {
|
|
149
|
+
pushPattern(name, regexText, flags, severity);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return output;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {Buffer} buffer
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
function isProbablyBinary(buffer) {
|
|
162
|
+
const sample = buffer.subarray(0, Math.min(buffer.length, 8000));
|
|
163
|
+
let suspicious = 0;
|
|
164
|
+
|
|
165
|
+
for (const byte of sample) {
|
|
166
|
+
if (byte === 0) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isControl = byte < 7 || (byte > 13 && byte < 32);
|
|
171
|
+
if (isControl) {
|
|
172
|
+
suspicious += 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return sample.length > 0 && suspicious / sample.length > 0.3;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} value
|
|
181
|
+
* @returns {string}
|
|
182
|
+
*/
|
|
183
|
+
function redactMatch(value) {
|
|
184
|
+
if (value.length <= 8) {
|
|
185
|
+
return "***";
|
|
186
|
+
}
|
|
187
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {string} line
|
|
192
|
+
* @param {string} matchedText
|
|
193
|
+
* @param {RegExp[]} allowMatchers
|
|
194
|
+
* @returns {boolean}
|
|
195
|
+
*/
|
|
196
|
+
function isAllowed(line, matchedText, allowMatchers) {
|
|
197
|
+
for (const matcher of allowMatchers) {
|
|
198
|
+
if (matcher.test(line) || matcher.test(matchedText)) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Scan all text files for hard-coded secret patterns.
|
|
207
|
+
*
|
|
208
|
+
* @param {object} options
|
|
209
|
+
* @param {string} options.repoRoot
|
|
210
|
+
* @param {string[]} [options.ignoreGlobs]
|
|
211
|
+
* @param {string[]} [options.allowPatterns]
|
|
212
|
+
* @param {Array<unknown>} [options.secretPatterns]
|
|
213
|
+
* @returns {Promise<object>}
|
|
214
|
+
*/
|
|
215
|
+
export async function scanSecrets(options = {}) {
|
|
216
|
+
const { repoRoot, ignoreGlobs = [], allowPatterns = [], secretPatterns = [] } = options;
|
|
217
|
+
|
|
218
|
+
if (!repoRoot) {
|
|
219
|
+
throw new Error("scanSecrets requires a repoRoot.");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const findings = [];
|
|
223
|
+
const files = await fg(["**/*"], {
|
|
224
|
+
cwd: repoRoot,
|
|
225
|
+
dot: true,
|
|
226
|
+
onlyFiles: true,
|
|
227
|
+
unique: true,
|
|
228
|
+
ignore: [...DEFAULT_IGNORES, ...ignoreGlobs]
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const allowMatchers = compileAllowPatterns(allowPatterns);
|
|
232
|
+
const patterns = compileSecretPatterns(secretPatterns);
|
|
233
|
+
|
|
234
|
+
for (const relativePath of files) {
|
|
235
|
+
const fullPath = path.join(repoRoot, relativePath);
|
|
236
|
+
let buffer;
|
|
237
|
+
try {
|
|
238
|
+
buffer = await fs.readFile(fullPath);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
findings.push({
|
|
241
|
+
check: "secrets",
|
|
242
|
+
severity: "moderate",
|
|
243
|
+
message: `Unable to read file during secret scan: ${error.message}`,
|
|
244
|
+
file: relativePath
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (isProbablyBinary(buffer)) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const content = buffer.toString("utf8");
|
|
254
|
+
const lines = content.split(/\r?\n/u);
|
|
255
|
+
|
|
256
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
257
|
+
const line = lines[index];
|
|
258
|
+
for (const pattern of patterns) {
|
|
259
|
+
pattern.regex.lastIndex = 0;
|
|
260
|
+
|
|
261
|
+
let match;
|
|
262
|
+
while ((match = pattern.regex.exec(line)) !== null) {
|
|
263
|
+
const matchedText = match[0];
|
|
264
|
+
|
|
265
|
+
if (isAllowed(line, matchedText, allowMatchers)) {
|
|
266
|
+
if (pattern.regex.lastIndex === match.index) {
|
|
267
|
+
pattern.regex.lastIndex += 1;
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
findings.push({
|
|
273
|
+
check: "secrets",
|
|
274
|
+
rule: pattern.name,
|
|
275
|
+
severity: pattern.severity,
|
|
276
|
+
message: `Potential ${pattern.name} detected`,
|
|
277
|
+
file: relativePath,
|
|
278
|
+
line: index + 1,
|
|
279
|
+
column: match.index + 1,
|
|
280
|
+
excerpt: redactMatch(matchedText)
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (pattern.regex.lastIndex === match.index) {
|
|
284
|
+
pattern.regex.lastIndex += 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
id: "secrets-detection",
|
|
293
|
+
name: "Secrets Detection",
|
|
294
|
+
status: findings.length > 0 ? "failed" : "passed",
|
|
295
|
+
findings,
|
|
296
|
+
stats: {
|
|
297
|
+
filesScanned: files.length
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export { DEFAULT_SECRET_PATTERNS };
|