@parasraju/plumber-mcp 1.0.1 → 1.2.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 +100 -6
- package/bin/plumber-mcp.js +99 -13
- package/examples/custom-rules.example.json +20 -0
- package/package.json +12 -4
- package/src/engine.js +1095 -323
package/src/engine.js
CHANGED
|
@@ -1,323 +1,1095 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plumber inspection engine.
|
|
3
|
-
*
|
|
4
|
-
* Ported from plumber-linter v1.1.2
|
|
5
|
-
* (packages/cli/bin/plumber.js, commit bde61c5), with two additions:
|
|
6
|
-
* - leak/api-key now matches AWS access key IDs (AKIA...).
|
|
7
|
-
* - leak/hardcoded-secret now matches quoted password/token assignments.
|
|
8
|
-
* Both rule IDs already exist in the CLI's RULES table; the CLI declares them
|
|
9
|
-
* but does not detect them. The CLI itself is left untouched.
|
|
10
|
-
*
|
|
11
|
-
* This is a copy rather than a shared import because the published CLI package
|
|
12
|
-
* ships only `bin/` and `README.md`, so a relative import from the MCP package
|
|
13
|
-
* would break `plumber-linter` for npm consumers. The CLI is intentionally left
|
|
14
|
-
* untouched; keep the detection rules in this file in sync with the CLI.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{ id: '
|
|
28
|
-
{ id: '
|
|
29
|
-
{ id: '
|
|
30
|
-
{ id: '
|
|
31
|
-
{ id: 'clog/
|
|
32
|
-
{ id: '
|
|
33
|
-
{ id: '
|
|
34
|
-
{ id: '
|
|
35
|
-
{ id: '
|
|
36
|
-
{ id: '
|
|
37
|
-
{ id: '
|
|
38
|
-
{ id: '
|
|
39
|
-
{ id: '
|
|
40
|
-
{ id: '
|
|
41
|
-
{ id: '
|
|
42
|
-
{ id: '
|
|
43
|
-
{ id: '
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Plumber inspection engine.
|
|
3
|
+
*
|
|
4
|
+
* Ported from plumber-linter v1.1.2
|
|
5
|
+
* (packages/cli/bin/plumber.js, commit bde61c5), with two additions:
|
|
6
|
+
* - leak/api-key now matches AWS access key IDs (AKIA...).
|
|
7
|
+
* - leak/hardcoded-secret now matches quoted password/token assignments.
|
|
8
|
+
* Both rule IDs already exist in the CLI's RULES table; the CLI declares them
|
|
9
|
+
* but does not detect them. The CLI itself is left untouched.
|
|
10
|
+
*
|
|
11
|
+
* This is a copy rather than a shared import because the published CLI package
|
|
12
|
+
* ships only `bin/` and `README.md`, so a relative import from the MCP package
|
|
13
|
+
* would break `plumber-linter` for npm consumers. The CLI is intentionally left
|
|
14
|
+
* untouched; keep the detection rules in this file in sync with the CLI.
|
|
15
|
+
*
|
|
16
|
+
* Rule packs (v1.2.0) are local JSON files. Their ids start with custom/
|
|
17
|
+
* and are never written into RULES, so a pack cannot replace a built-in.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const yaml = require('js-yaml');
|
|
23
|
+
|
|
24
|
+
const ENV_TEMPLATE_RE = /\.(example|sample|template|dist)$/;
|
|
25
|
+
|
|
26
|
+
const RULES = [
|
|
27
|
+
{ id: 'leak/api-key', cat: 'Leaks & Secrets', sev: 'CRITICAL', title: 'Leaked API Key or Private Access Token', fix: 'Move secret to environment variable (.env).' },
|
|
28
|
+
{ id: 'leak/committed-env', cat: 'Leaks & Secrets', sev: 'CRITICAL', title: 'Production Secret Committed in Environment File', fix: 'Remove sensitive values and use .env.example.' },
|
|
29
|
+
{ id: 'leak/hardcoded-secret', cat: 'Leaks & Secrets', sev: 'HIGH', title: 'Hardcoded Password or Database Credential', fix: 'Replace with process.env / os.environ reference.' },
|
|
30
|
+
{ id: 'sec/raw-html', cat: 'Leaks & Secrets', sev: 'MEDIUM', title: 'Raw HTML Injection (XSS Risk)', fix: 'Use textContent or safe DOM templating.' },
|
|
31
|
+
{ id: 'clog/elided-code', cat: 'Clogs & Dead Code', sev: 'HIGH', title: 'AI-Elided Code Snippet Left in File', fix: 'Restore the complete function or component implementation.' },
|
|
32
|
+
{ id: 'clog/swallowed-error', cat: 'Clogs & Dead Code', sev: 'HIGH', title: 'Swallowed Exception (Silent Failure)', fix: 'Log the error or properly re-raise/handle it.' },
|
|
33
|
+
{ id: 'clog/stub-function', cat: 'Clogs & Dead Code', sev: 'MEDIUM', title: 'Unimplemented Stub Function', fix: 'Complete missing logic or remove placeholder.' },
|
|
34
|
+
{ id: 'clog/step-comments', cat: 'Clogs & Dead Code', sev: 'LOW', title: 'Robotic AI Step Comments Left Behind', fix: 'Clean up AI instructional comments.' },
|
|
35
|
+
{ id: 'clog/dead-code', cat: 'Clogs & Dead Code', sev: 'HIGH', title: 'Unreachable Dead Code', fix: 'Delete unreachable lines or run plumber clean --write.' },
|
|
36
|
+
{ id: 'slop/pointless-wrapper', cat: 'Clogs & Dead Code', sev: 'LOW', title: 'Function That Only Forwards Its Arguments', fix: 'Call underlying function directly.' },
|
|
37
|
+
{ id: 'joint/icon-button', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Icon Button Without Accessible Name', fix: 'Add aria-label attribute to button element.' },
|
|
38
|
+
{ id: 'joint/clickable-div', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Non-Semantic Clickable Element (Missing Button / Role)', fix: 'Use native <button type="button">.' },
|
|
39
|
+
{ id: 'joint/empty-test', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Empty Test with No Assertions', fix: 'Add assert statements or verification logic.' },
|
|
40
|
+
{ id: 'joint/focus-removed', cat: 'Joints & Accessibility', sev: 'MEDIUM', title: 'Focus Outline Removed Without Replacement', fix: 'Provide a visible :focus-visible style.' },
|
|
41
|
+
{ id: 'pipe/phantom-import', cat: 'Pipes & Dependencies', sev: 'HIGH', title: 'Plausible but Hallucinated Package Import', fix: 'Check package registry or use official package.' },
|
|
42
|
+
{ id: 'pipe/dead-link', cat: 'Pipes & Dependencies', sev: 'MEDIUM', title: 'Broken or Null Link in Navigation', fix: 'Use a <button> for actions or valid route for href.' },
|
|
43
|
+
{ id: 'slop/ai-gradient', cat: 'AI Slop Tells', sev: 'MEDIUM', title: 'Purple / Violet AI Default Gradient', fix: 'Use clean brand tokens or neutral surfaces.' },
|
|
44
|
+
{ id: 'slop/chatbot-residue', cat: 'AI Slop Tells', sev: 'MEDIUM', title: 'Pasted Chatbot Conversational Residue', fix: 'Delete conversational intro/outro boilerplate.' },
|
|
45
|
+
{ id: 'slop/neon-glow', cat: 'AI Slop Tells', sev: 'LOW', title: 'Colored Neon Glow Shadow', fix: 'Replace with subtle crisp 1px borders.' },
|
|
46
|
+
{ id: 'slop/gradient-text', cat: 'AI Slop Tells', sev: 'LOW', title: 'Gradient-Clipped Headline Text', fix: 'Use solid, high-contrast typography.' },
|
|
47
|
+
{ id: 'ui/important-abuse', cat: 'AI Slop Tells', sev: 'LOW', title: '!important Overrides in Stylesheet', fix: 'Structure CSS cascade cleanly without !important.' },
|
|
48
|
+
// v1.1.0 structural GitHub Actions checks. Not the CLI text rules
|
|
49
|
+
// pipe/docker-unpinned, sec/curl-pipe-shell, sec/permissions-write-all,
|
|
50
|
+
// leak/slack-webhook, or pipe/npm-unpinned — those are not in this engine.
|
|
51
|
+
// sec/job-permissions does not flag a least-privilege contents: write on a
|
|
52
|
+
// job that pushes or releases.
|
|
53
|
+
{ id: 'sec/pull-request-target', cat: 'Leaks & Secrets', sev: 'CRITICAL', title: 'Untrusted pull_request_target Checkout or Write Permission', fix: 'Use pull_request, or check out the base ref read-only with no secrets.' },
|
|
54
|
+
{ id: 'sec/expression-injection', cat: 'Leaks & Secrets', sev: 'CRITICAL', title: 'Expression Injection in Workflow run Step', fix: 'Pass the value through an env: variable and reference that variable in the script.' },
|
|
55
|
+
{ id: 'pipe/action-unpinned', cat: 'Pipes & Dependencies', sev: 'MEDIUM', title: 'Unpinned Third-Party Action', fix: 'Pin the action to a full commit SHA.' },
|
|
56
|
+
{ id: 'sec/job-permissions', cat: 'Leaks & Secrets', sev: 'HIGH', title: 'Job Writes Without Least-Privilege Permissions', fix: 'Set least-privilege permissions on the job.' },
|
|
57
|
+
{ id: 'sec/secret-to-untrusted', cat: 'Leaks & Secrets', sev: 'HIGH', title: 'Secret Passed to Untrusted Step', fix: 'Scope the secret to the trusted step that needs it.' }
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const BUILTIN_IDS = new Set(RULES.map((rule) => rule.id));
|
|
61
|
+
const PACK_SEVERITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
|
|
62
|
+
|
|
63
|
+
const SEV_WEIGHT = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
|
|
64
|
+
|
|
65
|
+
function gradeFor(score) {
|
|
66
|
+
return score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scoreFor(issueCount) {
|
|
70
|
+
return Math.max(0, 100 - (issueCount * 4.2));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SCANNABLE_EXTS = ['.py', '.js', '.jsx', '.ts', '.tsx', '.html', '.htm', '.css', '.scss', '.env'];
|
|
74
|
+
|
|
75
|
+
function scanLocalDirectory(dirPath, options) {
|
|
76
|
+
const pack = options && options.pack ? options.pack : null;
|
|
77
|
+
const scannableExts = new Set(SCANNABLE_EXTS);
|
|
78
|
+
const skipDirs = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', 'vendor', '.venv', 'venv', '__pycache__']);
|
|
79
|
+
|
|
80
|
+
const issues = [];
|
|
81
|
+
let totalFiles = 0;
|
|
82
|
+
let totalLines = 0;
|
|
83
|
+
const fileReports = [];
|
|
84
|
+
|
|
85
|
+
function walk(current, onlyWorkflows) {
|
|
86
|
+
const stat = fs.statSync(current);
|
|
87
|
+
if (stat.isFile()) {
|
|
88
|
+
const ext = path.extname(current).toLowerCase();
|
|
89
|
+
const base = path.basename(current);
|
|
90
|
+
const relRaw = path.relative(dirPath, current).replace(/\\/g, '/') || base;
|
|
91
|
+
const rel = canonicalWorkflowPath(current, relRaw);
|
|
92
|
+
const workflowFile = isGitHubWorkflowPath(rel);
|
|
93
|
+
// Dot directories other than a specially opened .github stay skipped.
|
|
94
|
+
// Files under that .github walk are workflow YAML only, so a repo with
|
|
95
|
+
// no workflow files keeps the previous file set.
|
|
96
|
+
if (onlyWorkflows && !workflowFile) return;
|
|
97
|
+
if (scannableExts.has(ext) || base.startsWith('.env') || workflowFile) {
|
|
98
|
+
totalFiles++;
|
|
99
|
+
try {
|
|
100
|
+
const content = fs.readFileSync(current, 'utf-8');
|
|
101
|
+
const lines = content.split('\n');
|
|
102
|
+
totalLines += lines.length;
|
|
103
|
+
const fileIssues = inspectFile(rel, content, lines, ext, false);
|
|
104
|
+
if (pack) {
|
|
105
|
+
try {
|
|
106
|
+
fileIssues.push(...collectPackIssues(rel, content, lines, pack));
|
|
107
|
+
} catch (packErr) {
|
|
108
|
+
notePackError(pack, `Pack rules failed on ${rel}: ${packErr.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (fileIssues.length > 0) {
|
|
112
|
+
issues.push(...fileIssues);
|
|
113
|
+
fileReports.push({ path: rel, issues: fileIssues });
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// Unreadable or locked file: skip it, matching the CLI's behavior.
|
|
117
|
+
void err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (stat.isDirectory()) {
|
|
124
|
+
const base = path.basename(current);
|
|
125
|
+
// v1.0.1 fix: the skip list applies to subdirectories only, never to the
|
|
126
|
+
// directory the user asked to scan.
|
|
127
|
+
// Repro (v1.0.0): plumber_scan { target: "proj/dist" } or { target: "~/.dotfiles" }
|
|
128
|
+
// with an AKIA key inside -> "Files: 0 | Issues: 0 ... No issues found."
|
|
129
|
+
if (current !== dirPath && (skipDirs.has(base) || base.startsWith('.'))) {
|
|
130
|
+
if (base !== '.github' || onlyWorkflows) return;
|
|
131
|
+
const entries = fs.readdirSync(current);
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
walk(path.join(current, entry), true);
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const entries = fs.readdirSync(current);
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
walk(path.join(current, entry), onlyWorkflows);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
walk(dirPath, false);
|
|
145
|
+
|
|
146
|
+
const score = scoreFor(issues.length);
|
|
147
|
+
const grade = gradeFor(score);
|
|
148
|
+
|
|
149
|
+
const report = {
|
|
150
|
+
repo_name: path.basename(dirPath),
|
|
151
|
+
total_files: totalFiles,
|
|
152
|
+
total_lines: totalLines,
|
|
153
|
+
score: Math.round(score * 10) / 10,
|
|
154
|
+
grade,
|
|
155
|
+
issues,
|
|
156
|
+
file_reports: fileReports
|
|
157
|
+
};
|
|
158
|
+
if (pack) report.pack = pack;
|
|
159
|
+
return report;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function scanText(text, filename, options) {
|
|
163
|
+
if (typeof text !== 'string') {
|
|
164
|
+
throw new TypeError('text must be a string');
|
|
165
|
+
}
|
|
166
|
+
const pack = options && options.pack ? options.pack : null;
|
|
167
|
+
const name = filename && typeof filename === 'string' ? filename.replace(/\\/g, '/') : 'snippet.js';
|
|
168
|
+
const ext = path.extname(name).toLowerCase();
|
|
169
|
+
const content = text.replace(/\r\n?/g, '\n');
|
|
170
|
+
const lines = content.split('\n');
|
|
171
|
+
const issues = inspectFile(name, content, lines, ext, true);
|
|
172
|
+
if (pack) {
|
|
173
|
+
try {
|
|
174
|
+
issues.push(...collectPackIssues(name, content, lines, pack));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
notePackError(pack, `Pack rules failed on ${name}: ${err.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const score = scoreFor(issues.length);
|
|
180
|
+
|
|
181
|
+
const report = {
|
|
182
|
+
repo_name: name,
|
|
183
|
+
total_files: 1,
|
|
184
|
+
total_lines: lines.length,
|
|
185
|
+
score: Math.round(score * 10) / 10,
|
|
186
|
+
grade: gradeFor(score),
|
|
187
|
+
issues,
|
|
188
|
+
file_reports: issues.length > 0 ? [{ path: name, issues }] : []
|
|
189
|
+
};
|
|
190
|
+
if (pack) report.pack = pack;
|
|
191
|
+
return report;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// v1.0.1 fix: match "test"/"spec" as a path segment or filename part, not as a
|
|
195
|
+
// substring. The old substring check treated latest.js, inspector.js, special.js,
|
|
196
|
+
// contest/, respect.py ... as test files and skipped secret detection in them.
|
|
197
|
+
// Repro (v1.0.0): plumber_scan_text { text: 'const k = "AKIAIOSFODNN7EXAMPLE";',
|
|
198
|
+
// filename: "src/latest.js" } -> "No issues found."
|
|
199
|
+
function isTestPath(relPath) {
|
|
200
|
+
const base = path.basename(relPath);
|
|
201
|
+
return /(?:^|[\/._-])(?:__tests__|tests?|specs?|testing)(?:[\/._-]|$)/i.test(relPath)
|
|
202
|
+
|| /[a-z0-9](?:Tests?|Specs?)\.\w+$/.test(base)
|
|
203
|
+
|| base.toLowerCase() === 'conftest.py';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function inspectFile(relPath, content, lines, ext, detectWorkflowContent) {
|
|
207
|
+
const issues = [];
|
|
208
|
+
const lower = content.toLowerCase();
|
|
209
|
+
const base = path.basename(relPath);
|
|
210
|
+
const isTest = isTestPath(relPath);
|
|
211
|
+
|
|
212
|
+
// Environment files must never be committed
|
|
213
|
+
if (base.startsWith('.env') && !ENV_TEMPLATE_RE.test(base)) {
|
|
214
|
+
issues.push({ rule_id: 'leak/committed-env', title: 'Environment File Staged for Commit', severity: 'CRITICAL', line: 1, evidence: base, fix: 'Remove from git, add to .gitignore, and commit .env.example instead.' });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Elided code
|
|
218
|
+
if (!isTest && /(?:\/\/|#|\/\*)\s*(?:\.\.\.|…)?\s*(?:existing\s+code|rest\s+of\s+code)/i.test(content)) {
|
|
219
|
+
lines.forEach((l, idx) => {
|
|
220
|
+
if (/(?:\/\/|#|\/\*)\s*(?:\.\.\.|…)?\s*(?:existing\s+code|rest\s+of\s+code)/i.test(l)) {
|
|
221
|
+
issues.push({ rule_id: 'clog/elided-code', title: 'AI-Elided Code Snippet Left in File', severity: 'HIGH', line: idx + 1, evidence: l.trim().slice(0, 90), fix: 'Restore the complete code implementation.' });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Swallowed error
|
|
227
|
+
if (!isTest && (ext === '.py' || ext === '.js' || ext === '.ts' || ext === '.jsx' || ext === '.tsx')) {
|
|
228
|
+
lines.forEach((l, idx) => {
|
|
229
|
+
const trim = l.trim();
|
|
230
|
+
if (trim.startsWith('#') || trim.startsWith('//') || trim.startsWith('/*')) return;
|
|
231
|
+
if (ext === '.py' && /^\s*except(?:\s+(?:\(\s*)?(?:Exception|BaseException)(?:\s*\))?)?\s*:\s*(?:pass|\.\.\.)/.test(l)) {
|
|
232
|
+
issues.push({ rule_id: 'clog/swallowed-error', title: 'Swallowed Exception (Silent Failure)', severity: 'HIGH', line: idx + 1, evidence: trim, fix: 'Log the error or properly re-raise/handle it.' });
|
|
233
|
+
} else if (ext !== '.py' && /catch\s*\([^)]*\)\s*\{\s*\}/.test(l)) {
|
|
234
|
+
issues.push({ rule_id: 'clog/swallowed-error', title: 'Swallowed Exception (Silent Failure)', severity: 'HIGH', line: idx + 1, evidence: trim, fix: 'Log the error or properly re-raise/handle it.' });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// API keys
|
|
240
|
+
if (!isTest && (content.includes('sk-') || content.includes('ghp_') || content.includes('AKIA'))) {
|
|
241
|
+
lines.forEach((l, idx) => {
|
|
242
|
+
const trim = l.trim();
|
|
243
|
+
if (trim.startsWith('#') || trim.startsWith('//')) return;
|
|
244
|
+
if (trim.includes('@openssh') || trim.includes('sk-ssh-') || trim.includes('sk-ecdsa-')) return;
|
|
245
|
+
if (/\b(sk-(?:live|test|proj)?[a-zA-Z0-9_-]{24,})\b/.test(l)) {
|
|
246
|
+
issues.push({ rule_id: 'leak/api-key', title: 'Leaked OpenAI / Model API Key', severity: 'CRITICAL', line: idx + 1, evidence: trim.slice(0, 70), fix: 'Extract secret to .env file immediately.' });
|
|
247
|
+
}
|
|
248
|
+
if (/\b(ghp_[a-zA-Z0-9]{36})\b/.test(l)) {
|
|
249
|
+
issues.push({ rule_id: 'leak/api-key', title: 'Leaked GitHub Personal Access Token', severity: 'CRITICAL', line: idx + 1, evidence: trim.slice(0, 70), fix: 'Revoke key and store in secrets manager.' });
|
|
250
|
+
}
|
|
251
|
+
// Added in the MCP package: AWS access key IDs.
|
|
252
|
+
if (/\b(AKIA[0-9A-Z]{16})\b/.test(l)) {
|
|
253
|
+
issues.push({ rule_id: 'leak/api-key', title: 'Leaked AWS Access Key ID', severity: 'CRITICAL', line: idx + 1, evidence: trim.slice(0, 70), fix: 'Revoke key and store in secrets manager.' });
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Hardcoded password or token assignment.
|
|
259
|
+
// Added in the MCP package: the CLI declares leak/hardcoded-secret but has no match.
|
|
260
|
+
if (!isTest && !ENV_TEMPLATE_RE.test(base) && /(?:password|passwd|pwd|secret|api[_-]?key|token)/i.test(content)) {
|
|
261
|
+
lines.forEach((l, idx) => {
|
|
262
|
+
const trim = l.trim();
|
|
263
|
+
if (trim.startsWith('#') || trim.startsWith('//') || trim.startsWith('/*')) return;
|
|
264
|
+
const match = /(?:password|passwd|pwd|secret|api[_-]?key|token)\w*\s*[:=]\s*['"]([^'"]{4,})['"]/i.exec(l);
|
|
265
|
+
if (match && !match[1].includes('${') && !/^(?:sk-|ghp_|AKIA)/.test(match[1])) {
|
|
266
|
+
issues.push({ rule_id: 'leak/hardcoded-secret', title: 'Hardcoded Password or Database Credential', severity: 'HIGH', line: idx + 1, evidence: trim.slice(0, 90), fix: 'Replace with process.env / os.environ reference.' });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Clickable div
|
|
272
|
+
if ((ext === '.html' || ext === '.jsx' || ext === '.tsx') && lower.includes('<div') && lower.includes('onclick')) {
|
|
273
|
+
lines.forEach((l, idx) => {
|
|
274
|
+
if (/<(?:div|span)\b(?![^>]*role=)[^>]*(?:onClick|onclick)=/i.test(l)) {
|
|
275
|
+
issues.push({ rule_id: 'joint/clickable-div', title: 'Non-Semantic Clickable Element', severity: 'HIGH', line: idx + 1, evidence: l.trim().slice(0, 80), fix: 'Use a native <button type="button"> instead.' });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// AI Default Gradient
|
|
281
|
+
if ((ext === '.html' || ext === '.jsx' || ext === '.tsx' || ext === '.css') && (lower.includes('purple') || lower.includes('violet'))) {
|
|
282
|
+
lines.forEach((l, idx) => {
|
|
283
|
+
if (/\b(?:from|via|to)-(?:purple|violet|indigo|fuchsia)-\d{2,3}\b/.test(l)) {
|
|
284
|
+
issues.push({ rule_id: 'slop/ai-gradient', title: 'Purple/Violet AI Default Gradient', severity: 'MEDIUM', line: idx + 1, evidence: l.trim().slice(0, 80), fix: 'Use clean brand tokens or neutral surface.' });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Unreachable dead code
|
|
290
|
+
for (const dead of findDeadCode(content, ext)) {
|
|
291
|
+
issues.push({
|
|
292
|
+
rule_id: 'clog/dead-code',
|
|
293
|
+
title: 'Unreachable Dead Code',
|
|
294
|
+
severity: 'HIGH',
|
|
295
|
+
line: dead.line,
|
|
296
|
+
evidence: dead.evidence.slice(0, 100),
|
|
297
|
+
fix: dead.removable ? "Delete this unreachable line or run 'plumber clean <path> --write'." : 'Remove the constant-false branch and its body.'
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (shouldInspectWorkflow(relPath, content, detectWorkflowContent)) {
|
|
302
|
+
issues.push(...inspectWorkflow(content, lines));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return issues;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function findDeadCode(content, ext) {
|
|
309
|
+
if (!['.py', '.js', '.jsx', '.ts', '.tsx'].includes(ext)) return [];
|
|
310
|
+
const lines = content.split('\n');
|
|
311
|
+
const skip = new Set();
|
|
312
|
+
markMultilineStrings(lines, skip, ext === '.py' ? ['"""', "'''"] : ['`']);
|
|
313
|
+
|
|
314
|
+
const found = [];
|
|
315
|
+
const indentOf = (line) => line.length - line.replace(/^[ \t]+/, '').length;
|
|
316
|
+
const termRe = ext === '.py' ? /^(return|raise|break|continue)\b/ : /^(return|throw|break|continue)\b/;
|
|
317
|
+
const unsafeTail = ['\\', '(', '[', '{', ',', ':', '+', '-', '*', '/', '%', '&', '|', '=', '?', '>'];
|
|
318
|
+
|
|
319
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
320
|
+
const line = lines[idx];
|
|
321
|
+
const clean = line.trim();
|
|
322
|
+
if (skip.has(idx) || !clean || clean.startsWith('#') || clean.startsWith('//') || clean.startsWith('/*') || clean.startsWith('*')) continue;
|
|
323
|
+
|
|
324
|
+
const isPy = ext === '.py';
|
|
325
|
+
const deadBranch = isPy ? /^(?:if|while)\s+(?:False|0)\s*:/.test(clean) : /^(?:if|while)\s*\(\s*(?:false|0)\s*\)/.test(clean);
|
|
326
|
+
if (deadBranch) {
|
|
327
|
+
found.push({ line: idx + 1, reason: 'Dead branch: condition is always false', evidence: clean, removable: false });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const terminator = termRe.exec(clean);
|
|
332
|
+
if (!terminator || unsafeTail.some((t) => clean.endsWith(t))) continue;
|
|
333
|
+
|
|
334
|
+
const target = findUnreachable(lines, idx, indentOf(line), skip, isPy);
|
|
335
|
+
if (target !== null) {
|
|
336
|
+
found.push({ line: target + 1, reason: `Unreachable statement after '${terminator[1]}'`, evidence: lines[target].trim(), removable: true });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return found;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function markMultilineStrings(lines, skip, quotes) {
|
|
343
|
+
let inside = null;
|
|
344
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
345
|
+
if (inside !== null) skip.add(idx);
|
|
346
|
+
for (const quote of quotes) {
|
|
347
|
+
const count = lines[idx].split(quote).length - 1;
|
|
348
|
+
if (count === 0) continue;
|
|
349
|
+
if (inside === quote) {
|
|
350
|
+
if (count % 2 === 1) inside = null;
|
|
351
|
+
} else if (inside === null && count % 2 === 1) {
|
|
352
|
+
inside = quote;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function findUnreachable(lines, start, indent, skip, isPy) {
|
|
359
|
+
const closers = ['else:', 'elif ', 'except', 'finally:', 'case ', 'default:'];
|
|
360
|
+
for (let j = start + 1; j < lines.length; j++) {
|
|
361
|
+
const raw = lines[j];
|
|
362
|
+
const clean = raw.trim();
|
|
363
|
+
if (skip.has(j) || !clean || clean.startsWith('#') || clean.startsWith('//') || clean.startsWith('/*') || clean.startsWith('*')) continue;
|
|
364
|
+
if ((raw.length - raw.replace(/^[ \t]+/, '').length) < indent) return null;
|
|
365
|
+
if (isPy ? closers.some((p) => clean.startsWith(p)) : /^(?:\}|else\b|catch\b|finally\b|case\b|default\b)/.test(clean)) return null;
|
|
366
|
+
if (/^[\)\]\}\.,:?+\-*\/%&|]/.test(clean)) continue;
|
|
367
|
+
return j;
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const FULL_SHA = /^[0-9a-f]{40}$/i;
|
|
373
|
+
const FIRST_PARTY_OWNERS = new Set(['actions', 'github']);
|
|
374
|
+
const INJECTED_EXPR = /\$\{\{\s*(?:github\.event\.|github\.head_ref\b|inputs\.)/;
|
|
375
|
+
const SECRET_REF = /\$\{\{\s*secrets\.[A-Za-z0-9_]+\s*\}\}/;
|
|
376
|
+
const UNTRUSTED_CHECKOUT = /github\.event\.pull_request|github\.head_ref|refs\/pull\//;
|
|
377
|
+
const CONTENTS_WRITE_RUN = /\bgit\s+push\b|\bgh\s+release\b|\bgh\s+pr\s+merge\b/;
|
|
378
|
+
const DEPLOY_RUN = /\bnpm\s+publish\b|\bpnpm\s+publish\b|\byarn\s+npm\s+publish\b|\bdocker\s+push\b|\btwine\s+upload\b/;
|
|
379
|
+
const CONTENTS_ACTIONS = new Set([
|
|
380
|
+
'softprops/action-gh-release',
|
|
381
|
+
'peaceiris/actions-gh-pages',
|
|
382
|
+
'ncipollo/release-action'
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
function isGitHubWorkflowPath(filePath) {
|
|
386
|
+
return /(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/i.test(String(filePath || '').replace(/\\/g, '/'));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function canonicalWorkflowPath(absPath, relPath) {
|
|
390
|
+
const rel = String(relPath || '').replace(/\\/g, '/');
|
|
391
|
+
if (isGitHubWorkflowPath(rel)) return rel;
|
|
392
|
+
const abs = String(absPath || '').replace(/\\/g, '/');
|
|
393
|
+
const marker = '.github/workflows/';
|
|
394
|
+
const idx = abs.toLowerCase().lastIndexOf(marker);
|
|
395
|
+
if (idx === -1) return rel;
|
|
396
|
+
const suffix = abs.slice(idx);
|
|
397
|
+
return isGitHubWorkflowPath(suffix) ? suffix : rel;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function looksLikeWorkflowSource(content) {
|
|
401
|
+
return /^(?:"on"|'on'|on)\s*:/m.test(content) && /^jobs\s*:/m.test(content);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function shouldInspectWorkflow(relPath, content, detectWorkflowContent) {
|
|
405
|
+
if (isGitHubWorkflowPath(relPath)) return true;
|
|
406
|
+
if (!detectWorkflowContent) return false;
|
|
407
|
+
const norm = String(relPath || '').replace(/\\/g, '/');
|
|
408
|
+
if (/(?:^|\/)workflows\/[^/]+\.ya?ml$/i.test(norm)) return true;
|
|
409
|
+
return looksLikeWorkflowSource(content);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function ruleById(id) {
|
|
413
|
+
for (let i = 0; i < RULES.length; i++) {
|
|
414
|
+
if (RULES[i].id === id) return RULES[i];
|
|
415
|
+
}
|
|
416
|
+
throw new Error('Unknown Plumber rule: ' + id);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function makeIssue(ruleId, line, evidence) {
|
|
420
|
+
const rule = ruleById(ruleId);
|
|
421
|
+
const text = evidence === undefined || evidence === null ? '' : String(evidence).trim();
|
|
422
|
+
return {
|
|
423
|
+
rule_id: rule.id,
|
|
424
|
+
title: rule.title,
|
|
425
|
+
severity: rule.sev,
|
|
426
|
+
line: line > 0 ? line : 1,
|
|
427
|
+
evidence: text.slice(0, 90),
|
|
428
|
+
fix: rule.fix
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function loadWorkflow(content) {
|
|
433
|
+
let doc;
|
|
434
|
+
try {
|
|
435
|
+
// DEFAULT_SCHEMA resolves anchors, aliases, and merge keys. js-yaml 4
|
|
436
|
+
// speaks YAML 1.2, so the workflow key "on" stays a string.
|
|
437
|
+
doc = yaml.load(content, { schema: yaml.DEFAULT_SCHEMA });
|
|
438
|
+
} catch (err) {
|
|
439
|
+
if (!err || err.name !== 'YAMLException') throw err;
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null;
|
|
443
|
+
if (doc.on === undefined || doc.on === null) return null;
|
|
444
|
+
if (!doc.jobs || typeof doc.jobs !== 'object' || Array.isArray(doc.jobs)) return null;
|
|
445
|
+
return doc;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function inspectWorkflow(content, lines) {
|
|
449
|
+
const doc = loadWorkflow(content);
|
|
450
|
+
if (!doc) return [];
|
|
451
|
+
const found = [];
|
|
452
|
+
const seen = new Set();
|
|
453
|
+
function add(ruleId, line, evidence) {
|
|
454
|
+
const issue = makeIssue(ruleId, line, evidence);
|
|
455
|
+
const key = issue.rule_id + '\n' + issue.line + '\n' + issue.evidence;
|
|
456
|
+
if (seen.has(key)) return;
|
|
457
|
+
seen.add(key);
|
|
458
|
+
found.push(issue);
|
|
459
|
+
}
|
|
460
|
+
collectPullRequestTarget(doc, lines, add);
|
|
461
|
+
collectExpressionInjection(doc, lines, add);
|
|
462
|
+
collectUnpinnedActions(doc, lines, add);
|
|
463
|
+
collectJobPermissions(doc, lines, add);
|
|
464
|
+
collectSecrets(doc, lines, add);
|
|
465
|
+
return found;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function triggerNames(onValue) {
|
|
469
|
+
if (typeof onValue === 'string' || typeof onValue === 'number' || typeof onValue === 'boolean') {
|
|
470
|
+
return [String(onValue)];
|
|
471
|
+
}
|
|
472
|
+
if (Array.isArray(onValue)) {
|
|
473
|
+
const names = [];
|
|
474
|
+
for (let i = 0; i < onValue.length; i++) {
|
|
475
|
+
if (typeof onValue[i] === 'string') names.push(onValue[i]);
|
|
476
|
+
}
|
|
477
|
+
return names;
|
|
478
|
+
}
|
|
479
|
+
if (onValue && typeof onValue === 'object') return Object.keys(onValue);
|
|
480
|
+
return [];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function eachJob(doc, visit) {
|
|
484
|
+
const names = Object.keys(doc.jobs);
|
|
485
|
+
for (let i = 0; i < names.length; i++) {
|
|
486
|
+
const job = doc.jobs[names[i]];
|
|
487
|
+
if (!job || typeof job !== 'object' || Array.isArray(job)) continue;
|
|
488
|
+
visit(names[i], job);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function eachStep(job, visit) {
|
|
493
|
+
if (!Array.isArray(job.steps)) return;
|
|
494
|
+
for (let i = 0; i < job.steps.length; i++) {
|
|
495
|
+
const step = job.steps[i];
|
|
496
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) continue;
|
|
497
|
+
visit(step, i);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function scalarText(value) {
|
|
502
|
+
if (typeof value === 'string') return value;
|
|
503
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
504
|
+
return '';
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function parseActionRef(uses) {
|
|
508
|
+
if (typeof uses !== 'string') return null;
|
|
509
|
+
const spec = uses.trim();
|
|
510
|
+
if (!spec || spec.startsWith('./') || spec.startsWith('docker://')) return null;
|
|
511
|
+
const at = spec.lastIndexOf('@');
|
|
512
|
+
const name = (at === -1 ? spec : spec.slice(0, at)).trim();
|
|
513
|
+
const ref = at === -1 ? '' : spec.slice(at + 1).trim();
|
|
514
|
+
if (!name.includes('/')) return null;
|
|
515
|
+
const owner = name.split('/')[0];
|
|
516
|
+
if (!owner) return null;
|
|
517
|
+
return {
|
|
518
|
+
spec: spec,
|
|
519
|
+
name: name,
|
|
520
|
+
owner: owner,
|
|
521
|
+
ref: ref,
|
|
522
|
+
thirdParty: !FIRST_PARTY_OWNERS.has(owner),
|
|
523
|
+
pinned: FULL_SHA.test(ref)
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function permissionsGrantWrite(perms) {
|
|
528
|
+
if (typeof perms === 'string') return perms.trim().toLowerCase() === 'write-all';
|
|
529
|
+
if (!perms || typeof perms !== 'object' || Array.isArray(perms)) return false;
|
|
530
|
+
const keys = Object.keys(perms);
|
|
531
|
+
for (let i = 0; i < keys.length; i++) {
|
|
532
|
+
if (String(perms[keys[i]]).trim().toLowerCase() === 'write') return true;
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function jobEffectivePerms(doc, job) {
|
|
538
|
+
if (job && job.permissions !== undefined && job.permissions !== null) return job.permissions;
|
|
539
|
+
if (doc.permissions !== undefined && doc.permissions !== null) return doc.permissions;
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function valueHasSecret(value, depth) {
|
|
544
|
+
if (depth > 8 || value === null || value === undefined) return false;
|
|
545
|
+
if (typeof value === 'string') return SECRET_REF.test(value);
|
|
546
|
+
if (typeof value !== 'object') return false;
|
|
547
|
+
if (Array.isArray(value)) {
|
|
548
|
+
for (let i = 0; i < value.length; i++) {
|
|
549
|
+
if (valueHasSecret(value[i], depth + 1)) return true;
|
|
550
|
+
}
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
const keys = Object.keys(value);
|
|
554
|
+
for (let i = 0; i < keys.length; i++) {
|
|
555
|
+
if (valueHasSecret(value[keys[i]], depth + 1)) return true;
|
|
556
|
+
}
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function collectPullRequestTarget(doc, lines, add) {
|
|
561
|
+
if (triggerNames(doc.on).indexOf('pull_request_target') === -1) return;
|
|
562
|
+
let cursor = 0;
|
|
563
|
+
eachJob(doc, function (name, job) {
|
|
564
|
+
eachStep(job, function (step) {
|
|
565
|
+
if (!checkoutRefIsUntrusted(step)) return;
|
|
566
|
+
const ref = scalarText(step.with && step.with.ref);
|
|
567
|
+
const snippet = ref || scalarText(step.uses);
|
|
568
|
+
const line = lineContainingFrom(lines, snippet.slice(0, 80), cursor);
|
|
569
|
+
cursor = line;
|
|
570
|
+
add('sec/pull-request-target', line, lines[line - 1] || snippet);
|
|
571
|
+
});
|
|
572
|
+
if (permissionsGrantWrite(jobEffectivePerms(doc, job))) {
|
|
573
|
+
const line = lineOfAppliedPermissions(lines, name, jobEffectivePerms(doc, job));
|
|
574
|
+
add('sec/pull-request-target', line, lines[line - 1] || 'permissions');
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function checkoutRefIsUntrusted(step) {
|
|
580
|
+
if (typeof step.uses !== 'string' || !/^actions\/checkout(?:@|\/|\s|$)/.test(step.uses.trim())) return false;
|
|
581
|
+
const withBlock = step.with;
|
|
582
|
+
if (!withBlock || typeof withBlock !== 'object' || Array.isArray(withBlock)) return false;
|
|
583
|
+
const ref = scalarText(withBlock.ref);
|
|
584
|
+
const repository = scalarText(withBlock.repository);
|
|
585
|
+
if (ref && UNTRUSTED_CHECKOUT.test(ref)) return true;
|
|
586
|
+
if (repository && /github\.event\.pull_request\.head/.test(repository)) return true;
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function collectExpressionInjection(doc, lines, add) {
|
|
591
|
+
let cursor = 0;
|
|
592
|
+
eachJob(doc, function (name, job) {
|
|
593
|
+
eachStep(job, function (step) {
|
|
594
|
+
const run = scalarText(step.run);
|
|
595
|
+
if (!run) return;
|
|
596
|
+
const match = INJECTED_EXPR.exec(run);
|
|
597
|
+
if (!match) return;
|
|
598
|
+
const located = lineOfInjectedRun(lines, match[0], cursor);
|
|
599
|
+
cursor = located.next;
|
|
600
|
+
add('sec/expression-injection', located.line, lines[located.line - 1] || run);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function collectUnpinnedActions(doc, lines, add) {
|
|
606
|
+
let cursor = 0;
|
|
607
|
+
eachJob(doc, function (name, job) {
|
|
608
|
+
cursor = flagUnpinned(lines, job.uses, cursor, add);
|
|
609
|
+
eachStep(job, function (step) {
|
|
610
|
+
cursor = flagUnpinned(lines, step.uses, cursor, add);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function flagUnpinned(lines, uses, cursor, add) {
|
|
616
|
+
const action = parseActionRef(uses);
|
|
617
|
+
if (!action || !action.thirdParty || action.pinned) return cursor;
|
|
618
|
+
const line = lineOfUses(lines, action.spec, cursor);
|
|
619
|
+
add('pipe/action-unpinned', line, lines[line - 1] || action.spec);
|
|
620
|
+
return line;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function collectJobPermissions(doc, lines, add) {
|
|
624
|
+
eachJob(doc, function (name, job) {
|
|
625
|
+
const kinds = jobWriteKinds(job);
|
|
626
|
+
if (!kinds.contents && !kinds.deploy) return;
|
|
627
|
+
const hasJobPerms = job.permissions !== undefined && job.permissions !== null;
|
|
628
|
+
const hasWorkflowPerms = doc.permissions !== undefined && doc.permissions !== null;
|
|
629
|
+
const perms = hasJobPerms ? job.permissions : (hasWorkflowPerms ? doc.permissions : undefined);
|
|
630
|
+
const info = describePermissions(perms);
|
|
631
|
+
let reason = '';
|
|
632
|
+
if (!info) reason = 'missing';
|
|
633
|
+
else if (info.writeAll) reason = 'write-all';
|
|
634
|
+
else if (info.contentsWrite && kinds.deploy && !kinds.contents) reason = 'contents-write';
|
|
635
|
+
else return;
|
|
636
|
+
const line = lineForJobPermission(lines, name, reason);
|
|
637
|
+
add('sec/job-permissions', line, lines[line - 1] || name);
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function jobWriteKinds(job) {
|
|
642
|
+
const kinds = { contents: false, deploy: false };
|
|
643
|
+
eachStep(job, function (step) {
|
|
644
|
+
const run = scalarText(step.run);
|
|
645
|
+
if (CONTENTS_WRITE_RUN.test(run)) kinds.contents = true;
|
|
646
|
+
if (DEPLOY_RUN.test(run)) kinds.deploy = true;
|
|
647
|
+
const action = parseActionRef(step.uses);
|
|
648
|
+
if (!action) return;
|
|
649
|
+
if (CONTENTS_ACTIONS.has(action.name)) kinds.contents = true;
|
|
650
|
+
if (action.name === 'actions/deploy-pages') kinds.deploy = true;
|
|
651
|
+
if (action.name === 'docker/build-push-action') {
|
|
652
|
+
const push = step.with && step.with.push;
|
|
653
|
+
if (push === true || String(push).toLowerCase() === 'true') kinds.deploy = true;
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
return kinds;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function describePermissions(perms) {
|
|
660
|
+
if (perms === undefined || perms === null) return null;
|
|
661
|
+
if (typeof perms === 'string') {
|
|
662
|
+
const value = perms.trim().toLowerCase();
|
|
663
|
+
return { writeAll: value === 'write-all', contentsWrite: value === 'write-all' };
|
|
664
|
+
}
|
|
665
|
+
if (typeof perms !== 'object' || Array.isArray(perms)) return null;
|
|
666
|
+
const contents = perms.contents === undefined || perms.contents === null ? '' : String(perms.contents).trim().toLowerCase();
|
|
667
|
+
return { writeAll: false, contentsWrite: contents === 'write' };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function collectSecrets(doc, lines, add) {
|
|
671
|
+
let cursor = 0;
|
|
672
|
+
eachJob(doc, function (name, job) {
|
|
673
|
+
const inherited = valueHasSecret(doc.env, 0) || valueHasSecret(job.env, 0);
|
|
674
|
+
const jobAction = parseActionRef(job.uses);
|
|
675
|
+
if (jobAction && jobAction.thirdParty && (job.secrets === 'inherit' || valueHasSecret(job.secrets, 0) || inherited)) {
|
|
676
|
+
const line = lineOfUses(lines, jobAction.spec, cursor);
|
|
677
|
+
cursor = line;
|
|
678
|
+
add('sec/secret-to-untrusted', line, lines[line - 1] || jobAction.spec);
|
|
679
|
+
}
|
|
680
|
+
eachStep(job, function (step) {
|
|
681
|
+
const action = parseActionRef(step.uses);
|
|
682
|
+
if (!action || !action.thirdParty) return;
|
|
683
|
+
const onStep = valueHasSecret(step.env, 0) || valueHasSecret(step.with, 0);
|
|
684
|
+
if (!onStep && !inherited) return;
|
|
685
|
+
const line = onStep
|
|
686
|
+
? lineContainingFrom(lines, 'secrets.', cursor)
|
|
687
|
+
: lineOfUses(lines, action.spec, cursor);
|
|
688
|
+
cursor = line;
|
|
689
|
+
add('sec/secret-to-untrusted', line, lines[line - 1] || action.spec);
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function lineContainingFrom(lines, snippet, start) {
|
|
695
|
+
const needle = String(snippet);
|
|
696
|
+
const from = start > 0 ? start : 0;
|
|
697
|
+
for (let i = from; i < lines.length; i++) {
|
|
698
|
+
if (lines[i].includes(needle)) return i + 1;
|
|
699
|
+
}
|
|
700
|
+
for (let i = 0; i < from; i++) {
|
|
701
|
+
if (lines[i].includes(needle)) return i + 1;
|
|
702
|
+
}
|
|
703
|
+
return 1;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function lineOfUses(lines, spec, cursor) {
|
|
707
|
+
const from = cursor > 0 ? cursor : 0;
|
|
708
|
+
for (let i = from; i < lines.length; i++) {
|
|
709
|
+
if (lines[i].includes(spec) && /uses\s*:/.test(lines[i])) return i + 1;
|
|
710
|
+
}
|
|
711
|
+
return lineContainingFrom(lines, spec, 0);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function lineOfInjectedRun(lines, snippet, cursor) {
|
|
715
|
+
const runKey = /^\s*(?:-\s+)?run\s*:/;
|
|
716
|
+
const start = cursor > 0 ? cursor : 0;
|
|
717
|
+
for (let i = start; i < lines.length; i++) {
|
|
718
|
+
if (!runKey.test(lines[i])) continue;
|
|
719
|
+
const indent = lines[i].search(/\S/);
|
|
720
|
+
if (lines[i].includes(snippet)) return { line: i + 1, next: i + 1 };
|
|
721
|
+
let found = 0;
|
|
722
|
+
let end = i + 1;
|
|
723
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
724
|
+
if (!lines[j].trim()) continue;
|
|
725
|
+
if (lines[j].search(/\S/) <= indent) break;
|
|
726
|
+
end = j + 1;
|
|
727
|
+
if (!found && lines[j].includes(snippet)) found = j + 1;
|
|
728
|
+
}
|
|
729
|
+
if (found) return { line: found, next: end };
|
|
730
|
+
if (/\*[A-Za-z0-9_-]+/.test(lines[i])) {
|
|
731
|
+
return { line: lineContainingFrom(lines, snippet, 0), next: i + 1 };
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return { line: lineContainingFrom(lines, snippet, 0), next: lines.length };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function findJobLine(lines, jobName) {
|
|
738
|
+
const pattern = new RegExp('^ ' + escapeRegExp(jobName) + '\\s*:');
|
|
739
|
+
let seenJobs = false;
|
|
740
|
+
for (let i = 0; i < lines.length; i++) {
|
|
741
|
+
if (/^jobs\s*:/.test(lines[i])) {
|
|
742
|
+
seenJobs = true;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (seenJobs && pattern.test(lines[i])) return i + 1;
|
|
746
|
+
}
|
|
747
|
+
return 1;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function nextJobBoundary(lines, jobLine) {
|
|
751
|
+
for (let i = jobLine; i < lines.length; i++) {
|
|
752
|
+
if (/^ [A-Za-z0-9_-]+\s*:/.test(lines[i])) return i;
|
|
753
|
+
}
|
|
754
|
+
return lines.length;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function findLineMatching(lines, pattern, start, end) {
|
|
758
|
+
const from = Math.max(0, start);
|
|
759
|
+
const to = Math.min(lines.length, end);
|
|
760
|
+
for (let i = from; i < to; i++) {
|
|
761
|
+
if (pattern.test(lines[i])) return i + 1;
|
|
762
|
+
}
|
|
763
|
+
return 0;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function lineOfAppliedPermissions(lines, jobName, perms) {
|
|
767
|
+
const jobAt = findJobLine(lines, jobName);
|
|
768
|
+
const regionEnd = nextJobBoundary(lines, jobAt);
|
|
769
|
+
if (typeof perms === 'string' && perms.trim().toLowerCase() === 'write-all') {
|
|
770
|
+
return findLineMatching(lines, /permissions\s*:\s*['"]?write-all['"]?/, jobAt - 1, regionEnd)
|
|
771
|
+
|| findLineMatching(lines, /permissions\s*:\s*['"]?write-all['"]?/, 0, lines.length)
|
|
772
|
+
|| jobAt;
|
|
773
|
+
}
|
|
774
|
+
return findLineMatching(lines, /^\s*.+:\s*['"]?write['"]?\s*(?:#.*)?$/, jobAt - 1, regionEnd)
|
|
775
|
+
|| findLineMatching(lines, /^\s*permissions\s*:/, jobAt - 1, regionEnd)
|
|
776
|
+
|| findLineMatching(lines, /^permissions\s*:/, 0, lines.length)
|
|
777
|
+
|| jobAt;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function lineForJobPermission(lines, jobName, reason) {
|
|
781
|
+
const jobAt = findJobLine(lines, jobName);
|
|
782
|
+
const regionEnd = nextJobBoundary(lines, jobAt);
|
|
783
|
+
if (reason === 'write-all') {
|
|
784
|
+
return findLineMatching(lines, /permissions\s*:\s*['"]?write-all['"]?/, 0, lines.length) || jobAt;
|
|
785
|
+
}
|
|
786
|
+
if (reason === 'contents-write') {
|
|
787
|
+
return findLineMatching(lines, /contents\s*:\s*['"]?write['"]?/, jobAt - 1, regionEnd)
|
|
788
|
+
|| findLineMatching(lines, /contents\s*:\s*['"]?write['"]?/, 0, lines.length)
|
|
789
|
+
|| jobAt;
|
|
790
|
+
}
|
|
791
|
+
return findLineMatching(
|
|
792
|
+
lines,
|
|
793
|
+
/git\s+push|gh\s+release|gh\s+pr\s+merge|npm\s+publish|pnpm\s+publish|docker\s+push|twine\s+upload|action-gh-release|actions-gh-pages|release-action|deploy-pages|build-push-action/,
|
|
794
|
+
jobAt - 1,
|
|
795
|
+
regionEnd
|
|
796
|
+
) || jobAt;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function escapeRegExp(value) {
|
|
800
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function notePackError(pack, message) {
|
|
804
|
+
if (!pack.errors) pack.errors = [];
|
|
805
|
+
if (pack.errors.indexOf(message) === -1) pack.errors.push(message);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function clipDetail(value) {
|
|
809
|
+
const text = String(value || '');
|
|
810
|
+
return text.length > 300 ? text.slice(0, 300) + '...' : text;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function ruleLabel(rule, index) {
|
|
814
|
+
if (rule && typeof rule.id === 'string' && rule.id.trim() !== '') return rule.id;
|
|
815
|
+
return 'rules[' + index + ']';
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function globToRegExp(glob) {
|
|
819
|
+
let normalized = String(glob).replace(/\\/g, '/');
|
|
820
|
+
if (normalized.startsWith('./')) normalized = normalized.slice(2);
|
|
821
|
+
let source = '';
|
|
822
|
+
let i = 0;
|
|
823
|
+
while (i < normalized.length) {
|
|
824
|
+
const c = normalized[i];
|
|
825
|
+
if (c === '*' && normalized[i + 1] === '*') {
|
|
826
|
+
if (normalized[i + 2] === '/') {
|
|
827
|
+
source += '(?:.*/)?';
|
|
828
|
+
i += 3;
|
|
829
|
+
} else {
|
|
830
|
+
source += '.*';
|
|
831
|
+
i += 2;
|
|
832
|
+
}
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
if (c === '*') {
|
|
836
|
+
source += '[^/]*';
|
|
837
|
+
i += 1;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
if (c === '?') {
|
|
841
|
+
source += '[^/]';
|
|
842
|
+
i += 1;
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
source += escapeRegExp(c);
|
|
846
|
+
i += 1;
|
|
847
|
+
}
|
|
848
|
+
return new RegExp('^' + source + '$');
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function fileMatchesRule(relPath, rule) {
|
|
852
|
+
if (!rule.globs || rule.globs.length === 0) return true;
|
|
853
|
+
const norm = String(relPath || '').replace(/\\/g, '/');
|
|
854
|
+
for (let i = 0; i < rule.globs.length; i++) {
|
|
855
|
+
if (rule.globs[i].test(norm)) return true;
|
|
856
|
+
}
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function firstNonEmptyMatch(regex, text) {
|
|
861
|
+
const flags = regex.flags.includes('g') ? regex.flags : regex.flags + 'g';
|
|
862
|
+
const re = new RegExp(regex.source, flags);
|
|
863
|
+
let match;
|
|
864
|
+
while ((match = re.exec(text)) !== null) {
|
|
865
|
+
if (match[0].length > 0) return match;
|
|
866
|
+
if (re.lastIndex <= match.index) re.lastIndex = match.index + 1;
|
|
867
|
+
}
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function findPackHits(rule, content, lines) {
|
|
872
|
+
const hits = [];
|
|
873
|
+
for (let i = 0; i < lines.length; i++) {
|
|
874
|
+
const match = firstNonEmptyMatch(rule.regex, lines[i]);
|
|
875
|
+
if (!match) continue;
|
|
876
|
+
const evidence = String(lines[i] || '').trim();
|
|
877
|
+
if (!evidence) continue;
|
|
878
|
+
hits.push({ line: i + 1, evidence: evidence });
|
|
879
|
+
}
|
|
880
|
+
if (hits.length > 0) return hits;
|
|
881
|
+
const match = firstNonEmptyMatch(rule.regex, content);
|
|
882
|
+
if (!match) return hits;
|
|
883
|
+
const evidence = String(match[0]).replace(/\s+/g, ' ').trim();
|
|
884
|
+
if (!evidence) return hits;
|
|
885
|
+
const line = content.slice(0, match.index).split('\n').length;
|
|
886
|
+
hits.push({ line: line > 0 ? line : 1, evidence: evidence });
|
|
887
|
+
return hits;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function packIssue(rule, hit) {
|
|
891
|
+
return {
|
|
892
|
+
rule_id: rule.id,
|
|
893
|
+
title: rule.title,
|
|
894
|
+
severity: rule.severity,
|
|
895
|
+
line: hit.line > 0 ? hit.line : 1,
|
|
896
|
+
evidence: String(hit.evidence).slice(0, 90),
|
|
897
|
+
fix: rule.fix,
|
|
898
|
+
source: 'pack'
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function collectPackIssues(relPath, content, lines, pack) {
|
|
903
|
+
if (!pack || !Array.isArray(pack.rules) || pack.rules.length === 0) return [];
|
|
904
|
+
const issues = [];
|
|
905
|
+
for (let i = 0; i < pack.rules.length; i++) {
|
|
906
|
+
const rule = pack.rules[i];
|
|
907
|
+
try {
|
|
908
|
+
if (!fileMatchesRule(relPath, rule)) continue;
|
|
909
|
+
const hits = findPackHits(rule, content, lines);
|
|
910
|
+
for (let h = 0; h < hits.length; h++) {
|
|
911
|
+
issues.push(packIssue(rule, hits[h]));
|
|
912
|
+
}
|
|
913
|
+
} catch (err) {
|
|
914
|
+
notePackError(pack, `Rejected pack rule ${rule && rule.id ? rule.id : 'rules[' + i + ']'}: pattern failed during scan (${clipDetail(err.message)})`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return issues;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function missingPackFields(rule) {
|
|
921
|
+
const missing = [];
|
|
922
|
+
if (!rule || typeof rule.id !== 'string' || rule.id.trim() === '') missing.push('id');
|
|
923
|
+
if (!rule || typeof rule.severity !== 'string' || rule.severity.trim() === '') missing.push('severity');
|
|
924
|
+
if (!rule || typeof rule.title !== 'string' || rule.title.trim() === '') missing.push('title');
|
|
925
|
+
if (!rule || typeof rule.pattern !== 'string' || rule.pattern.trim() === '') missing.push('pattern');
|
|
926
|
+
if (!rule || typeof rule.fix !== 'string' || rule.fix.trim() === '') missing.push('fix');
|
|
927
|
+
return missing;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function compilePackRule(rule, index, seen) {
|
|
931
|
+
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
|
|
932
|
+
return { error: 'Rejected pack rule rules[' + index + ']: rule must be an object with id, severity, title, pattern, and fix' };
|
|
933
|
+
}
|
|
934
|
+
const missing = missingPackFields(rule);
|
|
935
|
+
if (missing.length > 0) {
|
|
936
|
+
return { error: 'Rejected pack rule ' + ruleLabel(rule, index) + ': missing ' + missing.join(', ') };
|
|
937
|
+
}
|
|
938
|
+
if (BUILTIN_IDS.has(rule.id)) {
|
|
939
|
+
return { error: 'Rejected pack rule ' + rule.id + ': duplicate id (matches built-in rule ' + rule.id + ')' };
|
|
940
|
+
}
|
|
941
|
+
if (!rule.id.startsWith('custom/') || rule.id === 'custom/' || /\s/.test(rule.id)) {
|
|
942
|
+
return { error: 'Rejected pack rule ' + rule.id + ': id must start with custom/' };
|
|
943
|
+
}
|
|
944
|
+
if (seen.has(rule.id)) {
|
|
945
|
+
return { error: 'Rejected pack rule ' + rule.id + ': duplicate id' };
|
|
946
|
+
}
|
|
947
|
+
seen.add(rule.id);
|
|
948
|
+
if (!PACK_SEVERITIES.has(rule.severity)) {
|
|
949
|
+
return { error: 'Rejected pack rule ' + rule.id + ': unknown severity "' + clipDetail(rule.severity) + '" (expected CRITICAL, HIGH, MEDIUM, or LOW)' };
|
|
950
|
+
}
|
|
951
|
+
let regex;
|
|
952
|
+
try {
|
|
953
|
+
regex = new RegExp(rule.pattern);
|
|
954
|
+
} catch (err) {
|
|
955
|
+
return { error: 'Rejected pack rule ' + rule.id + ': bad regex (' + clipDetail(err.message) + ')' };
|
|
956
|
+
}
|
|
957
|
+
let files = null;
|
|
958
|
+
if (rule.files !== undefined) {
|
|
959
|
+
if (!Array.isArray(rule.files) || rule.files.some((item) => typeof item !== 'string' || item.trim() === '')) {
|
|
960
|
+
return { error: 'Rejected pack rule ' + rule.id + ': files must be an array of glob strings' };
|
|
961
|
+
}
|
|
962
|
+
if (rule.files.length > 0) files = rule.files.slice();
|
|
963
|
+
}
|
|
964
|
+
let globs = null;
|
|
965
|
+
if (files) {
|
|
966
|
+
try {
|
|
967
|
+
globs = files.map((item) => globToRegExp(item));
|
|
968
|
+
} catch (err) {
|
|
969
|
+
return { error: 'Rejected pack rule ' + rule.id + ': files must be an array of glob strings (' + clipDetail(err.message) + ')' };
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return {
|
|
973
|
+
rule: {
|
|
974
|
+
id: rule.id,
|
|
975
|
+
cat: 'Custom',
|
|
976
|
+
sev: rule.severity,
|
|
977
|
+
severity: rule.severity,
|
|
978
|
+
title: rule.title,
|
|
979
|
+
pattern: rule.pattern,
|
|
980
|
+
fix: rule.fix,
|
|
981
|
+
files: files,
|
|
982
|
+
globs: globs,
|
|
983
|
+
regex: regex,
|
|
984
|
+
source: 'pack'
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function readRulePack(filePath) {
|
|
990
|
+
const resolved = path.resolve(filePath);
|
|
991
|
+
let raw;
|
|
992
|
+
try {
|
|
993
|
+
raw = fs.readFileSync(resolved, 'utf8');
|
|
994
|
+
} catch (err) {
|
|
995
|
+
if (err && err.code === 'ENOENT') {
|
|
996
|
+
return { path: resolved, rules: [], errors: ['Pack file not found: ' + resolved], broken: true };
|
|
997
|
+
}
|
|
998
|
+
return { path: resolved, rules: [], errors: ['Pack file could not be read: ' + resolved + ' (' + err.message + ')'], broken: true };
|
|
999
|
+
}
|
|
1000
|
+
let data;
|
|
1001
|
+
try {
|
|
1002
|
+
data = JSON.parse(String(raw).replace(/^\uFEFF/, ''));
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
return { path: resolved, rules: [], errors: ['Invalid JSON in ' + resolved + ': ' + clipDetail(err.message)], broken: true };
|
|
1005
|
+
}
|
|
1006
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.rules)) {
|
|
1007
|
+
return { path: resolved, rules: [], errors: ['Pack ' + resolved + ' must be a JSON object with a "rules" array'], broken: true };
|
|
1008
|
+
}
|
|
1009
|
+
const rules = [];
|
|
1010
|
+
const errors = [];
|
|
1011
|
+
const seen = new Set();
|
|
1012
|
+
for (let i = 0; i < data.rules.length; i++) {
|
|
1013
|
+
const compiled = compilePackRule(data.rules[i], i, seen);
|
|
1014
|
+
if (compiled.error) {
|
|
1015
|
+
errors.push(compiled.error);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
rules.push(compiled.rule);
|
|
1019
|
+
}
|
|
1020
|
+
return { path: resolved, rules: rules, errors: errors, broken: false };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function loadRulePack(filePath) {
|
|
1024
|
+
if (typeof filePath !== 'string' || filePath.trim() === '') {
|
|
1025
|
+
return { path: '', rules: [], errors: ['Pack file path must be a non-empty string'], broken: true };
|
|
1026
|
+
}
|
|
1027
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(filePath)) {
|
|
1028
|
+
return {
|
|
1029
|
+
path: filePath,
|
|
1030
|
+
rules: [],
|
|
1031
|
+
errors: ['Rejected pack ' + filePath + ': rule packs must be local JSON files'],
|
|
1032
|
+
broken: true
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
try {
|
|
1036
|
+
return readRulePack(filePath);
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
return {
|
|
1039
|
+
path: filePath,
|
|
1040
|
+
rules: [],
|
|
1041
|
+
errors: ['Pack ' + filePath + ' failed to load: ' + clipDetail(err.message)],
|
|
1042
|
+
broken: true
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function resolveScanPack(targetPath, rulesFile) {
|
|
1048
|
+
if (typeof rulesFile === 'string' && rulesFile.trim() !== '') {
|
|
1049
|
+
return loadRulePack(rulesFile);
|
|
1050
|
+
}
|
|
1051
|
+
if (typeof targetPath !== 'string' || targetPath.trim() === '') return null;
|
|
1052
|
+
let root = targetPath;
|
|
1053
|
+
try {
|
|
1054
|
+
const stat = fs.statSync(targetPath);
|
|
1055
|
+
root = stat.isDirectory() ? targetPath : path.dirname(targetPath);
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
return {
|
|
1058
|
+
path: targetPath,
|
|
1059
|
+
rules: [],
|
|
1060
|
+
errors: ['Pack auto-load failed for ' + targetPath + ': ' + err.message],
|
|
1061
|
+
broken: true
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
const candidate = path.join(root, '.plumber-rules.json');
|
|
1065
|
+
try {
|
|
1066
|
+
const stat = fs.statSync(candidate);
|
|
1067
|
+
if (!stat.isFile()) {
|
|
1068
|
+
return {
|
|
1069
|
+
path: candidate,
|
|
1070
|
+
rules: [],
|
|
1071
|
+
errors: ['Pack file is not a file: ' + candidate],
|
|
1072
|
+
broken: true
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
if (err && err.code === 'ENOENT') return null;
|
|
1077
|
+
return {
|
|
1078
|
+
path: candidate,
|
|
1079
|
+
rules: [],
|
|
1080
|
+
errors: ['Pack file could not be read: ' + candidate + ' (' + err.message + ')'],
|
|
1081
|
+
broken: true
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
return loadRulePack(candidate);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
module.exports = {
|
|
1088
|
+
RULES,
|
|
1089
|
+
SEV_WEIGHT,
|
|
1090
|
+
SCANNABLE_EXTS,
|
|
1091
|
+
scanLocalDirectory,
|
|
1092
|
+
scanText,
|
|
1093
|
+
loadRulePack,
|
|
1094
|
+
resolveScanPack
|
|
1095
|
+
};
|