shroud-privacy 2.0.14 → 2.0.18

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.
@@ -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
+ }
@@ -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
- return DOC_HOSTNAMES.has(value) || DOC_HOSTNAMES.has(value.toUpperCase());
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
+ }
@@ -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;
@@ -159,15 +159,19 @@ export class SubnetMapper {
159
159
  const existing = this.subnetFwd.get(key);
160
160
  if (existing !== undefined)
161
161
  return existing;
162
- // Allocate a slot in CGNAT space, aligned to the subnet size
162
+ // Allocate in CGNAT space using a byte offset (not a slot counter).
163
+ // Each allocation advances the offset by the actual subnet size,
164
+ // aligned to the subnet boundary, to prevent overlapping subnets.
163
165
  const hostBits = 32 - prefixLen;
164
166
  const subnetSize = 1 << hostBits;
165
- // Place fake networks sequentially in CGNAT space
166
- let fakeNet = (CGNAT_BASE + this.subnetNextSlot * subnetSize) >>> 0;
167
- this.subnetNextSlot += 1;
167
+ // Align the current offset up to the subnet boundary
168
+ const alignedOffset = (this.subnetNextSlot + subnetSize - 1) & ~(subnetSize - 1);
169
+ let fakeNet = (CGNAT_BASE + alignedOffset) >>> 0;
170
+ // Advance offset past this allocation
171
+ this.subnetNextSlot = alignedOffset + subnetSize;
168
172
  // Wrap around if we exceed CGNAT space
169
173
  if ((fakeNet + subnetSize) >>> 0 > (CGNAT_BASE + CGNAT_SIZE) >>> 0) {
170
- this.subnetNextSlot = 0;
174
+ this.subnetNextSlot = subnetSize;
171
175
  fakeNet = CGNAT_BASE;
172
176
  }
173
177
  this.subnetFwd.set(key, fakeNet);
@@ -189,6 +193,12 @@ export const VLAN_NAMES = [
189
193
  "MGMT", "USERS", "SERVERS", "PRINTERS", "VOIP", "GUEST",
190
194
  "DMZ", "BACKUP", "IOT", "SECURITY", "WIRELESS", "STORAGE",
191
195
  ];
196
+ export const VRF_NAMES = [
197
+ "VRF-TRANSIT", "VRF-SERVICES", "VRF-INTERNAL", "VRF-EXTERNAL",
198
+ "VRF-MGMT", "VRF-BACKUP", "VRF-GUEST", "VRF-DMZ",
199
+ "VRF-CORE", "VRF-EDGE", "VRF-INFRA", "VRF-MONITOR",
200
+ "VRF-VOICE", "VRF-DATA", "VRF-IOT", "VRF-SECURE",
201
+ ];
192
202
  export const INTERFACE_DESCS = [
193
203
  "Uplink to Core", "Server Farm Link", "WAN Circuit", "Management VLAN",
194
204
  "User Access Port", "Trunk to Distribution", "Backup Link", "DMZ Segment",
@@ -430,8 +440,18 @@ export class NetworkGenerator {
430
440
  const num = (seed % 99) + 1;
431
441
  return `${site}-${role}-${String(num).padStart(2, "0")}`;
432
442
  }
433
- /** Fake VLAN ID/name. Preserves the keyword structure. */
443
+ /** Fake VLAN ID/name or VRF name. Preserves the keyword structure. */
434
444
  _fakeVlanId(seed, original) {
445
+ // VRF names: VRF-VOICE, VRF-EUROCAT_E, VRF_OPS_DATA, etc.
446
+ if (/^VRF[-_]/i.test(original) || /^[A-Z][A-Z_]{2,}$/i.test(original)) {
447
+ return VRF_NAMES[seed % VRF_NAMES.length];
448
+ }
449
+ // Route distinguisher / route target: 65001:100
450
+ if (/^\d+:\d+$/.test(original)) {
451
+ const asn = 64512 + (seed % 1023);
452
+ const id = 100 + (seed % 900);
453
+ return `${asn}:${id}`;
454
+ }
435
455
  // If the original is a "vlan <id>" or just a number in a vlan context,
436
456
  // the detector captures the full match. Preserve surrounding keywords.
437
457
  const nameMatch = original.match(/name\s+(.+)/i);
package/dist/hooks.js CHANGED
@@ -179,6 +179,10 @@ export function registerHooks(api, obfuscator) {
179
179
  return;
180
180
  dumpStatsFile(obfuscator);
181
181
  api.logger?.info(`[shroud] before_prompt_build: obfuscated ${result.entities.length} entities`);
182
+ // NOTE: OpenClaw's hook API only supports prependContext (not prompt
183
+ // replacement). The raw user text still reaches the LLM alongside
184
+ // the obfuscated version. This is a known limitation tracked as a
185
+ // feature request for OpenClaw's before_prompt_build hook.
182
186
  return {
183
187
  prependContext: [
184
188
  "--- SHROUD PRIVACY LAYER ---",
@@ -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
  */
@@ -17,6 +17,20 @@ 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
+ // Match CGNAT range descriptions including:
29
+ // - Full IPs with wildcards: "100.64.x.x/xx", "100.64.9.x/32"
30
+ // - Hyphenated ranges: "100.64.16-19.0/24", "100.64.0-3.0"
31
+ // - Short 3-octet forms: "100.64.8-14", "100.64.9"
32
+ // - Prose references: "100.64.x.x space", "100.64.8-11"
33
+ const CGNAT_RANGE_DESC_RE = /\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.[\dx]+(?:-[\dx]+)?(?:\.[\dx]+(?:-[\dx]+)?)?)?(?:\/[\dx]+(?:-[\dx]+)?)?\b/gi;
20
34
  /** Regex to find fd00::/8 ULA IPv6 addresses (Shroud fake range) in text. */
21
35
  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
36
  /**
@@ -405,6 +419,14 @@ export class Obfuscator {
405
419
  result = residualV6.text;
406
420
  totalReplacements += residualV6.count;
407
421
  }
422
+ // CGNAT range description cleanup: catch LLM-generated summaries like
423
+ // "100.64.x.x/xx" or "100.64.0.x/24" that indicate the LLM learned
424
+ // Shroud's fake range and is describing it generically.
425
+ const rangeCleanup = this._deobfuscateCgnatRangeDescriptions(result);
426
+ if (rangeCleanup.count > 0) {
427
+ result = rangeCleanup.text;
428
+ totalReplacements += rangeCleanup.count;
429
+ }
408
430
  // Audit log
409
431
  if (this._audit && totalReplacements > 0) {
410
432
  const elapsed = Date.now() - startTime;
@@ -468,6 +490,12 @@ export class Obfuscator {
468
490
  result = residualV6.text;
469
491
  replacementCount += residualV6.count;
470
492
  }
493
+ // CGNAT range description cleanup (same as in deobfuscate)
494
+ const rangeCleanup = this._deobfuscateCgnatRangeDescriptions(result);
495
+ if (rangeCleanup.count > 0) {
496
+ result = rangeCleanup.text;
497
+ replacementCount += rangeCleanup.count;
498
+ }
471
499
  if (this._audit && replacementCount > 0) {
472
500
  const elapsed = Date.now() - startTime;
473
501
  this._audit.logDeobfuscation(replacementCount, undefined, elapsed);
@@ -487,24 +515,42 @@ export class Obfuscator {
487
515
  if (knownFakes.has(match))
488
516
  return match;
489
517
  try {
518
+ // Validate all octets are 0-255 before processing
519
+ const octets = match.split(".").map(s => parseInt(s, 10));
520
+ if (octets.some(o => o > 255 || o < 0 || isNaN(o)))
521
+ return match;
490
522
  const fakeInt = ipToInt(match);
491
523
  // Check if this IP is in CGNAT range
492
524
  if ((fakeInt & CGNAT_MASK_10) !== CGNAT_BASE)
493
525
  return match;
494
- // Try each known fake subnet to find which one this IP belongs to
526
+ // Try each known fake subnet use longest-prefix match to avoid
527
+ // broader subnets incorrectly claiming IPs from narrower ones.
528
+ let bestMatch = null;
495
529
  for (const [fakeNetInt, key] of mapper.subnetRev) {
496
530
  const [realNetStr, prefixLenStr] = key.split(",");
497
531
  const prefixLen = parseInt(prefixLenStr, 10);
498
532
  const mask = prefixLen === 0 ? 0 : ((0xffffffff << (32 - prefixLen)) >>> 0);
499
533
  // Check if this fake IP is in this fake subnet
500
534
  if (((fakeInt & mask) >>> 0) === fakeNetInt) {
501
- const hostBits = (fakeInt & (~mask >>> 0)) >>> 0;
502
- const realNetInt = parseInt(realNetStr, 10);
503
- const realIp = intToIp((realNetInt | hostBits) >>> 0);
504
- count++;
505
- return realIp;
535
+ if (!bestMatch || prefixLen > bestMatch.prefixLen) {
536
+ const hostBits = (fakeInt & (~mask >>> 0)) >>> 0;
537
+ const realNetInt = parseInt(realNetStr, 10);
538
+ const combined = (realNetInt | hostBits) >>> 0;
539
+ // Validate: all octets must be 0-255
540
+ const o1 = (combined >>> 24) & 0xff;
541
+ const o2 = (combined >>> 16) & 0xff;
542
+ const o3 = (combined >>> 8) & 0xff;
543
+ const o4 = combined & 0xff;
544
+ if (o1 <= 255 && o2 <= 255 && o3 <= 255 && o4 <= 255) {
545
+ bestMatch = { realIp: `${o1}.${o2}.${o3}.${o4}`, prefixLen };
546
+ }
547
+ }
506
548
  }
507
549
  }
550
+ if (bestMatch) {
551
+ count++;
552
+ return bestMatch.realIp;
553
+ }
508
554
  }
509
555
  catch {
510
556
  // skip invalid
@@ -513,6 +559,106 @@ export class Obfuscator {
513
559
  });
514
560
  return { text: result, count };
515
561
  }
562
+ /**
563
+ * Clean up CGNAT range descriptions that LLMs generate when summarizing
564
+ * fake networks. The LLM sees multiple 100.64.x.y addresses and writes
565
+ * summaries like "100.64.x.x/xx" or "within 100.64.x.x space".
566
+ *
567
+ * Strategy: find the most common real network prefix from the store
568
+ * mappings and replace CGNAT range descriptions with the real prefix.
569
+ */
570
+ _deobfuscateCgnatRangeDescriptions(text) {
571
+ const mapper = this._subnetMapper;
572
+ if (mapper.subnetRev.size === 0)
573
+ return { text, count: 0 };
574
+ // Find the most common real network prefix to use as replacement
575
+ // Build a map of real network prefixes and their frequency
576
+ const realPrefixCounts = new Map();
577
+ for (const [, key] of mapper.subnetRev) {
578
+ const [realNetStr, prefixLenStr] = key.split(",");
579
+ const realNetInt = parseInt(realNetStr, 10);
580
+ const prefixLen = parseInt(prefixLenStr, 10);
581
+ const realIp = intToIp(realNetInt);
582
+ // Extract first two octets as the prefix
583
+ const prefix = realIp.split(".").slice(0, 2).join(".");
584
+ realPrefixCounts.set(prefix, (realPrefixCounts.get(prefix) ?? 0) + 1);
585
+ }
586
+ if (realPrefixCounts.size === 0)
587
+ return { text, count: 0 };
588
+ // Find the most common real prefix
589
+ let bestPrefix = "10.0";
590
+ let bestCount = 0;
591
+ for (const [prefix, count] of realPrefixCounts) {
592
+ if (count > bestCount) {
593
+ bestPrefix = prefix;
594
+ bestCount = count;
595
+ }
596
+ }
597
+ // Build mapping: full fake network IP → {realIp, prefixLen}
598
+ // Key on the full IP (not just 2 octets) to avoid collisions — all
599
+ // CGNAT subnets start with 100.64, so 2-octet keys overwrite each other.
600
+ const fakeToRealMap = new Map();
601
+ let mostCommonPrefixLen = 24;
602
+ const prefixLenCounts = new Map();
603
+ for (const [fakeNetInt, key] of mapper.subnetRev) {
604
+ const fakeIp = intToIp(fakeNetInt);
605
+ const [realNetStr, prefixLenStr] = key.split(",");
606
+ const realIp = intToIp(parseInt(realNetStr, 10));
607
+ const prefixLen = parseInt(prefixLenStr, 10);
608
+ fakeToRealMap.set(fakeIp, { realIp, prefixLen });
609
+ prefixLenCounts.set(prefixLen, (prefixLenCounts.get(prefixLen) ?? 0) + 1);
610
+ }
611
+ // Find most common prefix length
612
+ let bestPrefixLenCount = 0;
613
+ for (const [pLen, cnt] of prefixLenCounts) {
614
+ if (cnt > bestPrefixLenCount) {
615
+ mostCommonPrefixLen = pLen;
616
+ bestPrefixLenCount = cnt;
617
+ }
618
+ }
619
+ let count = 0;
620
+ const result = text.replace(CGNAT_RANGE_DESC_RE, (match) => {
621
+ // Skip bare IPs without CIDR/range notation ONLY if they were already
622
+ // handled by _deobfuscateResidualCgnat (i.e., no longer contain 100.64)
623
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(match) && !match.startsWith("100."))
624
+ return match;
625
+ // For range descriptions, try to find the best real prefix.
626
+ // First try exact third-octet match, then fall back to best prefix.
627
+ const matchOctets = match.split(".");
628
+ const thirdOctet = parseInt(matchOctets[2], 10);
629
+ let realPrefix = bestPrefix;
630
+ let realPrefixLen = mostCommonPrefixLen;
631
+ if (!isNaN(thirdOctet)) {
632
+ // Try to find a fake subnet whose third octet matches
633
+ for (const [fakeIp, info] of fakeToRealMap) {
634
+ const fakeOctets = fakeIp.split(".");
635
+ if (parseInt(fakeOctets[2], 10) === thirdOctet) {
636
+ realPrefix = info.realIp.split(".").slice(0, 2).join(".");
637
+ realPrefixLen = info.prefixLen;
638
+ break;
639
+ }
640
+ }
641
+ }
642
+ count++;
643
+ // Replace CGNAT first two octets with real prefix
644
+ let replaced = match.replace(/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])/, realPrefix);
645
+ // Fix CIDR suffix
646
+ replaced = replaced.replace(/\/10\b/, `/${realPrefixLen}`);
647
+ replaced = replaced.replace(/\/xx\b/, `/${realPrefixLen}`);
648
+ // Validate: check all numeric octets in result are 0-255
649
+ const numericOctets = replaced.match(/\b\d{1,3}\b/g);
650
+ if (numericOctets) {
651
+ for (let i = 0; i < Math.min(numericOctets.length, 4); i++) {
652
+ if (parseInt(numericOctets[i], 10) > 255) {
653
+ count--;
654
+ return match;
655
+ }
656
+ }
657
+ }
658
+ return replaced;
659
+ });
660
+ return { text: result, count };
661
+ }
516
662
  /**
517
663
  * Normalize-and-match deobfuscation for fd00::/8 ULA IPv6 addresses.
518
664
  */
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "shroud-privacy",
3
3
  "name": "Shroud",
4
- "version": "2.0.14",
4
+ "version": "2.0.18",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shroud-privacy",
3
- "version": "2.0.14",
3
+ "version": "2.0.18",
4
4
  "description": "Privacy obfuscation plugin for OpenClaw — detects sensitive data and replaces with deterministic fake values",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",