@apitap/core 1.4.0 → 1.4.2

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.
Files changed (74) hide show
  1. package/README.md +4 -2
  2. package/dist/auth/crypto.d.ts +10 -0
  3. package/dist/auth/crypto.js +30 -6
  4. package/dist/auth/crypto.js.map +1 -1
  5. package/dist/auth/handoff.js +20 -1
  6. package/dist/auth/handoff.js.map +1 -1
  7. package/dist/auth/manager.d.ts +1 -0
  8. package/dist/auth/manager.js +35 -9
  9. package/dist/auth/manager.js.map +1 -1
  10. package/dist/capture/monitor.js +4 -0
  11. package/dist/capture/monitor.js.map +1 -1
  12. package/dist/capture/scrubber.js +10 -0
  13. package/dist/capture/scrubber.js.map +1 -1
  14. package/dist/capture/session.js +7 -17
  15. package/dist/capture/session.js.map +1 -1
  16. package/dist/cli.js +74 -17
  17. package/dist/cli.js.map +1 -1
  18. package/dist/discovery/fetch.js +3 -3
  19. package/dist/discovery/fetch.js.map +1 -1
  20. package/dist/mcp.d.ts +2 -0
  21. package/dist/mcp.js +59 -33
  22. package/dist/mcp.js.map +1 -1
  23. package/dist/native-host.js +2 -2
  24. package/dist/native-host.js.map +1 -1
  25. package/dist/orchestration/browse.js +13 -4
  26. package/dist/orchestration/browse.js.map +1 -1
  27. package/dist/plugin.d.ts +1 -1
  28. package/dist/plugin.js +14 -4
  29. package/dist/plugin.js.map +1 -1
  30. package/dist/read/decoders/reddit.js +4 -0
  31. package/dist/read/decoders/reddit.js.map +1 -1
  32. package/dist/replay/engine.js +60 -17
  33. package/dist/replay/engine.js.map +1 -1
  34. package/dist/serve.d.ts +2 -0
  35. package/dist/serve.js +8 -1
  36. package/dist/serve.js.map +1 -1
  37. package/dist/skill/generator.d.ts +5 -0
  38. package/dist/skill/generator.js +30 -4
  39. package/dist/skill/generator.js.map +1 -1
  40. package/dist/skill/search.js +1 -1
  41. package/dist/skill/search.js.map +1 -1
  42. package/dist/skill/signing.js +19 -1
  43. package/dist/skill/signing.js.map +1 -1
  44. package/dist/skill/ssrf.js +71 -2
  45. package/dist/skill/ssrf.js.map +1 -1
  46. package/dist/skill/store.d.ts +2 -0
  47. package/dist/skill/store.js +23 -10
  48. package/dist/skill/store.js.map +1 -1
  49. package/dist/skill/validate.d.ts +10 -0
  50. package/dist/skill/validate.js +106 -0
  51. package/dist/skill/validate.js.map +1 -0
  52. package/package.json +1 -1
  53. package/src/auth/crypto.ts +14 -6
  54. package/src/auth/handoff.ts +19 -1
  55. package/src/auth/manager.ts +22 -5
  56. package/src/capture/monitor.ts +4 -0
  57. package/src/capture/scrubber.ts +12 -0
  58. package/src/capture/session.ts +5 -14
  59. package/src/cli.ts +215 -12
  60. package/src/discovery/fetch.ts +2 -2
  61. package/src/index/reader.ts +65 -0
  62. package/src/mcp.ts +64 -31
  63. package/src/native-host.ts +29 -2
  64. package/src/orchestration/browse.ts +13 -4
  65. package/src/plugin.ts +17 -5
  66. package/src/read/decoders/reddit.ts +3 -3
  67. package/src/replay/engine.ts +65 -15
  68. package/src/serve.ts +10 -1
  69. package/src/skill/generator.ts +32 -4
  70. package/src/skill/search.ts +1 -1
  71. package/src/skill/signing.ts +20 -1
  72. package/src/skill/ssrf.ts +69 -2
  73. package/src/skill/store.ts +29 -11
  74. package/src/skill/validate.ts +48 -0
@@ -27,10 +27,11 @@ async function signSkillJson(skillJson: string): Promise<string> {
27
27
  }
28
28
 
29
29
  export interface NativeRequest {
30
- action: 'save_skill' | 'save_batch' | 'ping' | 'capture_request';
30
+ action: 'save_skill' | 'save_batch' | 'ping' | 'capture_request' | 'save_index';
31
31
  domain?: string;
32
32
  skillJson?: string;
33
33
  skills?: Array<{ domain: string; skillJson: string }>;
34
+ indexJson?: string;
34
35
  }
35
36
 
36
37
  export interface NativeResponse {
@@ -115,6 +116,32 @@ export async function handleNativeMessage(
115
116
  return { success: true, paths };
116
117
  }
117
118
 
119
+ if (request.action === 'save_index') {
120
+ if (!request.indexJson) {
121
+ return { success: false, error: 'Missing indexJson' };
122
+ }
123
+ if (request.indexJson.length > 5 * 1024 * 1024) {
124
+ return { success: false, error: 'Index too large (max 5MB)' };
125
+ }
126
+ try {
127
+ JSON.parse(request.indexJson);
128
+ } catch {
129
+ return { success: false, error: 'Invalid JSON in indexJson' };
130
+ }
131
+
132
+ // index.json lives in ~/.apitap/ (parent of skills dir)
133
+ const apitapDir = path.dirname(skillsDir);
134
+ await fs.mkdir(apitapDir, { recursive: true });
135
+ const indexPath = path.join(apitapDir, 'index.json');
136
+
137
+ // Atomic write: temp file + rename
138
+ const tmpPath = indexPath + '.tmp.' + process.pid;
139
+ await fs.writeFile(tmpPath, request.indexJson, { mode: 0o600 });
140
+ await fs.rename(tmpPath, indexPath);
141
+
142
+ return { success: true, path: indexPath };
143
+ }
144
+
118
145
  return { success: false, error: `Unknown action: ${request.action}` };
119
146
  } catch (err) {
120
147
  return { success: false, error: String(err) };
@@ -124,7 +151,7 @@ export async function handleNativeMessage(
124
151
  // --- Relay handler ---
125
152
 
126
153
  // Actions handled locally by the native host (filesystem operations)
127
- const LOCAL_ACTIONS = new Set(['save_skill', 'save_batch', 'ping']);
154
+ const LOCAL_ACTIONS = new Set(['save_skill', 'save_batch', 'ping', 'save_index']);
128
155
 
129
156
  // Actions relayed to the extension (browser operations)
130
157
  const EXTENSION_ACTIONS = new Set(['capture_request']);
@@ -4,6 +4,9 @@ import { replayEndpoint } from '../replay/engine.js';
4
4
  import { SessionCache } from './cache.js';
5
5
  import { read } from '../read/index.js';
6
6
  import { bridgeAvailable, requestBridgeCapture, DEFAULT_SOCKET } from '../bridge/client.js';
7
+ import { signSkillFile } from '../skill/signing.js';
8
+ import { deriveSigningKey } from '../auth/crypto.js';
9
+ import { getMachineId } from '../auth/manager.js';
7
10
 
8
11
  export interface BrowseOptions {
9
12
  skillsDir?: string;
@@ -61,11 +64,14 @@ async function tryBridgeCapture(
61
64
 
62
65
  if (result.success && result.skillFiles && result.skillFiles.length > 0) {
63
66
  const skillFiles = result.skillFiles;
64
- // Save each skill file to disk
67
+ // Sign and save each skill file to disk
65
68
  try {
66
69
  const { writeSkillFile: writeSF } = await import('../skill/store.js');
67
- for (const skill of skillFiles) {
68
- await writeSF(skill, options.skillsDir);
70
+ const mid = await getMachineId();
71
+ const sk = deriveSigningKey(mid);
72
+ for (let i = 0; i < skillFiles.length; i++) {
73
+ skillFiles[i] = signSkillFile(skillFiles[i], sk);
74
+ await writeSF(skillFiles[i], options.skillsDir);
69
75
  }
70
76
  } catch {
71
77
  // Saving failed — still have the data in memory
@@ -202,7 +208,10 @@ export async function browse(
202
208
  skill = discovery.skillFile;
203
209
  source = 'discovered';
204
210
 
205
- // Save to disk
211
+ // Sign and save to disk (H1: skill files must be signed for verification)
212
+ const machineId = await getMachineId();
213
+ const sigKey = deriveSigningKey(machineId);
214
+ skill = signSkillFile(skill, sigKey);
206
215
  const { writeSkillFile: writeSF } = await import('../skill/store.js');
207
216
  await writeSF(skill, skillsDir);
208
217
  cache?.set(domain, skill, 'discovered');
package/src/plugin.ts CHANGED
@@ -21,12 +21,17 @@ export interface Plugin {
21
21
 
22
22
  export interface PluginOptions {
23
23
  skillsDir?: string;
24
- /** @internal Skip SSRF check for testing only */
24
+ /** @internal Testing onlynever expose to external callers (M14) */
25
25
  _skipSsrfCheck?: boolean;
26
26
  }
27
27
 
28
28
  const APITAP_DIR = join(homedir(), '.apitap');
29
29
 
30
+ /** M20: Mark plugin responses as untrusted external content */
31
+ function wrapUntrusted(data: unknown): unknown {
32
+ return { ...data as Record<string, unknown>, _meta: { externalContent: { untrusted: true } } };
33
+ }
34
+
30
35
  export function createPlugin(options: PluginOptions = {}): Plugin {
31
36
  const skillsDir = options.skillsDir;
32
37
 
@@ -53,7 +58,7 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
53
58
  },
54
59
  execute: async (args) => {
55
60
  const query = args.query as string;
56
- return searchSkills(query, skillsDir);
61
+ return wrapUntrusted(await searchSkills(query, skillsDir));
57
62
  },
58
63
  };
59
64
 
@@ -88,7 +93,7 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
88
93
  const endpointId = args.endpointId as string;
89
94
  const params = args.params as Record<string, string> | undefined;
90
95
 
91
- const skill = await readSkillFile(domain, skillsDir);
96
+ const skill = await readSkillFile(domain, skillsDir, { trustUnsigned: true });
92
97
  if (!skill) {
93
98
  return {
94
99
  error: `No skill file found for "${domain}". Use apitap_capture to capture it first.`,
@@ -104,7 +109,7 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
104
109
  domain,
105
110
  _skipSsrfCheck: options._skipSsrfCheck,
106
111
  });
107
- return { status: result.status, data: result.data };
112
+ return wrapUntrusted({ status: result.status, data: result.data });
108
113
  } catch (err: any) {
109
114
  return { error: err.message };
110
115
  }
@@ -142,6 +147,13 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
142
147
  const duration = (args.duration as number) ?? 30;
143
148
  const allDomains = (args.allDomains as boolean) ?? false;
144
149
 
150
+ // M19: SSRF validation before capture
151
+ const { resolveAndValidateUrl } = await import('./skill/ssrf.js');
152
+ const ssrfCheck = await resolveAndValidateUrl(url);
153
+ if (!ssrfCheck.safe) {
154
+ return { error: `Blocked: ${ssrfCheck.reason}` };
155
+ }
156
+
145
157
  // Shell out to CLI for capture (it handles browser lifecycle, signing, etc.)
146
158
  const { execFile } = await import('node:child_process');
147
159
  const { promisify } = await import('node:util');
@@ -155,7 +167,7 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
155
167
  timeout: (duration + 30) * 1000,
156
168
  env: { ...process.env, ...(skillsDir ? { APITAP_SKILLS_DIR: skillsDir } : {}) },
157
169
  });
158
- return JSON.parse(stdout);
170
+ return wrapUntrusted(JSON.parse(stdout));
159
171
  } catch (err: any) {
160
172
  return { error: `Capture failed: ${err.message}` };
161
173
  }
@@ -58,9 +58,9 @@ async function recoverDeletedComments(
58
58
  const recovered = new Map<string, { author: string; body: string }>();
59
59
  if (commentIds.length === 0) return recovered;
60
60
 
61
- // Third-party disclosure: PullPush is an external service.
62
- // Users can opt out via APITAP_NO_THIRD_PARTY=1.
63
- if (process.env.APITAP_NO_THIRD_PARTY === '1') return recovered;
61
+ // M21: Third-party requests are opt-in, not opt-out.
62
+ // PullPush is an external service — only contact it if explicitly enabled.
63
+ if (process.env.APITAP_THIRD_PARTY !== '1') return recovered;
64
64
 
65
65
  try {
66
66
  const ids = commentIds.join(',');
@@ -139,6 +139,41 @@ function normalizeOptions(
139
139
  return { params: optionsOrParams as Record<string, string> };
140
140
  }
141
141
 
142
+ /**
143
+ * Check if redirect host is safe to send auth to.
144
+ * Allows exact match or single-level subdomain only (H4 fix).
145
+ */
146
+ function isSafeAuthRedirectHost(originalHost: string, redirectHost: string): boolean {
147
+ if (redirectHost === originalHost) return true;
148
+ if (redirectHost.endsWith('.' + originalHost)) {
149
+ const prefix = redirectHost.slice(0, -(originalHost.length + 1));
150
+ // Only allow single-level subdomain (no dots in prefix)
151
+ return !prefix.includes('.');
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /**
157
+ * Strip auth headers from redirect request if cross-domain (H4 fix).
158
+ * Shared between initial and retry redirect paths.
159
+ */
160
+ function stripAuthForRedirect(
161
+ headers: Record<string, string>,
162
+ originalHost: string,
163
+ redirectHost: string,
164
+ ): Record<string, string> {
165
+ const redirectHeaders = { ...headers };
166
+ if (!isSafeAuthRedirectHost(originalHost, redirectHost)) {
167
+ delete redirectHeaders['authorization'];
168
+ for (const key of Object.keys(redirectHeaders)) {
169
+ if (key.toLowerCase() === 'authorization' || redirectHeaders[key] === '[stored]') {
170
+ delete redirectHeaders[key];
171
+ }
172
+ }
173
+ }
174
+ return redirectHeaders;
175
+ }
176
+
142
177
  /**
143
178
  * Wrap a 401/403 response with structured auth guidance.
144
179
  */
@@ -209,7 +244,7 @@ export async function replayEndpoint(
209
244
  // SSRF validation — resolve DNS and check the IP isn't private/internal.
210
245
  // We do NOT substitute the IP into the URL because that breaks TLS/SNI
211
246
  // for sites behind CDNs (Cloudflare, etc.) where the cert is for the hostname.
212
- // The DNS check still prevents rebinding attacks by validating at request time.
247
+ // M15: We re-validate DNS after fetch to narrow the TOCTOU window.
213
248
  const fetchUrl = url.toString();
214
249
  if (!options._skipSsrfCheck) {
215
250
  const ssrfCheck = await resolveAndValidateUrl(url.toString());
@@ -237,6 +272,19 @@ export async function replayEndpoint(
237
272
  }
238
273
  }
239
274
 
275
+ // Domain-lock: verify request URL matches skill domain before injecting auth (C1 fix).
276
+ // validateSkillFile() enforces baseUrl-domain consistency at load time.
277
+ // This is defense-in-depth for manually-constructed SkillFile objects.
278
+ if (authManager && domain && !options._skipSsrfCheck) {
279
+ const fetchHost = url.hostname;
280
+ if (fetchHost !== skill.domain && !fetchHost.endsWith('.' + skill.domain)) {
281
+ throw new Error(
282
+ `Domain-lock violation: request host "${fetchHost}" does not match skill domain "${skill.domain}". ` +
283
+ `Auth injection blocked to prevent credential exfiltration.`
284
+ );
285
+ }
286
+ }
287
+
240
288
  // Inject auth header from auth manager (if available)
241
289
  if (authManager && domain) {
242
290
  const auth = endpoint.isolatedAuth
@@ -376,6 +424,16 @@ export async function replayEndpoint(
376
424
  redirect: 'manual', // Don't auto-follow redirects
377
425
  });
378
426
 
427
+ // M15: Post-fetch DNS re-validation to narrow TOCTOU window.
428
+ // Re-resolves DNS and checks if the hostname now points to a private IP
429
+ // (indicates DNS rebinding attack between our pre-check and the actual connection).
430
+ if (!options._skipSsrfCheck && url.hostname && !url.hostname.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
431
+ const postCheck = await resolveAndValidateUrl(fetchUrl);
432
+ if (!postCheck.safe) {
433
+ throw new Error(`DNS rebinding detected (post-fetch): ${postCheck.reason}`);
434
+ }
435
+ }
436
+
379
437
  // Handle redirects with SSRF validation (single hop only)
380
438
  if (response.status >= 300 && response.status < 400) {
381
439
  const location = response.headers.get('location');
@@ -388,18 +446,8 @@ export async function replayEndpoint(
388
446
  throw new Error(`Redirect blocked (SSRF): ${redirectCheck.reason}`);
389
447
  }
390
448
  }
391
- // Strip auth headers before cross-domain redirect
392
- const redirectHeaders = { ...headers };
393
- const originalHost = url.hostname;
394
- const redirectHost = redirectUrl.hostname;
395
- if (redirectHost !== originalHost && !redirectHost.endsWith('.' + originalHost)) {
396
- delete redirectHeaders['authorization'];
397
- for (const key of Object.keys(redirectHeaders)) {
398
- if (key.toLowerCase() === 'authorization' || redirectHeaders[key] === '[stored]') {
399
- delete redirectHeaders[key];
400
- }
401
- }
402
- }
449
+ // Strip auth headers before cross-domain redirect (uses shared function)
450
+ const redirectHeaders = stripAuthForRedirect(headers, url.hostname, redirectUrl.hostname);
403
451
  // Follow the redirect manually (single hop to prevent chains)
404
452
  response = await fetch(redirectFetchUrl, {
405
453
  method: 'GET', // Redirects typically become GET
@@ -437,7 +485,7 @@ export async function replayEndpoint(
437
485
  redirect: 'manual',
438
486
  });
439
487
 
440
- // Handle redirects on retry (single hop)
488
+ // Handle redirects on retry (single hop) — with auth stripping (H4 fix)
441
489
  if (retryResponse.status >= 300 && retryResponse.status < 400) {
442
490
  const location = retryResponse.headers.get('location');
443
491
  if (location) {
@@ -449,9 +497,11 @@ export async function replayEndpoint(
449
497
  throw new Error(`Redirect blocked (SSRF): ${redirectCheck.reason}`);
450
498
  }
451
499
  }
500
+ // Strip auth headers on cross-domain redirect (same logic as initial path)
501
+ const retryRedirectHeaders = stripAuthForRedirect(headers, url.hostname, redirectUrl.hostname);
452
502
  retryResponse = await fetch(retryRedirectFetchUrl, {
453
503
  method: 'GET',
454
- headers,
504
+ headers: retryRedirectHeaders,
455
505
  signal: AbortSignal.timeout(30_000),
456
506
  redirect: 'manual',
457
507
  });
package/src/serve.ts CHANGED
@@ -75,6 +75,8 @@ export function buildServeTools(skill: SkillFile): ServeTool[] {
75
75
  export interface ServeOptions {
76
76
  skillsDir?: string;
77
77
  noAuth?: boolean;
78
+ /** Allow loading unsigned skill files */
79
+ trustUnsigned?: boolean;
78
80
  /** @internal Skip SSRF validation — for testing only */
79
81
  _skipSsrfCheck?: boolean;
80
82
  }
@@ -87,7 +89,7 @@ export async function createServeServer(
87
89
  domain: string,
88
90
  options: ServeOptions = {},
89
91
  ): Promise<McpServer> {
90
- const skill = await readSkillFile(domain, options.skillsDir);
92
+ const skill = await readSkillFile(domain, options.skillsDir, { trustUnsigned: options.trustUnsigned });
91
93
  if (!skill) {
92
94
  throw new Error(`No skill file found for "${domain}". Run: apitap capture ${domain}`);
93
95
  }
@@ -146,11 +148,18 @@ export async function createServeServer(
146
148
  _skipSsrfCheck: options._skipSsrfCheck,
147
149
  });
148
150
 
151
+ // M22: Mark responses as untrusted external content
149
152
  return {
150
153
  content: [{
151
154
  type: 'text' as const,
152
155
  text: JSON.stringify({ status: result.status, data: result.data }),
153
156
  }],
157
+ _meta: {
158
+ externalContent: {
159
+ untrusted: true,
160
+ source: `apitap-serve-${domain}`,
161
+ },
162
+ },
154
163
  };
155
164
  } catch (err: any) {
156
165
  console.error('Replay failed:', err instanceof Error ? err.message : String(err));
@@ -316,10 +316,10 @@ export class SkillGenerator {
316
316
  this.totalNetworkBytes += exchange.response.body.length;
317
317
 
318
318
  if (this.endpoints.has(key)) {
319
- // Store duplicate body for cross-request diffing (Strategy 1)
319
+ // Store duplicate body for cross-request diffing (Strategy 1) — scrubbed (H3 fix)
320
320
  if (exchange.request.postData) {
321
321
  const bodies = this.exchangeBodies.get(key);
322
- if (bodies) bodies.push(exchange.request.postData);
322
+ if (bodies) bodies.push(this.scrubBodyString(exchange.request.postData));
323
323
  }
324
324
  return null;
325
325
  }
@@ -455,9 +455,17 @@ export class SkillGenerator {
455
455
 
456
456
  this.endpoints.set(key, endpoint);
457
457
 
458
- // Store first body for cross-request diffing
458
+ // Store first body for cross-request diffing (scrub sensitive fields — H3 fix)
459
459
  if (exchange.request.postData) {
460
- this.exchangeBodies.set(key, [exchange.request.postData]);
460
+ this.exchangeBodies.set(key, [this.scrubBodyString(exchange.request.postData)]);
461
+ }
462
+
463
+ // Clear auth values from exchange to reduce credential exposure window (H3 fix)
464
+ for (const key of Object.keys(exchange.request.headers)) {
465
+ const lower = key.toLowerCase();
466
+ if (AUTH_HEADERS.has(lower) || lower === 'cookie') {
467
+ exchange.request.headers[key] = '[scrubbed]';
468
+ }
461
469
  }
462
470
 
463
471
  return endpoint;
@@ -513,6 +521,23 @@ export class SkillGenerator {
513
521
  this.totalNetworkBytes += bytes;
514
522
  }
515
523
 
524
+ /**
525
+ * Scrub sensitive fields from a POST body string for intermediate storage (H3 fix).
526
+ * Preserves structure for cross-request diffing while removing credentials.
527
+ */
528
+ private scrubBodyString(bodyStr: string): string {
529
+ try {
530
+ const parsed = JSON.parse(bodyStr);
531
+ if (typeof parsed === 'object' && parsed !== null) {
532
+ const scrubbed = scrubBody(parsed, true) as Record<string, unknown>;
533
+ return JSON.stringify(scrubbed);
534
+ }
535
+ } catch {
536
+ // Non-JSON body — apply PII scrubber
537
+ }
538
+ return scrubPII(bodyStr);
539
+ }
540
+
516
541
  /** Generate the complete skill file for a domain. */
517
542
  toSkillFile(domain: string, options?: { domBytes?: number; totalRequests?: number }): SkillFile {
518
543
  // Apply cross-request diffing (Strategy 1) to endpoints with multiple bodies
@@ -559,6 +584,9 @@ export class SkillGenerator {
559
584
  };
560
585
  }
561
586
 
587
+ // Clear intermediate body storage to reduce credential exposure window (H3 fix)
588
+ this.exchangeBodies.clear();
589
+
562
590
  return skill;
563
591
  }
564
592
  }
@@ -37,7 +37,7 @@ export async function searchSkills(
37
37
  const results: SearchResult[] = [];
38
38
 
39
39
  for (const summary of summaries) {
40
- const skill = await readSkillFile(summary.domain, skillsDir);
40
+ const skill = await readSkillFile(summary.domain, skillsDir, { trustUnsigned: true });
41
41
  if (!skill) continue;
42
42
 
43
43
  const domainLower = skill.domain.toLowerCase();
@@ -9,7 +9,26 @@ import type { SkillFile } from '../types.js';
9
9
  */
10
10
  export function canonicalize(skill: SkillFile): string {
11
11
  const { signature: _sig, provenance: _prov, ...rest } = skill;
12
- return JSON.stringify(rest, Object.keys(rest).sort());
12
+ return JSON.stringify(sortKeysDeep(rest));
13
+ }
14
+
15
+ /**
16
+ * Recursively sort all object keys for stable canonicalization (M10 fix).
17
+ * Ensures identical skill files always produce the same canonical string
18
+ * regardless of key insertion order at any nesting level.
19
+ */
20
+ function sortKeysDeep(value: unknown): unknown {
21
+ if (Array.isArray(value)) {
22
+ return value.map(sortKeysDeep);
23
+ }
24
+ if (value !== null && typeof value === 'object') {
25
+ const sorted: Record<string, unknown> = {};
26
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
27
+ sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
28
+ }
29
+ return sorted;
30
+ }
31
+ return value;
13
32
  }
14
33
 
15
34
  /**
package/src/skill/ssrf.ts CHANGED
@@ -73,8 +73,12 @@ export function validateUrl(urlString: string): ValidationResult {
73
73
  return { safe: false, reason: `URL targets IPv6 unique-local address: ${hostname}` };
74
74
  }
75
75
 
76
- // IPv4 private ranges
77
- const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
76
+ // Normalize IP representations: decimal integer, octal, hex → dotted-decimal (M17 fix)
77
+ const normalizedIp = normalizeIpv4(hostname);
78
+ const ipToCheck = normalizedIp ?? hostname;
79
+
80
+ // IPv4 private/reserved ranges (M16: added CGNAT, IETF, benchmarking, reserved)
81
+ const ipv4Match = ipToCheck.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
78
82
  if (ipv4Match) {
79
83
  const [, a, b] = ipv4Match.map(Number);
80
84
  const first = Number(a);
@@ -104,11 +108,70 @@ export function validateUrl(urlString: string): ValidationResult {
104
108
  if (first === 169 && second === 254) {
105
109
  return { safe: false, reason: `URL targets link-local address: ${hostname}` };
106
110
  }
111
+ // 100.64.0.0/10 — CGNAT (RFC 6598), used in cloud/Tailscale
112
+ if (first === 100 && second >= 64 && second <= 127) {
113
+ return { safe: false, reason: `URL targets CGNAT address: ${hostname}` };
114
+ }
115
+ // 192.0.0.0/24 — IETF Protocol Assignments (RFC 6890)
116
+ if (first === 192 && second === 0 && Number(ipv4Match[3]) === 0) {
117
+ return { safe: false, reason: `URL targets IETF reserved address: ${hostname}` };
118
+ }
119
+ // 198.18.0.0/15 — Benchmarking (RFC 2544)
120
+ if (first === 198 && (second === 18 || second === 19)) {
121
+ return { safe: false, reason: `URL targets benchmarking address: ${hostname}` };
122
+ }
123
+ // 240.0.0.0/4 — Reserved/future use
124
+ if (first >= 240) {
125
+ return { safe: false, reason: `URL targets reserved address: ${hostname}` };
126
+ }
107
127
  }
108
128
 
109
129
  return { safe: true };
110
130
  }
111
131
 
132
+ /**
133
+ * Normalize alternative IPv4 representations to dotted-decimal.
134
+ * Handles decimal integer (2130706433), octal (0177.0.0.1), and hex (0x7f.0.0.1).
135
+ * Returns null if hostname is not an IP address or can't be parsed.
136
+ */
137
+ function normalizeIpv4(hostname: string): string | null {
138
+ // Already standard dotted-decimal
139
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
140
+ return hostname;
141
+ }
142
+
143
+ // Pure decimal integer (e.g. 2130706433 = 127.0.0.1)
144
+ if (/^\d+$/.test(hostname)) {
145
+ const num = parseInt(hostname, 10);
146
+ if (num >= 0 && num <= 0xFFFFFFFF) {
147
+ return `${(num >>> 24) & 0xFF}.${(num >>> 16) & 0xFF}.${(num >>> 8) & 0xFF}.${num & 0xFF}`;
148
+ }
149
+ }
150
+
151
+ // Dotted with octal (0-prefixed) or hex (0x-prefixed) octets
152
+ const parts = hostname.split('.');
153
+ if (parts.length === 4) {
154
+ const octets: number[] = [];
155
+ for (const part of parts) {
156
+ let val: number;
157
+ if (/^0x[0-9a-f]+$/i.test(part)) {
158
+ val = parseInt(part, 16);
159
+ } else if (/^0[0-7]+$/.test(part)) {
160
+ val = parseInt(part, 8);
161
+ } else if (/^\d+$/.test(part)) {
162
+ val = parseInt(part, 10);
163
+ } else {
164
+ return null; // Not an IP
165
+ }
166
+ if (val < 0 || val > 255) return null;
167
+ octets.push(val);
168
+ }
169
+ return octets.join('.');
170
+ }
171
+
172
+ return null;
173
+ }
174
+
112
175
  /**
113
176
  * Check if a resolved IP address is in a private/reserved range.
114
177
  */
@@ -148,6 +211,10 @@ function isPrivateIp(ip: string): string | null {
148
211
  if (first === 192 && second === 168) return 'private (192.168.x)';
149
212
  if (first === 169 && second === 254) return 'link-local';
150
213
  if (first === 0) return 'unspecified';
214
+ // M16: additional reserved ranges
215
+ if (first === 100 && second >= 64 && second <= 127) return 'CGNAT (100.64/10)';
216
+ if (first === 198 && (second === 18 || second === 19)) return 'benchmarking (198.18/15)';
217
+ if (first >= 240) return 'reserved (240/4)';
151
218
 
152
219
  return null;
153
220
  }
@@ -37,7 +37,7 @@ export async function writeSkillFile(
37
37
  skill: SkillFile,
38
38
  skillsDir: string = DEFAULT_SKILLS_DIR,
39
39
  ): Promise<string> {
40
- await mkdir(skillsDir, { recursive: true });
40
+ await mkdir(skillsDir, { recursive: true, mode: 0o700 });
41
41
  await ensureGitignore(skillsDir);
42
42
  const filePath = skillPath(skill.domain, skillsDir);
43
43
  await writeFile(filePath, JSON.stringify(skill, null, 2) + '\n', { mode: 0o600 });
@@ -47,7 +47,12 @@ export async function writeSkillFile(
47
47
  export async function readSkillFile(
48
48
  domain: string,
49
49
  skillsDir: string = DEFAULT_SKILLS_DIR,
50
- options?: { verifySignature?: boolean; signingKey?: Buffer }
50
+ options?: {
51
+ verifySignature?: boolean;
52
+ signingKey?: Buffer;
53
+ /** Allow loading unsigned files without throwing. Tampered signed files still reject. */
54
+ trustUnsigned?: boolean;
55
+ }
51
56
  ): Promise<SkillFile | null> {
52
57
  // Validate domain before file I/O — path traversal should throw, not return null
53
58
  const path = skillPath(domain, skillsDir);
@@ -56,18 +61,31 @@ export async function readSkillFile(
56
61
  const raw = JSON.parse(content);
57
62
  const skill = validateSkillFile(raw);
58
63
 
59
- // If verification requested, check signature
60
- if (options?.verifySignature && options.signingKey) {
64
+ // Signature verification is ON by default (H1 fix)
65
+ const shouldVerify = options?.verifySignature !== false;
66
+ if (shouldVerify) {
67
+ // Auto-derive signing key if not provided
68
+ let signingKey = options?.signingKey;
69
+ if (!signingKey) {
70
+ const { deriveSigningKey } = await import('../auth/crypto.js');
71
+ const { getMachineId } = await import('../auth/manager.js');
72
+ const machineId = await getMachineId();
73
+ signingKey = deriveSigningKey(machineId);
74
+ }
75
+
61
76
  if (skill.provenance === 'imported') {
62
- // Imported files had foreign signature stripped — can't verify, warn only
63
- // Future: re-sign on import with local key
77
+ // Imported files had foreign signature stripped — can't verify
64
78
  } else if (!skill.signature) {
65
- // Phase 2: warn for unsigned files, don't reject
66
- // (user-created or pre-signing files are legitimately unsigned)
67
- console.error(`[apitap] Warning: skill file for ${domain} is unsigned`);
79
+ // Unsigned files are rejected unless trustUnsigned is set
80
+ if (!options?.trustUnsigned) {
81
+ throw new Error(
82
+ `Skill file for ${domain} is unsigned and cannot be verified. ` +
83
+ `Re-capture or re-import the skill file, or use --trust-unsigned to load it.`
84
+ );
85
+ }
68
86
  } else {
69
87
  const { verifySignature } = await import('./signing.js');
70
- if (!verifySignature(skill, options.signingKey)) {
88
+ if (!verifySignature(skill, signingKey)) {
71
89
  throw new Error(`Skill file signature verification failed for ${domain} — file may be tampered`);
72
90
  }
73
91
  }
@@ -96,7 +114,7 @@ export async function listSkillFiles(
96
114
  if (!file.endsWith('.json')) continue;
97
115
  const domain = file.replace(/\.json$/, '');
98
116
  if (!DOMAIN_RE.test(domain)) continue; // skip non-conforming filenames
99
- const skill = await readSkillFile(domain, skillsDir);
117
+ const skill = await readSkillFile(domain, skillsDir, { trustUnsigned: true });
100
118
  if (skill) {
101
119
  summaries.push({
102
120
  domain: skill.domain,