shroud-privacy 2.2.9 → 2.2.11
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 +33 -11
- package/dist/agent-session.d.ts +259 -0
- package/dist/agent-session.js +693 -0
- package/dist/dashboard.d.ts +42 -0
- package/dist/dashboard.js +1558 -0
- package/dist/detectors/context.js +7 -0
- package/dist/detectors/injection-multilingual.d.ts +27 -0
- package/dist/detectors/injection-multilingual.js +399 -0
- package/dist/detectors/injection-signatures.d.ts +26 -0
- package/dist/detectors/injection-signatures.js +508 -0
- package/dist/detectors/injection.d.ts +56 -0
- package/dist/detectors/injection.js +269 -0
- package/dist/detectors/regex.js +2 -2
- package/dist/detectors/tool-guard.d.ts +27 -0
- package/dist/detectors/tool-guard.js +418 -0
- package/dist/event-grader.d.ts +97 -0
- package/dist/event-grader.js +214 -0
- package/dist/policy.d.ts +93 -43
- package/dist/policy.js +193 -86
- package/dist/profiler-analysis.d.ts +35 -0
- package/dist/profiler-analysis.js +230 -0
- package/dist/profiler-store.d.ts +33 -0
- package/dist/profiler-store.js +118 -0
- package/dist/profiler-types.d.ts +128 -0
- package/dist/profiler-types.js +16 -0
- package/dist/profiler.d.ts +81 -0
- package/dist/profiler.js +392 -0
- package/dist/security-event.d.ts +70 -0
- package/dist/security-event.js +80 -0
- package/dist/siem.d.ts +38 -24
- package/dist/siem.js +90 -68
- package/dist/signature-loader.d.ts +113 -0
- package/dist/signature-loader.js +255 -0
- package/openclaw.plugin.json +155 -30
- package/package.json +4 -4
package/dist/siem.js
CHANGED
|
@@ -1,91 +1,113 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SIEM
|
|
3
|
-
*
|
|
2
|
+
* SIEM integration — ships security events to external systems.
|
|
3
|
+
*
|
|
4
|
+
* Two output modes:
|
|
5
|
+
* 1. Webhook POST: sends events as JSON to a configured URL (Splunk HEC, Grafana Loki, custom)
|
|
6
|
+
* 2. JSONL file: appends events as newline-delimited JSON to a local file
|
|
7
|
+
*
|
|
8
|
+
* Both are async fire-and-forget — never blocks the request pipeline.
|
|
9
|
+
* Zero runtime dependencies.
|
|
4
10
|
*/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { request as httpRequest } from "node:http";
|
|
14
|
+
import { request as httpsRequest } from "node:https";
|
|
15
|
+
/**
|
|
16
|
+
* SIEM event shipper. Subscribes to SecurityEventBus and ships events
|
|
17
|
+
* to configured destinations.
|
|
18
|
+
*/
|
|
19
|
+
export class SiemShipper {
|
|
13
20
|
_config;
|
|
14
21
|
_buffer = [];
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
_flushTimer = null;
|
|
23
|
+
_stats = { shipped: 0, webhookErrors: 0, fileErrors: 0 };
|
|
24
|
+
constructor(config) {
|
|
25
|
+
this._config = config;
|
|
26
|
+
// Start flush timer if batching
|
|
27
|
+
if (config.batchSize > 1 && config.flushIntervalMs > 0) {
|
|
28
|
+
this._flushTimer = setInterval(() => this.flush(), config.flushIntervalMs);
|
|
29
|
+
}
|
|
30
|
+
// Ensure JSONL directory exists
|
|
31
|
+
if (config.jsonlPath) {
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(dirname(config.jsonlPath), { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
25
36
|
}
|
|
26
37
|
}
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
});
|
|
38
|
+
/** Called for each security event. Buffers and flushes. */
|
|
39
|
+
onEvent(event) {
|
|
40
|
+
this._buffer.push(event);
|
|
39
41
|
if (this._buffer.length >= this._config.batchSize) {
|
|
40
42
|
this.flush();
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
|
-
/** Flush buffered events to
|
|
45
|
+
/** Flush buffered events to all configured destinations. */
|
|
44
46
|
flush() {
|
|
45
|
-
if (this._buffer.length === 0
|
|
47
|
+
if (this._buffer.length === 0)
|
|
46
48
|
return;
|
|
47
49
|
const events = this._buffer.splice(0);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
// JSONL file output
|
|
51
|
+
if (this._config.jsonlPath) {
|
|
52
|
+
try {
|
|
53
|
+
const lines = events.map(e => JSON.stringify(e)).join("\n") + "\n";
|
|
54
|
+
appendFileSync(this._config.jsonlPath, lines);
|
|
55
|
+
this._stats.shipped += events.length;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
this._stats.fileErrors++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Webhook POST
|
|
62
|
+
if (this._config.webhookUrl) {
|
|
63
|
+
this._postWebhook(events);
|
|
64
|
+
}
|
|
55
65
|
}
|
|
56
|
-
/** Stop the
|
|
66
|
+
/** Stop the flush timer and flush remaining events. */
|
|
57
67
|
stop() {
|
|
58
|
-
if (this.
|
|
59
|
-
clearInterval(this.
|
|
60
|
-
this.
|
|
68
|
+
if (this._flushTimer) {
|
|
69
|
+
clearInterval(this._flushTimer);
|
|
70
|
+
this._flushTimer = null;
|
|
61
71
|
}
|
|
62
72
|
this.flush();
|
|
63
73
|
}
|
|
74
|
+
/** Get shipping stats. */
|
|
64
75
|
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
|
-
};
|
|
76
|
+
return { ...this._stats, buffered: this._buffer.length };
|
|
72
77
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
78
|
+
_postWebhook(events) {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(this._config.webhookUrl);
|
|
81
|
+
const isHttps = url.protocol === "https:";
|
|
82
|
+
const reqFn = isHttps ? httpsRequest : httpRequest;
|
|
83
|
+
const body = JSON.stringify({ events, count: events.length, timestamp: new Date().toISOString() });
|
|
84
|
+
const options = {
|
|
85
|
+
hostname: url.hostname,
|
|
86
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
87
|
+
path: url.pathname + url.search,
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
"Content-Length": Buffer.byteLength(body),
|
|
92
|
+
...(this._config.webhookAuth ? { Authorization: this._config.webhookAuth } : {}),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const req = reqFn(options, (res) => {
|
|
96
|
+
// Drain response
|
|
97
|
+
res.resume();
|
|
98
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
99
|
+
this._stats.shipped += events.length;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
this._stats.webhookErrors++;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
req.on("error", () => { this._stats.webhookErrors++; });
|
|
106
|
+
req.write(body);
|
|
107
|
+
req.end();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
this._stats.webhookErrors++;
|
|
88
111
|
}
|
|
89
|
-
this._sendCount += 1;
|
|
90
112
|
}
|
|
91
113
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot-refresh signature loader.
|
|
3
|
+
*
|
|
4
|
+
* Fetches external signature definitions from a URL or local file,
|
|
5
|
+
* merges them with built-in signatures at runtime. Supports periodic
|
|
6
|
+
* polling with atomic swap — no restart needed.
|
|
7
|
+
*
|
|
8
|
+
* Zero runtime dependencies — uses Node.js built-in http/https + fs.
|
|
9
|
+
*
|
|
10
|
+
* Signature JSON schema:
|
|
11
|
+
* {
|
|
12
|
+
* "version": "1.0",
|
|
13
|
+
* "updated": "2026-03-30T12:00:00Z",
|
|
14
|
+
* "injectionSignatures": [
|
|
15
|
+
* { "id": "custom_sig_1", "threatClass": "instruction_override",
|
|
16
|
+
* "pattern": "ignore all safety", "flags": "gi",
|
|
17
|
+
* "severity": "high", "description": "...", "direction": "request" }
|
|
18
|
+
* ],
|
|
19
|
+
* "toolGuardPatterns": [
|
|
20
|
+
* { "id": "custom_tg_1", "paramPattern": "kubectl delete ns",
|
|
21
|
+
* "flags": "gi", "severity": "high", "description": "...",
|
|
22
|
+
* "block": true, "toolName": null }
|
|
23
|
+
* ]
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
/** External signature definition (JSON-friendly — pattern is a string, not RegExp). */
|
|
27
|
+
export interface ExternalSignature {
|
|
28
|
+
id: string;
|
|
29
|
+
threatClass: string;
|
|
30
|
+
pattern: string;
|
|
31
|
+
flags?: string;
|
|
32
|
+
severity: "low" | "medium" | "high";
|
|
33
|
+
description: string;
|
|
34
|
+
direction: "request" | "response" | "both";
|
|
35
|
+
}
|
|
36
|
+
/** External tool guard pattern (JSON-friendly). */
|
|
37
|
+
export interface ExternalToolGuard {
|
|
38
|
+
id: string;
|
|
39
|
+
toolName: string | null;
|
|
40
|
+
paramPattern: string;
|
|
41
|
+
flags?: string;
|
|
42
|
+
severity: "low" | "medium" | "high";
|
|
43
|
+
description: string;
|
|
44
|
+
block: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** The full signature feed payload. */
|
|
47
|
+
export interface SignatureFeed {
|
|
48
|
+
version: string;
|
|
49
|
+
updated: string;
|
|
50
|
+
injectionSignatures?: ExternalSignature[];
|
|
51
|
+
toolGuardPatterns?: ExternalToolGuard[];
|
|
52
|
+
}
|
|
53
|
+
/** Compiled signatures ready for the scanner. */
|
|
54
|
+
export interface CompiledSignatures {
|
|
55
|
+
injection: Array<{
|
|
56
|
+
id: string;
|
|
57
|
+
threatClass: string;
|
|
58
|
+
pattern: RegExp;
|
|
59
|
+
severity: "low" | "medium" | "high";
|
|
60
|
+
description: string;
|
|
61
|
+
direction: "request" | "response" | "both";
|
|
62
|
+
}>;
|
|
63
|
+
toolGuard: Array<{
|
|
64
|
+
id: string;
|
|
65
|
+
toolName: string | null;
|
|
66
|
+
paramPattern: RegExp;
|
|
67
|
+
severity: "low" | "medium" | "high";
|
|
68
|
+
description: string;
|
|
69
|
+
block: boolean;
|
|
70
|
+
}>;
|
|
71
|
+
feedVersion: string;
|
|
72
|
+
feedUpdated: string;
|
|
73
|
+
loadedAt: number;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Signature loader with hot-refresh support.
|
|
77
|
+
*/
|
|
78
|
+
export declare class SignatureLoader {
|
|
79
|
+
private _url;
|
|
80
|
+
private _filePath;
|
|
81
|
+
private _refreshMs;
|
|
82
|
+
private _timer;
|
|
83
|
+
private _current;
|
|
84
|
+
private _onUpdate;
|
|
85
|
+
private _cacheDir;
|
|
86
|
+
constructor(opts: {
|
|
87
|
+
url?: string | null;
|
|
88
|
+
filePath?: string | null;
|
|
89
|
+
refreshSec?: number;
|
|
90
|
+
cacheDir?: string;
|
|
91
|
+
});
|
|
92
|
+
/** Register a callback for when signatures are refreshed. */
|
|
93
|
+
onUpdate(cb: (sigs: CompiledSignatures) => void): void;
|
|
94
|
+
/** Get the currently loaded external signatures (null if none loaded). */
|
|
95
|
+
getCurrent(): CompiledSignatures | null;
|
|
96
|
+
/** Start polling. Does an immediate load, then polls on interval. */
|
|
97
|
+
start(): Promise<void>;
|
|
98
|
+
/** Stop polling. */
|
|
99
|
+
stop(): void;
|
|
100
|
+
/** Force a manual refresh. */
|
|
101
|
+
refresh(): Promise<CompiledSignatures | null>;
|
|
102
|
+
private _refresh;
|
|
103
|
+
/** Compile JSON signature definitions into RegExp objects with safety validation. */
|
|
104
|
+
private _compile;
|
|
105
|
+
/** Validate an injection signature before compilation. */
|
|
106
|
+
private _validateSig;
|
|
107
|
+
/** Validate a tool guard pattern before compilation. */
|
|
108
|
+
private _validateToolGuard;
|
|
109
|
+
/** Sanitize regex flags — only allow safe flags. */
|
|
110
|
+
private _sanitizeFlags;
|
|
111
|
+
/** Fetch a URL using Node.js built-in http/https. Zero dependencies. */
|
|
112
|
+
private _fetchUrl;
|
|
113
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot-refresh signature loader.
|
|
3
|
+
*
|
|
4
|
+
* Fetches external signature definitions from a URL or local file,
|
|
5
|
+
* merges them with built-in signatures at runtime. Supports periodic
|
|
6
|
+
* polling with atomic swap — no restart needed.
|
|
7
|
+
*
|
|
8
|
+
* Zero runtime dependencies — uses Node.js built-in http/https + fs.
|
|
9
|
+
*
|
|
10
|
+
* Signature JSON schema:
|
|
11
|
+
* {
|
|
12
|
+
* "version": "1.0",
|
|
13
|
+
* "updated": "2026-03-30T12:00:00Z",
|
|
14
|
+
* "injectionSignatures": [
|
|
15
|
+
* { "id": "custom_sig_1", "threatClass": "instruction_override",
|
|
16
|
+
* "pattern": "ignore all safety", "flags": "gi",
|
|
17
|
+
* "severity": "high", "description": "...", "direction": "request" }
|
|
18
|
+
* ],
|
|
19
|
+
* "toolGuardPatterns": [
|
|
20
|
+
* { "id": "custom_tg_1", "paramPattern": "kubectl delete ns",
|
|
21
|
+
* "flags": "gi", "severity": "high", "description": "...",
|
|
22
|
+
* "block": true, "toolName": null }
|
|
23
|
+
* ]
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import * as nodeHttps from "node:https";
|
|
29
|
+
import * as nodeHttp from "node:http";
|
|
30
|
+
/**
|
|
31
|
+
* Signature loader with hot-refresh support.
|
|
32
|
+
*/
|
|
33
|
+
export class SignatureLoader {
|
|
34
|
+
_url;
|
|
35
|
+
_filePath;
|
|
36
|
+
_refreshMs;
|
|
37
|
+
_timer = null;
|
|
38
|
+
_current = null;
|
|
39
|
+
_onUpdate = null;
|
|
40
|
+
_cacheDir;
|
|
41
|
+
constructor(opts) {
|
|
42
|
+
this._url = opts.url || null;
|
|
43
|
+
this._filePath = opts.filePath || null;
|
|
44
|
+
this._refreshMs = (opts.refreshSec || 3600) * 1000;
|
|
45
|
+
this._cacheDir = opts.cacheDir || "/tmp/shroud-signatures";
|
|
46
|
+
}
|
|
47
|
+
/** Register a callback for when signatures are refreshed. */
|
|
48
|
+
onUpdate(cb) {
|
|
49
|
+
this._onUpdate = cb;
|
|
50
|
+
}
|
|
51
|
+
/** Get the currently loaded external signatures (null if none loaded). */
|
|
52
|
+
getCurrent() {
|
|
53
|
+
return this._current;
|
|
54
|
+
}
|
|
55
|
+
/** Start polling. Does an immediate load, then polls on interval. */
|
|
56
|
+
async start() {
|
|
57
|
+
if (!this._url && !this._filePath)
|
|
58
|
+
return;
|
|
59
|
+
// Immediate load
|
|
60
|
+
await this._refresh();
|
|
61
|
+
// Periodic poll
|
|
62
|
+
if (this._refreshMs > 0) {
|
|
63
|
+
this._timer = setInterval(() => this._refresh(), this._refreshMs);
|
|
64
|
+
// Don't keep the process alive for the timer
|
|
65
|
+
if (this._timer.unref)
|
|
66
|
+
this._timer.unref();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Stop polling. */
|
|
70
|
+
stop() {
|
|
71
|
+
if (this._timer) {
|
|
72
|
+
clearInterval(this._timer);
|
|
73
|
+
this._timer = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Force a manual refresh. */
|
|
77
|
+
async refresh() {
|
|
78
|
+
await this._refresh();
|
|
79
|
+
return this._current;
|
|
80
|
+
}
|
|
81
|
+
async _refresh() {
|
|
82
|
+
try {
|
|
83
|
+
let json = null;
|
|
84
|
+
// Try URL first
|
|
85
|
+
if (this._url) {
|
|
86
|
+
json = await this._fetchUrl(this._url);
|
|
87
|
+
// Cache to local file for offline resilience
|
|
88
|
+
if (json) {
|
|
89
|
+
try {
|
|
90
|
+
mkdirSync(this._cacheDir, { recursive: true });
|
|
91
|
+
writeFileSync(join(this._cacheDir, "signatures-cache.json"), json, "utf-8");
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Fall back to local file
|
|
97
|
+
if (!json && this._filePath) {
|
|
98
|
+
try {
|
|
99
|
+
json = readFileSync(this._filePath, "utf-8");
|
|
100
|
+
}
|
|
101
|
+
catch { }
|
|
102
|
+
}
|
|
103
|
+
// Fall back to cached copy
|
|
104
|
+
if (!json) {
|
|
105
|
+
const cachePath = join(this._cacheDir, "signatures-cache.json");
|
|
106
|
+
if (existsSync(cachePath)) {
|
|
107
|
+
try {
|
|
108
|
+
json = readFileSync(cachePath, "utf-8");
|
|
109
|
+
}
|
|
110
|
+
catch { }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!json)
|
|
114
|
+
return;
|
|
115
|
+
// Max feed size: 500KB — reject bloated/malicious payloads
|
|
116
|
+
if (json.length > 512_000)
|
|
117
|
+
return;
|
|
118
|
+
const feed = JSON.parse(json);
|
|
119
|
+
// Max 500 signatures per feed — prevent resource exhaustion
|
|
120
|
+
if ((feed.injectionSignatures?.length || 0) > 500)
|
|
121
|
+
return;
|
|
122
|
+
if ((feed.toolGuardPatterns?.length || 0) > 500)
|
|
123
|
+
return;
|
|
124
|
+
const compiled = this._compile(feed);
|
|
125
|
+
// Only update if version changed or first load
|
|
126
|
+
if (!this._current || compiled.feedVersion !== this._current.feedVersion ||
|
|
127
|
+
compiled.feedUpdated !== this._current.feedUpdated) {
|
|
128
|
+
this._current = compiled;
|
|
129
|
+
if (this._onUpdate)
|
|
130
|
+
this._onUpdate(compiled);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Non-fatal — keep using existing signatures
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Compile JSON signature definitions into RegExp objects with safety validation. */
|
|
138
|
+
_compile(feed) {
|
|
139
|
+
const injection = (feed.injectionSignatures || [])
|
|
140
|
+
.filter(sig => this._validateSig(sig))
|
|
141
|
+
.map(sig => ({
|
|
142
|
+
id: sig.id,
|
|
143
|
+
threatClass: sig.threatClass,
|
|
144
|
+
pattern: new RegExp(sig.pattern, this._sanitizeFlags(sig.flags)),
|
|
145
|
+
severity: sig.severity,
|
|
146
|
+
description: sig.description,
|
|
147
|
+
direction: sig.direction,
|
|
148
|
+
}));
|
|
149
|
+
const toolGuard = (feed.toolGuardPatterns || [])
|
|
150
|
+
.filter(tg => this._validateToolGuard(tg))
|
|
151
|
+
.map(tg => ({
|
|
152
|
+
id: tg.id,
|
|
153
|
+
toolName: tg.toolName,
|
|
154
|
+
paramPattern: new RegExp(tg.paramPattern, this._sanitizeFlags(tg.flags)),
|
|
155
|
+
severity: tg.severity,
|
|
156
|
+
description: tg.description,
|
|
157
|
+
block: tg.block,
|
|
158
|
+
}));
|
|
159
|
+
return {
|
|
160
|
+
injection,
|
|
161
|
+
toolGuard,
|
|
162
|
+
feedVersion: feed.version || "unknown",
|
|
163
|
+
feedUpdated: feed.updated || new Date().toISOString(),
|
|
164
|
+
loadedAt: Date.now(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Validate an injection signature before compilation. */
|
|
168
|
+
_validateSig(sig) {
|
|
169
|
+
// Must have required fields
|
|
170
|
+
if (!sig.id || !sig.pattern || !sig.severity || !sig.direction)
|
|
171
|
+
return false;
|
|
172
|
+
// ID must be prefixed with ext_ (external signatures can't impersonate built-ins)
|
|
173
|
+
if (!sig.id.startsWith("ext_"))
|
|
174
|
+
return false;
|
|
175
|
+
// Severity must be valid
|
|
176
|
+
if (!["low", "medium", "high"].includes(sig.severity))
|
|
177
|
+
return false;
|
|
178
|
+
// Direction must be valid
|
|
179
|
+
if (!["request", "response", "both"].includes(sig.direction))
|
|
180
|
+
return false;
|
|
181
|
+
// Pattern length cap — prevents ReDoS via catastrophic backtracking
|
|
182
|
+
if (sig.pattern.length > 500)
|
|
183
|
+
return false;
|
|
184
|
+
// Block nested quantifiers (ReDoS: (a+)+ or (a*)*b)
|
|
185
|
+
if (/\([^)]*[+*][^)]*\)[+*]/.test(sig.pattern))
|
|
186
|
+
return false;
|
|
187
|
+
// Test compile — reject if regex is invalid
|
|
188
|
+
try {
|
|
189
|
+
new RegExp(sig.pattern);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
// Description length cap
|
|
195
|
+
if ((sig.description || "").length > 300)
|
|
196
|
+
return false;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
/** Validate a tool guard pattern before compilation. */
|
|
200
|
+
_validateToolGuard(tg) {
|
|
201
|
+
if (!tg.id || !tg.paramPattern || !tg.severity)
|
|
202
|
+
return false;
|
|
203
|
+
if (!tg.id.startsWith("ext_"))
|
|
204
|
+
return false;
|
|
205
|
+
if (!["low", "medium", "high"].includes(tg.severity))
|
|
206
|
+
return false;
|
|
207
|
+
if (tg.paramPattern.length > 500)
|
|
208
|
+
return false;
|
|
209
|
+
if (/\([^)]*[+*][^)]*\)[+*]/.test(tg.paramPattern))
|
|
210
|
+
return false;
|
|
211
|
+
try {
|
|
212
|
+
new RegExp(tg.paramPattern);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
if ((tg.description || "").length > 300)
|
|
218
|
+
return false;
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
/** Sanitize regex flags — only allow safe flags. */
|
|
222
|
+
_sanitizeFlags(flags) {
|
|
223
|
+
if (!flags)
|
|
224
|
+
return "gi";
|
|
225
|
+
return flags.replace(/[^gimsuy]/g, "") || "gi";
|
|
226
|
+
}
|
|
227
|
+
/** Fetch a URL using Node.js built-in http/https. Zero dependencies. */
|
|
228
|
+
_fetchUrl(url) {
|
|
229
|
+
return new Promise((resolve) => {
|
|
230
|
+
try {
|
|
231
|
+
const mod = url.startsWith("https") ? nodeHttps : nodeHttp;
|
|
232
|
+
const req = mod.get(url, { timeout: 10_000 }, (res) => {
|
|
233
|
+
// Follow redirects (1 level)
|
|
234
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
235
|
+
this._fetchUrl(res.headers.location).then(resolve);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (res.statusCode !== 200) {
|
|
239
|
+
resolve(null);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const chunks = [];
|
|
243
|
+
res.on("data", (c) => chunks.push(c));
|
|
244
|
+
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
245
|
+
res.on("error", () => resolve(null));
|
|
246
|
+
});
|
|
247
|
+
req.on("error", () => resolve(null));
|
|
248
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
resolve(null);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|