@apitap/core 1.0.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/LICENSE +60 -0
- package/README.md +362 -0
- package/SKILL.md +270 -0
- package/dist/auth/crypto.d.ts +31 -0
- package/dist/auth/crypto.js +66 -0
- package/dist/auth/crypto.js.map +1 -0
- package/dist/auth/handoff.d.ts +29 -0
- package/dist/auth/handoff.js +180 -0
- package/dist/auth/handoff.js.map +1 -0
- package/dist/auth/manager.d.ts +46 -0
- package/dist/auth/manager.js +127 -0
- package/dist/auth/manager.js.map +1 -0
- package/dist/auth/oauth-refresh.d.ts +16 -0
- package/dist/auth/oauth-refresh.js +91 -0
- package/dist/auth/oauth-refresh.js.map +1 -0
- package/dist/auth/refresh.d.ts +43 -0
- package/dist/auth/refresh.js +217 -0
- package/dist/auth/refresh.js.map +1 -0
- package/dist/capture/anti-bot.d.ts +15 -0
- package/dist/capture/anti-bot.js +43 -0
- package/dist/capture/anti-bot.js.map +1 -0
- package/dist/capture/blocklist.d.ts +6 -0
- package/dist/capture/blocklist.js +70 -0
- package/dist/capture/blocklist.js.map +1 -0
- package/dist/capture/body-diff.d.ts +8 -0
- package/dist/capture/body-diff.js +102 -0
- package/dist/capture/body-diff.js.map +1 -0
- package/dist/capture/body-variables.d.ts +13 -0
- package/dist/capture/body-variables.js +142 -0
- package/dist/capture/body-variables.js.map +1 -0
- package/dist/capture/domain.d.ts +8 -0
- package/dist/capture/domain.js +34 -0
- package/dist/capture/domain.js.map +1 -0
- package/dist/capture/entropy.d.ts +33 -0
- package/dist/capture/entropy.js +100 -0
- package/dist/capture/entropy.js.map +1 -0
- package/dist/capture/filter.d.ts +11 -0
- package/dist/capture/filter.js +49 -0
- package/dist/capture/filter.js.map +1 -0
- package/dist/capture/graphql.d.ts +21 -0
- package/dist/capture/graphql.js +99 -0
- package/dist/capture/graphql.js.map +1 -0
- package/dist/capture/idle.d.ts +23 -0
- package/dist/capture/idle.js +44 -0
- package/dist/capture/idle.js.map +1 -0
- package/dist/capture/monitor.d.ts +26 -0
- package/dist/capture/monitor.js +183 -0
- package/dist/capture/monitor.js.map +1 -0
- package/dist/capture/oauth-detector.d.ts +18 -0
- package/dist/capture/oauth-detector.js +96 -0
- package/dist/capture/oauth-detector.js.map +1 -0
- package/dist/capture/pagination.d.ts +9 -0
- package/dist/capture/pagination.js +40 -0
- package/dist/capture/pagination.js.map +1 -0
- package/dist/capture/parameterize.d.ts +17 -0
- package/dist/capture/parameterize.js +63 -0
- package/dist/capture/parameterize.js.map +1 -0
- package/dist/capture/scrubber.d.ts +5 -0
- package/dist/capture/scrubber.js +38 -0
- package/dist/capture/scrubber.js.map +1 -0
- package/dist/capture/session.d.ts +46 -0
- package/dist/capture/session.js +445 -0
- package/dist/capture/session.js.map +1 -0
- package/dist/capture/token-detector.d.ts +16 -0
- package/dist/capture/token-detector.js +62 -0
- package/dist/capture/token-detector.js.map +1 -0
- package/dist/capture/verifier.d.ts +17 -0
- package/dist/capture/verifier.js +147 -0
- package/dist/capture/verifier.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +930 -0
- package/dist/cli.js.map +1 -0
- package/dist/discovery/auth.d.ts +17 -0
- package/dist/discovery/auth.js +81 -0
- package/dist/discovery/auth.js.map +1 -0
- package/dist/discovery/fetch.d.ts +17 -0
- package/dist/discovery/fetch.js +59 -0
- package/dist/discovery/fetch.js.map +1 -0
- package/dist/discovery/frameworks.d.ts +11 -0
- package/dist/discovery/frameworks.js +249 -0
- package/dist/discovery/frameworks.js.map +1 -0
- package/dist/discovery/index.d.ts +21 -0
- package/dist/discovery/index.js +219 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/openapi.d.ts +13 -0
- package/dist/discovery/openapi.js +175 -0
- package/dist/discovery/openapi.js.map +1 -0
- package/dist/discovery/probes.d.ts +9 -0
- package/dist/discovery/probes.js +70 -0
- package/dist/discovery/probes.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect/report.d.ts +52 -0
- package/dist/inspect/report.js +191 -0
- package/dist/inspect/report.js.map +1 -0
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.js +526 -0
- package/dist/mcp.js.map +1 -0
- package/dist/orchestration/browse.d.ts +38 -0
- package/dist/orchestration/browse.js +198 -0
- package/dist/orchestration/browse.js.map +1 -0
- package/dist/orchestration/cache.d.ts +15 -0
- package/dist/orchestration/cache.js +24 -0
- package/dist/orchestration/cache.js.map +1 -0
- package/dist/plugin.d.ts +17 -0
- package/dist/plugin.js +158 -0
- package/dist/plugin.js.map +1 -0
- package/dist/read/decoders/deepwiki.d.ts +2 -0
- package/dist/read/decoders/deepwiki.js +148 -0
- package/dist/read/decoders/deepwiki.js.map +1 -0
- package/dist/read/decoders/grokipedia.d.ts +2 -0
- package/dist/read/decoders/grokipedia.js +210 -0
- package/dist/read/decoders/grokipedia.js.map +1 -0
- package/dist/read/decoders/hackernews.d.ts +2 -0
- package/dist/read/decoders/hackernews.js +168 -0
- package/dist/read/decoders/hackernews.js.map +1 -0
- package/dist/read/decoders/index.d.ts +2 -0
- package/dist/read/decoders/index.js +12 -0
- package/dist/read/decoders/index.js.map +1 -0
- package/dist/read/decoders/reddit.d.ts +2 -0
- package/dist/read/decoders/reddit.js +142 -0
- package/dist/read/decoders/reddit.js.map +1 -0
- package/dist/read/decoders/twitter.d.ts +12 -0
- package/dist/read/decoders/twitter.js +187 -0
- package/dist/read/decoders/twitter.js.map +1 -0
- package/dist/read/decoders/wikipedia.d.ts +2 -0
- package/dist/read/decoders/wikipedia.js +66 -0
- package/dist/read/decoders/wikipedia.js.map +1 -0
- package/dist/read/decoders/youtube.d.ts +2 -0
- package/dist/read/decoders/youtube.js +69 -0
- package/dist/read/decoders/youtube.js.map +1 -0
- package/dist/read/extract.d.ts +25 -0
- package/dist/read/extract.js +320 -0
- package/dist/read/extract.js.map +1 -0
- package/dist/read/index.d.ts +14 -0
- package/dist/read/index.js +66 -0
- package/dist/read/index.js.map +1 -0
- package/dist/read/peek.d.ts +9 -0
- package/dist/read/peek.js +137 -0
- package/dist/read/peek.js.map +1 -0
- package/dist/read/types.d.ts +44 -0
- package/dist/read/types.js +3 -0
- package/dist/read/types.js.map +1 -0
- package/dist/replay/engine.d.ts +53 -0
- package/dist/replay/engine.js +441 -0
- package/dist/replay/engine.js.map +1 -0
- package/dist/replay/truncate.d.ts +16 -0
- package/dist/replay/truncate.js +92 -0
- package/dist/replay/truncate.js.map +1 -0
- package/dist/serve.d.ts +31 -0
- package/dist/serve.js +149 -0
- package/dist/serve.js.map +1 -0
- package/dist/skill/generator.d.ts +44 -0
- package/dist/skill/generator.js +419 -0
- package/dist/skill/generator.js.map +1 -0
- package/dist/skill/importer.d.ts +26 -0
- package/dist/skill/importer.js +80 -0
- package/dist/skill/importer.js.map +1 -0
- package/dist/skill/search.d.ts +19 -0
- package/dist/skill/search.js +51 -0
- package/dist/skill/search.js.map +1 -0
- package/dist/skill/signing.d.ts +16 -0
- package/dist/skill/signing.js +34 -0
- package/dist/skill/signing.js.map +1 -0
- package/dist/skill/ssrf.d.ts +27 -0
- package/dist/skill/ssrf.js +210 -0
- package/dist/skill/ssrf.js.map +1 -0
- package/dist/skill/store.d.ts +7 -0
- package/dist/skill/store.js +93 -0
- package/dist/skill/store.js.map +1 -0
- package/dist/stats/report.d.ts +26 -0
- package/dist/stats/report.js +157 -0
- package/dist/stats/report.js.map +1 -0
- package/dist/types.d.ts +214 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
- package/src/auth/crypto.ts +92 -0
- package/src/auth/handoff.ts +229 -0
- package/src/auth/manager.ts +140 -0
- package/src/auth/oauth-refresh.ts +120 -0
- package/src/auth/refresh.ts +300 -0
- package/src/capture/anti-bot.ts +63 -0
- package/src/capture/blocklist.ts +75 -0
- package/src/capture/body-diff.ts +109 -0
- package/src/capture/body-variables.ts +156 -0
- package/src/capture/domain.ts +34 -0
- package/src/capture/entropy.ts +121 -0
- package/src/capture/filter.ts +56 -0
- package/src/capture/graphql.ts +124 -0
- package/src/capture/idle.ts +45 -0
- package/src/capture/monitor.ts +224 -0
- package/src/capture/oauth-detector.ts +106 -0
- package/src/capture/pagination.ts +49 -0
- package/src/capture/parameterize.ts +68 -0
- package/src/capture/scrubber.ts +49 -0
- package/src/capture/session.ts +502 -0
- package/src/capture/token-detector.ts +76 -0
- package/src/capture/verifier.ts +171 -0
- package/src/cli.ts +1031 -0
- package/src/discovery/auth.ts +99 -0
- package/src/discovery/fetch.ts +85 -0
- package/src/discovery/frameworks.ts +231 -0
- package/src/discovery/index.ts +256 -0
- package/src/discovery/openapi.ts +230 -0
- package/src/discovery/probes.ts +76 -0
- package/src/index.ts +26 -0
- package/src/inspect/report.ts +247 -0
- package/src/mcp.ts +618 -0
- package/src/orchestration/browse.ts +250 -0
- package/src/orchestration/cache.ts +37 -0
- package/src/plugin.ts +188 -0
- package/src/read/decoders/deepwiki.ts +180 -0
- package/src/read/decoders/grokipedia.ts +246 -0
- package/src/read/decoders/hackernews.ts +198 -0
- package/src/read/decoders/index.ts +15 -0
- package/src/read/decoders/reddit.ts +158 -0
- package/src/read/decoders/twitter.ts +211 -0
- package/src/read/decoders/wikipedia.ts +75 -0
- package/src/read/decoders/youtube.ts +75 -0
- package/src/read/extract.ts +396 -0
- package/src/read/index.ts +78 -0
- package/src/read/peek.ts +175 -0
- package/src/read/types.ts +37 -0
- package/src/replay/engine.ts +559 -0
- package/src/replay/truncate.ts +116 -0
- package/src/serve.ts +189 -0
- package/src/skill/generator.ts +473 -0
- package/src/skill/importer.ts +107 -0
- package/src/skill/search.ts +76 -0
- package/src/skill/signing.ts +36 -0
- package/src/skill/ssrf.ts +238 -0
- package/src/skill/store.ts +107 -0
- package/src/stats/report.ts +208 -0
- package/src/types.ts +233 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/skill/importer.ts
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { verifySignature } from './signing.js';
|
|
4
|
+
import { validateSkillFileUrls } from './ssrf.js';
|
|
5
|
+
import { writeSkillFile } from './store.js';
|
|
6
|
+
import type { SkillFile } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export interface ImportValidation {
|
|
9
|
+
valid: boolean;
|
|
10
|
+
reason?: string;
|
|
11
|
+
signatureStatus: 'valid' | 'invalid' | 'unsigned';
|
|
12
|
+
summary?: {
|
|
13
|
+
domain: string;
|
|
14
|
+
endpointCount: number;
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ImportResult {
|
|
20
|
+
success: boolean;
|
|
21
|
+
reason?: string;
|
|
22
|
+
skillFile?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate a skill file for import.
|
|
27
|
+
* Checks structure, SSRF safety, and signature integrity.
|
|
28
|
+
*/
|
|
29
|
+
export function validateImport(skill: SkillFile, localKey?: Buffer): ImportValidation {
|
|
30
|
+
// Basic structure validation
|
|
31
|
+
if (!skill.domain || !skill.baseUrl || !Array.isArray(skill.endpoints)) {
|
|
32
|
+
return { valid: false, reason: 'Invalid skill file structure', signatureStatus: 'unsigned' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Signature check
|
|
36
|
+
let signatureStatus: ImportValidation['signatureStatus'] = 'unsigned';
|
|
37
|
+
if (skill.signature) {
|
|
38
|
+
if (localKey && verifySignature(skill, localKey)) {
|
|
39
|
+
signatureStatus = 'valid';
|
|
40
|
+
} else {
|
|
41
|
+
return {
|
|
42
|
+
valid: false,
|
|
43
|
+
reason: 'Skill file signature is invalid — file was tampered with or signed by a different instance',
|
|
44
|
+
signatureStatus: 'invalid',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// SSRF validation
|
|
50
|
+
const ssrfResult = validateSkillFileUrls(skill);
|
|
51
|
+
if (!ssrfResult.safe) {
|
|
52
|
+
return {
|
|
53
|
+
valid: false,
|
|
54
|
+
reason: `SSRF risk: ${ssrfResult.reason}`,
|
|
55
|
+
signatureStatus,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
valid: true,
|
|
61
|
+
signatureStatus,
|
|
62
|
+
summary: {
|
|
63
|
+
domain: skill.domain,
|
|
64
|
+
endpointCount: skill.endpoints.length,
|
|
65
|
+
baseUrl: skill.baseUrl,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Import a skill file from disk.
|
|
72
|
+
* Validates, strips foreign signatures, sets provenance to 'imported'.
|
|
73
|
+
*/
|
|
74
|
+
export async function importSkillFile(
|
|
75
|
+
filePath: string,
|
|
76
|
+
skillsDir?: string,
|
|
77
|
+
localKey?: Buffer,
|
|
78
|
+
): Promise<ImportResult> {
|
|
79
|
+
let content: string;
|
|
80
|
+
try {
|
|
81
|
+
content = await readFile(filePath, 'utf-8');
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { success: false, reason: `Cannot read file: ${(err as Error).message}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let skill: SkillFile;
|
|
87
|
+
try {
|
|
88
|
+
skill = JSON.parse(content);
|
|
89
|
+
} catch {
|
|
90
|
+
return { success: false, reason: 'File is not valid JSON' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const validation = validateImport(skill, localKey);
|
|
94
|
+
if (!validation.valid) {
|
|
95
|
+
return { success: false, reason: validation.reason };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Strip foreign signature, set provenance
|
|
99
|
+
const importedSkill: SkillFile = {
|
|
100
|
+
...skill,
|
|
101
|
+
provenance: 'imported',
|
|
102
|
+
signature: undefined,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const writtenPath = await writeSkillFile(importedSkill, skillsDir);
|
|
106
|
+
return { success: true, skillFile: writtenPath };
|
|
107
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// src/skill/search.ts
|
|
2
|
+
import { listSkillFiles, readSkillFile } from './store.js';
|
|
3
|
+
|
|
4
|
+
export interface SearchResult {
|
|
5
|
+
domain: string;
|
|
6
|
+
endpointId: string;
|
|
7
|
+
method: string;
|
|
8
|
+
path: string;
|
|
9
|
+
tier: string;
|
|
10
|
+
verified: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchResponse {
|
|
14
|
+
found: boolean;
|
|
15
|
+
results?: SearchResult[];
|
|
16
|
+
suggestion?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Search skill files for endpoints matching a query.
|
|
21
|
+
* Matches against domain names, endpoint IDs, and endpoint paths.
|
|
22
|
+
* Query terms are matched case-insensitively.
|
|
23
|
+
*/
|
|
24
|
+
export async function searchSkills(
|
|
25
|
+
query: string,
|
|
26
|
+
skillsDir?: string,
|
|
27
|
+
): Promise<SearchResponse> {
|
|
28
|
+
const summaries = await listSkillFiles(skillsDir);
|
|
29
|
+
if (summaries.length === 0) {
|
|
30
|
+
return {
|
|
31
|
+
found: false,
|
|
32
|
+
suggestion: 'No skill files found. Run `apitap capture <url>` to capture API traffic first.',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
37
|
+
const results: SearchResult[] = [];
|
|
38
|
+
|
|
39
|
+
for (const summary of summaries) {
|
|
40
|
+
const skill = await readSkillFile(summary.domain, skillsDir);
|
|
41
|
+
if (!skill) continue;
|
|
42
|
+
|
|
43
|
+
const domainLower = skill.domain.toLowerCase();
|
|
44
|
+
|
|
45
|
+
for (const ep of skill.endpoints) {
|
|
46
|
+
const endpointIdLower = ep.id.toLowerCase();
|
|
47
|
+
const pathLower = ep.path.toLowerCase();
|
|
48
|
+
const methodLower = ep.method.toLowerCase();
|
|
49
|
+
|
|
50
|
+
// Check if all query terms match against the combined searchable text
|
|
51
|
+
const searchText = `${domainLower} ${endpointIdLower} ${pathLower} ${methodLower}`;
|
|
52
|
+
const allMatch = terms.every(term => searchText.includes(term));
|
|
53
|
+
|
|
54
|
+
if (allMatch) {
|
|
55
|
+
results.push({
|
|
56
|
+
domain: skill.domain,
|
|
57
|
+
endpointId: ep.id,
|
|
58
|
+
method: ep.method,
|
|
59
|
+
path: ep.path,
|
|
60
|
+
tier: ep.replayability?.tier ?? 'unknown',
|
|
61
|
+
verified: ep.replayability?.verified ?? false,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (results.length === 0) {
|
|
68
|
+
const domains = summaries.map(s => s.domain).join(', ');
|
|
69
|
+
return {
|
|
70
|
+
found: false,
|
|
71
|
+
suggestion: `No matches for "${query}". Available domains: ${domains}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { found: true, results };
|
|
76
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/skill/signing.ts
|
|
2
|
+
import { hmacSign, hmacVerify } from '../auth/crypto.js';
|
|
3
|
+
import type { SkillFile } from '../types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a canonical JSON string from a skill file,
|
|
7
|
+
* excluding `signature` and `provenance` fields.
|
|
8
|
+
* This is the payload that gets signed.
|
|
9
|
+
*/
|
|
10
|
+
export function canonicalize(skill: SkillFile): string {
|
|
11
|
+
const { signature: _sig, provenance: _prov, ...rest } = skill;
|
|
12
|
+
return JSON.stringify(rest, Object.keys(rest).sort());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sign a skill file. Returns a new object with signature and provenance: 'self'.
|
|
17
|
+
*/
|
|
18
|
+
export function signSkillFile(skill: SkillFile, key: Buffer): SkillFile {
|
|
19
|
+
const payload = canonicalize(skill);
|
|
20
|
+
const signature = hmacSign(payload, key);
|
|
21
|
+
return {
|
|
22
|
+
...skill,
|
|
23
|
+
provenance: 'self',
|
|
24
|
+
signature,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Verify a skill file's signature.
|
|
30
|
+
* Returns true if the signature is valid for the given key.
|
|
31
|
+
*/
|
|
32
|
+
export function verifySignature(skill: SkillFile, key: Buffer): boolean {
|
|
33
|
+
if (!skill.signature) return false;
|
|
34
|
+
const payload = canonicalize(skill);
|
|
35
|
+
return hmacVerify(payload, skill.signature, key);
|
|
36
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// src/skill/ssrf.ts
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import type { SkillFile } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export interface ValidationResult {
|
|
6
|
+
safe: boolean;
|
|
7
|
+
reason?: string;
|
|
8
|
+
resolvedUrl?: string;
|
|
9
|
+
resolvedIp?: string;
|
|
10
|
+
originalHost?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const INTERNAL_HOSTNAMES = ['localhost'];
|
|
14
|
+
const INTERNAL_SUFFIXES = ['.local', '.internal'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check if a URL is safe to replay (not targeting internal infrastructure).
|
|
18
|
+
*/
|
|
19
|
+
export function validateUrl(urlString: string): ValidationResult {
|
|
20
|
+
let url: URL;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(urlString);
|
|
23
|
+
} catch {
|
|
24
|
+
return { safe: false, reason: 'Invalid URL' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Scheme check
|
|
28
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
29
|
+
return { safe: false, reason: `Non-HTTP scheme: ${url.protocol}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hostname = url.hostname;
|
|
33
|
+
|
|
34
|
+
// Exact internal hostnames
|
|
35
|
+
if (INTERNAL_HOSTNAMES.includes(hostname)) {
|
|
36
|
+
return { safe: false, reason: `URL targets internal hostname: ${hostname}` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Internal domain suffixes
|
|
40
|
+
for (const suffix of INTERNAL_SUFFIXES) {
|
|
41
|
+
if (hostname.endsWith(suffix)) {
|
|
42
|
+
return { safe: false, reason: `URL targets internal domain: ${hostname}` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// IPv6 loopback
|
|
47
|
+
if (hostname === '[::1]' || hostname === '::1') {
|
|
48
|
+
return { safe: false, reason: 'URL targets IPv6 loopback' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// IPv4-mapped IPv6 — dotted-quad form (e.g. [::ffff:127.0.0.1])
|
|
52
|
+
const v4MappedMatch = hostname.match(/^\[?::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\]?$/i);
|
|
53
|
+
if (v4MappedMatch) {
|
|
54
|
+
return validateUrl(`${url.protocol}//${v4MappedMatch[1]}${url.port ? ':' + url.port : ''}${url.pathname}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// IPv4-mapped IPv6 — hex form (e.g. [::ffff:7f00:1], Node normalizes to this)
|
|
58
|
+
const v4MappedHexMatch = hostname.match(/^\[?::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})\]?$/i);
|
|
59
|
+
if (v4MappedHexMatch) {
|
|
60
|
+
const hi = parseInt(v4MappedHexMatch[1], 16);
|
|
61
|
+
const lo = parseInt(v4MappedHexMatch[2], 16);
|
|
62
|
+
const ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
63
|
+
return validateUrl(`${url.protocol}//${ipv4}${url.port ? ':' + url.port : ''}${url.pathname}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// IPv6 link-local (fe80::/10)
|
|
67
|
+
if (/^\[?fe[89ab][0-9a-f]:/i.test(hostname)) {
|
|
68
|
+
return { safe: false, reason: `URL targets IPv6 link-local address: ${hostname}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// IPv6 unique local (fc00::/7 — includes fd00::/8)
|
|
72
|
+
if (/^\[?f[cd][0-9a-f]{2}:/i.test(hostname)) {
|
|
73
|
+
return { safe: false, reason: `URL targets IPv6 unique-local address: ${hostname}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// IPv4 private ranges
|
|
77
|
+
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
78
|
+
if (ipv4Match) {
|
|
79
|
+
const [, a, b] = ipv4Match.map(Number);
|
|
80
|
+
const first = Number(a);
|
|
81
|
+
const second = Number(b);
|
|
82
|
+
|
|
83
|
+
// 0.0.0.0 — unspecified
|
|
84
|
+
if (first === 0) {
|
|
85
|
+
return { safe: false, reason: `URL targets unspecified address: ${hostname}` };
|
|
86
|
+
}
|
|
87
|
+
// 127.x.x.x — loopback
|
|
88
|
+
if (first === 127) {
|
|
89
|
+
return { safe: false, reason: `URL targets loopback address: ${hostname}` };
|
|
90
|
+
}
|
|
91
|
+
// 10.x.x.x — private
|
|
92
|
+
if (first === 10) {
|
|
93
|
+
return { safe: false, reason: `URL targets private IP: ${hostname}` };
|
|
94
|
+
}
|
|
95
|
+
// 172.16-31.x.x — private
|
|
96
|
+
if (first === 172 && second >= 16 && second <= 31) {
|
|
97
|
+
return { safe: false, reason: `URL targets private IP: ${hostname}` };
|
|
98
|
+
}
|
|
99
|
+
// 192.168.x.x — private
|
|
100
|
+
if (first === 192 && second === 168) {
|
|
101
|
+
return { safe: false, reason: `URL targets private IP: ${hostname}` };
|
|
102
|
+
}
|
|
103
|
+
// 169.254.x.x — link-local
|
|
104
|
+
if (first === 169 && second === 254) {
|
|
105
|
+
return { safe: false, reason: `URL targets link-local address: ${hostname}` };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { safe: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if a resolved IP address is in a private/reserved range.
|
|
114
|
+
*/
|
|
115
|
+
function isPrivateIp(ip: string): string | null {
|
|
116
|
+
// IPv6 loopback
|
|
117
|
+
if (ip === '::1') return 'IPv6 loopback';
|
|
118
|
+
|
|
119
|
+
// IPv6 link-local (fe80::/10)
|
|
120
|
+
if (/^fe[89ab][0-9a-f]:/i.test(ip)) return 'IPv6 link-local';
|
|
121
|
+
|
|
122
|
+
// IPv6 unique local (fc00::/7 — includes fd00::/8)
|
|
123
|
+
if (/^f[cd][0-9a-f]{2}:/i.test(ip)) return 'IPv6 unique-local';
|
|
124
|
+
|
|
125
|
+
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
|
|
126
|
+
const v4mapped = ip.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
|
|
127
|
+
const ipv4 = v4mapped ? v4mapped[1] : ip;
|
|
128
|
+
|
|
129
|
+
const parts = ipv4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
130
|
+
if (!parts) return null; // Not an IPv4 — let it pass (non-private IPv6)
|
|
131
|
+
|
|
132
|
+
const [, a, b] = parts;
|
|
133
|
+
const first = Number(a);
|
|
134
|
+
const second = Number(b);
|
|
135
|
+
|
|
136
|
+
if (first === 127) return 'loopback';
|
|
137
|
+
if (first === 10) return 'private (10.x)';
|
|
138
|
+
if (first === 172 && second >= 16 && second <= 31) return 'private (172.16-31.x)';
|
|
139
|
+
if (first === 192 && second === 168) return 'private (192.168.x)';
|
|
140
|
+
if (first === 169 && second === 254) return 'link-local';
|
|
141
|
+
if (first === 0) return 'unspecified';
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve hostname and validate the resolved IP against private ranges.
|
|
148
|
+
* Prevents DNS rebinding attacks where a domain resolves to 127.0.0.1.
|
|
149
|
+
*/
|
|
150
|
+
export async function resolveAndValidateUrl(urlString: string): Promise<ValidationResult> {
|
|
151
|
+
// First run the sync hostname-based checks
|
|
152
|
+
const syncResult = validateUrl(urlString);
|
|
153
|
+
if (!syncResult.safe) return syncResult;
|
|
154
|
+
|
|
155
|
+
let url: URL;
|
|
156
|
+
try {
|
|
157
|
+
url = new URL(urlString);
|
|
158
|
+
} catch {
|
|
159
|
+
return { safe: false, reason: 'Invalid URL' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const hostname = url.hostname;
|
|
163
|
+
|
|
164
|
+
// Skip DNS resolution for raw IPs (already checked by validateUrl)
|
|
165
|
+
if (hostname.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) || hostname.startsWith('[')) {
|
|
166
|
+
return { safe: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Resolve DNS and check the actual IP
|
|
170
|
+
try {
|
|
171
|
+
const { address } = await lookup(hostname);
|
|
172
|
+
const privateReason = isPrivateIp(address);
|
|
173
|
+
if (privateReason) {
|
|
174
|
+
return { safe: false, reason: `DNS rebinding: ${hostname} resolves to ${address} (${privateReason})` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Return the resolved URL with IP pinned to prevent DNS rebinding
|
|
178
|
+
const pinnedUrl = new URL(urlString);
|
|
179
|
+
pinnedUrl.hostname = address;
|
|
180
|
+
return {
|
|
181
|
+
safe: true,
|
|
182
|
+
resolvedUrl: pinnedUrl.toString(),
|
|
183
|
+
resolvedIp: address,
|
|
184
|
+
originalHost: hostname
|
|
185
|
+
};
|
|
186
|
+
} catch {
|
|
187
|
+
// DNS resolution failed — hostname doesn't exist
|
|
188
|
+
return { safe: false, reason: `DNS resolution failed for ${hostname}` };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validate all URLs in a skill file with DNS resolution.
|
|
194
|
+
* Checks baseUrl and all endpoint example URLs.
|
|
195
|
+
*/
|
|
196
|
+
export async function resolveAndValidateSkillFileUrls(skill: SkillFile): Promise<ValidationResult> {
|
|
197
|
+
const baseResult = await resolveAndValidateUrl(skill.baseUrl);
|
|
198
|
+
if (!baseResult.safe) {
|
|
199
|
+
return { safe: false, reason: `baseUrl: ${baseResult.reason}` };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const ep of skill.endpoints) {
|
|
203
|
+
const exUrl = ep.examples?.request?.url;
|
|
204
|
+
if (exUrl) {
|
|
205
|
+
const result = await resolveAndValidateUrl(exUrl);
|
|
206
|
+
if (!result.safe) {
|
|
207
|
+
return { safe: false, reason: `endpoint ${ep.id}: ${result.reason}` };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { safe: true };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Validate all URLs in a skill file (sync, hostname-based only).
|
|
217
|
+
* Checks baseUrl and all endpoint example URLs.
|
|
218
|
+
*/
|
|
219
|
+
export function validateSkillFileUrls(skill: SkillFile): ValidationResult {
|
|
220
|
+
// Check baseUrl
|
|
221
|
+
const baseResult = validateUrl(skill.baseUrl);
|
|
222
|
+
if (!baseResult.safe) {
|
|
223
|
+
return { safe: false, reason: `baseUrl: ${baseResult.reason}` };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check endpoint example URLs
|
|
227
|
+
for (const ep of skill.endpoints) {
|
|
228
|
+
const exUrl = ep.examples?.request?.url;
|
|
229
|
+
if (exUrl) {
|
|
230
|
+
const result = validateUrl(exUrl);
|
|
231
|
+
if (!result.safe) {
|
|
232
|
+
return { safe: false, reason: `endpoint ${ep.id}: ${result.reason}` };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { safe: true };
|
|
238
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/skill/store.ts
|
|
2
|
+
import { readFile, writeFile, mkdir, readdir, access } from 'node:fs/promises';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import type { SkillFile, SkillSummary } from '../types.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_SKILLS_DIR = join(homedir(), '.apitap', 'skills');
|
|
8
|
+
|
|
9
|
+
const BASE_GITIGNORE = `# ApiTap — prevent accidental credential commits
|
|
10
|
+
auth.enc
|
|
11
|
+
*.key
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
function skillPath(domain: string, skillsDir: string): string {
|
|
15
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(domain)) {
|
|
16
|
+
throw new Error(`Invalid domain: ${domain}`);
|
|
17
|
+
}
|
|
18
|
+
return join(skillsDir, `${domain}.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function ensureGitignore(skillsDir: string): Promise<void> {
|
|
22
|
+
const baseDir = dirname(skillsDir);
|
|
23
|
+
const gitignorePath = join(baseDir, '.gitignore');
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await access(gitignorePath);
|
|
27
|
+
// File exists, don't overwrite
|
|
28
|
+
} catch {
|
|
29
|
+
// File doesn't exist, create it
|
|
30
|
+
await mkdir(baseDir, { recursive: true });
|
|
31
|
+
await writeFile(gitignorePath, BASE_GITIGNORE);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function writeSkillFile(
|
|
36
|
+
skill: SkillFile,
|
|
37
|
+
skillsDir: string = DEFAULT_SKILLS_DIR,
|
|
38
|
+
): Promise<string> {
|
|
39
|
+
await mkdir(skillsDir, { recursive: true });
|
|
40
|
+
await ensureGitignore(skillsDir);
|
|
41
|
+
const filePath = skillPath(skill.domain, skillsDir);
|
|
42
|
+
await writeFile(filePath, JSON.stringify(skill, null, 2) + '\n');
|
|
43
|
+
return filePath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readSkillFile(
|
|
47
|
+
domain: string,
|
|
48
|
+
skillsDir: string = DEFAULT_SKILLS_DIR,
|
|
49
|
+
options?: { verifySignature?: boolean; signingKey?: Buffer }
|
|
50
|
+
): Promise<SkillFile | null> {
|
|
51
|
+
// Validate domain before file I/O — path traversal should throw, not return null
|
|
52
|
+
const path = skillPath(domain, skillsDir);
|
|
53
|
+
try {
|
|
54
|
+
const content = await readFile(path, 'utf-8');
|
|
55
|
+
const skill = JSON.parse(content) as SkillFile;
|
|
56
|
+
|
|
57
|
+
// If verification requested, check signature
|
|
58
|
+
if (options?.verifySignature && options.signingKey) {
|
|
59
|
+
if (skill.provenance === 'imported') {
|
|
60
|
+
// Imported files had foreign signature stripped — can't verify, warn only
|
|
61
|
+
// Future: re-sign on import with local key
|
|
62
|
+
} else if (!skill.signature) {
|
|
63
|
+
// No signature present on non-imported file
|
|
64
|
+
throw new Error(`Skill file for ${domain} has no signature — file may be tampered`);
|
|
65
|
+
} else {
|
|
66
|
+
const { verifySignature } = await import('./signing.js');
|
|
67
|
+
if (!verifySignature(skill, options.signingKey)) {
|
|
68
|
+
throw new Error(`Skill file signature verification failed for ${domain} — file may be tampered`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return skill;
|
|
74
|
+
} catch (e: unknown) {
|
|
75
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function listSkillFiles(
|
|
81
|
+
skillsDir: string = DEFAULT_SKILLS_DIR,
|
|
82
|
+
): Promise<SkillSummary[]> {
|
|
83
|
+
let files: string[];
|
|
84
|
+
try {
|
|
85
|
+
files = await readdir(skillsDir);
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const summaries: SkillSummary[] = [];
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
if (!file.endsWith('.json')) continue;
|
|
93
|
+
const domain = file.replace(/\.json$/, '');
|
|
94
|
+
const skill = await readSkillFile(domain, skillsDir);
|
|
95
|
+
if (skill) {
|
|
96
|
+
summaries.push({
|
|
97
|
+
domain: skill.domain,
|
|
98
|
+
skillFile: join(skillsDir, file),
|
|
99
|
+
endpointCount: skill.endpoints.length,
|
|
100
|
+
capturedAt: skill.capturedAt,
|
|
101
|
+
provenance: skill.provenance ?? 'unsigned',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return summaries;
|
|
107
|
+
}
|