@panguard-ai/atr 1.4.3 → 1.5.1
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-executor.d.ts +44 -0
- package/dist/action-executor.d.ts.map +1 -0
- package/dist/action-executor.js +130 -0
- package/dist/action-executor.js.map +1 -0
- package/dist/adapters/default-adapter.d.ts +24 -0
- package/dist/adapters/default-adapter.d.ts.map +1 -0
- package/dist/adapters/default-adapter.js +51 -0
- package/dist/adapters/default-adapter.js.map +1 -0
- package/dist/adapters/stdio-adapter.d.ts +30 -0
- package/dist/adapters/stdio-adapter.d.ts.map +1 -0
- package/dist/adapters/stdio-adapter.js +128 -0
- package/dist/adapters/stdio-adapter.js.map +1 -0
- package/dist/badge.d.ts +42 -0
- package/dist/badge.d.ts.map +1 -0
- package/dist/badge.js +163 -0
- package/dist/badge.js.map +1 -0
- package/dist/capability-extractor.d.ts +35 -0
- package/dist/capability-extractor.d.ts.map +1 -0
- package/dist/capability-extractor.js +91 -0
- package/dist/capability-extractor.js.map +1 -0
- package/dist/cli/scan-handler.d.ts +21 -0
- package/dist/cli/scan-handler.d.ts.map +1 -0
- package/dist/cli/scan-handler.js +276 -0
- package/dist/cli/scan-handler.js.map +1 -0
- package/dist/cli/tc-pipeline.d.ts +18 -0
- package/dist/cli/tc-pipeline.d.ts.map +1 -0
- package/dist/cli/tc-pipeline.js +295 -0
- package/dist/cli/tc-pipeline.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +894 -0
- package/dist/cli.js.map +1 -0
- package/dist/content-hash.d.ts +7 -0
- package/dist/content-hash.d.ts.map +1 -0
- package/dist/content-hash.js +10 -0
- package/dist/content-hash.js.map +1 -0
- package/dist/converters/elastic.d.ts +36 -0
- package/dist/converters/elastic.d.ts.map +1 -0
- package/dist/converters/elastic.js +125 -0
- package/dist/converters/elastic.js.map +1 -0
- package/dist/converters/generic-regex.d.ts +37 -0
- package/dist/converters/generic-regex.d.ts.map +1 -0
- package/dist/converters/generic-regex.js +59 -0
- package/dist/converters/generic-regex.js.map +1 -0
- package/dist/converters/index.d.ts +32 -0
- package/dist/converters/index.d.ts.map +1 -0
- package/dist/converters/index.js +38 -0
- package/dist/converters/index.js.map +1 -0
- package/dist/converters/sarif.d.ts +18 -0
- package/dist/converters/sarif.d.ts.map +1 -0
- package/dist/converters/sarif.js +142 -0
- package/dist/converters/sarif.js.map +1 -0
- package/dist/converters/splunk.d.ts +19 -0
- package/dist/converters/splunk.d.ts.map +1 -0
- package/dist/converters/splunk.js +148 -0
- package/dist/converters/splunk.js.map +1 -0
- package/dist/coverage-analyzer.d.ts +43 -0
- package/dist/coverage-analyzer.d.ts.map +1 -0
- package/dist/coverage-analyzer.js +329 -0
- package/dist/coverage-analyzer.js.map +1 -0
- package/dist/embedding/build-corpus.d.ts +15 -0
- package/dist/embedding/build-corpus.d.ts.map +1 -0
- package/dist/embedding/build-corpus.js +105 -0
- package/dist/embedding/build-corpus.js.map +1 -0
- package/dist/embedding/model-loader.d.ts +41 -0
- package/dist/embedding/model-loader.d.ts.map +1 -0
- package/dist/embedding/model-loader.js +90 -0
- package/dist/embedding/model-loader.js.map +1 -0
- package/dist/embedding/vector-store.d.ts +41 -0
- package/dist/embedding/vector-store.d.ts.map +1 -0
- package/dist/embedding/vector-store.js +70 -0
- package/dist/embedding/vector-store.js.map +1 -0
- package/dist/engine.d.ts +222 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1185 -0
- package/dist/engine.js.map +1 -0
- package/dist/eval/corpus.d.ts +42 -0
- package/dist/eval/corpus.d.ts.map +1 -0
- package/dist/eval/corpus.js +427 -0
- package/dist/eval/corpus.js.map +1 -0
- package/dist/eval/eval-harness.d.ts +44 -0
- package/dist/eval/eval-harness.d.ts.map +1 -0
- package/dist/eval/eval-harness.js +296 -0
- package/dist/eval/eval-harness.js.map +1 -0
- package/dist/eval/index.d.ts +13 -0
- package/dist/eval/index.d.ts.map +1 -0
- package/dist/eval/index.js +9 -0
- package/dist/eval/index.js.map +1 -0
- package/dist/eval/metrics.d.ts +74 -0
- package/dist/eval/metrics.d.ts.map +1 -0
- package/dist/eval/metrics.js +108 -0
- package/dist/eval/metrics.js.map +1 -0
- package/dist/eval/pint-corpus.d.ts +34 -0
- package/dist/eval/pint-corpus.d.ts.map +1 -0
- package/dist/eval/pint-corpus.js +113 -0
- package/dist/eval/pint-corpus.js.map +1 -0
- package/dist/eval/rule-corpus.d.ts +9 -0
- package/dist/eval/rule-corpus.d.ts.map +1 -0
- package/dist/eval/rule-corpus.js +4780 -0
- package/dist/eval/rule-corpus.js.map +1 -0
- package/dist/eval/rule-metrics.d.ts +34 -0
- package/dist/eval/rule-metrics.d.ts.map +1 -0
- package/dist/eval/rule-metrics.js +92 -0
- package/dist/eval/rule-metrics.js.map +1 -0
- package/dist/eval/run-eval.d.ts +7 -0
- package/dist/eval/run-eval.d.ts.map +1 -0
- package/dist/eval/run-eval.js +11 -0
- package/dist/eval/run-eval.js.map +1 -0
- package/dist/eval/run-pint-benchmark.d.ts +18 -0
- package/dist/eval/run-pint-benchmark.d.ts.map +1 -0
- package/dist/eval/run-pint-benchmark.js +159 -0
- package/dist/eval/run-pint-benchmark.js.map +1 -0
- package/dist/eval/skill-benchmark.d.ts +66 -0
- package/dist/eval/skill-benchmark.d.ts.map +1 -0
- package/dist/eval/skill-benchmark.js +194 -0
- package/dist/eval/skill-benchmark.js.map +1 -0
- package/dist/flywheel.d.ts +54 -0
- package/dist/flywheel.d.ts.map +1 -0
- package/dist/flywheel.js +121 -0
- package/dist/flywheel.js.map +1 -0
- package/dist/hook-handler.d.ts +61 -0
- package/dist/hook-handler.d.ts.map +1 -0
- package/dist/hook-handler.js +178 -0
- package/dist/hook-handler.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/{src/index.ts → dist/index.js} +1 -0
- package/dist/index.js.map +1 -0
- package/dist/layer-integration.d.ts +55 -0
- package/dist/layer-integration.d.ts.map +1 -0
- package/dist/layer-integration.js +187 -0
- package/dist/layer-integration.js.map +1 -0
- package/dist/loader.d.ts +18 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +129 -0
- package/dist/loader.js.map +1 -0
- package/dist/mcp-server.d.ts +13 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +246 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/mcp-tools/coverage-gaps.d.ts +13 -0
- package/dist/mcp-tools/coverage-gaps.d.ts.map +1 -0
- package/dist/mcp-tools/coverage-gaps.js +55 -0
- package/dist/mcp-tools/coverage-gaps.js.map +1 -0
- package/dist/mcp-tools/list-rules.d.ts +17 -0
- package/dist/mcp-tools/list-rules.d.ts.map +1 -0
- package/dist/mcp-tools/list-rules.js +45 -0
- package/dist/mcp-tools/list-rules.js.map +1 -0
- package/dist/mcp-tools/scan-skill.d.ts +17 -0
- package/dist/mcp-tools/scan-skill.d.ts.map +1 -0
- package/dist/mcp-tools/scan-skill.js +65 -0
- package/dist/mcp-tools/scan-skill.js.map +1 -0
- package/dist/mcp-tools/scan.d.ts +24 -0
- package/dist/mcp-tools/scan.d.ts.map +1 -0
- package/dist/mcp-tools/scan.js +94 -0
- package/dist/mcp-tools/scan.js.map +1 -0
- package/dist/mcp-tools/submit-proposal.d.ts +12 -0
- package/dist/mcp-tools/submit-proposal.d.ts.map +1 -0
- package/dist/mcp-tools/submit-proposal.js +103 -0
- package/dist/mcp-tools/submit-proposal.js.map +1 -0
- package/dist/mcp-tools/threat-summary.d.ts +12 -0
- package/dist/mcp-tools/threat-summary.d.ts.map +1 -0
- package/dist/mcp-tools/threat-summary.js +74 -0
- package/dist/mcp-tools/threat-summary.js.map +1 -0
- package/dist/mcp-tools/validate.d.ts +15 -0
- package/dist/mcp-tools/validate.d.ts.map +1 -0
- package/dist/mcp-tools/validate.js +51 -0
- package/dist/mcp-tools/validate.js.map +1 -0
- package/dist/modules/embedding.d.ts +71 -0
- package/dist/modules/embedding.d.ts.map +1 -0
- package/dist/modules/embedding.js +141 -0
- package/dist/modules/embedding.js.map +1 -0
- package/dist/modules/index.d.ts +144 -0
- package/dist/modules/index.d.ts.map +1 -0
- package/dist/modules/index.js +82 -0
- package/dist/modules/index.js.map +1 -0
- package/dist/modules/semantic.d.ts +106 -0
- package/dist/modules/semantic.d.ts.map +1 -0
- package/dist/modules/semantic.js +359 -0
- package/dist/modules/semantic.js.map +1 -0
- package/dist/modules/session.d.ts +70 -0
- package/dist/modules/session.d.ts.map +1 -0
- package/dist/modules/session.js +128 -0
- package/dist/modules/session.js.map +1 -0
- package/dist/quality/adapters/atr.d.ts +65 -0
- package/dist/quality/adapters/atr.d.ts.map +1 -0
- package/dist/quality/adapters/atr.js +154 -0
- package/dist/quality/adapters/atr.js.map +1 -0
- package/dist/quality/adapters/index.d.ts +10 -0
- package/dist/quality/adapters/index.d.ts.map +1 -0
- package/dist/quality/adapters/index.js +10 -0
- package/dist/quality/adapters/index.js.map +1 -0
- package/dist/quality/compute-confidence.d.ts +45 -0
- package/dist/quality/compute-confidence.d.ts.map +1 -0
- package/dist/quality/compute-confidence.js +133 -0
- package/dist/quality/compute-confidence.js.map +1 -0
- package/dist/quality/index.d.ts +36 -0
- package/dist/quality/index.d.ts.map +1 -0
- package/dist/quality/index.js +39 -0
- package/dist/quality/index.js.map +1 -0
- package/dist/quality/quality-gate.d.ts +86 -0
- package/dist/quality/quality-gate.d.ts.map +1 -0
- package/dist/quality/quality-gate.js +187 -0
- package/dist/quality/quality-gate.js.map +1 -0
- package/dist/quality/types.d.ts +129 -0
- package/dist/quality/types.d.ts.map +1 -0
- package/dist/quality/types.js +10 -0
- package/dist/quality/types.js.map +1 -0
- package/dist/quality/validate-maturity.d.ts +51 -0
- package/dist/quality/validate-maturity.d.ts.map +1 -0
- package/dist/quality/validate-maturity.js +134 -0
- package/dist/quality/validate-maturity.js.map +1 -0
- package/dist/quality.d.ts +8 -0
- package/dist/quality.d.ts.map +1 -0
- package/dist/quality.js +8 -0
- package/dist/quality.js.map +1 -0
- package/dist/rule-scaffolder.d.ts +53 -0
- package/dist/rule-scaffolder.d.ts.map +1 -0
- package/dist/rule-scaffolder.js +301 -0
- package/dist/rule-scaffolder.js.map +1 -0
- package/dist/session-tracker.d.ts +58 -0
- package/dist/session-tracker.d.ts.map +1 -0
- package/dist/session-tracker.js +176 -0
- package/dist/session-tracker.js.map +1 -0
- package/dist/shadow-evaluator.d.ts +48 -0
- package/dist/shadow-evaluator.d.ts.map +1 -0
- package/dist/shadow-evaluator.js +129 -0
- package/dist/shadow-evaluator.js.map +1 -0
- package/dist/skill-fingerprint.d.ts +85 -0
- package/dist/skill-fingerprint.d.ts.map +1 -0
- package/dist/skill-fingerprint.js +284 -0
- package/dist/skill-fingerprint.js.map +1 -0
- package/dist/tc-reporter.d.ts +50 -0
- package/dist/tc-reporter.d.ts.map +1 -0
- package/dist/tc-reporter.js +164 -0
- package/dist/tc-reporter.js.map +1 -0
- package/dist/tier0-invariant.d.ts +49 -0
- package/dist/tier0-invariant.d.ts.map +1 -0
- package/dist/tier0-invariant.js +185 -0
- package/dist/tier0-invariant.js.map +1 -0
- package/dist/tier1-blacklist.d.ts +48 -0
- package/dist/tier1-blacklist.d.ts.map +1 -0
- package/dist/tier1-blacklist.js +92 -0
- package/dist/tier1-blacklist.js.map +1 -0
- package/dist/types.d.ts +232 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/verdict.d.ts +26 -0
- package/dist/verdict.d.ts.map +1 -0
- package/dist/verdict.js +127 -0
- package/dist/verdict.js.map +1 -0
- package/package.json +16 -4
- package/.github/ISSUE_TEMPLATE/evasion-report.yml +0 -75
- package/.github/ISSUE_TEMPLATE/false-positive.yml +0 -31
- package/.github/ISSUE_TEMPLATE/mirofish-prediction.yml +0 -128
- package/.github/ISSUE_TEMPLATE/new-rule.yml +0 -37
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -23
- package/.github/workflows/rule-quality.yml +0 -203
- package/.github/workflows/validate.yml +0 -42
- package/CHANGELOG.md +0 -30
- package/CONTRIBUTING.md +0 -168
- package/CONTRIBUTORS.md +0 -28
- package/COVERAGE.md +0 -135
- package/LIMITATIONS.md +0 -154
- package/SECURITY.md +0 -48
- package/THREAT-MODEL.md +0 -243
- package/docs/contribution-paths.md +0 -202
- package/docs/mirofish-prediction-guide.md +0 -304
- package/docs/quick-start.md +0 -245
- package/docs/rule-writing-guide.md +0 -647
- package/docs/schema-spec.md +0 -594
- package/examples/how-to-write-a-rule.md +0 -251
- package/tsconfig.json +0 -17
package/dist/engine.js
ADDED
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ATR Engine - Evaluates agent events against ATR rules
|
|
3
|
+
*
|
|
4
|
+
* Core detection engine that:
|
|
5
|
+
* 1. Loads ATR YAML rules from disk
|
|
6
|
+
* 2. Evaluates agent events (LLM I/O, tool calls, behaviors) against rules
|
|
7
|
+
* 3. Returns matched rules with confidence scores
|
|
8
|
+
* 4. Supports two condition formats:
|
|
9
|
+
* - Array format: conditions is an array of {field, operator, value} objects
|
|
10
|
+
* - Named format: conditions is an object map of named condition blocks
|
|
11
|
+
*
|
|
12
|
+
* @module agent-threat-rules/engine
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
import { computeContentHash } from './content-hash.js';
|
|
17
|
+
import { loadRulesFromDirectory, loadRuleFile } from './loader.js';
|
|
18
|
+
import { computeVerdict } from './verdict.js';
|
|
19
|
+
import { SemanticModule } from './modules/semantic.js';
|
|
20
|
+
import { resolveSkillId, runFingerprintLayer, shouldRunSemanticLayer, createSemanticModuleFromConfig, runSemanticLayer, } from './layer-integration.js';
|
|
21
|
+
import { buildBlacklistMatch, resolveSkillId as resolveBlacklistSkillId } from './tier1-blacklist.js';
|
|
22
|
+
/**
|
|
23
|
+
* Rules excluded from skill-context scanning due to high false-positive rate.
|
|
24
|
+
* Threshold: >0.5% FP on 466 real-world SKILL.md files (skills-sh corpus).
|
|
25
|
+
* Re-evaluate when adding new rules or updating detection patterns.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Rules excluded from skill-context scanning due to high false-positive rate.
|
|
29
|
+
*
|
|
30
|
+
* Audit 2026-04-14: denylist reduced from 22 → 10 rules.
|
|
31
|
+
* Rules with ≤2 FP on 466 benign SKILL.md samples were removed from denylist
|
|
32
|
+
* to improve wild scan coverage (previously missing 2000+ potential threats).
|
|
33
|
+
*
|
|
34
|
+
* Threshold: >2 FP on 466 benchmark benign samples (>0.43%).
|
|
35
|
+
* Re-evaluate when adding new rules or updating detection patterns.
|
|
36
|
+
*/
|
|
37
|
+
const SKILL_CONTEXT_DENYLIST = new Set([
|
|
38
|
+
// HIGH FP (>20%) — must stay denylisted until regex rewrite
|
|
39
|
+
'ATR-2026-00111', // Shell Escape — 70% FP (matches any shell/exec mention in code examples)
|
|
40
|
+
'ATR-2026-00118', // Approval Fatigue — 27% FP (matches any approval/confirm pattern)
|
|
41
|
+
'ATR-2026-00051', // Resource Exhaustion — 23% FP (SQL/loop patterns in normal skills)
|
|
42
|
+
// MEDIUM FP (1-6%) — denylisted, need regex tightening to unlock
|
|
43
|
+
'ATR-2026-00030', // Cross-Agent Attack — 6% FP (multi-agent communication patterns)
|
|
44
|
+
'ATR-2026-00032', // Goal Hijacking — 3.2% FP (instructional language)
|
|
45
|
+
'ATR-2026-00002', // Indirect Prompt Injection — 2.4% FP (content-fetch patterns)
|
|
46
|
+
'ATR-2026-00115', // Env Var Harvesting — 1.7% FP (legitimate env var references)
|
|
47
|
+
'ATR-2026-00113', // Credential Theft — 1.5% FP (security skills reference credential files)
|
|
48
|
+
'ATR-2026-00110', // eval() Injection — 1.3% FP (coding skills mention eval/exec)
|
|
49
|
+
'ATR-2026-00114', // OAuth Token Interception — 1.3% FP (normal auth patterns)
|
|
50
|
+
'ATR-2026-00050', // Runaway Agent Loop — 1.1% FP (loop patterns in automation skills)
|
|
51
|
+
'ATR-2026-00112', // Dynamic Import — 0.9% FP (import/require references)
|
|
52
|
+
'ATR-2026-00142', // Piggyback Transition — 0.6% FP (descriptive text triggers)
|
|
53
|
+
'ATR-2026-00116', // A2A Message Injection — 0.4% FP (agent communication patterns)
|
|
54
|
+
// LOW FP but zero wild hits — kept in denylist for precision
|
|
55
|
+
'ATR-2026-00060', // MCP Skill Impersonation — 1 FP, 0 wild hits
|
|
56
|
+
'ATR-2026-00074', // Cross-Agent Privilege Escalation — 1 FP, 0 wild hits
|
|
57
|
+
'ATR-2026-00076', // Insecure Inter-Agent Communication — 1 FP, 0 wild hits
|
|
58
|
+
'ATR-2026-00077', // Human-Agent Trust Exploitation — 1 FP, 0 wild hits
|
|
59
|
+
'ATR-2026-00098', // Unauthorized Financial Action — 1 FP, 0 wild hits
|
|
60
|
+
'ATR-2026-00117', // Agent Identity Spoofing — 1 FP, 0 wild hits
|
|
61
|
+
'ATR-2026-00123', // Over-Privileged Skill — 1 FP, 0 wild hits
|
|
62
|
+
'ATR-2026-00148', // Multilingual Prompt Injection — 1 FP, 0 wild hits
|
|
63
|
+
// REMOVED from denylist (≤1 FP + has wild value):
|
|
64
|
+
// ATR-2026-00117 Agent Identity Spoofing (1 FP)
|
|
65
|
+
// ATR-2026-00060 MCP Skill Impersonation (1 FP)
|
|
66
|
+
// ATR-2026-00077 Human-Agent Trust Exploitation (1 FP)
|
|
67
|
+
// ATR-2026-00076 Insecure Inter-Agent Communication (1 FP)
|
|
68
|
+
// ATR-2026-00148 Multilingual Prompt Injection (1 FP)
|
|
69
|
+
// ATR-2026-00123 Over-Privileged Skill (1 FP)
|
|
70
|
+
// ATR-2026-00074 Cross-Agent Privilege Escalation (1 FP)
|
|
71
|
+
// ATR-2026-00098 Unauthorized Financial Action (1 FP)
|
|
72
|
+
]);
|
|
73
|
+
/**
|
|
74
|
+
* Detect and decode base64-encoded blocks in content.
|
|
75
|
+
* Bounds: max 1 level decode, max 5 blocks, min 32 chars per block,
|
|
76
|
+
* MAX_EVAL_LENGTH per decoded block. Returns decoded text fragments.
|
|
77
|
+
*/
|
|
78
|
+
const BASE64_BLOCK_RE = /(?:[A-Za-z0-9+/]{32,}={0,2})/g;
|
|
79
|
+
const MAX_DECODE_BLOCKS = 5;
|
|
80
|
+
function decodeBase64Blocks(content) {
|
|
81
|
+
const decoded = [];
|
|
82
|
+
let match;
|
|
83
|
+
let count = 0;
|
|
84
|
+
while ((match = BASE64_BLOCK_RE.exec(content)) !== null && count < MAX_DECODE_BLOCKS) {
|
|
85
|
+
try {
|
|
86
|
+
const raw = Buffer.from(match[0], 'base64');
|
|
87
|
+
const text = raw.toString('utf-8');
|
|
88
|
+
// Only keep if decoded content looks like text (not binary garbage)
|
|
89
|
+
// Check: >80% printable ASCII or valid UTF-8 with common chars
|
|
90
|
+
const printable = text.split('').filter(c => c.charCodeAt(0) >= 32 && c.charCodeAt(0) < 127).length;
|
|
91
|
+
if (printable / text.length > 0.7 && text.length >= 10) {
|
|
92
|
+
decoded.push(text.slice(0, 100_000)); // MAX_EVAL_LENGTH bound
|
|
93
|
+
count++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Invalid base64, skip
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return decoded;
|
|
101
|
+
}
|
|
102
|
+
/** Map agent event types to ATR source types */
|
|
103
|
+
const EVENT_TYPE_TO_SOURCE = {
|
|
104
|
+
llm_input: 'llm_io',
|
|
105
|
+
llm_output: 'llm_io',
|
|
106
|
+
tool_call: 'tool_call',
|
|
107
|
+
tool_response: 'mcp_exchange',
|
|
108
|
+
agent_behavior: 'agent_behavior',
|
|
109
|
+
multi_agent_message: 'multi_agent_comm',
|
|
110
|
+
};
|
|
111
|
+
/** Map agent event types to default field names */
|
|
112
|
+
const EVENT_TYPE_TO_FIELD = {
|
|
113
|
+
llm_input: 'user_input',
|
|
114
|
+
llm_output: 'agent_output',
|
|
115
|
+
tool_call: 'tool_name',
|
|
116
|
+
tool_response: 'tool_response',
|
|
117
|
+
agent_behavior: 'metric',
|
|
118
|
+
multi_agent_message: 'agent_message',
|
|
119
|
+
};
|
|
120
|
+
export class ATREngine {
|
|
121
|
+
config;
|
|
122
|
+
rules = [];
|
|
123
|
+
compiledPatterns = new Map();
|
|
124
|
+
semanticModuleInstance;
|
|
125
|
+
/**
|
|
126
|
+
* Find bundled rules directory shipped with the npm package.
|
|
127
|
+
* Checks: ../rules (from dist/), ./rules (repo root)
|
|
128
|
+
*/
|
|
129
|
+
findBundledRulesDir() {
|
|
130
|
+
// import.meta.dirname available in Node 21+, fallback to cwd
|
|
131
|
+
const base = typeof import.meta.dirname === 'string' ? import.meta.dirname : process.cwd();
|
|
132
|
+
const candidates = [
|
|
133
|
+
resolve(base, '..', 'rules'), // dist/engine.js → ../rules
|
|
134
|
+
resolve(base, 'rules'), // repo root
|
|
135
|
+
resolve(process.cwd(), 'rules'),
|
|
136
|
+
resolve(process.cwd(), 'node_modules', 'agent-threat-rules', 'rules'),
|
|
137
|
+
];
|
|
138
|
+
for (const dir of candidates) {
|
|
139
|
+
if (existsSync(dir))
|
|
140
|
+
return dir;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
constructor(config = {}) {
|
|
145
|
+
this.config = config;
|
|
146
|
+
// Initialize Layer 3 semantic module if config provided
|
|
147
|
+
if (config.semanticModule) {
|
|
148
|
+
const moduleConfig = createSemanticModuleFromConfig(config.semanticModule);
|
|
149
|
+
this.semanticModuleInstance = new SemanticModule(moduleConfig);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
this.semanticModuleInstance = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Load rules from configured directory and/or pre-loaded rules.
|
|
157
|
+
*/
|
|
158
|
+
async loadRules() {
|
|
159
|
+
this.rules = [];
|
|
160
|
+
this.compiledPatterns.clear();
|
|
161
|
+
if (this.config.rules) {
|
|
162
|
+
this.rules.push(...this.config.rules);
|
|
163
|
+
}
|
|
164
|
+
// Resolve rules directory: explicit config > bundled rules in package
|
|
165
|
+
const rulesDir = this.config.rulesDir ?? this.findBundledRulesDir();
|
|
166
|
+
if (rulesDir) {
|
|
167
|
+
try {
|
|
168
|
+
const fileRules = loadRulesFromDirectory(rulesDir);
|
|
169
|
+
this.rules.push(...fileRules);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Directory may not exist yet
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Pre-compile regex patterns for performance
|
|
176
|
+
for (const rule of this.rules) {
|
|
177
|
+
this.compilePatterns(rule);
|
|
178
|
+
}
|
|
179
|
+
return this.rules.length;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Load a single rule file and add it to the engine.
|
|
183
|
+
*/
|
|
184
|
+
addRuleFile(filePath) {
|
|
185
|
+
const rule = loadRuleFile(filePath);
|
|
186
|
+
this.rules.push(rule);
|
|
187
|
+
this.compilePatterns(rule);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Add a pre-parsed rule to the engine.
|
|
191
|
+
*/
|
|
192
|
+
addRule(rule) {
|
|
193
|
+
this.rules.push(rule);
|
|
194
|
+
this.compilePatterns(rule);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Evaluate an agent event against all loaded ATR rules.
|
|
198
|
+
* Returns all matching rules with details.
|
|
199
|
+
*/
|
|
200
|
+
evaluate(event) {
|
|
201
|
+
const matches = [];
|
|
202
|
+
const eventSourceType = EVENT_TYPE_TO_SOURCE[event.type];
|
|
203
|
+
const allMatchedPatterns = [];
|
|
204
|
+
const sessionId = event.sessionId;
|
|
205
|
+
// Tier 0: Invariant enforcement (hard boundaries, pre-check)
|
|
206
|
+
if (this.config.invariantChecker) {
|
|
207
|
+
const violations = this.config.invariantChecker.check(event);
|
|
208
|
+
if (violations.length > 0) {
|
|
209
|
+
// Record denied event in session tracker for telemetry before returning
|
|
210
|
+
if (this.config.sessionTracker && sessionId) {
|
|
211
|
+
this.config.sessionTracker.recordEvent(sessionId, event, ['tier0-invariant-deny']);
|
|
212
|
+
}
|
|
213
|
+
return violations.map((v) => this.config.invariantChecker.buildDenyMatch(v));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Tier 1: Blacklist lookup (known-bad skills)
|
|
217
|
+
if (this.config.blacklistProvider) {
|
|
218
|
+
const skillId = resolveBlacklistSkillId(event);
|
|
219
|
+
if (skillId) {
|
|
220
|
+
const entry = this.config.blacklistProvider.lookup(skillId);
|
|
221
|
+
if (entry) {
|
|
222
|
+
matches.push(buildBlacklistMatch(entry));
|
|
223
|
+
// Don't short-circuit -- continue for telemetry, but blacklist match
|
|
224
|
+
// has critical severity which guarantees DENY verdict
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Tier 2: Pattern matching (existing regex rules)
|
|
229
|
+
const isSkillContext = event.scanContext === 'skill';
|
|
230
|
+
for (const rule of this.rules) {
|
|
231
|
+
// Skip deprecated and draft rules
|
|
232
|
+
if (rule.status === 'deprecated' || rule.status === 'draft')
|
|
233
|
+
continue;
|
|
234
|
+
// Source type filtering: skip rules that don't apply to this event type
|
|
235
|
+
// When scanContext is 'skill', skip source-type filtering — all rules fire
|
|
236
|
+
if (!isSkillContext && eventSourceType && rule.agent_source.type !== eventSourceType) {
|
|
237
|
+
// Allow mcp_exchange rules to also match tool_call events
|
|
238
|
+
if (!(rule.agent_source.type === 'mcp_exchange' && eventSourceType === 'tool_call')) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const matchResult = this.evaluateRule(rule, event);
|
|
243
|
+
if (matchResult) {
|
|
244
|
+
// Skill context compound gating: rules not designed for SKILL.md
|
|
245
|
+
// must match 2+ CONDITIONS (not patterns) to trigger. A single
|
|
246
|
+
// condition with many patterns fires too easily on long documents.
|
|
247
|
+
// 2+ condition co-occurrence means the document exhibits multiple
|
|
248
|
+
// distinct threat signals — strongly indicates a real attack, not
|
|
249
|
+
// security documentation that happens to describe one attack type.
|
|
250
|
+
// Rules with scan_target 'skill' or 'both' fire normally.
|
|
251
|
+
// Compound gate: rules not designed for skill scanning need 30%+
|
|
252
|
+
// conditions to match. Rules with scan_target 'skill' or 'both'
|
|
253
|
+
// have verified FP rates and fire normally.
|
|
254
|
+
if (isSkillContext && rule.tags.scan_target !== 'skill' && rule.tags.scan_target !== 'both') {
|
|
255
|
+
// Require at least 30% of conditions to match (min 2) — long documents
|
|
256
|
+
// with many technical terms easily hit 2 conditions; percentage-based
|
|
257
|
+
// threshold scales with rule complexity.
|
|
258
|
+
const totalConds = Number(rule.detection?.conditions?.length ?? 1);
|
|
259
|
+
const minRequired = Math.max(2, Math.ceil(totalConds * 0.3));
|
|
260
|
+
if ((matchResult.matchedConditions?.length ?? 0) < minRequired) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
matches.push(matchResult);
|
|
265
|
+
allMatchedPatterns.push(...matchResult.matchedPatterns);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Record event in session tracker (always, for cross-event sequence detection)
|
|
269
|
+
if (this.config.sessionTracker && sessionId) {
|
|
270
|
+
this.config.sessionTracker.recordEvent(sessionId, event, allMatchedPatterns);
|
|
271
|
+
}
|
|
272
|
+
// Layer 2: Skill behavioral fingerprinting (optional, no LLM)
|
|
273
|
+
const fingerprintStore = this.config.fingerprintStore;
|
|
274
|
+
if (fingerprintStore) {
|
|
275
|
+
const skillId = resolveSkillId(event);
|
|
276
|
+
if (skillId) {
|
|
277
|
+
const layer2Matches = runFingerprintLayer(fingerprintStore, event, skillId);
|
|
278
|
+
matches.push(...layer2Matches);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Sort by severity (critical first) then confidence
|
|
282
|
+
const sorted = matches.sort((a, b) => {
|
|
283
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, informational: 4 };
|
|
284
|
+
const aSev = severityOrder[a.rule.severity] ?? 4;
|
|
285
|
+
const bSev = severityOrder[b.rule.severity] ?? 4;
|
|
286
|
+
if (aSev !== bSev)
|
|
287
|
+
return aSev - bSev;
|
|
288
|
+
return b.confidence - a.confidence;
|
|
289
|
+
});
|
|
290
|
+
// Report detections to Threat Cloud (opt-in)
|
|
291
|
+
if (this.config.reporter) {
|
|
292
|
+
const hash = computeContentHash(event.content ?? '');
|
|
293
|
+
const scanTarget = isSkillContext ? 'skill' : (event.type ?? 'unknown');
|
|
294
|
+
const now = new Date().toISOString();
|
|
295
|
+
if (sorted.length > 0) {
|
|
296
|
+
for (const match of sorted) {
|
|
297
|
+
this.config.reporter.onDetection({
|
|
298
|
+
ruleId: match.rule.id,
|
|
299
|
+
severity: match.rule.severity,
|
|
300
|
+
scanTarget,
|
|
301
|
+
category: match.rule.tags?.category ?? 'unknown',
|
|
302
|
+
confidence: match.confidence,
|
|
303
|
+
timestamp: now,
|
|
304
|
+
contentHash: hash,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
else if (this.config.reporter.onClean) {
|
|
309
|
+
this.config.reporter.onClean({
|
|
310
|
+
rulesEvaluated: this.rules.length,
|
|
311
|
+
scanTarget,
|
|
312
|
+
timestamp: now,
|
|
313
|
+
contentHash: hash,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return sorted;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Evaluate a single rule against an event.
|
|
321
|
+
* Supports both array-format and named-map-format conditions.
|
|
322
|
+
*/
|
|
323
|
+
evaluateRule(rule, event) {
|
|
324
|
+
const { detection } = rule;
|
|
325
|
+
const conditions = detection.conditions;
|
|
326
|
+
const allMatchedPatterns = [];
|
|
327
|
+
// Detect format: array or named map
|
|
328
|
+
if (Array.isArray(conditions)) {
|
|
329
|
+
return this.evaluateArrayConditions(rule, conditions, detection.condition, event, allMatchedPatterns);
|
|
330
|
+
}
|
|
331
|
+
return this.evaluateNamedConditions(rule, conditions, detection.condition, event, allMatchedPatterns);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Evaluate array-format conditions: [{field, operator, value}, ...]
|
|
335
|
+
* with condition: "any" | "all"
|
|
336
|
+
*/
|
|
337
|
+
evaluateArrayConditions(rule, conditions, conditionExpr, event, allMatchedPatterns) {
|
|
338
|
+
const matchedConditionIndices = [];
|
|
339
|
+
const isAny = conditionExpr === 'any' || conditionExpr === 'or';
|
|
340
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
341
|
+
const cond = conditions[i];
|
|
342
|
+
const result = this.evaluateArrayCondition(cond, event, rule.id, i, allMatchedPatterns);
|
|
343
|
+
if (result) {
|
|
344
|
+
matchedConditionIndices.push(i);
|
|
345
|
+
if (isAny)
|
|
346
|
+
break; // Short-circuit on first match for "any"
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const matched = isAny
|
|
350
|
+
? matchedConditionIndices.length > 0
|
|
351
|
+
: matchedConditionIndices.length === conditions.length;
|
|
352
|
+
if (!matched)
|
|
353
|
+
return null;
|
|
354
|
+
const baseConfidence = rule.tags.confidence === 'high' ? 0.9 : rule.tags.confidence === 'medium' ? 0.7 : 0.5;
|
|
355
|
+
const matchRatio = matchedConditionIndices.length / Math.max(conditions.length, 1);
|
|
356
|
+
const confidence = Math.min(baseConfidence + matchRatio * 0.1, 1.0);
|
|
357
|
+
return {
|
|
358
|
+
rule,
|
|
359
|
+
matchedConditions: matchedConditionIndices.map(String),
|
|
360
|
+
matchedPatterns: allMatchedPatterns,
|
|
361
|
+
confidence,
|
|
362
|
+
timestamp: new Date().toISOString(),
|
|
363
|
+
scan_context: 'native',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Evaluate a single array-format condition {field, operator, value}.
|
|
368
|
+
*/
|
|
369
|
+
evaluateArrayCondition(cond, event, ruleId, index, matchedPatterns) {
|
|
370
|
+
// Sequence condition (has 'steps' array)
|
|
371
|
+
if (cond['steps'] && Array.isArray(cond['steps'])) {
|
|
372
|
+
return this.evaluateSequenceCondition(cond, event);
|
|
373
|
+
}
|
|
374
|
+
// Behavioral condition
|
|
375
|
+
if (cond['metric'] && cond['operator'] && cond['threshold'] !== undefined) {
|
|
376
|
+
return this.evaluateBehavioralCondition(cond, event);
|
|
377
|
+
}
|
|
378
|
+
const field = cond['field'];
|
|
379
|
+
const operator = cond['operator'];
|
|
380
|
+
const value = cond['value'];
|
|
381
|
+
if (!field || !operator || value === undefined)
|
|
382
|
+
return false;
|
|
383
|
+
const rawFieldValue = this.resolveField(field, event);
|
|
384
|
+
if (!rawFieldValue)
|
|
385
|
+
return false;
|
|
386
|
+
const fieldValue = normalizeUnicode(rawFieldValue);
|
|
387
|
+
switch (operator) {
|
|
388
|
+
case 'regex': {
|
|
389
|
+
// Try pre-compiled pattern first
|
|
390
|
+
const compiled = this.compiledPatterns.get(ruleId)?.get(String(index));
|
|
391
|
+
if (compiled && compiled.length > 0) {
|
|
392
|
+
// Test against both normalized and raw values so that patterns
|
|
393
|
+
// detecting zero-width/bidi characters can match before stripping
|
|
394
|
+
if (safeRegexTest(compiled[0], fieldValue) || safeRegexTest(compiled[0], rawFieldValue)) {
|
|
395
|
+
matchedPatterns.push(value);
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
// Fallback: compile on the fly
|
|
401
|
+
try {
|
|
402
|
+
const normalized = normalizeRegex(value);
|
|
403
|
+
const rFlags = normalized.includes('\\u{') || normalized.includes('\\p{') ? 'iu' : 'i';
|
|
404
|
+
const regex = new RegExp(normalized, rFlags);
|
|
405
|
+
if (safeRegexTest(regex, fieldValue) || safeRegexTest(regex, rawFieldValue)) {
|
|
406
|
+
matchedPatterns.push(value);
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// Invalid regex
|
|
412
|
+
}
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
case 'contains': {
|
|
416
|
+
if (fieldValue.toLowerCase().includes(value.toLowerCase())) {
|
|
417
|
+
matchedPatterns.push(value);
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
case 'exact': {
|
|
423
|
+
if (fieldValue === value) {
|
|
424
|
+
matchedPatterns.push(value);
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
case 'starts_with': {
|
|
430
|
+
if (fieldValue.toLowerCase().startsWith(value.toLowerCase())) {
|
|
431
|
+
matchedPatterns.push(value);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
default:
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Evaluate named-map-format conditions: {name: {field, patterns, match_type}, ...}
|
|
442
|
+
* with condition: "name1 AND name2" | "name1 OR name2" | "name1"
|
|
443
|
+
*/
|
|
444
|
+
evaluateNamedConditions(rule, conditions, conditionExpr, event, allMatchedPatterns) {
|
|
445
|
+
const conditionResults = new Map();
|
|
446
|
+
const matchedConditionNames = [];
|
|
447
|
+
for (const [condName, condDef] of Object.entries(conditions)) {
|
|
448
|
+
const result = this.evaluateNamedCondition(condName, condDef, event, rule, allMatchedPatterns);
|
|
449
|
+
conditionResults.set(condName, result);
|
|
450
|
+
if (result) {
|
|
451
|
+
matchedConditionNames.push(condName);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Evaluate the boolean expression
|
|
455
|
+
const finalResult = this.evaluateExpression(conditionExpr, conditionResults);
|
|
456
|
+
if (!finalResult)
|
|
457
|
+
return null;
|
|
458
|
+
const baseConfidence = rule.tags.confidence === 'high' ? 0.9 : rule.tags.confidence === 'medium' ? 0.7 : 0.5;
|
|
459
|
+
const matchRatio = matchedConditionNames.length / Math.max(Object.keys(conditions).length, 1);
|
|
460
|
+
const confidence = Math.min(baseConfidence + matchRatio * 0.1, 1.0);
|
|
461
|
+
return {
|
|
462
|
+
rule,
|
|
463
|
+
matchedConditions: matchedConditionNames,
|
|
464
|
+
matchedPatterns: allMatchedPatterns,
|
|
465
|
+
confidence,
|
|
466
|
+
timestamp: new Date().toISOString(),
|
|
467
|
+
scan_context: 'native',
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Evaluate a single named condition against an event.
|
|
472
|
+
*/
|
|
473
|
+
evaluateNamedCondition(condName, condDef, event, rule, matchedPatterns) {
|
|
474
|
+
const cond = condDef;
|
|
475
|
+
// Pattern matching condition (named format with patterns array)
|
|
476
|
+
if (cond['patterns'] && cond['field']) {
|
|
477
|
+
return this.evaluatePatternCondition(cond, event, rule.id, condName, matchedPatterns);
|
|
478
|
+
}
|
|
479
|
+
// Behavioral condition
|
|
480
|
+
if (cond['metric'] && cond['operator'] && cond['threshold'] !== undefined) {
|
|
481
|
+
return this.evaluateBehavioralCondition(cond, event);
|
|
482
|
+
}
|
|
483
|
+
// Sequence condition
|
|
484
|
+
if (cond['steps'] && Array.isArray(cond['steps'])) {
|
|
485
|
+
return this.evaluateSequenceCondition(cond, event);
|
|
486
|
+
}
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Evaluate a pattern matching condition (named format with patterns array).
|
|
491
|
+
*/
|
|
492
|
+
evaluatePatternCondition(cond, event, ruleId, condName, matchedPatterns) {
|
|
493
|
+
const rawFieldValue = this.resolveField(cond.field, event);
|
|
494
|
+
if (!rawFieldValue)
|
|
495
|
+
return false;
|
|
496
|
+
const fieldValue = normalizeUnicode(rawFieldValue);
|
|
497
|
+
// Code block suppression: for runtime events, rules that commonly
|
|
498
|
+
// false-positive on documentation content are suppressed when the match
|
|
499
|
+
// falls inside a markdown code block.
|
|
500
|
+
// Code block suppression in skill context:
|
|
501
|
+
// - scan_target: 'skill' rules → NOT suppressed (their patterns are designed
|
|
502
|
+
// for SKILL.md code blocks which ARE executable instructions)
|
|
503
|
+
// - Other rules → suppressed (their patterns FP on code examples)
|
|
504
|
+
const isSkillCtx = event.scanContext === 'skill';
|
|
505
|
+
const isSkillRule = this.rules.find(r => r.id === ruleId)?.tags?.scan_target === 'skill';
|
|
506
|
+
const suppressInCodeBlocks = (isSkillCtx && !isSkillRule)
|
|
507
|
+
? true // non-skill rules: always suppress code blocks in SKILL.md
|
|
508
|
+
: (!isSkillCtx && this.shouldSuppressInCodeBlocks(ruleId));
|
|
509
|
+
const codeRanges = suppressInCodeBlocks ? buildCodeBlockRanges(fieldValue) : [];
|
|
510
|
+
// Get pre-compiled patterns
|
|
511
|
+
const compiled = this.compiledPatterns.get(ruleId)?.get(condName);
|
|
512
|
+
if (compiled) {
|
|
513
|
+
for (let i = 0; i < compiled.length; i++) {
|
|
514
|
+
if (safeRegexTest(compiled[i], fieldValue) || (rawFieldValue && safeRegexTest(compiled[i], rawFieldValue))) {
|
|
515
|
+
// If match is inside a code block and this rule supports suppression, skip it
|
|
516
|
+
if (suppressInCodeBlocks && codeRanges.length > 0 && isInsideCodeBlock(fieldValue, compiled[i], codeRanges)) {
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
matchedPatterns.push(cond.patterns[i] ?? 'unknown');
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
// Fallback: direct string matching
|
|
526
|
+
const checkValue = cond.case_sensitive ? fieldValue : fieldValue.toLowerCase();
|
|
527
|
+
for (const pattern of cond.patterns) {
|
|
528
|
+
const checkPattern = cond.case_sensitive ? pattern : pattern.toLowerCase();
|
|
529
|
+
switch (cond.match_type) {
|
|
530
|
+
case 'contains':
|
|
531
|
+
if (checkValue.includes(checkPattern)) {
|
|
532
|
+
matchedPatterns.push(pattern);
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
break;
|
|
536
|
+
case 'exact':
|
|
537
|
+
if (checkValue === checkPattern) {
|
|
538
|
+
matchedPatterns.push(pattern);
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
break;
|
|
542
|
+
case 'starts_with':
|
|
543
|
+
if (checkValue.startsWith(checkPattern)) {
|
|
544
|
+
matchedPatterns.push(pattern);
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
break;
|
|
548
|
+
case 'regex':
|
|
549
|
+
default: {
|
|
550
|
+
try {
|
|
551
|
+
const flags = cond.case_sensitive ? '' : 'i';
|
|
552
|
+
const regex = new RegExp(pattern, flags);
|
|
553
|
+
if (safeRegexTest(regex, fieldValue)) {
|
|
554
|
+
matchedPatterns.push(pattern);
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
// Invalid regex, skip
|
|
560
|
+
}
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Determine if a rule should suppress matches inside markdown code blocks.
|
|
569
|
+
* Rules that commonly false-positive on documentation (shell commands, file paths,
|
|
570
|
+
* code examples) are suppressed. Prompt injection rules are NEVER suppressed
|
|
571
|
+
* because attackers deliberately hide payloads in code blocks.
|
|
572
|
+
*/
|
|
573
|
+
shouldSuppressInCodeBlocks(ruleId) {
|
|
574
|
+
const rule = this.rules.find(r => r.id === ruleId);
|
|
575
|
+
if (!rule)
|
|
576
|
+
return false;
|
|
577
|
+
const category = rule.tags?.category ?? '';
|
|
578
|
+
const subcategory = rule.tags?.subcategory ?? '';
|
|
579
|
+
// Categories that commonly match documentation content
|
|
580
|
+
const suppressCategories = [
|
|
581
|
+
'privilege-escalation', // ATR-111 shell metacharacter
|
|
582
|
+
'context-exfiltration', // ATR-113 credential paths
|
|
583
|
+
'skill-compromise', // supply chain patterns in docs
|
|
584
|
+
];
|
|
585
|
+
// Never suppress skill-content rules (ATR-120+) — code blocks in SKILL.md
|
|
586
|
+
// are executable instructions, not documentation examples
|
|
587
|
+
const neverSuppressSubcategories = [
|
|
588
|
+
'skill-instruction-injection',
|
|
589
|
+
'dangerous-script',
|
|
590
|
+
'weaponized-skill',
|
|
591
|
+
'skill-overreach',
|
|
592
|
+
'skill-squatting',
|
|
593
|
+
];
|
|
594
|
+
if (neverSuppressSubcategories.includes(subcategory))
|
|
595
|
+
return false;
|
|
596
|
+
return suppressCategories.includes(category);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Evaluate a behavioral threshold condition.
|
|
600
|
+
* When a session tracker is available and the event has a sessionId,
|
|
601
|
+
* supports session-derived metrics: call_frequency, pattern_frequency, event_count.
|
|
602
|
+
*/
|
|
603
|
+
evaluateBehavioralCondition(cond, event) {
|
|
604
|
+
const metricValue = this.resolveMetricValue(cond, event);
|
|
605
|
+
if (metricValue === undefined)
|
|
606
|
+
return false;
|
|
607
|
+
switch (cond.operator) {
|
|
608
|
+
case 'gt': return metricValue > cond.threshold;
|
|
609
|
+
case 'lt': return metricValue < cond.threshold;
|
|
610
|
+
case 'eq': return metricValue === cond.threshold;
|
|
611
|
+
case 'gte': return metricValue >= cond.threshold;
|
|
612
|
+
case 'lte': return metricValue <= cond.threshold;
|
|
613
|
+
case 'deviation_from_baseline':
|
|
614
|
+
return Math.abs(metricValue) > cond.threshold;
|
|
615
|
+
default:
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Resolve a metric value from event metrics or session tracker.
|
|
621
|
+
* Session-derived metrics use the format: "call_frequency:toolName" or "pattern_frequency:pattern".
|
|
622
|
+
*/
|
|
623
|
+
resolveMetricValue(cond, event) {
|
|
624
|
+
// Check event-level metrics first
|
|
625
|
+
const directValue = event.metrics?.[cond.metric];
|
|
626
|
+
if (directValue !== undefined)
|
|
627
|
+
return directValue;
|
|
628
|
+
// Try session tracker for session-derived metrics
|
|
629
|
+
const tracker = this.config.sessionTracker;
|
|
630
|
+
const sessionId = event.sessionId;
|
|
631
|
+
if (!tracker || !sessionId)
|
|
632
|
+
return undefined;
|
|
633
|
+
const windowMs = this.parseWindowMs(cond.window);
|
|
634
|
+
if (cond.metric.startsWith('call_frequency:')) {
|
|
635
|
+
const toolName = cond.metric.slice('call_frequency:'.length);
|
|
636
|
+
return tracker.getCallFrequency(sessionId, toolName, windowMs);
|
|
637
|
+
}
|
|
638
|
+
if (cond.metric.startsWith('pattern_frequency:')) {
|
|
639
|
+
const pattern = cond.metric.slice('pattern_frequency:'.length);
|
|
640
|
+
return tracker.getPatternFrequency(sessionId, pattern, windowMs);
|
|
641
|
+
}
|
|
642
|
+
if (cond.metric === 'event_count') {
|
|
643
|
+
return tracker.getEventCount(sessionId, windowMs);
|
|
644
|
+
}
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Parse a window string (e.g. "5m", "1h", "30s") to milliseconds.
|
|
649
|
+
* Defaults to 5 minutes if not specified or unparseable.
|
|
650
|
+
*/
|
|
651
|
+
parseWindowMs(window) {
|
|
652
|
+
if (!window)
|
|
653
|
+
return 5 * 60 * 1000;
|
|
654
|
+
const match = window.match(/^(\d+)\s*(s|m|h)$/);
|
|
655
|
+
if (!match)
|
|
656
|
+
return 5 * 60 * 1000;
|
|
657
|
+
const value = parseInt(match[1], 10);
|
|
658
|
+
const unit = match[2];
|
|
659
|
+
switch (unit) {
|
|
660
|
+
case 's': return value * 1000;
|
|
661
|
+
case 'm': return value * 60 * 1000;
|
|
662
|
+
case 'h': return value * 60 * 60 * 1000;
|
|
663
|
+
default: return 5 * 60 * 1000;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Evaluate a sequence condition against the current event.
|
|
668
|
+
*
|
|
669
|
+
* Two modes:
|
|
670
|
+
* 1. Session-aware (when SessionTracker + sessionId available):
|
|
671
|
+
* Checks patterns across historical events in the session.
|
|
672
|
+
* Respects `ordered` flag and `within` time window.
|
|
673
|
+
* 2. Single-event fallback: checks if patterns co-occur in one event.
|
|
674
|
+
*/
|
|
675
|
+
evaluateSequenceCondition(cond, event) {
|
|
676
|
+
const steps = cond['steps'];
|
|
677
|
+
if (!steps || steps.length === 0)
|
|
678
|
+
return false;
|
|
679
|
+
// Try session-aware detection first
|
|
680
|
+
const tracker = this.config.sessionTracker;
|
|
681
|
+
const sessionId = event.sessionId;
|
|
682
|
+
if (tracker && sessionId) {
|
|
683
|
+
const sessionResult = this.evaluateSequenceAcrossSession(steps, cond, tracker, sessionId, event);
|
|
684
|
+
if (sessionResult)
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
// Fallback: single-event check
|
|
688
|
+
return this.evaluateSequenceSingleEvent(steps, event);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Cross-event sequence detection using SessionTracker.
|
|
692
|
+
* Checks if step patterns have been seen across events in order.
|
|
693
|
+
*/
|
|
694
|
+
evaluateSequenceAcrossSession(steps, cond, tracker, sessionId, currentEvent) {
|
|
695
|
+
const ordered = cond['ordered'] !== false; // default: true
|
|
696
|
+
const withinMs = this.parseWindowMs(cond['within']);
|
|
697
|
+
const snapshot = tracker.getSessionSnapshot(sessionId);
|
|
698
|
+
if (!snapshot)
|
|
699
|
+
return false;
|
|
700
|
+
// Collect all events: historical + current
|
|
701
|
+
const allEvents = [...snapshot.events, currentEvent];
|
|
702
|
+
if (allEvents.length < steps.length)
|
|
703
|
+
return false;
|
|
704
|
+
// For each step, find the earliest event that matches
|
|
705
|
+
const stepMatches = [];
|
|
706
|
+
for (let si = 0; si < steps.length; si++) {
|
|
707
|
+
const step = steps[si];
|
|
708
|
+
const patterns = step['patterns'];
|
|
709
|
+
if (!patterns)
|
|
710
|
+
continue;
|
|
711
|
+
for (let ei = 0; ei < allEvents.length; ei++) {
|
|
712
|
+
const ev = allEvents[ei];
|
|
713
|
+
const content = normalizeUnicode(ev.content);
|
|
714
|
+
let matched = false;
|
|
715
|
+
for (const pattern of patterns) {
|
|
716
|
+
try {
|
|
717
|
+
if (safeRegexTest(new RegExp(pattern, 'i'), content)) {
|
|
718
|
+
matched = true;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
// Invalid regex
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (matched) {
|
|
727
|
+
stepMatches.push({
|
|
728
|
+
stepIndex: si,
|
|
729
|
+
eventIndex: ei,
|
|
730
|
+
timestamp: new Date(ev.timestamp).getTime(),
|
|
731
|
+
});
|
|
732
|
+
break; // First match per step
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// Need all steps to match
|
|
737
|
+
if (stepMatches.length < steps.length)
|
|
738
|
+
return false;
|
|
739
|
+
// Check ordering
|
|
740
|
+
if (ordered) {
|
|
741
|
+
for (let i = 1; i < stepMatches.length; i++) {
|
|
742
|
+
if (stepMatches[i].eventIndex <= stepMatches[i - 1].eventIndex) {
|
|
743
|
+
return false; // Out of order
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// Check time window
|
|
748
|
+
if (withinMs > 0) {
|
|
749
|
+
const firstTs = Math.min(...stepMatches.map((m) => m.timestamp));
|
|
750
|
+
const lastTs = Math.max(...stepMatches.map((m) => m.timestamp));
|
|
751
|
+
if (lastTs - firstTs > withinMs)
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Single-event fallback: check if step patterns co-occur in one event.
|
|
758
|
+
*/
|
|
759
|
+
evaluateSequenceSingleEvent(steps, event) {
|
|
760
|
+
const content = normalizeUnicode(event.content);
|
|
761
|
+
let matchCount = 0;
|
|
762
|
+
for (const step of steps) {
|
|
763
|
+
const patterns = step['patterns'];
|
|
764
|
+
if (patterns) {
|
|
765
|
+
for (const pattern of patterns) {
|
|
766
|
+
try {
|
|
767
|
+
if (safeRegexTest(new RegExp(pattern, 'i'), content)) {
|
|
768
|
+
matchCount++;
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// Invalid regex
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return matchCount >= 2;
|
|
779
|
+
}
|
|
780
|
+
// parseWindowMs already defined above (behavioral conditions)
|
|
781
|
+
/**
|
|
782
|
+
* Resolve a field value from an agent event.
|
|
783
|
+
*/
|
|
784
|
+
resolveField(fieldName, event) {
|
|
785
|
+
// Skill context: a SKILL.md is the entire document. ALL fields resolve to
|
|
786
|
+
// content so every rule can scan it. FP is controlled by requiring 2+
|
|
787
|
+
// condition matches in skill context (see evaluate()), not by field filtering.
|
|
788
|
+
if (event.scanContext === 'skill' && event.content) {
|
|
789
|
+
return event.content;
|
|
790
|
+
}
|
|
791
|
+
// Check explicit fields first
|
|
792
|
+
if (event.fields?.[fieldName]) {
|
|
793
|
+
return event.fields[fieldName];
|
|
794
|
+
}
|
|
795
|
+
// Map standard field names to event properties
|
|
796
|
+
const defaultField = EVENT_TYPE_TO_FIELD[event.type];
|
|
797
|
+
if (fieldName === defaultField || fieldName === 'content') {
|
|
798
|
+
return event.content;
|
|
799
|
+
}
|
|
800
|
+
// Common field aliases
|
|
801
|
+
switch (fieldName) {
|
|
802
|
+
case 'user_input':
|
|
803
|
+
return event.type === 'llm_input' ? event.content : event.fields?.['user_input'];
|
|
804
|
+
case 'agent_output':
|
|
805
|
+
return event.type === 'llm_output' ? event.content : event.fields?.['agent_output'];
|
|
806
|
+
case 'tool_response':
|
|
807
|
+
return event.type === 'tool_response' ? event.content : event.fields?.['tool_response'];
|
|
808
|
+
case 'tool_name':
|
|
809
|
+
return event.fields?.['tool_name'] ?? (event.type === 'tool_call' ? event.content : undefined);
|
|
810
|
+
case 'tool_args':
|
|
811
|
+
return event.fields?.['tool_args'] ?? (event.type === 'tool_call' ? event.content : undefined);
|
|
812
|
+
case 'agent_message':
|
|
813
|
+
return event.type === 'multi_agent_message' ? event.content : event.fields?.['agent_message'];
|
|
814
|
+
default:
|
|
815
|
+
// Try metadata
|
|
816
|
+
return event.metadata?.[fieldName];
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Evaluate a boolean expression string against condition results.
|
|
821
|
+
* Supports AND, OR, NOT operators.
|
|
822
|
+
*/
|
|
823
|
+
evaluateExpression(expression, results) {
|
|
824
|
+
const expr = expression.trim();
|
|
825
|
+
// Simple single condition
|
|
826
|
+
if (results.has(expr)) {
|
|
827
|
+
return results.get(expr) ?? false;
|
|
828
|
+
}
|
|
829
|
+
// Handle NOT
|
|
830
|
+
if (expr.startsWith('NOT ') || expr.startsWith('not ')) {
|
|
831
|
+
const inner = expr.slice(4).trim();
|
|
832
|
+
return !this.evaluateExpression(inner, results);
|
|
833
|
+
}
|
|
834
|
+
// Handle OR (lower precedence — split first so AND binds tighter)
|
|
835
|
+
const orParts = this.splitByOperator(expr, 'OR');
|
|
836
|
+
if (orParts.length > 1) {
|
|
837
|
+
return orParts.some((part) => this.evaluateExpression(part, results));
|
|
838
|
+
}
|
|
839
|
+
// Handle AND (higher precedence — evaluated within each OR branch)
|
|
840
|
+
const andParts = this.splitByOperator(expr, 'AND');
|
|
841
|
+
if (andParts.length > 1) {
|
|
842
|
+
return andParts.every((part) => this.evaluateExpression(part, results));
|
|
843
|
+
}
|
|
844
|
+
// Handle parentheses
|
|
845
|
+
if (expr.startsWith('(') && expr.endsWith(')')) {
|
|
846
|
+
return this.evaluateExpression(expr.slice(1, -1), results);
|
|
847
|
+
}
|
|
848
|
+
// Default: treat as condition name
|
|
849
|
+
return results.get(expr) ?? false;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Split expression by operator, respecting parentheses.
|
|
853
|
+
*/
|
|
854
|
+
splitByOperator(expr, operator) {
|
|
855
|
+
const parts = [];
|
|
856
|
+
let depth = 0;
|
|
857
|
+
let current = '';
|
|
858
|
+
const op = ` ${operator} `;
|
|
859
|
+
const opLower = ` ${operator.toLowerCase()} `;
|
|
860
|
+
for (let i = 0; i < expr.length; i++) {
|
|
861
|
+
const char = expr[i];
|
|
862
|
+
if (char === '(')
|
|
863
|
+
depth++;
|
|
864
|
+
if (char === ')')
|
|
865
|
+
depth--;
|
|
866
|
+
if (depth === 0) {
|
|
867
|
+
const remaining = expr.slice(i);
|
|
868
|
+
if (remaining.startsWith(op) || remaining.startsWith(opLower)) {
|
|
869
|
+
parts.push(current.trim());
|
|
870
|
+
current = '';
|
|
871
|
+
i += op.length - 1;
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
current += char;
|
|
876
|
+
}
|
|
877
|
+
if (current.trim()) {
|
|
878
|
+
parts.push(current.trim());
|
|
879
|
+
}
|
|
880
|
+
return parts;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Pre-compile regex patterns for a rule (performance optimization).
|
|
884
|
+
* Supports both array-format and named-map-format conditions.
|
|
885
|
+
*/
|
|
886
|
+
compilePatterns(rule) {
|
|
887
|
+
const ruleMap = new Map();
|
|
888
|
+
const conditions = rule.detection.conditions;
|
|
889
|
+
if (Array.isArray(conditions)) {
|
|
890
|
+
// Array format: compile each {operator: regex, value: "pattern"} entry
|
|
891
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
892
|
+
const cond = conditions[i];
|
|
893
|
+
if (cond['operator'] === 'regex' && typeof cond['value'] === 'string') {
|
|
894
|
+
try {
|
|
895
|
+
const pattern = normalizeRegex(cond['value']);
|
|
896
|
+
const flags = pattern.includes('\\u{') || pattern.includes('\\p{') ? 'iu' : 'i';
|
|
897
|
+
ruleMap.set(String(i), [new RegExp(pattern, flags)]);
|
|
898
|
+
}
|
|
899
|
+
catch {
|
|
900
|
+
// Invalid regex, skip
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
// Named format: compile patterns arrays
|
|
907
|
+
for (const [condName, condDef] of Object.entries(conditions)) {
|
|
908
|
+
const cond = condDef;
|
|
909
|
+
if (cond['patterns'] && Array.isArray(cond['patterns'])) {
|
|
910
|
+
const matchType = cond['match_type'] ?? 'regex';
|
|
911
|
+
const caseSensitive = cond['case_sensitive'] ?? false;
|
|
912
|
+
const flags = caseSensitive ? '' : 'i';
|
|
913
|
+
const compiled = [];
|
|
914
|
+
for (const pattern of cond['patterns']) {
|
|
915
|
+
try {
|
|
916
|
+
if (matchType === 'regex') {
|
|
917
|
+
compiled.push(new RegExp(normalizeRegex(pattern), flags));
|
|
918
|
+
}
|
|
919
|
+
else if (matchType === 'contains') {
|
|
920
|
+
compiled.push(new RegExp(escapeRegex(pattern), flags));
|
|
921
|
+
}
|
|
922
|
+
else if (matchType === 'exact') {
|
|
923
|
+
compiled.push(new RegExp(`^${escapeRegex(pattern)}$`, flags));
|
|
924
|
+
}
|
|
925
|
+
else if (matchType === 'starts_with') {
|
|
926
|
+
compiled.push(new RegExp(`^${escapeRegex(pattern)}`, flags));
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
catch {
|
|
930
|
+
// Invalid regex pattern, skip
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
ruleMap.set(condName, compiled);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
this.compiledPatterns.set(rule.id, ruleMap);
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Evaluate an event and compute a verdict with optional action execution.
|
|
941
|
+
*
|
|
942
|
+
* Combines evaluate() + computeVerdict() + optional ActionExecutor
|
|
943
|
+
* into a single call for convenience.
|
|
944
|
+
*/
|
|
945
|
+
async evaluateWithVerdict(event, executor) {
|
|
946
|
+
const layersUsed = ['layer1-regex'];
|
|
947
|
+
let matches = this.evaluate(event);
|
|
948
|
+
// Tier 0 + Tier 1 run inside evaluate(), track them
|
|
949
|
+
if (this.config.invariantChecker)
|
|
950
|
+
layersUsed.push('tier0-invariant');
|
|
951
|
+
if (this.config.blacklistProvider)
|
|
952
|
+
layersUsed.push('tier1-blacklist');
|
|
953
|
+
// Layer 2 runs synchronously inside evaluate(), but track if it was configured
|
|
954
|
+
if (this.config.fingerprintStore) {
|
|
955
|
+
layersUsed.push('layer2-fingerprint');
|
|
956
|
+
}
|
|
957
|
+
// Tier 2.5: Embedding similarity (async, runs on all events)
|
|
958
|
+
if (this.config.embeddingModule?.isAvailable()) {
|
|
959
|
+
layersUsed.push('tier2.5-embedding');
|
|
960
|
+
try {
|
|
961
|
+
const embResult = await this.config.embeddingModule.evaluate(event, {
|
|
962
|
+
module: 'embedding',
|
|
963
|
+
function: 'similarity_search',
|
|
964
|
+
args: { field: 'content' },
|
|
965
|
+
operator: 'gte',
|
|
966
|
+
threshold: 0.65,
|
|
967
|
+
});
|
|
968
|
+
if (embResult.matched) {
|
|
969
|
+
const severity = embResult.value >= 0.95 ? 'critical'
|
|
970
|
+
: embResult.value >= 0.88 ? 'high'
|
|
971
|
+
: 'medium';
|
|
972
|
+
const syntheticMatch = {
|
|
973
|
+
rule: {
|
|
974
|
+
title: `Embedding Match: ${embResult.description}`,
|
|
975
|
+
id: 'tier2.5-embedding-match',
|
|
976
|
+
status: 'experimental',
|
|
977
|
+
description: embResult.description,
|
|
978
|
+
author: 'atr-engine/tier2.5',
|
|
979
|
+
date: new Date().toISOString().slice(0, 10),
|
|
980
|
+
severity,
|
|
981
|
+
tags: { category: 'prompt-injection', subcategory: 'semantic-similarity', confidence: 'high' },
|
|
982
|
+
agent_source: { type: 'llm_io' },
|
|
983
|
+
detection: { conditions: {}, condition: 'tier2.5-runtime' },
|
|
984
|
+
response: {
|
|
985
|
+
actions: severity === 'critical'
|
|
986
|
+
? ['block_input', 'alert']
|
|
987
|
+
: ['alert'],
|
|
988
|
+
},
|
|
989
|
+
},
|
|
990
|
+
matchedConditions: ['embedding_similarity'],
|
|
991
|
+
matchedPatterns: [`similarity=${embResult.value.toFixed(3)}`],
|
|
992
|
+
confidence: embResult.value,
|
|
993
|
+
timestamp: new Date().toISOString(),
|
|
994
|
+
scan_context: 'native',
|
|
995
|
+
};
|
|
996
|
+
matches = [...matches, syntheticMatch];
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
catch {
|
|
1000
|
+
// Embedding failure is non-fatal
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
// Layer 3: Semantic LLM-as-judge (async, conditional)
|
|
1004
|
+
if (this.semanticModuleInstance && shouldRunSemanticLayer(matches, event)) {
|
|
1005
|
+
layersUsed.push('layer3-semantic');
|
|
1006
|
+
const semanticMatches = await runSemanticLayer(this.semanticModuleInstance, event, matches);
|
|
1007
|
+
if (semanticMatches.length > 0) {
|
|
1008
|
+
// Merge and re-sort immutably
|
|
1009
|
+
const merged = [...matches, ...semanticMatches];
|
|
1010
|
+
const severityOrder = {
|
|
1011
|
+
critical: 0, high: 1, medium: 2, low: 3, informational: 4,
|
|
1012
|
+
};
|
|
1013
|
+
merged.sort((a, b) => {
|
|
1014
|
+
const aSev = severityOrder[a.rule.severity] ?? 4;
|
|
1015
|
+
const bSev = severityOrder[b.rule.severity] ?? 4;
|
|
1016
|
+
if (aSev !== bSev)
|
|
1017
|
+
return aSev - bSev;
|
|
1018
|
+
return b.confidence - a.confidence;
|
|
1019
|
+
});
|
|
1020
|
+
matches = merged;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
const verdict = computeVerdict(matches);
|
|
1024
|
+
let actionResults = Object.freeze([]);
|
|
1025
|
+
if (executor && verdict.actions.length > 0) {
|
|
1026
|
+
const context = Object.freeze({
|
|
1027
|
+
event,
|
|
1028
|
+
matches,
|
|
1029
|
+
verdict,
|
|
1030
|
+
sessionId: event.sessionId,
|
|
1031
|
+
metadata: event.metadata ? Object.freeze({ ...event.metadata }) : undefined,
|
|
1032
|
+
});
|
|
1033
|
+
actionResults = await executor.execute(context);
|
|
1034
|
+
}
|
|
1035
|
+
return { verdict, actionResults, layersUsed: Object.freeze(layersUsed) };
|
|
1036
|
+
}
|
|
1037
|
+
/** Get loaded rule count */
|
|
1038
|
+
getRuleCount() {
|
|
1039
|
+
return this.rules.length;
|
|
1040
|
+
}
|
|
1041
|
+
/** Get all loaded rules */
|
|
1042
|
+
getRules() {
|
|
1043
|
+
return this.rules;
|
|
1044
|
+
}
|
|
1045
|
+
/** Get a rule by ID */
|
|
1046
|
+
getRuleById(id) {
|
|
1047
|
+
return this.rules.find((r) => r.id === id);
|
|
1048
|
+
}
|
|
1049
|
+
/** Get rules by category */
|
|
1050
|
+
getRulesByCategory(category) {
|
|
1051
|
+
return this.rules.filter((r) => r.tags.category === category);
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Scan SKILL.md content for threats.
|
|
1055
|
+
* All rules fire with scanContext='skill':
|
|
1056
|
+
* - skill/both rules: native context, full confidence
|
|
1057
|
+
* - MCP-only rules: cross-context, confidence * 0.6
|
|
1058
|
+
* Also decodes base64 blocks and scans decoded content.
|
|
1059
|
+
* Code-block suppression and FP denylist applied in evaluate().
|
|
1060
|
+
*/
|
|
1061
|
+
scanSkill(content) {
|
|
1062
|
+
const baseEvent = {
|
|
1063
|
+
type: 'mcp_exchange',
|
|
1064
|
+
timestamp: new Date().toISOString(),
|
|
1065
|
+
sessionId: 'skill-scan',
|
|
1066
|
+
fields: {},
|
|
1067
|
+
scanContext: 'skill',
|
|
1068
|
+
};
|
|
1069
|
+
// Scan original content
|
|
1070
|
+
const matches = this.evaluate({ ...baseEvent, content });
|
|
1071
|
+
// Scan base64-decoded blocks for hidden payloads
|
|
1072
|
+
const decodedBlocks = decodeBase64Blocks(content);
|
|
1073
|
+
for (const block of decodedBlocks) {
|
|
1074
|
+
const blockMatches = this.evaluate({ ...baseEvent, content: block });
|
|
1075
|
+
for (const m of blockMatches) {
|
|
1076
|
+
// Tag decoded matches so consumers know the source
|
|
1077
|
+
matches.push({
|
|
1078
|
+
...m,
|
|
1079
|
+
matchedPatterns: [...m.matchedPatterns, '[decoded:base64]'],
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return matches;
|
|
1084
|
+
}
|
|
1085
|
+
/** Scan a SKILL.md file and return a unified ScanResult with content_hash. */
|
|
1086
|
+
scanSkillFull(content, filePath) {
|
|
1087
|
+
const matches = this.scanSkill(content);
|
|
1088
|
+
return {
|
|
1089
|
+
scan_type: 'skill',
|
|
1090
|
+
content_hash: computeContentHash(content),
|
|
1091
|
+
input_file: filePath,
|
|
1092
|
+
timestamp: new Date().toISOString(),
|
|
1093
|
+
rules_loaded: this.rules.length,
|
|
1094
|
+
matches,
|
|
1095
|
+
threat_count: matches.length,
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
/** Evaluate an MCP agent event and return a unified ScanResult with content_hash. */
|
|
1099
|
+
evaluateFull(event, filePath) {
|
|
1100
|
+
const matches = this.evaluate(event);
|
|
1101
|
+
// Hash content + fields to distinguish tool-call events with same content but different args
|
|
1102
|
+
const hashInput = event.fields
|
|
1103
|
+
? event.content + '\0' + JSON.stringify(event.fields)
|
|
1104
|
+
: event.content;
|
|
1105
|
+
return {
|
|
1106
|
+
scan_type: 'mcp',
|
|
1107
|
+
content_hash: computeContentHash(hashInput),
|
|
1108
|
+
input_file: filePath,
|
|
1109
|
+
timestamp: new Date().toISOString(),
|
|
1110
|
+
rules_loaded: this.rules.length,
|
|
1111
|
+
matches,
|
|
1112
|
+
threat_count: matches.length,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
function escapeRegex(str) {
|
|
1117
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Strip inline flags like (?i) from regex patterns.
|
|
1121
|
+
* JavaScript RegExp uses flags as a constructor parameter, not inline.
|
|
1122
|
+
*/
|
|
1123
|
+
function normalizeRegex(pattern) {
|
|
1124
|
+
return pattern.replace(/^\(\?[imsx]+\)/, '');
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Normalize Unicode text to NFC form and strip zero-width characters.
|
|
1128
|
+
* This prevents evasion via combining characters, zero-width joiners, etc.
|
|
1129
|
+
*/
|
|
1130
|
+
function normalizeUnicode(text) {
|
|
1131
|
+
return text
|
|
1132
|
+
.normalize('NFC')
|
|
1133
|
+
.replace(/[\u200B\u200C\u200D\uFEFF\u2060\u180E\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '');
|
|
1134
|
+
}
|
|
1135
|
+
/** Maximum input length for regex evaluation to mitigate ReDoS */
|
|
1136
|
+
const MAX_EVAL_LENGTH = 100_000;
|
|
1137
|
+
/**
|
|
1138
|
+
* Safely test a regex pattern against input with length limits.
|
|
1139
|
+
* Returns false if input exceeds MAX_EVAL_LENGTH to prevent ReDoS.
|
|
1140
|
+
*/
|
|
1141
|
+
function safeRegexTest(regex, input) {
|
|
1142
|
+
if (input.length > MAX_EVAL_LENGTH)
|
|
1143
|
+
return false;
|
|
1144
|
+
return regex.test(input);
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Build a set of character ranges that fall inside markdown code blocks.
|
|
1148
|
+
* Covers both fenced (``` ```) and inline (`code`) blocks.
|
|
1149
|
+
* Used to suppress false positives when regex matches documentation examples
|
|
1150
|
+
* rather than actual attack payloads.
|
|
1151
|
+
*/
|
|
1152
|
+
function buildCodeBlockRanges(text) {
|
|
1153
|
+
const ranges = [];
|
|
1154
|
+
// Fenced code blocks: ```...```
|
|
1155
|
+
const fenced = /```[\s\S]*?```/g;
|
|
1156
|
+
let m;
|
|
1157
|
+
while ((m = fenced.exec(text)) !== null) {
|
|
1158
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
1159
|
+
}
|
|
1160
|
+
// Inline code: `...` (but not inside fenced blocks)
|
|
1161
|
+
const inline = /`[^`\n]+`/g;
|
|
1162
|
+
while ((m = inline.exec(text)) !== null) {
|
|
1163
|
+
const pos = m.index;
|
|
1164
|
+
const inFenced = ranges.some(([start, end]) => pos >= start && pos < end);
|
|
1165
|
+
if (!inFenced) {
|
|
1166
|
+
ranges.push([pos, pos + m[0].length]);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return ranges;
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Check if a regex match position falls inside a code block.
|
|
1173
|
+
*/
|
|
1174
|
+
function isInsideCodeBlock(text, regex, codeRanges) {
|
|
1175
|
+
if (codeRanges.length === 0)
|
|
1176
|
+
return false;
|
|
1177
|
+
// Reset regex state and find match position
|
|
1178
|
+
const searchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g');
|
|
1179
|
+
const m = searchRegex.exec(text);
|
|
1180
|
+
if (!m)
|
|
1181
|
+
return false;
|
|
1182
|
+
const matchPos = m.index;
|
|
1183
|
+
return codeRanges.some(([start, end]) => matchPos >= start && matchPos < end);
|
|
1184
|
+
}
|
|
1185
|
+
//# sourceMappingURL=engine.js.map
|