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.
@@ -0,0 +1,207 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import fg from "fast-glob";
4
+ import { parse } from "@babel/parser";
5
+
6
+ const DEFAULT_IGNORES = [
7
+ "**/.git/**",
8
+ "**/node_modules/**",
9
+ "**/dist/**",
10
+ "**/build/**",
11
+ "**/coverage/**"
12
+ ];
13
+
14
+ const CODE_GLOBS = ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.mjs", "**/*.cjs"];
15
+ const CREDENTIAL_VAR_PATTERN = /(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?token)/i;
16
+
17
+ /**
18
+ * @param {string} value
19
+ * @returns {string}
20
+ */
21
+ function escapeRegex(value) {
22
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
+ }
24
+
25
+ /**
26
+ * @param {string[]} allowPatterns
27
+ * @returns {RegExp[]}
28
+ */
29
+ function compileAllowMatchers(allowPatterns) {
30
+ if (!Array.isArray(allowPatterns)) {
31
+ return [];
32
+ }
33
+
34
+ const output = [];
35
+ for (const rawPattern of allowPatterns) {
36
+ if (typeof rawPattern !== "string" || rawPattern.trim() === "") {
37
+ continue;
38
+ }
39
+
40
+ const trimmed = rawPattern.trim();
41
+ const regexLike = trimmed.match(/^\/(.+)\/([a-z]*)$/i);
42
+
43
+ try {
44
+ if (regexLike) {
45
+ output.push(new RegExp(regexLike[1], regexLike[2].replace(/g/g, "")));
46
+ } else {
47
+ output.push(new RegExp(escapeRegex(trimmed), "i"));
48
+ }
49
+ } catch {
50
+ // Ignore invalid allow patterns.
51
+ }
52
+ }
53
+
54
+ return output;
55
+ }
56
+
57
+ /**
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}
81
+ */
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
+ }
103
+ }
104
+
105
+ /**
106
+ * Parse JS/TS files and detect hard-coded credentials in const declarations.
107
+ *
108
+ * @param {object} options
109
+ * @param {string} options.repoRoot
110
+ * @param {string[]} [options.ignoreGlobs]
111
+ * @param {string[]} [options.allowPatterns]
112
+ * @returns {Promise<object>}
113
+ */
114
+ export async function scanHardcodedCredentials(options = {}) {
115
+ const { repoRoot, ignoreGlobs = [], allowPatterns = [] } = options;
116
+
117
+ if (!repoRoot) {
118
+ throw new Error("scanHardcodedCredentials requires a repoRoot.");
119
+ }
120
+
121
+ const files = await fg(CODE_GLOBS, {
122
+ cwd: repoRoot,
123
+ dot: true,
124
+ onlyFiles: true,
125
+ unique: true,
126
+ ignore: [...DEFAULT_IGNORES, ...ignoreGlobs]
127
+ });
128
+
129
+ const allowMatchers = compileAllowMatchers(allowPatterns);
130
+ const findings = [];
131
+
132
+ for (const relativePath of files) {
133
+ const fullPath = path.join(repoRoot, relativePath);
134
+ let source = "";
135
+
136
+ try {
137
+ source = await fs.readFile(fullPath, "utf8");
138
+ } catch (error) {
139
+ findings.push({
140
+ check: "credentials",
141
+ severity: "moderate",
142
+ message: `Unable to read source file during credential scan: ${error.message}`,
143
+ file: relativePath
144
+ });
145
+ continue;
146
+ }
147
+
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
+ }
164
+
165
+ walk(ast, (node) => {
166
+ if (node.type !== "VariableDeclaration" || node.kind !== "const") {
167
+ return;
168
+ }
169
+
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)) {
173
+ continue;
174
+ }
175
+
176
+ const value = getStaticStringValue(declaration.init);
177
+ if (!value) {
178
+ continue;
179
+ }
180
+
181
+ const isAllowed = allowMatchers.some((matcher) => matcher.test(value));
182
+ if (isAllowed) {
183
+ continue;
184
+ }
185
+
186
+ findings.push({
187
+ check: "credentials",
188
+ severity: "high",
189
+ message: `Hardcoded credential in const '${variableName}'`,
190
+ file: relativePath,
191
+ line: declaration.loc?.start?.line,
192
+ column: declaration.loc?.start?.column ? declaration.loc.start.column + 1 : 1
193
+ });
194
+ }
195
+ });
196
+ }
197
+
198
+ return {
199
+ id: "hardcoded-credentials-scan",
200
+ name: "Hardcoded Credentials Scan",
201
+ status: findings.length > 0 ? "failed" : "passed",
202
+ findings,
203
+ stats: {
204
+ filesScanned: files.length
205
+ }
206
+ };
207
+ }
@@ -0,0 +1,160 @@
1
+ import path from "node:path";
2
+ import { execFile } from "node:child_process";
3
+ import { promises as fs } from "node:fs";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const TARGET_SEVERITIES = ["critical", "high", "moderate"];
8
+
9
+ /**
10
+ * @param {Record<string, unknown>} report
11
+ * @returns {{critical: number, high: number, moderate: number, low: number}}
12
+ */
13
+ function extractVulnerabilityCounts(report) {
14
+ const fallback = { critical: 0, high: 0, moderate: 0, low: 0 };
15
+ const metadataCounts = report?.metadata?.vulnerabilities;
16
+ if (metadataCounts && typeof metadataCounts === "object") {
17
+ return {
18
+ critical: Number(metadataCounts.critical || 0),
19
+ high: Number(metadataCounts.high || 0),
20
+ moderate: Number(metadataCounts.moderate || 0),
21
+ low: Number(metadataCounts.low || 0)
22
+ };
23
+ }
24
+
25
+ const vulnerabilities = report?.vulnerabilities;
26
+ if (!vulnerabilities || typeof vulnerabilities !== "object") {
27
+ return fallback;
28
+ }
29
+
30
+ const counts = { ...fallback };
31
+ for (const value of Object.values(vulnerabilities)) {
32
+ if (!value || typeof value !== "object") {
33
+ continue;
34
+ }
35
+ const severity = String(value.severity || "").toLowerCase();
36
+ if (Object.prototype.hasOwnProperty.call(counts, severity)) {
37
+ counts[severity] += 1;
38
+ }
39
+ }
40
+ return counts;
41
+ }
42
+
43
+ /**
44
+ * Run npm audit and parse severity counts.
45
+ *
46
+ * @param {object} options
47
+ * @param {string} options.repoRoot
48
+ * @returns {Promise<object>}
49
+ */
50
+ export async function scanDependencies(options = {}) {
51
+ const { repoRoot } = options;
52
+
53
+ if (!repoRoot) {
54
+ throw new Error("scanDependencies requires a repoRoot.");
55
+ }
56
+
57
+ const packageJsonPath = path.join(repoRoot, "package.json");
58
+ try {
59
+ await fs.access(packageJsonPath);
60
+ } catch {
61
+ return {
62
+ id: "dependency-vulnerability-check",
63
+ name: "Dependency Vulnerability Check",
64
+ status: "skipped",
65
+ findings: [],
66
+ meta: {
67
+ reason: "No package.json found in repository root."
68
+ }
69
+ };
70
+ }
71
+
72
+ let stdout = "";
73
+ let stderr = "";
74
+
75
+ try {
76
+ const result = await execFileAsync("npm", ["audit", "--json"], {
77
+ cwd: repoRoot,
78
+ maxBuffer: 20 * 1024 * 1024
79
+ });
80
+ stdout = result.stdout;
81
+ stderr = result.stderr;
82
+ } catch (error) {
83
+ if (error.code === "ENOENT") {
84
+ return {
85
+ id: "dependency-vulnerability-check",
86
+ name: "Dependency Vulnerability Check",
87
+ status: "failed",
88
+ findings: [
89
+ {
90
+ check: "dependencies",
91
+ severity: "moderate",
92
+ message: "npm command is not available. Unable to run npm audit.",
93
+ file: "package.json"
94
+ }
95
+ ]
96
+ };
97
+ }
98
+ stdout = typeof error.stdout === "string" ? error.stdout : "";
99
+ stderr = typeof error.stderr === "string" ? error.stderr : "";
100
+ }
101
+
102
+ if (!stdout || stdout.trim() === "") {
103
+ const errorSummary = stderr.split(/\r?\n/u).find((line) => line.trim() !== "") || "Unknown npm audit error.";
104
+ return {
105
+ id: "dependency-vulnerability-check",
106
+ name: "Dependency Vulnerability Check",
107
+ status: "failed",
108
+ findings: [
109
+ {
110
+ check: "dependencies",
111
+ severity: "moderate",
112
+ message: `npm audit did not return JSON output: ${errorSummary}`,
113
+ file: "package.json"
114
+ }
115
+ ]
116
+ };
117
+ }
118
+
119
+ let report;
120
+ try {
121
+ report = JSON.parse(stdout);
122
+ } catch (error) {
123
+ return {
124
+ id: "dependency-vulnerability-check",
125
+ name: "Dependency Vulnerability Check",
126
+ status: "failed",
127
+ findings: [
128
+ {
129
+ check: "dependencies",
130
+ severity: "moderate",
131
+ message: `Unable to parse npm audit JSON: ${error.message}`,
132
+ file: "package.json"
133
+ }
134
+ ]
135
+ };
136
+ }
137
+
138
+ const counts = extractVulnerabilityCounts(report);
139
+ const findings = [];
140
+
141
+ for (const severity of TARGET_SEVERITIES) {
142
+ const count = counts[severity];
143
+ if (count > 0) {
144
+ findings.push({
145
+ check: "dependencies",
146
+ severity,
147
+ message: `npm audit found ${count} ${severity} vulnerabilit${count === 1 ? "y" : "ies"}.`,
148
+ file: "package-lock.json"
149
+ });
150
+ }
151
+ }
152
+
153
+ return {
154
+ id: "dependency-vulnerability-check",
155
+ name: "Dependency Vulnerability Check",
156
+ status: findings.length > 0 ? "failed" : "passed",
157
+ findings,
158
+ stats: counts
159
+ };
160
+ }
@@ -0,0 +1,88 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import fg from "fast-glob";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ const DEFAULT_IGNORES = [
8
+ "**/.git/**",
9
+ "**/node_modules/**",
10
+ "**/dist/**",
11
+ "**/build/**",
12
+ "**/coverage/**"
13
+ ];
14
+
15
+ const SENSITIVE_FILE_GLOBS = [
16
+ "**/.env",
17
+ "**/.env.local",
18
+ "**/id_rsa",
19
+ "**/*.pem",
20
+ "**/config.prod.json",
21
+ "**/secrets.json"
22
+ ];
23
+
24
+ /**
25
+ * @param {string} repoRoot
26
+ * @param {string} relativePath
27
+ * @returns {Promise<boolean>}
28
+ */
29
+ async function isIgnoredByGit(repoRoot, relativePath) {
30
+ try {
31
+ await execFileAsync("git", ["check-ignore", relativePath], { cwd: repoRoot });
32
+ return true;
33
+ } catch (error) {
34
+ if (error && typeof error.code === "number" && error.code === 1) {
35
+ return false;
36
+ }
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Detect sensitive files that exist locally and are not ignored by Git.
43
+ *
44
+ * @param {object} options
45
+ * @param {string} options.repoRoot
46
+ * @param {string[]} [options.ignoreGlobs]
47
+ * @returns {Promise<object>}
48
+ */
49
+ export async function scanSensitiveFiles(options = {}) {
50
+ const { repoRoot, ignoreGlobs = [] } = options;
51
+
52
+ if (!repoRoot) {
53
+ throw new Error("scanSensitiveFiles requires a repoRoot.");
54
+ }
55
+
56
+ const matches = await fg(SENSITIVE_FILE_GLOBS, {
57
+ cwd: repoRoot,
58
+ dot: true,
59
+ onlyFiles: true,
60
+ unique: true,
61
+ ignore: [...DEFAULT_IGNORES, ...ignoreGlobs]
62
+ });
63
+
64
+ const findings = [];
65
+ for (const file of matches) {
66
+ const ignored = await isIgnoredByGit(repoRoot, file);
67
+ if (!ignored) {
68
+ findings.push({
69
+ check: "sensitive-files",
70
+ severity: "critical",
71
+ message: "Sensitive file exists and is not ignored by Git",
72
+ file
73
+ });
74
+ }
75
+ }
76
+
77
+ return {
78
+ id: "sensitive-files-detection",
79
+ name: "Sensitive Files Detection",
80
+ status: findings.length > 0 ? "failed" : "passed",
81
+ findings,
82
+ stats: {
83
+ filesFound: matches.length
84
+ }
85
+ };
86
+ }
87
+
88
+ export { SENSITIVE_FILE_GLOBS };
@@ -0,0 +1,112 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+
4
+ const REQUIRED_PATTERNS = [
5
+ ".env",
6
+ ".env.local",
7
+ "id_rsa",
8
+ "*.pem",
9
+ "config.prod.json",
10
+ "secrets.json"
11
+ ];
12
+
13
+ /**
14
+ * @param {string} value
15
+ * @returns {string}
16
+ */
17
+ function normalizeEntry(value) {
18
+ return value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "");
19
+ }
20
+
21
+ /**
22
+ * @param {string} required
23
+ * @param {Set<string>} entries
24
+ * @returns {boolean}
25
+ */
26
+ function isPatternCovered(required, entries) {
27
+ if (entries.has(required) || entries.has(`**/${required}`)) {
28
+ return true;
29
+ }
30
+
31
+ if (required === ".env") {
32
+ return entries.has(".env*") || entries.has("*.env");
33
+ }
34
+
35
+ if (required === ".env.local") {
36
+ return entries.has(".env*") || entries.has("*.env.local") || entries.has("*.env*");
37
+ }
38
+
39
+ if (required === "id_rsa") {
40
+ return entries.has("*id_rsa*");
41
+ }
42
+
43
+ if (required === "*.pem") {
44
+ return entries.has("**/*.pem") || entries.has("*.pem*");
45
+ }
46
+
47
+ return false;
48
+ }
49
+
50
+ /**
51
+ * Validate whether .gitignore protects sensitive file patterns.
52
+ *
53
+ * @param {object} options
54
+ * @param {string} options.repoRoot
55
+ * @returns {Promise<object>}
56
+ */
57
+ export async function validateGitignore(options = {}) {
58
+ const { repoRoot } = options;
59
+
60
+ if (!repoRoot) {
61
+ throw new Error("validateGitignore requires a repoRoot.");
62
+ }
63
+
64
+ const gitignorePath = path.join(repoRoot, ".gitignore");
65
+ let content = "";
66
+
67
+ try {
68
+ content = await fs.readFile(gitignorePath, "utf8");
69
+ } catch (error) {
70
+ if (error.code === "ENOENT") {
71
+ return {
72
+ id: "gitignore-validation",
73
+ name: "Gitignore Validation",
74
+ status: "failed",
75
+ findings: [
76
+ {
77
+ check: "gitignore",
78
+ severity: "high",
79
+ message: ".gitignore file is missing",
80
+ file: ".gitignore"
81
+ }
82
+ ]
83
+ };
84
+ }
85
+ throw error;
86
+ }
87
+
88
+ const entries = new Set(
89
+ content
90
+ .split(/\r?\n/u)
91
+ .map((line) => line.trim())
92
+ .filter((line) => line !== "" && !line.startsWith("#"))
93
+ .map((line) => normalizeEntry(line))
94
+ );
95
+
96
+ const missing = REQUIRED_PATTERNS.filter((pattern) => !isPatternCovered(pattern, entries));
97
+ const findings = missing.map((pattern) => ({
98
+ check: "gitignore",
99
+ severity: "high",
100
+ message: `Missing sensitive ignore pattern: ${pattern}`,
101
+ file: ".gitignore"
102
+ }));
103
+
104
+ return {
105
+ id: "gitignore-validation",
106
+ name: "Gitignore Validation",
107
+ status: findings.length > 0 ? "failed" : "passed",
108
+ findings
109
+ };
110
+ }
111
+
112
+ export { REQUIRED_PATTERNS };