@parasraju/plumber-mcp 1.0.1 → 1.1.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 CHANGED
@@ -84,7 +84,7 @@ Add to `.vscode/mcp.json`:
84
84
  | Tool | Description |
85
85
  | --- | --- |
86
86
  | `plumber_scan` | Scan a local directory or file. Returns a score, grade, file count, and one line per issue with its fix. |
87
- | `plumber_list_rules` | List all 21 inspection rules with id, severity, title, and fix. |
87
+ | `plumber_list_rules` | List all 26 inspection rules with id, severity, title, and fix. |
88
88
  | `plumber_scan_text` | Scan a pasted code or config snippet. `filename` is optional and only selects the applicable rules. |
89
89
 
90
90
  Tool inputs are deliberately small so they cost few tokens:
@@ -103,6 +103,28 @@ Tool inputs are deliberately small so they cost few tokens:
103
103
  >
104
104
  > **Agent:** I moved the key into `.env`, replaced it with `process.env.OPENAI_API_KEY`, and added `.env` to `.gitignore`. You should revoke the exposed key.
105
105
 
106
+ ### Scan GitHub Actions workflows
107
+
108
+ `plumber_scan` reads `.github/workflows/*.yml` and `*.yaml`. `plumber_scan_text` applies the same rules when the filename is under a `workflows` path, or when the snippet parses as a workflow (`on:` and `jobs:`).
109
+
110
+ - `sec/pull-request-target` (CRITICAL) — a `pull_request_target` workflow checks out untrusted pull request code or grants write permissions.
111
+ - `sec/expression-injection` (CRITICAL) — `${{ github.event.* }}`, `${{ github.head_ref }}`, or `${{ inputs.* }}` is interpolated into a `run` script.
112
+ - `pipe/action-unpinned` (MEDIUM) — a third-party `uses:` action is pinned to a branch or version tag instead of a full commit SHA.
113
+ - `sec/job-permissions` (HIGH) — a job that pushes, releases, or deploys has no permissions block, uses `write-all`, or sets `contents: write` where a narrower scope would do.
114
+ - `sec/secret-to-untrusted` (HIGH) — a secret is passed through `env` or an argument to a third-party action.
115
+
116
+ > **Agent:** *(calls `plumber_scan` with `target: "."` on a repo whose `.github/workflows/pr.yml` checks out the pull request head from `pull_request_target`)*
117
+ >
118
+ > `- [CRITICAL] Untrusted pull_request_target Checkout or Write Permission (Use pull_request, or check out the base ref read-only with no secrets.) — .github/workflows/pr.yml:9`
119
+
120
+ ### Workflow YAML rules
121
+
122
+ - `sec/pull-request-target` [CRITICAL] Untrusted pull_request_target Checkout or Write Permission (Use pull_request, or check out the base ref read-only with no secrets.)
123
+ - `sec/expression-injection` [CRITICAL] Expression Injection in Workflow run Step (Pass the value through an env: variable and reference that variable in the script.)
124
+ - `pipe/action-unpinned` [MEDIUM] Unpinned Third-Party Action (Pin the action to a full commit SHA.)
125
+ - `sec/job-permissions` [HIGH] Job Writes Without Least-Privilege Permissions (Set least-privilege permissions on the job.)
126
+ - `sec/secret-to-untrusted` [HIGH] Secret Passed to Untrusted Step (Scope the secret to the trusted step that needs it.)
127
+
106
128
  ## Links
107
129
 
108
130
  - CLI: [plumber-linter](https://www.npmjs.com/package/plumber-linter)
@@ -112,6 +134,9 @@ Tool inputs are deliberately small so they cost few tokens:
112
134
 
113
135
  ## Changelog
114
136
 
137
+ ## 1.1.0
138
+ - Workflow YAML rules: sec/pull-request-target, sec/expression-injection, pipe/action-unpinned, sec/job-permissions, sec/secret-to-untrusted.
139
+
115
140
  ## 1.0.1
116
141
  - Fix false-positive test path matching that skipped secrets in files containing "test" or "spec" substrings (e.g. `latest.js`, `inspector.js`).
117
142
  - Fix directory scan skipping target path when root directory name matches skip list or begins with a dot (e.g. `dist`, `.hidden`).
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Tools:
7
7
  * plumber_scan scan a local directory or file
8
- * plumber_list_rules list the 21 inspection rules
8
+ * plumber_list_rules list every inspection rule
9
9
  * plumber_scan_text scan a pasted snippet
10
10
  *
11
11
  * Only JSON-RPC goes to stdout. Diagnostics go to stderr.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@parasraju/plumber-mcp",
3
- "version": "1.0.1",
4
- "description": "Plumber as an MCP server — let your AI coding agent scan repos for leaked secrets, dead code, and AI slop, with fixes in plain English.",
3
+ "version": "1.1.0",
4
+ "description": "Plumber as an MCP server — let your AI coding agent scan repos and GitHub Actions workflows for leaked secrets, dead code, and AI slop, with fixes in plain English.",
5
5
  "main": "src/engine.js",
6
6
  "bin": {
7
7
  "plumber-mcp": "bin/plumber-mcp.js"
@@ -27,7 +27,11 @@
27
27
  "security",
28
28
  "secrets-detection",
29
29
  "code-quality",
30
- "linter"
30
+ "linter",
31
+ "github-actions",
32
+ "ci-cd",
33
+ "workflow",
34
+ "yaml"
31
35
  ],
32
36
  "repository": {
33
37
  "type": "git",
@@ -40,6 +44,7 @@
40
44
  "license": "MIT",
41
45
  "author": "Paras Raju",
42
46
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "^1.32.2"
47
+ "@modelcontextprotocol/sdk": "^1.32.2",
48
+ "js-yaml": "^4.3.2"
44
49
  }
45
50
  }
package/src/engine.js CHANGED
@@ -1,323 +1,783 @@
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
- const fs = require('fs');
18
- const path = require('path');
19
-
20
- const ENV_TEMPLATE_RE = /\.(example|sample|template|dist)$/;
21
-
22
- const RULES = [
23
- { id: 'leak/api-key', cat: 'Leaks & Secrets', sev: 'CRITICAL', title: 'Leaked API Key or Private Access Token', fix: 'Move secret to environment variable (.env).' },
24
- { id: 'leak/committed-env', cat: 'Leaks & Secrets', sev: 'CRITICAL', title: 'Production Secret Committed in Environment File', fix: 'Remove sensitive values and use .env.example.' },
25
- { id: 'leak/hardcoded-secret', cat: 'Leaks & Secrets', sev: 'HIGH', title: 'Hardcoded Password or Database Credential', fix: 'Replace with process.env / os.environ reference.' },
26
- { id: 'sec/raw-html', cat: 'Leaks & Secrets', sev: 'MEDIUM', title: 'Raw HTML Injection (XSS Risk)', fix: 'Use textContent or safe DOM templating.' },
27
- { 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.' },
28
- { 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.' },
29
- { id: 'clog/stub-function', cat: 'Clogs & Dead Code', sev: 'MEDIUM', title: 'Unimplemented Stub Function', fix: 'Complete missing logic or remove placeholder.' },
30
- { id: 'clog/step-comments', cat: 'Clogs & Dead Code', sev: 'LOW', title: 'Robotic AI Step Comments Left Behind', fix: 'Clean up AI instructional comments.' },
31
- { id: 'clog/dead-code', cat: 'Clogs & Dead Code', sev: 'HIGH', title: 'Unreachable Dead Code', fix: 'Delete unreachable lines or run plumber clean --write.' },
32
- { id: 'slop/pointless-wrapper', cat: 'Clogs & Dead Code', sev: 'LOW', title: 'Function That Only Forwards Its Arguments', fix: 'Call underlying function directly.' },
33
- { id: 'joint/icon-button', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Icon Button Without Accessible Name', fix: 'Add aria-label attribute to button element.' },
34
- { id: 'joint/clickable-div', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Non-Semantic Clickable Element (Missing Button / Role)', fix: 'Use native <button type="button">.' },
35
- { id: 'joint/empty-test', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Empty Test with No Assertions', fix: 'Add assert statements or verification logic.' },
36
- { id: 'joint/focus-removed', cat: 'Joints & Accessibility', sev: 'MEDIUM', title: 'Focus Outline Removed Without Replacement', fix: 'Provide a visible :focus-visible style.' },
37
- { id: 'pipe/phantom-import', cat: 'Pipes & Dependencies', sev: 'HIGH', title: 'Plausible but Hallucinated Package Import', fix: 'Check package registry or use official package.' },
38
- { 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.' },
39
- { id: 'slop/ai-gradient', cat: 'AI Slop Tells', sev: 'MEDIUM', title: 'Purple / Violet AI Default Gradient', fix: 'Use clean brand tokens or neutral surfaces.' },
40
- { id: 'slop/chatbot-residue', cat: 'AI Slop Tells', sev: 'MEDIUM', title: 'Pasted Chatbot Conversational Residue', fix: 'Delete conversational intro/outro boilerplate.' },
41
- { id: 'slop/neon-glow', cat: 'AI Slop Tells', sev: 'LOW', title: 'Colored Neon Glow Shadow', fix: 'Replace with subtle crisp 1px borders.' },
42
- { id: 'slop/gradient-text', cat: 'AI Slop Tells', sev: 'LOW', title: 'Gradient-Clipped Headline Text', fix: 'Use solid, high-contrast typography.' },
43
- { id: 'ui/important-abuse', cat: 'AI Slop Tells', sev: 'LOW', title: '!important Overrides in Stylesheet', fix: 'Structure CSS cascade cleanly without !important.' }
44
- ];
45
-
46
- const SEV_WEIGHT = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
47
-
48
- function gradeFor(score) {
49
- return score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
50
- }
51
-
52
- function scoreFor(issueCount) {
53
- return Math.max(0, 100 - (issueCount * 4.2));
54
- }
55
-
56
- const SCANNABLE_EXTS = ['.py', '.js', '.jsx', '.ts', '.tsx', '.html', '.htm', '.css', '.scss', '.env'];
57
-
58
- function scanLocalDirectory(dirPath) {
59
- const scannableExts = new Set(SCANNABLE_EXTS);
60
- const skipDirs = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', 'vendor', '.venv', 'venv', '__pycache__']);
61
-
62
- const issues = [];
63
- let totalFiles = 0;
64
- let totalLines = 0;
65
- const fileReports = [];
66
-
67
- function walk(current) {
68
- const stat = fs.statSync(current);
69
- if (stat.isFile()) {
70
- const ext = path.extname(current).toLowerCase();
71
- const base = path.basename(current);
72
- if (scannableExts.has(ext) || base.startsWith('.env')) {
73
- totalFiles++;
74
- const rel = path.relative(dirPath, current).replace(/\\/g, '/') || path.basename(current);
75
- try {
76
- const content = fs.readFileSync(current, 'utf-8');
77
- const lines = content.split('\n');
78
- totalLines += lines.length;
79
- const fileIssues = inspectFile(rel, content, lines, ext);
80
- if (fileIssues.length > 0) {
81
- issues.push(...fileIssues);
82
- fileReports.push({ path: rel, issues: fileIssues });
83
- }
84
- } catch (err) {
85
- // Unreadable or locked file: skip it, matching the CLI's behavior.
86
- void err;
87
- }
88
- }
89
- return;
90
- }
91
-
92
- if (stat.isDirectory()) {
93
- const base = path.basename(current);
94
- // v1.0.1 fix: the skip list applies to subdirectories only, never to the
95
- // directory the user asked to scan.
96
- // Repro (v1.0.0): plumber_scan { target: "proj/dist" } or { target: "~/.dotfiles" }
97
- // with an AKIA key inside -> "Files: 0 | Issues: 0 ... No issues found."
98
- if (current !== dirPath && (skipDirs.has(base) || base.startsWith('.'))) return;
99
- const entries = fs.readdirSync(current);
100
- for (const entry of entries) {
101
- walk(path.join(current, entry));
102
- }
103
- }
104
- }
105
-
106
- walk(dirPath);
107
-
108
- const score = scoreFor(issues.length);
109
- const grade = gradeFor(score);
110
-
111
- return {
112
- repo_name: path.basename(dirPath),
113
- total_files: totalFiles,
114
- total_lines: totalLines,
115
- score: Math.round(score * 10) / 10,
116
- grade,
117
- issues,
118
- file_reports: fileReports
119
- };
120
- }
121
-
122
- function scanText(text, filename) {
123
- if (typeof text !== 'string') {
124
- throw new TypeError('text must be a string');
125
- }
126
- const name = filename && typeof filename === 'string' ? filename.replace(/\\/g, '/') : 'snippet.js';
127
- const ext = path.extname(name).toLowerCase();
128
- const content = text.replace(/\r\n?/g, '\n');
129
- const lines = content.split('\n');
130
- const issues = inspectFile(name, content, lines, ext);
131
- const score = scoreFor(issues.length);
132
-
133
- return {
134
- repo_name: name,
135
- total_files: 1,
136
- total_lines: lines.length,
137
- score: Math.round(score * 10) / 10,
138
- grade: gradeFor(score),
139
- issues,
140
- file_reports: issues.length > 0 ? [{ path: name, issues }] : []
141
- };
142
- }
143
-
144
- // v1.0.1 fix: match "test"/"spec" as a path segment or filename part, not as a
145
- // substring. The old substring check treated latest.js, inspector.js, special.js,
146
- // contest/, respect.py ... as test files and skipped secret detection in them.
147
- // Repro (v1.0.0): plumber_scan_text { text: 'const k = "AKIAIOSFODNN7EXAMPLE";',
148
- // filename: "src/latest.js" } -> "No issues found."
149
- function isTestPath(relPath) {
150
- const base = path.basename(relPath);
151
- return /(?:^|[\/._-])(?:__tests__|tests?|specs?|testing)(?:[\/._-]|$)/i.test(relPath)
152
- || /[a-z0-9](?:Tests?|Specs?)\.\w+$/.test(base)
153
- || base.toLowerCase() === 'conftest.py';
154
- }
155
-
156
- function inspectFile(relPath, content, lines, ext) {
157
- const issues = [];
158
- const lower = content.toLowerCase();
159
- const base = path.basename(relPath);
160
- const isTest = isTestPath(relPath);
161
-
162
- // Environment files must never be committed
163
- if (base.startsWith('.env') && !ENV_TEMPLATE_RE.test(base)) {
164
- 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.' });
165
- }
166
-
167
- // Elided code
168
- if (!isTest && /(?:\/\/|#|\/\*)\s*(?:\.\.\.|…)?\s*(?:existing\s+code|rest\s+of\s+code)/i.test(content)) {
169
- lines.forEach((l, idx) => {
170
- if (/(?:\/\/|#|\/\*)\s*(?:\.\.\.|…)?\s*(?:existing\s+code|rest\s+of\s+code)/i.test(l)) {
171
- 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.' });
172
- }
173
- });
174
- }
175
-
176
- // Swallowed error
177
- if (!isTest && (ext === '.py' || ext === '.js' || ext === '.ts' || ext === '.jsx' || ext === '.tsx')) {
178
- lines.forEach((l, idx) => {
179
- const trim = l.trim();
180
- if (trim.startsWith('#') || trim.startsWith('//') || trim.startsWith('/*')) return;
181
- if (ext === '.py' && /^\s*except(?:\s+\w+)?:\s*(?:pass|\.\.\.)/.test(l)) {
182
- 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.' });
183
- } else if (ext !== '.py' && /catch\s*\([^)]*\)\s*\{\s*\}/.test(l)) {
184
- 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.' });
185
- }
186
- });
187
- }
188
-
189
- // API keys
190
- if (!isTest && (content.includes('sk-') || content.includes('ghp_') || content.includes('AKIA'))) {
191
- lines.forEach((l, idx) => {
192
- const trim = l.trim();
193
- if (trim.startsWith('#') || trim.startsWith('//')) return;
194
- if (/\b(sk-(?:live|test|proj)?[a-zA-Z0-9_-]{24,})\b/.test(l)) {
195
- 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.' });
196
- }
197
- if (/\b(ghp_[a-zA-Z0-9]{36})\b/.test(l)) {
198
- 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.' });
199
- }
200
- // Added in the MCP package: AWS access key IDs.
201
- if (/\b(AKIA[0-9A-Z]{16})\b/.test(l)) {
202
- 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.' });
203
- }
204
- });
205
- }
206
-
207
- // Hardcoded password or token assignment.
208
- // Added in the MCP package: the CLI declares leak/hardcoded-secret but has no match.
209
- if (!isTest && !ENV_TEMPLATE_RE.test(base) && /(?:password|passwd|pwd|secret|api[_-]?key|token)/i.test(content)) {
210
- lines.forEach((l, idx) => {
211
- const trim = l.trim();
212
- if (trim.startsWith('#') || trim.startsWith('//') || trim.startsWith('/*')) return;
213
- const match = /(?:password|passwd|pwd|secret|api[_-]?key|token)\w*\s*[:=]\s*['"]([^'"]{4,})['"]/i.exec(l);
214
- if (match && !match[1].includes('${') && !/^(?:sk-|ghp_|AKIA)/.test(match[1])) {
215
- 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.' });
216
- }
217
- });
218
- }
219
-
220
- // Clickable div
221
- if ((ext === '.html' || ext === '.jsx' || ext === '.tsx') && lower.includes('<div') && lower.includes('onclick')) {
222
- lines.forEach((l, idx) => {
223
- if (/<(?:div|span)\b(?![^>]*role=)[^>]*(?:onClick|onclick)=/i.test(l)) {
224
- 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.' });
225
- }
226
- });
227
- }
228
-
229
- // AI Default Gradient
230
- if ((ext === '.html' || ext === '.jsx' || ext === '.tsx' || ext === '.css') && (lower.includes('purple') || lower.includes('violet'))) {
231
- lines.forEach((l, idx) => {
232
- if (/\b(?:from|via|to)-(?:purple|violet|indigo|fuchsia)-\d{2,3}\b/.test(l)) {
233
- 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.' });
234
- }
235
- });
236
- }
237
-
238
- // Unreachable dead code
239
- for (const dead of findDeadCode(content, ext)) {
240
- issues.push({
241
- rule_id: 'clog/dead-code',
242
- title: 'Unreachable Dead Code',
243
- severity: 'HIGH',
244
- line: dead.line,
245
- evidence: dead.evidence.slice(0, 100),
246
- fix: dead.removable ? "Delete this unreachable line or run 'plumber clean <path> --write'." : 'Remove the constant-false branch and its body.'
247
- });
248
- }
249
-
250
- return issues;
251
- }
252
-
253
- function findDeadCode(content, ext) {
254
- if (!['.py', '.js', '.jsx', '.ts', '.tsx'].includes(ext)) return [];
255
- const lines = content.split('\n');
256
- const skip = new Set();
257
- markMultilineStrings(lines, skip, ext === '.py' ? ['"""', "'''"] : ['`']);
258
-
259
- const found = [];
260
- const indentOf = (line) => line.length - line.replace(/^[ \t]+/, '').length;
261
- const termRe = ext === '.py' ? /^(return|raise|break|continue)\b/ : /^(return|throw|break|continue)\b/;
262
- const unsafeTail = ['\\', '(', '[', '{', ',', ':', '+', '-', '*', '/', '%', '&', '|', '=', '?', '>'];
263
-
264
- for (let idx = 0; idx < lines.length; idx++) {
265
- const line = lines[idx];
266
- const clean = line.trim();
267
- if (skip.has(idx) || !clean || clean.startsWith('#') || clean.startsWith('//') || clean.startsWith('/*') || clean.startsWith('*')) continue;
268
-
269
- const isPy = ext === '.py';
270
- const deadBranch = isPy ? /^(?:if|while)\s+(?:False|0)\s*:/.test(clean) : /^(?:if|while)\s*\(\s*(?:false|0)\s*\)/.test(clean);
271
- if (deadBranch) {
272
- found.push({ line: idx + 1, reason: 'Dead branch: condition is always false', evidence: clean, removable: false });
273
- continue;
274
- }
275
-
276
- const terminator = termRe.exec(clean);
277
- if (!terminator || unsafeTail.some((t) => clean.endsWith(t))) continue;
278
-
279
- const target = findUnreachable(lines, idx, indentOf(line), skip, isPy);
280
- if (target !== null) {
281
- found.push({ line: target + 1, reason: `Unreachable statement after '${terminator[1]}'`, evidence: lines[target].trim(), removable: true });
282
- }
283
- }
284
- return found;
285
- }
286
-
287
- function markMultilineStrings(lines, skip, quotes) {
288
- let inside = null;
289
- for (let idx = 0; idx < lines.length; idx++) {
290
- if (inside !== null) skip.add(idx);
291
- for (const quote of quotes) {
292
- const count = lines[idx].split(quote).length - 1;
293
- if (count === 0) continue;
294
- if (inside === quote) {
295
- if (count % 2 === 1) inside = null;
296
- } else if (inside === null && count % 2 === 1) {
297
- inside = quote;
298
- }
299
- }
300
- }
301
- }
302
-
303
- function findUnreachable(lines, start, indent, skip, isPy) {
304
- const closers = ['else:', 'elif ', 'except', 'finally:', 'case ', 'default:'];
305
- for (let j = start + 1; j < lines.length; j++) {
306
- const raw = lines[j];
307
- const clean = raw.trim();
308
- if (skip.has(j) || !clean || clean.startsWith('#') || clean.startsWith('//') || clean.startsWith('/*') || clean.startsWith('*')) continue;
309
- if ((raw.length - raw.replace(/^[ \t]+/, '').length) < indent) return null;
310
- if (isPy ? closers.some((p) => clean.startsWith(p)) : /^(?:\}|else\b|catch\b|finally\b|case\b|default\b)/.test(clean)) return null;
311
- if (/^[\)\]\}\.,:?+\-*\/%&|]/.test(clean)) continue;
312
- return j;
313
- }
314
- return null;
315
- }
316
-
317
- module.exports = {
318
- RULES,
319
- SEV_WEIGHT,
320
- SCANNABLE_EXTS,
321
- scanLocalDirectory,
322
- scanText
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
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const yaml = require('js-yaml');
20
+
21
+ const ENV_TEMPLATE_RE = /\.(example|sample|template|dist)$/;
22
+
23
+ const RULES = [
24
+ { id: 'leak/api-key', cat: 'Leaks & Secrets', sev: 'CRITICAL', title: 'Leaked API Key or Private Access Token', fix: 'Move secret to environment variable (.env).' },
25
+ { id: 'leak/committed-env', cat: 'Leaks & Secrets', sev: 'CRITICAL', title: 'Production Secret Committed in Environment File', fix: 'Remove sensitive values and use .env.example.' },
26
+ { id: 'leak/hardcoded-secret', cat: 'Leaks & Secrets', sev: 'HIGH', title: 'Hardcoded Password or Database Credential', fix: 'Replace with process.env / os.environ reference.' },
27
+ { id: 'sec/raw-html', cat: 'Leaks & Secrets', sev: 'MEDIUM', title: 'Raw HTML Injection (XSS Risk)', fix: 'Use textContent or safe DOM templating.' },
28
+ { 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.' },
29
+ { 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.' },
30
+ { id: 'clog/stub-function', cat: 'Clogs & Dead Code', sev: 'MEDIUM', title: 'Unimplemented Stub Function', fix: 'Complete missing logic or remove placeholder.' },
31
+ { id: 'clog/step-comments', cat: 'Clogs & Dead Code', sev: 'LOW', title: 'Robotic AI Step Comments Left Behind', fix: 'Clean up AI instructional comments.' },
32
+ { id: 'clog/dead-code', cat: 'Clogs & Dead Code', sev: 'HIGH', title: 'Unreachable Dead Code', fix: 'Delete unreachable lines or run plumber clean --write.' },
33
+ { id: 'slop/pointless-wrapper', cat: 'Clogs & Dead Code', sev: 'LOW', title: 'Function That Only Forwards Its Arguments', fix: 'Call underlying function directly.' },
34
+ { id: 'joint/icon-button', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Icon Button Without Accessible Name', fix: 'Add aria-label attribute to button element.' },
35
+ { id: 'joint/clickable-div', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Non-Semantic Clickable Element (Missing Button / Role)', fix: 'Use native <button type="button">.' },
36
+ { id: 'joint/empty-test', cat: 'Joints & Accessibility', sev: 'HIGH', title: 'Empty Test with No Assertions', fix: 'Add assert statements or verification logic.' },
37
+ { id: 'joint/focus-removed', cat: 'Joints & Accessibility', sev: 'MEDIUM', title: 'Focus Outline Removed Without Replacement', fix: 'Provide a visible :focus-visible style.' },
38
+ { id: 'pipe/phantom-import', cat: 'Pipes & Dependencies', sev: 'HIGH', title: 'Plausible but Hallucinated Package Import', fix: 'Check package registry or use official package.' },
39
+ { 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.' },
40
+ { id: 'slop/ai-gradient', cat: 'AI Slop Tells', sev: 'MEDIUM', title: 'Purple / Violet AI Default Gradient', fix: 'Use clean brand tokens or neutral surfaces.' },
41
+ { id: 'slop/chatbot-residue', cat: 'AI Slop Tells', sev: 'MEDIUM', title: 'Pasted Chatbot Conversational Residue', fix: 'Delete conversational intro/outro boilerplate.' },
42
+ { id: 'slop/neon-glow', cat: 'AI Slop Tells', sev: 'LOW', title: 'Colored Neon Glow Shadow', fix: 'Replace with subtle crisp 1px borders.' },
43
+ { id: 'slop/gradient-text', cat: 'AI Slop Tells', sev: 'LOW', title: 'Gradient-Clipped Headline Text', fix: 'Use solid, high-contrast typography.' },
44
+ { id: 'ui/important-abuse', cat: 'AI Slop Tells', sev: 'LOW', title: '!important Overrides in Stylesheet', fix: 'Structure CSS cascade cleanly without !important.' },
45
+ // v1.1.0 structural GitHub Actions checks. Not the CLI text rules
46
+ // pipe/docker-unpinned, sec/curl-pipe-shell, sec/permissions-write-all,
47
+ // leak/slack-webhook, or pipe/npm-unpinned — those are not in this engine.
48
+ // sec/job-permissions does not flag a least-privilege contents: write on a
49
+ // job that pushes or releases.
50
+ { 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.' },
51
+ { 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.' },
52
+ { id: 'pipe/action-unpinned', cat: 'Pipes & Dependencies', sev: 'MEDIUM', title: 'Unpinned Third-Party Action', fix: 'Pin the action to a full commit SHA.' },
53
+ { id: 'sec/job-permissions', cat: 'Leaks & Secrets', sev: 'HIGH', title: 'Job Writes Without Least-Privilege Permissions', fix: 'Set least-privilege permissions on the job.' },
54
+ { 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.' }
55
+ ];
56
+
57
+ const SEV_WEIGHT = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
58
+
59
+ function gradeFor(score) {
60
+ return score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
61
+ }
62
+
63
+ function scoreFor(issueCount) {
64
+ return Math.max(0, 100 - (issueCount * 4.2));
65
+ }
66
+
67
+ const SCANNABLE_EXTS = ['.py', '.js', '.jsx', '.ts', '.tsx', '.html', '.htm', '.css', '.scss', '.env'];
68
+
69
+ function scanLocalDirectory(dirPath) {
70
+ const scannableExts = new Set(SCANNABLE_EXTS);
71
+ const skipDirs = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', 'vendor', '.venv', 'venv', '__pycache__']);
72
+
73
+ const issues = [];
74
+ let totalFiles = 0;
75
+ let totalLines = 0;
76
+ const fileReports = [];
77
+
78
+ function walk(current, onlyWorkflows) {
79
+ const stat = fs.statSync(current);
80
+ if (stat.isFile()) {
81
+ const ext = path.extname(current).toLowerCase();
82
+ const base = path.basename(current);
83
+ const relRaw = path.relative(dirPath, current).replace(/\\/g, '/') || base;
84
+ const rel = canonicalWorkflowPath(current, relRaw);
85
+ const workflowFile = isGitHubWorkflowPath(rel);
86
+ // Dot directories other than a specially opened .github stay skipped.
87
+ // Files under that .github walk are workflow YAML only, so a repo with
88
+ // no workflow files keeps the previous file set.
89
+ if (onlyWorkflows && !workflowFile) return;
90
+ if (scannableExts.has(ext) || base.startsWith('.env') || workflowFile) {
91
+ totalFiles++;
92
+ try {
93
+ const content = fs.readFileSync(current, 'utf-8');
94
+ const lines = content.split('\n');
95
+ totalLines += lines.length;
96
+ const fileIssues = inspectFile(rel, content, lines, ext, false);
97
+ if (fileIssues.length > 0) {
98
+ issues.push(...fileIssues);
99
+ fileReports.push({ path: rel, issues: fileIssues });
100
+ }
101
+ } catch (err) {
102
+ // Unreadable or locked file: skip it, matching the CLI's behavior.
103
+ void err;
104
+ }
105
+ }
106
+ return;
107
+ }
108
+
109
+ if (stat.isDirectory()) {
110
+ const base = path.basename(current);
111
+ // v1.0.1 fix: the skip list applies to subdirectories only, never to the
112
+ // directory the user asked to scan.
113
+ // Repro (v1.0.0): plumber_scan { target: "proj/dist" } or { target: "~/.dotfiles" }
114
+ // with an AKIA key inside -> "Files: 0 | Issues: 0 ... No issues found."
115
+ if (current !== dirPath && (skipDirs.has(base) || base.startsWith('.'))) {
116
+ if (base !== '.github' || onlyWorkflows) return;
117
+ const entries = fs.readdirSync(current);
118
+ for (const entry of entries) {
119
+ walk(path.join(current, entry), true);
120
+ }
121
+ return;
122
+ }
123
+ const entries = fs.readdirSync(current);
124
+ for (const entry of entries) {
125
+ walk(path.join(current, entry), onlyWorkflows);
126
+ }
127
+ }
128
+ }
129
+
130
+ walk(dirPath, false);
131
+
132
+ const score = scoreFor(issues.length);
133
+ const grade = gradeFor(score);
134
+
135
+ return {
136
+ repo_name: path.basename(dirPath),
137
+ total_files: totalFiles,
138
+ total_lines: totalLines,
139
+ score: Math.round(score * 10) / 10,
140
+ grade,
141
+ issues,
142
+ file_reports: fileReports
143
+ };
144
+ }
145
+
146
+ function scanText(text, filename) {
147
+ if (typeof text !== 'string') {
148
+ throw new TypeError('text must be a string');
149
+ }
150
+ const name = filename && typeof filename === 'string' ? filename.replace(/\\/g, '/') : 'snippet.js';
151
+ const ext = path.extname(name).toLowerCase();
152
+ const content = text.replace(/\r\n?/g, '\n');
153
+ const lines = content.split('\n');
154
+ const issues = inspectFile(name, content, lines, ext, true);
155
+ const score = scoreFor(issues.length);
156
+
157
+ return {
158
+ repo_name: name,
159
+ total_files: 1,
160
+ total_lines: lines.length,
161
+ score: Math.round(score * 10) / 10,
162
+ grade: gradeFor(score),
163
+ issues,
164
+ file_reports: issues.length > 0 ? [{ path: name, issues }] : []
165
+ };
166
+ }
167
+
168
+ // v1.0.1 fix: match "test"/"spec" as a path segment or filename part, not as a
169
+ // substring. The old substring check treated latest.js, inspector.js, special.js,
170
+ // contest/, respect.py ... as test files and skipped secret detection in them.
171
+ // Repro (v1.0.0): plumber_scan_text { text: 'const k = "AKIAIOSFODNN7EXAMPLE";',
172
+ // filename: "src/latest.js" } -> "No issues found."
173
+ function isTestPath(relPath) {
174
+ const base = path.basename(relPath);
175
+ return /(?:^|[\/._-])(?:__tests__|tests?|specs?|testing)(?:[\/._-]|$)/i.test(relPath)
176
+ || /[a-z0-9](?:Tests?|Specs?)\.\w+$/.test(base)
177
+ || base.toLowerCase() === 'conftest.py';
178
+ }
179
+
180
+ function inspectFile(relPath, content, lines, ext, detectWorkflowContent) {
181
+ const issues = [];
182
+ const lower = content.toLowerCase();
183
+ const base = path.basename(relPath);
184
+ const isTest = isTestPath(relPath);
185
+
186
+ // Environment files must never be committed
187
+ if (base.startsWith('.env') && !ENV_TEMPLATE_RE.test(base)) {
188
+ 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.' });
189
+ }
190
+
191
+ // Elided code
192
+ if (!isTest && /(?:\/\/|#|\/\*)\s*(?:\.\.\.|…)?\s*(?:existing\s+code|rest\s+of\s+code)/i.test(content)) {
193
+ lines.forEach((l, idx) => {
194
+ if (/(?:\/\/|#|\/\*)\s*(?:\.\.\.|…)?\s*(?:existing\s+code|rest\s+of\s+code)/i.test(l)) {
195
+ 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.' });
196
+ }
197
+ });
198
+ }
199
+
200
+ // Swallowed error
201
+ if (!isTest && (ext === '.py' || ext === '.js' || ext === '.ts' || ext === '.jsx' || ext === '.tsx')) {
202
+ lines.forEach((l, idx) => {
203
+ const trim = l.trim();
204
+ if (trim.startsWith('#') || trim.startsWith('//') || trim.startsWith('/*')) return;
205
+ if (ext === '.py' && /^\s*except(?:\s+(?:\(\s*)?(?:Exception|BaseException)(?:\s*\))?)?\s*:\s*(?:pass|\.\.\.)/.test(l)) {
206
+ 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.' });
207
+ } else if (ext !== '.py' && /catch\s*\([^)]*\)\s*\{\s*\}/.test(l)) {
208
+ 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.' });
209
+ }
210
+ });
211
+ }
212
+
213
+ // API keys
214
+ if (!isTest && (content.includes('sk-') || content.includes('ghp_') || content.includes('AKIA'))) {
215
+ lines.forEach((l, idx) => {
216
+ const trim = l.trim();
217
+ if (trim.startsWith('#') || trim.startsWith('//')) return;
218
+ if (trim.includes('@openssh') || trim.includes('sk-ssh-') || trim.includes('sk-ecdsa-')) return;
219
+ if (/\b(sk-(?:live|test|proj)?[a-zA-Z0-9_-]{24,})\b/.test(l)) {
220
+ 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.' });
221
+ }
222
+ if (/\b(ghp_[a-zA-Z0-9]{36})\b/.test(l)) {
223
+ 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.' });
224
+ }
225
+ // Added in the MCP package: AWS access key IDs.
226
+ if (/\b(AKIA[0-9A-Z]{16})\b/.test(l)) {
227
+ 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.' });
228
+ }
229
+ });
230
+ }
231
+
232
+ // Hardcoded password or token assignment.
233
+ // Added in the MCP package: the CLI declares leak/hardcoded-secret but has no match.
234
+ if (!isTest && !ENV_TEMPLATE_RE.test(base) && /(?:password|passwd|pwd|secret|api[_-]?key|token)/i.test(content)) {
235
+ lines.forEach((l, idx) => {
236
+ const trim = l.trim();
237
+ if (trim.startsWith('#') || trim.startsWith('//') || trim.startsWith('/*')) return;
238
+ const match = /(?:password|passwd|pwd|secret|api[_-]?key|token)\w*\s*[:=]\s*['"]([^'"]{4,})['"]/i.exec(l);
239
+ if (match && !match[1].includes('${') && !/^(?:sk-|ghp_|AKIA)/.test(match[1])) {
240
+ 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.' });
241
+ }
242
+ });
243
+ }
244
+
245
+ // Clickable div
246
+ if ((ext === '.html' || ext === '.jsx' || ext === '.tsx') && lower.includes('<div') && lower.includes('onclick')) {
247
+ lines.forEach((l, idx) => {
248
+ if (/<(?:div|span)\b(?![^>]*role=)[^>]*(?:onClick|onclick)=/i.test(l)) {
249
+ 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.' });
250
+ }
251
+ });
252
+ }
253
+
254
+ // AI Default Gradient
255
+ if ((ext === '.html' || ext === '.jsx' || ext === '.tsx' || ext === '.css') && (lower.includes('purple') || lower.includes('violet'))) {
256
+ lines.forEach((l, idx) => {
257
+ if (/\b(?:from|via|to)-(?:purple|violet|indigo|fuchsia)-\d{2,3}\b/.test(l)) {
258
+ 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.' });
259
+ }
260
+ });
261
+ }
262
+
263
+ // Unreachable dead code
264
+ for (const dead of findDeadCode(content, ext)) {
265
+ issues.push({
266
+ rule_id: 'clog/dead-code',
267
+ title: 'Unreachable Dead Code',
268
+ severity: 'HIGH',
269
+ line: dead.line,
270
+ evidence: dead.evidence.slice(0, 100),
271
+ fix: dead.removable ? "Delete this unreachable line or run 'plumber clean <path> --write'." : 'Remove the constant-false branch and its body.'
272
+ });
273
+ }
274
+
275
+ if (shouldInspectWorkflow(relPath, content, detectWorkflowContent)) {
276
+ issues.push(...inspectWorkflow(content, lines));
277
+ }
278
+
279
+ return issues;
280
+ }
281
+
282
+ function findDeadCode(content, ext) {
283
+ if (!['.py', '.js', '.jsx', '.ts', '.tsx'].includes(ext)) return [];
284
+ const lines = content.split('\n');
285
+ const skip = new Set();
286
+ markMultilineStrings(lines, skip, ext === '.py' ? ['"""', "'''"] : ['`']);
287
+
288
+ const found = [];
289
+ const indentOf = (line) => line.length - line.replace(/^[ \t]+/, '').length;
290
+ const termRe = ext === '.py' ? /^(return|raise|break|continue)\b/ : /^(return|throw|break|continue)\b/;
291
+ const unsafeTail = ['\\', '(', '[', '{', ',', ':', '+', '-', '*', '/', '%', '&', '|', '=', '?', '>'];
292
+
293
+ for (let idx = 0; idx < lines.length; idx++) {
294
+ const line = lines[idx];
295
+ const clean = line.trim();
296
+ if (skip.has(idx) || !clean || clean.startsWith('#') || clean.startsWith('//') || clean.startsWith('/*') || clean.startsWith('*')) continue;
297
+
298
+ const isPy = ext === '.py';
299
+ const deadBranch = isPy ? /^(?:if|while)\s+(?:False|0)\s*:/.test(clean) : /^(?:if|while)\s*\(\s*(?:false|0)\s*\)/.test(clean);
300
+ if (deadBranch) {
301
+ found.push({ line: idx + 1, reason: 'Dead branch: condition is always false', evidence: clean, removable: false });
302
+ continue;
303
+ }
304
+
305
+ const terminator = termRe.exec(clean);
306
+ if (!terminator || unsafeTail.some((t) => clean.endsWith(t))) continue;
307
+
308
+ const target = findUnreachable(lines, idx, indentOf(line), skip, isPy);
309
+ if (target !== null) {
310
+ found.push({ line: target + 1, reason: `Unreachable statement after '${terminator[1]}'`, evidence: lines[target].trim(), removable: true });
311
+ }
312
+ }
313
+ return found;
314
+ }
315
+
316
+ function markMultilineStrings(lines, skip, quotes) {
317
+ let inside = null;
318
+ for (let idx = 0; idx < lines.length; idx++) {
319
+ if (inside !== null) skip.add(idx);
320
+ for (const quote of quotes) {
321
+ const count = lines[idx].split(quote).length - 1;
322
+ if (count === 0) continue;
323
+ if (inside === quote) {
324
+ if (count % 2 === 1) inside = null;
325
+ } else if (inside === null && count % 2 === 1) {
326
+ inside = quote;
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ function findUnreachable(lines, start, indent, skip, isPy) {
333
+ const closers = ['else:', 'elif ', 'except', 'finally:', 'case ', 'default:'];
334
+ for (let j = start + 1; j < lines.length; j++) {
335
+ const raw = lines[j];
336
+ const clean = raw.trim();
337
+ if (skip.has(j) || !clean || clean.startsWith('#') || clean.startsWith('//') || clean.startsWith('/*') || clean.startsWith('*')) continue;
338
+ if ((raw.length - raw.replace(/^[ \t]+/, '').length) < indent) return null;
339
+ if (isPy ? closers.some((p) => clean.startsWith(p)) : /^(?:\}|else\b|catch\b|finally\b|case\b|default\b)/.test(clean)) return null;
340
+ if (/^[\)\]\}\.,:?+\-*\/%&|]/.test(clean)) continue;
341
+ return j;
342
+ }
343
+ return null;
344
+ }
345
+
346
+ const FULL_SHA = /^[0-9a-f]{40}$/i;
347
+ const FIRST_PARTY_OWNERS = new Set(['actions', 'github']);
348
+ const INJECTED_EXPR = /\$\{\{\s*(?:github\.event\.|github\.head_ref\b|inputs\.)/;
349
+ const SECRET_REF = /\$\{\{\s*secrets\.[A-Za-z0-9_]+\s*\}\}/;
350
+ const UNTRUSTED_CHECKOUT = /github\.event\.pull_request|github\.head_ref|refs\/pull\//;
351
+ const CONTENTS_WRITE_RUN = /\bgit\s+push\b|\bgh\s+release\b|\bgh\s+pr\s+merge\b/;
352
+ 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/;
353
+ const CONTENTS_ACTIONS = new Set([
354
+ 'softprops/action-gh-release',
355
+ 'peaceiris/actions-gh-pages',
356
+ 'ncipollo/release-action'
357
+ ]);
358
+
359
+ function isGitHubWorkflowPath(filePath) {
360
+ return /(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/i.test(String(filePath || '').replace(/\\/g, '/'));
361
+ }
362
+
363
+ function canonicalWorkflowPath(absPath, relPath) {
364
+ const rel = String(relPath || '').replace(/\\/g, '/');
365
+ if (isGitHubWorkflowPath(rel)) return rel;
366
+ const abs = String(absPath || '').replace(/\\/g, '/');
367
+ const marker = '.github/workflows/';
368
+ const idx = abs.toLowerCase().lastIndexOf(marker);
369
+ if (idx === -1) return rel;
370
+ const suffix = abs.slice(idx);
371
+ return isGitHubWorkflowPath(suffix) ? suffix : rel;
372
+ }
373
+
374
+ function looksLikeWorkflowSource(content) {
375
+ return /^(?:"on"|'on'|on)\s*:/m.test(content) && /^jobs\s*:/m.test(content);
376
+ }
377
+
378
+ function shouldInspectWorkflow(relPath, content, detectWorkflowContent) {
379
+ if (isGitHubWorkflowPath(relPath)) return true;
380
+ if (!detectWorkflowContent) return false;
381
+ const norm = String(relPath || '').replace(/\\/g, '/');
382
+ if (/(?:^|\/)workflows\/[^/]+\.ya?ml$/i.test(norm)) return true;
383
+ return looksLikeWorkflowSource(content);
384
+ }
385
+
386
+ function ruleById(id) {
387
+ for (let i = 0; i < RULES.length; i++) {
388
+ if (RULES[i].id === id) return RULES[i];
389
+ }
390
+ throw new Error('Unknown Plumber rule: ' + id);
391
+ }
392
+
393
+ function makeIssue(ruleId, line, evidence) {
394
+ const rule = ruleById(ruleId);
395
+ const text = evidence === undefined || evidence === null ? '' : String(evidence).trim();
396
+ return {
397
+ rule_id: rule.id,
398
+ title: rule.title,
399
+ severity: rule.sev,
400
+ line: line > 0 ? line : 1,
401
+ evidence: text.slice(0, 90),
402
+ fix: rule.fix
403
+ };
404
+ }
405
+
406
+ function loadWorkflow(content) {
407
+ let doc;
408
+ try {
409
+ // DEFAULT_SCHEMA resolves anchors, aliases, and merge keys. js-yaml 4
410
+ // speaks YAML 1.2, so the workflow key "on" stays a string.
411
+ doc = yaml.load(content, { schema: yaml.DEFAULT_SCHEMA });
412
+ } catch (err) {
413
+ if (!err || err.name !== 'YAMLException') throw err;
414
+ return null;
415
+ }
416
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null;
417
+ if (doc.on === undefined || doc.on === null) return null;
418
+ if (!doc.jobs || typeof doc.jobs !== 'object' || Array.isArray(doc.jobs)) return null;
419
+ return doc;
420
+ }
421
+
422
+ function inspectWorkflow(content, lines) {
423
+ const doc = loadWorkflow(content);
424
+ if (!doc) return [];
425
+ const found = [];
426
+ const seen = new Set();
427
+ function add(ruleId, line, evidence) {
428
+ const issue = makeIssue(ruleId, line, evidence);
429
+ const key = issue.rule_id + '\n' + issue.line + '\n' + issue.evidence;
430
+ if (seen.has(key)) return;
431
+ seen.add(key);
432
+ found.push(issue);
433
+ }
434
+ collectPullRequestTarget(doc, lines, add);
435
+ collectExpressionInjection(doc, lines, add);
436
+ collectUnpinnedActions(doc, lines, add);
437
+ collectJobPermissions(doc, lines, add);
438
+ collectSecrets(doc, lines, add);
439
+ return found;
440
+ }
441
+
442
+ function triggerNames(onValue) {
443
+ if (typeof onValue === 'string' || typeof onValue === 'number' || typeof onValue === 'boolean') {
444
+ return [String(onValue)];
445
+ }
446
+ if (Array.isArray(onValue)) {
447
+ const names = [];
448
+ for (let i = 0; i < onValue.length; i++) {
449
+ if (typeof onValue[i] === 'string') names.push(onValue[i]);
450
+ }
451
+ return names;
452
+ }
453
+ if (onValue && typeof onValue === 'object') return Object.keys(onValue);
454
+ return [];
455
+ }
456
+
457
+ function eachJob(doc, visit) {
458
+ const names = Object.keys(doc.jobs);
459
+ for (let i = 0; i < names.length; i++) {
460
+ const job = doc.jobs[names[i]];
461
+ if (!job || typeof job !== 'object' || Array.isArray(job)) continue;
462
+ visit(names[i], job);
463
+ }
464
+ }
465
+
466
+ function eachStep(job, visit) {
467
+ if (!Array.isArray(job.steps)) return;
468
+ for (let i = 0; i < job.steps.length; i++) {
469
+ const step = job.steps[i];
470
+ if (!step || typeof step !== 'object' || Array.isArray(step)) continue;
471
+ visit(step, i);
472
+ }
473
+ }
474
+
475
+ function scalarText(value) {
476
+ if (typeof value === 'string') return value;
477
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
478
+ return '';
479
+ }
480
+
481
+ function parseActionRef(uses) {
482
+ if (typeof uses !== 'string') return null;
483
+ const spec = uses.trim();
484
+ if (!spec || spec.startsWith('./') || spec.startsWith('docker://')) return null;
485
+ const at = spec.lastIndexOf('@');
486
+ const name = (at === -1 ? spec : spec.slice(0, at)).trim();
487
+ const ref = at === -1 ? '' : spec.slice(at + 1).trim();
488
+ if (!name.includes('/')) return null;
489
+ const owner = name.split('/')[0];
490
+ if (!owner) return null;
491
+ return {
492
+ spec: spec,
493
+ name: name,
494
+ owner: owner,
495
+ ref: ref,
496
+ thirdParty: !FIRST_PARTY_OWNERS.has(owner),
497
+ pinned: FULL_SHA.test(ref)
498
+ };
499
+ }
500
+
501
+ function permissionsGrantWrite(perms) {
502
+ if (typeof perms === 'string') return perms.trim().toLowerCase() === 'write-all';
503
+ if (!perms || typeof perms !== 'object' || Array.isArray(perms)) return false;
504
+ const keys = Object.keys(perms);
505
+ for (let i = 0; i < keys.length; i++) {
506
+ if (String(perms[keys[i]]).trim().toLowerCase() === 'write') return true;
507
+ }
508
+ return false;
509
+ }
510
+
511
+ function jobEffectivePerms(doc, job) {
512
+ if (job && job.permissions !== undefined && job.permissions !== null) return job.permissions;
513
+ if (doc.permissions !== undefined && doc.permissions !== null) return doc.permissions;
514
+ return undefined;
515
+ }
516
+
517
+ function valueHasSecret(value, depth) {
518
+ if (depth > 8 || value === null || value === undefined) return false;
519
+ if (typeof value === 'string') return SECRET_REF.test(value);
520
+ if (typeof value !== 'object') return false;
521
+ if (Array.isArray(value)) {
522
+ for (let i = 0; i < value.length; i++) {
523
+ if (valueHasSecret(value[i], depth + 1)) return true;
524
+ }
525
+ return false;
526
+ }
527
+ const keys = Object.keys(value);
528
+ for (let i = 0; i < keys.length; i++) {
529
+ if (valueHasSecret(value[keys[i]], depth + 1)) return true;
530
+ }
531
+ return false;
532
+ }
533
+
534
+ function collectPullRequestTarget(doc, lines, add) {
535
+ if (triggerNames(doc.on).indexOf('pull_request_target') === -1) return;
536
+ let cursor = 0;
537
+ eachJob(doc, function (name, job) {
538
+ eachStep(job, function (step) {
539
+ if (!checkoutRefIsUntrusted(step)) return;
540
+ const ref = scalarText(step.with && step.with.ref);
541
+ const snippet = ref || scalarText(step.uses);
542
+ const line = lineContainingFrom(lines, snippet.slice(0, 80), cursor);
543
+ cursor = line;
544
+ add('sec/pull-request-target', line, lines[line - 1] || snippet);
545
+ });
546
+ if (permissionsGrantWrite(jobEffectivePerms(doc, job))) {
547
+ const line = lineOfAppliedPermissions(lines, name, jobEffectivePerms(doc, job));
548
+ add('sec/pull-request-target', line, lines[line - 1] || 'permissions');
549
+ }
550
+ });
551
+ }
552
+
553
+ function checkoutRefIsUntrusted(step) {
554
+ if (typeof step.uses !== 'string' || !/^actions\/checkout(?:@|\/|\s|$)/.test(step.uses.trim())) return false;
555
+ const withBlock = step.with;
556
+ if (!withBlock || typeof withBlock !== 'object' || Array.isArray(withBlock)) return false;
557
+ const ref = scalarText(withBlock.ref);
558
+ const repository = scalarText(withBlock.repository);
559
+ if (ref && UNTRUSTED_CHECKOUT.test(ref)) return true;
560
+ if (repository && /github\.event\.pull_request\.head/.test(repository)) return true;
561
+ return false;
562
+ }
563
+
564
+ function collectExpressionInjection(doc, lines, add) {
565
+ let cursor = 0;
566
+ eachJob(doc, function (name, job) {
567
+ eachStep(job, function (step) {
568
+ const run = scalarText(step.run);
569
+ if (!run) return;
570
+ const match = INJECTED_EXPR.exec(run);
571
+ if (!match) return;
572
+ const located = lineOfInjectedRun(lines, match[0], cursor);
573
+ cursor = located.next;
574
+ add('sec/expression-injection', located.line, lines[located.line - 1] || run);
575
+ });
576
+ });
577
+ }
578
+
579
+ function collectUnpinnedActions(doc, lines, add) {
580
+ let cursor = 0;
581
+ eachJob(doc, function (name, job) {
582
+ cursor = flagUnpinned(lines, job.uses, cursor, add);
583
+ eachStep(job, function (step) {
584
+ cursor = flagUnpinned(lines, step.uses, cursor, add);
585
+ });
586
+ });
587
+ }
588
+
589
+ function flagUnpinned(lines, uses, cursor, add) {
590
+ const action = parseActionRef(uses);
591
+ if (!action || !action.thirdParty || action.pinned) return cursor;
592
+ const line = lineOfUses(lines, action.spec, cursor);
593
+ add('pipe/action-unpinned', line, lines[line - 1] || action.spec);
594
+ return line;
595
+ }
596
+
597
+ function collectJobPermissions(doc, lines, add) {
598
+ eachJob(doc, function (name, job) {
599
+ const kinds = jobWriteKinds(job);
600
+ if (!kinds.contents && !kinds.deploy) return;
601
+ const hasJobPerms = job.permissions !== undefined && job.permissions !== null;
602
+ const hasWorkflowPerms = doc.permissions !== undefined && doc.permissions !== null;
603
+ const perms = hasJobPerms ? job.permissions : (hasWorkflowPerms ? doc.permissions : undefined);
604
+ const info = describePermissions(perms);
605
+ let reason = '';
606
+ if (!info) reason = 'missing';
607
+ else if (info.writeAll) reason = 'write-all';
608
+ else if (info.contentsWrite && kinds.deploy && !kinds.contents) reason = 'contents-write';
609
+ else return;
610
+ const line = lineForJobPermission(lines, name, reason);
611
+ add('sec/job-permissions', line, lines[line - 1] || name);
612
+ });
613
+ }
614
+
615
+ function jobWriteKinds(job) {
616
+ const kinds = { contents: false, deploy: false };
617
+ eachStep(job, function (step) {
618
+ const run = scalarText(step.run);
619
+ if (CONTENTS_WRITE_RUN.test(run)) kinds.contents = true;
620
+ if (DEPLOY_RUN.test(run)) kinds.deploy = true;
621
+ const action = parseActionRef(step.uses);
622
+ if (!action) return;
623
+ if (CONTENTS_ACTIONS.has(action.name)) kinds.contents = true;
624
+ if (action.name === 'actions/deploy-pages') kinds.deploy = true;
625
+ if (action.name === 'docker/build-push-action') {
626
+ const push = step.with && step.with.push;
627
+ if (push === true || String(push).toLowerCase() === 'true') kinds.deploy = true;
628
+ }
629
+ });
630
+ return kinds;
631
+ }
632
+
633
+ function describePermissions(perms) {
634
+ if (perms === undefined || perms === null) return null;
635
+ if (typeof perms === 'string') {
636
+ const value = perms.trim().toLowerCase();
637
+ return { writeAll: value === 'write-all', contentsWrite: value === 'write-all' };
638
+ }
639
+ if (typeof perms !== 'object' || Array.isArray(perms)) return null;
640
+ const contents = perms.contents === undefined || perms.contents === null ? '' : String(perms.contents).trim().toLowerCase();
641
+ return { writeAll: false, contentsWrite: contents === 'write' };
642
+ }
643
+
644
+ function collectSecrets(doc, lines, add) {
645
+ let cursor = 0;
646
+ eachJob(doc, function (name, job) {
647
+ const inherited = valueHasSecret(doc.env, 0) || valueHasSecret(job.env, 0);
648
+ const jobAction = parseActionRef(job.uses);
649
+ if (jobAction && jobAction.thirdParty && (job.secrets === 'inherit' || valueHasSecret(job.secrets, 0) || inherited)) {
650
+ const line = lineOfUses(lines, jobAction.spec, cursor);
651
+ cursor = line;
652
+ add('sec/secret-to-untrusted', line, lines[line - 1] || jobAction.spec);
653
+ }
654
+ eachStep(job, function (step) {
655
+ const action = parseActionRef(step.uses);
656
+ if (!action || !action.thirdParty) return;
657
+ const onStep = valueHasSecret(step.env, 0) || valueHasSecret(step.with, 0);
658
+ if (!onStep && !inherited) return;
659
+ const line = onStep
660
+ ? lineContainingFrom(lines, 'secrets.', cursor)
661
+ : lineOfUses(lines, action.spec, cursor);
662
+ cursor = line;
663
+ add('sec/secret-to-untrusted', line, lines[line - 1] || action.spec);
664
+ });
665
+ });
666
+ }
667
+
668
+ function lineContainingFrom(lines, snippet, start) {
669
+ const needle = String(snippet);
670
+ const from = start > 0 ? start : 0;
671
+ for (let i = from; i < lines.length; i++) {
672
+ if (lines[i].includes(needle)) return i + 1;
673
+ }
674
+ for (let i = 0; i < from; i++) {
675
+ if (lines[i].includes(needle)) return i + 1;
676
+ }
677
+ return 1;
678
+ }
679
+
680
+ function lineOfUses(lines, spec, cursor) {
681
+ const from = cursor > 0 ? cursor : 0;
682
+ for (let i = from; i < lines.length; i++) {
683
+ if (lines[i].includes(spec) && /uses\s*:/.test(lines[i])) return i + 1;
684
+ }
685
+ return lineContainingFrom(lines, spec, 0);
686
+ }
687
+
688
+ function lineOfInjectedRun(lines, snippet, cursor) {
689
+ const runKey = /^\s*(?:-\s+)?run\s*:/;
690
+ const start = cursor > 0 ? cursor : 0;
691
+ for (let i = start; i < lines.length; i++) {
692
+ if (!runKey.test(lines[i])) continue;
693
+ const indent = lines[i].search(/\S/);
694
+ if (lines[i].includes(snippet)) return { line: i + 1, next: i + 1 };
695
+ let found = 0;
696
+ let end = i + 1;
697
+ for (let j = i + 1; j < lines.length; j++) {
698
+ if (!lines[j].trim()) continue;
699
+ if (lines[j].search(/\S/) <= indent) break;
700
+ end = j + 1;
701
+ if (!found && lines[j].includes(snippet)) found = j + 1;
702
+ }
703
+ if (found) return { line: found, next: end };
704
+ if (/\*[A-Za-z0-9_-]+/.test(lines[i])) {
705
+ return { line: lineContainingFrom(lines, snippet, 0), next: i + 1 };
706
+ }
707
+ }
708
+ return { line: lineContainingFrom(lines, snippet, 0), next: lines.length };
709
+ }
710
+
711
+ function findJobLine(lines, jobName) {
712
+ const pattern = new RegExp('^ ' + escapeRegExp(jobName) + '\\s*:');
713
+ let seenJobs = false;
714
+ for (let i = 0; i < lines.length; i++) {
715
+ if (/^jobs\s*:/.test(lines[i])) {
716
+ seenJobs = true;
717
+ continue;
718
+ }
719
+ if (seenJobs && pattern.test(lines[i])) return i + 1;
720
+ }
721
+ return 1;
722
+ }
723
+
724
+ function nextJobBoundary(lines, jobLine) {
725
+ for (let i = jobLine; i < lines.length; i++) {
726
+ if (/^ [A-Za-z0-9_-]+\s*:/.test(lines[i])) return i;
727
+ }
728
+ return lines.length;
729
+ }
730
+
731
+ function findLineMatching(lines, pattern, start, end) {
732
+ const from = Math.max(0, start);
733
+ const to = Math.min(lines.length, end);
734
+ for (let i = from; i < to; i++) {
735
+ if (pattern.test(lines[i])) return i + 1;
736
+ }
737
+ return 0;
738
+ }
739
+
740
+ function lineOfAppliedPermissions(lines, jobName, perms) {
741
+ const jobAt = findJobLine(lines, jobName);
742
+ const regionEnd = nextJobBoundary(lines, jobAt);
743
+ if (typeof perms === 'string' && perms.trim().toLowerCase() === 'write-all') {
744
+ return findLineMatching(lines, /permissions\s*:\s*['"]?write-all['"]?/, jobAt - 1, regionEnd)
745
+ || findLineMatching(lines, /permissions\s*:\s*['"]?write-all['"]?/, 0, lines.length)
746
+ || jobAt;
747
+ }
748
+ return findLineMatching(lines, /^\s*.+:\s*['"]?write['"]?\s*(?:#.*)?$/, jobAt - 1, regionEnd)
749
+ || findLineMatching(lines, /^\s*permissions\s*:/, jobAt - 1, regionEnd)
750
+ || findLineMatching(lines, /^permissions\s*:/, 0, lines.length)
751
+ || jobAt;
752
+ }
753
+
754
+ function lineForJobPermission(lines, jobName, reason) {
755
+ const jobAt = findJobLine(lines, jobName);
756
+ const regionEnd = nextJobBoundary(lines, jobAt);
757
+ if (reason === 'write-all') {
758
+ return findLineMatching(lines, /permissions\s*:\s*['"]?write-all['"]?/, 0, lines.length) || jobAt;
759
+ }
760
+ if (reason === 'contents-write') {
761
+ return findLineMatching(lines, /contents\s*:\s*['"]?write['"]?/, jobAt - 1, regionEnd)
762
+ || findLineMatching(lines, /contents\s*:\s*['"]?write['"]?/, 0, lines.length)
763
+ || jobAt;
764
+ }
765
+ return findLineMatching(
766
+ lines,
767
+ /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/,
768
+ jobAt - 1,
769
+ regionEnd
770
+ ) || jobAt;
771
+ }
772
+
773
+ function escapeRegExp(value) {
774
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
775
+ }
776
+
777
+ module.exports = {
778
+ RULES,
779
+ SEV_WEIGHT,
780
+ SCANNABLE_EXTS,
781
+ scanLocalDirectory,
782
+ scanText
783
+ };