ticketlens 0.10.18 → 0.11.0
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/README.md +1 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +1 -1
- package/skills/jtb/scripts/fetch-ticket.mjs +2 -1
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +4 -4
- package/skills/jtb/scripts/lib/attachment-downloader.mjs +10 -3
- package/skills/jtb/scripts/lib/config-wizard.mjs +36 -13
- package/skills/jtb/scripts/lib/config.mjs +6 -0
- package/skills/jtb/scripts/lib/connection-tester.mjs +1 -0
- package/skills/jtb/scripts/lib/error-classifier.mjs +9 -0
- package/skills/jtb/scripts/lib/init-wizard.mjs +19 -3
- package/skills/jtb/scripts/lib/jira-client.mjs +46 -22
- package/skills/jtb/scripts/lib/profile-resolver.mjs +1 -0
- package/skills/jtb/scripts/lib/profile-switcher.mjs +2 -1
- package/skills/jtb/scripts/lib/team-jira-sync.mjs +14 -2
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config
|
|
|
79
79
|
| `ticketlens profiles` | List all configured profiles (alias: `ticketlens ls`) |
|
|
80
80
|
| `ticketlens delete <NAME>` | Remove a profile and its credentials (prompts `y/N` in TTY; use `--yes` in scripts/CI) |
|
|
81
81
|
|
|
82
|
-
`init` collects: profile name, tracker type (Jira / GitHub Issues / Linear), URL or workspace, credentials (masked), and optional ticket prefixes, project paths, and triage statuses. On connection failure, a retry menu lets you fix credentials, URL, or skip — all inputs pre-populated. `config` is tracker-aware and always re-validates the connection after edits.
|
|
82
|
+
`init` collects: profile name, tracker type (Jira / GitHub Issues / Linear), URL or workspace, credentials (masked), and optional ticket prefixes, project paths, and triage statuses. On connection failure, a retry menu lets you fix credentials, URL, or skip — all inputs pre-populated. If your Jira instance sits behind a VPN and resolves to a private/internal address, you'll be asked to confirm you trust that connection before it's allowed through — a one-time confirmation, remembered per profile and scoped to that exact host (changing the URL asks again). `config` is tracker-aware and always re-validates the connection after edits.
|
|
83
83
|
|
|
84
84
|
`config` uses merge semantics: new ticket prefixes and triage statuses are added to existing lists, never replaced. Partial matching resolves `QA` to `QA Testing` if that's the status in your Jira.
|
|
85
85
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.11.0 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -1134,7 +1134,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1134
1134
|
const authHeader = buildAuthHeader(jiraEnv);
|
|
1135
1135
|
const apiVersion = conn.auth === 'cloud' ? 3 : 2;
|
|
1136
1136
|
const jiraOrigin = new URL(jiraEnv.JIRA_BASE_URL).origin;
|
|
1137
|
-
const links = await fetchRemoteLinks(ticketKey, { env: jiraEnv, fetcher, apiVersion });
|
|
1137
|
+
const links = await fetchRemoteLinks(ticketKey, { env: jiraEnv, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp });
|
|
1138
1138
|
const MAX_CONFLUENCE_PAGES = 10;
|
|
1139
1139
|
const safeLinks = links
|
|
1140
1140
|
.filter(l => { try { return new URL(l.url).origin === jiraOrigin; } catch { return false; } })
|
|
@@ -1189,6 +1189,7 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1189
1189
|
fetcher,
|
|
1190
1190
|
noCache: args.includes('--no-cache'),
|
|
1191
1191
|
onProgress: (msg) => process.stderr.write(msg + '\n'),
|
|
1192
|
+
allowPrivateIp: conn.allowPrivateIp,
|
|
1192
1193
|
});
|
|
1193
1194
|
const downloaded = ticket.localAttachments.filter(r => !r.skipped).length;
|
|
1194
1195
|
const cached = ticket.localAttachments.filter(r => r.skipReason === 'cached').length;
|
|
@@ -11,9 +11,9 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
11
11
|
|
|
12
12
|
return {
|
|
13
13
|
type: 'jira',
|
|
14
|
-
fetchTicket: (key, opts = {}) => fetchTicket(key, { env, fetcher, apiVersion, ...opts }),
|
|
15
|
-
fetchCurrentUser: (opts = {}) => fetchCurrentUser({ env, fetcher, apiVersion, ...opts }),
|
|
16
|
-
searchTickets: (query, opts = {}) => searchTickets(query, { env, fetcher, apiVersion, ...opts }),
|
|
17
|
-
fetchStatuses: (opts = {}) => fetchStatuses({ env, fetcher, apiVersion, ...opts }),
|
|
14
|
+
fetchTicket: (key, opts = {}) => fetchTicket(key, { env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
|
|
15
|
+
fetchCurrentUser: (opts = {}) => fetchCurrentUser({ env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
|
|
16
|
+
searchTickets: (query, opts = {}) => searchTickets(query, { env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
|
|
17
|
+
fetchStatuses: (opts = {}) => fetchStatuses({ env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp, ...opts }),
|
|
18
18
|
};
|
|
19
19
|
}
|
|
@@ -20,10 +20,15 @@ const MAX_REDIRECT_HOPS = 2;
|
|
|
20
20
|
* code-review remediation). Auth headers are only ever sent on the initial
|
|
21
21
|
* request — every hop after that is auth-less, matching Jira Cloud's
|
|
22
22
|
* presigned-URL CDN redirect model.
|
|
23
|
+
*
|
|
24
|
+
* `allowPrivateIp` only ever applies to the initial hop (the user's own
|
|
25
|
+
* trusted Jira origin). A redirect target is server-controlled — a
|
|
26
|
+
* categorically different trust domain — so it is never passed through,
|
|
27
|
+
* regardless of the caller's trust level.
|
|
23
28
|
*/
|
|
24
|
-
async function followValidatedRedirects(initialUrl, fetcher, lookup, headers) {
|
|
29
|
+
async function followValidatedRedirects(initialUrl, fetcher, lookup, headers, allowPrivateIp = false) {
|
|
25
30
|
let url = initialUrl;
|
|
26
|
-
await validateResolvedHost(new URL(url).hostname, lookup);
|
|
31
|
+
await validateResolvedHost(new URL(url).hostname, lookup, allowPrivateIp);
|
|
27
32
|
let response = await fetcher(url, { headers, redirect: 'manual' });
|
|
28
33
|
let hops = 0;
|
|
29
34
|
while (response.status >= 300 && response.status < 400) {
|
|
@@ -53,6 +58,7 @@ async function followValidatedRedirects(initialUrl, fetcher, lookup, headers) {
|
|
|
53
58
|
* @param {string} opts.configDir Base config dir (default: ~/.ticketlens)
|
|
54
59
|
* @param {boolean} opts.noCache Force re-download even if cached (default: false)
|
|
55
60
|
* @param {Function} opts.onProgress Optional callback(msg: string) for progress lines
|
|
61
|
+
* @param {boolean} opts.allowPrivateIp Skip the private-IP DNS check on the initial hop only (default: false)
|
|
56
62
|
*
|
|
57
63
|
* @returns {Promise<Array<{filename, mimeType, size, localPath, skipped, skipReason, error}>>}
|
|
58
64
|
*/
|
|
@@ -64,6 +70,7 @@ export async function downloadAttachments(ticket, opts = {}) {
|
|
|
64
70
|
configDir = DEFAULT_CONFIG_DIR,
|
|
65
71
|
noCache = false,
|
|
66
72
|
onProgress = null,
|
|
73
|
+
allowPrivateIp = false,
|
|
67
74
|
} = opts;
|
|
68
75
|
|
|
69
76
|
const attachments = (ticket.attachments ?? []).filter(a => a.content);
|
|
@@ -118,7 +125,7 @@ export async function downloadAttachments(ticket, opts = {}) {
|
|
|
118
125
|
onProgress?.(` download ${a.filename}`);
|
|
119
126
|
try {
|
|
120
127
|
const headers = buildAuthHeader(env);
|
|
121
|
-
const response = await followValidatedRedirects(a.content, fetcher, lookup, headers);
|
|
128
|
+
const response = await followValidatedRedirects(a.content, fetcher, lookup, headers, allowPrivateIp);
|
|
122
129
|
if (!response.ok) throw new Error(`HTTP ${response.status} (${response.statusText})`);
|
|
123
130
|
const buffer = await response.arrayBuffer();
|
|
124
131
|
fs.writeFileSync(localPath, Buffer.from(buffer));
|
|
@@ -16,7 +16,7 @@ import { loadProfiles, loadCredentials, saveProfile } from './profile-resolver.m
|
|
|
16
16
|
import { promptSelect } from './select-prompt.mjs';
|
|
17
17
|
import { parseAge } from './cache-manager.mjs';
|
|
18
18
|
import { DEFAULT_BRIEF_TTL } from './brief-cache.mjs';
|
|
19
|
-
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
19
|
+
import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
|
|
20
20
|
import { visLen, SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
|
|
21
21
|
import { isLicensed } from './license.mjs';
|
|
22
22
|
import { siteBase } from './api-utils.mjs';
|
|
@@ -103,6 +103,10 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
103
103
|
let email = profile.email || '';
|
|
104
104
|
const existingToken = profileCreds.pat || profileCreds.apiToken || '';
|
|
105
105
|
let token = existingToken;
|
|
106
|
+
// Trust is bound to the exact hostname it was granted for — never a flat
|
|
107
|
+
// per-profile boolean. Editing the URL to a different host must re-trigger
|
|
108
|
+
// the private-IP block and a fresh "Trust this connection?" prompt.
|
|
109
|
+
let trustedHostname = (profile.allowPrivateIp && hostnameOf(profile.baseUrl)) || null;
|
|
106
110
|
|
|
107
111
|
// ── URL ───────────────────────────────────────────────────────────────────
|
|
108
112
|
const urlTyped = await promptText(
|
|
@@ -158,6 +162,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
158
162
|
let connected = false;
|
|
159
163
|
let startFrom = 'test';
|
|
160
164
|
|
|
165
|
+
function isTrustedForCurrentUrl() {
|
|
166
|
+
return trustedHostname !== null && hostnameOf(url) === trustedHostname;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function attemptConnection() {
|
|
170
|
+
if (isJira) {
|
|
171
|
+
const testEnv = {
|
|
172
|
+
JIRA_BASE_URL: url,
|
|
173
|
+
JIRA_EMAIL: email,
|
|
174
|
+
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
175
|
+
JIRA_PAT: auth === 'pat' ? token : '',
|
|
176
|
+
};
|
|
177
|
+
await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2, allowPrivateIp: isTrustedForCurrentUrl() });
|
|
178
|
+
} else {
|
|
179
|
+
await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
161
183
|
setupLoop: while (true) {
|
|
162
184
|
// Re-prompt URL + auth (on retry with 'url' option)
|
|
163
185
|
if (startFrom === 'url') {
|
|
@@ -216,17 +238,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
216
238
|
session.spin('Testing connection...');
|
|
217
239
|
|
|
218
240
|
try {
|
|
219
|
-
|
|
220
|
-
const testEnv = {
|
|
221
|
-
JIRA_BASE_URL: url,
|
|
222
|
-
JIRA_EMAIL: email,
|
|
223
|
-
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
224
|
-
JIRA_PAT: auth === 'pat' ? token : '',
|
|
225
|
-
};
|
|
226
|
-
await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
|
|
227
|
-
} else {
|
|
228
|
-
await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
|
|
229
|
-
}
|
|
241
|
+
await attemptConnection();
|
|
230
242
|
session.connected();
|
|
231
243
|
connected = true;
|
|
232
244
|
break setupLoop;
|
|
@@ -234,6 +246,15 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
234
246
|
session.failed();
|
|
235
247
|
const classified = classifyError(err, { baseUrl: url, profileName: target });
|
|
236
248
|
session.footer(classified.message, 'error', classified.hint);
|
|
249
|
+
|
|
250
|
+
if (classified.privateIpBlocked) {
|
|
251
|
+
const trust = await promptYN('Trust this connection?', { stream });
|
|
252
|
+
if (trust) {
|
|
253
|
+
trustedHostname = hostnameOf(url);
|
|
254
|
+
startFrom = 'test';
|
|
255
|
+
continue setupLoop;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
237
258
|
}
|
|
238
259
|
|
|
239
260
|
stream.write(`\n ${s.dim('What would you like to do?')}\n\n`);
|
|
@@ -326,7 +347,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
326
347
|
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
327
348
|
JIRA_PAT: auth === 'pat' ? token : '',
|
|
328
349
|
};
|
|
329
|
-
available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
|
|
350
|
+
available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2, allowPrivateIp: isTrustedForCurrentUrl() });
|
|
330
351
|
} else {
|
|
331
352
|
available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
|
|
332
353
|
}
|
|
@@ -397,6 +418,8 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
397
418
|
else delete updated.projectPaths;
|
|
398
419
|
if (cacheTtl && cacheTtl !== DEFAULT_BRIEF_TTL) updated.cacheTtl = cacheTtl;
|
|
399
420
|
else delete updated.cacheTtl;
|
|
421
|
+
if (isTrustedForCurrentUrl()) updated.allowPrivateIp = true;
|
|
422
|
+
else delete updated.allowPrivateIp;
|
|
400
423
|
|
|
401
424
|
const credData = (token !== existingToken)
|
|
402
425
|
? (auth === 'pat' ? { pat: token } : { apiToken: token })
|
|
@@ -68,6 +68,12 @@ export function stripCr(str) {
|
|
|
68
68
|
return str.replace(/\r/g, '');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** Parse a URL's hostname, or null if unparseable. Used to scope trust
|
|
72
|
+
* grants (e.g. allowPrivateIp) to the exact host they were confirmed for. */
|
|
73
|
+
export function hostnameOf(url) {
|
|
74
|
+
try { return new URL(url).hostname; } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
/**
|
|
72
78
|
* Build the env-like object expected by jira-client functions.
|
|
73
79
|
* @param {{ baseUrl: string, pat?: string, email?: string, apiToken?: string }} conn
|
|
@@ -18,6 +18,15 @@ export function classifyError(err, { baseUrl, profileName } = {}) {
|
|
|
18
18
|
const profile = profileName || 'your profile';
|
|
19
19
|
const code = getNetworkCode(err);
|
|
20
20
|
|
|
21
|
+
if (err.code === 'PRIVATE_IP_BLOCKED') {
|
|
22
|
+
const address = err.blockedAddress ? ` (${err.blockedAddress})` : '';
|
|
23
|
+
return {
|
|
24
|
+
message: `${err.blockedHostname} resolves to a private address${address}`,
|
|
25
|
+
hint: "This is expected if you're on a VPN connecting to an on-prem Jira instance. It could also mean something is wrong.",
|
|
26
|
+
privateIpBlocked: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
// Network-level errors (fetch threw before getting an HTTP response)
|
|
22
31
|
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
|
23
32
|
return {
|
|
@@ -16,7 +16,7 @@ import { promptSelect } from './select-prompt.mjs';
|
|
|
16
16
|
import { runSwitch } from './profile-switcher.mjs';
|
|
17
17
|
import { renderWordmark } from './wordmark.mjs';
|
|
18
18
|
import { printQuickStart } from './quick-start-panel.mjs';
|
|
19
|
-
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
19
|
+
import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
|
|
20
20
|
import { SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
|
|
21
21
|
|
|
22
22
|
const RETRY_OPTIONS = [
|
|
@@ -328,6 +328,12 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
328
328
|
let baseUrl = '', authType = '', email = '', token = '';
|
|
329
329
|
let env = {}, apiVersion = 2;
|
|
330
330
|
let startFrom = 'url';
|
|
331
|
+
// Trust is bound to the exact hostname it was granted for — never a flat
|
|
332
|
+
// per-run boolean. Choosing "Edit from URL" after granting trust for one
|
|
333
|
+
// host must re-trigger the private-IP block and a fresh confirmation
|
|
334
|
+
// for the new host, not silently inherit trust.
|
|
335
|
+
let trustedHostname = null;
|
|
336
|
+
const isTrustedForCurrentUrl = () => trustedHostname !== null && hostnameOf(baseUrl) === trustedHostname;
|
|
331
337
|
|
|
332
338
|
setupLoop: while (true) {
|
|
333
339
|
// ── URL + auth type ────────────────────────────────────────────────────
|
|
@@ -432,7 +438,7 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
432
438
|
session.spin('Testing connection...');
|
|
433
439
|
|
|
434
440
|
try {
|
|
435
|
-
await fetchCurrentUser({ env, apiVersion });
|
|
441
|
+
await fetchCurrentUser({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
|
|
436
442
|
session.connected();
|
|
437
443
|
connected = true;
|
|
438
444
|
break setupLoop;
|
|
@@ -440,6 +446,15 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
440
446
|
session.failed();
|
|
441
447
|
const classified = classifyError(err, { baseUrl, profileName });
|
|
442
448
|
session.footer(classified.message, 'error', classified.hint);
|
|
449
|
+
|
|
450
|
+
if (classified.privateIpBlocked) {
|
|
451
|
+
const trust = await promptYN('Trust this connection?', { stream });
|
|
452
|
+
if (trust) {
|
|
453
|
+
trustedHostname = hostnameOf(baseUrl);
|
|
454
|
+
startFrom = 'retry';
|
|
455
|
+
continue setupLoop;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
443
458
|
}
|
|
444
459
|
|
|
445
460
|
// ── Retry options ──────────────────────────────────────────────────────
|
|
@@ -503,7 +518,7 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
503
518
|
|
|
504
519
|
stream.write(` ${s.dim('Validating statuses...')}\n`);
|
|
505
520
|
try {
|
|
506
|
-
const available = await fetchStatuses({ env, apiVersion });
|
|
521
|
+
const available = await fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
|
|
507
522
|
const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
|
|
508
523
|
stream.write('\x1b[A\r\x1b[2K'); // clear "Validating..." line
|
|
509
524
|
const corrected = [];
|
|
@@ -540,6 +555,7 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
|
540
555
|
...(email ? { email } : {}),
|
|
541
556
|
...(ticketPrefixes.length > 0 ? { ticketPrefixes } : {}),
|
|
542
557
|
...(projectPaths.length > 0 ? { projectPaths } : {}),
|
|
558
|
+
...(isTrustedForCurrentUrl() ? { allowPrivateIp: true } : {}),
|
|
543
559
|
triageStatuses,
|
|
544
560
|
};
|
|
545
561
|
const credData = authType === 'pat' ? { pat: token } : { apiToken: token };
|
|
@@ -125,7 +125,14 @@ function normalizeHostname(hostname) {
|
|
|
125
125
|
return hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} url
|
|
130
|
+
* @param {boolean} [allowPrivateIp] - skip the private/internal blocklist check
|
|
131
|
+
* for THIS hostname. Set only via explicit interactive user confirmation
|
|
132
|
+
* during setup, persisted per-profile — never from synced/network data
|
|
133
|
+
* (see team-jira-sync.mjs). HTTPS enforcement is never skippable.
|
|
134
|
+
*/
|
|
135
|
+
export function validateBaseUrl(url, allowPrivateIp = false) {
|
|
129
136
|
let parsed;
|
|
130
137
|
try { parsed = new URL(url); } catch {
|
|
131
138
|
throw new Error(`JIRA_BASE_URL is not a valid URL: ${url}`);
|
|
@@ -133,9 +140,14 @@ export function validateBaseUrl(url) {
|
|
|
133
140
|
if (parsed.protocol !== 'https:') {
|
|
134
141
|
throw new Error(`JIRA_BASE_URL must use HTTPS (got ${parsed.protocol})`);
|
|
135
142
|
}
|
|
143
|
+
if (allowPrivateIp) return;
|
|
136
144
|
const hostname = normalizeHostname(parsed.hostname);
|
|
137
145
|
if (BLOCKED_PATTERNS.some(re => re.test(hostname))) {
|
|
138
|
-
|
|
146
|
+
const err = new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
|
|
147
|
+
err.code = 'PRIVATE_IP_BLOCKED';
|
|
148
|
+
err.blockedHostname = hostname;
|
|
149
|
+
err.blockedAddress = null;
|
|
150
|
+
throw err;
|
|
139
151
|
}
|
|
140
152
|
}
|
|
141
153
|
|
|
@@ -189,9 +201,17 @@ const dnsValidationCaches = new WeakMap();
|
|
|
189
201
|
* `lookup` is null when the fetcher is mocked (tests) — no socket opens, so
|
|
190
202
|
* there is nothing to protect. Production always uses globalThis.fetch, which
|
|
191
203
|
* always gets the real resolver (see defaultLookupFor).
|
|
204
|
+
*
|
|
205
|
+
* `allowPrivateIp` skips the lookup entirely (not just the block) when a
|
|
206
|
+
* profile has an explicit, user-confirmed trust exception for this exact
|
|
207
|
+
* hostname — set only during interactive setup, never from synced/network
|
|
208
|
+
* data. Skipping the resolution outright (rather than resolving-then-
|
|
209
|
+
* ignoring) avoids the DNS round-trip and sidesteps any question of how the
|
|
210
|
+
* per-lookup-fn cache should key on a flag that no real caller varies
|
|
211
|
+
* concurrently for the same hostname.
|
|
192
212
|
*/
|
|
193
|
-
export async function validateResolvedHost(hostname, lookup) {
|
|
194
|
-
if (!lookup) return;
|
|
213
|
+
export async function validateResolvedHost(hostname, lookup, allowPrivateIp = false) {
|
|
214
|
+
if (!lookup || allowPrivateIp) return;
|
|
195
215
|
let cache = dnsValidationCaches.get(lookup);
|
|
196
216
|
if (!cache) {
|
|
197
217
|
cache = new Map();
|
|
@@ -202,7 +222,11 @@ export async function validateResolvedHost(hostname, lookup) {
|
|
|
202
222
|
const addresses = await lookup(hostname, { all: true });
|
|
203
223
|
for (const { address } of addresses) {
|
|
204
224
|
if (BLOCKED_PATTERNS.some(re => re.test(address))) {
|
|
205
|
-
|
|
225
|
+
const err = new Error(`Hostname ${hostname} resolves to a blocked address (${address}) — refusing to connect`);
|
|
226
|
+
err.code = 'PRIVATE_IP_BLOCKED';
|
|
227
|
+
err.blockedHostname = hostname;
|
|
228
|
+
err.blockedAddress = address;
|
|
229
|
+
throw err;
|
|
206
230
|
}
|
|
207
231
|
}
|
|
208
232
|
})();
|
|
@@ -223,8 +247,8 @@ export async function validateResolvedHost(hostname, lookup) {
|
|
|
223
247
|
* Error message carries the redirect target's HOSTNAME only — Location values
|
|
224
248
|
* can contain presigned tokens in path/query.
|
|
225
249
|
*/
|
|
226
|
-
export async function guardedFetch(url, fetchOpts, { fetcher, lookup }) {
|
|
227
|
-
await validateResolvedHost(new URL(url).hostname, lookup);
|
|
250
|
+
export async function guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp = false }) {
|
|
251
|
+
await validateResolvedHost(new URL(url).hostname, lookup, allowPrivateIp);
|
|
228
252
|
const response = await fetcher(url, { ...fetchOpts, redirect: 'manual' });
|
|
229
253
|
if (response.status >= 300 && response.status < 400) {
|
|
230
254
|
let target = 'unknown host';
|
|
@@ -248,15 +272,15 @@ export function buildAuthHeader(env) {
|
|
|
248
272
|
}
|
|
249
273
|
|
|
250
274
|
export async function fetchCurrentUser(opts = {}) {
|
|
251
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
252
|
-
validateBaseUrl(env.JIRA_BASE_URL);
|
|
275
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
276
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
253
277
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
254
278
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
255
279
|
|
|
256
280
|
const url = `${baseUrl}/rest/api/${apiVersion}/myself`;
|
|
257
281
|
const fetchOpts = { headers };
|
|
258
282
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
259
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
283
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
260
284
|
|
|
261
285
|
if (!response.ok) {
|
|
262
286
|
const err = new Error(`Jira API error ${response.status} fetching current user`);
|
|
@@ -274,15 +298,15 @@ export async function fetchCurrentUser(opts = {}) {
|
|
|
274
298
|
}
|
|
275
299
|
|
|
276
300
|
export async function fetchStatuses(opts = {}) {
|
|
277
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
278
|
-
validateBaseUrl(env.JIRA_BASE_URL);
|
|
301
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
302
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
279
303
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
280
304
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
281
305
|
|
|
282
306
|
const url = `${baseUrl}/rest/api/${apiVersion}/status`;
|
|
283
307
|
const fetchOpts = { headers };
|
|
284
308
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
285
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
309
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
286
310
|
|
|
287
311
|
if (!response.ok) {
|
|
288
312
|
const err = new Error(`Jira API error ${response.status} fetching statuses`);
|
|
@@ -295,8 +319,8 @@ export async function fetchStatuses(opts = {}) {
|
|
|
295
319
|
}
|
|
296
320
|
|
|
297
321
|
export async function searchTickets(jql, opts = {}) {
|
|
298
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
|
|
299
|
-
validateBaseUrl(env.JIRA_BASE_URL);
|
|
322
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false } = opts;
|
|
323
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
300
324
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
301
325
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
302
326
|
|
|
@@ -307,7 +331,7 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
307
331
|
const url = `${baseUrl}${endpoint}?${params}`;
|
|
308
332
|
const fetchOpts = { headers };
|
|
309
333
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
310
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
334
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
311
335
|
|
|
312
336
|
if (!response.ok) {
|
|
313
337
|
let detail = '';
|
|
@@ -323,15 +347,15 @@ export async function searchTickets(jql, opts = {}) {
|
|
|
323
347
|
}
|
|
324
348
|
|
|
325
349
|
export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
326
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000 } = opts;
|
|
327
|
-
validateBaseUrl(env.JIRA_BASE_URL);
|
|
350
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
351
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
328
352
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
329
353
|
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
|
|
330
354
|
|
|
331
355
|
const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
|
|
332
356
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
333
357
|
|
|
334
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
358
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
335
359
|
if (!response.ok) return [];
|
|
336
360
|
|
|
337
361
|
const raw = await response.json();
|
|
@@ -341,8 +365,8 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
|
341
365
|
}
|
|
342
366
|
|
|
343
367
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
344
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
345
|
-
validateBaseUrl(env.JIRA_BASE_URL);
|
|
368
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
369
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
346
370
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
347
371
|
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
348
372
|
|
|
@@ -350,7 +374,7 @@ export async function fetchTicket(ticketKey, opts = {}) {
|
|
|
350
374
|
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}${expand}`;
|
|
351
375
|
const fetchOpts = { headers };
|
|
352
376
|
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
353
|
-
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup });
|
|
377
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
354
378
|
|
|
355
379
|
if (!response.ok) {
|
|
356
380
|
const err = new Error(`Jira API error ${response.status} fetching ${ticketKey}`);
|
|
@@ -233,6 +233,7 @@ export function resolveConnection(ticketKey, opts = {}) {
|
|
|
233
233
|
ticketPrefixes: profile.ticketPrefixes || null,
|
|
234
234
|
attentionRules: profile.attentionRules ?? null,
|
|
235
235
|
staleRule: profile.staleRule ?? null,
|
|
236
|
+
allowPrivateIp: profile.allowPrivateIp || false,
|
|
236
237
|
source: 'profile',
|
|
237
238
|
profileName: profile.name,
|
|
238
239
|
};
|
|
@@ -118,6 +118,7 @@ export async function runSwitch({ configDir = DEFAULT_CONFIG_DIR, stream = proce
|
|
|
118
118
|
JIRA_PAT: profileCreds.pat || '',
|
|
119
119
|
};
|
|
120
120
|
const apiVersion = profile.auth === 'cloud' ? 3 : 2;
|
|
121
|
+
const allowPrivateIp = profile.allowPrivateIp;
|
|
121
122
|
|
|
122
123
|
let frame = 0;
|
|
123
124
|
const spinLine = () => ` ${s.cyan(SPINNER_FRAMES[frame])} Connecting to ${s.bold(chosen.name)}...`;
|
|
@@ -129,7 +130,7 @@ export async function runSwitch({ configDir = DEFAULT_CONFIG_DIR, stream = proce
|
|
|
129
130
|
}, 80);
|
|
130
131
|
|
|
131
132
|
try {
|
|
132
|
-
await fetchCurrentUser({ env, apiVersion });
|
|
133
|
+
await fetchCurrentUser({ env, apiVersion, allowPrivateIp });
|
|
133
134
|
clearInterval(timer);
|
|
134
135
|
stream.write('\r\x1b[2K\x1b[A\r\x1b[2K');
|
|
135
136
|
await saveDefault(chosen.name, configDir);
|
|
@@ -9,7 +9,7 @@ import { join } from 'node:path';
|
|
|
9
9
|
import { readCliToken } from './cli-auth.mjs';
|
|
10
10
|
import { saveProfile, loadProfiles } from './profile-resolver.mjs';
|
|
11
11
|
import { apiBase } from './api-utils.mjs';
|
|
12
|
-
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
12
|
+
import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
|
|
13
13
|
|
|
14
14
|
const META_FILE = 'team-jira-meta.json';
|
|
15
15
|
|
|
@@ -78,11 +78,23 @@ function teamConfigToCliProfile(apiData) {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Writes team config to the named profile without touching credentials.
|
|
81
|
+
* Carries forward `allowPrivateIp` from the existing LOCAL profile only —
|
|
82
|
+
* never from server data (see teamConfigToCliProfile) — and only when the
|
|
83
|
+
* synced baseUrl resolves to the SAME hostname the trust was granted for.
|
|
84
|
+
* A sync that changes the host (legitimate re-point, or a compromised/
|
|
85
|
+
* malicious sync response) must never inherit trust for a different,
|
|
86
|
+
* unconfirmed host — the member has to re-confirm via the wizard.
|
|
81
87
|
* Returns { error, message } on failure — never throws.
|
|
82
88
|
*/
|
|
83
89
|
async function applyTeamJiraConfig(groupName, profileData, configDir) {
|
|
84
90
|
try {
|
|
85
|
-
|
|
91
|
+
const existing = loadProfiles(configDir)?.profiles?.[groupName];
|
|
92
|
+
const sameHost = existing?.baseUrl && profileData.baseUrl &&
|
|
93
|
+
hostnameOf(existing.baseUrl) === hostnameOf(profileData.baseUrl);
|
|
94
|
+
const merged = (existing?.allowPrivateIp && sameHost)
|
|
95
|
+
? { ...profileData, allowPrivateIp: true }
|
|
96
|
+
: profileData;
|
|
97
|
+
await saveProfile(groupName, merged, null, configDir);
|
|
86
98
|
return { ok: true };
|
|
87
99
|
} catch (e) {
|
|
88
100
|
return { error: 'save-failed', message: e.message };
|