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,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 };
|