@siduri-x/knowledge 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/dist/index.d.ts +46 -0
- package/dist/index.js +299 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +148 -0
- package/organ-manifest.json +76 -0
- package/package.json +50 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { KnowledgeItem, KnowledgeOrgan } from '@siduri-x/core';
|
|
2
|
+
export interface EKnowledgeConfig {
|
|
3
|
+
provider?: 'e-knowledge' | 'e-remote' | 'e-hub';
|
|
4
|
+
packPath?: string;
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
registryUrl?: string;
|
|
7
|
+
packId?: string;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
maxResponseBytes?: number;
|
|
10
|
+
preferredMode?: 'lexical' | 'semantic' | 'hybrid';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Validates whether an IP address belongs to loopback, private, link-local, or cloud metadata ranges.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isBlockedIp(ip: string): boolean;
|
|
16
|
+
export interface SafeUrlValidationOptions {
|
|
17
|
+
dnsLookup?: (hostname: string) => Promise<string[]>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Validates a destination URL against SSRF rules:
|
|
21
|
+
* - Scheme must be http: or https:
|
|
22
|
+
* - Host must not resolve to blocked IP addresses
|
|
23
|
+
*/
|
|
24
|
+
export declare function validateSafeUrl(urlStr: string, options?: SafeUrlValidationOptions): Promise<URL>;
|
|
25
|
+
/**
|
|
26
|
+
* Safe fetch wrapper that enforces:
|
|
27
|
+
* 1. Target URL validation (SSRF defense)
|
|
28
|
+
* 2. Manual redirect following with re-validation of each redirect target
|
|
29
|
+
* 3. Timeout via AbortController
|
|
30
|
+
* 4. Maximum response size limit
|
|
31
|
+
*/
|
|
32
|
+
export declare function safeFetch(urlStr: string, options?: {
|
|
33
|
+
method?: string;
|
|
34
|
+
headers?: Record<string, string>;
|
|
35
|
+
body?: string;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
maxBytes?: number;
|
|
38
|
+
maxRedirects?: number;
|
|
39
|
+
}): Promise<Response>;
|
|
40
|
+
export declare class EKnowledgeAdapter implements KnowledgeOrgan {
|
|
41
|
+
private loaded;
|
|
42
|
+
private readonly preferredMode;
|
|
43
|
+
constructor(config: EKnowledgeConfig);
|
|
44
|
+
get currentRevision(): Promise<string>;
|
|
45
|
+
search(query: string): Promise<KnowledgeItem[]>;
|
|
46
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.EKnowledgeAdapter = void 0;
|
|
7
|
+
exports.isBlockedIp = isBlockedIp;
|
|
8
|
+
exports.validateSafeUrl = validateSafeUrl;
|
|
9
|
+
exports.safeFetch = safeFetch;
|
|
10
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
11
|
+
const promises_1 = __importDefault(require("node:dns/promises"));
|
|
12
|
+
const loadEKnowledgeModule = () => new Function('specifier', 'return import(specifier)')('@vxnus/e-knowledge');
|
|
13
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024; // 1MB
|
|
14
|
+
/**
|
|
15
|
+
* Validates whether an IP address belongs to loopback, private, link-local, or cloud metadata ranges.
|
|
16
|
+
*/
|
|
17
|
+
function isBlockedIp(ip) {
|
|
18
|
+
// IPv4 checks
|
|
19
|
+
if (node_net_1.default.isIPv4(ip)) {
|
|
20
|
+
const parts = ip.split('.').map(Number);
|
|
21
|
+
if (parts.length !== 4 || parts.some(p => isNaN(p) || p < 0 || p > 255))
|
|
22
|
+
return true;
|
|
23
|
+
// 0.0.0.0/8 (Current network)
|
|
24
|
+
if (parts[0] === 0)
|
|
25
|
+
return true;
|
|
26
|
+
// 127.0.0.0/8 (Loopback)
|
|
27
|
+
if (parts[0] === 127)
|
|
28
|
+
return true;
|
|
29
|
+
// 10.0.0.0/8 (Private RFC1918)
|
|
30
|
+
if (parts[0] === 10)
|
|
31
|
+
return true;
|
|
32
|
+
// 172.16.0.0/12 (Private RFC1918)
|
|
33
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
|
34
|
+
return true;
|
|
35
|
+
// 192.168.0.0/16 (Private RFC1918)
|
|
36
|
+
if (parts[0] === 192 && parts[1] === 168)
|
|
37
|
+
return true;
|
|
38
|
+
// 169.254.0.0/16 (Link-local / Cloud metadata)
|
|
39
|
+
if (parts[0] === 169 && parts[1] === 254)
|
|
40
|
+
return true;
|
|
41
|
+
// 100.64.0.0/10 (Carrier-grade NAT)
|
|
42
|
+
if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127)
|
|
43
|
+
return true;
|
|
44
|
+
// 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 (TEST-NET)
|
|
45
|
+
if (parts[0] === 192 && parts[1] === 0 && parts[2] === 2)
|
|
46
|
+
return true;
|
|
47
|
+
if (parts[0] === 198 && parts[1] === 51 && parts[2] === 100)
|
|
48
|
+
return true;
|
|
49
|
+
if (parts[0] === 203 && parts[1] === 0 && parts[2] === 113)
|
|
50
|
+
return true;
|
|
51
|
+
// 224.0.0.0/4 (Multicast) & 240.0.0.0/4 (Reserved)
|
|
52
|
+
if (parts[0] >= 224)
|
|
53
|
+
return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
// IPv6 checks
|
|
57
|
+
if (node_net_1.default.isIPv6(ip)) {
|
|
58
|
+
const normalized = ip.toLowerCase();
|
|
59
|
+
// ::1 (Loopback)
|
|
60
|
+
if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1')
|
|
61
|
+
return true;
|
|
62
|
+
// :: (Unspecified)
|
|
63
|
+
if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0')
|
|
64
|
+
return true;
|
|
65
|
+
// IPv4-mapped IPv6 (::ffff:127.0.0.1)
|
|
66
|
+
if (normalized.startsWith('::ffff:')) {
|
|
67
|
+
const v4Part = normalized.slice(7);
|
|
68
|
+
if (node_net_1.default.isIPv4(v4Part))
|
|
69
|
+
return isBlockedIp(v4Part);
|
|
70
|
+
}
|
|
71
|
+
// fe80::/10 (Link-local)
|
|
72
|
+
if (normalized.startsWith('fe80:') || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb'))
|
|
73
|
+
return true;
|
|
74
|
+
// fc00::/7 (Unique local / ULA)
|
|
75
|
+
if (normalized.startsWith('fc') || normalized.startsWith('fd'))
|
|
76
|
+
return true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
// Non-IP string is invalid
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Validates a destination URL against SSRF rules:
|
|
84
|
+
* - Scheme must be http: or https:
|
|
85
|
+
* - Host must not resolve to blocked IP addresses
|
|
86
|
+
*/
|
|
87
|
+
async function validateSafeUrl(urlStr, options = {}) {
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = new URL(urlStr);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error(`Invalid URL provided: ${urlStr}`);
|
|
94
|
+
}
|
|
95
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
96
|
+
throw new Error(`Forbidden protocol: ${parsed.protocol}. Only http: and https: are allowed.`);
|
|
97
|
+
}
|
|
98
|
+
let hostname = parsed.hostname;
|
|
99
|
+
if (!hostname) {
|
|
100
|
+
throw new Error('URL hostname is required');
|
|
101
|
+
}
|
|
102
|
+
// Strip IPv6 brackets if present in hostname (e.g. "[::1]" -> "::1")
|
|
103
|
+
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
|
104
|
+
hostname = hostname.slice(1, -1);
|
|
105
|
+
}
|
|
106
|
+
// If hostname is already an IP address
|
|
107
|
+
if (node_net_1.default.isIP(hostname)) {
|
|
108
|
+
if (isBlockedIp(hostname)) {
|
|
109
|
+
throw new Error(`Blocked destination IP address: ${hostname}`);
|
|
110
|
+
}
|
|
111
|
+
return parsed;
|
|
112
|
+
}
|
|
113
|
+
// Resolve hostname via DNS
|
|
114
|
+
let addresses;
|
|
115
|
+
try {
|
|
116
|
+
if (options.dnsLookup) {
|
|
117
|
+
addresses = await options.dnsLookup(hostname);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
const res = await promises_1.default.lookup(hostname, { all: true });
|
|
121
|
+
addresses = res.map(r => r.address);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
throw new Error(`DNS resolution failed for hostname "${hostname}": ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
if (!addresses || addresses.length === 0) {
|
|
128
|
+
throw new Error(`No DNS records found for hostname: ${hostname}`);
|
|
129
|
+
}
|
|
130
|
+
for (const addr of addresses) {
|
|
131
|
+
if (isBlockedIp(addr)) {
|
|
132
|
+
throw new Error(`Hostname "${hostname}" resolved to blocked IP address: ${addr}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return parsed;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Safe fetch wrapper that enforces:
|
|
139
|
+
* 1. Target URL validation (SSRF defense)
|
|
140
|
+
* 2. Manual redirect following with re-validation of each redirect target
|
|
141
|
+
* 3. Timeout via AbortController
|
|
142
|
+
* 4. Maximum response size limit
|
|
143
|
+
*/
|
|
144
|
+
async function safeFetch(urlStr, options = {}) {
|
|
145
|
+
let currentUrl = urlStr;
|
|
146
|
+
const maxRedirects = options.maxRedirects ?? 3;
|
|
147
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
148
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
149
|
+
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
|
|
150
|
+
const validated = await validateSafeUrl(currentUrl);
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
153
|
+
try {
|
|
154
|
+
const response = await fetch(validated.toString(), {
|
|
155
|
+
method: options.method || 'GET',
|
|
156
|
+
headers: options.headers,
|
|
157
|
+
body: options.body,
|
|
158
|
+
signal: controller.signal,
|
|
159
|
+
redirect: 'manual',
|
|
160
|
+
});
|
|
161
|
+
// Handle Redirects safely
|
|
162
|
+
if (response.status >= 300 && response.status < 400) {
|
|
163
|
+
const location = response.headers.get('location');
|
|
164
|
+
if (!location) {
|
|
165
|
+
throw new Error(`HTTP ${response.status} redirect missing Location header`);
|
|
166
|
+
}
|
|
167
|
+
if (redirectCount >= maxRedirects) {
|
|
168
|
+
throw new Error(`Exceeded maximum redirect limit of ${maxRedirects}`);
|
|
169
|
+
}
|
|
170
|
+
currentUrl = new URL(location, validated).toString();
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// Check Content-Length header if present
|
|
174
|
+
const contentLengthHeader = response.headers.get('content-length');
|
|
175
|
+
if (contentLengthHeader) {
|
|
176
|
+
const contentLength = parseInt(contentLengthHeader, 10);
|
|
177
|
+
if (!isNaN(contentLength) && contentLength > maxBytes) {
|
|
178
|
+
throw new Error(`Response size (${contentLength} bytes) exceeds limit of ${maxBytes} bytes`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return response;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
throw new Error('Too many redirects');
|
|
188
|
+
}
|
|
189
|
+
async function safeFetchJson(urlStr, options = {}) {
|
|
190
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
191
|
+
const response = await safeFetch(urlStr, options);
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
194
|
+
}
|
|
195
|
+
// Read response as text with size limit to prevent memory exhaustion
|
|
196
|
+
const text = await response.text();
|
|
197
|
+
if (text.length > maxBytes) {
|
|
198
|
+
throw new Error(`Response body length (${text.length} chars) exceeds maximum allowed ${maxBytes} bytes`);
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(text);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
throw new Error(`Failed to parse JSON response: ${err.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function resolveManifest(provider, baseUrl, timeoutMs, maxBytes) {
|
|
208
|
+
if (typeof provider.manifest === 'function') {
|
|
209
|
+
return await provider.manifest();
|
|
210
|
+
}
|
|
211
|
+
const cleanUrl = baseUrl.replace(/\/+$/, '');
|
|
212
|
+
return await safeFetchJson(`${cleanUrl}/manifest`, {
|
|
213
|
+
headers: { accept: 'application/json' },
|
|
214
|
+
timeoutMs: timeoutMs || 5000,
|
|
215
|
+
maxBytes,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async function resolveHubProvider(config, module) {
|
|
219
|
+
if (!config.registryUrl || !config.packId)
|
|
220
|
+
throw new Error('E Hub provider requires registryUrl and packId');
|
|
221
|
+
const match = config.packId.match(/^@([^/]+)\/([^/]+)$/);
|
|
222
|
+
if (!match)
|
|
223
|
+
throw new Error('E Hub packId must use the @publisher/name format');
|
|
224
|
+
const registryUrl = config.registryUrl.replace(/\/+$/, '');
|
|
225
|
+
const hubPackUrl = `${registryUrl}/${encodeURIComponent(match[1])}/${encodeURIComponent(match[2])}`;
|
|
226
|
+
const pack = await safeFetchJson(hubPackUrl, {
|
|
227
|
+
timeoutMs: config.timeoutMs,
|
|
228
|
+
maxBytes: config.maxResponseBytes,
|
|
229
|
+
});
|
|
230
|
+
if (pack.distribution?.kind !== 'provider' || !pack.distribution.url) {
|
|
231
|
+
throw new Error(`E Hub pack ${config.packId} is not a remote provider`);
|
|
232
|
+
}
|
|
233
|
+
const baseUrl = pack.distribution.url;
|
|
234
|
+
// Validate distribution URL against SSRF
|
|
235
|
+
await validateSafeUrl(baseUrl);
|
|
236
|
+
return {
|
|
237
|
+
provider: module.createRemoteProvider({ baseUrl, timeoutMs: config.timeoutMs, manifest: pack }),
|
|
238
|
+
baseUrl,
|
|
239
|
+
manifest: pack,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
class EKnowledgeAdapter {
|
|
243
|
+
loaded;
|
|
244
|
+
preferredMode;
|
|
245
|
+
constructor(config) {
|
|
246
|
+
this.preferredMode = config.preferredMode ?? 'lexical';
|
|
247
|
+
this.loaded = loadEKnowledgeModule().then(async (module) => {
|
|
248
|
+
if (config.provider === 'e-hub') {
|
|
249
|
+
const { provider, baseUrl, manifest: hubManifest } = await resolveHubProvider(config, module);
|
|
250
|
+
let manifest;
|
|
251
|
+
try {
|
|
252
|
+
manifest = await resolveManifest(provider, baseUrl, config.timeoutMs);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
if (!hubManifest)
|
|
256
|
+
throw new Error('Could not resolve manifest for E Hub provider');
|
|
257
|
+
manifest = hubManifest;
|
|
258
|
+
}
|
|
259
|
+
return { provider: provider, manifest };
|
|
260
|
+
}
|
|
261
|
+
if (config.provider === 'e-remote' || config.baseUrl) {
|
|
262
|
+
const baseUrl = config.baseUrl || '';
|
|
263
|
+
const provider = module.createRemoteProvider({ baseUrl, timeoutMs: config.timeoutMs });
|
|
264
|
+
const manifest = await resolveManifest(provider, baseUrl, config.timeoutMs);
|
|
265
|
+
return { provider: provider, manifest };
|
|
266
|
+
}
|
|
267
|
+
if (!config.packPath)
|
|
268
|
+
throw new Error('EKnowledgeAdapter requires packPath, baseUrl, or E Hub configuration');
|
|
269
|
+
return module.loadPack(config.packPath);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
get currentRevision() { return this.loaded.then(pack => 'revision' in pack ? pack.revision.id : 'remote'); }
|
|
273
|
+
async search(query) {
|
|
274
|
+
const pack = await this.loaded;
|
|
275
|
+
if (!query.trim())
|
|
276
|
+
return [];
|
|
277
|
+
const requestedMode = this.preferredMode;
|
|
278
|
+
const manifest = pack.manifest;
|
|
279
|
+
const modeSupported = requestedMode === 'lexical' || manifest.capabilities.semanticSearch;
|
|
280
|
+
let response;
|
|
281
|
+
try {
|
|
282
|
+
response = await pack.provider.retrieve({ query, mode: modeSupported ? requestedMode : 'lexical', limit: 8 });
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
if (requestedMode === 'lexical')
|
|
286
|
+
throw error;
|
|
287
|
+
// Semantic infrastructure is optional: an outage must not remove the
|
|
288
|
+
// provider's cited lexical path.
|
|
289
|
+
response = await pack.provider.retrieve({ query, mode: 'lexical', limit: 8 });
|
|
290
|
+
}
|
|
291
|
+
return response.results.map((result) => ({
|
|
292
|
+
content: result.content,
|
|
293
|
+
revision: result.revision,
|
|
294
|
+
citations: result.citations,
|
|
295
|
+
provenance: result.citations[0]?.sourceId || pack.manifest.publisher
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
exports.EKnowledgeAdapter = EKnowledgeAdapter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const index_1 = require("./index");
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const promises_1 = __importDefault(require("node:dns/promises"));
|
|
9
|
+
const testPackPath = process.env.E_TEST_PACK_PATH || node_path_1.default.resolve(__dirname, '../../../../../e/packages/knowledge/fixtures/sample');
|
|
10
|
+
describe('EKnowledgeAdapter', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
jest.spyOn(promises_1.default, 'lookup').mockImplementation(async (hostname) => {
|
|
13
|
+
if (hostname.includes('blocked') || hostname.includes('127.0.0.1')) {
|
|
14
|
+
return [{ address: '127.0.0.1', family: 4 }];
|
|
15
|
+
}
|
|
16
|
+
return [{ address: '93.184.216.34', family: 4 }]; // public IP
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
jest.restoreAllMocks();
|
|
21
|
+
});
|
|
22
|
+
test('loads an E pack and preserves citations and revision', async () => {
|
|
23
|
+
const adapter = new index_1.EKnowledgeAdapter({ packPath: testPackPath });
|
|
24
|
+
const results = await adapter.search('grounded facts');
|
|
25
|
+
expect(results).toHaveLength(1);
|
|
26
|
+
expect(results[0]).toMatchObject({ content: 'Siduri knowledge packs provide grounded facts.', revision: 'r1' });
|
|
27
|
+
expect(results[0].citations[0]).toMatchObject({ sourceId: 'handbook', documentId: 'intro', chunkId: 'intro-1' });
|
|
28
|
+
});
|
|
29
|
+
test('does not retrieve for an empty query', async () => {
|
|
30
|
+
const adapter = new index_1.EKnowledgeAdapter({ packPath: testPackPath });
|
|
31
|
+
expect(await adapter.search(' ')).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
test('falls back to lexical when semantic capability is unavailable', async () => {
|
|
34
|
+
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
|
35
|
+
const url = String(input);
|
|
36
|
+
const data = url.endsWith('/manifest')
|
|
37
|
+
? { id: '@publisher/installed-pack', name: 'installed-pack', publisher: 'publisher', version: '1.0.0', schemaVersion: '1.0', sources: [{ id: 'source', title: 'Source', license: 'CC-BY-4.0' }], capabilities: { lexicalSearch: true, semanticSearch: false, structuredEntities: true, relations: true, revisions: true } }
|
|
38
|
+
: { revision: 'r1', results: [{ id: 'c1', content: 'lexical fallback', revision: 'r1', citations: [{ sourceId: 'gi-data', chunkId: 'c1' }] }] };
|
|
39
|
+
if (!url.endsWith('/manifest')) {
|
|
40
|
+
expect(JSON.parse(String(init?.body)).mode).toBe('lexical');
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
status: 200,
|
|
45
|
+
text: async () => JSON.stringify(data),
|
|
46
|
+
json: async () => data,
|
|
47
|
+
headers: new Headers(),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const adapter = new index_1.EKnowledgeAdapter({ provider: 'e-remote', baseUrl: 'https://provider.example/api/e', preferredMode: 'semantic' });
|
|
51
|
+
await expect(adapter.search('fallback')).resolves.toMatchObject([{ content: 'lexical fallback' }]);
|
|
52
|
+
fetchMock.mockRestore();
|
|
53
|
+
});
|
|
54
|
+
test('loads an E remote provider and preserves its citation contract', async () => {
|
|
55
|
+
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
|
56
|
+
const url = String(input);
|
|
57
|
+
const body = url.endsWith('/manifest')
|
|
58
|
+
? { id: '@publisher/installed-pack', name: 'installed-pack', publisher: 'publisher', version: '1.0.0', schemaVersion: '1.0', sources: [{ id: 'source', title: 'Source', license: 'CC-BY-4.0' }], capabilities: { lexicalSearch: true, semanticSearch: false, structuredEntities: true, relations: true, revisions: true } }
|
|
59
|
+
: { revision: 'teyvat-r1', results: [{ id: 'chunk-1', content: 'Furina uses materials.', revision: 'teyvat-r1', citations: [{ sourceId: 'gi-data', documentId: 'doc-1', chunkId: 'chunk-1' }] }] };
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
status: 200,
|
|
63
|
+
text: async () => JSON.stringify(body),
|
|
64
|
+
json: async () => body,
|
|
65
|
+
headers: new Headers(),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
const adapter = new index_1.EKnowledgeAdapter({ provider: 'e-remote', baseUrl: 'https://provider.example/api/e' });
|
|
69
|
+
await expect(adapter.search('Furina')).resolves.toMatchObject([{ revision: 'teyvat-r1', provenance: 'gi-data' }]);
|
|
70
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/manifest'), expect.anything());
|
|
71
|
+
fetchMock.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
test('resolves a provider distribution through the E Hub', async () => {
|
|
74
|
+
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
|
75
|
+
const url = String(input);
|
|
76
|
+
const body = url.includes('/api/packs/publisher/installed-pack')
|
|
77
|
+
? { distribution: { kind: 'provider', url: 'https://provider.example/api/e' } }
|
|
78
|
+
: url.endsWith('/manifest')
|
|
79
|
+
? { id: '@publisher/installed-pack', name: 'installed-pack', publisher: 'publisher', version: '1.0.0', schemaVersion: '1.0', sources: [{ id: 'source', title: 'Source', license: 'CC-BY-4.0' }], capabilities: { lexicalSearch: true, semanticSearch: false, structuredEntities: true, relations: true, revisions: true } }
|
|
80
|
+
: { revision: 'teyvat-r1', results: [{ id: 'chunk-1', content: 'hub fact', revision: 'teyvat-r1', citations: [{ sourceId: 'gi-data', documentId: 'doc-1', chunkId: 'chunk-1' }] }] };
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
status: 200,
|
|
84
|
+
text: async () => JSON.stringify(body),
|
|
85
|
+
json: async () => body,
|
|
86
|
+
headers: new Headers(),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
const adapter = new index_1.EKnowledgeAdapter({ provider: 'e-hub', registryUrl: 'https://hub.example/api/packs', packId: '@publisher/installed-pack' });
|
|
90
|
+
await expect(adapter.search('hub')).resolves.toMatchObject([{ content: 'hub fact', revision: 'teyvat-r1' }]);
|
|
91
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/packs/publisher/installed-pack'), expect.anything());
|
|
92
|
+
fetchMock.mockRestore();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('SSRF Hardening Suite', () => {
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
jest.spyOn(promises_1.default, 'lookup').mockImplementation(async (hostname) => {
|
|
98
|
+
if (hostname.includes('blocked') || hostname.includes('127.0.0.1') || hostname.includes('internal.corp')) {
|
|
99
|
+
return [{ address: '10.0.0.5', family: 4 }];
|
|
100
|
+
}
|
|
101
|
+
return [{ address: '93.184.216.34', family: 4 }]; // public IP
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
jest.restoreAllMocks();
|
|
106
|
+
});
|
|
107
|
+
test('isBlockedIp identifies private, loopback, link-local, and cloud metadata IPs', () => {
|
|
108
|
+
expect((0, index_1.isBlockedIp)('127.0.0.1')).toBe(true);
|
|
109
|
+
expect((0, index_1.isBlockedIp)('127.255.255.255')).toBe(true);
|
|
110
|
+
expect((0, index_1.isBlockedIp)('10.0.0.1')).toBe(true);
|
|
111
|
+
expect((0, index_1.isBlockedIp)('172.16.0.1')).toBe(true);
|
|
112
|
+
expect((0, index_1.isBlockedIp)('192.168.1.1')).toBe(true);
|
|
113
|
+
expect((0, index_1.isBlockedIp)('169.254.169.254')).toBe(true); // AWS/GCP Metadata
|
|
114
|
+
expect((0, index_1.isBlockedIp)('0.0.0.0')).toBe(true);
|
|
115
|
+
expect((0, index_1.isBlockedIp)('::1')).toBe(true);
|
|
116
|
+
expect((0, index_1.isBlockedIp)('::ffff:127.0.0.1')).toBe(true);
|
|
117
|
+
expect((0, index_1.isBlockedIp)('fe80::1')).toBe(true);
|
|
118
|
+
expect((0, index_1.isBlockedIp)('fc00::1')).toBe(true);
|
|
119
|
+
// Public IPs should not be blocked
|
|
120
|
+
expect((0, index_1.isBlockedIp)('8.8.8.8')).toBe(false);
|
|
121
|
+
expect((0, index_1.isBlockedIp)('93.184.216.34')).toBe(false);
|
|
122
|
+
expect((0, index_1.isBlockedIp)('2606:2800:220:1:248:1893:25c8:1946')).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
test('validateSafeUrl rejects non-http schemes and blocked IPs', async () => {
|
|
125
|
+
await expect((0, index_1.validateSafeUrl)('file:///etc/passwd')).rejects.toThrow(/Forbidden protocol/);
|
|
126
|
+
await expect((0, index_1.validateSafeUrl)('ftp://example.com')).rejects.toThrow(/Forbidden protocol/);
|
|
127
|
+
await expect((0, index_1.validateSafeUrl)('http://127.0.0.1:8080/manifest')).rejects.toThrow(/Blocked destination IP address/);
|
|
128
|
+
await expect((0, index_1.validateSafeUrl)('http://169.254.169.254/latest/meta-data')).rejects.toThrow(/Blocked destination IP address/);
|
|
129
|
+
await expect((0, index_1.validateSafeUrl)('http://0.0.0.0/')).rejects.toThrow(/Blocked destination IP address/);
|
|
130
|
+
await expect((0, index_1.validateSafeUrl)('http://100.64.0.1/')).rejects.toThrow(/Blocked destination IP address/); // CGNAT
|
|
131
|
+
await expect((0, index_1.validateSafeUrl)('http://[::1]/')).rejects.toThrow(/Blocked destination IP address/); // IPv6 loopback
|
|
132
|
+
await expect((0, index_1.validateSafeUrl)('http://[fc00::1]/')).rejects.toThrow(/Blocked destination IP address/); // IPv6 ULA
|
|
133
|
+
});
|
|
134
|
+
test('validateSafeUrl rejects hostnames resolving to private IPs', async () => {
|
|
135
|
+
jest.spyOn(promises_1.default, 'lookup').mockResolvedValueOnce([{ address: '10.0.0.5', family: 4 }]);
|
|
136
|
+
await expect((0, index_1.validateSafeUrl)('https://internal.corp/manifest')).rejects.toThrow(/resolved to blocked IP address/);
|
|
137
|
+
});
|
|
138
|
+
test('safeFetch rejects open redirects pointing to internal IP addresses', async () => {
|
|
139
|
+
const fetchMock = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
|
140
|
+
status: 302,
|
|
141
|
+
ok: false,
|
|
142
|
+
headers: new Headers({ location: 'http://169.254.169.254/latest/meta-data' }),
|
|
143
|
+
text: async () => '',
|
|
144
|
+
});
|
|
145
|
+
await expect((0, index_1.safeFetch)('https://public.example/redirect')).rejects.toThrow(/Blocked destination IP address/);
|
|
146
|
+
fetchMock.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@siduri-x/knowledge",
|
|
3
|
+
"organType": "knowledge",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"displayName": "Knowledge (E-Compatible Cited Facts)",
|
|
6
|
+
"description": "Factual context retrieval from local or hosted E Knowledge packs",
|
|
7
|
+
"entrypoint": "./dist/index.js",
|
|
8
|
+
"factory": "EKnowledgeAdapter",
|
|
9
|
+
"configKey": "knowledge",
|
|
10
|
+
"configSchema": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": [
|
|
13
|
+
"provider"
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"provider": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"enum": [
|
|
19
|
+
"e-knowledge",
|
|
20
|
+
"e-remote",
|
|
21
|
+
"e-hub",
|
|
22
|
+
"none"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"packPath": {
|
|
26
|
+
"type": "string"
|
|
27
|
+
},
|
|
28
|
+
"baseUrl": {
|
|
29
|
+
"type": "string"
|
|
30
|
+
},
|
|
31
|
+
"registryUrl": {
|
|
32
|
+
"type": "string"
|
|
33
|
+
},
|
|
34
|
+
"packId": {
|
|
35
|
+
"type": "string"
|
|
36
|
+
},
|
|
37
|
+
"timeoutMs": {
|
|
38
|
+
"type": "number",
|
|
39
|
+
"default": 5000
|
|
40
|
+
},
|
|
41
|
+
"preferredMode": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"enum": [
|
|
44
|
+
"lexical",
|
|
45
|
+
"semantic",
|
|
46
|
+
"hybrid"
|
|
47
|
+
],
|
|
48
|
+
"default": "lexical"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"environment": [
|
|
53
|
+
{
|
|
54
|
+
"name": "SIDURI_KNOWLEDGE_PACK",
|
|
55
|
+
"required": false,
|
|
56
|
+
"secret": false,
|
|
57
|
+
"description": "Path to local E knowledge pack"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "SIDURI_KNOWLEDGE_REGISTRY_URL",
|
|
61
|
+
"required": false,
|
|
62
|
+
"secret": false,
|
|
63
|
+
"default": "https://e.vxnus.xyz/api/v1/knowledge",
|
|
64
|
+
"description": "URL of the E Knowledge Hub registry"
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"services": [
|
|
68
|
+
{
|
|
69
|
+
"name": "E Knowledge Hub",
|
|
70
|
+
"kind": "http_service",
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"database": null,
|
|
75
|
+
"healthCheck": null
|
|
76
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@siduri-x/knowledge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"dev": "tsc -w",
|
|
9
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.json"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@vxnus/e": "^0.1.4",
|
|
13
|
+
"@vxnus/e-knowledge": "^0.1.4",
|
|
14
|
+
"@siduri-x/core": "workspace:*"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/jest": "^29.5.14",
|
|
18
|
+
"@types/node": "^26.2.0",
|
|
19
|
+
"jest": "^29.7.0",
|
|
20
|
+
"ts-jest": "^29.4.12",
|
|
21
|
+
"typescript": "^5.3.3"
|
|
22
|
+
},
|
|
23
|
+
"description": "Knowledge organ for semantic search and retrieval from external sources",
|
|
24
|
+
"license": "UNLICENSED",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/vxnuslabs/siduri-y",
|
|
28
|
+
"directory": "packages/organs/knowledge"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"organ-manifest.json",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"exports": {
|
|
43
|
+
".": {
|
|
44
|
+
"types": "./dist/index.d.ts",
|
|
45
|
+
"import": "./dist/index.js",
|
|
46
|
+
"default": "./dist/index.js"
|
|
47
|
+
},
|
|
48
|
+
"./organ-manifest.json": "./organ-manifest.json"
|
|
49
|
+
}
|
|
50
|
+
}
|