secure-push-check 1.0.0 ā 2.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/package.json +3 -10
- package/src/cli.js +112 -62
- package/src/scanners/credentials.js +72 -79
- package/src/scanners/files.js +1 -3
- package/src/scanners/secrets.js +1 -3
- package/src/utils/glob.js +141 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "secure-push-check",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Production-ready CLI that scans local Git repositories for security risks before push.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -28,14 +28,7 @@
|
|
|
28
28
|
"git",
|
|
29
29
|
"cli",
|
|
30
30
|
"secrets",
|
|
31
|
-
"audit"
|
|
32
|
-
"pre-push"
|
|
31
|
+
"audit"
|
|
33
32
|
],
|
|
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
|
-
}
|
|
33
|
+
"license": "MIT"
|
|
41
34
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { Command } from "commander";
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
3
2
|
import {
|
|
4
3
|
TOOL_VERSION,
|
|
5
4
|
installPrePushHook,
|
|
@@ -7,6 +6,28 @@ import {
|
|
|
7
6
|
runScan
|
|
8
7
|
} from "./index.js";
|
|
9
8
|
|
|
9
|
+
// ANSI color codes for terminal output
|
|
10
|
+
const ansi = {
|
|
11
|
+
reset: "\x1b[0m",
|
|
12
|
+
bold: "\x1b[1m",
|
|
13
|
+
red: "\x1b[31m",
|
|
14
|
+
green: "\x1b[32m",
|
|
15
|
+
yellow: "\x1b[33m",
|
|
16
|
+
blue: "\x1b[34m",
|
|
17
|
+
cyan: "\x1b[36m",
|
|
18
|
+
gray: "\x1b[90m",
|
|
19
|
+
orange: "\x1b[38;5;214m"
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} text
|
|
24
|
+
* @param {...string} codes
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function colorize(text, ...codes) {
|
|
28
|
+
return `${codes.join("")}${text}${ansi.reset}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
/**
|
|
11
32
|
* @param {string} severity
|
|
12
33
|
* @returns {(text: string) => string}
|
|
@@ -14,15 +35,15 @@ import {
|
|
|
14
35
|
function severityColor(severity) {
|
|
15
36
|
switch (normalizeSeverity(severity)) {
|
|
16
37
|
case "critical":
|
|
17
|
-
return
|
|
38
|
+
return (text) => colorize(text, ansi.red, ansi.bold);
|
|
18
39
|
case "high":
|
|
19
|
-
return
|
|
40
|
+
return (text) => colorize(text, ansi.yellow, ansi.bold);
|
|
20
41
|
case "moderate":
|
|
21
|
-
return
|
|
42
|
+
return (text) => colorize(text, ansi.orange);
|
|
22
43
|
case "low":
|
|
23
|
-
return
|
|
44
|
+
return (text) => colorize(text, ansi.blue);
|
|
24
45
|
default:
|
|
25
|
-
return
|
|
46
|
+
return (text) => text;
|
|
26
47
|
}
|
|
27
48
|
}
|
|
28
49
|
|
|
@@ -63,11 +84,11 @@ function renderHumanReport(result) {
|
|
|
63
84
|
const passedChecks = result.checks.filter((check) => check.status === "passed");
|
|
64
85
|
const skippedChecks = result.checks.filter((check) => check.status === "skipped");
|
|
65
86
|
|
|
66
|
-
console.log(
|
|
87
|
+
console.log(colorize(`\nš Secure Push Check v${result.version}\n`, ansi.cyan, ansi.bold));
|
|
67
88
|
|
|
68
|
-
console.log(
|
|
89
|
+
console.log(colorize("ā Critical Issues", ansi.red, ansi.bold));
|
|
69
90
|
if (critical.length === 0) {
|
|
70
|
-
console.log(
|
|
91
|
+
console.log(colorize(" ā
None", ansi.green));
|
|
71
92
|
} else {
|
|
72
93
|
for (const finding of critical) {
|
|
73
94
|
const color = severityColor(finding.severity);
|
|
@@ -78,9 +99,9 @@ function renderHumanReport(result) {
|
|
|
78
99
|
}
|
|
79
100
|
}
|
|
80
101
|
|
|
81
|
-
console.log(
|
|
102
|
+
console.log(colorize("\nā ļø Warnings", ansi.yellow, ansi.bold));
|
|
82
103
|
if (warnings.length === 0) {
|
|
83
|
-
console.log(
|
|
104
|
+
console.log(colorize(" ā
None", ansi.green));
|
|
84
105
|
} else {
|
|
85
106
|
for (const finding of warnings) {
|
|
86
107
|
const color = severityColor(finding.severity);
|
|
@@ -91,31 +112,32 @@ function renderHumanReport(result) {
|
|
|
91
112
|
}
|
|
92
113
|
}
|
|
93
114
|
|
|
94
|
-
console.log(
|
|
115
|
+
console.log(colorize("\nā
Passed Checks", ansi.green, ansi.bold));
|
|
95
116
|
if (passedChecks.length === 0) {
|
|
96
|
-
console.log(
|
|
117
|
+
console.log(colorize(" - None", ansi.yellow));
|
|
97
118
|
} else {
|
|
98
119
|
for (const check of passedChecks) {
|
|
99
|
-
console.log(
|
|
120
|
+
console.log(colorize(` - ${check.name}`, ansi.green));
|
|
100
121
|
}
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
if (skippedChecks.length > 0) {
|
|
104
|
-
console.log(
|
|
125
|
+
console.log(colorize("\nā¹ļø Skipped Checks", ansi.gray, ansi.bold));
|
|
105
126
|
for (const check of skippedChecks) {
|
|
106
|
-
console.log(
|
|
127
|
+
console.log(colorize(` - ${check.name}`, ansi.gray));
|
|
107
128
|
}
|
|
108
129
|
}
|
|
109
130
|
|
|
110
131
|
console.log(
|
|
111
|
-
|
|
112
|
-
`\nSummary: critical=${result.summary.critical}, high=${result.summary.high}, moderate=${result.summary.moderate}, total=${result.summary.totalFindings}
|
|
132
|
+
colorize(
|
|
133
|
+
`\nSummary: critical=${result.summary.critical}, high=${result.summary.high}, moderate=${result.summary.moderate}, total=${result.summary.totalFindings}`,
|
|
134
|
+
ansi.bold
|
|
113
135
|
)
|
|
114
136
|
);
|
|
115
137
|
console.log(
|
|
116
138
|
result.summary.blocked
|
|
117
|
-
?
|
|
118
|
-
:
|
|
139
|
+
? colorize(`Result: BLOCKED (threshold: ${result.summary.severityThreshold})`, ansi.red)
|
|
140
|
+
: colorize(`Result: SAFE (threshold: ${result.summary.severityThreshold})`, ansi.green)
|
|
119
141
|
);
|
|
120
142
|
}
|
|
121
143
|
|
|
@@ -137,49 +159,77 @@ async function executeScan(options) {
|
|
|
137
159
|
process.exitCode = result.summary.blocked ? 1 : 0;
|
|
138
160
|
}
|
|
139
161
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
162
|
+
function printHelp() {
|
|
163
|
+
console.log(`
|
|
164
|
+
${colorize("secure-push-check", ansi.cyan, ansi.bold)} v${TOOL_VERSION}
|
|
165
|
+
Scan local Git repositories for security risks before pushing.
|
|
166
|
+
|
|
167
|
+
${colorize("Usage:", ansi.bold)}
|
|
168
|
+
secure-push-check <command> [options]
|
|
169
|
+
|
|
170
|
+
${colorize("Commands:", ansi.bold)}
|
|
171
|
+
scan Run all checks and print a colorized report
|
|
172
|
+
report Run all checks and generate a report (alias for scan)
|
|
173
|
+
install Install secure-push-check as a pre-push Git hook
|
|
174
|
+
|
|
175
|
+
${colorize("Options:", ansi.bold)}
|
|
176
|
+
--json Output report as JSON (scan/report only)
|
|
177
|
+
--cwd Working directory to scan (default: current directory)
|
|
178
|
+
--help Show this help message
|
|
179
|
+
--version Show version number
|
|
180
|
+
`);
|
|
181
|
+
}
|
|
158
182
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
183
|
+
async function main() {
|
|
184
|
+
const { values, positionals } = parseArgs({
|
|
185
|
+
args: process.argv.slice(2),
|
|
186
|
+
options: {
|
|
187
|
+
json: { type: "boolean", default: false },
|
|
188
|
+
cwd: { type: "string", default: process.cwd() },
|
|
189
|
+
help: { type: "boolean", short: "h", default: false },
|
|
190
|
+
version: { type: "boolean", short: "v", default: false }
|
|
191
|
+
},
|
|
192
|
+
allowPositionals: true,
|
|
193
|
+
strict: false
|
|
169
194
|
});
|
|
170
195
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
196
|
+
if (values.version) {
|
|
197
|
+
console.log(TOOL_VERSION);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (values.help || positionals.length === 0) {
|
|
202
|
+
printHelp();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const command = positionals[0];
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
switch (command) {
|
|
210
|
+
case "scan":
|
|
211
|
+
case "report":
|
|
212
|
+
await executeScan({
|
|
213
|
+
json: Boolean(values.json),
|
|
214
|
+
cwd: values.cwd
|
|
215
|
+
});
|
|
216
|
+
break;
|
|
217
|
+
|
|
218
|
+
case "install": {
|
|
219
|
+
const result = await installPrePushHook({ cwd: values.cwd });
|
|
220
|
+
console.log(colorize(`Pre-push hook ${result.operation} at ${result.hookPath}`, ansi.green));
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
179
223
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
224
|
+
default:
|
|
225
|
+
console.error(colorize(`Unknown command: ${command}`, ansi.red));
|
|
226
|
+
printHelp();
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error(colorize(`Error: ${error.message}`, ansi.red));
|
|
231
|
+
process.exitCode = 1;
|
|
232
|
+
}
|
|
185
233
|
}
|
|
234
|
+
|
|
235
|
+
main();
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
|
-
import fg from "
|
|
4
|
-
import { parse } from "@babel/parser";
|
|
3
|
+
import { fg } from "../utils/glob.js";
|
|
5
4
|
|
|
6
5
|
const DEFAULT_IGNORES = [
|
|
7
6
|
"**/.git/**",
|
|
@@ -12,7 +11,12 @@ const DEFAULT_IGNORES = [
|
|
|
12
11
|
];
|
|
13
12
|
|
|
14
13
|
const CODE_GLOBS = ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.mjs", "**/*.cjs"];
|
|
15
|
-
|
|
14
|
+
|
|
15
|
+
// Matches: const password = "value", const apiKey = 'value', const secret = `value`
|
|
16
|
+
const CREDENTIAL_PATTERN = /\b(?:const|let|var)\s+(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?token)\s*=\s*(['"`])([^'"`\n]{4,})\2/gi;
|
|
17
|
+
|
|
18
|
+
// Matches object properties: password: "value", apiKey: 'value'
|
|
19
|
+
const OBJECT_CREDENTIAL_PATTERN = /\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?token)\s*:\s*(['"`])([^'"`\n]{4,})\2/gi;
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* @param {string} value
|
|
@@ -55,55 +59,38 @@ function compileAllowMatchers(allowPatterns) {
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
/**
|
|
58
|
-
*
|
|
59
|
-
* @
|
|
60
|
-
|
|
61
|
-
function getStaticStringValue(node) {
|
|
62
|
-
if (!node || typeof node !== "object") {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (node.type === "StringLiteral" && typeof node.value === "string") {
|
|
67
|
-
return node.value;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (node.type === "TemplateLiteral" && Array.isArray(node.expressions) && node.expressions.length === 0) {
|
|
71
|
-
return node.quasis.map((item) => item.value.cooked || "").join("");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* @param {unknown} node
|
|
79
|
-
* @param {(node: any) => void} visitor
|
|
80
|
-
* @returns {void}
|
|
62
|
+
* Check if a value looks like a placeholder/template rather than a real secret.
|
|
63
|
+
* @param {string} value
|
|
64
|
+
* @returns {boolean}
|
|
81
65
|
*/
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
66
|
+
function isPlaceholder(value) {
|
|
67
|
+
const lowerValue = value.toLowerCase();
|
|
68
|
+
|
|
69
|
+
// Common placeholder patterns
|
|
70
|
+
const placeholders = [
|
|
71
|
+
"your_",
|
|
72
|
+
"your-",
|
|
73
|
+
"<your",
|
|
74
|
+
"${",
|
|
75
|
+
"{{",
|
|
76
|
+
"process.env",
|
|
77
|
+
"env.",
|
|
78
|
+
"xxx",
|
|
79
|
+
"placeholder",
|
|
80
|
+
"example",
|
|
81
|
+
"changeme",
|
|
82
|
+
"todo",
|
|
83
|
+
"fixme",
|
|
84
|
+
"replace",
|
|
85
|
+
"insert",
|
|
86
|
+
"fill"
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
return placeholders.some(p => lowerValue.includes(p));
|
|
103
90
|
}
|
|
104
91
|
|
|
105
92
|
/**
|
|
106
|
-
* Parse JS/TS files and detect hard-coded credentials
|
|
93
|
+
* Parse JS/TS files and detect hard-coded credentials using regex patterns.
|
|
107
94
|
*
|
|
108
95
|
* @param {object} options
|
|
109
96
|
* @param {string} options.repoRoot
|
|
@@ -121,8 +108,6 @@ export async function scanHardcodedCredentials(options = {}) {
|
|
|
121
108
|
const files = await fg(CODE_GLOBS, {
|
|
122
109
|
cwd: repoRoot,
|
|
123
110
|
dot: true,
|
|
124
|
-
onlyFiles: true,
|
|
125
|
-
unique: true,
|
|
126
111
|
ignore: [...DEFAULT_IGNORES, ...ignoreGlobs]
|
|
127
112
|
});
|
|
128
113
|
|
|
@@ -145,36 +130,44 @@ export async function scanHardcodedCredentials(options = {}) {
|
|
|
145
130
|
continue;
|
|
146
131
|
}
|
|
147
132
|
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
ast = parse(source, {
|
|
151
|
-
sourceType: "unambiguous",
|
|
152
|
-
errorRecovery: true,
|
|
153
|
-
plugins: ["typescript", "jsx", "classProperties", "decorators-legacy"]
|
|
154
|
-
});
|
|
155
|
-
} catch (error) {
|
|
156
|
-
findings.push({
|
|
157
|
-
check: "credentials",
|
|
158
|
-
severity: "moderate",
|
|
159
|
-
message: `Unable to parse source file for AST scan: ${error.message}`,
|
|
160
|
-
file: relativePath
|
|
161
|
-
});
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
133
|
+
const lines = source.split(/\r?\n/);
|
|
164
134
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
135
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
136
|
+
const line = lines[lineIndex];
|
|
137
|
+
|
|
138
|
+
// Check variable assignments
|
|
139
|
+
CREDENTIAL_PATTERN.lastIndex = 0;
|
|
140
|
+
let match;
|
|
141
|
+
while ((match = CREDENTIAL_PATTERN.exec(line)) !== null) {
|
|
142
|
+
const varName = match[1];
|
|
143
|
+
const value = match[3];
|
|
144
|
+
|
|
145
|
+
if (isPlaceholder(value)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
169
148
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (!variableName || !CREDENTIAL_VAR_PATTERN.test(variableName)) {
|
|
149
|
+
const isAllowed = allowMatchers.some((matcher) => matcher.test(value));
|
|
150
|
+
if (isAllowed) {
|
|
173
151
|
continue;
|
|
174
152
|
}
|
|
175
153
|
|
|
176
|
-
|
|
177
|
-
|
|
154
|
+
findings.push({
|
|
155
|
+
check: "credentials",
|
|
156
|
+
severity: "high",
|
|
157
|
+
message: `Hardcoded credential in variable '${varName}'`,
|
|
158
|
+
file: relativePath,
|
|
159
|
+
line: lineIndex + 1,
|
|
160
|
+
column: match.index + 1
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check object properties
|
|
165
|
+
OBJECT_CREDENTIAL_PATTERN.lastIndex = 0;
|
|
166
|
+
while ((match = OBJECT_CREDENTIAL_PATTERN.exec(line)) !== null) {
|
|
167
|
+
const propName = match[1];
|
|
168
|
+
const value = match[3];
|
|
169
|
+
|
|
170
|
+
if (isPlaceholder(value)) {
|
|
178
171
|
continue;
|
|
179
172
|
}
|
|
180
173
|
|
|
@@ -186,13 +179,13 @@ export async function scanHardcodedCredentials(options = {}) {
|
|
|
186
179
|
findings.push({
|
|
187
180
|
check: "credentials",
|
|
188
181
|
severity: "high",
|
|
189
|
-
message: `Hardcoded credential in
|
|
182
|
+
message: `Hardcoded credential in property '${propName}'`,
|
|
190
183
|
file: relativePath,
|
|
191
|
-
line:
|
|
192
|
-
column:
|
|
184
|
+
line: lineIndex + 1,
|
|
185
|
+
column: match.index + 1
|
|
193
186
|
});
|
|
194
187
|
}
|
|
195
|
-
}
|
|
188
|
+
}
|
|
196
189
|
}
|
|
197
190
|
|
|
198
191
|
return {
|
package/src/scanners/files.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import fg from "
|
|
3
|
+
import { fg } from "../utils/glob.js";
|
|
4
4
|
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
|
|
@@ -56,8 +56,6 @@ export async function scanSensitiveFiles(options = {}) {
|
|
|
56
56
|
const matches = await fg(SENSITIVE_FILE_GLOBS, {
|
|
57
57
|
cwd: repoRoot,
|
|
58
58
|
dot: true,
|
|
59
|
-
onlyFiles: true,
|
|
60
|
-
unique: true,
|
|
61
59
|
ignore: [...DEFAULT_IGNORES, ...ignoreGlobs]
|
|
62
60
|
});
|
|
63
61
|
|
package/src/scanners/secrets.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
|
-
import fg from "
|
|
3
|
+
import { fg } from "../utils/glob.js";
|
|
4
4
|
|
|
5
5
|
const DEFAULT_IGNORES = [
|
|
6
6
|
"**/.git/**",
|
|
@@ -223,8 +223,6 @@ export async function scanSecrets(options = {}) {
|
|
|
223
223
|
const files = await fg(["**/*"], {
|
|
224
224
|
cwd: repoRoot,
|
|
225
225
|
dot: true,
|
|
226
|
-
onlyFiles: true,
|
|
227
|
-
unique: true,
|
|
228
226
|
ignore: [...DEFAULT_IGNORES, ...ignoreGlobs]
|
|
229
227
|
});
|
|
230
228
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert a simple glob pattern to a RegExp.
|
|
6
|
+
* Supports: *, **, ?
|
|
7
|
+
* @param {string} pattern
|
|
8
|
+
* @returns {RegExp}
|
|
9
|
+
*/
|
|
10
|
+
function globToRegex(pattern) {
|
|
11
|
+
let regex = "";
|
|
12
|
+
let i = 0;
|
|
13
|
+
|
|
14
|
+
while (i < pattern.length) {
|
|
15
|
+
const char = pattern[i];
|
|
16
|
+
|
|
17
|
+
if (char === "*") {
|
|
18
|
+
if (pattern[i + 1] === "*") {
|
|
19
|
+
// ** matches any path including separators
|
|
20
|
+
if (pattern[i + 2] === "/" || pattern[i + 2] === "\\") {
|
|
21
|
+
regex += "(?:.*[\\/\\\\])?";
|
|
22
|
+
i += 3;
|
|
23
|
+
} else {
|
|
24
|
+
regex += ".*";
|
|
25
|
+
i += 2;
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
// * matches anything except path separators
|
|
29
|
+
regex += "[^\\/\\\\]*";
|
|
30
|
+
i += 1;
|
|
31
|
+
}
|
|
32
|
+
} else if (char === "?") {
|
|
33
|
+
regex += "[^\\/\\\\]";
|
|
34
|
+
i += 1;
|
|
35
|
+
} else if (char === "/" || char === "\\") {
|
|
36
|
+
regex += "[\\/\\\\]";
|
|
37
|
+
i += 1;
|
|
38
|
+
} else if ("[]{}()+^$.|\\".includes(char)) {
|
|
39
|
+
regex += "\\" + char;
|
|
40
|
+
i += 1;
|
|
41
|
+
} else {
|
|
42
|
+
regex += char;
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return new RegExp(`^${regex}$`, "i");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if a path matches any of the given glob patterns.
|
|
52
|
+
* @param {string} filePath - Normalized forward-slash path
|
|
53
|
+
* @param {string[]} patterns
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function matchesAny(filePath, patterns) {
|
|
57
|
+
for (const pattern of patterns) {
|
|
58
|
+
const regex = globToRegex(pattern);
|
|
59
|
+
if (regex.test(filePath)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Recursively find files matching glob patterns.
|
|
68
|
+
* @param {object} options
|
|
69
|
+
* @param {string[]} options.patterns - Glob patterns to match
|
|
70
|
+
* @param {string} options.cwd - Base directory
|
|
71
|
+
* @param {string[]} [options.ignore] - Patterns to ignore
|
|
72
|
+
* @param {boolean} [options.dot] - Include dotfiles (default: false)
|
|
73
|
+
* @returns {Promise<string[]>} - Array of relative paths
|
|
74
|
+
*/
|
|
75
|
+
export async function glob(options) {
|
|
76
|
+
const { patterns, cwd, ignore = [], dot = false } = options;
|
|
77
|
+
|
|
78
|
+
const results = [];
|
|
79
|
+
const ignorePatterns = ignore.length > 0 ? ignore : [];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} dir - Current directory (relative to cwd)
|
|
83
|
+
*/
|
|
84
|
+
async function traverse(dir) {
|
|
85
|
+
const fullDir = dir ? path.join(cwd, dir) : cwd;
|
|
86
|
+
let entries;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
entries = await fs.readdir(fullDir, { withFileTypes: true });
|
|
90
|
+
} catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const name = entry.name;
|
|
96
|
+
|
|
97
|
+
// Skip hidden files/dirs unless dot is enabled
|
|
98
|
+
if (!dot && name.startsWith(".")) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const relativePath = dir ? `${dir}/${name}` : name;
|
|
103
|
+
|
|
104
|
+
// Check if should be ignored
|
|
105
|
+
if (matchesAny(relativePath, ignorePatterns)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
// Check if directory path matches ignore (for patterns like **/node_modules/**)
|
|
111
|
+
const dirWithSlash = relativePath + "/";
|
|
112
|
+
if (!matchesAny(dirWithSlash, ignorePatterns)) {
|
|
113
|
+
await traverse(relativePath);
|
|
114
|
+
}
|
|
115
|
+
} else if (entry.isFile()) {
|
|
116
|
+
// Check if file matches any of the include patterns
|
|
117
|
+
if (matchesAny(relativePath, patterns)) {
|
|
118
|
+
results.push(relativePath);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await traverse("");
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Convenience function matching fast-glob's API for easier migration.
|
|
130
|
+
* @param {string[]} patterns
|
|
131
|
+
* @param {object} options
|
|
132
|
+
* @returns {Promise<string[]>}
|
|
133
|
+
*/
|
|
134
|
+
export async function fg(patterns, options = {}) {
|
|
135
|
+
return glob({
|
|
136
|
+
patterns,
|
|
137
|
+
cwd: options.cwd || process.cwd(),
|
|
138
|
+
ignore: options.ignore || [],
|
|
139
|
+
dot: options.dot ?? false
|
|
140
|
+
});
|
|
141
|
+
}
|