ticketlens 0.38.22 → 0.38.24
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
|
@@ -23,6 +23,22 @@ export async function testConnections({
|
|
|
23
23
|
for (const name of names) {
|
|
24
24
|
const profile = config.profiles[name];
|
|
25
25
|
const profileCreds = creds[name] || {};
|
|
26
|
+
|
|
27
|
+
if (!profileCreds.apiToken && !profileCreds.pat) {
|
|
28
|
+
// No point attempting a network call with no credentials — but still
|
|
29
|
+
// drive the session through spin()/failed()/footer() so callers that
|
|
30
|
+
// render `stream` live (e.g. onboarding.mjs's "Test connections" menu
|
|
31
|
+
// item, which discards this function's return value and relies
|
|
32
|
+
// entirely on stream output for user feedback) don't go silent.
|
|
33
|
+
const session = createSession({ baseUrl: profile.baseUrl, profileName: name, email: profile.email || undefined }, { stream });
|
|
34
|
+
const hint = `Run \`ticketlens config --profile=${name}\` to add an API token or PAT.`;
|
|
35
|
+
session.spin('Testing connection...');
|
|
36
|
+
session.failed();
|
|
37
|
+
session.footer('No credentials stored.', 'error', hint);
|
|
38
|
+
results.push({ name, ok: false, error: 'No credentials stored.', hint });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
const conn = {
|
|
27
43
|
baseUrl: profile.baseUrl,
|
|
28
44
|
auth: profile.auth,
|
|
@@ -131,6 +131,14 @@ export async function checkConnectivity({
|
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
133
|
const creds = loadCredentialsFn(configDir)[profile.name] || {};
|
|
134
|
+
if (!creds.apiToken && !creds.pat) {
|
|
135
|
+
return {
|
|
136
|
+
id: 'connectivity', label: 'Tracker connectivity', ok: false,
|
|
137
|
+
message: `Profile "${profile.name}" has no credentials stored.`,
|
|
138
|
+
hint: `Run \`ticketlens config --profile=${profile.name}\` to add an API token or PAT.`,
|
|
139
|
+
fixable: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
134
142
|
const conn = {
|
|
135
143
|
baseUrl: profile.baseUrl, auth: profile.auth, email: profile.email,
|
|
136
144
|
apiToken: creds.apiToken, pat: creds.pat, allowPrivateIp: profile.allowPrivateIp,
|
|
@@ -156,6 +156,7 @@ const TOOLS = [
|
|
|
156
156
|
summary: { type: 'string', description: 'Ticket title/summary.' },
|
|
157
157
|
description: { type: 'string', description: 'Ticket description. Omit for none.' },
|
|
158
158
|
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach, uploaded after the ticket is created. On Linear the image is automatically linked into the description. On Jira it becomes a real, visible attachment on the issue, but is not embedded inline in the initial description (use ticket_comment afterward for an inline thumbnail). Not supported on GitHub.' },
|
|
159
|
+
profile: { type: 'string', description: 'Connection profile to target, overriding folder-based inference and the default profile. Use this when `project` belongs to a profile other than the one auto-resolved from the current working directory.' },
|
|
159
160
|
},
|
|
160
161
|
required: ['summary'],
|
|
161
162
|
},
|
|
@@ -386,6 +387,7 @@ function buildTicketCreateArgs(args) {
|
|
|
386
387
|
cmdArgs.push(`--summary=${args.summary}`);
|
|
387
388
|
if (args.description !== undefined) cmdArgs.push(`--description=${args.description}`);
|
|
388
389
|
if (args.attachments?.length) cmdArgs.push(`--attach=${args.attachments.join(',')}`);
|
|
390
|
+
if (args.profile !== undefined) cmdArgs.push(`--profile=${args.profile}`);
|
|
389
391
|
return cmdArgs;
|
|
390
392
|
}
|
|
391
393
|
|
|
@@ -26,6 +26,11 @@ const REFERENCE_CONTEXT_WINDOW = 20;
|
|
|
26
26
|
// rejoined before checking. See isLabelWord for what stops a run — and the
|
|
27
27
|
// documented gap in inserting genuine dictionary words as separators.
|
|
28
28
|
const MAX_JOINED_CHUNKS = 4;
|
|
29
|
+
// Real compound-word segments ("dual-store", "not-yet-configured") run short —
|
|
30
|
+
// capping segment length keeps a long random letters-only run from masquerading
|
|
31
|
+
// as one "segment" of a fake compound. See isHyphenatedWordCompound.
|
|
32
|
+
const MAX_COMPOUND_SEGMENT_LENGTH = 15;
|
|
33
|
+
const HYPHENATED_COMPOUND_RE = /^[A-Za-z]+(-[A-Za-z]+)+$/;
|
|
29
34
|
|
|
30
35
|
const HARD_REJECT_PATTERNS = [
|
|
31
36
|
{ name: 'AWS access key', re: /AKIA[0-9A-Z]{16}/ },
|
|
@@ -124,29 +129,61 @@ function hasInternalCaseSwitch(token) {
|
|
|
124
129
|
return /[a-z][A-Z]/.test(token);
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
/**
|
|
133
|
+
* True for a letters-only, hyphen-delimited token whose segments are all
|
|
134
|
+
* short enough to read as a real compound word ("dual-store", "REDIS-vs-PG",
|
|
135
|
+
* "not-yet-configured") rather than a whitespace-split secret fragment. Only
|
|
136
|
+
* consulted from isLabelWord, i.e. only affects the stopAtLabelWords:true
|
|
137
|
+
* (generic-secret entropy) pass — joinedChunkRuns's stopAtLabelWords:false
|
|
138
|
+
* pass, which is what HARD_REJECT_PATTERNS relies on to catch a whitespace-
|
|
139
|
+
* split sk-/gsk_/AKIA/gh*_/eyJ/PEM-prefixed secret, never calls isLabelWord
|
|
140
|
+
* at all, so this cannot weaken that protection (see the regression test
|
|
141
|
+
* for exactly that shape, still passing after this change).
|
|
142
|
+
*
|
|
143
|
+
* The hasInternalCaseSwitch check matters the same way it does everywhere
|
|
144
|
+
* else in this file that distinguishes base64 content from prose (see
|
|
145
|
+
* isLabelWord's plain-word branch and looksLikeCodeFilename): without it, a
|
|
146
|
+
* base64-shaped fragment like "zqXvbNmKl-PoIuYtR" reads as a "compound word"
|
|
147
|
+
* purely because it happens to contain a hyphen, which would let it stop a
|
|
148
|
+
* run the same way a real compound does — the same category of shape-based
|
|
149
|
+
* exemption that made the code-filename bypass CRITICAL. Every real compound
|
|
150
|
+
* tested (dual-store, REDIS-vs-PG, well-known, not-yet-configured, PROD-DB,
|
|
151
|
+
* end-to-end, ...) has no internal case switch, so this costs nothing.
|
|
152
|
+
*/
|
|
153
|
+
function isHyphenatedWordCompound(token) {
|
|
154
|
+
if (!HYPHENATED_COMPOUND_RE.test(token)) return false;
|
|
155
|
+
if (hasInternalCaseSwitch(token)) return false;
|
|
156
|
+
return token.split('-').every(segment => segment.length <= MAX_COMPOUND_SEGMENT_LENGTH);
|
|
157
|
+
}
|
|
158
|
+
|
|
127
159
|
/**
|
|
128
160
|
* True for a token that stops a joined-chunk run: either a recognized git/
|
|
129
|
-
* checksum label word ("commit", "sha256", "md5sum", ...),
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* be reassembled for the
|
|
161
|
+
* checksum label word ("commit", "sha256", "md5sum", ...), a hyphenated
|
|
162
|
+
* compound word (see isHyphenatedWordCompound), or an ordinary English word
|
|
163
|
+
* (letters only, optionally with an internal possessive/contraction
|
|
164
|
+
* apostrophe — "relay's", "doesn't" — but no base64-style case switching).
|
|
165
|
+
* Anything else — a fragment containing a digit or other symbol, or an
|
|
166
|
+
* all-letter chunk that still reads as random content — stays eligible to
|
|
167
|
+
* join, so a secret split by whitespace can still be reassembled for the
|
|
168
|
+
* entropy check.
|
|
136
169
|
*
|
|
137
170
|
* The apostrophe allowance matters because without it, an ordinary possessive
|
|
138
171
|
* next to another non-label token (e.g. a hyphenated compound: "relay's
|
|
139
172
|
* decision-lookup") never gets a chance to stop the run — both fail to
|
|
140
173
|
* qualify, so they concatenate into one artificial blob whose mixed
|
|
141
174
|
* punctuation trips the entropy threshold. A real false positive, not a
|
|
142
|
-
* hypothetical one (see the regression test below).
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
175
|
+
* hypothetical one (see the regression test below).
|
|
176
|
+
*
|
|
177
|
+
* Hyphenated compounds get the narrower isHyphenatedWordCompound allowance
|
|
178
|
+
* rather than the full apostrophe treatment: a short prefix + hyphen + one
|
|
179
|
+
* long unstructured letter-run (e.g. "sk-abcdefghijklmnop") still fails it —
|
|
180
|
+
* one segment exceeds MAX_COMPOUND_SEGMENT_LENGTH — so it stays eligible to
|
|
181
|
+
* join. This is belt-and-suspenders on top of the fact noted above that the
|
|
182
|
+
* hard-reject pass doesn't consult isLabelWord in the first place. Accepted
|
|
183
|
+
* trade-off: a hypothetical generic (non-hard-reject-shaped) secret that is
|
|
184
|
+
* itself letters-only, hyphen-delimited, and short-segmented would no longer
|
|
185
|
+
* bridge across a whitespace split via this path — no known real secret
|
|
186
|
+
* generator produces that shape (base32/64/hex alphabets are digit-inclusive).
|
|
150
187
|
*
|
|
151
188
|
* Known accepted gap: this can't tell a genuine possessive from one an
|
|
152
189
|
* attacker deliberately chose as a separator to defeat the scanner (e.g.
|
|
@@ -161,6 +198,7 @@ function hasInternalCaseSwitch(token) {
|
|
|
161
198
|
function isLabelWord(token) {
|
|
162
199
|
const stripped = stripEdgePunctuation(token);
|
|
163
200
|
if (GIT_REFERENCE_WORD_RE.test(stripped)) return true;
|
|
201
|
+
if (isHyphenatedWordCompound(stripped)) return true;
|
|
164
202
|
return /^[A-Za-z]+(?:'[A-Za-z]+)*$/.test(stripped) && !hasInternalCaseSwitch(stripped);
|
|
165
203
|
}
|
|
166
204
|
|