@goplus/agentguard 1.1.28-beta.0 → 1.1.28-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action/detectors/exec.d.ts.map +1 -1
- package/dist/action/detectors/exec.js +287 -8
- package/dist/action/detectors/exec.js.map +1 -1
- package/dist/action/detectors/network.d.ts.map +1 -1
- package/dist/action/detectors/network.js +50 -6
- package/dist/action/detectors/network.js.map +1 -1
- package/dist/adapters/openclaw-plugin.d.ts.map +1 -1
- package/dist/adapters/openclaw-plugin.js +16 -0
- package/dist/adapters/openclaw-plugin.js.map +1 -1
- package/dist/cli.js +41 -12
- package/dist/cli.js.map +1 -1
- package/dist/runtime/evaluator.d.ts +5 -1
- package/dist/runtime/evaluator.d.ts.map +1 -1
- package/dist/runtime/evaluator.js +530 -42
- package/dist/runtime/evaluator.js.map +1 -1
- package/dist/runtime/policy.d.ts.map +1 -1
- package/dist/runtime/policy.js +1 -0
- package/dist/runtime/policy.js.map +1 -1
- package/dist/runtime/protect.d.ts +2 -0
- package/dist/runtime/protect.d.ts.map +1 -1
- package/dist/runtime/protect.js +33 -10
- package/dist/runtime/protect.js.map +1 -1
- package/dist/runtime/types.d.ts +3 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/tests/action.test.js +115 -9
- package/dist/tests/action.test.js.map +1 -1
- package/dist/tests/cli-connect.test.js +77 -3
- package/dist/tests/cli-connect.test.js.map +1 -1
- package/dist/tests/cli-policy.test.js +34 -4
- package/dist/tests/cli-policy.test.js.map +1 -1
- package/dist/tests/cli-subscribe.test.js +80 -3
- package/dist/tests/cli-subscribe.test.js.map +1 -1
- package/dist/tests/feed-cron.test.js +1 -1
- package/dist/tests/feed-cron.test.js.map +1 -1
- package/dist/tests/integration.test.js +48 -0
- package/dist/tests/integration.test.js.map +1 -1
- package/dist/tests/runtime-cloud.test.js +388 -1
- package/dist/tests/runtime-cloud.test.js.map +1 -1
- package/dist/tests/smoke.test.js +1 -3
- package/dist/tests/smoke.test.js.map +1 -1
- package/dist/types/action.d.ts +1 -1
- package/dist/types/action.d.ts.map +1 -1
- package/dist/utils/system-paths.d.ts +14 -0
- package/dist/utils/system-paths.d.ts.map +1 -0
- package/dist/utils/system-paths.js +172 -0
- package/dist/utils/system-paths.js.map +1 -0
- package/package.json +1 -1
- package/skills/agentguard/scripts/hermes-hook.js +12 -1
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.__resetNetworkBehaviorForTests = __resetNetworkBehaviorForTests;
|
|
3
4
|
exports.evaluateLocalAction = evaluateLocalAction;
|
|
4
5
|
const index_js_1 = require("../action/index.js");
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
5
8
|
const node_os_1 = require("node:os");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
6
10
|
const skill_js_1 = require("../types/skill.js");
|
|
7
11
|
const patterns_js_1 = require("../utils/patterns.js");
|
|
12
|
+
const system_paths_js_1 = require("../utils/system-paths.js");
|
|
13
|
+
const config_js_1 = require("../config.js");
|
|
8
14
|
const redaction_js_1 = require("./redaction.js");
|
|
15
|
+
const ONE_MINUTE_MS = 60_000;
|
|
16
|
+
const TEN_MINUTES_MS = 10 * ONE_MINUTE_MS;
|
|
17
|
+
const HIGH_FREQUENCY_THRESHOLD = 100;
|
|
18
|
+
const ODD_HOUR_FREQUENCY_THRESHOLD = 20;
|
|
19
|
+
const TOKEN_DOMAIN_THRESHOLD = 10;
|
|
20
|
+
const REPLAY_THRESHOLD = 5;
|
|
21
|
+
const DOS_STATUS_THRESHOLD = 5;
|
|
22
|
+
const SINGLE_RESPONSE_BYTES_THRESHOLD = 10 * 1024 * 1024;
|
|
23
|
+
const WINDOW_RESPONSE_BYTES_THRESHOLD = 100 * 1024 * 1024;
|
|
24
|
+
const MAX_PERSISTED_BEHAVIOR_EVENTS = 1_000;
|
|
25
|
+
const networkBehaviorEvents = [];
|
|
26
|
+
let networkBehaviorStateLoaded = false;
|
|
27
|
+
function __resetNetworkBehaviorForTests() {
|
|
28
|
+
networkBehaviorEvents.length = 0;
|
|
29
|
+
networkBehaviorStateLoaded = true;
|
|
30
|
+
const statePath = process.env.AGENTGUARD_BEHAVIOR_STATE_PATH;
|
|
31
|
+
if (statePath) {
|
|
32
|
+
try {
|
|
33
|
+
(0, node_fs_1.rmSync)(statePath, { force: true });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Test cleanup only.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
9
40
|
function reason(code, severity, title, description, evidence) {
|
|
10
41
|
return {
|
|
11
42
|
code,
|
|
@@ -15,7 +46,7 @@ function reason(code, severity, title, description, evidence) {
|
|
|
15
46
|
evidence: evidence === undefined ? undefined : (0, redaction_js_1.redactPreview)(evidence, 240),
|
|
16
47
|
};
|
|
17
48
|
}
|
|
18
|
-
async function evaluateLocalAction(policy, action) {
|
|
49
|
+
async function evaluateLocalAction(policy, action, options = {}) {
|
|
19
50
|
if (isAllowedByCommandPolicy(policy, action)) {
|
|
20
51
|
return {
|
|
21
52
|
actionId: `act_local_${Date.now()}_${process.pid}`,
|
|
@@ -27,7 +58,7 @@ async function evaluateLocalAction(policy, action) {
|
|
|
27
58
|
};
|
|
28
59
|
}
|
|
29
60
|
const customReasons = customPolicyReasons(policy, action);
|
|
30
|
-
const ossDecision = await evaluateWithOssActionScanner(policy, action);
|
|
61
|
+
const ossDecision = await evaluateWithOssActionScanner(policy, action, options);
|
|
31
62
|
const ossReasons = (ossDecision?.risk_tags || []).map((tag, index) => normalizeOssReason(tag, ossDecision?.evidence?.[index], action));
|
|
32
63
|
const reasons = (0, redaction_js_1.redactReasons)([...customReasons, ...ossReasons]);
|
|
33
64
|
const riskScore = riskScoreFor(reasons, ossDecision?.risk_level || 'safe');
|
|
@@ -65,7 +96,7 @@ function customPolicyReasons(policy, action) {
|
|
|
65
96
|
}
|
|
66
97
|
}
|
|
67
98
|
for (const domain of policy.network.blockedDomains) {
|
|
68
|
-
if (domain &&
|
|
99
|
+
if (domain && matchesNetworkReference(input, domain)) {
|
|
69
100
|
reasons.push(reason('CUSTOM_BLOCKED_DOMAIN', 'high', 'Custom blocked domain', 'The action references a domain blocked by runtime policy.', domain));
|
|
70
101
|
}
|
|
71
102
|
}
|
|
@@ -80,7 +111,15 @@ function customPolicyReasons(policy, action) {
|
|
|
80
111
|
reasons.push(reason('NETWORK_OUTBOUND', 'medium', 'Network outbound policy', 'The action makes an outbound network request covered by runtime policy.', input));
|
|
81
112
|
}
|
|
82
113
|
}
|
|
114
|
+
const networkTargets = networkTargetsFromAction(action);
|
|
115
|
+
if (networkTargets.length > 0) {
|
|
116
|
+
reasons.push(...networkBehaviorReasons(action, networkTargets));
|
|
117
|
+
reasons.push(...networkResponseReasons(action, networkTargets[0]));
|
|
118
|
+
}
|
|
83
119
|
if (action.actionType === 'file_read' || action.actionType === 'file_write') {
|
|
120
|
+
const systemPathReason = systemPathReasonForFileAction(action);
|
|
121
|
+
if (systemPathReason)
|
|
122
|
+
reasons.push(systemPathReason);
|
|
84
123
|
for (const pathPattern of policy.protectedPaths) {
|
|
85
124
|
if (matchesPath(input, pathPattern)) {
|
|
86
125
|
reasons.push(reason('SECRET_ACCESS', action.actionType === 'file_write' ? 'critical' : 'high', 'Protected path access', 'The agent attempted to access a path protected by runtime policy.', pathPattern));
|
|
@@ -92,25 +131,36 @@ function customPolicyReasons(policy, action) {
|
|
|
92
131
|
}
|
|
93
132
|
return reasons;
|
|
94
133
|
}
|
|
95
|
-
|
|
134
|
+
function systemPathReasonForFileAction(action) {
|
|
135
|
+
const operation = action.actionType === 'file_read' ? 'read' : 'write';
|
|
136
|
+
const classification = (0, system_paths_js_1.classifySystemPathOperation)(action.input, operation);
|
|
137
|
+
if (!classification)
|
|
138
|
+
return null;
|
|
139
|
+
return reason(classification.decision === 'block' ? 'SYSTEM_PATH_MUTATION' : 'SYSTEM_PATH_ACCESS', classification.severity, classification.decision === 'block' ? 'System path mutation blocked' : 'System path access requires approval', `${operation} operation targets ${classification.description}.`, classification.path);
|
|
140
|
+
}
|
|
141
|
+
async function evaluateWithOssActionScanner(policy, action, options) {
|
|
96
142
|
const mapped = mapRuntimeAction(action);
|
|
97
143
|
if (!mapped)
|
|
98
144
|
return null;
|
|
145
|
+
const runtimeCapabilities = {
|
|
146
|
+
...skill_js_1.DEFAULT_CAPABILITY,
|
|
147
|
+
exec: 'allow',
|
|
148
|
+
network_allowlist: policy.network.approvalDomains,
|
|
149
|
+
filesystem_allowlist: runtimeFilesystemAllowlist(policy, options),
|
|
150
|
+
};
|
|
99
151
|
const registry = {
|
|
100
152
|
async lookup() {
|
|
101
153
|
return {
|
|
102
154
|
record: null,
|
|
103
155
|
effective_trust_level: 'trusted',
|
|
104
|
-
effective_capabilities:
|
|
105
|
-
...skill_js_1.DEFAULT_CAPABILITY,
|
|
106
|
-
exec: 'allow',
|
|
107
|
-
network_allowlist: policy.network.approvalDomains,
|
|
108
|
-
filesystem_allowlist: policy.protectedPaths,
|
|
109
|
-
},
|
|
156
|
+
effective_capabilities: runtimeCapabilities,
|
|
110
157
|
};
|
|
111
158
|
},
|
|
112
159
|
};
|
|
113
|
-
const scanner = new index_js_1.ActionScanner({
|
|
160
|
+
const scanner = new index_js_1.ActionScanner({
|
|
161
|
+
registry: registry,
|
|
162
|
+
defaultCapabilities: runtimeCapabilities,
|
|
163
|
+
});
|
|
114
164
|
return scanner.decide({
|
|
115
165
|
actor: {
|
|
116
166
|
skill: {
|
|
@@ -130,6 +180,9 @@ async function evaluateWithOssActionScanner(policy, action) {
|
|
|
130
180
|
},
|
|
131
181
|
});
|
|
132
182
|
}
|
|
183
|
+
function runtimeFilesystemAllowlist(policy, options) {
|
|
184
|
+
return options.filesystemAllowlist ?? policy.filesystemAllowlist ?? ['*'];
|
|
185
|
+
}
|
|
133
186
|
function mapRuntimeAction(action) {
|
|
134
187
|
if (action.actionType === 'shell') {
|
|
135
188
|
return { type: 'exec_command', data: { command: action.input, cwd: action.cwd } };
|
|
@@ -156,18 +209,72 @@ function mapRuntimeAction(action) {
|
|
|
156
209
|
return null;
|
|
157
210
|
}
|
|
158
211
|
function methodFromMetadata(value) {
|
|
159
|
-
|
|
160
|
-
|
|
212
|
+
const method = typeof value === 'string' ? value.toUpperCase() : '';
|
|
213
|
+
if (method === 'GET' ||
|
|
214
|
+
method === 'HEAD' ||
|
|
215
|
+
method === 'OPTIONS' ||
|
|
216
|
+
method === 'POST' ||
|
|
217
|
+
method === 'PUT' ||
|
|
218
|
+
method === 'DELETE' ||
|
|
219
|
+
method === 'PATCH') {
|
|
220
|
+
return method;
|
|
221
|
+
}
|
|
161
222
|
return 'GET';
|
|
162
223
|
}
|
|
163
224
|
function stringFromMetadata(value) {
|
|
164
225
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
165
226
|
}
|
|
227
|
+
function firstStringFromMetadata(...values) {
|
|
228
|
+
for (const value of values) {
|
|
229
|
+
if (typeof value === 'string' && value.length > 0)
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
function numberFromMetadata(...values) {
|
|
235
|
+
for (const value of values) {
|
|
236
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
237
|
+
return value;
|
|
238
|
+
if (typeof value === 'string' && value.trim()) {
|
|
239
|
+
const parsed = Number(value);
|
|
240
|
+
if (Number.isFinite(parsed))
|
|
241
|
+
return parsed;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
function timeFromMetadata(value) {
|
|
247
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
248
|
+
return value;
|
|
249
|
+
if (typeof value === 'string') {
|
|
250
|
+
const parsed = Date.parse(value);
|
|
251
|
+
if (Number.isFinite(parsed))
|
|
252
|
+
return parsed;
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
function recordFromMetadata(value) {
|
|
257
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
258
|
+
return undefined;
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
166
261
|
function normalizeOssReason(tag, evidence, action) {
|
|
167
262
|
const evidenceText = evidence?.match || evidence?.description || action.input;
|
|
168
263
|
if (tag === 'DANGEROUS_COMMAND') {
|
|
169
264
|
return reason('DESTRUCTIVE_COMMAND', 'critical', 'Dangerous command', 'The local OSS runtime detected a dangerous command.', evidenceText);
|
|
170
265
|
}
|
|
266
|
+
if (tag === 'DESTRUCTIVE_FILE_OPERATION') {
|
|
267
|
+
return reason('DESTRUCTIVE_FILE_OPERATION', 'high', 'Destructive file operation', 'The local OSS runtime detected a destructive file operation.', evidenceText);
|
|
268
|
+
}
|
|
269
|
+
if (tag === 'SYSTEM_PATH_MUTATION') {
|
|
270
|
+
return reason('SYSTEM_PATH_MUTATION', 'critical', 'System path mutation', 'The local OSS runtime detected a mutation of a protected system path.', evidenceText);
|
|
271
|
+
}
|
|
272
|
+
if (tag === 'SYSTEM_PATH_ACCESS') {
|
|
273
|
+
return reason('SYSTEM_PATH_ACCESS', 'high', 'System path access', 'The local OSS runtime detected access to a protected system path.', evidenceText);
|
|
274
|
+
}
|
|
275
|
+
if (tag === 'HIDDEN_NETWORK_COMMAND') {
|
|
276
|
+
return reason('HIDDEN_NETWORK_COMMAND', 'high', 'Hidden network command', 'The local OSS runtime detected a network command hidden inside a wrapper.', evidenceText);
|
|
277
|
+
}
|
|
171
278
|
if (tag === 'SENSITIVE_DATA_ACCESS' || tag === 'SENSITIVE_ENV_VAR') {
|
|
172
279
|
return reason('SECRET_ACCESS', 'high', 'Sensitive data access', 'The local OSS runtime detected access to sensitive data.', evidenceText);
|
|
173
280
|
}
|
|
@@ -182,44 +289,201 @@ function normalizeOssReason(tag, evidence, action) {
|
|
|
182
289
|
}
|
|
183
290
|
return reason(tag, 'medium', tag.replace(/_/g, ' ').toLowerCase(), 'The local OSS runtime detected a risky action.', evidenceText);
|
|
184
291
|
}
|
|
292
|
+
const BEHAVIOR_ANOMALY_CODES = new Set([
|
|
293
|
+
'NETWORK_RATE_LIMIT',
|
|
294
|
+
'NETWORK_TOKEN_DOMAIN_SWEEP',
|
|
295
|
+
'NETWORK_ODD_HOUR_ACTIVITY',
|
|
296
|
+
'NETWORK_REPLAY',
|
|
297
|
+
'NETWORK_LARGE_RESPONSE',
|
|
298
|
+
'NETWORK_DOS_RESPONSE',
|
|
299
|
+
]);
|
|
300
|
+
const RESPONSE_ANOMALY_CODES = new Set([
|
|
301
|
+
'RESPONSE_XSS_ECHO',
|
|
302
|
+
'RESPONSE_ERROR_DISCLOSURE',
|
|
303
|
+
'RESPONSE_MALICIOUS_SCRIPT',
|
|
304
|
+
'RESPONSE_PATH_TRAVERSAL',
|
|
305
|
+
'RESPONSE_CONTENT_TYPE_MISMATCH',
|
|
306
|
+
'RESPONSE_CREDENTIAL_ECHO',
|
|
307
|
+
]);
|
|
308
|
+
function networkTargetsFromAction(action) {
|
|
309
|
+
if (action.actionType === 'network' || action.actionType === 'browser') {
|
|
310
|
+
const target = parseNetworkTarget(action.input);
|
|
311
|
+
return target ? [target] : [];
|
|
312
|
+
}
|
|
313
|
+
if (action.actionType !== 'shell')
|
|
314
|
+
return [];
|
|
315
|
+
const targets = new Map();
|
|
316
|
+
for (const reference of extractNetworkReferences(action.input)) {
|
|
317
|
+
const target = parseNetworkTarget(reference);
|
|
318
|
+
if (target)
|
|
319
|
+
targets.set(`${target.hostname}${target.pathname}`, target);
|
|
320
|
+
}
|
|
321
|
+
return [...targets.values()];
|
|
322
|
+
}
|
|
323
|
+
function networkBehaviorReasons(action, targets) {
|
|
324
|
+
const timestamp = timeFromMetadata(action.metadata?.timestamp) ?? Date.now();
|
|
325
|
+
loadNetworkBehaviorState(timestamp);
|
|
326
|
+
pruneNetworkBehaviorEvents(timestamp);
|
|
327
|
+
const method = methodFromMetadata(action.metadata?.method);
|
|
328
|
+
const headers = recordFromMetadata(action.metadata?.headers) ?? recordFromMetadata(action.metadata?.requestHeaders);
|
|
329
|
+
const bodyPreview = stringFromMetadata(action.metadata?.bodyPreview);
|
|
330
|
+
const responseBytes = numberFromMetadata(action.metadata?.responseBodyBytes, action.metadata?.responseBytes, action.metadata?.contentLength);
|
|
331
|
+
const responseStatus = numberFromMetadata(action.metadata?.responseStatusCode, action.metadata?.statusCode, action.metadata?.status);
|
|
332
|
+
const tokenHashes = extractCredentialValues(action.input, headers, bodyPreview).map(hashValue);
|
|
333
|
+
const fingerprint = requestFingerprint(action.input, targets[0], method, headers, bodyPreview);
|
|
334
|
+
for (const target of targets) {
|
|
335
|
+
networkBehaviorEvents.push({
|
|
336
|
+
timestamp,
|
|
337
|
+
sessionId: action.sessionId,
|
|
338
|
+
hostname: target.hostname,
|
|
339
|
+
method,
|
|
340
|
+
fingerprint,
|
|
341
|
+
tokenHashes,
|
|
342
|
+
responseBytes,
|
|
343
|
+
responseStatus,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const reasons = [];
|
|
347
|
+
const sessionRecent = networkBehaviorEvents.filter((event) => event.sessionId === action.sessionId &&
|
|
348
|
+
timestamp - event.timestamp <= ONE_MINUTE_MS);
|
|
349
|
+
if (sessionRecent.length > HIGH_FREQUENCY_THRESHOLD) {
|
|
350
|
+
reasons.push(reason('NETWORK_RATE_LIMIT', 'high', 'High-frequency network activity', 'The agent made more than 100 outbound requests in one minute.', `${sessionRecent.length} requests in 60s`));
|
|
351
|
+
}
|
|
352
|
+
if (isOddHour(timestamp) && sessionRecent.length > ODD_HOUR_FREQUENCY_THRESHOLD) {
|
|
353
|
+
reasons.push(reason('NETWORK_ODD_HOUR_ACTIVITY', 'high', 'Odd-hour network burst', 'The agent made a high-frequency network burst during the 02:00-06:00 local risk window.', `${sessionRecent.length} requests in 60s`));
|
|
354
|
+
}
|
|
355
|
+
if (fingerprint) {
|
|
356
|
+
const repeats = sessionRecent.filter((event) => event.fingerprint === fingerprint).length;
|
|
357
|
+
if (repeats >= REPLAY_THRESHOLD) {
|
|
358
|
+
reasons.push(reason('NETWORK_REPLAY', 'high', 'Repeated identical request', 'The same request body and headers appeared repeatedly in a short window.', `${repeats} repeats in 60s`));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
for (const tokenHash of tokenHashes) {
|
|
362
|
+
const domains = new Set(networkBehaviorEvents
|
|
363
|
+
.filter((event) => event.sessionId === action.sessionId &&
|
|
364
|
+
timestamp - event.timestamp <= TEN_MINUTES_MS &&
|
|
365
|
+
event.tokenHashes.includes(tokenHash))
|
|
366
|
+
.map((event) => event.hostname));
|
|
367
|
+
if (domains.size > TOKEN_DOMAIN_THRESHOLD) {
|
|
368
|
+
reasons.push(reason('NETWORK_TOKEN_DOMAIN_SWEEP', 'high', 'Credential used across many domains', 'The same credential-like value was used against more than 10 distinct domains.', `${domains.size} domains in 10m`));
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const responseBytesTotal = sessionRecent.reduce((total, event) => total + (event.responseBytes ?? 0), 0);
|
|
373
|
+
if ((responseBytes !== undefined && responseBytes > SINGLE_RESPONSE_BYTES_THRESHOLD) ||
|
|
374
|
+
responseBytesTotal > WINDOW_RESPONSE_BYTES_THRESHOLD) {
|
|
375
|
+
reasons.push(reason('NETWORK_LARGE_RESPONSE', 'high', 'Large network response volume', 'The network response volume crossed the runtime anomaly threshold.', responseBytes !== undefined ? `${responseBytes} bytes` : `${responseBytesTotal} bytes in 60s`));
|
|
376
|
+
}
|
|
377
|
+
if (responseStatus === 429 || responseStatus === 503) {
|
|
378
|
+
const statusRepeats = sessionRecent.filter((event) => event.responseStatus === responseStatus).length;
|
|
379
|
+
if (statusRepeats >= DOS_STATUS_THRESHOLD) {
|
|
380
|
+
reasons.push(reason('NETWORK_DOS_RESPONSE', 'high', 'Repeated throttling or service unavailable responses', 'The agent received repeated 429/503 responses in a short window.', `${statusRepeats} responses with status ${responseStatus} in 60s`));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
saveNetworkBehaviorState();
|
|
384
|
+
return dedupeReasons(reasons);
|
|
385
|
+
}
|
|
386
|
+
function networkResponseReasons(action, target) {
|
|
387
|
+
const responseBody = firstStringFromMetadata(action.metadata?.responseBodyPreview, action.metadata?.responsePreview, action.metadata?.responseBody);
|
|
388
|
+
const responseHeaders = recordFromMetadata(action.metadata?.responseHeaders);
|
|
389
|
+
const contentType = (firstStringFromMetadata(action.metadata?.responseContentType, contentTypeFromHeaders(responseHeaders)) ?? '').toLowerCase();
|
|
390
|
+
if (!responseBody)
|
|
391
|
+
return [];
|
|
392
|
+
const reasons = [];
|
|
393
|
+
if (hasSuspiciousXssResponse(responseBody)) {
|
|
394
|
+
reasons.push(reason('RESPONSE_XSS_ECHO', 'high', 'Executable markup in response', 'The network response contains script-like markup or JavaScript URL content.', target.hostname));
|
|
395
|
+
}
|
|
396
|
+
if (/sql syntax|mysql_fetch|postgresql|ora-\d+|traceback|stack trace|exception|at .+:\d+:\d+/i.test(responseBody)) {
|
|
397
|
+
reasons.push(reason('RESPONSE_ERROR_DISCLOSURE', 'high', 'Server error disclosure in response', 'The network response contains SQL, command, or stack-trace style error output.', target.hostname));
|
|
398
|
+
}
|
|
399
|
+
if (/eval\s*\(\s*(?:atob|unescape)|fromcharcode|document\.write\s*\(/i.test(responseBody)) {
|
|
400
|
+
reasons.push(reason('RESPONSE_MALICIOUS_SCRIPT', 'critical', 'Obfuscated script in response', 'The network response contains script patterns commonly used for payload staging.', target.hostname));
|
|
401
|
+
}
|
|
402
|
+
if (/root:.*:0:0:|\/etc\/passwd|c:\\windows\\|windows\\system32/i.test(responseBody)) {
|
|
403
|
+
reasons.push(reason('RESPONSE_PATH_TRAVERSAL', 'critical', 'Path traversal content in response', 'The network response contains filesystem markers associated with traversal or local file disclosure.', target.hostname));
|
|
404
|
+
}
|
|
405
|
+
if (contentType && isBinaryOrMediaContentType(contentType) && looksLikeHtmlOrScript(responseBody)) {
|
|
406
|
+
reasons.push(reason('RESPONSE_CONTENT_TYPE_MISMATCH', 'critical', 'Response content type mismatch', 'The response body looks executable or HTML-like while the Content-Type claims binary/media content.', target.hostname));
|
|
407
|
+
}
|
|
408
|
+
const requestSecrets = extractCredentialValues(action.input, recordFromMetadata(action.metadata?.headers) ?? recordFromMetadata(action.metadata?.requestHeaders), stringFromMetadata(action.metadata?.bodyPreview));
|
|
409
|
+
if (requestSecrets.some((secret) => secret.length >= 8 && responseBody.includes(secret))) {
|
|
410
|
+
reasons.push(reason('RESPONSE_CREDENTIAL_ECHO', 'critical', 'Credential echoed in response', 'The response appears to echo a credential-like value from the request.', target.hostname));
|
|
411
|
+
}
|
|
412
|
+
return dedupeReasons(reasons);
|
|
413
|
+
}
|
|
185
414
|
function decisionFor(policy, reasons, riskLevel, ossDecision) {
|
|
415
|
+
const policyDecisions = [];
|
|
186
416
|
for (const item of reasons) {
|
|
187
|
-
|
|
188
|
-
continue;
|
|
189
|
-
const decision = policyDecisionFor(item.code, policy);
|
|
417
|
+
const decision = policyDecisionFor(item, policy);
|
|
190
418
|
if (decision)
|
|
191
|
-
|
|
419
|
+
policyDecisions.push(decision);
|
|
192
420
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return decision;
|
|
421
|
+
const strongestPolicyDecision = strongestDecision(policyDecisions);
|
|
422
|
+
const scannerDecision = scannerPolicyDecision(ossDecision, riskLevel);
|
|
423
|
+
if (strongestPolicyDecision) {
|
|
424
|
+
if (scannerDecision &&
|
|
425
|
+
DECISION_ORDER[strongestPolicyDecision] <= DECISION_ORDER.warn &&
|
|
426
|
+
DECISION_ORDER[scannerDecision] > DECISION_ORDER[strongestPolicyDecision]) {
|
|
427
|
+
return scannerDecision;
|
|
428
|
+
}
|
|
429
|
+
return strongestPolicyDecision;
|
|
203
430
|
}
|
|
431
|
+
if (scannerDecision)
|
|
432
|
+
return scannerDecision;
|
|
204
433
|
if (reasons.length > 0)
|
|
205
434
|
return 'warn';
|
|
206
435
|
return 'allow';
|
|
207
436
|
}
|
|
208
|
-
function policyDecisionFor(
|
|
437
|
+
function policyDecisionFor(reasonItem, policy) {
|
|
438
|
+
const code = reasonItem.code;
|
|
209
439
|
if (code === 'CUSTOM_BLOCKED_COMMAND' || code === 'DESTRUCTIVE_COMMAND')
|
|
210
440
|
return policy.decisions.destructiveCommand;
|
|
441
|
+
if (code === 'DESTRUCTIVE_FILE_OPERATION')
|
|
442
|
+
return 'require_approval';
|
|
443
|
+
if (code === 'SYSTEM_PATH_MUTATION')
|
|
444
|
+
return 'block';
|
|
445
|
+
if (code === 'SYSTEM_PATH_ACCESS')
|
|
446
|
+
return 'require_approval';
|
|
447
|
+
if (code === 'HIDDEN_NETWORK_COMMAND')
|
|
448
|
+
return 'require_approval';
|
|
211
449
|
if (code === 'REMOTE_CODE_EXECUTION')
|
|
212
450
|
return policy.decisions.remoteCodeExecution;
|
|
213
451
|
if (code === 'CUSTOM_BLOCKED_DOMAIN' || code === 'DATA_EXFILTRATION')
|
|
214
452
|
return policy.decisions.dataExfiltration;
|
|
215
453
|
if (code === 'NETWORK_OUTBOUND')
|
|
216
454
|
return policy.network.defaultOutbound;
|
|
455
|
+
if (BEHAVIOR_ANOMALY_CODES.has(code))
|
|
456
|
+
return policy.network.behaviorAnomaly ?? 'require_approval';
|
|
457
|
+
if (RESPONSE_ANOMALY_CODES.has(code)) {
|
|
458
|
+
return policy.network.responseAnomaly ?? (reasonItem.severity === 'critical' ? 'block' : 'require_approval');
|
|
459
|
+
}
|
|
217
460
|
if (code === 'SECRET_ACCESS')
|
|
218
461
|
return policy.decisions.secretAccess;
|
|
219
462
|
if (code === 'DEPLOYMENT_ACTION')
|
|
220
463
|
return policy.decisions.deployAction;
|
|
221
464
|
return null;
|
|
222
465
|
}
|
|
466
|
+
const DECISION_ORDER = {
|
|
467
|
+
allow: 0,
|
|
468
|
+
warn: 1,
|
|
469
|
+
require_approval: 2,
|
|
470
|
+
block: 3,
|
|
471
|
+
};
|
|
472
|
+
function strongestDecision(decisions) {
|
|
473
|
+
let strongest = null;
|
|
474
|
+
for (const decision of decisions) {
|
|
475
|
+
if (!strongest || DECISION_ORDER[decision] > DECISION_ORDER[strongest])
|
|
476
|
+
strongest = decision;
|
|
477
|
+
}
|
|
478
|
+
return strongest;
|
|
479
|
+
}
|
|
480
|
+
function scannerPolicyDecision(ossDecision, riskLevel) {
|
|
481
|
+
if (ossDecision === 'deny')
|
|
482
|
+
return riskLevel === 'critical' ? 'block' : 'require_approval';
|
|
483
|
+
if (ossDecision === 'confirm')
|
|
484
|
+
return 'require_approval';
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
223
487
|
function riskScoreFor(reasons, ossRiskLevel) {
|
|
224
488
|
if (reasons.some((item) => item.severity === 'critical') || ossRiskLevel === 'critical')
|
|
225
489
|
return 95;
|
|
@@ -248,11 +512,22 @@ function shouldAutoAllowRuntimeDecision(riskScore, riskLevel) {
|
|
|
248
512
|
function matchesPattern(input, pattern) {
|
|
249
513
|
if (!pattern)
|
|
250
514
|
return false;
|
|
515
|
+
if (isRootRmRfPattern(pattern))
|
|
516
|
+
return isRootRmRfCommand(input);
|
|
251
517
|
if (input.includes(pattern))
|
|
252
518
|
return true;
|
|
253
519
|
const compact = pattern.replace(/\s*\.\.\.\s*/g, ' ');
|
|
254
520
|
return compact !== pattern && input.includes(compact);
|
|
255
521
|
}
|
|
522
|
+
function isRootRmRfPattern(pattern) {
|
|
523
|
+
return /^rm\s+-[^\s]*r[^\s]*f[^\s]*\s+\/$/.test(pattern) ||
|
|
524
|
+
/^rm\s+-[^\s]*f[^\s]*r[^\s]*\s+\/$/.test(pattern);
|
|
525
|
+
}
|
|
526
|
+
function isRootRmRfCommand(input) {
|
|
527
|
+
const normalized = normalizeCommand(input);
|
|
528
|
+
return /^rm\s+-[^\s]*r[^\s]*f[^\s]*\s+\/\s*$/.test(normalized) ||
|
|
529
|
+
/^rm\s+-[^\s]*f[^\s]*r[^\s]*\s+\/\s*$/.test(normalized);
|
|
530
|
+
}
|
|
256
531
|
function matchesAllowedCommand(input, pattern) {
|
|
257
532
|
const trimmedInput = input.trim();
|
|
258
533
|
const trimmedPattern = pattern.trim();
|
|
@@ -274,27 +549,240 @@ function matchesAllowedCommand(input, pattern) {
|
|
|
274
549
|
(!inputHasControl && normalizedInput.startsWith(`${normalizedPattern} `));
|
|
275
550
|
}
|
|
276
551
|
function matchesNetworkTarget(input, pattern) {
|
|
277
|
-
const
|
|
278
|
-
|
|
552
|
+
const target = parseNetworkTarget(input);
|
|
553
|
+
const matcher = parseNetworkPattern(pattern);
|
|
554
|
+
if (!target || !matcher)
|
|
279
555
|
return false;
|
|
280
|
-
|
|
281
|
-
if (normalizedInput.includes(normalizedPattern))
|
|
282
|
-
return true;
|
|
283
|
-
const domain = (0, patterns_js_1.extractDomain)(input);
|
|
284
|
-
if (!domain)
|
|
556
|
+
if (!(0, patterns_js_1.domainMatchesPattern)(target.hostname, matcher.hostname))
|
|
285
557
|
return false;
|
|
286
|
-
if (!
|
|
287
|
-
return
|
|
558
|
+
if (!matcher.pathname)
|
|
559
|
+
return true;
|
|
560
|
+
return target.pathname === matcher.pathname ||
|
|
561
|
+
target.pathname.startsWith(`${matcher.pathname.replace(/\/+$/, '')}/`);
|
|
562
|
+
}
|
|
563
|
+
function matchesNetworkReference(input, pattern) {
|
|
564
|
+
if (matchesNetworkTarget(input, pattern))
|
|
565
|
+
return true;
|
|
566
|
+
return extractNetworkReferences(input).some((reference) => matchesNetworkTarget(reference, pattern));
|
|
567
|
+
}
|
|
568
|
+
function parseNetworkTarget(value) {
|
|
569
|
+
const trimmed = trimNetworkToken(value);
|
|
570
|
+
if (!trimmed)
|
|
571
|
+
return null;
|
|
572
|
+
const urlLike = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
|
573
|
+
? trimmed
|
|
574
|
+
: `https://${trimmed}`;
|
|
575
|
+
try {
|
|
576
|
+
const parsed = new URL(urlLike);
|
|
577
|
+
return {
|
|
578
|
+
hostname: parsed.hostname.toLowerCase(),
|
|
579
|
+
pathname: normalizeNetworkPath(parsed.pathname),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return null;
|
|
288
584
|
}
|
|
585
|
+
}
|
|
586
|
+
function parseNetworkPattern(value) {
|
|
587
|
+
const trimmed = trimNetworkToken(value).toLowerCase();
|
|
588
|
+
if (!trimmed)
|
|
589
|
+
return null;
|
|
590
|
+
const urlLike = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
|
591
|
+
? trimmed
|
|
592
|
+
: `https://${trimmed}`;
|
|
289
593
|
try {
|
|
290
|
-
const parsed = new URL(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
594
|
+
const parsed = new URL(urlLike);
|
|
595
|
+
return {
|
|
596
|
+
hostname: parsed.hostname.toLowerCase(),
|
|
597
|
+
pathname: parsed.pathname && parsed.pathname !== '/' ? normalizeNetworkPath(parsed.pathname) : null,
|
|
598
|
+
};
|
|
294
599
|
}
|
|
295
600
|
catch {
|
|
296
|
-
return
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function extractNetworkReferences(input) {
|
|
605
|
+
const references = new Set();
|
|
606
|
+
for (const match of input.matchAll(/https?:\/\/[^\s'"<>`]+/gi)) {
|
|
607
|
+
references.add(trimNetworkToken(match[0]));
|
|
608
|
+
}
|
|
609
|
+
for (const match of input.matchAll(/\b[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}(?:\/[^\s'"<>`]*)?/gi)) {
|
|
610
|
+
references.add(trimNetworkToken(match[0]));
|
|
611
|
+
}
|
|
612
|
+
return [...references].filter(Boolean);
|
|
613
|
+
}
|
|
614
|
+
function trimNetworkToken(value) {
|
|
615
|
+
return value.trim().replace(/[),.;\]]+$/g, '');
|
|
616
|
+
}
|
|
617
|
+
function normalizeNetworkPath(pathname) {
|
|
618
|
+
if (!pathname || pathname === '/')
|
|
619
|
+
return '/';
|
|
620
|
+
return pathname.replace(/\/+$/g, '') || '/';
|
|
621
|
+
}
|
|
622
|
+
function behaviorStatePath() {
|
|
623
|
+
return process.env.AGENTGUARD_BEHAVIOR_STATE_PATH ||
|
|
624
|
+
(0, node_path_1.join)((0, config_js_1.getAgentGuardPaths)().home, 'network-behavior.json');
|
|
625
|
+
}
|
|
626
|
+
function loadNetworkBehaviorState(now) {
|
|
627
|
+
if (networkBehaviorStateLoaded)
|
|
628
|
+
return;
|
|
629
|
+
networkBehaviorStateLoaded = true;
|
|
630
|
+
const statePath = behaviorStatePath();
|
|
631
|
+
try {
|
|
632
|
+
if (!(0, node_fs_1.existsSync)(statePath))
|
|
633
|
+
return;
|
|
634
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(statePath, 'utf8'));
|
|
635
|
+
if (!Array.isArray(parsed))
|
|
636
|
+
return;
|
|
637
|
+
const events = [];
|
|
638
|
+
for (const item of parsed) {
|
|
639
|
+
const event = parseNetworkBehaviorEvent(item);
|
|
640
|
+
if (event && now - event.timestamp <= TEN_MINUTES_MS)
|
|
641
|
+
events.push(event);
|
|
642
|
+
}
|
|
643
|
+
networkBehaviorEvents.push(...events);
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
networkBehaviorEvents.length = 0;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
function saveNetworkBehaviorState() {
|
|
650
|
+
const statePath = behaviorStatePath();
|
|
651
|
+
try {
|
|
652
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(statePath), { recursive: true, mode: 0o700 });
|
|
653
|
+
const events = networkBehaviorEvents.slice(-MAX_PERSISTED_BEHAVIOR_EVENTS);
|
|
654
|
+
(0, node_fs_1.writeFileSync)(statePath, `${JSON.stringify(events)}\n`, { mode: 0o600 });
|
|
655
|
+
(0, node_fs_1.chmodSync)(statePath, 0o600);
|
|
297
656
|
}
|
|
657
|
+
catch {
|
|
658
|
+
// Behavior state is best-effort; runtime protection still works without it.
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function parseNetworkBehaviorEvent(value) {
|
|
662
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
663
|
+
return null;
|
|
664
|
+
const record = value;
|
|
665
|
+
if (typeof record.timestamp !== 'number' ||
|
|
666
|
+
typeof record.sessionId !== 'string' ||
|
|
667
|
+
typeof record.hostname !== 'string' ||
|
|
668
|
+
typeof record.method !== 'string') {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
timestamp: record.timestamp,
|
|
673
|
+
sessionId: record.sessionId,
|
|
674
|
+
hostname: record.hostname,
|
|
675
|
+
method: record.method,
|
|
676
|
+
fingerprint: typeof record.fingerprint === 'string' ? record.fingerprint : undefined,
|
|
677
|
+
tokenHashes: Array.isArray(record.tokenHashes)
|
|
678
|
+
? record.tokenHashes.filter((item) => typeof item === 'string')
|
|
679
|
+
: [],
|
|
680
|
+
responseBytes: typeof record.responseBytes === 'number' ? record.responseBytes : undefined,
|
|
681
|
+
responseStatus: typeof record.responseStatus === 'number' ? record.responseStatus : undefined,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function pruneNetworkBehaviorEvents(now) {
|
|
685
|
+
const cutoff = now - TEN_MINUTES_MS;
|
|
686
|
+
while (networkBehaviorEvents.length > 0 && networkBehaviorEvents[0].timestamp < cutoff) {
|
|
687
|
+
networkBehaviorEvents.shift();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
function isOddHour(timestamp) {
|
|
691
|
+
const hour = new Date(timestamp).getHours();
|
|
692
|
+
return hour >= 2 && hour < 6;
|
|
693
|
+
}
|
|
694
|
+
function requestFingerprint(input, target, method, headers, bodyPreview) {
|
|
695
|
+
if (!headers && !bodyPreview)
|
|
696
|
+
return undefined;
|
|
697
|
+
return hashValue(JSON.stringify({
|
|
698
|
+
method,
|
|
699
|
+
host: target.hostname,
|
|
700
|
+
path: target.pathname,
|
|
701
|
+
input,
|
|
702
|
+
headers: stableRecordString(headers),
|
|
703
|
+
bodyPreview,
|
|
704
|
+
}));
|
|
705
|
+
}
|
|
706
|
+
function extractCredentialValues(input, headers, bodyPreview) {
|
|
707
|
+
const values = new Set();
|
|
708
|
+
collectUrlCredentialValues(input, values);
|
|
709
|
+
if (headers) {
|
|
710
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
711
|
+
if (!/(authorization|api[-_]?key|access[-_]?token|token|secret)/i.test(key))
|
|
712
|
+
continue;
|
|
713
|
+
const text = Array.isArray(value) ? value.join(',') : String(value ?? '');
|
|
714
|
+
for (const item of text.matchAll(/[A-Za-z0-9._~+/=-]{8,}/g)) {
|
|
715
|
+
values.add(item[0]);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (bodyPreview) {
|
|
720
|
+
for (const match of bodyPreview.matchAll(/(?:api[-_]?key|access[-_]?token|token|authorization|secret)["':=\s]+([A-Za-z0-9._~+/=-]{8,})/gi)) {
|
|
721
|
+
values.add(match[1]);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return [...values];
|
|
725
|
+
}
|
|
726
|
+
function collectUrlCredentialValues(input, values) {
|
|
727
|
+
for (const reference of extractNetworkReferences(input).length > 0 ? extractNetworkReferences(input) : [input]) {
|
|
728
|
+
const token = trimNetworkToken(reference);
|
|
729
|
+
if (!token)
|
|
730
|
+
continue;
|
|
731
|
+
const urlLike = /^[a-z][a-z0-9+.-]*:\/\//i.test(token) ? token : `https://${token}`;
|
|
732
|
+
try {
|
|
733
|
+
const parsed = new URL(urlLike);
|
|
734
|
+
for (const [key, value] of parsed.searchParams.entries()) {
|
|
735
|
+
if (/(api[-_]?key|access[-_]?token|token|authorization|secret|auth|key)/i.test(key) && value.length >= 8) {
|
|
736
|
+
values.add(value);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
// Ignore malformed references.
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
function stableRecordString(record) {
|
|
746
|
+
if (!record)
|
|
747
|
+
return undefined;
|
|
748
|
+
return Object.keys(record)
|
|
749
|
+
.sort((a, b) => a.localeCompare(b))
|
|
750
|
+
.map((key) => `${key.toLowerCase()}:${String(record[key])}`)
|
|
751
|
+
.join('\n');
|
|
752
|
+
}
|
|
753
|
+
function hashValue(value) {
|
|
754
|
+
return (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
|
|
755
|
+
}
|
|
756
|
+
function contentTypeFromHeaders(headers) {
|
|
757
|
+
if (!headers)
|
|
758
|
+
return undefined;
|
|
759
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
760
|
+
if (key.toLowerCase() === 'content-type' && typeof value === 'string')
|
|
761
|
+
return value;
|
|
762
|
+
}
|
|
763
|
+
return undefined;
|
|
764
|
+
}
|
|
765
|
+
function isBinaryOrMediaContentType(contentType) {
|
|
766
|
+
return /^(image|audio|video)\//i.test(contentType) ||
|
|
767
|
+
/application\/(?:octet-stream|pdf|zip|gzip|x-tar)/i.test(contentType);
|
|
768
|
+
}
|
|
769
|
+
function hasSuspiciousXssResponse(body) {
|
|
770
|
+
return /javascript:\s*(?:alert|confirm|prompt|eval|fetch|\w+\()/i.test(body) ||
|
|
771
|
+
/on(?:error|load|click|mouseover|focus)\s*=\s*["']?(?:alert|confirm|prompt|eval|fetch|javascript:)/i.test(body) ||
|
|
772
|
+
/<script\b(?![^>]*\bsrc=)[^>]*>[\s\S]{0,500}?(?:alert|confirm|prompt|document\.cookie|localStorage|eval|fetch\s*\()/i.test(body);
|
|
773
|
+
}
|
|
774
|
+
function looksLikeHtmlOrScript(body) {
|
|
775
|
+
return /^\s*(?:<!doctype\s+html|<html\b|<script\b)/i.test(body) ||
|
|
776
|
+
/javascript:|<script\b/i.test(body);
|
|
777
|
+
}
|
|
778
|
+
function dedupeReasons(items) {
|
|
779
|
+
const seen = new Set();
|
|
780
|
+
return items.filter((item) => {
|
|
781
|
+
if (seen.has(item.code))
|
|
782
|
+
return false;
|
|
783
|
+
seen.add(item.code);
|
|
784
|
+
return true;
|
|
785
|
+
});
|
|
298
786
|
}
|
|
299
787
|
function normalizeCommand(value) {
|
|
300
788
|
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|