@sentriflow/core 0.2.1 → 0.3.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.
- package/package.json +1 -1
- package/src/grx2-loader/types.ts +7 -3
- package/src/index.ts +3 -0
- package/src/ip/classifier.ts +416 -0
- package/src/ip/index.ts +16 -0
- package/src/json-rules/schema.json +1 -1
- package/src/pack-loader/format-detector.ts +113 -0
- package/src/pack-loader/index.ts +8 -1
- package/src/validation/index.ts +12 -0
- package/src/validation/rule-validation.ts +209 -0
package/package.json
CHANGED
package/src/grx2-loader/types.ts
CHANGED
|
@@ -30,8 +30,8 @@ export interface LicensePayload {
|
|
|
30
30
|
/** Entitled feed IDs */
|
|
31
31
|
feeds: string[];
|
|
32
32
|
|
|
33
|
-
/** API URL for cloud updates */
|
|
34
|
-
api
|
|
33
|
+
/** API URL for cloud updates (optional - derived from domain at runtime if not set) */
|
|
34
|
+
api?: string;
|
|
35
35
|
|
|
36
36
|
/** Expiration timestamp (Unix seconds) */
|
|
37
37
|
exp: number;
|
|
@@ -189,6 +189,9 @@ export interface EncryptedPackInfo {
|
|
|
189
189
|
|
|
190
190
|
/** Source: local directory or cloud cache */
|
|
191
191
|
source: 'local' | 'cache';
|
|
192
|
+
|
|
193
|
+
/** Pack format (for unified loading) */
|
|
194
|
+
format?: 'grx2' | 'grpx' | 'unencrypted' | 'unknown';
|
|
192
195
|
}
|
|
193
196
|
|
|
194
197
|
/**
|
|
@@ -226,7 +229,8 @@ export type EncryptedPackErrorCode =
|
|
|
226
229
|
| 'DECRYPTION_FAILED'
|
|
227
230
|
| 'MACHINE_MISMATCH'
|
|
228
231
|
| 'NETWORK_ERROR'
|
|
229
|
-
| 'API_ERROR'
|
|
232
|
+
| 'API_ERROR'
|
|
233
|
+
| 'ACTIVATION_FAILED';
|
|
230
234
|
|
|
231
235
|
/**
|
|
232
236
|
* Encrypted pack error
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// packages/core/src/ip/classifier.ts
|
|
2
|
+
|
|
3
|
+
import type { IPSummary, IPCounts } from './types';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// IP Classification Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Classification categories for IP addresses.
|
|
11
|
+
*/
|
|
12
|
+
export type IPClassification =
|
|
13
|
+
| 'public' // Globally routable
|
|
14
|
+
| 'private' // RFC 1918 private ranges
|
|
15
|
+
| 'loopback' // 127.0.0.0/8, ::1
|
|
16
|
+
| 'link-local' // 169.254.0.0/16, fe80::/10
|
|
17
|
+
| 'multicast' // 224.0.0.0/4, ff00::/8
|
|
18
|
+
| 'reserved' // 240.0.0.0/4, other reserved
|
|
19
|
+
| 'unspecified' // 0.0.0.0, ::
|
|
20
|
+
| 'broadcast' // 255.255.255.255
|
|
21
|
+
| 'documentation' // 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32
|
|
22
|
+
| 'cgnat'; // 100.64.0.0/10 (Carrier-grade NAT)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Options for filtering IP addresses.
|
|
26
|
+
*/
|
|
27
|
+
export interface IPFilterOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Keep public (globally routable) addresses.
|
|
30
|
+
* @default true
|
|
31
|
+
*/
|
|
32
|
+
keepPublic?: boolean;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Keep private (RFC 1918) addresses.
|
|
36
|
+
* @default true
|
|
37
|
+
*/
|
|
38
|
+
keepPrivate?: boolean;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Keep loopback addresses (127.x.x.x, ::1).
|
|
42
|
+
* @default false
|
|
43
|
+
*/
|
|
44
|
+
keepLoopback?: boolean;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Keep link-local addresses (169.254.x.x, fe80::).
|
|
48
|
+
* @default false
|
|
49
|
+
*/
|
|
50
|
+
keepLinkLocal?: boolean;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Keep multicast addresses (224.x.x.x - 239.x.x.x, ff00::).
|
|
54
|
+
* @default false
|
|
55
|
+
*/
|
|
56
|
+
keepMulticast?: boolean;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Keep reserved/future use addresses (240.x.x.x - 255.x.x.x).
|
|
60
|
+
* @default false
|
|
61
|
+
*/
|
|
62
|
+
keepReserved?: boolean;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Keep unspecified addresses (0.0.0.0, ::).
|
|
66
|
+
* @default false
|
|
67
|
+
*/
|
|
68
|
+
keepUnspecified?: boolean;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Keep broadcast address (255.255.255.255).
|
|
72
|
+
* @default false
|
|
73
|
+
*/
|
|
74
|
+
keepBroadcast?: boolean;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Keep documentation addresses (TEST-NET ranges, 2001:db8::).
|
|
78
|
+
* @default false
|
|
79
|
+
*/
|
|
80
|
+
keepDocumentation?: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Keep CGNAT addresses (100.64.0.0/10).
|
|
84
|
+
* @default true
|
|
85
|
+
*/
|
|
86
|
+
keepCgnat?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Default filter options - keep only public and private addresses.
|
|
91
|
+
*/
|
|
92
|
+
export const DEFAULT_FILTER_OPTIONS: Required<IPFilterOptions> = {
|
|
93
|
+
keepPublic: true,
|
|
94
|
+
keepPrivate: true,
|
|
95
|
+
keepLoopback: false,
|
|
96
|
+
keepLinkLocal: false,
|
|
97
|
+
keepMulticast: false,
|
|
98
|
+
keepReserved: false,
|
|
99
|
+
keepUnspecified: false,
|
|
100
|
+
keepBroadcast: false,
|
|
101
|
+
keepDocumentation: false,
|
|
102
|
+
keepCgnat: true,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// IPv4 Classification
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Convert IPv4 address string to 32-bit number.
|
|
111
|
+
*/
|
|
112
|
+
function ipv4ToNumber(ip: string): number {
|
|
113
|
+
const parts = ip.split('.');
|
|
114
|
+
if (parts.length !== 4) return 0;
|
|
115
|
+
|
|
116
|
+
let result = 0;
|
|
117
|
+
for (let i = 0; i < 4; i++) {
|
|
118
|
+
const octet = parseInt(parts[i] ?? '0', 10);
|
|
119
|
+
if (isNaN(octet) || octet < 0 || octet > 255) return 0;
|
|
120
|
+
result = (result << 8) + octet;
|
|
121
|
+
}
|
|
122
|
+
return result >>> 0; // Ensure unsigned
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if IPv4 address is in a given CIDR range.
|
|
127
|
+
*/
|
|
128
|
+
function isInIPv4Range(ip: string, network: string, prefix: number): boolean {
|
|
129
|
+
const ipNum = ipv4ToNumber(ip);
|
|
130
|
+
const netNum = ipv4ToNumber(network);
|
|
131
|
+
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
|
|
132
|
+
return (ipNum & mask) === (netNum & mask);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Classify an IPv4 address.
|
|
137
|
+
*/
|
|
138
|
+
export function classifyIPv4(ip: string): IPClassification {
|
|
139
|
+
// Unspecified
|
|
140
|
+
if (ip === '0.0.0.0') return 'unspecified';
|
|
141
|
+
|
|
142
|
+
// Broadcast
|
|
143
|
+
if (ip === '255.255.255.255') return 'broadcast';
|
|
144
|
+
|
|
145
|
+
// Current network (0.0.0.0/8) - treat as unspecified
|
|
146
|
+
if (isInIPv4Range(ip, '0.0.0.0', 8)) return 'unspecified';
|
|
147
|
+
|
|
148
|
+
// Loopback (127.0.0.0/8)
|
|
149
|
+
if (isInIPv4Range(ip, '127.0.0.0', 8)) return 'loopback';
|
|
150
|
+
|
|
151
|
+
// Link-local (169.254.0.0/16)
|
|
152
|
+
if (isInIPv4Range(ip, '169.254.0.0', 16)) return 'link-local';
|
|
153
|
+
|
|
154
|
+
// Private ranges (RFC 1918)
|
|
155
|
+
if (isInIPv4Range(ip, '10.0.0.0', 8)) return 'private';
|
|
156
|
+
if (isInIPv4Range(ip, '172.16.0.0', 12)) return 'private';
|
|
157
|
+
if (isInIPv4Range(ip, '192.168.0.0', 16)) return 'private';
|
|
158
|
+
|
|
159
|
+
// CGNAT (100.64.0.0/10)
|
|
160
|
+
if (isInIPv4Range(ip, '100.64.0.0', 10)) return 'cgnat';
|
|
161
|
+
|
|
162
|
+
// Documentation ranges (TEST-NET)
|
|
163
|
+
if (isInIPv4Range(ip, '192.0.2.0', 24)) return 'documentation';
|
|
164
|
+
if (isInIPv4Range(ip, '198.51.100.0', 24)) return 'documentation';
|
|
165
|
+
if (isInIPv4Range(ip, '203.0.113.0', 24)) return 'documentation';
|
|
166
|
+
|
|
167
|
+
// Multicast (224.0.0.0/4)
|
|
168
|
+
if (isInIPv4Range(ip, '224.0.0.0', 4)) return 'multicast';
|
|
169
|
+
|
|
170
|
+
// Reserved for future use (240.0.0.0/4)
|
|
171
|
+
if (isInIPv4Range(ip, '240.0.0.0', 4)) return 'reserved';
|
|
172
|
+
|
|
173
|
+
// Everything else is public
|
|
174
|
+
return 'public';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Classify an IPv4 subnet (uses network address for classification).
|
|
179
|
+
*/
|
|
180
|
+
export function classifyIPv4Subnet(subnet: string): IPClassification {
|
|
181
|
+
const slashIndex = subnet.lastIndexOf('/');
|
|
182
|
+
if (slashIndex === -1) return classifyIPv4(subnet);
|
|
183
|
+
|
|
184
|
+
const network = subnet.substring(0, slashIndex);
|
|
185
|
+
return classifyIPv4(network);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// IPv6 Classification
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Expand IPv6 address to full form and return as array of 16-bit values.
|
|
194
|
+
*/
|
|
195
|
+
function expandIPv6(ip: string): number[] {
|
|
196
|
+
// Strip zone ID if present
|
|
197
|
+
const zoneIndex = ip.indexOf('%');
|
|
198
|
+
const addr = zoneIndex !== -1 ? ip.substring(0, zoneIndex) : ip;
|
|
199
|
+
|
|
200
|
+
const parts = addr.split(':');
|
|
201
|
+
const result: number[] = [];
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < parts.length; i++) {
|
|
204
|
+
const part = parts[i] ?? '';
|
|
205
|
+
if (part === '' && i > 0 && i < parts.length - 1) {
|
|
206
|
+
// Middle :: - expand
|
|
207
|
+
const nonEmpty = parts.filter((p) => p !== '').length;
|
|
208
|
+
const zeros = 8 - nonEmpty;
|
|
209
|
+
for (let j = 0; j < zeros; j++) {
|
|
210
|
+
result.push(0);
|
|
211
|
+
}
|
|
212
|
+
} else if (part !== '') {
|
|
213
|
+
result.push(parseInt(part, 16) || 0);
|
|
214
|
+
} else if (i === 0 && (parts[1] ?? '') === '') {
|
|
215
|
+
// Leading ::
|
|
216
|
+
const nonEmpty = parts.filter((p) => p !== '').length;
|
|
217
|
+
const zeros = 8 - nonEmpty;
|
|
218
|
+
for (let j = 0; j < zeros; j++) {
|
|
219
|
+
result.push(0);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Pad to 8 if needed
|
|
225
|
+
while (result.length < 8) {
|
|
226
|
+
result.push(0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return result.slice(0, 8);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Check if IPv6 starts with a specific prefix.
|
|
234
|
+
*/
|
|
235
|
+
function ipv6StartsWith(ip: string, prefixHex: number, prefixBits: number): boolean {
|
|
236
|
+
const parts = expandIPv6(ip);
|
|
237
|
+
|
|
238
|
+
// Calculate how many 16-bit groups we need to check
|
|
239
|
+
const fullGroups = Math.floor(prefixBits / 16);
|
|
240
|
+
const remainingBits = prefixBits % 16;
|
|
241
|
+
|
|
242
|
+
// Build the prefix value from parts
|
|
243
|
+
let value = 0;
|
|
244
|
+
for (let i = 0; i < fullGroups && i < parts.length; i++) {
|
|
245
|
+
value = (value << 16) | (parts[i] ?? 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (remainingBits > 0 && fullGroups < parts.length) {
|
|
249
|
+
const mask = (~0 << (16 - remainingBits)) & 0xffff;
|
|
250
|
+
value = (value << remainingBits) | (((parts[fullGroups] ?? 0) & mask) >> (16 - remainingBits));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return value === prefixHex;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Classify an IPv6 address.
|
|
258
|
+
*/
|
|
259
|
+
export function classifyIPv6(ip: string): IPClassification {
|
|
260
|
+
const parts = expandIPv6(ip);
|
|
261
|
+
|
|
262
|
+
// Unspecified (::)
|
|
263
|
+
if (parts.every((p) => p === 0)) return 'unspecified';
|
|
264
|
+
|
|
265
|
+
// Loopback (::1)
|
|
266
|
+
if (parts.slice(0, 7).every((p) => p === 0) && parts[7] === 1) return 'loopback';
|
|
267
|
+
|
|
268
|
+
// Link-local (fe80::/10)
|
|
269
|
+
if ((parts[0] ?? 0) >= 0xfe80 && (parts[0] ?? 0) <= 0xfebf) return 'link-local';
|
|
270
|
+
|
|
271
|
+
// Multicast (ff00::/8)
|
|
272
|
+
if (((parts[0] ?? 0) & 0xff00) === 0xff00) return 'multicast';
|
|
273
|
+
|
|
274
|
+
// Documentation (2001:db8::/32)
|
|
275
|
+
if (parts[0] === 0x2001 && parts[1] === 0x0db8) return 'documentation';
|
|
276
|
+
|
|
277
|
+
// Unique local (fc00::/7) - similar to private
|
|
278
|
+
if (((parts[0] ?? 0) & 0xfe00) === 0xfc00) return 'private';
|
|
279
|
+
|
|
280
|
+
// Everything else is public
|
|
281
|
+
return 'public';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Classify an IPv6 subnet (uses network address for classification).
|
|
286
|
+
*/
|
|
287
|
+
export function classifyIPv6Subnet(subnet: string): IPClassification {
|
|
288
|
+
const slashIndex = subnet.lastIndexOf('/');
|
|
289
|
+
if (slashIndex === -1) return classifyIPv6(subnet);
|
|
290
|
+
|
|
291
|
+
const network = subnet.substring(0, slashIndex);
|
|
292
|
+
return classifyIPv6(network);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ============================================================================
|
|
296
|
+
// Filtering Functions
|
|
297
|
+
// ============================================================================
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Check if a classification should be kept based on filter options.
|
|
301
|
+
*/
|
|
302
|
+
function shouldKeepClassification(
|
|
303
|
+
classification: IPClassification,
|
|
304
|
+
options: Required<IPFilterOptions>
|
|
305
|
+
): boolean {
|
|
306
|
+
switch (classification) {
|
|
307
|
+
case 'public':
|
|
308
|
+
return options.keepPublic;
|
|
309
|
+
case 'private':
|
|
310
|
+
return options.keepPrivate;
|
|
311
|
+
case 'loopback':
|
|
312
|
+
return options.keepLoopback;
|
|
313
|
+
case 'link-local':
|
|
314
|
+
return options.keepLinkLocal;
|
|
315
|
+
case 'multicast':
|
|
316
|
+
return options.keepMulticast;
|
|
317
|
+
case 'reserved':
|
|
318
|
+
return options.keepReserved;
|
|
319
|
+
case 'unspecified':
|
|
320
|
+
return options.keepUnspecified;
|
|
321
|
+
case 'broadcast':
|
|
322
|
+
return options.keepBroadcast;
|
|
323
|
+
case 'documentation':
|
|
324
|
+
return options.keepDocumentation;
|
|
325
|
+
case 'cgnat':
|
|
326
|
+
return options.keepCgnat;
|
|
327
|
+
default:
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Filter an array of IPv4 addresses based on classification.
|
|
334
|
+
*/
|
|
335
|
+
export function filterIPv4Addresses(
|
|
336
|
+
addresses: string[],
|
|
337
|
+
options: IPFilterOptions = {}
|
|
338
|
+
): string[] {
|
|
339
|
+
const opts: Required<IPFilterOptions> = { ...DEFAULT_FILTER_OPTIONS, ...options };
|
|
340
|
+
return addresses.filter((ip) => {
|
|
341
|
+
const classification = classifyIPv4(ip);
|
|
342
|
+
return shouldKeepClassification(classification, opts);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Filter an array of IPv6 addresses based on classification.
|
|
348
|
+
*/
|
|
349
|
+
export function filterIPv6Addresses(
|
|
350
|
+
addresses: string[],
|
|
351
|
+
options: IPFilterOptions = {}
|
|
352
|
+
): string[] {
|
|
353
|
+
const opts: Required<IPFilterOptions> = { ...DEFAULT_FILTER_OPTIONS, ...options };
|
|
354
|
+
return addresses.filter((ip) => {
|
|
355
|
+
const classification = classifyIPv6(ip);
|
|
356
|
+
return shouldKeepClassification(classification, opts);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Filter an array of IPv4 subnets based on classification.
|
|
362
|
+
*/
|
|
363
|
+
export function filterIPv4Subnets(
|
|
364
|
+
subnets: string[],
|
|
365
|
+
options: IPFilterOptions = {}
|
|
366
|
+
): string[] {
|
|
367
|
+
const opts: Required<IPFilterOptions> = { ...DEFAULT_FILTER_OPTIONS, ...options };
|
|
368
|
+
return subnets.filter((subnet) => {
|
|
369
|
+
const classification = classifyIPv4Subnet(subnet);
|
|
370
|
+
return shouldKeepClassification(classification, opts);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Filter an array of IPv6 subnets based on classification.
|
|
376
|
+
*/
|
|
377
|
+
export function filterIPv6Subnets(
|
|
378
|
+
subnets: string[],
|
|
379
|
+
options: IPFilterOptions = {}
|
|
380
|
+
): string[] {
|
|
381
|
+
const opts: Required<IPFilterOptions> = { ...DEFAULT_FILTER_OPTIONS, ...options };
|
|
382
|
+
return subnets.filter((subnet) => {
|
|
383
|
+
const classification = classifyIPv6Subnet(subnet);
|
|
384
|
+
return shouldKeepClassification(classification, opts);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Filter an entire IPSummary based on classification options.
|
|
390
|
+
* Returns a new IPSummary with filtered results and updated counts.
|
|
391
|
+
*/
|
|
392
|
+
export function filterIPSummary(
|
|
393
|
+
summary: IPSummary,
|
|
394
|
+
options: IPFilterOptions = {}
|
|
395
|
+
): IPSummary {
|
|
396
|
+
const ipv4Addresses = filterIPv4Addresses(summary.ipv4Addresses, options);
|
|
397
|
+
const ipv6Addresses = filterIPv6Addresses(summary.ipv6Addresses, options);
|
|
398
|
+
const ipv4Subnets = filterIPv4Subnets(summary.ipv4Subnets, options);
|
|
399
|
+
const ipv6Subnets = filterIPv6Subnets(summary.ipv6Subnets, options);
|
|
400
|
+
|
|
401
|
+
const counts: IPCounts = {
|
|
402
|
+
ipv4: ipv4Addresses.length,
|
|
403
|
+
ipv6: ipv6Addresses.length,
|
|
404
|
+
ipv4Subnets: ipv4Subnets.length,
|
|
405
|
+
ipv6Subnets: ipv6Subnets.length,
|
|
406
|
+
total: ipv4Addresses.length + ipv6Addresses.length + ipv4Subnets.length + ipv6Subnets.length,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
ipv4Addresses,
|
|
411
|
+
ipv6Addresses,
|
|
412
|
+
ipv4Subnets,
|
|
413
|
+
ipv6Subnets,
|
|
414
|
+
counts,
|
|
415
|
+
};
|
|
416
|
+
}
|
package/src/ip/index.ts
CHANGED
|
@@ -25,3 +25,19 @@ export type {
|
|
|
25
25
|
} from './types';
|
|
26
26
|
|
|
27
27
|
export { InputValidationError, DEFAULT_MAX_CONTENT_SIZE } from './types';
|
|
28
|
+
|
|
29
|
+
// IP Classification and Filtering
|
|
30
|
+
export {
|
|
31
|
+
classifyIPv4,
|
|
32
|
+
classifyIPv6,
|
|
33
|
+
classifyIPv4Subnet,
|
|
34
|
+
classifyIPv6Subnet,
|
|
35
|
+
filterIPv4Addresses,
|
|
36
|
+
filterIPv6Addresses,
|
|
37
|
+
filterIPv4Subnets,
|
|
38
|
+
filterIPv6Subnets,
|
|
39
|
+
filterIPSummary,
|
|
40
|
+
DEFAULT_FILTER_OPTIONS,
|
|
41
|
+
} from './classifier';
|
|
42
|
+
|
|
43
|
+
export type { IPClassification, IPFilterOptions } from './classifier';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "https://sentriflow.
|
|
3
|
+
"$id": "https://sentriflow.com.au/schemas/json-rules/v1.0.json",
|
|
4
4
|
"title": "SentriFlow JSON Rules",
|
|
5
5
|
"description": "Schema for SentriFlow JSON rule files",
|
|
6
6
|
"type": "object",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pack Format Detection
|
|
3
|
+
*
|
|
4
|
+
* Detects pack format from magic bytes.
|
|
5
|
+
* Shared between CLI and VS Code extension.
|
|
6
|
+
*
|
|
7
|
+
* @module format-detector
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { open } from 'node:fs/promises';
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Detected pack format
|
|
15
|
+
*/
|
|
16
|
+
export type PackFormat = 'grx2' | 'grpx' | 'unencrypted' | 'unknown';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Priority tiers by format.
|
|
20
|
+
* Higher priority packs override lower priority packs for the same rule.
|
|
21
|
+
*
|
|
22
|
+
* - unknown: 0 (fallback, should not occur in normal operation)
|
|
23
|
+
* - unencrypted: 100 (plain JS/TS modules)
|
|
24
|
+
* - grpx: 200 (legacy encrypted format)
|
|
25
|
+
* - grx2: 300 (extended encrypted format)
|
|
26
|
+
*/
|
|
27
|
+
export const FORMAT_PRIORITIES: Record<PackFormat, number> = {
|
|
28
|
+
unknown: 0,
|
|
29
|
+
unencrypted: 100,
|
|
30
|
+
grpx: 200,
|
|
31
|
+
grx2: 300,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Magic bytes for format detection
|
|
36
|
+
*/
|
|
37
|
+
const MAGIC_BYTES = {
|
|
38
|
+
GRX2: Buffer.from('GRX2', 'ascii'),
|
|
39
|
+
GRPX: Buffer.from('GRPX', 'ascii'),
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
const MAGIC_BYTES_LENGTH = 4;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Detect the format of a pack file by reading magic bytes.
|
|
46
|
+
*
|
|
47
|
+
* @param filePath - Path to the pack file
|
|
48
|
+
* @returns Promise resolving to the detected format
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* import { detectPackFormat } from '@sentriflow/core';
|
|
53
|
+
*
|
|
54
|
+
* const format = await detectPackFormat('/path/to/pack.grx2');
|
|
55
|
+
* // Returns: 'grx2'
|
|
56
|
+
*
|
|
57
|
+
* const format2 = await detectPackFormat('/path/to/rules.js');
|
|
58
|
+
* // Returns: 'unencrypted'
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export async function detectPackFormat(filePath: string): Promise<PackFormat> {
|
|
62
|
+
const absolutePath = resolve(filePath);
|
|
63
|
+
let fileHandle;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
fileHandle = await open(absolutePath, 'r');
|
|
67
|
+
const stats = await fileHandle.stat();
|
|
68
|
+
|
|
69
|
+
// Files smaller than magic bytes length are treated as unencrypted
|
|
70
|
+
if (stats.size < MAGIC_BYTES_LENGTH) {
|
|
71
|
+
return 'unencrypted';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const buffer = Buffer.alloc(MAGIC_BYTES_LENGTH);
|
|
75
|
+
const { bytesRead } = await fileHandle.read(
|
|
76
|
+
buffer,
|
|
77
|
+
0,
|
|
78
|
+
MAGIC_BYTES_LENGTH,
|
|
79
|
+
0
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (bytesRead < MAGIC_BYTES_LENGTH) {
|
|
83
|
+
return 'unencrypted';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check for GRX2 magic bytes
|
|
87
|
+
if (buffer.equals(MAGIC_BYTES.GRX2)) {
|
|
88
|
+
return 'grx2';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check for GRPX magic bytes
|
|
92
|
+
if (buffer.equals(MAGIC_BYTES.GRPX)) {
|
|
93
|
+
return 'grpx';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// No magic bytes match - treat as unencrypted module
|
|
97
|
+
return 'unencrypted';
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Re-throw file access errors with context
|
|
100
|
+
if (error instanceof Error) {
|
|
101
|
+
const nodeError = error as NodeJS.ErrnoException;
|
|
102
|
+
if (nodeError.code === 'ENOENT') {
|
|
103
|
+
throw new Error(`Pack file not found: ${absolutePath}`);
|
|
104
|
+
}
|
|
105
|
+
if (nodeError.code === 'EACCES') {
|
|
106
|
+
throw new Error(`Permission denied reading pack file: ${absolutePath}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
await fileHandle?.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/pack-loader/index.ts
CHANGED
|
@@ -14,4 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
export * from './types';
|
|
17
|
-
export { loadEncryptedPack, validatePackFormat } from './PackLoader';
|
|
17
|
+
export { loadEncryptedPack, validatePackFormat, compileNativeCheckFunction } from './PackLoader';
|
|
18
|
+
|
|
19
|
+
// Pack format detection (shared with CLI and VS Code)
|
|
20
|
+
export {
|
|
21
|
+
detectPackFormat,
|
|
22
|
+
FORMAT_PRIORITIES,
|
|
23
|
+
type PackFormat,
|
|
24
|
+
} from './format-detector';
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule and RulePack validation utilities
|
|
3
|
+
* Shared between CLI and VS Code extension for DRY compliance.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { RULE_ID_PATTERN } from '../constants';
|
|
7
|
+
import { isValidVendorId } from '../types/IRule';
|
|
8
|
+
import type { IRule, RulePack, RuleVendor } from '../types/IRule';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validates that an object has the basic structure of an IRule.
|
|
12
|
+
* Returns error message if invalid, null if valid.
|
|
13
|
+
*
|
|
14
|
+
* Security: Prevents malicious extensions from registering invalid rules.
|
|
15
|
+
*/
|
|
16
|
+
export function validateRule(rule: unknown): string | null {
|
|
17
|
+
if (typeof rule !== 'object' || rule === null) {
|
|
18
|
+
return 'Rule is not an object';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const obj = rule as Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
// Required: id (string matching pattern)
|
|
24
|
+
if (typeof obj.id !== 'string') {
|
|
25
|
+
return 'Rule id is not a string';
|
|
26
|
+
}
|
|
27
|
+
if (!RULE_ID_PATTERN.test(obj.id)) {
|
|
28
|
+
return `Rule id "${obj.id}" does not match pattern ${RULE_ID_PATTERN}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Required: check (function)
|
|
32
|
+
if (typeof obj.check !== 'function') {
|
|
33
|
+
return `Rule ${obj.id}: check is not a function (got ${typeof obj.check})`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Optional but recommended: selector (string)
|
|
37
|
+
if (obj.selector !== undefined && typeof obj.selector !== 'string') {
|
|
38
|
+
return `Rule ${obj.id}: selector is not a string`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Optional: vendor (string or array of valid vendors)
|
|
42
|
+
if (obj.vendor !== undefined) {
|
|
43
|
+
if (Array.isArray(obj.vendor)) {
|
|
44
|
+
for (const v of obj.vendor) {
|
|
45
|
+
if (typeof v !== 'string') {
|
|
46
|
+
return `Rule ${obj.id}: vendor array contains non-string`;
|
|
47
|
+
}
|
|
48
|
+
if (!isValidVendorId(v)) {
|
|
49
|
+
return `Rule ${obj.id}: invalid vendor "${v}"`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} else if (typeof obj.vendor !== 'string') {
|
|
53
|
+
return `Rule ${obj.id}: vendor is not a string`;
|
|
54
|
+
} else if (!isValidVendorId(obj.vendor)) {
|
|
55
|
+
return `Rule ${obj.id}: invalid vendor "${obj.vendor}"`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Required: metadata (object with level)
|
|
60
|
+
if (typeof obj.metadata !== 'object' || obj.metadata === null) {
|
|
61
|
+
return `Rule ${obj.id}: metadata is not an object`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const metadata = obj.metadata as Record<string, unknown>;
|
|
65
|
+
if (!['error', 'warning', 'info'].includes(metadata.level as string)) {
|
|
66
|
+
return `Rule ${obj.id}: invalid metadata.level "${metadata.level}"`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Type guard to check if an object is a valid IRule.
|
|
74
|
+
*/
|
|
75
|
+
export function isValidRule(rule: unknown): rule is IRule {
|
|
76
|
+
return validateRule(rule) === null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validates that an object has the basic structure of a RulePack.
|
|
81
|
+
* Returns error message if invalid, null if valid.
|
|
82
|
+
*
|
|
83
|
+
* @param pack - The object to validate
|
|
84
|
+
* @param reservedPackName - Optional pack name that is reserved (e.g., "Default Rules")
|
|
85
|
+
*/
|
|
86
|
+
export function validateRulePack(
|
|
87
|
+
pack: unknown,
|
|
88
|
+
reservedPackName?: string
|
|
89
|
+
): string | null {
|
|
90
|
+
if (typeof pack !== 'object' || pack === null) {
|
|
91
|
+
return 'Pack is not an object';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const obj = pack as Record<string, unknown>;
|
|
95
|
+
|
|
96
|
+
// Required: name (non-empty string)
|
|
97
|
+
if (typeof obj.name !== 'string' || obj.name.length === 0) {
|
|
98
|
+
return 'Pack name is missing or empty';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check reserved name if provided
|
|
102
|
+
if (reservedPackName && obj.name === reservedPackName) {
|
|
103
|
+
return `Pack name "${obj.name}" is reserved`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Required: version (string)
|
|
107
|
+
if (typeof obj.version !== 'string' || obj.version.length === 0) {
|
|
108
|
+
return 'Pack version is missing or empty';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Required: publisher (string)
|
|
112
|
+
if (typeof obj.publisher !== 'string' || obj.publisher.length === 0) {
|
|
113
|
+
return 'Pack publisher is missing or empty';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Required: priority (number >= 0)
|
|
117
|
+
if (typeof obj.priority !== 'number' || obj.priority < 0) {
|
|
118
|
+
return `Pack priority is invalid (got ${obj.priority})`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Required: rules (array)
|
|
122
|
+
if (!Array.isArray(obj.rules)) {
|
|
123
|
+
return 'Pack rules is not an array';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Validate each rule in the pack
|
|
127
|
+
for (let i = 0; i < obj.rules.length; i++) {
|
|
128
|
+
const ruleError = validateRule(obj.rules[i]);
|
|
129
|
+
if (ruleError) {
|
|
130
|
+
return `Rule[${i}]: ${ruleError}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Optional: disables (object with specific structure)
|
|
135
|
+
if (obj.disables !== undefined) {
|
|
136
|
+
if (typeof obj.disables !== 'object' || obj.disables === null) {
|
|
137
|
+
return 'Pack disables is not an object';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const disables = obj.disables as Record<string, unknown>;
|
|
141
|
+
|
|
142
|
+
// Optional: all (boolean)
|
|
143
|
+
if (disables.all !== undefined && typeof disables.all !== 'boolean') {
|
|
144
|
+
return 'Pack disables.all is not a boolean';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Optional: vendors (array of valid vendor strings)
|
|
148
|
+
if (disables.vendors !== undefined) {
|
|
149
|
+
if (!Array.isArray(disables.vendors)) {
|
|
150
|
+
return 'Pack disables.vendors is not an array';
|
|
151
|
+
}
|
|
152
|
+
for (const v of disables.vendors) {
|
|
153
|
+
if (typeof v !== 'string' || !isValidVendorId(v)) {
|
|
154
|
+
return `Pack disables.vendors contains invalid vendor "${v}"`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Optional: rules (array of strings)
|
|
160
|
+
if (disables.rules !== undefined) {
|
|
161
|
+
if (!Array.isArray(disables.rules)) {
|
|
162
|
+
return 'Pack disables.rules is not an array';
|
|
163
|
+
}
|
|
164
|
+
for (const r of disables.rules) {
|
|
165
|
+
if (typeof r !== 'string') {
|
|
166
|
+
return 'Pack disables.rules contains non-string';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Type guard to check if an object is a valid RulePack.
|
|
177
|
+
*
|
|
178
|
+
* @param pack - The object to validate
|
|
179
|
+
* @param reservedPackName - Optional pack name that is reserved
|
|
180
|
+
*/
|
|
181
|
+
export function isValidRulePack(
|
|
182
|
+
pack: unknown,
|
|
183
|
+
reservedPackName?: string
|
|
184
|
+
): pack is RulePack {
|
|
185
|
+
return validateRulePack(pack, reservedPackName) === null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if a rule applies to the given vendor.
|
|
190
|
+
* Rules without a vendor property are considered vendor-agnostic (apply to all).
|
|
191
|
+
* Rules with vendor: 'common' also apply to all vendors.
|
|
192
|
+
*/
|
|
193
|
+
export function ruleAppliesToVendor(rule: IRule, vendorId: string): boolean {
|
|
194
|
+
// No vendor specified = vendor-agnostic, applies to all
|
|
195
|
+
if (!rule.vendor) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Handle array of vendors
|
|
200
|
+
if (Array.isArray(rule.vendor)) {
|
|
201
|
+
return (
|
|
202
|
+
rule.vendor.includes('common') ||
|
|
203
|
+
rule.vendor.includes(vendorId as RuleVendor)
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Single vendor
|
|
208
|
+
return rule.vendor === 'common' || rule.vendor === vendorId;
|
|
209
|
+
}
|