@apitap/core 1.4.0 → 1.4.1

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 (72) hide show
  1. package/README.md +2 -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 +71 -11
  60. package/src/discovery/fetch.ts +2 -2
  61. package/src/mcp.ts +58 -31
  62. package/src/orchestration/browse.ts +13 -4
  63. package/src/plugin.ts +17 -5
  64. package/src/read/decoders/reddit.ts +3 -3
  65. package/src/replay/engine.ts +65 -15
  66. package/src/serve.ts +10 -1
  67. package/src/skill/generator.ts +32 -4
  68. package/src/skill/search.ts +1 -1
  69. package/src/skill/signing.ts +20 -1
  70. package/src/skill/ssrf.ts +69 -2
  71. package/src/skill/store.ts +29 -11
  72. package/src/skill/validate.ts +48 -0
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,
@@ -25,14 +25,26 @@ export function validateSkillFile(raw: unknown, options?: { checkSsrf?: boolean
25
25
  if (typeof obj.baseUrl !== 'string') {
26
26
  throw new Error('Missing baseUrl');
27
27
  }
28
+ let baseUrlHostname: string;
28
29
  try {
29
30
  const url = new URL(obj.baseUrl);
30
31
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
31
32
  throw new Error('non-HTTP scheme');
32
33
  }
34
+ baseUrlHostname = url.hostname;
33
35
  } catch {
34
36
  throw new Error(`Invalid baseUrl: must be a valid HTTP(S) URL`);
35
37
  }
38
+
39
+ // Domain-lock: baseUrl hostname must match or be a subdomain of domain (C1 fix)
40
+ const domainStr = obj.domain as string;
41
+ if (baseUrlHostname !== domainStr && !baseUrlHostname.endsWith('.' + domainStr)) {
42
+ throw new Error(
43
+ `baseUrl hostname "${baseUrlHostname}" does not match domain "${domainStr}". ` +
44
+ `Skill files cannot redirect requests to unrelated hosts.`
45
+ );
46
+ }
47
+
36
48
  if (options?.checkSsrf) {
37
49
  const ssrf = validateUrl(obj.baseUrl);
38
50
  if (!ssrf.safe) {
@@ -66,6 +78,42 @@ export function validateSkillFile(raw: unknown, options?: { checkSsrf?: boolean
66
78
  if (e.path.length > 2000) {
67
79
  throw new Error(`Endpoint ${i}: path exceeds 2000 characters`);
68
80
  }
81
+
82
+ // M11: Deep type validation on nested structures
83
+ if ('headers' in e && e.headers !== undefined) {
84
+ if (typeof e.headers !== 'object' || e.headers === null || Array.isArray(e.headers)) {
85
+ throw new Error(`Endpoint ${i}: headers must be an object`);
86
+ }
87
+ for (const [hk, hv] of Object.entries(e.headers as Record<string, unknown>)) {
88
+ if (typeof hv !== 'string') {
89
+ throw new Error(`Endpoint ${i}: header "${hk}" value must be a string`);
90
+ }
91
+ }
92
+ }
93
+
94
+ if ('queryParams' in e && e.queryParams !== undefined) {
95
+ if (typeof e.queryParams !== 'object' || e.queryParams === null || Array.isArray(e.queryParams)) {
96
+ throw new Error(`Endpoint ${i}: queryParams must be an object`);
97
+ }
98
+ for (const [qk, qv] of Object.entries(e.queryParams as Record<string, unknown>)) {
99
+ if (typeof qv !== 'object' || qv === null || typeof (qv as any).example !== 'string') {
100
+ throw new Error(`Endpoint ${i}: queryParam "${qk}" must have a string example`);
101
+ }
102
+ }
103
+ }
104
+
105
+ if ('requestBody' in e && e.requestBody !== undefined) {
106
+ const rb = e.requestBody as Record<string, unknown>;
107
+ if (typeof rb !== 'object' || rb === null) {
108
+ throw new Error(`Endpoint ${i}: requestBody must be an object`);
109
+ }
110
+ if (typeof rb.contentType !== 'string') {
111
+ throw new Error(`Endpoint ${i}: requestBody.contentType must be a string`);
112
+ }
113
+ if (rb.variables !== undefined && !Array.isArray(rb.variables)) {
114
+ throw new Error(`Endpoint ${i}: requestBody.variables must be an array`);
115
+ }
116
+ }
69
117
  }
70
118
 
71
119
  return raw as SkillFile;