@wrongstack/plugins 1.0.4 → 1.0.5

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.
@@ -68,10 +68,21 @@ var ATTR_TITLE = /\btitle\s*=/i;
68
68
  var ATTR_PLACEHOLDER = /\bplaceholder\s*=/i;
69
69
  var ATTR_VALUE = /\bvalue\s*=/i;
70
70
  var ATTR_ROLE_DECORATIVE = /\brole\s*=\s*["'](?:presentation|none)["']/i;
71
+ var LABEL_SPAN = /<label\b[^>]*>[\s\S]*?<\/label>/gi;
72
+ var FIELDSET_SPAN = /<fieldset\b[^>]*>[\s\S]*?<\/fieldset>/gi;
71
73
  var SINGLE_FILE_LABEL_NOTE = "Single-file heuristic: a label declared in a sibling component file is not visible to this scan.";
72
74
  function escapeRegExp(value) {
73
75
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
74
76
  }
77
+ function collectSpans(content, re) {
78
+ return [...content.matchAll(re)].map((m) => ({
79
+ start: m.index ?? 0,
80
+ end: (m.index ?? 0) + m[0].length
81
+ }));
82
+ }
83
+ function isInsideSpan(spans, offset) {
84
+ return spans.some((s) => offset > s.start && offset < s.end);
85
+ }
75
86
  function hasMeaningfulAlt(tag) {
76
87
  const m = ATTR_ALT.exec(tag);
77
88
  ATTR_ALT.lastIndex = 0;
@@ -81,7 +92,7 @@ function hasMeaningfulAlt(tag) {
81
92
  if (alt.length > 0) return true;
82
93
  return ATTR_ROLE_DECORATIVE.test(tag);
83
94
  }
84
- function hasFieldsetLegendLabel(tag, content) {
95
+ function hasFieldsetLegendLabel(tag, content, fieldsetSpans, inputStart) {
85
96
  const labelledBy = tag.match(/\baria-labelledby\s*=\s*["']([^"']+)["']/i);
86
97
  if (labelledBy?.[1]) {
87
98
  for (const id of labelledBy[1].split(/\s+/).filter(Boolean)) {
@@ -92,8 +103,8 @@ function hasFieldsetLegendLabel(tag, content) {
92
103
  const typeMatch = tag.match(/\btype\s*=\s*["']?([^"'\s>]*)["']?/i);
93
104
  const type = typeMatch ? typeMatch[1].toLowerCase() : "text";
94
105
  if (type === "checkbox" || type === "radio") {
95
- return /<fieldset\b[\s\S]*?<legend\b[\s\S]*?<\/legend>[\s\S]*?<input\b[\s\S]*?<\/fieldset>/i.test(
96
- content
106
+ return fieldsetSpans.some(
107
+ (s) => inputStart > s.start && inputStart < s.end && /<legend\b[\s\S]*?<\/legend>/i.test(content.slice(s.start, inputStart))
97
108
  );
98
109
  }
99
110
  return false;
@@ -108,6 +119,12 @@ async function auditFile(filePath, projectRoot) {
108
119
  const findings = [];
109
120
  const lines = content.split(/\r?\n/);
110
121
  const idsByValue = /* @__PURE__ */ new Map();
122
+ const lineStarts = [0];
123
+ for (let nl = content.indexOf("\n"); nl !== -1; nl = content.indexOf("\n", nl + 1)) {
124
+ lineStarts.push(nl + 1);
125
+ }
126
+ const labelSpans = collectSpans(content, LABEL_SPAN);
127
+ const fieldsetSpans = collectSpans(content, FIELDSET_SPAN);
111
128
  function add(line, rule, severity, message, note) {
112
129
  findings.push({
113
130
  file: relative(projectRoot, filePath),
@@ -155,8 +172,9 @@ async function auditFile(filePath, projectRoot) {
155
172
  );
156
173
  hasLabelFor = labelForRe.test(content);
157
174
  }
158
- const wrappedInLabel = /<label\b[\s\S]*?<input\b[\s\S]*?<\/label>/i.test(content);
159
- const hasLegend = hasFieldsetLegendLabel(tag, content);
175
+ const inputStart = (lineStarts[i] ?? 0) + (inputMatch.index ?? 0);
176
+ const wrappedInLabel = isInsideSpan(labelSpans, inputStart);
177
+ const hasLegend = hasFieldsetLegendLabel(tag, content, fieldsetSpans, inputStart);
160
178
  const hasPrimaryLabel = hasAriaLabel || hasTitle || hasLabelFor || wrappedInLabel || hasLegend;
161
179
  if (!hasPrimaryLabel && hasDescribedBy) {
162
180
  add(
@@ -137,7 +137,7 @@ var plugin = {
137
137
  },
138
138
  required: ["text"]
139
139
  },
140
- permission: "auto",
140
+ permission: "confirm",
141
141
  category: "Memory",
142
142
  mutating: true,
143
143
  async execute(input) {
@@ -175,7 +175,7 @@ var plugin = {
175
175
  label: { type: "string", description: "Alternative label to remove." }
176
176
  }
177
177
  },
178
- permission: "auto",
178
+ permission: "confirm",
179
179
  category: "Memory",
180
180
  mutating: true,
181
181
  async execute(input) {
@@ -362,7 +362,7 @@ var plugin = {
362
362
  },
363
363
  required: ["path"]
364
364
  },
365
- permission: "auto",
365
+ permission: "confirm",
366
366
  category: "Code Quality",
367
367
  mutating: true,
368
368
  async execute(input) {
package/dist/index.js CHANGED
@@ -68,10 +68,21 @@ var ATTR_TITLE = /\btitle\s*=/i;
68
68
  var ATTR_PLACEHOLDER = /\bplaceholder\s*=/i;
69
69
  var ATTR_VALUE = /\bvalue\s*=/i;
70
70
  var ATTR_ROLE_DECORATIVE = /\brole\s*=\s*["'](?:presentation|none)["']/i;
71
+ var LABEL_SPAN = /<label\b[^>]*>[\s\S]*?<\/label>/gi;
72
+ var FIELDSET_SPAN = /<fieldset\b[^>]*>[\s\S]*?<\/fieldset>/gi;
71
73
  var SINGLE_FILE_LABEL_NOTE = "Single-file heuristic: a label declared in a sibling component file is not visible to this scan.";
72
74
  function escapeRegExp(value) {
73
75
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
74
76
  }
77
+ function collectSpans(content, re) {
78
+ return [...content.matchAll(re)].map((m) => ({
79
+ start: m.index ?? 0,
80
+ end: (m.index ?? 0) + m[0].length
81
+ }));
82
+ }
83
+ function isInsideSpan(spans, offset) {
84
+ return spans.some((s) => offset > s.start && offset < s.end);
85
+ }
75
86
  function hasMeaningfulAlt(tag) {
76
87
  const m = ATTR_ALT.exec(tag);
77
88
  ATTR_ALT.lastIndex = 0;
@@ -81,7 +92,7 @@ function hasMeaningfulAlt(tag) {
81
92
  if (alt.length > 0) return true;
82
93
  return ATTR_ROLE_DECORATIVE.test(tag);
83
94
  }
84
- function hasFieldsetLegendLabel(tag, content) {
95
+ function hasFieldsetLegendLabel(tag, content, fieldsetSpans, inputStart) {
85
96
  const labelledBy = tag.match(/\baria-labelledby\s*=\s*["']([^"']+)["']/i);
86
97
  if (labelledBy?.[1]) {
87
98
  for (const id of labelledBy[1].split(/\s+/).filter(Boolean)) {
@@ -92,8 +103,8 @@ function hasFieldsetLegendLabel(tag, content) {
92
103
  const typeMatch = tag.match(/\btype\s*=\s*["']?([^"'\s>]*)["']?/i);
93
104
  const type = typeMatch ? typeMatch[1].toLowerCase() : "text";
94
105
  if (type === "checkbox" || type === "radio") {
95
- return /<fieldset\b[\s\S]*?<legend\b[\s\S]*?<\/legend>[\s\S]*?<input\b[\s\S]*?<\/fieldset>/i.test(
96
- content
106
+ return fieldsetSpans.some(
107
+ (s) => inputStart > s.start && inputStart < s.end && /<legend\b[\s\S]*?<\/legend>/i.test(content.slice(s.start, inputStart))
97
108
  );
98
109
  }
99
110
  return false;
@@ -108,6 +119,12 @@ async function auditFile(filePath, projectRoot) {
108
119
  const findings = [];
109
120
  const lines = content.split(/\r?\n/);
110
121
  const idsByValue = /* @__PURE__ */ new Map();
122
+ const lineStarts = [0];
123
+ for (let nl = content.indexOf("\n"); nl !== -1; nl = content.indexOf("\n", nl + 1)) {
124
+ lineStarts.push(nl + 1);
125
+ }
126
+ const labelSpans = collectSpans(content, LABEL_SPAN);
127
+ const fieldsetSpans = collectSpans(content, FIELDSET_SPAN);
111
128
  function add(line, rule, severity, message, note) {
112
129
  findings.push({
113
130
  file: relative(projectRoot, filePath),
@@ -155,8 +172,9 @@ async function auditFile(filePath, projectRoot) {
155
172
  );
156
173
  hasLabelFor = labelForRe.test(content);
157
174
  }
158
- const wrappedInLabel = /<label\b[\s\S]*?<input\b[\s\S]*?<\/label>/i.test(content);
159
- const hasLegend = hasFieldsetLegendLabel(tag, content);
175
+ const inputStart = (lineStarts[i] ?? 0) + (inputMatch.index ?? 0);
176
+ const wrappedInLabel = isInsideSpan(labelSpans, inputStart);
177
+ const hasLegend = hasFieldsetLegendLabel(tag, content, fieldsetSpans, inputStart);
160
178
  const hasPrimaryLabel = hasAriaLabel || hasTitle || hasLabelFor || wrappedInLabel || hasLegend;
161
179
  if (!hasPrimaryLabel && hasDescribedBy) {
162
180
  add(
@@ -4475,7 +4493,7 @@ var plugin13 = {
4475
4493
  },
4476
4494
  required: ["text"]
4477
4495
  },
4478
- permission: "auto",
4496
+ permission: "confirm",
4479
4497
  category: "Memory",
4480
4498
  mutating: true,
4481
4499
  async execute(input) {
@@ -4513,7 +4531,7 @@ var plugin13 = {
4513
4531
  label: { type: "string", description: "Alternative label to remove." }
4514
4532
  }
4515
4533
  },
4516
- permission: "auto",
4534
+ permission: "confirm",
4517
4535
  category: "Memory",
4518
4536
  mutating: true,
4519
4537
  async execute(input) {
@@ -9861,7 +9879,7 @@ var plugin27 = {
9861
9879
  },
9862
9880
  required: ["path"]
9863
9881
  },
9864
- permission: "auto",
9882
+ permission: "confirm",
9865
9883
  category: "Code Quality",
9866
9884
  mutating: true,
9867
9885
  async execute(input) {
@@ -11049,7 +11067,7 @@ var plugin31 = {
11049
11067
  },
11050
11068
  required: ["subject", "relation", "object"]
11051
11069
  },
11052
- permission: "auto",
11070
+ permission: "confirm",
11053
11071
  category: "Memory",
11054
11072
  mutating: true,
11055
11073
  async execute(input) {
@@ -11150,7 +11168,7 @@ var plugin31 = {
11150
11168
  },
11151
11169
  required: ["id"]
11152
11170
  },
11153
- permission: "auto",
11171
+ permission: "confirm",
11154
11172
  category: "Memory",
11155
11173
  mutating: true,
11156
11174
  async execute(input) {
@@ -13626,6 +13644,20 @@ var WebhookNotificationChannel = class {
13626
13644
  method: "POST",
13627
13645
  headers: { "content-type": "application/json", ...this.#headers },
13628
13646
  body,
13647
+ // The private-IP check on this URL runs ONCE, at plugin setup
13648
+ // (`index.ts`), against the host the operator configured. Delivery
13649
+ // resolves again and, with the default `redirect: 'follow'`, would
13650
+ // replay this POST — including `this.#headers`, which is where the
13651
+ // operator's `X-Webhook-Secret` / `X-Api-Key` lives — to wherever a
13652
+ // 307 pointed. A webhook host that is compromised, or simply
13653
+ // hostile, could aim that at loopback or the cloud metadata
13654
+ // endpoint and read the secret out of the request.
13655
+ //
13656
+ // Refusing the redirect outright is what the sibling HTTP hook
13657
+ // executor already does (`core/src/hooks/http-executor.ts:73`). A
13658
+ // webhook endpoint that answers with a redirect is misconfigured;
13659
+ // failing it is correct and is visible through the circuit breaker.
13660
+ redirect: "error",
13629
13661
  signal: controller.signal
13630
13662
  });
13631
13663
  if (typeof res.arrayBuffer === "function") {
@@ -167,7 +167,7 @@ var plugin = {
167
167
  },
168
168
  required: ["subject", "relation", "object"]
169
169
  },
170
- permission: "auto",
170
+ permission: "confirm",
171
171
  category: "Memory",
172
172
  mutating: true,
173
173
  async execute(input) {
@@ -268,7 +268,7 @@ var plugin = {
268
268
  },
269
269
  required: ["id"]
270
270
  },
271
- permission: "auto",
271
+ permission: "confirm",
272
272
  category: "Memory",
273
273
  mutating: true,
274
274
  async execute(input) {
@@ -84,6 +84,20 @@ var WebhookNotificationChannel = class {
84
84
  method: "POST",
85
85
  headers: { "content-type": "application/json", ...this.#headers },
86
86
  body,
87
+ // The private-IP check on this URL runs ONCE, at plugin setup
88
+ // (`index.ts`), against the host the operator configured. Delivery
89
+ // resolves again and, with the default `redirect: 'follow'`, would
90
+ // replay this POST — including `this.#headers`, which is where the
91
+ // operator's `X-Webhook-Secret` / `X-Api-Key` lives — to wherever a
92
+ // 307 pointed. A webhook host that is compromised, or simply
93
+ // hostile, could aim that at loopback or the cloud metadata
94
+ // endpoint and read the secret out of the request.
95
+ //
96
+ // Refusing the redirect outright is what the sibling HTTP hook
97
+ // executor already does (`core/src/hooks/http-executor.ts:73`). A
98
+ // webhook endpoint that answers with a redirect is misconfigured;
99
+ // failing it is correct and is visible through the circuit breaker.
100
+ redirect: "error",
87
101
  signal: controller.signal
88
102
  });
89
103
  if (typeof res.arrayBuffer === "function") {
@@ -514,7 +514,13 @@ var HOST_PLUGIN_AUDIT_ENTRIES = [
514
514
  summary: "Language Server Protocol tools and slash commands.",
515
515
  // Active by default: with `autoStart: 'lazy'` nothing is spawned until a
516
516
  // file of a matching language is touched, and auto-discovery only adopts
517
- // servers already installed on the machine. Disable with
517
+ // servers from the user's own environment never from the opened
518
+ // repository. That second clause used to read "already installed on the
519
+ // machine", which was wrong: the resolver preferred
520
+ // `<repo>/node_modules/.bin` over PATH on an existence check alone, so a
521
+ // cloned repo could supply the executable and have it spawned unprompted
522
+ // (WS-SEC-01). Provenance is now decided by where the binary lives; see
523
+ // `plug-lsp/src/utils/command-resolver.ts`. Disable the plugin with
518
524
  // `{ name: 'lsp', enabled: false }` in config.plugins.
519
525
  defaultState: "active",
520
526
  canDisable: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/plugins",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Official WrongStack collection of focused plugins for code quality, security, observability, planning, and agent coordination",
5
5
  "license": "MIT",
6
6
  "author": "ECOSTACK TECHNOLOGY OÜ",
@@ -303,10 +303,10 @@
303
303
  "vitest": "^4.1.11"
304
304
  },
305
305
  "dependencies": {
306
- "@wrongstack/core": "1.0.4",
307
- "@wrongstack/primitives": "1.0.4",
308
- "@wrongstack/plugin-sdk": "1.0.4",
309
- "@wrongstack/tools": "1.0.4"
306
+ "@wrongstack/core": "1.0.5",
307
+ "@wrongstack/primitives": "1.0.5",
308
+ "@wrongstack/plugin-sdk": "1.0.5",
309
+ "@wrongstack/tools": "1.0.5"
310
310
  },
311
311
  "scripts": {
312
312
  "build": "node ../../scripts/build-package.mjs",