@parasraju/plumber-mcp 1.1.0 → 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 +75 -6
- package/bin/plumber-mcp.js +98 -12
- package/examples/custom-rules.example.json +20 -0
- package/package.json +6 -3
- package/src/engine.js +317 -5
package/README.md
CHANGED
|
@@ -83,15 +83,15 @@ Add to `.vscode/mcp.json`:
|
|
|
83
83
|
|
|
84
84
|
| Tool | Description |
|
|
85
85
|
| --- | --- |
|
|
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 26 inspection rules with id, severity, title, and fix. |
|
|
88
|
-
| `plumber_scan_text` | Scan a pasted code or config snippet. `filename` is optional and only selects the applicable rules. |
|
|
86
|
+
| `plumber_scan` | Scan a local directory or file. Returns a score, grade, file count, and one line per issue with its fix. Optional `rulesFile`, or `.plumber-rules.json` in the target, adds a rule pack. |
|
|
87
|
+
| `plumber_list_rules` | List all 26 inspection rules with id, severity, title, and fix. Optional `rulesFile` appends pack rules marked `source: pack`. |
|
|
88
|
+
| `plumber_scan_text` | Scan a pasted code or config snippet. `filename` is optional and only selects the applicable rules. Optional `rulesFile` adds a rule pack. |
|
|
89
89
|
|
|
90
90
|
Tool inputs are deliberately small so they cost few tokens:
|
|
91
91
|
|
|
92
|
-
- `plumber_scan`: `target` (string, default `"."`)
|
|
93
|
-
- `plumber_list_rules`:
|
|
94
|
-
- `plumber_scan_text`: `text` (string, required), `filename` (string, optional)
|
|
92
|
+
- `plumber_scan`: `target` (string, default `"."`), `rulesFile` (string, optional)
|
|
93
|
+
- `plumber_list_rules`: `rulesFile` (string, optional)
|
|
94
|
+
- `plumber_scan_text`: `text` (string, required), `filename` (string, optional), `rulesFile` (string, optional)
|
|
95
95
|
|
|
96
96
|
## Example session
|
|
97
97
|
|
|
@@ -125,6 +125,72 @@ Tool inputs are deliberately small so they cost few tokens:
|
|
|
125
125
|
- `sec/job-permissions` [HIGH] Job Writes Without Least-Privilege Permissions (Set least-privilege permissions on the job.)
|
|
126
126
|
- `sec/secret-to-untrusted` [HIGH] Secret Passed to Untrusted Step (Scope the secret to the trusted step that needs it.)
|
|
127
127
|
|
|
128
|
+
### Custom rule packs — bring your own rules
|
|
129
|
+
|
|
130
|
+
Bring rule packs when you need custom rules the built-in set does not ship: an internal secret format, a banned pattern, or a house naming rule. A pack is one local JSON file. This release does not read YAML packs, and it does not download a pack from a URL.
|
|
131
|
+
|
|
132
|
+
The file is a top-level object `{ "rules": [...] }`. Each rule uses these fields:
|
|
133
|
+
|
|
134
|
+
- `id` (string, required) — must start with `custom/`, for example `custom/no-internal-api-key`. Any other id is rejected. An id that matches a built-in rule, or another rule in the same pack, is rejected. The built-in rule is left unchanged.
|
|
135
|
+
- `severity` (string, required) — one of `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`. Anything else is rejected.
|
|
136
|
+
- `title` (string, required) — short name, for example `Internal API key format`.
|
|
137
|
+
- `pattern` (string, required) — a JavaScript regular expression, matched against the file text. A pattern that does not compile rejects that rule.
|
|
138
|
+
- `files` (array of glob strings, optional) — for example `["**/*.js", "**/*.ts"]`. The rule runs only on files that match one of these globs. Omit it to use every file Plumber already scans. Globs support `*`, `?`, and `**`. `*` does not cross a `/`; use `**/*.js` to match JavaScript in any directory.
|
|
139
|
+
- `fix` (string, required) — the plain-English fix, in the same voice as the built-in rules. A rule with no fix is rejected.
|
|
140
|
+
|
|
141
|
+
On `plumber_scan`, globs only filter files the scanner already opens: `.py`, `.js`, `.jsx`, `.ts`, `.tsx`, `.html`, `.htm`, `.css`, `.scss`, `.env*`, and GitHub Actions workflow YAML. `plumber_scan_text` applies the same globs to the `filename` you pass.
|
|
142
|
+
|
|
143
|
+
A bad rule is dropped. The tool result includes a `Pack error:` line that names the rule and the problem (invalid JSON names the pack file, because there is no rule yet). The 26 built-in rules still run. A broken pack does not wipe the report and does not stop the server.
|
|
144
|
+
|
|
145
|
+
One pack is loaded, in this order:
|
|
146
|
+
|
|
147
|
+
1. `rulesFile`, when the tool call includes it. A relative path is resolved from the server's working directory.
|
|
148
|
+
2. Auto-load, only for `plumber_scan`, and only when `rulesFile` is omitted. Plumber reads `.plumber-rules.json` in the directory passed as `target`. A file target uses the directory that contains that file. Parent folders and subfolders are not searched. `plumber_scan_text` and `plumber_list_rules` do not auto-load.
|
|
149
|
+
3. When both are present, `rulesFile` is the pack that runs. The two files are not merged.
|
|
150
|
+
|
|
151
|
+
The scan result names the pack on one line: the file path and how many pack rules loaded, for example `Pack: /repo/.plumber-rules.json (2 pack rules loaded)`.
|
|
152
|
+
|
|
153
|
+
`plumber_list_rules` with no `rulesFile` returns the 26 built-in rules. With `rulesFile`, the pack rules follow the built-ins and each one ends with `source: pack`.
|
|
154
|
+
|
|
155
|
+
Save this file as `.plumber-rules.json` in the directory you scan, or pass the path as `rulesFile`. The same example ships at `examples/custom-rules.example.json`.
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"rules": [
|
|
160
|
+
{
|
|
161
|
+
"id": "custom/no-internal-api-key",
|
|
162
|
+
"severity": "CRITICAL",
|
|
163
|
+
"title": "Internal API key format",
|
|
164
|
+
"pattern": "\\bINT_[A-Z0-9]{16}\\b",
|
|
165
|
+
"files": ["**/*.js", "**/*.ts"],
|
|
166
|
+
"fix": "Move the internal key to an environment variable and rotate it."
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"id": "custom/no-banned-marker",
|
|
170
|
+
"severity": "MEDIUM",
|
|
171
|
+
"title": "Banned ship marker",
|
|
172
|
+
"pattern": "\\bDO_NOT_SHIP\\b",
|
|
173
|
+
"files": ["**/*.js", "**/*.py"],
|
|
174
|
+
"fix": "Remove the banned marker before committing this file."
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Three ways to run that pack:
|
|
181
|
+
|
|
182
|
+
1. Auto-load: write it to `.plumber-rules.json`, then call `plumber_scan` with `{ "target": "." }`.
|
|
183
|
+
2. `rulesFile` on `plumber_scan`: `{ "target": ".", "rulesFile": "examples/custom-rules.example.json" }`. This is the pack that runs when `.plumber-rules.json` is also present.
|
|
184
|
+
3. `rulesFile` on `plumber_scan_text`: `{ "text": "const internal = \"INT_ABCDEFGHIJ123456\";\n", "filename": "src/config.js", "rulesFile": "examples/custom-rules.example.json" }`. A snippet that does not match the pattern produces no pack finding.
|
|
185
|
+
|
|
186
|
+
`src/config.js` containing `INT_ABCDEFGHIJ123456` then reports:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
- [CRITICAL] Internal API key format (Move the internal key to an environment variable and rotate it.) — src/config.js:1
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The secret-format rule above does not check Python files. The banned-marker rule does, because its `files` list says so.
|
|
193
|
+
|
|
128
194
|
## Links
|
|
129
195
|
|
|
130
196
|
- CLI: [plumber-linter](https://www.npmjs.com/package/plumber-linter)
|
|
@@ -134,6 +200,9 @@ Tool inputs are deliberately small so they cost few tokens:
|
|
|
134
200
|
|
|
135
201
|
## Changelog
|
|
136
202
|
|
|
203
|
+
## 1.2.0
|
|
204
|
+
- Custom rule packs: load your own rules from `.plumber-rules.json` or a `rulesFile`, ids under `custom/`.
|
|
205
|
+
|
|
137
206
|
## 1.1.0
|
|
138
207
|
- Workflow YAML rules: sec/pull-request-target, sec/expression-injection, pipe/action-unpinned, sec/job-permissions, sec/secret-to-untrusted.
|
|
139
208
|
|
package/bin/plumber-mcp.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* plumber_list_rules list every inspection rule
|
|
9
9
|
* plumber_scan_text scan a pasted snippet
|
|
10
10
|
*
|
|
11
|
+
* Each tool accepts an optional rulesFile path to one local JSON rule pack.
|
|
12
|
+
* plumber_scan also reads .plumber-rules.json in the target when rulesFile is omitted.
|
|
13
|
+
* Packs are never fetched from a URL.
|
|
14
|
+
*
|
|
11
15
|
* Only JSON-RPC goes to stdout. Diagnostics go to stderr.
|
|
12
16
|
*/
|
|
13
17
|
|
|
@@ -21,7 +25,7 @@ const {
|
|
|
21
25
|
ErrorCode,
|
|
22
26
|
McpError
|
|
23
27
|
} = require('@modelcontextprotocol/sdk/types.js');
|
|
24
|
-
const { RULES, SCANNABLE_EXTS, scanLocalDirectory, scanText } = require('../src/engine.js');
|
|
28
|
+
const { RULES, SCANNABLE_EXTS, scanLocalDirectory, scanText, loadRulePack, resolveScanPack } = require('../src/engine.js');
|
|
25
29
|
|
|
26
30
|
// v1.0.1 fix: read the version from package.json so serverInfo never drifts.
|
|
27
31
|
// Repro (v1.0.0): initialize -> serverInfo.version was hardcoded to "1.0.0".
|
|
@@ -34,7 +38,8 @@ const TOOLS = [
|
|
|
34
38
|
inputSchema: {
|
|
35
39
|
type: 'object',
|
|
36
40
|
properties: {
|
|
37
|
-
target: { type: 'string', description: 'Directory or file path to scan (default ".")' }
|
|
41
|
+
target: { type: 'string', description: 'Directory or file path to scan (default ".")' },
|
|
42
|
+
rulesFile: { type: 'string', description: 'Optional path to a local JSON rule pack. When omitted, .plumber-rules.json is loaded from the scan target if that file exists.' }
|
|
38
43
|
},
|
|
39
44
|
additionalProperties: false
|
|
40
45
|
}
|
|
@@ -44,7 +49,9 @@ const TOOLS = [
|
|
|
44
49
|
description: 'List all Plumber inspection rules with severity, title, and recommended fix.',
|
|
45
50
|
inputSchema: {
|
|
46
51
|
type: 'object',
|
|
47
|
-
properties: {
|
|
52
|
+
properties: {
|
|
53
|
+
rulesFile: { type: 'string', description: 'Optional path to a local JSON rule pack. Pack rules are listed after the built-in rules and marked as custom.' }
|
|
54
|
+
},
|
|
48
55
|
additionalProperties: false
|
|
49
56
|
}
|
|
50
57
|
},
|
|
@@ -55,7 +62,8 @@ const TOOLS = [
|
|
|
55
62
|
type: 'object',
|
|
56
63
|
properties: {
|
|
57
64
|
text: { type: 'string', description: 'Code or config snippet to scan' },
|
|
58
|
-
filename: { type: 'string', description: 'Optional filename used to select applicable rules (e.g. app.js)' }
|
|
65
|
+
filename: { type: 'string', description: 'Optional filename used to select applicable rules (e.g. app.js)' },
|
|
66
|
+
rulesFile: { type: 'string', description: 'Optional path to a local JSON rule pack applied to this snippet. Pasted snippets do not auto-load a pack.' }
|
|
59
67
|
},
|
|
60
68
|
required: ['text'],
|
|
61
69
|
additionalProperties: false
|
|
@@ -63,22 +71,47 @@ const TOOLS = [
|
|
|
63
71
|
}
|
|
64
72
|
];
|
|
65
73
|
|
|
74
|
+
function packLoadedLine(pack) {
|
|
75
|
+
const count = Array.isArray(pack.rules) ? pack.rules.length : 0;
|
|
76
|
+
const label = count === 1 ? 'pack rule' : 'pack rules';
|
|
77
|
+
return `Pack: ${pack.path} (${count} ${label} loaded)`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatPackBanner(pack) {
|
|
81
|
+
if (!pack) return '';
|
|
82
|
+
const lines = [packLoadedLine(pack)];
|
|
83
|
+
const errors = Array.isArray(pack.errors) ? pack.errors : [];
|
|
84
|
+
for (let i = 0; i < errors.length; i++) {
|
|
85
|
+
lines.push(`Pack error: ${errors[i]}`);
|
|
86
|
+
}
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatListedRule(rule) {
|
|
91
|
+
const severity = rule.sev || rule.severity;
|
|
92
|
+
const line = `${rule.id} [${severity}] ${rule.title} (${rule.fix})`;
|
|
93
|
+
if (rule.source === 'pack') return `${line} source: pack`;
|
|
94
|
+
return line;
|
|
95
|
+
}
|
|
96
|
+
|
|
66
97
|
function formatReport(report) {
|
|
67
98
|
const header = [
|
|
68
99
|
`Plumber Health Report for ${report.repo_name}`,
|
|
69
100
|
report.total_files === 0 ? 'Score: n/a' : `Score: ${report.score}/100 (Grade ${report.grade})`,
|
|
70
101
|
`Files: ${report.total_files} | Issues: ${report.issues.length}`
|
|
71
102
|
].join('\n');
|
|
103
|
+
const banner = formatPackBanner(report.pack);
|
|
104
|
+
const top = banner ? `${header}\n${banner}` : header;
|
|
72
105
|
|
|
73
106
|
// v1.0.1 fix: do not report "No issues found." when nothing was scanned.
|
|
74
107
|
// Repro (v1.0.0): plumber_scan { target: ".github/workflows/ci.yml" } (or a
|
|
75
108
|
// Dockerfile, or an empty dir) -> "Score: 100/100 (Grade A) ... No issues found."
|
|
76
109
|
if (report.total_files === 0) {
|
|
77
|
-
return `${
|
|
110
|
+
return `${top}\n\nNo scannable files found, so nothing was checked. Supported: ${SCANNABLE_EXTS.join(' ')} and .env* files.`;
|
|
78
111
|
}
|
|
79
112
|
|
|
80
113
|
if (report.issues.length === 0) {
|
|
81
|
-
return `${
|
|
114
|
+
return `${top}\n\nNo issues found.`;
|
|
82
115
|
}
|
|
83
116
|
|
|
84
117
|
const locations = new Map();
|
|
@@ -94,7 +127,7 @@ function formatReport(report) {
|
|
|
94
127
|
return `- [${issue.severity}] ${issue.title} (${issue.fix})${suffix}`;
|
|
95
128
|
});
|
|
96
129
|
|
|
97
|
-
return `${
|
|
130
|
+
return `${top}\n\n${lines.join('\n')}`;
|
|
98
131
|
}
|
|
99
132
|
|
|
100
133
|
function requireString(value, name) {
|
|
@@ -114,15 +147,29 @@ function requireText(text) {
|
|
|
114
147
|
|
|
115
148
|
function runScanTool(args) {
|
|
116
149
|
const target = requireString(args.target, 'target') || '.';
|
|
150
|
+
const rulesFile = requireString(args.rulesFile, 'rulesFile');
|
|
117
151
|
const absolute = path.resolve(target);
|
|
118
152
|
|
|
119
153
|
if (!fs.existsSync(absolute)) {
|
|
120
154
|
throw new McpError(ErrorCode.InvalidParams, `Target path does not exist: ${absolute}`);
|
|
121
155
|
}
|
|
122
156
|
|
|
157
|
+
let pack = null;
|
|
123
158
|
try {
|
|
124
|
-
|
|
159
|
+
pack = resolveScanPack(absolute, rulesFile);
|
|
125
160
|
} catch (err) {
|
|
161
|
+
pack = {
|
|
162
|
+
path: rulesFile || absolute,
|
|
163
|
+
rules: [],
|
|
164
|
+
errors: [`Pack failed to load: ${err.message}`],
|
|
165
|
+
broken: true
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
return formatReport(scanLocalDirectory(absolute, pack ? { pack } : undefined));
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (err instanceof McpError) throw err;
|
|
126
173
|
throw new McpError(ErrorCode.InternalError, `Scan failed: ${err.message}`);
|
|
127
174
|
}
|
|
128
175
|
}
|
|
@@ -130,16 +177,55 @@ function runScanTool(args) {
|
|
|
130
177
|
function runScanTextTool(args) {
|
|
131
178
|
const text = requireText(args.text);
|
|
132
179
|
const filename = requireString(args.filename, 'filename');
|
|
180
|
+
const rulesFile = requireString(args.rulesFile, 'rulesFile');
|
|
181
|
+
let pack = null;
|
|
182
|
+
if (rulesFile) {
|
|
183
|
+
try {
|
|
184
|
+
pack = loadRulePack(rulesFile);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
pack = {
|
|
187
|
+
path: rulesFile,
|
|
188
|
+
rules: [],
|
|
189
|
+
errors: [`Pack ${rulesFile} failed to load: ${err.message}`],
|
|
190
|
+
broken: true
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
133
194
|
|
|
134
195
|
try {
|
|
135
|
-
return formatReport(scanText(text, filename));
|
|
196
|
+
return formatReport(scanText(text, filename, pack ? { pack } : undefined));
|
|
136
197
|
} catch (err) {
|
|
198
|
+
if (err instanceof McpError) throw err;
|
|
137
199
|
throw new McpError(ErrorCode.InternalError, `Scan failed: ${err.message}`);
|
|
138
200
|
}
|
|
139
201
|
}
|
|
140
202
|
|
|
141
|
-
function runListRulesTool() {
|
|
142
|
-
|
|
203
|
+
function runListRulesTool(args) {
|
|
204
|
+
const rulesFile = requireString(args && args.rulesFile, 'rulesFile');
|
|
205
|
+
let pack = null;
|
|
206
|
+
if (rulesFile) {
|
|
207
|
+
try {
|
|
208
|
+
pack = loadRulePack(rulesFile);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
pack = {
|
|
211
|
+
path: rulesFile,
|
|
212
|
+
rules: [],
|
|
213
|
+
errors: [`Pack ${rulesFile} failed to load: ${err.message}`],
|
|
214
|
+
broken: true
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const lines = RULES.map((rule) => formatListedRule(rule));
|
|
219
|
+
if (!pack) return lines.join('\n');
|
|
220
|
+
for (let i = 0; i < pack.rules.length; i++) {
|
|
221
|
+
lines.push(formatListedRule(pack.rules[i]));
|
|
222
|
+
}
|
|
223
|
+
lines.push(packLoadedLine(pack));
|
|
224
|
+
const errors = pack.errors || [];
|
|
225
|
+
for (let i = 0; i < errors.length; i++) {
|
|
226
|
+
lines.push(`Pack error: ${errors[i]}`);
|
|
227
|
+
}
|
|
228
|
+
return lines.join('\n');
|
|
143
229
|
}
|
|
144
230
|
|
|
145
231
|
async function main() {
|
|
@@ -155,7 +241,7 @@ async function main() {
|
|
|
155
241
|
if (name === 'plumber_scan') {
|
|
156
242
|
text = runScanTool(args);
|
|
157
243
|
} else if (name === 'plumber_list_rules') {
|
|
158
|
-
text = runListRulesTool();
|
|
244
|
+
text = runListRulesTool(args);
|
|
159
245
|
} else if (name === 'plumber_scan_text') {
|
|
160
246
|
text = runScanTextTool(args);
|
|
161
247
|
} else {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"rules": [
|
|
3
|
+
{
|
|
4
|
+
"id": "custom/no-internal-api-key",
|
|
5
|
+
"severity": "CRITICAL",
|
|
6
|
+
"title": "Internal API key format",
|
|
7
|
+
"pattern": "\\bINT_[A-Z0-9]{16}\\b",
|
|
8
|
+
"files": ["**/*.js", "**/*.ts"],
|
|
9
|
+
"fix": "Move the internal key to an environment variable and rotate it."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "custom/no-banned-marker",
|
|
13
|
+
"severity": "MEDIUM",
|
|
14
|
+
"title": "Banned ship marker",
|
|
15
|
+
"pattern": "\\bDO_NOT_SHIP\\b",
|
|
16
|
+
"files": ["**/*.js", "**/*.py"],
|
|
17
|
+
"fix": "Remove the banned marker before committing this file."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parasraju/plumber-mcp",
|
|
3
|
-
"version": "1.
|
|
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.",
|
|
3
|
+
"version": "1.2.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. Add your own checks with custom rule packs.",
|
|
5
5
|
"main": "src/engine.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"plumber-mcp": "bin/plumber-mcp.js"
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"bin",
|
|
17
17
|
"src",
|
|
18
|
+
"examples",
|
|
18
19
|
"README.md"
|
|
19
20
|
],
|
|
20
21
|
"keywords": [
|
|
@@ -31,7 +32,9 @@
|
|
|
31
32
|
"github-actions",
|
|
32
33
|
"ci-cd",
|
|
33
34
|
"workflow",
|
|
34
|
-
"yaml"
|
|
35
|
+
"yaml",
|
|
36
|
+
"custom-rules",
|
|
37
|
+
"rule-packs"
|
|
35
38
|
],
|
|
36
39
|
"repository": {
|
|
37
40
|
"type": "git",
|
package/src/engine.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* ships only `bin/` and `README.md`, so a relative import from the MCP package
|
|
13
13
|
* would break `plumber-linter` for npm consumers. The CLI is intentionally left
|
|
14
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.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
const fs = require('fs');
|
|
@@ -54,6 +57,9 @@ const RULES = [
|
|
|
54
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.' }
|
|
55
58
|
];
|
|
56
59
|
|
|
60
|
+
const BUILTIN_IDS = new Set(RULES.map((rule) => rule.id));
|
|
61
|
+
const PACK_SEVERITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
|
|
62
|
+
|
|
57
63
|
const SEV_WEIGHT = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
|
|
58
64
|
|
|
59
65
|
function gradeFor(score) {
|
|
@@ -66,7 +72,8 @@ function scoreFor(issueCount) {
|
|
|
66
72
|
|
|
67
73
|
const SCANNABLE_EXTS = ['.py', '.js', '.jsx', '.ts', '.tsx', '.html', '.htm', '.css', '.scss', '.env'];
|
|
68
74
|
|
|
69
|
-
function scanLocalDirectory(dirPath) {
|
|
75
|
+
function scanLocalDirectory(dirPath, options) {
|
|
76
|
+
const pack = options && options.pack ? options.pack : null;
|
|
70
77
|
const scannableExts = new Set(SCANNABLE_EXTS);
|
|
71
78
|
const skipDirs = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', 'vendor', '.venv', 'venv', '__pycache__']);
|
|
72
79
|
|
|
@@ -94,6 +101,13 @@ function scanLocalDirectory(dirPath) {
|
|
|
94
101
|
const lines = content.split('\n');
|
|
95
102
|
totalLines += lines.length;
|
|
96
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
|
+
}
|
|
97
111
|
if (fileIssues.length > 0) {
|
|
98
112
|
issues.push(...fileIssues);
|
|
99
113
|
fileReports.push({ path: rel, issues: fileIssues });
|
|
@@ -132,7 +146,7 @@ function scanLocalDirectory(dirPath) {
|
|
|
132
146
|
const score = scoreFor(issues.length);
|
|
133
147
|
const grade = gradeFor(score);
|
|
134
148
|
|
|
135
|
-
|
|
149
|
+
const report = {
|
|
136
150
|
repo_name: path.basename(dirPath),
|
|
137
151
|
total_files: totalFiles,
|
|
138
152
|
total_lines: totalLines,
|
|
@@ -141,20 +155,30 @@ function scanLocalDirectory(dirPath) {
|
|
|
141
155
|
issues,
|
|
142
156
|
file_reports: fileReports
|
|
143
157
|
};
|
|
158
|
+
if (pack) report.pack = pack;
|
|
159
|
+
return report;
|
|
144
160
|
}
|
|
145
161
|
|
|
146
|
-
function scanText(text, filename) {
|
|
162
|
+
function scanText(text, filename, options) {
|
|
147
163
|
if (typeof text !== 'string') {
|
|
148
164
|
throw new TypeError('text must be a string');
|
|
149
165
|
}
|
|
166
|
+
const pack = options && options.pack ? options.pack : null;
|
|
150
167
|
const name = filename && typeof filename === 'string' ? filename.replace(/\\/g, '/') : 'snippet.js';
|
|
151
168
|
const ext = path.extname(name).toLowerCase();
|
|
152
169
|
const content = text.replace(/\r\n?/g, '\n');
|
|
153
170
|
const lines = content.split('\n');
|
|
154
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
|
+
}
|
|
155
179
|
const score = scoreFor(issues.length);
|
|
156
180
|
|
|
157
|
-
|
|
181
|
+
const report = {
|
|
158
182
|
repo_name: name,
|
|
159
183
|
total_files: 1,
|
|
160
184
|
total_lines: lines.length,
|
|
@@ -163,6 +187,8 @@ function scanText(text, filename) {
|
|
|
163
187
|
issues,
|
|
164
188
|
file_reports: issues.length > 0 ? [{ path: name, issues }] : []
|
|
165
189
|
};
|
|
190
|
+
if (pack) report.pack = pack;
|
|
191
|
+
return report;
|
|
166
192
|
}
|
|
167
193
|
|
|
168
194
|
// v1.0.1 fix: match "test"/"spec" as a path segment or filename part, not as a
|
|
@@ -774,10 +800,296 @@ function escapeRegExp(value) {
|
|
|
774
800
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
775
801
|
}
|
|
776
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
|
+
|
|
777
1087
|
module.exports = {
|
|
778
1088
|
RULES,
|
|
779
1089
|
SEV_WEIGHT,
|
|
780
1090
|
SCANNABLE_EXTS,
|
|
781
1091
|
scanLocalDirectory,
|
|
782
|
-
scanText
|
|
1092
|
+
scanText,
|
|
1093
|
+
loadRulePack,
|
|
1094
|
+
resolveScanPack
|
|
783
1095
|
};
|