@apitap/core 1.8.1 → 1.9.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/dist/cli.js +325 -0
- package/dist/cli.js.map +1 -1
- package/dist/skill/github.d.ts +105 -0
- package/dist/skill/github.js +410 -0
- package/dist/skill/github.js.map +1 -0
- package/dist/skill/signing.js +6 -3
- package/dist/skill/signing.js.map +1 -1
- package/dist/skill/store.js +12 -0
- package/dist/skill/store.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
- package/src/cli.ts +368 -0
- package/src/skill/github.ts +542 -0
- package/src/skill/signing.ts +6 -3
- package/src/skill/store.ts +16 -0
- package/src/types.ts +2 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
// src/skill/github.ts
|
|
2
|
+
import { execFileSync as _execFileSync } from 'node:child_process';
|
|
3
|
+
import { resolveAndValidateUrl as _resolveAndValidateUrl } from './ssrf.js';
|
|
4
|
+
|
|
5
|
+
// ─── Token resolution ─────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
// Indirection so tests can inject a fake without patching non-configurable
|
|
8
|
+
// built-in module exports.
|
|
9
|
+
let _execFileSyncImpl: typeof _execFileSync = _execFileSync;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Override the execFileSync implementation — for testing only.
|
|
13
|
+
* Returns the previous implementation so callers can restore it.
|
|
14
|
+
*/
|
|
15
|
+
export function _setExecFileSync(impl: typeof _execFileSync): typeof _execFileSync {
|
|
16
|
+
const prev = _execFileSyncImpl;
|
|
17
|
+
_execFileSyncImpl = impl;
|
|
18
|
+
return prev;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let cachedToken: string | null | undefined; // undefined = not yet resolved
|
|
22
|
+
|
|
23
|
+
/** Reset token cache — for testing only. */
|
|
24
|
+
export function resetTokenCache(): void {
|
|
25
|
+
cachedToken = undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a GitHub token. Tries gh CLI first, then GITHUB_TOKEN env var.
|
|
30
|
+
* Caches the result (even null) for the session.
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveGitHubToken(): Promise<string | null> {
|
|
33
|
+
if (cachedToken !== undefined) return cachedToken;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
cachedToken = _execFileSyncImpl('gh', ['auth', 'token'], { timeout: 2000 })
|
|
37
|
+
.toString()
|
|
38
|
+
.trim();
|
|
39
|
+
if (!cachedToken) {
|
|
40
|
+
// gh returned empty output — treat as failure
|
|
41
|
+
throw new Error('empty token');
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
cachedToken = process.env.GITHUB_TOKEN ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (cachedToken === null) {
|
|
48
|
+
console.error(
|
|
49
|
+
"Warning: No GitHub token found — rate limited to 60 req/hr. " +
|
|
50
|
+
"Run 'gh auth login' or set GITHUB_TOKEN.",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return cachedToken;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── GitHub API helper ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export interface RateLimit {
|
|
60
|
+
remaining: number;
|
|
61
|
+
limit: number;
|
|
62
|
+
resetAt: Date;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetch a GitHub API path and return parsed JSON plus rate-limit metadata.
|
|
69
|
+
* Throws with a descriptive message on HTTP errors and size overruns.
|
|
70
|
+
*/
|
|
71
|
+
export async function githubFetch(
|
|
72
|
+
path: string,
|
|
73
|
+
token: string | null,
|
|
74
|
+
): Promise<{ data: any; rateLimit: RateLimit }> {
|
|
75
|
+
const url = `https://api.github.com${path}`;
|
|
76
|
+
const headers: Record<string, string> = {
|
|
77
|
+
Accept: 'application/vnd.github+json',
|
|
78
|
+
'User-Agent': 'apitap-import',
|
|
79
|
+
};
|
|
80
|
+
if (token) {
|
|
81
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const response = await fetch(url, {
|
|
85
|
+
headers,
|
|
86
|
+
signal: AbortSignal.timeout(30_000),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Parse rate limit before checking status (available even on error responses).
|
|
90
|
+
const rateLimit: RateLimit = {
|
|
91
|
+
remaining: parseInt(response.headers.get('x-ratelimit-remaining') ?? '0', 10),
|
|
92
|
+
limit: parseInt(response.headers.get('x-ratelimit-limit') ?? '0', 10),
|
|
93
|
+
resetAt: new Date(
|
|
94
|
+
parseInt(response.headers.get('x-ratelimit-reset') ?? '0', 10) * 1000,
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
if (response.status === 403 && rateLimit.remaining === 0) {
|
|
100
|
+
const err = new Error(
|
|
101
|
+
`GitHub API rate limit exhausted. Resets at ${rateLimit.resetAt.toLocaleTimeString()}.` +
|
|
102
|
+
` Run 'gh auth login' for higher limits.`,
|
|
103
|
+
);
|
|
104
|
+
(err as any).status = 403;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
if (response.status === 429) {
|
|
108
|
+
const retryAfter = response.headers.get('retry-after');
|
|
109
|
+
const waitMsg = retryAfter ? ` Retry after ${retryAfter}s.` : '';
|
|
110
|
+
const err = new Error(`GitHub secondary rate limit hit.${waitMsg}`);
|
|
111
|
+
(err as any).status = 429;
|
|
112
|
+
(err as any).retryAfter = retryAfter ? parseInt(retryAfter, 10) : undefined;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
const err = new Error(
|
|
116
|
+
`GitHub API ${response.status} ${response.statusText} for ${path}`,
|
|
117
|
+
);
|
|
118
|
+
(err as any).status = response.status;
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Size check via Content-Length header (fast path, avoids reading body).
|
|
123
|
+
const contentLength = response.headers.get('content-length');
|
|
124
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`GitHub API response too large: ${contentLength} bytes (limit: ${MAX_RESPONSE_SIZE})`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const text = await response.text();
|
|
131
|
+
if (text.length > MAX_RESPONSE_SIZE) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`GitHub API response body too large: ${text.length} bytes (limit: ${MAX_RESPONSE_SIZE})`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { data: JSON.parse(text), rateLimit };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Template domain normalizer ───────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Strips leading `{var}.` template segments from a domain name.
|
|
144
|
+
* e.g. "{region}.api.example.com" → "api.example.com"
|
|
145
|
+
*/
|
|
146
|
+
export function normalizeTemplatedDomain(domain: string): string {
|
|
147
|
+
let d = domain;
|
|
148
|
+
while (d.startsWith('{')) {
|
|
149
|
+
const next = d.replace(/^\{[^}]+\}\./, '');
|
|
150
|
+
if (next === d) break; // malformed template — stop
|
|
151
|
+
d = next;
|
|
152
|
+
}
|
|
153
|
+
return d;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pre-process spec's server URLs to normalize templated domains.
|
|
158
|
+
* Mutates the spec object in place — intentional, called before convertOpenAPISpec() consumes it.
|
|
159
|
+
*/
|
|
160
|
+
export function normalizeSpecServerUrls(spec: Record<string, any>): void {
|
|
161
|
+
if (!spec.servers) return;
|
|
162
|
+
for (const server of spec.servers) {
|
|
163
|
+
if (!server.url || !server.url.includes('{')) continue;
|
|
164
|
+
try {
|
|
165
|
+
const url = new URL(server.url);
|
|
166
|
+
url.hostname = normalizeTemplatedDomain(url.hostname);
|
|
167
|
+
server.url = url.toString();
|
|
168
|
+
} catch {
|
|
169
|
+
// URL with leading template like https://{region}.sentry.io fails new URL()
|
|
170
|
+
const match = server.url.match(/^(https?:\/\/)([^/]+)(.*)/);
|
|
171
|
+
if (match) {
|
|
172
|
+
const normalized = normalizeTemplatedDomain(match[2]);
|
|
173
|
+
server.url = match[1] + normalized + match[3];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── Result types shared across GitHub import tasks ───────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Represents a single OpenAPI spec file discovered in a GitHub repository.
|
|
183
|
+
* Returned by org-scan and topic-search importers; consumed by the CLI handler.
|
|
184
|
+
*/
|
|
185
|
+
export interface GitHubSpecResult {
|
|
186
|
+
owner: string;
|
|
187
|
+
repo: string;
|
|
188
|
+
repoFullName: string; // e.g. "cloudflare/api-schemas"
|
|
189
|
+
filePath: string; // e.g. "openapi.json" or "specs/openapi.json"
|
|
190
|
+
htmlUrl: string; // GitHub web URL for the file (for dedup + dry-run display)
|
|
191
|
+
specUrl: string; // raw.githubusercontent.com URL for fetching content
|
|
192
|
+
stars: number;
|
|
193
|
+
isFork: boolean;
|
|
194
|
+
isArchived: boolean;
|
|
195
|
+
pushedAt: string; // ISO timestamp
|
|
196
|
+
description: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Filter pipeline ──────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export interface FilterOptions {
|
|
202
|
+
includeStale?: boolean;
|
|
203
|
+
minStars?: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface FilterResult {
|
|
207
|
+
passed: GitHubSpecResult[];
|
|
208
|
+
skips: Array<{ repo: string; reason: string }>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Repos not pushed to in 3 years are considered stale. */
|
|
212
|
+
const STALE_THRESHOLD_MS = 3 * 365 * 24 * 60 * 60 * 1000;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Run results through the metadata filter pipeline.
|
|
216
|
+
* Skips forks, archived repos, stale repos (>3 years, unless includeStale),
|
|
217
|
+
* and repos below the minStars threshold.
|
|
218
|
+
*/
|
|
219
|
+
export function filterResults(
|
|
220
|
+
results: GitHubSpecResult[],
|
|
221
|
+
options: FilterOptions,
|
|
222
|
+
): FilterResult {
|
|
223
|
+
const passed: GitHubSpecResult[] = [];
|
|
224
|
+
const skips: Array<{ repo: string; reason: string }> = [];
|
|
225
|
+
|
|
226
|
+
for (const result of results) {
|
|
227
|
+
if (result.isFork) {
|
|
228
|
+
skips.push({ repo: result.repoFullName, reason: 'fork' });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (result.isArchived) {
|
|
232
|
+
skips.push({ repo: result.repoFullName, reason: 'archived' });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!options.includeStale) {
|
|
236
|
+
const pushedMs = new Date(result.pushedAt).getTime();
|
|
237
|
+
if (Date.now() - pushedMs > STALE_THRESHOLD_MS) {
|
|
238
|
+
const pushedDate = new Date(result.pushedAt).toISOString().slice(0, 10);
|
|
239
|
+
skips.push({ repo: result.repoFullName, reason: `stale, last push ${pushedDate}` });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (options.minStars !== undefined && result.stars < options.minStars) {
|
|
244
|
+
skips.push({ repo: result.repoFullName, reason: `${result.stars} stars, below --min-stars ${options.minStars}` });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
passed.push(result);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { passed, skips };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Org Scan — Code Search ───────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
export const SPEC_FILENAMES = ['openapi.json', 'openapi.yaml', 'swagger.json', 'swagger.yaml'];
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Uses GitHub's Code Search API to find OpenAPI spec files across an org's repos.
|
|
259
|
+
* Queries run sequentially — GitHub's code search has a secondary rate limit of
|
|
260
|
+
* 30 req/min (authenticated). Parallel fan-out risks immediate 403.
|
|
261
|
+
*/
|
|
262
|
+
export async function searchOrgSpecs(
|
|
263
|
+
org: string,
|
|
264
|
+
token: string | null,
|
|
265
|
+
): Promise<GitHubSpecResult[]> {
|
|
266
|
+
const allItems: GitHubSpecResult[] = [];
|
|
267
|
+
const seen = new Set<string>();
|
|
268
|
+
|
|
269
|
+
// Sequential queries — GitHub's code search has a secondary rate limit
|
|
270
|
+
// of 30 req/min (authenticated). Parallel fan-out risks immediate 403.
|
|
271
|
+
for (const filename of SPEC_FILENAMES) {
|
|
272
|
+
const q = encodeURIComponent(`filename:${filename} org:${org}`);
|
|
273
|
+
let response;
|
|
274
|
+
try {
|
|
275
|
+
response = await githubFetch(`/search/code?q=${q}&per_page=100`, token);
|
|
276
|
+
} catch (err: any) {
|
|
277
|
+
if (err.status === 422) {
|
|
278
|
+
throw new Error(`GitHub org '${org}' not found.`);
|
|
279
|
+
}
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const item of response.data.items ?? []) {
|
|
284
|
+
const htmlUrl: string = item.html_url;
|
|
285
|
+
if (seen.has(htmlUrl)) continue;
|
|
286
|
+
seen.add(htmlUrl);
|
|
287
|
+
|
|
288
|
+
const repo = item.repository;
|
|
289
|
+
allItems.push({
|
|
290
|
+
owner: repo.owner?.login ?? org,
|
|
291
|
+
repo: repo.name,
|
|
292
|
+
repoFullName: repo.full_name,
|
|
293
|
+
filePath: item.path,
|
|
294
|
+
htmlUrl,
|
|
295
|
+
specUrl: `https://raw.githubusercontent.com/${repo.full_name}/${repo.default_branch ?? 'main'}/${item.path}`,
|
|
296
|
+
stars: repo.stargazers_count ?? 0,
|
|
297
|
+
isFork: repo.fork ?? false,
|
|
298
|
+
isArchived: repo.archived ?? false,
|
|
299
|
+
pushedAt: repo.pushed_at ?? '',
|
|
300
|
+
description: repo.description ?? '',
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Rank: stars desc, then path depth asc (shallower = more likely canonical)
|
|
306
|
+
allItems.sort((a, b) => {
|
|
307
|
+
if (b.stars !== a.stars) return b.stars - a.stars;
|
|
308
|
+
const depthA = a.filePath.split('/').length;
|
|
309
|
+
const depthB = b.filePath.split('/').length;
|
|
310
|
+
return depthA - depthB;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return allItems;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Topic Search — Repo Discovery + Spec Probing ─────────────────────────────
|
|
317
|
+
|
|
318
|
+
export const CANONICAL_TOPICS = ['openapi-specification', 'openapi', 'openapi3', 'swagger-api'];
|
|
319
|
+
|
|
320
|
+
const PROBE_DIRS = ['', 'api', 'spec', 'docs'];
|
|
321
|
+
|
|
322
|
+
async function probeForSpecs(
|
|
323
|
+
owner: string,
|
|
324
|
+
repo: string,
|
|
325
|
+
token: string | null,
|
|
326
|
+
): Promise<Array<{ path: string; htmlUrl: string }>> {
|
|
327
|
+
const found: Array<{ path: string; htmlUrl: string }> = [];
|
|
328
|
+
|
|
329
|
+
for (const dir of PROBE_DIRS) {
|
|
330
|
+
const contentsPath = dir
|
|
331
|
+
? `/repos/${owner}/${repo}/contents/${dir}`
|
|
332
|
+
: `/repos/${owner}/${repo}/contents`;
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const { data } = await githubFetch(contentsPath, token);
|
|
336
|
+
if (!Array.isArray(data)) continue;
|
|
337
|
+
|
|
338
|
+
for (const entry of data) {
|
|
339
|
+
if (SPEC_FILENAMES.includes(entry.name)) {
|
|
340
|
+
found.push({
|
|
341
|
+
path: entry.path,
|
|
342
|
+
htmlUrl: entry.html_url,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
// Directory doesn't exist or 404 — continue to next
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// If we found specs in this dir, don't probe deeper
|
|
352
|
+
if (found.length > 0) break;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return found;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function searchTopicSpecs(
|
|
359
|
+
topics: string[],
|
|
360
|
+
token: string | null,
|
|
361
|
+
options: { minStars: number; query?: string },
|
|
362
|
+
): Promise<GitHubSpecResult[]> {
|
|
363
|
+
// Fan out topic queries in parallel — repository search has more
|
|
364
|
+
// generous secondary rate limits than code search.
|
|
365
|
+
const responses = await Promise.all(
|
|
366
|
+
topics.map(topic =>
|
|
367
|
+
githubFetch(
|
|
368
|
+
`/search/repositories?q=${encodeURIComponent(`topic:${topic}`)}&sort=stars&order=desc&per_page=100`,
|
|
369
|
+
token,
|
|
370
|
+
)
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// Dedup by full_name
|
|
375
|
+
const seen = new Set<string>();
|
|
376
|
+
const repos: Array<{ owner: string; repo: string; fullName: string; stars: number; isFork: boolean; isArchived: boolean; pushedAt: string; description: string; defaultBranch: string }> = [];
|
|
377
|
+
|
|
378
|
+
for (const { data } of responses) {
|
|
379
|
+
for (const item of data.items ?? []) {
|
|
380
|
+
if (seen.has(item.full_name)) continue;
|
|
381
|
+
seen.add(item.full_name);
|
|
382
|
+
|
|
383
|
+
if (item.stargazers_count < options.minStars) continue;
|
|
384
|
+
|
|
385
|
+
if (options.query) {
|
|
386
|
+
const q = options.query.toLowerCase();
|
|
387
|
+
const name = (item.name ?? '').toLowerCase();
|
|
388
|
+
const desc = (item.description ?? '').toLowerCase();
|
|
389
|
+
if (!name.includes(q) && !desc.includes(q)) continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
repos.push({
|
|
393
|
+
owner: item.owner?.login,
|
|
394
|
+
repo: item.name,
|
|
395
|
+
fullName: item.full_name,
|
|
396
|
+
stars: item.stargazers_count ?? 0,
|
|
397
|
+
isFork: item.fork ?? false,
|
|
398
|
+
isArchived: item.archived ?? false,
|
|
399
|
+
pushedAt: item.pushed_at ?? '',
|
|
400
|
+
description: item.description ?? '',
|
|
401
|
+
defaultBranch: item.default_branch ?? 'main',
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Probe each repo for spec files
|
|
407
|
+
const results: GitHubSpecResult[] = [];
|
|
408
|
+
for (const repo of repos) {
|
|
409
|
+
const specs = await probeForSpecs(repo.owner, repo.repo, token);
|
|
410
|
+
for (const spec of specs) {
|
|
411
|
+
results.push({
|
|
412
|
+
owner: repo.owner,
|
|
413
|
+
repo: repo.repo,
|
|
414
|
+
repoFullName: repo.fullName,
|
|
415
|
+
filePath: spec.path,
|
|
416
|
+
htmlUrl: spec.htmlUrl,
|
|
417
|
+
specUrl: `https://raw.githubusercontent.com/${repo.fullName}/${repo.defaultBranch}/${spec.path}`,
|
|
418
|
+
stars: repo.stars,
|
|
419
|
+
isFork: repo.isFork,
|
|
420
|
+
isArchived: repo.isArchived,
|
|
421
|
+
pushedAt: repo.pushedAt,
|
|
422
|
+
description: repo.description,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Sort by stars desc
|
|
428
|
+
results.sort((a, b) => b.stars - a.stars);
|
|
429
|
+
|
|
430
|
+
return results;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ─── Spec content predicates ──────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Returns true if the spec has a usable server URL —
|
|
437
|
+
* either OpenAPI 3.x `servers[0].url` or Swagger 2.0 `host`.
|
|
438
|
+
*/
|
|
439
|
+
export function hasServerUrl(spec: Record<string, any>): boolean {
|
|
440
|
+
if (spec.host) return true;
|
|
441
|
+
if (Array.isArray(spec.servers) && spec.servers.length > 0 && spec.servers[0].url) {
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ─── SSRF DI hook ─────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
// Indirection so tests can inject a fake resolveAndValidateUrl without network.
|
|
450
|
+
let _resolveAndValidateUrlImpl: typeof _resolveAndValidateUrl = _resolveAndValidateUrl;
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Override the resolveAndValidateUrl implementation — for testing only.
|
|
454
|
+
* Returns the previous implementation so callers can restore it.
|
|
455
|
+
*/
|
|
456
|
+
export function _setResolveAndValidateUrl(
|
|
457
|
+
impl: typeof _resolveAndValidateUrl,
|
|
458
|
+
): typeof _resolveAndValidateUrl {
|
|
459
|
+
const prev = _resolveAndValidateUrlImpl;
|
|
460
|
+
_resolveAndValidateUrlImpl = impl;
|
|
461
|
+
return prev;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ─── Spec content fetching ────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
const MAX_SPEC_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Fetch an OpenAPI spec from raw.githubusercontent.com.
|
|
470
|
+
* Uses direct fetch() — does NOT use githubFetch() since this is a different host.
|
|
471
|
+
* raw.githubusercontent.com requests do not count against the GitHub API rate limit.
|
|
472
|
+
* Auth token is sent to raw.githubusercontent.com (GitHub-controlled domain) for private repo support.
|
|
473
|
+
*/
|
|
474
|
+
export async function fetchGitHubSpec(
|
|
475
|
+
specUrl: string,
|
|
476
|
+
token: string | null,
|
|
477
|
+
): Promise<Record<string, any>> {
|
|
478
|
+
const ssrf = await _resolveAndValidateUrlImpl(specUrl);
|
|
479
|
+
if (!ssrf.safe) {
|
|
480
|
+
throw new Error(`SSRF check failed for spec URL ${specUrl}: ${ssrf.reason}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const headers: Record<string, string> = { 'User-Agent': 'apitap-import' };
|
|
484
|
+
if (token) {
|
|
485
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const response = await fetch(specUrl, {
|
|
489
|
+
headers,
|
|
490
|
+
signal: AbortSignal.timeout(30_000),
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
if (!response.ok) {
|
|
494
|
+
throw new Error(`HTTP ${response.status} ${response.statusText} for ${specUrl}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const contentLength = response.headers.get('content-length');
|
|
498
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_SPEC_SIZE) {
|
|
499
|
+
throw new Error(`Spec too large: ${contentLength} bytes (limit: ${MAX_SPEC_SIZE})`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const text = await response.text();
|
|
503
|
+
if (text.length > MAX_SPEC_SIZE) {
|
|
504
|
+
throw new Error(`Spec body too large: ${text.length} bytes`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Try JSON first, then YAML
|
|
508
|
+
try {
|
|
509
|
+
return JSON.parse(text) as Record<string, any>;
|
|
510
|
+
} catch {
|
|
511
|
+
const yaml = await import('js-yaml');
|
|
512
|
+
const parsed = yaml.load(text);
|
|
513
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
514
|
+
throw new Error(`Invalid JSON/YAML from ${specUrl}`);
|
|
515
|
+
}
|
|
516
|
+
return parsed as Record<string, any>;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Placeholder domains that indicate a spec is not pointing at a real API. */
|
|
521
|
+
const PLACEHOLDER_HOSTS = ['localhost', '127.0.0.1', 'example.com', 'petstore.swagger.io'];
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Returns true if the spec's server URL points at localhost, a loopback address,
|
|
525
|
+
* or a well-known placeholder domain (example.com, petstore.swagger.io).
|
|
526
|
+
*/
|
|
527
|
+
export function isLocalhostSpec(spec: Record<string, any>): boolean {
|
|
528
|
+
const urls: string[] = [];
|
|
529
|
+
|
|
530
|
+
if (spec.host) {
|
|
531
|
+
urls.push(spec.host);
|
|
532
|
+
}
|
|
533
|
+
if (Array.isArray(spec.servers)) {
|
|
534
|
+
for (const server of spec.servers) {
|
|
535
|
+
if (server.url) urls.push(server.url);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return urls.some(u =>
|
|
540
|
+
PLACEHOLDER_HOSTS.some(ph => u.includes(ph)),
|
|
541
|
+
);
|
|
542
|
+
}
|
package/src/skill/signing.ts
CHANGED
|
@@ -35,10 +35,12 @@ function sortKeysDeep(value: unknown): unknown {
|
|
|
35
35
|
* Sign a skill file. Returns a new object with signature and provenance: 'self'.
|
|
36
36
|
*/
|
|
37
37
|
export function signSkillFile(skill: SkillFile, key: Buffer): SkillFile {
|
|
38
|
-
const
|
|
38
|
+
const signedAt = new Date().toISOString();
|
|
39
|
+
const payload = canonicalize({ ...skill, signedAt } as SkillFile);
|
|
39
40
|
const signature = hmacSign(payload, key);
|
|
40
41
|
return {
|
|
41
42
|
...skill,
|
|
43
|
+
signedAt,
|
|
42
44
|
provenance: 'self',
|
|
43
45
|
signature,
|
|
44
46
|
};
|
|
@@ -53,9 +55,10 @@ export function signSkillFileAs(
|
|
|
53
55
|
key: Buffer,
|
|
54
56
|
provenance: 'self' | 'imported-signed',
|
|
55
57
|
): SkillFile {
|
|
56
|
-
const
|
|
58
|
+
const signedAt = new Date().toISOString();
|
|
59
|
+
const payload = canonicalize({ ...skill, signedAt } as SkillFile);
|
|
57
60
|
const signature = hmacSign(payload, key);
|
|
58
|
-
return { ...skill, provenance, signature };
|
|
61
|
+
return { ...skill, signedAt, provenance, signature };
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
/**
|
package/src/skill/store.ts
CHANGED
|
@@ -13,6 +13,8 @@ auth.enc
|
|
|
13
13
|
*.key
|
|
14
14
|
`;
|
|
15
15
|
|
|
16
|
+
const MAX_SIGNATURE_AGE_DAYS = 180;
|
|
17
|
+
|
|
16
18
|
function skillPath(domain: string, skillsDir: string): string {
|
|
17
19
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(domain)) {
|
|
18
20
|
throw new Error(`Invalid domain: ${domain}`);
|
|
@@ -125,6 +127,20 @@ export async function readSkillFile(
|
|
|
125
127
|
if (!verified) {
|
|
126
128
|
throw new Error(`Skill file signature verification failed for ${domain} — file may be tampered`);
|
|
127
129
|
}
|
|
130
|
+
|
|
131
|
+
if (skill.signedAt) {
|
|
132
|
+
const signedAtMs = Date.parse(skill.signedAt);
|
|
133
|
+
if (!Number.isNaN(signedAtMs)) {
|
|
134
|
+
const ageMs = Date.now() - signedAtMs;
|
|
135
|
+
const maxAgeMs = MAX_SIGNATURE_AGE_DAYS * 24 * 60 * 60 * 1000;
|
|
136
|
+
if (ageMs > maxAgeMs) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Skill file signature is stale for ${domain} (signed ${skill.signedAt}). ` +
|
|
139
|
+
`Re-capture or re-import to refresh signature.`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
128
144
|
}
|
|
129
145
|
}
|
|
130
146
|
|
package/src/types.ts
CHANGED
|
@@ -143,6 +143,8 @@ export interface SkillFile {
|
|
|
143
143
|
version: string;
|
|
144
144
|
domain: string;
|
|
145
145
|
capturedAt: string;
|
|
146
|
+
/** Signature timestamp (ISO) used for anti-replay staleness checks */
|
|
147
|
+
signedAt?: string;
|
|
146
148
|
baseUrl: string;
|
|
147
149
|
endpoints: SkillEndpoint[];
|
|
148
150
|
metadata: {
|