@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 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`: none
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
 
@@ -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 `${header}\n\nNo scannable files found, so nothing was checked. Supported: ${SCANNABLE_EXTS.join(' ')} and .env* files.`;
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 `${header}\n\nNo issues found.`;
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 `${header}\n\n${lines.join('\n')}`;
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
- return formatReport(scanLocalDirectory(absolute));
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
- return RULES.map((rule) => `${rule.id} [${rule.sev}] ${rule.title} (${rule.fix})`).join('\n');
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.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.",
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
- return {
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
- return {
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
  };