ticketlens 0.38.39 → 0.38.41

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.39",
3
+ "version": "0.38.41",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -150,6 +150,89 @@ const CODE_FILENAME_RE = new RegExp(`^([A-Za-z][A-Za-z-]*)\\.(${CODE_EXTENSION_A
150
150
  // text regardless of any token's label-word status.
151
151
  const FILENAME_REFERENCE_RE = new RegExp(`^[A-Za-z][A-Za-z-]*\\.(${CODE_EXTENSION_ALTERNATION})('s)?$`, 'i');
152
152
 
153
+ // Underscore-delimited segments (Zend-1-style PHP class names — a common
154
+ // legacy naming convention, e.g. "Acme_Http_ClientFactory",
155
+ // "Zend_Http_Client") are letters-only per segment, no digit support (same
156
+ // convention as CODE_FILENAME_RE's stem). Deliberately does NOT require
157
+ // isHyphenatedWordCompound's "no internal case switch" guard: that guard
158
+ // exists there to keep base64-shaped content from posing as a lowercase-
159
+ // English hyphenated compound, but PascalCase segments ARE the real,
160
+ // expected positive signal for a class name ("ClientFactory",
161
+ // "SaleSystem") — requiring their absence would reject the exact
162
+ // legitimate shape this exists to recognize. See
163
+ // looksLikeCodeIdentifierOrPath's own comment for how this stays
164
+ // downgrade-only despite that asymmetry.
165
+ const UNDERSCORE_COMPOUND_RE = /^[A-Za-z]+(_[A-Za-z]+)+$/;
166
+
167
+ // Slash-delimited path segments (a namespace-shaped filesystem/class path —
168
+ // "acme/library/Billing/Client/Http/Adapter/Fetch/", optionally trailing-
169
+ // slash-terminated). Allows digits per segment (real
170
+ // directory/namespace segments routinely do — "v2", "api2"), unlike the
171
+ // underscore shape above — a deliberate, narrower convention than mixing
172
+ // delimiters within one candidate would need.
173
+ const SLASH_PATH_RE = /^[A-Za-z0-9]+(\/[A-Za-z0-9]+)+\/?$/;
174
+
175
+ // PHP static method/property/const access (Class_Name::method,
176
+ // Class_Name::CONST), optionally prefixed by a leading backslash (PHP's
177
+ // fully-qualified global-namespace form — stripEdgePunctuation does not
178
+ // strip a LEADING backslash, only the trailing/leading punctuation in
179
+ // EDGE_PUNCTUATION_RE's class). The class part reuses the same shape as
180
+ // UNDERSCORE_COMPOUND_RE but allows a bare (non-underscore) class name too
181
+ // — real code has both ("Zend_Http_Client::GET" and "Router::dispatch").
182
+ // Found live: backlog #17's ORIGINAL bug report repro included this exact
183
+ // class+member shape (`Advent_Http_ClientFactory::clientForJson`,
184
+ // `Zend_Http_Client::GET`), but the first fix only covered the bare class
185
+ // name in isolation — the embedded "::member" broke UNDERSCORE_COMPOUND_RE's
186
+ // match (colons aren't letters/underscores) and no other exemption applied.
187
+ const STATIC_REFERENCE_RE = /^\\?([A-Za-z]+(?:_[A-Za-z]+)*)::([A-Za-z_][A-Za-z0-9_]*)$/;
188
+
189
+ /**
190
+ * Downgrade-only, same never-exempt treatment as looksLikeCodeFilename and
191
+ * looksLikeCodeSyntax below: true for a candidate shaped like a multi-segment
192
+ * code identifier (underscore-delimited PHP/Zend-1-style class name), a
193
+ * namespace/filesystem path (slash-delimited), or a PHP static method/const
194
+ * reference (Class_Name::member, see STATIC_REFERENCE_RE), each segment
195
+ * capped at MAX_COMPOUND_SEGMENT_LENGTH so a long random run can't pose as
196
+ * one "segment" — same cap isHyphenatedWordCompound already uses. Backlog
197
+ * #17: isLabelWord's ordinary-word branch only matches letters-only tokens
198
+ * with no separator at all, and neither existing downgrade path below
199
+ * requires a file extension or bracket/paren — so a standalone identifier,
200
+ * path, or static reference like the three shapes above had no exemption
201
+ * path whatsoever, unlike a hyphenated compound or a dotted filename.
202
+ *
203
+ * A disguised real secret matching any of the three shapes still surfaces
204
+ * as a warning, never a silent pass — see the security regression tests
205
+ * alongside this function's own tests for all three shapes.
206
+ *
207
+ * Known accepted gap (security review, backlog #17): unlike
208
+ * isHyphenatedWordCompound, this has no whole-token case-switch guard (see
209
+ * UNDERSCORE_COMPOUND_RE's own comment for why one can't be added without
210
+ * rejecting the real Zend_Http_Client-shaped identifiers this exists to
211
+ * recognize) — so a uniform-case (all-upper or all-lower) letters-only
212
+ * secret needs only ONE underscore, slash, or "::" inserted to downgrade
213
+ * from a full reject to a warning, no camouflage (fake extension, case
214
+ * pattern) required. Still never a silent pass — always a warning — so
215
+ * this is the same class of trade-off as CODE_SYNTAX_RE's even less-
216
+ * constrained downgrade above (any bracket/paren, no shape requirement at
217
+ * all), not a new exposure.
218
+ */
219
+ function looksLikeCodeIdentifierOrPath(rawToken) {
220
+ const stripped = stripEdgePunctuation(rawToken);
221
+ if (UNDERSCORE_COMPOUND_RE.test(stripped)) {
222
+ return stripped.split('_').every(segment => segment.length <= MAX_COMPOUND_SEGMENT_LENGTH);
223
+ }
224
+ if (SLASH_PATH_RE.test(stripped)) {
225
+ return stripped.split('/').filter(Boolean).every(segment => segment.length <= MAX_COMPOUND_SEGMENT_LENGTH);
226
+ }
227
+ const staticRefMatch = stripped.match(STATIC_REFERENCE_RE);
228
+ if (staticRefMatch) {
229
+ const classSegmentsWithinCap = staticRefMatch[1].split('_').every(segment => segment.length <= MAX_COMPOUND_SEGMENT_LENGTH);
230
+ const memberWithinCap = staticRefMatch[2].length <= MAX_COMPOUND_SEGMENT_LENGTH;
231
+ return classSegmentsWithinCap && memberWithinCap;
232
+ }
233
+ return false;
234
+ }
235
+
153
236
  function looksLikeCodeFilename(rawToken) {
154
237
  const stripped = stripEdgePunctuation(rawToken);
155
238
  const match = stripped.match(CODE_FILENAME_RE);
@@ -443,7 +526,11 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
443
526
  // Downgrading to a warning — never silently dropping the signal — matches
444
527
  // how an email address is already handled below.
445
528
  const randomCandidates = candidates.filter(token => !EMAIL_RE.test(token) && looksRandom(token, combined));
446
- if (randomCandidates.some(token => !looksLikeCodeFilename(token) && !looksLikeCodeSyntax(token))) {
529
+ if (
530
+ randomCandidates.some(
531
+ token => !looksLikeCodeFilename(token) && !looksLikeCodeSyntax(token) && !looksLikeCodeIdentifierOrPath(token),
532
+ )
533
+ ) {
447
534
  reasons.push('Contains a long, random-looking string that could be a secret.');
448
535
  }
449
536
  if (randomCandidates.some(token => looksLikeCodeFilename(token))) {
@@ -452,6 +539,9 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
452
539
  if (randomCandidates.some(token => looksLikeCodeSyntax(token))) {
453
540
  warnings.push('Contains a code-syntax-shaped token (brackets or parentheses) that also reads as high-entropy — double-check it is not a credential.');
454
541
  }
542
+ if (randomCandidates.some(token => looksLikeCodeIdentifierOrPath(token))) {
543
+ warnings.push('Contains a code-identifier-or-path-shaped token that also reads as high-entropy — double-check it is not a credential.');
544
+ }
455
545
 
456
546
  if (EMAIL_RE.test(combined)) {
457
547
  warnings.push('Contains an email address.');