shroud-privacy 2.0.13 → 2.0.16
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/README.md +10 -5
- package/dist/compliance.d.ts +44 -0
- package/dist/compliance.js +76 -0
- package/dist/detectors/regex.js +63 -1
- package/dist/exposure.d.ts +29 -0
- package/dist/exposure.js +72 -0
- package/dist/generators/network.d.ts +2 -1
- package/dist/generators/network.js +17 -1
- package/dist/obfuscator.d.ts +9 -0
- package/dist/obfuscator.js +104 -0
- package/dist/policy.d.ts +49 -0
- package/dist/policy.js +105 -0
- package/dist/siem.d.ts +35 -0
- package/dist/siem.js +91 -0
- package/dist/store-file.d.ts +26 -0
- package/dist/store-file.js +79 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -174,12 +174,17 @@ Shroud registers tools that the LLM can call during conversations:
|
|
|
174
174
|
| `shroud_status` | Quick stats: entity counts, session info, audit status (JSON) |
|
|
175
175
|
| `shroud_reset` | Clear all mappings and start a fresh privacy session |
|
|
176
176
|
|
|
177
|
-
You can also run the stats CLI
|
|
177
|
+
You can also run the stats CLI from the terminal:
|
|
178
178
|
|
|
179
179
|
```bash
|
|
180
|
-
shroud-stats
|
|
181
|
-
shroud-stats --json
|
|
182
|
-
shroud-stats --test "Contact john@acme.com"
|
|
180
|
+
node ~/.openclaw/extensions/shroud-privacy/scripts/shroud-stats.mjs # live rule table
|
|
181
|
+
node ~/.openclaw/extensions/shroud-privacy/scripts/shroud-stats.mjs --json # JSON output
|
|
182
|
+
node ~/.openclaw/extensions/shroud-privacy/scripts/shroud-stats.mjs --test "Contact john@acme.com"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Tip: create an alias for convenience:
|
|
186
|
+
```bash
|
|
187
|
+
alias shroud-stats="node ~/.openclaw/extensions/shroud-privacy/scripts/shroud-stats.mjs"
|
|
183
188
|
```
|
|
184
189
|
|
|
185
190
|
The CLI reads live stats from `/tmp/shroud-stats.json` (override with `SHROUD_STATS_FILE` env var). The stats file is updated by the running gateway on every obfuscation event.
|
|
@@ -199,7 +204,7 @@ On subsequent loads, the patch is detected and skipped. To revert: restore the `
|
|
|
199
204
|
|
|
200
205
|
Shroud tracks per-rule match counts for the lifetime of the process. Counters appear in three places:
|
|
201
206
|
|
|
202
|
-
- **`shroud-stats` CLI** —
|
|
207
|
+
- **`shroud-stats` CLI** — see [Conversational tools](#conversational-tools) above for usage. Shows all rules with status, confidence, and hit counts from the running gateway.
|
|
203
208
|
- **Audit log lines** — `byRule=regex:email:3,regex:ipv4:2,...` alongside the existing `byCat` field.
|
|
204
209
|
- **`getStats()`** — the `ruleHits` object in the stats response, useful for programmatic access.
|
|
205
210
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compliance reporter — generates category coverage and detection gap reports.
|
|
3
|
+
*/
|
|
4
|
+
export interface ComplianceConfig {
|
|
5
|
+
/** Path to write report. Empty = disabled. */
|
|
6
|
+
reportPath: string;
|
|
7
|
+
/** Report interval: "hourly" | "daily". */
|
|
8
|
+
reportInterval: "hourly" | "daily";
|
|
9
|
+
/** Required categories that MUST have detections. */
|
|
10
|
+
requiredCategories: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface ComplianceReport {
|
|
13
|
+
generatedAt: string;
|
|
14
|
+
periodStart: string;
|
|
15
|
+
periodEnd: string;
|
|
16
|
+
/** Categories that had detections. */
|
|
17
|
+
activeCategoryCoverage: Record<string, number>;
|
|
18
|
+
/** Required categories with zero detections (gaps). */
|
|
19
|
+
detectionGaps: string[];
|
|
20
|
+
/** Total entities detected in period. */
|
|
21
|
+
totalEntities: number;
|
|
22
|
+
/** Store utilization. */
|
|
23
|
+
storeMappings: number;
|
|
24
|
+
/** Allowlist usage (how many entities were skipped). */
|
|
25
|
+
allowlistSkips: number;
|
|
26
|
+
/** Compliance score: % of required categories with detections. */
|
|
27
|
+
complianceScore: number;
|
|
28
|
+
}
|
|
29
|
+
export declare class ComplianceReporter {
|
|
30
|
+
private _config;
|
|
31
|
+
private _periodStart;
|
|
32
|
+
private _categoryCounts;
|
|
33
|
+
private _totalEntities;
|
|
34
|
+
private _allowlistSkips;
|
|
35
|
+
constructor(config?: Partial<ComplianceConfig>);
|
|
36
|
+
get enabled(): boolean;
|
|
37
|
+
/** Record detection event. */
|
|
38
|
+
recordDetections(categoryCounts: Record<string, number>, allowlistSkips?: number): void;
|
|
39
|
+
/** Generate and optionally write a compliance report. */
|
|
40
|
+
generateReport(storeMappings?: number): ComplianceReport;
|
|
41
|
+
/** Reset for new period. */
|
|
42
|
+
resetPeriod(): void;
|
|
43
|
+
getStats(): object;
|
|
44
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compliance reporter — generates category coverage and detection gap reports.
|
|
3
|
+
*/
|
|
4
|
+
import { writeFileSync } from "node:fs";
|
|
5
|
+
export class ComplianceReporter {
|
|
6
|
+
_config;
|
|
7
|
+
_periodStart;
|
|
8
|
+
_categoryCounts = {};
|
|
9
|
+
_totalEntities = 0;
|
|
10
|
+
_allowlistSkips = 0;
|
|
11
|
+
constructor(config = {}) {
|
|
12
|
+
this._config = {
|
|
13
|
+
reportPath: config.reportPath ?? "",
|
|
14
|
+
reportInterval: config.reportInterval ?? "daily",
|
|
15
|
+
requiredCategories: config.requiredCategories ?? [],
|
|
16
|
+
};
|
|
17
|
+
this._periodStart = new Date().toISOString();
|
|
18
|
+
}
|
|
19
|
+
get enabled() {
|
|
20
|
+
return !!this._config.reportPath;
|
|
21
|
+
}
|
|
22
|
+
/** Record detection event. */
|
|
23
|
+
recordDetections(categoryCounts, allowlistSkips = 0) {
|
|
24
|
+
for (const [cat, count] of Object.entries(categoryCounts)) {
|
|
25
|
+
this._categoryCounts[cat] = (this._categoryCounts[cat] ?? 0) + count;
|
|
26
|
+
}
|
|
27
|
+
this._totalEntities += Object.values(categoryCounts).reduce((a, b) => a + b, 0);
|
|
28
|
+
this._allowlistSkips += allowlistSkips;
|
|
29
|
+
}
|
|
30
|
+
/** Generate and optionally write a compliance report. */
|
|
31
|
+
generateReport(storeMappings = 0) {
|
|
32
|
+
const now = new Date().toISOString();
|
|
33
|
+
const gaps = this._config.requiredCategories.filter((cat) => (this._categoryCounts[cat] ?? 0) === 0);
|
|
34
|
+
const coveredRequired = this._config.requiredCategories.filter((cat) => (this._categoryCounts[cat] ?? 0) > 0);
|
|
35
|
+
const score = this._config.requiredCategories.length > 0
|
|
36
|
+
? Math.round((coveredRequired.length / this._config.requiredCategories.length) *
|
|
37
|
+
100)
|
|
38
|
+
: 100;
|
|
39
|
+
const report = {
|
|
40
|
+
generatedAt: now,
|
|
41
|
+
periodStart: this._periodStart,
|
|
42
|
+
periodEnd: now,
|
|
43
|
+
activeCategoryCoverage: { ...this._categoryCounts },
|
|
44
|
+
detectionGaps: gaps,
|
|
45
|
+
totalEntities: this._totalEntities,
|
|
46
|
+
storeMappings,
|
|
47
|
+
allowlistSkips: this._allowlistSkips,
|
|
48
|
+
complianceScore: score,
|
|
49
|
+
};
|
|
50
|
+
if (this._config.reportPath) {
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(this._config.reportPath, JSON.stringify(report, null, 2) + "\n");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// best-effort
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return report;
|
|
59
|
+
}
|
|
60
|
+
/** Reset for new period. */
|
|
61
|
+
resetPeriod() {
|
|
62
|
+
this._categoryCounts = {};
|
|
63
|
+
this._totalEntities = 0;
|
|
64
|
+
this._allowlistSkips = 0;
|
|
65
|
+
this._periodStart = new Date().toISOString();
|
|
66
|
+
}
|
|
67
|
+
getStats() {
|
|
68
|
+
return {
|
|
69
|
+
enabled: this.enabled,
|
|
70
|
+
periodStart: this._periodStart,
|
|
71
|
+
totalEntities: this._totalEntities,
|
|
72
|
+
categoryCoverage: Object.keys(this._categoryCounts).length,
|
|
73
|
+
requiredCategories: this._config.requiredCategories.length,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/detectors/regex.js
CHANGED
|
@@ -29,6 +29,11 @@ const DOC_HOSTNAMES = new Set([
|
|
|
29
29
|
"localhost", "HOSTNAME", "EXAMPLE", "CHANGEME",
|
|
30
30
|
"YOUR_HOST", "YOURHOST", "hostname", "example",
|
|
31
31
|
]);
|
|
32
|
+
/** Hostname prefixes that are documentation/example labels, not real devices. */
|
|
33
|
+
const DOC_HOSTNAME_PREFIXES = [
|
|
34
|
+
"TEST-NET-", "TEST-", "RFC-", "EXAMPLE-", "SAMPLE-", "DEMO-", "DUMMY-",
|
|
35
|
+
"PLACEHOLDER-", "CHANGEME-", "TODO-",
|
|
36
|
+
];
|
|
32
37
|
/** IPv6 documentation/reserved prefixes that should not be obfuscated. */
|
|
33
38
|
const DOC_IPV6_PREFIXES = [
|
|
34
39
|
"2001:db8:", // RFC 3849 documentation prefix
|
|
@@ -75,7 +80,13 @@ export function isDocExample(value, category) {
|
|
|
75
80
|
// Private ASNs are real infra identifiers — don't skip them
|
|
76
81
|
return false;
|
|
77
82
|
case Category.HOSTNAME:
|
|
78
|
-
|
|
83
|
+
if (DOC_HOSTNAMES.has(value) || DOC_HOSTNAMES.has(value.toUpperCase()))
|
|
84
|
+
return true;
|
|
85
|
+
for (const pfx of DOC_HOSTNAME_PREFIXES) {
|
|
86
|
+
if (value.toUpperCase().startsWith(pfx))
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
79
90
|
default:
|
|
80
91
|
return false;
|
|
81
92
|
}
|
|
@@ -321,6 +332,49 @@ export const BUILTIN_PATTERNS = [
|
|
|
321
332
|
category: Category.NETWORK_CREDENTIAL,
|
|
322
333
|
confidence: 1.0,
|
|
323
334
|
},
|
|
335
|
+
// --- VRF ---
|
|
336
|
+
{
|
|
337
|
+
// "ip vrf VRF-NAME" (IOS classic) — exclude "forwarding" and "context"
|
|
338
|
+
name: "vrf_name_classic",
|
|
339
|
+
pattern: /(?:ip\s+vrf\s+)(?!forwarding\b|context\b)(\S+)/gi,
|
|
340
|
+
category: Category.VLAN_ID,
|
|
341
|
+
confidence: 0.95,
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
// "vrf definition VRF-NAME" (IOS-XE / IOS-XR)
|
|
345
|
+
name: "vrf_definition",
|
|
346
|
+
pattern: /(?:vrf\s+definition\s+)(\S+)/gi,
|
|
347
|
+
category: Category.VLAN_ID,
|
|
348
|
+
confidence: 0.95,
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
// "vrf forwarding VRF-NAME" or "ip vrf forwarding VRF-NAME" (interface binding)
|
|
352
|
+
name: "vrf_forwarding",
|
|
353
|
+
pattern: /(?:(?:ip\s+)?vrf\s+forwarding\s+)(\S+)/gi,
|
|
354
|
+
category: Category.VLAN_ID,
|
|
355
|
+
confidence: 0.95,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
// "vrf VRF-NAME" in Junos / NX-OS (standalone)
|
|
359
|
+
name: "vrf_junos",
|
|
360
|
+
pattern: /(?:^|\n)\s*vrf\s+(\S+)\s*$/gm,
|
|
361
|
+
category: Category.VLAN_ID,
|
|
362
|
+
confidence: 0.85,
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
// Route distinguisher: "rd 65001:100"
|
|
366
|
+
name: "route_distinguisher",
|
|
367
|
+
pattern: /(?:rd\s+)(\d+:\d+)/g,
|
|
368
|
+
category: Category.VLAN_ID,
|
|
369
|
+
confidence: 0.90,
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
// Route target: "route-target export 65001:100"
|
|
373
|
+
name: "route_target",
|
|
374
|
+
pattern: /(?:route-target\s+(?:export|import|both)\s+)(\d+:\d+)/gi,
|
|
375
|
+
category: Category.VLAN_ID,
|
|
376
|
+
confidence: 0.90,
|
|
377
|
+
},
|
|
324
378
|
// --- VLAN ---
|
|
325
379
|
{
|
|
326
380
|
name: "vlan_name",
|
|
@@ -392,6 +446,14 @@ export const BUILTIN_PATTERNS = [
|
|
|
392
446
|
category: Category.HOSTNAME,
|
|
393
447
|
confidence: 0.70,
|
|
394
448
|
},
|
|
449
|
+
{
|
|
450
|
+
// Uppercase infrastructure hostnames: PROD-DB-01, AMS-CORE-SW-01, FRA-EDGE-FW-01
|
|
451
|
+
// Pattern: 2-5 uppercase segments separated by hyphens, ending with digits
|
|
452
|
+
name: "device_name_infra",
|
|
453
|
+
pattern: /\b([A-Z][A-Z0-9]{1,10}(?:-[A-Z][A-Z0-9]{0,10}){1,5}-\d{1,3})\b/g,
|
|
454
|
+
category: Category.HOSTNAME,
|
|
455
|
+
confidence: 0.80,
|
|
456
|
+
},
|
|
395
457
|
// --- Syslog / monitoring (#5) ---
|
|
396
458
|
{
|
|
397
459
|
// Cisco syslog facility: %SYS-5-CONFIG_I, %LINK-3-UPDOWN
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-of-exposure tracker — monitors entity detection velocity.
|
|
3
|
+
* Alerts when entities/minute exceeds threshold, indicating potential
|
|
4
|
+
* bulk data exfiltration or unusual activity.
|
|
5
|
+
*/
|
|
6
|
+
export interface ExposureConfig {
|
|
7
|
+
/** Entities per minute threshold. 0 = disabled. */
|
|
8
|
+
rateThreshold: number;
|
|
9
|
+
/** Window size in seconds for rate calculation. Default: 60. */
|
|
10
|
+
windowSeconds: number;
|
|
11
|
+
/** Callback when threshold exceeded. */
|
|
12
|
+
onAlert?: (rate: number, threshold: number, window: number) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare class ExposureTracker {
|
|
15
|
+
private _config;
|
|
16
|
+
private _timestamps;
|
|
17
|
+
private _alertCount;
|
|
18
|
+
private _lastAlertTime;
|
|
19
|
+
/** Minimum interval between alerts (ms) to prevent alert storms. */
|
|
20
|
+
private _alertCooldownMs;
|
|
21
|
+
constructor(config?: Partial<ExposureConfig>);
|
|
22
|
+
get enabled(): boolean;
|
|
23
|
+
/** Record entity detections. */
|
|
24
|
+
record(entityCount: number): void;
|
|
25
|
+
/** Current rate (entities/minute). */
|
|
26
|
+
currentRate(): number;
|
|
27
|
+
getStats(): object;
|
|
28
|
+
reset(): void;
|
|
29
|
+
}
|
package/dist/exposure.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-of-exposure tracker — monitors entity detection velocity.
|
|
3
|
+
* Alerts when entities/minute exceeds threshold, indicating potential
|
|
4
|
+
* bulk data exfiltration or unusual activity.
|
|
5
|
+
*/
|
|
6
|
+
export class ExposureTracker {
|
|
7
|
+
_config;
|
|
8
|
+
_timestamps = [];
|
|
9
|
+
_alertCount = 0;
|
|
10
|
+
_lastAlertTime = 0;
|
|
11
|
+
/** Minimum interval between alerts (ms) to prevent alert storms. */
|
|
12
|
+
_alertCooldownMs = 60000;
|
|
13
|
+
constructor(config = {}) {
|
|
14
|
+
this._config = {
|
|
15
|
+
rateThreshold: config.rateThreshold ?? 0,
|
|
16
|
+
windowSeconds: config.windowSeconds ?? 60,
|
|
17
|
+
onAlert: config.onAlert,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
get enabled() {
|
|
21
|
+
return this._config.rateThreshold > 0;
|
|
22
|
+
}
|
|
23
|
+
/** Record entity detections. */
|
|
24
|
+
record(entityCount) {
|
|
25
|
+
if (!this.enabled || entityCount === 0)
|
|
26
|
+
return;
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
for (let i = 0; i < entityCount; i++) {
|
|
29
|
+
this._timestamps.push(now);
|
|
30
|
+
}
|
|
31
|
+
// Trim old timestamps outside the window
|
|
32
|
+
const cutoff = now - this._config.windowSeconds * 1000;
|
|
33
|
+
while (this._timestamps.length > 0 && this._timestamps[0] < cutoff) {
|
|
34
|
+
this._timestamps.shift();
|
|
35
|
+
}
|
|
36
|
+
// Check rate
|
|
37
|
+
const windowMinutes = this._config.windowSeconds / 60;
|
|
38
|
+
const rate = this._timestamps.length / windowMinutes;
|
|
39
|
+
if (rate > this._config.rateThreshold) {
|
|
40
|
+
if (now - this._lastAlertTime > this._alertCooldownMs) {
|
|
41
|
+
this._alertCount++;
|
|
42
|
+
this._lastAlertTime = now;
|
|
43
|
+
this._config.onAlert?.(Math.round(rate), this._config.rateThreshold, this._config.windowSeconds);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Current rate (entities/minute). */
|
|
48
|
+
currentRate() {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const cutoff = now - this._config.windowSeconds * 1000;
|
|
51
|
+
while (this._timestamps.length > 0 && this._timestamps[0] < cutoff) {
|
|
52
|
+
this._timestamps.shift();
|
|
53
|
+
}
|
|
54
|
+
const windowMinutes = this._config.windowSeconds / 60;
|
|
55
|
+
return Math.round(this._timestamps.length / windowMinutes);
|
|
56
|
+
}
|
|
57
|
+
getStats() {
|
|
58
|
+
return {
|
|
59
|
+
enabled: this.enabled,
|
|
60
|
+
currentRate: this.currentRate(),
|
|
61
|
+
threshold: this._config.rateThreshold,
|
|
62
|
+
windowSeconds: this._config.windowSeconds,
|
|
63
|
+
alertCount: this._alertCount,
|
|
64
|
+
entitiesInWindow: this._timestamps.length,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
reset() {
|
|
68
|
+
this._timestamps = [];
|
|
69
|
+
this._alertCount = 0;
|
|
70
|
+
this._lastAlertTime = 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -45,6 +45,7 @@ export declare class SubnetMapper {
|
|
|
45
45
|
reset(): void;
|
|
46
46
|
}
|
|
47
47
|
export declare const VLAN_NAMES: string[];
|
|
48
|
+
export declare const VRF_NAMES: string[];
|
|
48
49
|
export declare const INTERFACE_DESCS: string[];
|
|
49
50
|
export declare const ROUTE_MAP_NAMES: string[];
|
|
50
51
|
export declare const ACL_NAMES: string[];
|
|
@@ -73,7 +74,7 @@ export declare class NetworkGenerator implements BaseGenerator {
|
|
|
73
74
|
_fakeNetworkCredential(seed: number, original: string): string;
|
|
74
75
|
/** Generate a fake hostname preserving structure. */
|
|
75
76
|
_fakeHostname(seed: number): string;
|
|
76
|
-
/** Fake VLAN ID/name. Preserves the keyword structure. */
|
|
77
|
+
/** Fake VLAN ID/name or VRF name. Preserves the keyword structure. */
|
|
77
78
|
_fakeVlanId(seed: number, original: string): string;
|
|
78
79
|
/** Fake interface description. */
|
|
79
80
|
_fakeInterfaceDesc(seed: number, _original: string): string;
|
|
@@ -189,6 +189,12 @@ export const VLAN_NAMES = [
|
|
|
189
189
|
"MGMT", "USERS", "SERVERS", "PRINTERS", "VOIP", "GUEST",
|
|
190
190
|
"DMZ", "BACKUP", "IOT", "SECURITY", "WIRELESS", "STORAGE",
|
|
191
191
|
];
|
|
192
|
+
export const VRF_NAMES = [
|
|
193
|
+
"VRF-TRANSIT", "VRF-SERVICES", "VRF-INTERNAL", "VRF-EXTERNAL",
|
|
194
|
+
"VRF-MGMT", "VRF-BACKUP", "VRF-GUEST", "VRF-DMZ",
|
|
195
|
+
"VRF-CORE", "VRF-EDGE", "VRF-INFRA", "VRF-MONITOR",
|
|
196
|
+
"VRF-VOICE", "VRF-DATA", "VRF-IOT", "VRF-SECURE",
|
|
197
|
+
];
|
|
192
198
|
export const INTERFACE_DESCS = [
|
|
193
199
|
"Uplink to Core", "Server Farm Link", "WAN Circuit", "Management VLAN",
|
|
194
200
|
"User Access Port", "Trunk to Distribution", "Backup Link", "DMZ Segment",
|
|
@@ -430,8 +436,18 @@ export class NetworkGenerator {
|
|
|
430
436
|
const num = (seed % 99) + 1;
|
|
431
437
|
return `${site}-${role}-${String(num).padStart(2, "0")}`;
|
|
432
438
|
}
|
|
433
|
-
/** Fake VLAN ID/name. Preserves the keyword structure. */
|
|
439
|
+
/** Fake VLAN ID/name or VRF name. Preserves the keyword structure. */
|
|
434
440
|
_fakeVlanId(seed, original) {
|
|
441
|
+
// VRF names: VRF-VOICE, VRF-EUROCAT_E, VRF_OPS_DATA, etc.
|
|
442
|
+
if (/^VRF[-_]/i.test(original) || /^[A-Z][A-Z_]{2,}$/i.test(original)) {
|
|
443
|
+
return VRF_NAMES[seed % VRF_NAMES.length];
|
|
444
|
+
}
|
|
445
|
+
// Route distinguisher / route target: 65001:100
|
|
446
|
+
if (/^\d+:\d+$/.test(original)) {
|
|
447
|
+
const asn = 64512 + (seed % 1023);
|
|
448
|
+
const id = 100 + (seed % 900);
|
|
449
|
+
return `${asn}:${id}`;
|
|
450
|
+
}
|
|
435
451
|
// If the original is a "vlan <id>" or just a number in a vlan context,
|
|
436
452
|
// the detector captures the full match. Preserve surrounding keywords.
|
|
437
453
|
const nameMatch = original.match(/name\s+(.+)/i);
|
package/dist/obfuscator.d.ts
CHANGED
|
@@ -65,6 +65,15 @@ export declare class Obfuscator {
|
|
|
65
65
|
* Subnet-aware reverse mapping for CGNAT IPs not in the store.
|
|
66
66
|
*/
|
|
67
67
|
private _deobfuscateResidualCgnat;
|
|
68
|
+
/**
|
|
69
|
+
* Clean up CGNAT range descriptions that LLMs generate when summarizing
|
|
70
|
+
* fake networks. The LLM sees multiple 100.64.x.y addresses and writes
|
|
71
|
+
* summaries like "100.64.x.x/xx" or "within 100.64.x.x space".
|
|
72
|
+
*
|
|
73
|
+
* Strategy: find the most common real network prefix from the store
|
|
74
|
+
* mappings and replace CGNAT range descriptions with the real prefix.
|
|
75
|
+
*/
|
|
76
|
+
private _deobfuscateCgnatRangeDescriptions;
|
|
68
77
|
/**
|
|
69
78
|
* Normalize-and-match deobfuscation for fd00::/8 ULA IPv6 addresses.
|
|
70
79
|
*/
|
package/dist/obfuscator.js
CHANGED
|
@@ -17,6 +17,15 @@ import { ContextDetector } from "./detectors/context.js";
|
|
|
17
17
|
import { RedactionFormatter } from "./redaction.js";
|
|
18
18
|
/** Regex to find CGNAT IPs (100.64.0.0/10) in text. */
|
|
19
19
|
const CGNAT_IP_RE = /\b(100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3})\b/g;
|
|
20
|
+
/**
|
|
21
|
+
* Regex to find CGNAT range descriptions that LLMs generate when summarizing
|
|
22
|
+
* fake networks. Catches patterns like:
|
|
23
|
+
* - "100.64.x.x/xx" or "100.64.0.x/24"
|
|
24
|
+
* - "100.64.x.x space" or "within 100.64.x.x"
|
|
25
|
+
* - "100.64.0.0/10" (the CGNAT range itself)
|
|
26
|
+
* - "100.64.x.x" (wildcard notation)
|
|
27
|
+
*/
|
|
28
|
+
const CGNAT_RANGE_DESC_RE = /\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.[\dx]+\.[\dx]+(?:\/[\dx]+)?\b/gi;
|
|
20
29
|
/** Regex to find fd00::/8 ULA IPv6 addresses (Shroud fake range) in text. */
|
|
21
30
|
const ULA_IPV6_RE = /(?:^|(?<=[\s,;=(\[]))fd00(?::[0-9a-fA-F]{1,4}){0,7}(?:::(?:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4})*)?)?(?=$|[\s,;)\]\/])/gi;
|
|
22
31
|
/**
|
|
@@ -405,6 +414,14 @@ export class Obfuscator {
|
|
|
405
414
|
result = residualV6.text;
|
|
406
415
|
totalReplacements += residualV6.count;
|
|
407
416
|
}
|
|
417
|
+
// CGNAT range description cleanup: catch LLM-generated summaries like
|
|
418
|
+
// "100.64.x.x/xx" or "100.64.0.x/24" that indicate the LLM learned
|
|
419
|
+
// Shroud's fake range and is describing it generically.
|
|
420
|
+
const rangeCleanup = this._deobfuscateCgnatRangeDescriptions(result);
|
|
421
|
+
if (rangeCleanup.count > 0) {
|
|
422
|
+
result = rangeCleanup.text;
|
|
423
|
+
totalReplacements += rangeCleanup.count;
|
|
424
|
+
}
|
|
408
425
|
// Audit log
|
|
409
426
|
if (this._audit && totalReplacements > 0) {
|
|
410
427
|
const elapsed = Date.now() - startTime;
|
|
@@ -513,6 +530,93 @@ export class Obfuscator {
|
|
|
513
530
|
});
|
|
514
531
|
return { text: result, count };
|
|
515
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* Clean up CGNAT range descriptions that LLMs generate when summarizing
|
|
535
|
+
* fake networks. The LLM sees multiple 100.64.x.y addresses and writes
|
|
536
|
+
* summaries like "100.64.x.x/xx" or "within 100.64.x.x space".
|
|
537
|
+
*
|
|
538
|
+
* Strategy: find the most common real network prefix from the store
|
|
539
|
+
* mappings and replace CGNAT range descriptions with the real prefix.
|
|
540
|
+
*/
|
|
541
|
+
_deobfuscateCgnatRangeDescriptions(text) {
|
|
542
|
+
const mapper = this._subnetMapper;
|
|
543
|
+
if (mapper.subnetRev.size === 0)
|
|
544
|
+
return { text, count: 0 };
|
|
545
|
+
// Find the most common real network prefix to use as replacement
|
|
546
|
+
// Build a map of real network prefixes and their frequency
|
|
547
|
+
const realPrefixCounts = new Map();
|
|
548
|
+
for (const [, key] of mapper.subnetRev) {
|
|
549
|
+
const [realNetStr, prefixLenStr] = key.split(",");
|
|
550
|
+
const realNetInt = parseInt(realNetStr, 10);
|
|
551
|
+
const prefixLen = parseInt(prefixLenStr, 10);
|
|
552
|
+
const realIp = intToIp(realNetInt);
|
|
553
|
+
// Extract first two octets as the prefix
|
|
554
|
+
const prefix = realIp.split(".").slice(0, 2).join(".");
|
|
555
|
+
realPrefixCounts.set(prefix, (realPrefixCounts.get(prefix) ?? 0) + 1);
|
|
556
|
+
}
|
|
557
|
+
if (realPrefixCounts.size === 0)
|
|
558
|
+
return { text, count: 0 };
|
|
559
|
+
// Find the most common real prefix
|
|
560
|
+
let bestPrefix = "10.0";
|
|
561
|
+
let bestCount = 0;
|
|
562
|
+
for (const [prefix, count] of realPrefixCounts) {
|
|
563
|
+
if (count > bestCount) {
|
|
564
|
+
bestPrefix = prefix;
|
|
565
|
+
bestCount = count;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Build mapping: fake subnet prefix → {realPrefix, realPrefixLen}
|
|
569
|
+
const fakeToRealMap = new Map();
|
|
570
|
+
let mostCommonPrefixLen = 24; // default
|
|
571
|
+
const prefixLenCounts = new Map();
|
|
572
|
+
for (const [fakeNetInt, key] of mapper.subnetRev) {
|
|
573
|
+
const fakeIp = intToIp(fakeNetInt);
|
|
574
|
+
const fakePrefix = fakeIp.split(".").slice(0, 2).join(".");
|
|
575
|
+
const [realNetStr, prefixLenStr] = key.split(",");
|
|
576
|
+
const realIp = intToIp(parseInt(realNetStr, 10));
|
|
577
|
+
const realPrefix = realIp.split(".").slice(0, 2).join(".");
|
|
578
|
+
const prefixLen = parseInt(prefixLenStr, 10);
|
|
579
|
+
fakeToRealMap.set(fakePrefix, { prefix: realPrefix, prefixLen });
|
|
580
|
+
prefixLenCounts.set(prefixLen, (prefixLenCounts.get(prefixLen) ?? 0) + 1);
|
|
581
|
+
}
|
|
582
|
+
// Find most common prefix length
|
|
583
|
+
let bestPrefixLenCount = 0;
|
|
584
|
+
for (const [pLen, cnt] of prefixLenCounts) {
|
|
585
|
+
if (cnt > bestPrefixLenCount) {
|
|
586
|
+
mostCommonPrefixLen = pLen;
|
|
587
|
+
bestPrefixLenCount = cnt;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
let count = 0;
|
|
591
|
+
const result = text.replace(CGNAT_RANGE_DESC_RE, (match) => {
|
|
592
|
+
// Don't replace if it's a standard CGNAT IP (handled by _deobfuscateResidualCgnat)
|
|
593
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(match))
|
|
594
|
+
return match;
|
|
595
|
+
// Extract the first two octets of the fake range
|
|
596
|
+
const parts = match.split(".");
|
|
597
|
+
if (parts.length >= 2) {
|
|
598
|
+
const fakePrefix = parts[0] + "." + parts[1];
|
|
599
|
+
const mapping = fakeToRealMap.get(fakePrefix);
|
|
600
|
+
const realPrefix = mapping?.prefix || bestPrefix;
|
|
601
|
+
const realPrefixLen = mapping?.prefixLen || mostCommonPrefixLen;
|
|
602
|
+
count++;
|
|
603
|
+
// Replace CGNAT octets with real octets
|
|
604
|
+
let replaced = match.replace(/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])/, realPrefix);
|
|
605
|
+
// Fix CIDR suffix: if the match has /NN where NN is a CGNAT-range prefix length
|
|
606
|
+
// (like /10 from 100.64.0.0/10), replace with the real prefix length
|
|
607
|
+
replaced = replaced.replace(/\/10\b/, `/${realPrefixLen}`);
|
|
608
|
+
// Also fix generic /xx notation
|
|
609
|
+
replaced = replaced.replace(/\/xx\b/, `/${realPrefixLen}`);
|
|
610
|
+
return replaced;
|
|
611
|
+
}
|
|
612
|
+
count++;
|
|
613
|
+
let replaced = match.replace(/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])/, bestPrefix);
|
|
614
|
+
replaced = replaced.replace(/\/10\b/, `/${mostCommonPrefixLen}`);
|
|
615
|
+
replaced = replaced.replace(/\/xx\b/, `/${mostCommonPrefixLen}`);
|
|
616
|
+
return replaced;
|
|
617
|
+
});
|
|
618
|
+
return { text: result, count };
|
|
619
|
+
}
|
|
516
620
|
/**
|
|
517
621
|
* Normalize-and-match deobfuscation for fd00::/8 ULA IPv6 addresses.
|
|
518
622
|
*/
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy-as-code enforcement — validate Shroud config against policy constraints.
|
|
3
|
+
* Policy files define required categories, minimum confidence levels, and mandatory denylist entries.
|
|
4
|
+
*/
|
|
5
|
+
import { ShroudConfig } from "./types.js";
|
|
6
|
+
export interface PolicyDef {
|
|
7
|
+
/** Policy version. */
|
|
8
|
+
version: string;
|
|
9
|
+
/** Human-readable policy name. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Categories that MUST be enabled (not overridden to disabled). */
|
|
12
|
+
requiredCategories?: string[];
|
|
13
|
+
/** Minimum global confidence threshold. */
|
|
14
|
+
minConfidence?: number;
|
|
15
|
+
/** Maximum global confidence threshold (prevent over-filtering). */
|
|
16
|
+
maxConfidence?: number;
|
|
17
|
+
/** Values that MUST be in the denylist. */
|
|
18
|
+
mandatoryDenylist?: string[];
|
|
19
|
+
/** Values that MUST NOT be in the allowlist. */
|
|
20
|
+
prohibitedAllowlist?: string[];
|
|
21
|
+
/** Audit must be enabled. */
|
|
22
|
+
requireAudit?: boolean;
|
|
23
|
+
/** Canary must be enabled. */
|
|
24
|
+
requireCanary?: boolean;
|
|
25
|
+
/** Redaction level must be one of these. */
|
|
26
|
+
allowedRedactionLevels?: string[];
|
|
27
|
+
/** Custom rules. */
|
|
28
|
+
customRules?: Array<{
|
|
29
|
+
name: string;
|
|
30
|
+
check: string;
|
|
31
|
+
message: string;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
export interface PolicyViolation {
|
|
35
|
+
rule: string;
|
|
36
|
+
severity: "error" | "warning";
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Load a policy from a JSON file.
|
|
41
|
+
*/
|
|
42
|
+
export declare function loadPolicy(filePath: string): PolicyDef | null;
|
|
43
|
+
/**
|
|
44
|
+
* Validate a ShroudConfig against a policy.
|
|
45
|
+
* Returns an array of violations (empty = compliant).
|
|
46
|
+
*/
|
|
47
|
+
export declare function validatePolicy(config: ShroudConfig, policy: PolicyDef, detectorOverrides?: Record<string, {
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
}>): PolicyViolation[];
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy-as-code enforcement — validate Shroud config against policy constraints.
|
|
3
|
+
* Policy files define required categories, minimum confidence levels, and mandatory denylist entries.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
/**
|
|
7
|
+
* Load a policy from a JSON file.
|
|
8
|
+
*/
|
|
9
|
+
export function loadPolicy(filePath) {
|
|
10
|
+
if (!filePath || !existsSync(filePath))
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Validate a ShroudConfig against a policy.
|
|
22
|
+
* Returns an array of violations (empty = compliant).
|
|
23
|
+
*/
|
|
24
|
+
export function validatePolicy(config, policy, detectorOverrides) {
|
|
25
|
+
const violations = [];
|
|
26
|
+
// Required categories
|
|
27
|
+
if (policy.requiredCategories) {
|
|
28
|
+
const overrides = detectorOverrides ?? config.detectorOverrides ?? {};
|
|
29
|
+
for (const cat of policy.requiredCategories) {
|
|
30
|
+
// Check if any rule for this category is explicitly disabled
|
|
31
|
+
const disabledRules = Object.entries(overrides)
|
|
32
|
+
.filter(([, v]) => v.enabled === false)
|
|
33
|
+
.map(([k]) => k);
|
|
34
|
+
// This is a simplified check — in practice you'd need to map rules to categories
|
|
35
|
+
if (disabledRules.length > 0) {
|
|
36
|
+
// Log a warning, not an error, since we can't easily map rule→category here
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Minimum confidence
|
|
41
|
+
if (policy.minConfidence !== undefined && config.minConfidence < policy.minConfidence) {
|
|
42
|
+
violations.push({
|
|
43
|
+
rule: "minConfidence",
|
|
44
|
+
severity: "error",
|
|
45
|
+
message: `Global minConfidence (${config.minConfidence}) is below policy minimum (${policy.minConfidence})`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// Maximum confidence
|
|
49
|
+
if (policy.maxConfidence !== undefined && config.minConfidence > policy.maxConfidence) {
|
|
50
|
+
violations.push({
|
|
51
|
+
rule: "maxConfidence",
|
|
52
|
+
severity: "warning",
|
|
53
|
+
message: `Global minConfidence (${config.minConfidence}) exceeds policy maximum (${policy.maxConfidence}) — may over-filter`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// Mandatory denylist
|
|
57
|
+
if (policy.mandatoryDenylist) {
|
|
58
|
+
for (const entry of policy.mandatoryDenylist) {
|
|
59
|
+
if (!config.denylist.includes(entry)) {
|
|
60
|
+
violations.push({
|
|
61
|
+
rule: "mandatoryDenylist",
|
|
62
|
+
severity: "error",
|
|
63
|
+
message: `Required denylist entry missing: "${entry}"`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Prohibited allowlist
|
|
69
|
+
if (policy.prohibitedAllowlist) {
|
|
70
|
+
for (const entry of policy.prohibitedAllowlist) {
|
|
71
|
+
if (config.allowlist.includes(entry)) {
|
|
72
|
+
violations.push({
|
|
73
|
+
rule: "prohibitedAllowlist",
|
|
74
|
+
severity: "error",
|
|
75
|
+
message: `Prohibited allowlist entry found: "${entry}"`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Audit required
|
|
81
|
+
if (policy.requireAudit && !config.auditEnabled) {
|
|
82
|
+
violations.push({
|
|
83
|
+
rule: "requireAudit",
|
|
84
|
+
severity: "error",
|
|
85
|
+
message: "Policy requires audit logging to be enabled",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// Canary required
|
|
89
|
+
if (policy.requireCanary && !config.canaryEnabled) {
|
|
90
|
+
violations.push({
|
|
91
|
+
rule: "requireCanary",
|
|
92
|
+
severity: "warning",
|
|
93
|
+
message: "Policy recommends canary token injection",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Allowed redaction levels
|
|
97
|
+
if (policy.allowedRedactionLevels && !policy.allowedRedactionLevels.includes(config.redactionLevel)) {
|
|
98
|
+
violations.push({
|
|
99
|
+
rule: "redactionLevel",
|
|
100
|
+
severity: "error",
|
|
101
|
+
message: `Redaction level "${config.redactionLevel}" is not allowed by policy. Allowed: ${policy.allowedRedactionLevels.join(", ")}`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return violations;
|
|
105
|
+
}
|
package/dist/siem.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SIEM webhook forwarder — batch and async delivery of audit events.
|
|
3
|
+
* Does not block obfuscation pipeline.
|
|
4
|
+
*/
|
|
5
|
+
export interface SiemConfig {
|
|
6
|
+
/** Webhook URL to send audit events to. Empty = disabled. */
|
|
7
|
+
webhookUrl: string;
|
|
8
|
+
/** Custom headers (e.g., Authorization). */
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
/** Max events to batch before flushing. Default: 10. */
|
|
11
|
+
batchSize: number;
|
|
12
|
+
/** Max time (ms) to hold events before flushing. Default: 5000. */
|
|
13
|
+
flushIntervalMs: number;
|
|
14
|
+
/** Format: "json" (array of events) or "ndjson" (newline-delimited). */
|
|
15
|
+
format: "json" | "ndjson";
|
|
16
|
+
}
|
|
17
|
+
export declare const DEFAULT_SIEM_CONFIG: SiemConfig;
|
|
18
|
+
export declare class SiemForwarder {
|
|
19
|
+
private _config;
|
|
20
|
+
private _buffer;
|
|
21
|
+
private _timer;
|
|
22
|
+
private _sendCount;
|
|
23
|
+
private _errorCount;
|
|
24
|
+
private _lastError;
|
|
25
|
+
constructor(config?: Partial<SiemConfig>);
|
|
26
|
+
get enabled(): boolean;
|
|
27
|
+
/** Queue an audit event for delivery. */
|
|
28
|
+
push(event: Record<string, unknown>): void;
|
|
29
|
+
/** Flush buffered events to the webhook. */
|
|
30
|
+
flush(): void;
|
|
31
|
+
/** Stop the forwarder and flush remaining events. */
|
|
32
|
+
stop(): void;
|
|
33
|
+
getStats(): object;
|
|
34
|
+
private _sendHttp;
|
|
35
|
+
}
|
package/dist/siem.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SIEM webhook forwarder — batch and async delivery of audit events.
|
|
3
|
+
* Does not block obfuscation pipeline.
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_SIEM_CONFIG = {
|
|
6
|
+
webhookUrl: "",
|
|
7
|
+
headers: {},
|
|
8
|
+
batchSize: 10,
|
|
9
|
+
flushIntervalMs: 5000,
|
|
10
|
+
format: "json",
|
|
11
|
+
};
|
|
12
|
+
export class SiemForwarder {
|
|
13
|
+
_config;
|
|
14
|
+
_buffer = [];
|
|
15
|
+
_timer = null;
|
|
16
|
+
_sendCount = 0;
|
|
17
|
+
_errorCount = 0;
|
|
18
|
+
_lastError = null;
|
|
19
|
+
constructor(config = {}) {
|
|
20
|
+
this._config = { ...DEFAULT_SIEM_CONFIG, ...config };
|
|
21
|
+
if (this._config.webhookUrl) {
|
|
22
|
+
this._timer = setInterval(() => this.flush(), this._config.flushIntervalMs);
|
|
23
|
+
if (this._timer.unref)
|
|
24
|
+
this._timer.unref();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
get enabled() {
|
|
28
|
+
return !!this._config.webhookUrl;
|
|
29
|
+
}
|
|
30
|
+
/** Queue an audit event for delivery. */
|
|
31
|
+
push(event) {
|
|
32
|
+
if (!this.enabled)
|
|
33
|
+
return;
|
|
34
|
+
this._buffer.push({
|
|
35
|
+
...event,
|
|
36
|
+
_ts: new Date().toISOString(),
|
|
37
|
+
_source: "shroud",
|
|
38
|
+
});
|
|
39
|
+
if (this._buffer.length >= this._config.batchSize) {
|
|
40
|
+
this.flush();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Flush buffered events to the webhook. */
|
|
44
|
+
flush() {
|
|
45
|
+
if (this._buffer.length === 0 || !this.enabled)
|
|
46
|
+
return;
|
|
47
|
+
const events = this._buffer.splice(0);
|
|
48
|
+
const body = this._config.format === "ndjson"
|
|
49
|
+
? events.map((e) => JSON.stringify(e)).join("\n") + "\n"
|
|
50
|
+
: JSON.stringify(events);
|
|
51
|
+
this._sendHttp(body).catch((err) => {
|
|
52
|
+
this._errorCount++;
|
|
53
|
+
this._lastError = String(err);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Stop the forwarder and flush remaining events. */
|
|
57
|
+
stop() {
|
|
58
|
+
if (this._timer) {
|
|
59
|
+
clearInterval(this._timer);
|
|
60
|
+
this._timer = null;
|
|
61
|
+
}
|
|
62
|
+
this.flush();
|
|
63
|
+
}
|
|
64
|
+
getStats() {
|
|
65
|
+
return {
|
|
66
|
+
enabled: this.enabled,
|
|
67
|
+
buffered: this._buffer.length,
|
|
68
|
+
sent: this._sendCount,
|
|
69
|
+
errors: this._errorCount,
|
|
70
|
+
lastError: this._lastError,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async _sendHttp(body) {
|
|
74
|
+
const contentType = this._config.format === "ndjson"
|
|
75
|
+
? "application/x-ndjson"
|
|
76
|
+
: "application/json";
|
|
77
|
+
const resp = await fetch(this._config.webhookUrl, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: {
|
|
80
|
+
"Content-Type": contentType,
|
|
81
|
+
"User-Agent": "shroud-siem/1.0",
|
|
82
|
+
...this._config.headers,
|
|
83
|
+
},
|
|
84
|
+
body,
|
|
85
|
+
});
|
|
86
|
+
if (!resp.ok) {
|
|
87
|
+
throw new Error(`SIEM webhook returned ${resp.status}`);
|
|
88
|
+
}
|
|
89
|
+
this._sendCount += 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed mapping store — persists to JSON on disk.
|
|
3
|
+
* Wraps MemoryStore with periodic flush and load-on-startup.
|
|
4
|
+
*/
|
|
5
|
+
import { Category } from "./types.js";
|
|
6
|
+
import { MappingStore } from "./store.js";
|
|
7
|
+
export declare class FileBackedStore implements MappingStore {
|
|
8
|
+
private _inner;
|
|
9
|
+
private _filePath;
|
|
10
|
+
private _dirty;
|
|
11
|
+
private _flushTimer;
|
|
12
|
+
private _flushIntervalMs;
|
|
13
|
+
constructor(filePath: string, maxSize?: number, flushIntervalMs?: number);
|
|
14
|
+
put(real: string, fake: string, category: Category): void;
|
|
15
|
+
getFake(real: string): string | undefined;
|
|
16
|
+
getReal(fake: string): string | undefined;
|
|
17
|
+
getCategory(real: string): Category | undefined;
|
|
18
|
+
allMappings(): Map<string, string>;
|
|
19
|
+
size(): number;
|
|
20
|
+
clear(): void;
|
|
21
|
+
/** Flush dirty mappings to disk. */
|
|
22
|
+
flush(): void;
|
|
23
|
+
/** Stop the periodic flush timer. */
|
|
24
|
+
stop(): void;
|
|
25
|
+
private _loadFromDisk;
|
|
26
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed mapping store — persists to JSON on disk.
|
|
3
|
+
* Wraps MemoryStore with periodic flush and load-on-startup.
|
|
4
|
+
*/
|
|
5
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { MemoryStore } from "./store.js";
|
|
8
|
+
export class FileBackedStore {
|
|
9
|
+
_inner;
|
|
10
|
+
_filePath;
|
|
11
|
+
_dirty = false;
|
|
12
|
+
_flushTimer = null;
|
|
13
|
+
_flushIntervalMs;
|
|
14
|
+
constructor(filePath, maxSize = 0, flushIntervalMs = 5000) {
|
|
15
|
+
this._inner = new MemoryStore(maxSize);
|
|
16
|
+
this._filePath = filePath;
|
|
17
|
+
this._flushIntervalMs = flushIntervalMs;
|
|
18
|
+
// Ensure directory exists
|
|
19
|
+
const dir = dirname(filePath);
|
|
20
|
+
if (!existsSync(dir)) {
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
// Load existing data
|
|
24
|
+
this._loadFromDisk();
|
|
25
|
+
// Periodic flush
|
|
26
|
+
this._flushTimer = setInterval(() => this.flush(), this._flushIntervalMs);
|
|
27
|
+
if (this._flushTimer.unref)
|
|
28
|
+
this._flushTimer.unref();
|
|
29
|
+
}
|
|
30
|
+
put(real, fake, category) {
|
|
31
|
+
this._inner.put(real, fake, category);
|
|
32
|
+
this._dirty = true;
|
|
33
|
+
}
|
|
34
|
+
getFake(real) { return this._inner.getFake(real); }
|
|
35
|
+
getReal(fake) { return this._inner.getReal(fake); }
|
|
36
|
+
getCategory(real) { return this._inner.getCategory(real); }
|
|
37
|
+
allMappings() { return this._inner.allMappings(); }
|
|
38
|
+
size() { return this._inner.size(); }
|
|
39
|
+
clear() {
|
|
40
|
+
this._inner.clear();
|
|
41
|
+
this._dirty = true;
|
|
42
|
+
this.flush();
|
|
43
|
+
}
|
|
44
|
+
/** Flush dirty mappings to disk. */
|
|
45
|
+
flush() {
|
|
46
|
+
if (!this._dirty)
|
|
47
|
+
return;
|
|
48
|
+
try {
|
|
49
|
+
const data = this._inner.export("", undefined);
|
|
50
|
+
writeFileSync(this._filePath, JSON.stringify(data, null, 2) + "\n");
|
|
51
|
+
this._dirty = false;
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
// best-effort — log but don't crash
|
|
55
|
+
process.stderr.write(`[shroud-store] Flush failed: ${e.message}\n`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Stop the periodic flush timer. */
|
|
59
|
+
stop() {
|
|
60
|
+
if (this._flushTimer) {
|
|
61
|
+
clearInterval(this._flushTimer);
|
|
62
|
+
this._flushTimer = null;
|
|
63
|
+
}
|
|
64
|
+
this.flush(); // final flush
|
|
65
|
+
}
|
|
66
|
+
_loadFromDisk() {
|
|
67
|
+
if (!existsSync(this._filePath))
|
|
68
|
+
return;
|
|
69
|
+
try {
|
|
70
|
+
const raw = readFileSync(this._filePath, "utf-8");
|
|
71
|
+
const data = JSON.parse(raw);
|
|
72
|
+
this._inner.import(data);
|
|
73
|
+
process.stderr.write(`[shroud-store] Loaded ${data.mappings.length} mappings from ${this._filePath}\n`);
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
process.stderr.write(`[shroud-store] Load failed: ${e.message}\n`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shroud-privacy",
|
|
3
3
|
"name": "Shroud",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.16",
|
|
5
5
|
"description": "Privacy obfuscation with deterministic fake values and deobfuscation — PII never reaches the LLM, tool calls still work",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
package/package.json
CHANGED