ticketlens 0.38.17 → 0.38.19

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.17",
3
+ "version": "0.38.19",
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": {
@@ -46,7 +46,7 @@ export async function testConnections({
46
46
  session.failed();
47
47
  const classified = classifyError(err, { baseUrl: conn.baseUrl, profileName: name });
48
48
  session.footer(classified.message, 'error', classified.hint);
49
- results.push({ name, ok: false, error: classified.message });
49
+ results.push({ name, ok: false, error: classified.message, hint: classified.hint });
50
50
  }
51
51
  }
52
52
 
@@ -153,7 +153,9 @@ export async function checkConnectivity({
153
153
  message: 'No profiles configured — nothing to test.', hint: null, fixable: false,
154
154
  };
155
155
  }
156
- const summary = results.map(r => r.ok ? `${r.name}: ok` : `${r.name}: ${r.error}`).join('; ');
156
+ const summary = results
157
+ .map(r => r.ok ? `${r.name}: ok` : `${r.name}: ${r.error}${r.hint ? ` → ${r.hint}` : ''}`)
158
+ .join('; ');
157
159
  return {
158
160
  id: 'connectivity', label: 'Tracker connectivity',
159
161
  ok: failedCount === 0,
@@ -18,6 +18,8 @@ import { readCliToken } from './cli-auth.mjs';
18
18
 
19
19
  const KNOWN_FLAGS = ['--format=', '--fix', '--profile=', '--help', '-h'];
20
20
 
21
+ const lowerFirst = (str) => str.charAt(0).toLowerCase() + str.slice(1);
22
+
21
23
  function renderPlain(checks, { fixed, skipped, stream }) {
22
24
  const s = createStyler({ isTTY: stream.isTTY });
23
25
  stream.write('\n');
@@ -109,14 +111,26 @@ export async function runDoctor(args, {
109
111
  const profileName = profileArg ? profileArg.split('=')[1] : null;
110
112
  const shouldFix = validated.includes('--fix');
111
113
 
112
- const rawResults = [
113
- checkProfileConfigFn({ configDir, profileName, cwd }),
114
- checkLicenseFreshnessFn({ configDir }),
115
- await checkConnectivityFn({ configDir, profileName, cwd }),
116
- checkCacheHealthFn({ configDir, profileName }),
117
- checkRecallQueueFn({ configDir }),
114
+ const checkList = [
115
+ { label: 'Profile configuration', run: () => checkProfileConfigFn({ configDir, profileName, cwd }) },
116
+ { label: 'License freshness', run: () => checkLicenseFreshnessFn({ configDir }) },
117
+ { label: 'Tracker connectivity', run: () => checkConnectivityFn({ configDir, profileName, cwd }) },
118
+ { label: 'Attachment cache', run: () => checkCacheHealthFn({ configDir, profileName }) },
119
+ { label: 'Recall sync queue', run: () => checkRecallQueueFn({ configDir }) },
118
120
  ];
119
121
 
122
+ const showProgress = format === 'plain' && stream.isTTY;
123
+ const s = createStyler({ isTTY: stream.isTTY });
124
+ const rawResults = [];
125
+ for (const { label, run } of checkList) {
126
+ if (showProgress) stream.write(` ${s.dim(`○ Checking ${lowerFirst(label)}…`)}\n`);
127
+ try {
128
+ rawResults.push(await run());
129
+ } finally {
130
+ if (showProgress) stream.write('\x1b[A\r\x1b[2K');
131
+ }
132
+ }
133
+
120
134
  let fixed = [];
121
135
  let skipped = [];
122
136
  let finalResults = rawResults;
@@ -13,7 +13,7 @@ const GIT_SHA_RE = /^[0-9a-f]{7,64}$/i;
13
13
  const TICKET_KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
14
14
  const GIT_REFERENCE_WORD_RE = /\b(commit|sha\d*|revision|rev|digest|checksum|md5(sum)?|hash|fingerprint)\b/i;
15
15
  const HASH_LABEL_PREFIX_RE = /^[a-z0-9]+:/i;
16
- const EDGE_PUNCTUATION_RE = /^[`'"(),.]+|[`'"(),.]+$/g;
16
+ const EDGE_PUNCTUATION_RE = /^[`'"(),.:]+|[`'"(),.:]+$/g;
17
17
  const MIN_RANDOM_TOKEN_LENGTH = 20;
18
18
  // Hex-alphabet strings (16 symbols) top out near 4.0 bits/char by definition, so a
19
19
  // threshold of 4.0 makes it nearly impossible to ever flag a hex-shaped secret.
@@ -37,6 +37,32 @@ const HARD_REJECT_PATTERNS = [
37
37
 
38
38
  const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
39
39
 
40
+ // A letters-only token ending in a recognized source-file extension reads as
41
+ // high-entropy the same way a real secret does — a class name doubling as its
42
+ // filename is the common case, but a deliberately-renamed or accidentally
43
+ // letters-only secret (no digits, so the hard-reject patterns above don't
44
+ // apply either) would match this shape too and MUST NOT be silently waved
45
+ // through: security review confirmed a full-exemption version of this check
46
+ // was a deterministic bypass (append ".php" to any 20+ char letters-only
47
+ // secret and it passed clean). So this only ever downgrades a match to a
48
+ // warning, never removes it from the reject reasons — see the two
49
+ // independent checks against looksRandomCandidates in scanForSecrets below,
50
+ // not a shortcut inside looksRandom itself. A bare identifier with no
51
+ // extension (a method name, not a filename) isn't covered by this at all —
52
+ // that shape is indistinguishable from a base64 secret fragment either way.
53
+ const CODE_FILENAME_RE = /^[A-Za-z]+\.(php|m?js|tsx?|jsx|py|rb|java|go|rs|vue|s?css|md|json|ya?ml|sh)$/i;
54
+
55
+ function looksLikeCodeFilename(rawToken) {
56
+ const stripped = stripEdgePunctuation(rawToken);
57
+ const match = stripped.match(CODE_FILENAME_RE);
58
+ // Requiring an internal case switch in the stem (the same signal used to
59
+ // detect base64 content elsewhere in this file) means a genuinely random
60
+ // single-case letter run plus a fake extension gets no special treatment
61
+ // at all — only tokens that already look like a real PascalCase/camelCase
62
+ // identifier reach the softer warning path below.
63
+ return match !== null && hasInternalCaseSwitch(match[0]);
64
+ }
65
+
40
66
  function shannonEntropy(token) {
41
67
  const counts = new Map();
42
68
  for (const ch of token) counts.set(ch, (counts.get(ch) ?? 0) + 1);
@@ -183,8 +209,19 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
183
209
  const reasons = [];
184
210
  const warnings = [];
185
211
 
186
- const tokens = combined.split(/\s+/).filter(Boolean);
187
- const candidates = [...tokens, ...joinedChunkRuns(tokens)];
212
+ // Each field is tokenized on its own, and joinedChunkRuns runs separately
213
+ // per field, so a trailing tag word can never glue onto the next tag or
214
+ // onto the body's first word (see Trigger 3 in
215
+ // recall-secret-scanner-false-positives.md — tags echoing a phrase already
216
+ // repeated in the body were false-positiving via exactly this cross-field
217
+ // join). The hard-reject pass below intentionally keeps using the flat,
218
+ // cross-field `tokens`/`combined`/`despacedCombined`: those patterns match
219
+ // an exact literal prefix (AKIA, sk-, ghp_, eyJ, -----BEGIN), so joining
220
+ // across a field boundary can't turn them into a false positive the way
221
+ // entropy can.
222
+ const fieldTokenGroups = [title, ...tags, body].map(field => field.split(/\s+/).filter(Boolean));
223
+ const tokens = fieldTokenGroups.flat();
224
+ const candidates = [...tokens, ...fieldTokenGroups.flatMap(group => joinedChunkRuns(group))];
188
225
 
189
226
  // A known secret shape (AWS key, API key prefix, JWT, PEM block...) is recognized
190
227
  // by a specific literal prefix. Checked three ways, each catching what the
@@ -216,9 +253,20 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
216
253
  // only split secrets), an email joined with an adjacent word can produce a long
217
254
  // mixed string with incidentally high entropy. Emails get their own warning
218
255
  // below — they're not secrets, so they shouldn't feed the random-string check.
219
- if (candidates.some(token => !EMAIL_RE.test(token) && looksRandom(token, combined))) {
256
+ //
257
+ // A code-filename-shaped candidate (looksLikeCodeFilename) is split out into
258
+ // its own warning instead of a reject reason: security review found that
259
+ // fully exempting this shape from the entropy check was a deterministic
260
+ // bypass (append ".php" to any letters-only secret and it passed clean).
261
+ // Downgrading to a warning — never silently dropping the signal — matches
262
+ // how an email address is already handled below.
263
+ const randomCandidates = candidates.filter(token => !EMAIL_RE.test(token) && looksRandom(token, combined));
264
+ if (randomCandidates.some(token => !looksLikeCodeFilename(token))) {
220
265
  reasons.push('Contains a long, random-looking string that could be a secret.');
221
266
  }
267
+ if (randomCandidates.some(token => looksLikeCodeFilename(token))) {
268
+ warnings.push('Contains a code-filename-shaped token that also reads as high-entropy — double-check it is not a credential.');
269
+ }
222
270
 
223
271
  if (EMAIL_RE.test(combined)) {
224
272
  warnings.push('Contains an email address.');