ticketlens 0.38.24 → 0.38.26

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.24",
3
+ "version": "0.38.26",
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": {
@@ -3,7 +3,7 @@
3
3
  * Centralised here to avoid triplicating the regex and warning logic.
4
4
  */
5
5
 
6
- export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
6
+ export const DEFAULT_API_BASE = 'http://api.ticketlens.test';
7
7
  export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
8
8
 
9
9
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
@@ -7,8 +7,12 @@
7
7
  import { readFileSync, writeFileSync, chmodSync, existsSync, mkdirSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
10
+ import { readMachineSecret, readOrCreateMachineSecret, signHmac, verifyHmac } from './machine-secret.mjs';
10
11
 
11
12
  const TOKEN_FILE = 'cli-token.json';
13
+ // Distinct from license.mjs's LICENSE_HMAC_SALT so a license.json signature
14
+ // can never be replayed as a valid cli-token.json tier signature, or vice versa.
15
+ const CLI_TOKEN_HMAC_SALT = 'tl-tok-v1';
12
16
 
13
17
  export function cliTokenPath(configDir = DEFAULT_CONFIG_DIR) {
14
18
  return join(configDir, TOKEN_FILE);
@@ -29,12 +33,22 @@ export function readCliToken(configDir = DEFAULT_CONFIG_DIR) {
29
33
  // every login/sync — separate from the signed, offline license.json file.
30
34
  // Lets team-invited members get Pro-equivalent CLI access without a personal
31
35
  // TL- key, mirroring what the manager already pays for.
36
+ //
37
+ // Signed with a machine-bound HMAC so `tier` can't be hand-edited in the file
38
+ // to escalate entitlement. A missing or invalid signature is treated as no
39
+ // tier at all (falls back to free) rather than trusted — self-heals on the
40
+ // next `tl login`/`tl sync`, both of which re-write and re-sign it.
32
41
  export function readCliTokenTier(configDir = DEFAULT_CONFIG_DIR) {
33
42
  const p = cliTokenPath(configDir);
34
43
  if (!existsSync(p)) return null;
35
44
  try {
36
45
  const data = JSON.parse(readFileSync(p, 'utf8'));
37
- return typeof data.tier === 'string' ? data.tier : null;
46
+ if (typeof data.tier !== 'string' || typeof data.tierSig !== 'string') return null;
47
+ const secret = readMachineSecret(configDir);
48
+ if (!secret) return null;
49
+ return verifyHmac(data.tierSig, { tier: data.tier }, `${CLI_TOKEN_HMAC_SALT}:${secret}`)
50
+ ? data.tier
51
+ : null;
38
52
  } catch {
39
53
  return null;
40
54
  }
@@ -43,7 +57,12 @@ export function readCliTokenTier(configDir = DEFAULT_CONFIG_DIR) {
43
57
  export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR, tier = undefined) {
44
58
  mkdirSync(configDir, { recursive: true });
45
59
  const p = cliTokenPath(configDir);
46
- const payload = tier === undefined ? { token } : { token, tier };
60
+ let payload = { token };
61
+ if (tier !== undefined) {
62
+ const secret = readOrCreateMachineSecret(configDir);
63
+ const tierSig = signHmac({ tier }, `${CLI_TOKEN_HMAC_SALT}:${secret}`);
64
+ payload = { token, tier, tierSig };
65
+ }
47
66
  writeFileSync(p, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
48
67
  chmodSync(p, 0o600);
49
68
  }
@@ -1,18 +1,21 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import crypto from 'node:crypto';
5
4
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
6
5
  import { createStyler } from './ansi.mjs';
7
6
  import { siteBase } from './api-utils.mjs';
8
7
  import { readCliTokenTier } from './cli-auth.mjs';
8
+ import {
9
+ readOrCreateMachineSecret as readOrCreateHmacSecret,
10
+ signHmac,
11
+ verifyHmac as verifyLicenseHmac,
12
+ } from './machine-secret.mjs';
9
13
 
10
14
  // Mixed into the HMAC key so that knowing the license key alone is not sufficient
11
15
  // to forge a valid signature — an attacker also needs this constant from the source.
12
16
  export const LICENSE_HMAC_SALT = 'tl-lic-v1';
13
17
  export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
14
18
  const LICENSE_FILE = 'license.json';
15
- const LICENSE_SECRET_FILE = 'license-hmac-secret.json';
16
19
  const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
17
20
  export const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
18
21
  const MS_PER_DAY = 86400000;
@@ -24,62 +27,23 @@ function padInner(str, width) {
24
27
  return str + ' '.repeat(Math.max(0, width - visLen(str)));
25
28
  }
26
29
 
27
- function readHmacSecret(configDir) {
28
- try {
29
- const data = JSON.parse(fs.readFileSync(path.join(configDir, LICENSE_SECRET_FILE), 'utf8'));
30
- return typeof data.secret === 'string' && data.secret.length === 64 ? data.secret : null;
31
- } catch {
32
- return null;
33
- }
34
- }
35
-
36
- function readOrCreateHmacSecret(configDir) {
37
- const existing = readHmacSecret(configDir);
38
- if (existing) return existing;
39
- fs.mkdirSync(configDir, { recursive: true });
40
- const secret = crypto.randomBytes(32).toString('hex');
41
- const secretPath = path.join(configDir, LICENSE_SECRET_FILE);
42
- const tmp = `${secretPath}.${process.pid}.tmp`;
43
- fs.writeFileSync(tmp, JSON.stringify({ secret }), { encoding: 'utf8', mode: 0o600 });
44
- try {
45
- // Atomic on POSIX — if we lost a concurrent race, the winner's file stays
46
- fs.renameSync(tmp, secretPath);
47
- } catch {
48
- try { fs.unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
49
- }
50
- // Read back: we may have lost the race; use whatever secret is on disk
51
- return readHmacSecret(configDir) ?? secret;
52
- }
53
-
54
- function verifyLicenseHmac(sig, payload, sigKey) {
55
- const expected = crypto.createHmac('sha256', sigKey)
56
- .update(JSON.stringify(payload)).digest('hex');
57
- const sigBuf = Buffer.from(sig, 'hex');
58
- const expBuf = Buffer.from(expected, 'hex');
59
- if (sigBuf.length !== expBuf.length) return false;
60
- return crypto.timingSafeEqual(sigBuf, expBuf);
61
- }
62
-
63
30
  export function readLicense(configDir = DEFAULT_CONFIG_DIR) {
64
31
  const filePath = path.join(configDir, LICENSE_FILE);
65
32
  try {
66
33
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
67
34
  const { sig, ...payload } = data;
68
- // Legacy unsigned files are trusted; will be re-signed on next write
69
- if (!sig) return payload;
70
-
71
- const machineSecret = readHmacSecret(configDir);
72
- if (machineSecret) {
73
- // Machine secret present — only accept signatures made with it
74
- return verifyLicenseHmac(sig, payload, `${LICENSE_HMAC_SALT}:${machineSecret}`)
75
- ? payload : null;
76
- }
77
-
78
- // No secret file yet — fall back to legacy key-based HMAC (backward compat).
79
- // If valid, immediately re-sign with a machine secret to close the migration window.
80
- if (!verifyLicenseHmac(sig, payload, `${LICENSE_HMAC_SALT}:${payload.key || ''}`)) return null;
81
- try { writeLicense(payload, configDir); } catch { /* non-fatal — next call will retry */ }
82
- return payload;
35
+ // No signature means no verifiable authenticity — trusting it would let
36
+ // anyone fabricate a license.json from scratch with no sig field at all.
37
+ if (!sig) return null;
38
+
39
+ // Always verify against the machine secret (created here if this is the
40
+ // first read ever, e.g. fresh install). There is deliberately no fallback
41
+ // to a "legacy key-based HMAC" scheme — a signature derived from a key
42
+ // that lives inside the same payload it signs proves nothing; anyone can
43
+ // compute it themselves without ever owning a real license.
44
+ const machineSecret = readOrCreateHmacSecret(configDir);
45
+ return verifyLicenseHmac(sig, payload, `${LICENSE_HMAC_SALT}:${machineSecret}`)
46
+ ? payload : null;
83
47
  } catch {
84
48
  return null;
85
49
  }
@@ -90,8 +54,7 @@ export function writeLicense(data, configDir = DEFAULT_CONFIG_DIR) {
90
54
  const filePath = path.join(configDir, LICENSE_FILE);
91
55
  const { sig: _, ...payload } = data; // strip any existing sig before re-signing
92
56
  const secret = readOrCreateHmacSecret(configDir);
93
- const mac = crypto.createHmac('sha256', `${LICENSE_HMAC_SALT}:${secret}`)
94
- .update(JSON.stringify(payload)).digest('hex');
57
+ const mac = signHmac(payload, `${LICENSE_HMAC_SALT}:${secret}`);
95
58
  fs.writeFileSync(filePath, JSON.stringify({ ...payload, sig: mac }), { encoding: 'utf8', mode: 0o600 });
96
59
  fs.chmodSync(filePath, 0o600);
97
60
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Machine-bound secret used to HMAC-sign local trust files (license.json,
3
+ * cli-token.json) so their contents can't be hand-edited without detection.
4
+ * The secret never leaves the machine and is never sent to the server.
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+
11
+ const SECRET_FILE = 'license-hmac-secret.json';
12
+
13
+ export function readMachineSecret(configDir) {
14
+ try {
15
+ const data = JSON.parse(fs.readFileSync(path.join(configDir, SECRET_FILE), 'utf8'));
16
+ return typeof data.secret === 'string' && data.secret.length === 64 ? data.secret : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function readOrCreateMachineSecret(configDir) {
23
+ const existing = readMachineSecret(configDir);
24
+ if (existing) return existing;
25
+ fs.mkdirSync(configDir, { recursive: true });
26
+ const secret = crypto.randomBytes(32).toString('hex');
27
+ const secretPath = path.join(configDir, SECRET_FILE);
28
+ const tmp = `${secretPath}.${process.pid}.tmp`;
29
+ fs.writeFileSync(tmp, JSON.stringify({ secret }), { encoding: 'utf8', mode: 0o600 });
30
+ try {
31
+ // Atomic on POSIX — if we lost a concurrent race, the winner's file stays
32
+ fs.renameSync(tmp, secretPath);
33
+ } catch {
34
+ try { fs.unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
35
+ }
36
+ // Read back: we may have lost the race; use whatever secret is on disk
37
+ return readMachineSecret(configDir) ?? secret;
38
+ }
39
+
40
+ export function signHmac(payload, sigKey) {
41
+ return crypto.createHmac('sha256', sigKey).update(JSON.stringify(payload)).digest('hex');
42
+ }
43
+
44
+ export function verifyHmac(sig, payload, sigKey) {
45
+ const expected = signHmac(payload, sigKey);
46
+ const sigBuf = Buffer.from(sig, 'hex');
47
+ const expBuf = Buffer.from(expected, 'hex');
48
+ if (sigBuf.length !== expBuf.length) return false;
49
+ return crypto.timingSafeEqual(sigBuf, expBuf);
50
+ }
@@ -32,9 +32,40 @@ const MAX_JOINED_CHUNKS = 4;
32
32
  const MAX_COMPOUND_SEGMENT_LENGTH = 15;
33
33
  const HYPHENATED_COMPOUND_RE = /^[A-Za-z]+(-[A-Za-z]+)+$/;
34
34
 
35
+ // U+200B (ZERO WIDTH SPACE) is added explicitly: despite the name, it does
36
+ // NOT carry the Unicode White_Space property (General_Category=Cf, not Zs),
37
+ // so it's excluded from JS's native \s (ECMA-262 WhiteSpace production) —
38
+ // the one gap backlog 1c/1d's U+FEFF handling didn't already cover for free,
39
+ // since \s does include U+FEFF. Without this, a secret split by U+200B stays
40
+ // as one unsplit token, invisible to the direct match, hardRejectRuns
41
+ // (nothing to rejoin), and despacedCombined (not stripped) alike — only
42
+ // entropy would catch it, and only by chance (backlog 1e). Shared as a
43
+ // fragment (not just inside the tokenize/despace regexes below) so the PEM
44
+ // entry in HARD_REJECT_PATTERNS stays in sync with it automatically —
45
+ // mirrors RecallSecretScanner.php's WHITESPACE_CLASS.
46
+ const WHITESPACE_CLASS = '[\\s\\u200B]';
47
+ const WHITESPACE_SPLIT_RE = new RegExp(WHITESPACE_CLASS + '+');
48
+ const WHITESPACE_STRIP_RE = new RegExp(WHITESPACE_CLASS + '+', 'g');
49
+
35
50
  const HARD_REJECT_PATTERNS = [
36
51
  { name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
37
- { name: 'private key block', re: /-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ },
52
+ // WHITESPACE_CLASS* (not a literal space) between segments: this is the
53
+ // only entry with required internal spacing, and hardRejectRuns
54
+ // (no-separator rejoin) and despacedCombined (whitespace stripped) both
55
+ // destroy a literal space the same way they correctly neutralize
56
+ // whitespace-splitting on every other pattern here — so a tab, extra
57
+ // spaces, a newline, Unicode whitespace, or no separator at all bypassed
58
+ // this entry until backlog 1d closed it, and U+200B specifically until
59
+ // backlog 1e (see WHITESPACE_CLASS above). Built with `new RegExp` instead
60
+ // of a literal so it reuses the exact same class as
61
+ // WHITESPACE_SPLIT_RE/WHITESPACE_STRIP_RE — a literal duplicate here is
62
+ // exactly how the /u flag itself drifted out of sync in backlog 1c.
63
+ {
64
+ name: 'private key block',
65
+ re: new RegExp(
66
+ `-----BEGIN${WHITESPACE_CLASS}*(RSA${WHITESPACE_CLASS}*|EC${WHITESPACE_CLASS}*|OPENSSH${WHITESPACE_CLASS}*|DSA${WHITESPACE_CLASS}*)?PRIVATE${WHITESPACE_CLASS}*KEY-----`,
67
+ ),
68
+ },
38
69
  { name: 'JSON Web Token (JWT)', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
39
70
  { name: 'API key', re: /\b(sk-|gsk_)[A-Za-z0-9]{20,}\b/ },
40
71
  { name: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
@@ -192,8 +223,13 @@ function isHyphenatedWordCompound(token) {
192
223
  * text into false-positive "random" strings (verified empirically — see the
193
224
  * regression test for "PROD-123456 for background"). Entropy-based detection
194
225
  * is a safety net, not a cryptographic guarantee; the well-known secret
195
- * shapes in HARD_REJECT_PATTERNS are the layer that's fully whitespace- and
196
- * word-insertion-proof, since they match a specific literal prefix.
226
+ * shapes in HARD_REJECT_PATTERNS are the layer that's whitespace-proof,
227
+ * since they match a specific literal prefix — with one exception: the
228
+ * private-key-block entry has two internal separator points (around the
229
+ * optional algorithm word and before KEY), and a word inserted at either
230
+ * one still defeats it, exactly as it did before backlog 1d's whitespace
231
+ * fix (that fix closed whitespace-substitution/removal, not word
232
+ * insertion — the same accepted-gap class as the possessive case above).
197
233
  */
198
234
  function isLabelWord(token) {
199
235
  const stripped = stripEdgePunctuation(token);
@@ -257,7 +293,7 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
257
293
  // an exact literal prefix (AKIA, sk-, ghp_, eyJ, -----BEGIN), so joining
258
294
  // across a field boundary can't turn them into a false positive the way
259
295
  // entropy can.
260
- const fieldTokenGroups = [title, ...tags, body].map(field => field.split(/\s+/).filter(Boolean));
296
+ const fieldTokenGroups = [title, ...tags, body].map(field => field.split(WHITESPACE_SPLIT_RE).filter(Boolean));
261
297
  const tokens = fieldTokenGroups.flat();
262
298
  const candidates = [...tokens, ...fieldTokenGroups.flatMap(group => joinedChunkRuns(group))];
263
299
 
@@ -278,7 +314,7 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
278
314
  // anchor risk #2 was built to avoid, but only as an additional check
279
315
  // alongside #2, never instead of it — it can only add a detection
280
316
  // that #1/#2 missed, never remove one they already caught.
281
- const despacedCombined = combined.replace(/\s+/g, '');
317
+ const despacedCombined = combined.replace(WHITESPACE_STRIP_RE, '');
282
318
  const hardRejectRuns = joinedChunkRuns(tokens, { stopAtLabelWords: false });
283
319
  for (const { name, re } of HARD_REJECT_PATTERNS) {
284
320
  if (re.test(combined) || hardRejectRuns.some(c => re.test(c)) || re.test(despacedCombined)) {