ticketlens 0.9.8 → 0.9.9

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.9.8",
3
+ "version": "0.9.9",
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": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.9.7 -->
1
+ <!-- jtb-skill-version: 0.9.9 -->
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.
@@ -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 = 'http://api.ticketlens.test';
6
+ export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
7
7
 
8
8
  // Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
9
9
  // with an optional port — all treated as local-only addresses.
@@ -83,7 +83,8 @@ export async function downloadAttachments(ticket, opts = {}) {
83
83
  onProgress?.(` download ${a.filename}`);
84
84
  try {
85
85
  const headers = buildAuthHeader(env);
86
- const response = await fetcher(a.content, { headers });
86
+ // redirect:'error' prevents a Jira redirect from leaking auth headers to a different origin.
87
+ const response = await fetcher(a.content, { headers, redirect: 'error' });
87
88
  if (!response.ok) throw new Error(`HTTP ${response.status} (${response.statusText})`);
88
89
  const buffer = await response.arrayBuffer();
89
90
  fs.writeFileSync(localPath, Buffer.from(buffer));
@@ -19,7 +19,14 @@ export const DEFAULT_BRIEF_TTL = '4h'; // human-readable default for display/con
19
19
  export function briefCachePath(ticketKey, profileName, configDir = DEFAULT_CONFIG_DIR) {
20
20
  const safeProfile = (profileName || '_default').replace(/[^a-zA-Z0-9_\-]/g, '_');
21
21
  const safeKey = ticketKey.replace(/[^a-zA-Z0-9_\-]/g, '_');
22
- return path.join(configDir, 'cache', safeProfile, safeKey, 'brief.json');
22
+ const resolvedDir = path.resolve(configDir);
23
+ const result = path.join(resolvedDir, 'cache', safeProfile, safeKey, 'brief.json');
24
+ // Defense-in-depth: ensure the final path cannot escape the config directory,
25
+ // even if configDir itself is manipulated or the sanitization above is weakened.
26
+ if (!result.startsWith(resolvedDir + path.sep)) {
27
+ throw new Error(`Cache path escapes config directory: ${result}`);
28
+ }
29
+ return result;
23
30
  }
24
31
 
25
32
  /**
@@ -81,6 +81,35 @@ export function normalizeTicket(raw) {
81
81
  };
82
82
  }
83
83
 
84
+ // RFC-1918 + link-local + loopback + localhost blocked to prevent SSRF.
85
+ const BLOCKED_PATTERNS = [
86
+ /^127\./,
87
+ /^10\./,
88
+ /^172\.(1[6-9]|2\d|3[01])\./,
89
+ /^192\.168\./,
90
+ /^169\.254\./,
91
+ /^0\.0\.0\.0$/,
92
+ /^::1$/,
93
+ /^::ffff:/i,
94
+ /^fc00:/i,
95
+ /^fe80:/i,
96
+ /^localhost$/i,
97
+ ];
98
+
99
+ export function validateBaseUrl(url) {
100
+ let parsed;
101
+ try { parsed = new URL(url); } catch {
102
+ throw new Error(`JIRA_BASE_URL is not a valid URL: ${url}`);
103
+ }
104
+ if (parsed.protocol !== 'https:') {
105
+ throw new Error(`JIRA_BASE_URL must use HTTPS (got ${parsed.protocol})`);
106
+ }
107
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
108
+ if (BLOCKED_PATTERNS.some(re => re.test(hostname))) {
109
+ throw new Error(`JIRA_BASE_URL hostname is blocked (${hostname})`);
110
+ }
111
+ }
112
+
84
113
  export function buildAuthHeader(env) {
85
114
  if (env.JIRA_PAT) {
86
115
  return { Authorization: `Bearer ${env.JIRA_PAT}` };
@@ -91,6 +120,7 @@ export function buildAuthHeader(env) {
91
120
 
92
121
  export async function fetchCurrentUser(opts = {}) {
93
122
  const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
123
+ validateBaseUrl(env.JIRA_BASE_URL);
94
124
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
95
125
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
96
126
 
@@ -116,6 +146,7 @@ export async function fetchCurrentUser(opts = {}) {
116
146
 
117
147
  export async function fetchStatuses(opts = {}) {
118
148
  const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
149
+ validateBaseUrl(env.JIRA_BASE_URL);
119
150
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
120
151
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
121
152
 
@@ -136,6 +167,7 @@ export async function fetchStatuses(opts = {}) {
136
167
 
137
168
  export async function searchTickets(jql, opts = {}) {
138
169
  const { env = process.env, fetcher = globalThis.fetch, maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false } = opts;
170
+ validateBaseUrl(env.JIRA_BASE_URL);
139
171
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
140
172
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
141
173
 
@@ -163,6 +195,7 @@ export async function searchTickets(jql, opts = {}) {
163
195
 
164
196
  export async function fetchRemoteLinks(ticketKey, opts = {}) {
165
197
  const { env = process.env, fetcher = globalThis.fetch, apiVersion = 2, timeoutMs = 10_000 } = opts;
198
+ validateBaseUrl(env.JIRA_BASE_URL);
166
199
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
167
200
  const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/remotelink`;
168
201
 
@@ -180,6 +213,7 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
180
213
 
181
214
  export async function fetchTicket(ticketKey, opts = {}) {
182
215
  const { env = process.env, fetcher = globalThis.fetch, depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, _visited = new Set(), _currentDepth = 0 } = opts;
216
+ validateBaseUrl(env.JIRA_BASE_URL);
183
217
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
184
218
  const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
185
219