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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "secure-push-check",
3
- "version": "1.0.0",
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 chalk from "chalk";
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 chalk.red.bold;
38
+ return (text) => colorize(text, ansi.red, ansi.bold);
18
39
  case "high":
19
- return chalk.yellow.bold;
40
+ return (text) => colorize(text, ansi.yellow, ansi.bold);
20
41
  case "moderate":
21
- return chalk.hex("#f59e0b");
42
+ return (text) => colorize(text, ansi.orange);
22
43
  case "low":
23
- return chalk.blue;
44
+ return (text) => colorize(text, ansi.blue);
24
45
  default:
25
- return chalk.white;
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(chalk.cyan.bold(`\nšŸ” Secure Push Check v${result.version}\n`));
87
+ console.log(colorize(`\nšŸ” Secure Push Check v${result.version}\n`, ansi.cyan, ansi.bold));
67
88
 
68
- console.log(chalk.red.bold("āŒ Critical Issues"));
89
+ console.log(colorize("āŒ Critical Issues", ansi.red, ansi.bold));
69
90
  if (critical.length === 0) {
70
- console.log(chalk.green(" āœ… None"));
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(chalk.yellow.bold("\nāš ļø Warnings"));
102
+ console.log(colorize("\nāš ļø Warnings", ansi.yellow, ansi.bold));
82
103
  if (warnings.length === 0) {
83
- console.log(chalk.green(" āœ… None"));
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(chalk.green.bold("\nāœ… Passed Checks"));
115
+ console.log(colorize("\nāœ… Passed Checks", ansi.green, ansi.bold));
95
116
  if (passedChecks.length === 0) {
96
- console.log(chalk.yellow(" - None"));
117
+ console.log(colorize(" - None", ansi.yellow));
97
118
  } else {
98
119
  for (const check of passedChecks) {
99
- console.log(chalk.green(` - ${check.name}`));
120
+ console.log(colorize(` - ${check.name}`, ansi.green));
100
121
  }
101
122
  }
102
123
 
103
124
  if (skippedChecks.length > 0) {
104
- console.log(chalk.gray.bold("\nā„¹ļø Skipped Checks"));
125
+ console.log(colorize("\nā„¹ļø Skipped Checks", ansi.gray, ansi.bold));
105
126
  for (const check of skippedChecks) {
106
- console.log(chalk.gray(` - ${check.name}`));
127
+ console.log(colorize(` - ${check.name}`, ansi.gray));
107
128
  }
108
129
  }
109
130
 
110
131
  console.log(
111
- chalk.bold(
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
- ? chalk.red(`Result: BLOCKED (threshold: ${result.summary.severityThreshold})`)
118
- : chalk.green(`Result: SAFE (threshold: ${result.summary.severityThreshold})`)
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
- 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
- });
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
- 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
- });
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
- 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
- });
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
- try {
181
- await program.parseAsync(process.argv);
182
- } catch (error) {
183
- console.error(chalk.red(`Error: ${error.message}`));
184
- process.exitCode = 1;
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 "fast-glob";
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
- const CREDENTIAL_VAR_PATTERN = /(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?token)/i;
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
- * @param {unknown} node
59
- * @returns {string | null}
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 walk(node, visitor) {
83
- if (!node || typeof node !== "object") {
84
- return;
85
- }
86
-
87
- visitor(node);
88
-
89
- for (const key of Object.keys(node)) {
90
- if (key === "loc" || key === "start" || key === "end") {
91
- continue;
92
- }
93
-
94
- const value = node[key];
95
- if (Array.isArray(value)) {
96
- for (const child of value) {
97
- walk(child, visitor);
98
- }
99
- } else if (value && typeof value === "object") {
100
- walk(value, visitor);
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 in const declarations.
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
- let ast;
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
- walk(ast, (node) => {
166
- if (node.type !== "VariableDeclaration" || node.kind !== "const") {
167
- return;
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
- for (const declaration of node.declarations || []) {
171
- const variableName = declaration?.id?.type === "Identifier" ? declaration.id.name : null;
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
- const value = getStaticStringValue(declaration.init);
177
- if (!value) {
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 const '${variableName}'`,
182
+ message: `Hardcoded credential in property '${propName}'`,
190
183
  file: relativePath,
191
- line: declaration.loc?.start?.line,
192
- column: declaration.loc?.start?.column ? declaration.loc.start.column + 1 : 1
184
+ line: lineIndex + 1,
185
+ column: match.index + 1
193
186
  });
194
187
  }
195
- });
188
+ }
196
189
  }
197
190
 
198
191
  return {
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import fg from "fast-glob";
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
 
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { promises as fs } from "node:fs";
3
- import fg from "fast-glob";
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
+ }