ticketlens 0.38.25 → 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 =
|
|
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,18 +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
|
-
//
|
|
38
|
-
// required internal spacing, and hardRejectRuns
|
|
39
|
-
// despacedCombined (whitespace stripped) both
|
|
40
|
-
// same way they correctly neutralize
|
|
41
|
-
// pattern here — so a tab, extra
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
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
|
+
},
|
|
47
69
|
{ name: 'JSON Web Token (JWT)', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
48
70
|
{ name: 'API key', re: /\b(sk-|gsk_)[A-Za-z0-9]{20,}\b/ },
|
|
49
71
|
{ name: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
|
|
@@ -271,7 +293,7 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
|
|
|
271
293
|
// an exact literal prefix (AKIA, sk-, ghp_, eyJ, -----BEGIN), so joining
|
|
272
294
|
// across a field boundary can't turn them into a false positive the way
|
|
273
295
|
// entropy can.
|
|
274
|
-
const fieldTokenGroups = [title, ...tags, body].map(field => field.split(
|
|
296
|
+
const fieldTokenGroups = [title, ...tags, body].map(field => field.split(WHITESPACE_SPLIT_RE).filter(Boolean));
|
|
275
297
|
const tokens = fieldTokenGroups.flat();
|
|
276
298
|
const candidates = [...tokens, ...fieldTokenGroups.flatMap(group => joinedChunkRuns(group))];
|
|
277
299
|
|
|
@@ -292,7 +314,7 @@ export function scanForSecrets({ title = '', tags = [], body = '' } = {}) {
|
|
|
292
314
|
// anchor risk #2 was built to avoid, but only as an additional check
|
|
293
315
|
// alongside #2, never instead of it — it can only add a detection
|
|
294
316
|
// that #1/#2 missed, never remove one they already caught.
|
|
295
|
-
const despacedCombined = combined.replace(
|
|
317
|
+
const despacedCombined = combined.replace(WHITESPACE_STRIP_RE, '');
|
|
296
318
|
const hardRejectRuns = joinedChunkRuns(tokens, { stopAtLabelWords: false });
|
|
297
319
|
for (const { name, re } of HARD_REJECT_PATTERNS) {
|
|
298
320
|
if (re.test(combined) || hardRejectRuns.some(c => re.test(c)) || re.test(despacedCombined)) {
|