@panguard-ai/atr 1.4.1 → 1.4.3

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.
Files changed (204) hide show
  1. package/.github/ISSUE_TEMPLATE/evasion-report.yml +75 -0
  2. package/.github/ISSUE_TEMPLATE/false-positive.yml +31 -0
  3. package/.github/ISSUE_TEMPLATE/mirofish-prediction.yml +128 -0
  4. package/.github/ISSUE_TEMPLATE/new-rule.yml +37 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +23 -0
  6. package/.github/workflows/rule-quality.yml +203 -0
  7. package/.github/workflows/validate.yml +42 -0
  8. package/CHANGELOG.md +30 -0
  9. package/CONTRIBUTING.md +168 -0
  10. package/CONTRIBUTORS.md +28 -0
  11. package/COVERAGE.md +135 -0
  12. package/LIMITATIONS.md +154 -0
  13. package/SECURITY.md +48 -0
  14. package/THREAT-MODEL.md +243 -0
  15. package/docs/contribution-paths.md +202 -0
  16. package/docs/mirofish-prediction-guide.md +304 -0
  17. package/docs/quick-start.md +245 -0
  18. package/docs/rule-writing-guide.md +647 -0
  19. package/docs/schema-spec.md +594 -0
  20. package/examples/how-to-write-a-rule.md +251 -0
  21. package/package.json +10 -57
  22. package/src/index.ts +7 -0
  23. package/tsconfig.json +17 -0
  24. package/dist/cli.d.ts +0 -14
  25. package/dist/cli.d.ts.map +0 -1
  26. package/dist/cli.js +0 -744
  27. package/dist/cli.js.map +0 -1
  28. package/dist/content-hash.d.ts +0 -7
  29. package/dist/content-hash.d.ts.map +0 -1
  30. package/dist/content-hash.js +0 -10
  31. package/dist/content-hash.js.map +0 -1
  32. package/dist/coverage-analyzer.d.ts +0 -43
  33. package/dist/coverage-analyzer.d.ts.map +0 -1
  34. package/dist/coverage-analyzer.js +0 -329
  35. package/dist/coverage-analyzer.js.map +0 -1
  36. package/dist/engine.d.ts +0 -136
  37. package/dist/engine.d.ts.map +0 -1
  38. package/dist/engine.js +0 -781
  39. package/dist/engine.js.map +0 -1
  40. package/dist/index.d.ts +0 -26
  41. package/dist/index.d.ts.map +0 -1
  42. package/dist/index.js +0 -18
  43. package/dist/index.js.map +0 -1
  44. package/dist/loader.d.ts +0 -21
  45. package/dist/loader.d.ts.map +0 -1
  46. package/dist/loader.js +0 -149
  47. package/dist/loader.js.map +0 -1
  48. package/dist/mcp-server.d.ts +0 -13
  49. package/dist/mcp-server.d.ts.map +0 -1
  50. package/dist/mcp-server.js +0 -244
  51. package/dist/mcp-server.js.map +0 -1
  52. package/dist/mcp-tools/coverage-gaps.d.ts +0 -13
  53. package/dist/mcp-tools/coverage-gaps.d.ts.map +0 -1
  54. package/dist/mcp-tools/coverage-gaps.js +0 -57
  55. package/dist/mcp-tools/coverage-gaps.js.map +0 -1
  56. package/dist/mcp-tools/list-rules.d.ts +0 -17
  57. package/dist/mcp-tools/list-rules.d.ts.map +0 -1
  58. package/dist/mcp-tools/list-rules.js +0 -45
  59. package/dist/mcp-tools/list-rules.js.map +0 -1
  60. package/dist/mcp-tools/scan.d.ts +0 -18
  61. package/dist/mcp-tools/scan.d.ts.map +0 -1
  62. package/dist/mcp-tools/scan.js +0 -87
  63. package/dist/mcp-tools/scan.js.map +0 -1
  64. package/dist/mcp-tools/submit-proposal.d.ts +0 -12
  65. package/dist/mcp-tools/submit-proposal.d.ts.map +0 -1
  66. package/dist/mcp-tools/submit-proposal.js +0 -116
  67. package/dist/mcp-tools/submit-proposal.js.map +0 -1
  68. package/dist/mcp-tools/threat-summary.d.ts +0 -12
  69. package/dist/mcp-tools/threat-summary.d.ts.map +0 -1
  70. package/dist/mcp-tools/threat-summary.js +0 -72
  71. package/dist/mcp-tools/threat-summary.js.map +0 -1
  72. package/dist/mcp-tools/validate.d.ts +0 -15
  73. package/dist/mcp-tools/validate.d.ts.map +0 -1
  74. package/dist/mcp-tools/validate.js +0 -57
  75. package/dist/mcp-tools/validate.js.map +0 -1
  76. package/dist/modules/index.d.ts +0 -144
  77. package/dist/modules/index.d.ts.map +0 -1
  78. package/dist/modules/index.js +0 -82
  79. package/dist/modules/index.js.map +0 -1
  80. package/dist/modules/semantic.d.ts +0 -105
  81. package/dist/modules/semantic.d.ts.map +0 -1
  82. package/dist/modules/semantic.js +0 -289
  83. package/dist/modules/semantic.js.map +0 -1
  84. package/dist/modules/session.d.ts +0 -70
  85. package/dist/modules/session.d.ts.map +0 -1
  86. package/dist/modules/session.js +0 -163
  87. package/dist/modules/session.js.map +0 -1
  88. package/dist/rule-scaffolder.d.ts +0 -39
  89. package/dist/rule-scaffolder.d.ts.map +0 -1
  90. package/dist/rule-scaffolder.js +0 -171
  91. package/dist/rule-scaffolder.js.map +0 -1
  92. package/dist/session-tracker.d.ts +0 -56
  93. package/dist/session-tracker.d.ts.map +0 -1
  94. package/dist/session-tracker.js +0 -175
  95. package/dist/session-tracker.js.map +0 -1
  96. package/dist/skill-fingerprint.d.ts +0 -96
  97. package/dist/skill-fingerprint.d.ts.map +0 -1
  98. package/dist/skill-fingerprint.js +0 -336
  99. package/dist/skill-fingerprint.js.map +0 -1
  100. package/dist/types.d.ts +0 -211
  101. package/dist/types.d.ts.map +0 -1
  102. package/dist/types.js +0 -6
  103. package/dist/types.js.map +0 -1
  104. package/rules/agent-manipulation/ATR-2026-00030-cross-agent-attack.yaml +0 -177
  105. package/rules/agent-manipulation/ATR-2026-00032-goal-hijacking.yaml +0 -137
  106. package/rules/agent-manipulation/ATR-2026-00074-cross-agent-privilege-escalation.yaml +0 -117
  107. package/rules/agent-manipulation/ATR-2026-00076-inter-agent-message-spoofing.yaml +0 -167
  108. package/rules/agent-manipulation/ATR-2026-00077-human-trust-exploitation.yaml +0 -146
  109. package/rules/agent-manipulation/ATR-2026-00108-consensus-sybil-attack.yaml +0 -105
  110. package/rules/agent-manipulation/ATR-2026-00116-a2a-message-validation.yaml +0 -92
  111. package/rules/agent-manipulation/ATR-2026-00117-agent-identity-spoofing.yaml +0 -92
  112. package/rules/agent-manipulation/ATR-2026-00118-approval-fatigue.yaml +0 -89
  113. package/rules/agent-manipulation/ATR-2026-00119-social-engineering-via-agent.yaml +0 -89
  114. package/rules/agent-manipulation/ATR-2026-00132-casual-authority-escalation.yaml +0 -99
  115. package/rules/agent-manipulation/ATR-2026-00139-casual-authority-redirect.yaml +0 -53
  116. package/rules/context-exfiltration/ATR-2026-00020-system-prompt-leak.yaml +0 -177
  117. package/rules/context-exfiltration/ATR-2026-00021-api-key-exposure.yaml +0 -178
  118. package/rules/context-exfiltration/ATR-2026-00075-agent-memory-manipulation.yaml +0 -117
  119. package/rules/context-exfiltration/ATR-2026-00102-disguised-analytics-exfiltration.yaml +0 -71
  120. package/rules/context-exfiltration/ATR-2026-00113-credential-theft.yaml +0 -89
  121. package/rules/context-exfiltration/ATR-2026-00114-oauth-token-abuse.yaml +0 -89
  122. package/rules/context-exfiltration/ATR-2026-00115-env-var-harvesting.yaml +0 -90
  123. package/rules/context-exfiltration/ATR-2026-00136-tool-response-data-piggyback.yaml +0 -100
  124. package/rules/context-exfiltration/ATR-2026-00141-example-format-key-leak.yaml +0 -52
  125. package/rules/context-exfiltration/ATR-2026-00142-piggyback-transition-words.yaml +0 -55
  126. package/rules/context-exfiltration/ATR-2026-00145-obfuscated-key-disclosure.yaml +0 -49
  127. package/rules/context-exfiltration/ATR-2026-00146-env-var-existence-probe.yaml +0 -49
  128. package/rules/data-poisoning/ATR-2026-00070-data-poisoning.yaml +0 -162
  129. package/rules/excessive-autonomy/ATR-2026-00050-runaway-agent-loop.yaml +0 -136
  130. package/rules/excessive-autonomy/ATR-2026-00051-resource-exhaustion.yaml +0 -139
  131. package/rules/excessive-autonomy/ATR-2026-00052-cascading-failure.yaml +0 -155
  132. package/rules/excessive-autonomy/ATR-2026-00098-unauthorized-financial-action.yaml +0 -157
  133. package/rules/excessive-autonomy/ATR-2026-00099-high-risk-tool-gate.yaml +0 -176
  134. package/rules/model-security/ATR-2026-00072-model-behavior-extraction.yaml +0 -117
  135. package/rules/model-security/ATR-2026-00073-malicious-finetuning-data.yaml +0 -110
  136. package/rules/privilege-escalation/ATR-2026-00040-privilege-escalation.yaml +0 -177
  137. package/rules/privilege-escalation/ATR-2026-00041-scope-creep.yaml +0 -126
  138. package/rules/privilege-escalation/ATR-2026-00107-delayed-execution-bypass.yaml +0 -69
  139. package/rules/privilege-escalation/ATR-2026-00110-eval-injection.yaml +0 -92
  140. package/rules/privilege-escalation/ATR-2026-00111-shell-escape.yaml +0 -93
  141. package/rules/privilege-escalation/ATR-2026-00112-dynamic-import-exploitation.yaml +0 -89
  142. package/rules/privilege-escalation/ATR-2026-00143-casual-privilege-escalation.yaml +0 -53
  143. package/rules/privilege-escalation/ATR-2026-00144-rationalized-safety-bypass.yaml +0 -49
  144. package/rules/prompt-injection/ATR-2026-00001-direct-prompt-injection.yaml +0 -563
  145. package/rules/prompt-injection/ATR-2026-00002-indirect-prompt-injection.yaml +0 -216
  146. package/rules/prompt-injection/ATR-2026-00003-jailbreak-attempt.yaml +0 -397
  147. package/rules/prompt-injection/ATR-2026-00004-system-prompt-override.yaml +0 -308
  148. package/rules/prompt-injection/ATR-2026-00005-multi-turn-injection.yaml +0 -183
  149. package/rules/prompt-injection/ATR-2026-00080-encoding-evasion.yaml +0 -88
  150. package/rules/prompt-injection/ATR-2026-00081-semantic-multi-turn.yaml +0 -85
  151. package/rules/prompt-injection/ATR-2026-00082-fingerprint-evasion.yaml +0 -84
  152. package/rules/prompt-injection/ATR-2026-00083-indirect-tool-injection.yaml +0 -87
  153. package/rules/prompt-injection/ATR-2026-00084-structured-data-injection.yaml +0 -86
  154. package/rules/prompt-injection/ATR-2026-00085-audit-evasion.yaml +0 -84
  155. package/rules/prompt-injection/ATR-2026-00086-visual-spoofing.yaml +0 -88
  156. package/rules/prompt-injection/ATR-2026-00087-rule-probing.yaml +0 -82
  157. package/rules/prompt-injection/ATR-2026-00088-adaptive-countermeasure.yaml +0 -84
  158. package/rules/prompt-injection/ATR-2026-00089-polymorphic-skill.yaml +0 -85
  159. package/rules/prompt-injection/ATR-2026-00090-threat-intel-exfil.yaml +0 -84
  160. package/rules/prompt-injection/ATR-2026-00091-nested-payload.yaml +0 -88
  161. package/rules/prompt-injection/ATR-2026-00092-consensus-poisoning.yaml +0 -92
  162. package/rules/prompt-injection/ATR-2026-00093-gradual-escalation.yaml +0 -86
  163. package/rules/prompt-injection/ATR-2026-00094-audit-bypass.yaml +0 -86
  164. package/rules/prompt-injection/ATR-2026-00097-cjk-injection-patterns.yaml +0 -339
  165. package/rules/prompt-injection/ATR-2026-00104-persona-hijacking.yaml +0 -74
  166. package/rules/prompt-injection/ATR-2026-00130-indirect-authority-claim.yaml +0 -97
  167. package/rules/prompt-injection/ATR-2026-00131-fictional-academic-framing.yaml +0 -93
  168. package/rules/prompt-injection/ATR-2026-00133-paraphrase-injection.yaml +0 -111
  169. package/rules/prompt-injection/ATR-2026-00137-authority-claim-injection.yaml +0 -52
  170. package/rules/prompt-injection/ATR-2026-00138-fictional-framing-bypass.yaml +0 -51
  171. package/rules/prompt-injection/ATR-2026-00140-indirect-reference-reversal.yaml +0 -52
  172. package/rules/prompt-injection/ATR-2026-00148-language-switch-injection.yaml +0 -71
  173. package/rules/skill-compromise/ATR-2026-00060-skill-impersonation.yaml +0 -155
  174. package/rules/skill-compromise/ATR-2026-00061-description-behavior-mismatch.yaml +0 -100
  175. package/rules/skill-compromise/ATR-2026-00062-hidden-capability.yaml +0 -98
  176. package/rules/skill-compromise/ATR-2026-00063-skill-chain-attack.yaml +0 -99
  177. package/rules/skill-compromise/ATR-2026-00064-over-permissioned-skill.yaml +0 -117
  178. package/rules/skill-compromise/ATR-2026-00065-skill-update-attack.yaml +0 -95
  179. package/rules/skill-compromise/ATR-2026-00066-parameter-injection.yaml +0 -108
  180. package/rules/skill-compromise/ATR-2026-00120-skill-instruction-injection.yaml +0 -121
  181. package/rules/skill-compromise/ATR-2026-00121-skill-dangerous-script.yaml +0 -165
  182. package/rules/skill-compromise/ATR-2026-00122-skill-weaponized-instruction.yaml +0 -114
  183. package/rules/skill-compromise/ATR-2026-00123-skill-overreach-permissions.yaml +0 -118
  184. package/rules/skill-compromise/ATR-2026-00124-skill-name-squatting.yaml +0 -98
  185. package/rules/skill-compromise/ATR-2026-00125-context-poisoning-compaction.yaml +0 -93
  186. package/rules/skill-compromise/ATR-2026-00126-skill-rug-pull-setup.yaml +0 -99
  187. package/rules/skill-compromise/ATR-2026-00127-subcommand-overflow.yaml +0 -74
  188. package/rules/skill-compromise/ATR-2026-00128-html-comment-hidden-payload.yaml +0 -79
  189. package/rules/skill-compromise/ATR-2026-00129-unicode-smuggling.yaml +0 -73
  190. package/rules/skill-compromise/ATR-2026-00134-fork-claim-impersonation.yaml +0 -86
  191. package/rules/skill-compromise/ATR-2026-00135-exfil-url-in-instructions.yaml +0 -82
  192. package/rules/skill-compromise/ATR-2026-00147-fork-impersonation.yaml +0 -48
  193. package/rules/tool-poisoning/ATR-2026-00010-mcp-malicious-response.yaml +0 -239
  194. package/rules/tool-poisoning/ATR-2026-00011-tool-output-injection.yaml +0 -196
  195. package/rules/tool-poisoning/ATR-2026-00012-unauthorized-tool-call.yaml +0 -201
  196. package/rules/tool-poisoning/ATR-2026-00013-tool-ssrf.yaml +0 -219
  197. package/rules/tool-poisoning/ATR-2026-00095-supply-chain-poisoning.yaml +0 -93
  198. package/rules/tool-poisoning/ATR-2026-00096-registry-poisoning.yaml +0 -95
  199. package/rules/tool-poisoning/ATR-2026-00100-consent-bypass-instruction.yaml +0 -82
  200. package/rules/tool-poisoning/ATR-2026-00101-trust-escalation-override.yaml +0 -68
  201. package/rules/tool-poisoning/ATR-2026-00103-hidden-safety-bypass-instruction.yaml +0 -73
  202. package/rules/tool-poisoning/ATR-2026-00105-silent-action-concealment.yaml +0 -69
  203. package/rules/tool-poisoning/ATR-2026-00106-schema-description-contradiction.yaml +0 -68
  204. package/spec/atr-schema.yaml +0 -404
package/dist/engine.js DELETED
@@ -1,781 +0,0 @@
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 { loadRulesFromDirectory, loadRuleFile } from './loader.js';
15
- /**
16
- * Rules excluded from skill-context scanning due to high false-positive rate.
17
- * Threshold: >2% FP on 250 benign SKILL.md files.
18
- */
19
- const SKILL_CONTEXT_DENYLIST = new Set([
20
- 'ATR-2026-00111', // Shell Escape — 95.2% FP
21
- 'ATR-2026-00118', // Approval Fatigue — 84.8% FP
22
- 'ATR-2026-00051', // Resource Exhaustion — 1.6% FP
23
- 'ATR-2026-00030', // Cross-Agent Attack — 0.8% FP
24
- 'ATR-2026-00002', // Indirect Prompt Injection — 0.8% FP
25
- 'ATR-2026-00050', // Runaway Agent Loop — 0.4% FP
26
- 'ATR-2026-00117', // Agent Identity Spoofing — 0.4% FP
27
- 'ATR-2026-00116', // A2A Message Injection — 0.4% FP
28
- ]);
29
- /**
30
- * Detect and decode base64-encoded blocks in content.
31
- * Max 1 level, max 5 blocks, min 32 chars, text-only.
32
- */
33
- const BASE64_BLOCK_RE = /(?:[A-Za-z0-9+/]{32,}={0,2})/g;
34
- const MAX_DECODE_BLOCKS = 5;
35
- function decodeBase64Blocks(content) {
36
- const decoded = [];
37
- let match;
38
- let count = 0;
39
- while ((match = BASE64_BLOCK_RE.exec(content)) !== null && count < MAX_DECODE_BLOCKS) {
40
- try {
41
- const raw = Buffer.from(match[0], 'base64');
42
- const text = raw.toString('utf-8');
43
- const printable = text.split('').filter(c => c.charCodeAt(0) >= 32 && c.charCodeAt(0) < 127).length;
44
- if (printable / text.length > 0.7 && text.length >= 10) {
45
- decoded.push(text.slice(0, 100_000));
46
- count++;
47
- }
48
- }
49
- catch { /* skip */ }
50
- }
51
- return decoded;
52
- }
53
- /** Map agent event types to ATR source types */
54
- const EVENT_TYPE_TO_SOURCE = {
55
- llm_input: 'llm_io',
56
- llm_output: 'llm_io',
57
- tool_call: 'tool_call',
58
- tool_response: 'mcp_exchange',
59
- agent_behavior: 'agent_behavior',
60
- multi_agent_message: 'multi_agent_comm',
61
- };
62
- /** Map agent event types to default field names */
63
- const EVENT_TYPE_TO_FIELD = {
64
- llm_input: 'user_input',
65
- llm_output: 'agent_output',
66
- tool_call: 'tool_name',
67
- tool_response: 'tool_response',
68
- agent_behavior: 'metric',
69
- multi_agent_message: 'agent_message',
70
- };
71
- export class ATREngine {
72
- config;
73
- rules = [];
74
- compiledPatterns = new Map();
75
- constructor(config = {}) {
76
- this.config = config;
77
- }
78
- /**
79
- * Load rules from configured directory and/or pre-loaded rules.
80
- */
81
- async loadRules() {
82
- this.rules = [];
83
- this.compiledPatterns.clear();
84
- if (this.config.rules) {
85
- this.rules.push(...this.config.rules);
86
- }
87
- if (this.config.rulesDir) {
88
- try {
89
- const fileRules = loadRulesFromDirectory(this.config.rulesDir);
90
- this.rules.push(...fileRules);
91
- }
92
- catch {
93
- // Directory may not exist yet
94
- }
95
- }
96
- // Pre-compile regex patterns for performance
97
- for (const rule of this.rules) {
98
- this.compilePatterns(rule);
99
- }
100
- return this.rules.length;
101
- }
102
- /**
103
- * Load a single rule file and add it to the engine.
104
- */
105
- addRuleFile(filePath) {
106
- const rule = loadRuleFile(filePath);
107
- this.rules.push(rule);
108
- this.compilePatterns(rule);
109
- }
110
- /**
111
- * Add a pre-parsed rule to the engine.
112
- */
113
- addRule(rule) {
114
- this.rules.push(rule);
115
- this.compilePatterns(rule);
116
- }
117
- /**
118
- * Evaluate an agent event against all loaded ATR rules.
119
- * Returns all matching rules with details.
120
- */
121
- evaluate(event) {
122
- const matches = [];
123
- const eventSourceType = EVENT_TYPE_TO_SOURCE[event.type];
124
- const allMatchedPatterns = [];
125
- const isSkillContext = event.scanContext === 'skill';
126
- for (const rule of this.rules) {
127
- // Skip deprecated and draft rules
128
- if (rule.status === 'deprecated' || rule.status === 'draft')
129
- continue;
130
- // Skill context denylist: skip rules known to cause high FP on SKILL.md
131
- if (isSkillContext && SKILL_CONTEXT_DENYLIST.has(rule.id))
132
- continue;
133
- // Source type filtering: skip rules that don't apply to this event type
134
- // When scanContext is 'skill', all rules fire (cross-context)
135
- if (!isSkillContext && eventSourceType && rule.agent_source.type !== eventSourceType) {
136
- // Allow mcp_exchange rules to also match tool_call events
137
- if (!(rule.agent_source.type === 'mcp_exchange' && eventSourceType === 'tool_call')) {
138
- continue;
139
- }
140
- }
141
- const matchResult = this.evaluateRule(rule, event);
142
- if (matchResult) {
143
- // Cross-context: MCP-only rules on SKILL.md get confidence downweight
144
- if (isSkillContext && rule.tags.scan_target !== 'skill' && rule.tags.scan_target !== 'both') {
145
- matches.push({
146
- ...matchResult,
147
- confidence: matchResult.confidence * 0.6,
148
- scan_context: 'cross-context',
149
- });
150
- }
151
- else {
152
- matches.push(matchResult);
153
- }
154
- allMatchedPatterns.push(...matchResult.matchedPatterns);
155
- }
156
- }
157
- // Record the event in the session tracker if available
158
- const sessionId = event.sessionId;
159
- if (this.config.sessionTracker && sessionId) {
160
- this.config.sessionTracker.recordEvent(sessionId, event, allMatchedPatterns);
161
- }
162
- // Sort by severity (critical first) then confidence
163
- return matches.sort((a, b) => {
164
- const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, informational: 4 };
165
- const aSev = severityOrder[a.rule.severity] ?? 4;
166
- const bSev = severityOrder[b.rule.severity] ?? 4;
167
- if (aSev !== bSev)
168
- return aSev - bSev;
169
- return b.confidence - a.confidence;
170
- });
171
- }
172
- /**
173
- * Evaluate a single rule against an event.
174
- * Supports both array-format and named-map-format conditions.
175
- */
176
- evaluateRule(rule, event) {
177
- const { detection } = rule;
178
- const conditions = detection.conditions;
179
- const allMatchedPatterns = [];
180
- // Detect format: array or named map
181
- if (Array.isArray(conditions)) {
182
- return this.evaluateArrayConditions(rule, conditions, detection.condition, event, allMatchedPatterns);
183
- }
184
- return this.evaluateNamedConditions(rule, conditions, detection.condition, event, allMatchedPatterns);
185
- }
186
- /**
187
- * Evaluate array-format conditions: [{field, operator, value}, ...]
188
- * with condition: "any" | "all"
189
- */
190
- evaluateArrayConditions(rule, conditions, conditionExpr, event, allMatchedPatterns) {
191
- const matchedConditionIndices = [];
192
- const isAny = conditionExpr === 'any' || conditionExpr === 'or';
193
- for (let i = 0; i < conditions.length; i++) {
194
- const cond = conditions[i];
195
- const result = this.evaluateArrayCondition(cond, event, rule.id, i, allMatchedPatterns);
196
- if (result) {
197
- matchedConditionIndices.push(i);
198
- if (isAny)
199
- break; // Short-circuit on first match for "any"
200
- }
201
- }
202
- const matched = isAny
203
- ? matchedConditionIndices.length > 0
204
- : matchedConditionIndices.length === conditions.length;
205
- if (!matched)
206
- return null;
207
- const baseConfidence = rule.tags.confidence === 'high' ? 0.9 : rule.tags.confidence === 'medium' ? 0.7 : 0.5;
208
- const matchRatio = matchedConditionIndices.length / Math.max(conditions.length, 1);
209
- const confidence = Math.min(baseConfidence + matchRatio * 0.1, 1.0);
210
- return {
211
- rule,
212
- matchedConditions: matchedConditionIndices.map(String),
213
- matchedPatterns: allMatchedPatterns,
214
- confidence,
215
- timestamp: new Date().toISOString(),
216
- scan_context: 'native',
217
- };
218
- }
219
- /**
220
- * Evaluate a single array-format condition {field, operator, value}.
221
- */
222
- evaluateArrayCondition(cond, event, ruleId, index, matchedPatterns) {
223
- const field = cond['field'];
224
- const operator = cond['operator'];
225
- const value = cond['value'];
226
- if (!field || !operator || value === undefined)
227
- return false;
228
- const rawFieldValue = this.resolveField(field, event);
229
- if (!rawFieldValue)
230
- return false;
231
- const fieldValue = normalizeUnicode(rawFieldValue);
232
- switch (operator) {
233
- case 'regex': {
234
- // Try pre-compiled pattern first
235
- const compiled = this.compiledPatterns.get(ruleId)?.get(String(index));
236
- if (compiled && compiled.length > 0) {
237
- // Test against both normalized and raw values so that patterns
238
- // detecting zero-width/bidi characters can match before stripping
239
- if (safeRegexTest(compiled[0], fieldValue) ||
240
- safeRegexTest(compiled[0], rawFieldValue)) {
241
- matchedPatterns.push(value);
242
- return true;
243
- }
244
- return false;
245
- }
246
- // Fallback: compile on the fly
247
- try {
248
- const normalized = normalizeRegex(value);
249
- const rFlags = normalized.includes('\\u{') || normalized.includes('\\p{') ? 'iu' : 'i';
250
- const regex = new RegExp(normalized, rFlags);
251
- if (safeRegexTest(regex, fieldValue) || safeRegexTest(regex, rawFieldValue)) {
252
- matchedPatterns.push(value);
253
- return true;
254
- }
255
- }
256
- catch {
257
- // Invalid regex
258
- }
259
- return false;
260
- }
261
- case 'contains': {
262
- if (fieldValue.toLowerCase().includes(value.toLowerCase())) {
263
- matchedPatterns.push(value);
264
- return true;
265
- }
266
- return false;
267
- }
268
- case 'exact': {
269
- if (fieldValue === value) {
270
- matchedPatterns.push(value);
271
- return true;
272
- }
273
- return false;
274
- }
275
- case 'starts_with': {
276
- if (fieldValue.toLowerCase().startsWith(value.toLowerCase())) {
277
- matchedPatterns.push(value);
278
- return true;
279
- }
280
- return false;
281
- }
282
- default:
283
- return false;
284
- }
285
- }
286
- /**
287
- * Evaluate named-map-format conditions: {name: {field, patterns, match_type}, ...}
288
- * with condition: "name1 AND name2" | "name1 OR name2" | "name1"
289
- */
290
- evaluateNamedConditions(rule, conditions, conditionExpr, event, allMatchedPatterns) {
291
- const conditionResults = new Map();
292
- const matchedConditionNames = [];
293
- for (const [condName, condDef] of Object.entries(conditions)) {
294
- const result = this.evaluateNamedCondition(condName, condDef, event, rule, allMatchedPatterns);
295
- conditionResults.set(condName, result);
296
- if (result) {
297
- matchedConditionNames.push(condName);
298
- }
299
- }
300
- // Evaluate the boolean expression
301
- const finalResult = this.evaluateExpression(conditionExpr, conditionResults);
302
- if (!finalResult)
303
- return null;
304
- const baseConfidence = rule.tags.confidence === 'high' ? 0.9 : rule.tags.confidence === 'medium' ? 0.7 : 0.5;
305
- const matchRatio = matchedConditionNames.length / Math.max(Object.keys(conditions).length, 1);
306
- const confidence = Math.min(baseConfidence + matchRatio * 0.1, 1.0);
307
- return {
308
- rule,
309
- matchedConditions: matchedConditionNames,
310
- matchedPatterns: allMatchedPatterns,
311
- confidence,
312
- timestamp: new Date().toISOString(),
313
- scan_context: 'native',
314
- };
315
- }
316
- /**
317
- * Evaluate a single named condition against an event.
318
- */
319
- evaluateNamedCondition(condName, condDef, event, rule, matchedPatterns) {
320
- const cond = condDef;
321
- // Pattern matching condition (named format with patterns array)
322
- if (cond['patterns'] && cond['field']) {
323
- return this.evaluatePatternCondition(cond, event, rule.id, condName, matchedPatterns);
324
- }
325
- // Behavioral condition
326
- if (cond['metric'] && cond['operator'] && cond['threshold'] !== undefined) {
327
- return this.evaluateBehavioralCondition(cond, event);
328
- }
329
- // Sequence condition
330
- if (cond['steps'] && Array.isArray(cond['steps'])) {
331
- return this.evaluateSequenceCondition(cond, event);
332
- }
333
- return false;
334
- }
335
- /**
336
- * Evaluate a pattern matching condition (named format with patterns array).
337
- */
338
- evaluatePatternCondition(cond, event, ruleId, condName, matchedPatterns) {
339
- const rawFieldValue = this.resolveField(cond.field, event);
340
- if (!rawFieldValue)
341
- return false;
342
- const fieldValue = normalizeUnicode(rawFieldValue);
343
- // Code block suppression: skip matches inside markdown code blocks
344
- // for rules that commonly false-positive on documentation content.
345
- // Prompt injection rules are NOT suppressed.
346
- const suppressInCodeBlocks = this.shouldSuppressInCodeBlocks(ruleId);
347
- const codeRanges = suppressInCodeBlocks ? buildCodeBlockRanges(fieldValue) : [];
348
- // Get pre-compiled patterns
349
- const compiled = this.compiledPatterns.get(ruleId)?.get(condName);
350
- if (compiled) {
351
- for (let i = 0; i < compiled.length; i++) {
352
- if (safeRegexTest(compiled[i], fieldValue)) {
353
- if (suppressInCodeBlocks &&
354
- codeRanges.length > 0 &&
355
- isInsideCodeBlock(fieldValue, compiled[i], codeRanges)) {
356
- continue;
357
- }
358
- matchedPatterns.push(cond.patterns[i] ?? 'unknown');
359
- return true;
360
- }
361
- }
362
- return false;
363
- }
364
- // Fallback: direct string matching
365
- const checkValue = cond.case_sensitive ? fieldValue : fieldValue.toLowerCase();
366
- for (const pattern of cond.patterns) {
367
- const checkPattern = cond.case_sensitive ? pattern : pattern.toLowerCase();
368
- switch (cond.match_type) {
369
- case 'contains':
370
- if (checkValue.includes(checkPattern)) {
371
- matchedPatterns.push(pattern);
372
- return true;
373
- }
374
- break;
375
- case 'exact':
376
- if (checkValue === checkPattern) {
377
- matchedPatterns.push(pattern);
378
- return true;
379
- }
380
- break;
381
- case 'starts_with':
382
- if (checkValue.startsWith(checkPattern)) {
383
- matchedPatterns.push(pattern);
384
- return true;
385
- }
386
- break;
387
- case 'regex':
388
- default: {
389
- try {
390
- const flags = cond.case_sensitive ? '' : 'i';
391
- const regex = new RegExp(pattern, flags);
392
- if (safeRegexTest(regex, fieldValue)) {
393
- matchedPatterns.push(pattern);
394
- return true;
395
- }
396
- }
397
- catch {
398
- // Invalid regex, skip
399
- }
400
- break;
401
- }
402
- }
403
- }
404
- return false;
405
- }
406
- shouldSuppressInCodeBlocks(ruleId) {
407
- const rule = this.rules.find((r) => r.id === ruleId);
408
- if (!rule)
409
- return false;
410
- const category = rule.tags?.category ?? '';
411
- const suppressCategories = ['privilege-escalation', 'context-exfiltration', 'skill-compromise'];
412
- return suppressCategories.includes(category);
413
- }
414
- /**
415
- * Evaluate a behavioral threshold condition.
416
- * When a session tracker is available and the event has a sessionId,
417
- * supports session-derived metrics: call_frequency, pattern_frequency, event_count.
418
- */
419
- evaluateBehavioralCondition(cond, event) {
420
- const metricValue = this.resolveMetricValue(cond, event);
421
- if (metricValue === undefined)
422
- return false;
423
- switch (cond.operator) {
424
- case 'gt':
425
- return metricValue > cond.threshold;
426
- case 'lt':
427
- return metricValue < cond.threshold;
428
- case 'eq':
429
- return metricValue === cond.threshold;
430
- case 'gte':
431
- return metricValue >= cond.threshold;
432
- case 'lte':
433
- return metricValue <= cond.threshold;
434
- case 'deviation_from_baseline':
435
- return Math.abs(metricValue) > cond.threshold;
436
- default:
437
- return false;
438
- }
439
- }
440
- /**
441
- * Resolve a metric value from event metrics or session tracker.
442
- * Session-derived metrics use the format: "call_frequency:toolName" or "pattern_frequency:pattern".
443
- */
444
- resolveMetricValue(cond, event) {
445
- // Check event-level metrics first
446
- const directValue = event.metrics?.[cond.metric];
447
- if (directValue !== undefined)
448
- return directValue;
449
- // Try session tracker for session-derived metrics
450
- const tracker = this.config.sessionTracker;
451
- const sessionId = event.sessionId;
452
- if (!tracker || !sessionId)
453
- return undefined;
454
- const windowMs = this.parseWindowMs(cond.window);
455
- if (cond.metric.startsWith('call_frequency:')) {
456
- const toolName = cond.metric.slice('call_frequency:'.length);
457
- return tracker.getCallFrequency(sessionId, toolName, windowMs);
458
- }
459
- if (cond.metric.startsWith('pattern_frequency:')) {
460
- const pattern = cond.metric.slice('pattern_frequency:'.length);
461
- return tracker.getPatternFrequency(sessionId, pattern, windowMs);
462
- }
463
- if (cond.metric === 'event_count') {
464
- return tracker.getEventCount(sessionId, windowMs);
465
- }
466
- return undefined;
467
- }
468
- /**
469
- * Parse a window string (e.g. "5m", "1h", "30s") to milliseconds.
470
- * Defaults to 5 minutes if not specified or unparseable.
471
- */
472
- parseWindowMs(window) {
473
- if (!window)
474
- return 5 * 60 * 1000;
475
- const match = window.match(/^(\d+)\s*(s|m|h)$/);
476
- if (!match)
477
- return 5 * 60 * 1000;
478
- const value = parseInt(match[1], 10);
479
- const unit = match[2];
480
- switch (unit) {
481
- case 's':
482
- return value * 1000;
483
- case 'm':
484
- return value * 60 * 1000;
485
- case 'h':
486
- return value * 60 * 60 * 1000;
487
- default:
488
- return 5 * 60 * 1000;
489
- }
490
- }
491
- /**
492
- * Evaluate a sequence condition against the current event.
493
- *
494
- * Limitation (v0.1): This checks whether patterns from multiple steps
495
- * co-occur in the current event's content. It does NOT track ordered
496
- * execution across separate events or enforce time windows.
497
- * Full session-aware sequence detection is planned for v0.2.
498
- */
499
- evaluateSequenceCondition(cond, event) {
500
- const steps = cond['steps'];
501
- if (!steps || steps.length === 0)
502
- return false;
503
- const content = normalizeUnicode(event.content);
504
- let matchCount = 0;
505
- for (const step of steps) {
506
- const patterns = step['patterns'];
507
- if (patterns) {
508
- for (const pattern of patterns) {
509
- try {
510
- const regex = new RegExp(pattern, 'i');
511
- if (safeRegexTest(regex, content)) {
512
- matchCount++;
513
- break;
514
- }
515
- }
516
- catch {
517
- // Invalid regex
518
- }
519
- }
520
- }
521
- }
522
- return matchCount >= 2;
523
- }
524
- /**
525
- * Resolve a field value from an agent event.
526
- */
527
- resolveField(fieldName, event) {
528
- // Check explicit fields first
529
- if (event.fields?.[fieldName]) {
530
- return event.fields[fieldName];
531
- }
532
- // Map standard field names to event properties
533
- const defaultField = EVENT_TYPE_TO_FIELD[event.type];
534
- if (fieldName === defaultField || fieldName === 'content') {
535
- return event.content;
536
- }
537
- // Common field aliases
538
- switch (fieldName) {
539
- case 'user_input':
540
- return event.type === 'llm_input' ? event.content : event.fields?.['user_input'];
541
- case 'agent_output':
542
- return event.type === 'llm_output' ? event.content : event.fields?.['agent_output'];
543
- case 'tool_response':
544
- return event.type === 'tool_response' ? event.content : event.fields?.['tool_response'];
545
- case 'tool_name':
546
- return (event.fields?.['tool_name'] ?? (event.type === 'tool_call' ? event.content : undefined));
547
- case 'tool_args':
548
- return event.fields?.['tool_args'];
549
- case 'agent_message':
550
- return event.type === 'multi_agent_message'
551
- ? event.content
552
- : event.fields?.['agent_message'];
553
- default:
554
- // Try metadata
555
- return event.metadata?.[fieldName];
556
- }
557
- }
558
- /**
559
- * Evaluate a boolean expression string against condition results.
560
- * Supports AND, OR, NOT operators.
561
- */
562
- evaluateExpression(expression, results) {
563
- const expr = expression.trim();
564
- // Simple single condition
565
- if (results.has(expr)) {
566
- return results.get(expr) ?? false;
567
- }
568
- // Handle NOT
569
- if (expr.startsWith('NOT ') || expr.startsWith('not ')) {
570
- const inner = expr.slice(4).trim();
571
- return !this.evaluateExpression(inner, results);
572
- }
573
- // Handle OR (lower precedence — split first so AND binds tighter)
574
- const orParts = this.splitByOperator(expr, 'OR');
575
- if (orParts.length > 1) {
576
- return orParts.some((part) => this.evaluateExpression(part, results));
577
- }
578
- // Handle AND (higher precedence — evaluated within each OR branch)
579
- const andParts = this.splitByOperator(expr, 'AND');
580
- if (andParts.length > 1) {
581
- return andParts.every((part) => this.evaluateExpression(part, results));
582
- }
583
- // Handle parentheses
584
- if (expr.startsWith('(') && expr.endsWith(')')) {
585
- return this.evaluateExpression(expr.slice(1, -1), results);
586
- }
587
- // Default: treat as condition name
588
- return results.get(expr) ?? false;
589
- }
590
- /**
591
- * Split expression by operator, respecting parentheses.
592
- */
593
- splitByOperator(expr, operator) {
594
- const parts = [];
595
- let depth = 0;
596
- let current = '';
597
- const op = ` ${operator} `;
598
- const opLower = ` ${operator.toLowerCase()} `;
599
- for (let i = 0; i < expr.length; i++) {
600
- const char = expr[i];
601
- if (char === '(')
602
- depth++;
603
- if (char === ')')
604
- depth--;
605
- if (depth === 0) {
606
- const remaining = expr.slice(i);
607
- if (remaining.startsWith(op) || remaining.startsWith(opLower)) {
608
- parts.push(current.trim());
609
- current = '';
610
- i += op.length - 1;
611
- continue;
612
- }
613
- }
614
- current += char;
615
- }
616
- if (current.trim()) {
617
- parts.push(current.trim());
618
- }
619
- return parts;
620
- }
621
- /**
622
- * Pre-compile regex patterns for a rule (performance optimization).
623
- * Supports both array-format and named-map-format conditions.
624
- */
625
- compilePatterns(rule) {
626
- const ruleMap = new Map();
627
- const conditions = rule.detection.conditions;
628
- if (Array.isArray(conditions)) {
629
- // Array format: compile each {operator: regex, value: "pattern"} entry
630
- for (let i = 0; i < conditions.length; i++) {
631
- const cond = conditions[i];
632
- if (cond['operator'] === 'regex' && typeof cond['value'] === 'string') {
633
- try {
634
- const pat = normalizeRegex(cond['value']);
635
- const fl = pat.includes('\\u{') || pat.includes('\\p{') ? 'iu' : 'i';
636
- ruleMap.set(String(i), [new RegExp(pat, fl)]);
637
- }
638
- catch {
639
- // Invalid regex, skip
640
- }
641
- }
642
- }
643
- }
644
- else {
645
- // Named format: compile patterns arrays
646
- for (const [condName, condDef] of Object.entries(conditions)) {
647
- const cond = condDef;
648
- if (cond['patterns'] && Array.isArray(cond['patterns'])) {
649
- const matchType = cond['match_type'] ?? 'regex';
650
- const caseSensitive = cond['case_sensitive'] ?? false;
651
- const flags = caseSensitive ? '' : 'i';
652
- const compiled = [];
653
- for (const pattern of cond['patterns']) {
654
- try {
655
- if (matchType === 'regex') {
656
- compiled.push(new RegExp(normalizeRegex(pattern), flags));
657
- }
658
- else if (matchType === 'contains') {
659
- compiled.push(new RegExp(escapeRegex(pattern), flags));
660
- }
661
- else if (matchType === 'exact') {
662
- compiled.push(new RegExp(`^${escapeRegex(pattern)}$`, flags));
663
- }
664
- else if (matchType === 'starts_with') {
665
- compiled.push(new RegExp(`^${escapeRegex(pattern)}`, flags));
666
- }
667
- }
668
- catch {
669
- // Invalid regex pattern, skip
670
- }
671
- }
672
- ruleMap.set(condName, compiled);
673
- }
674
- }
675
- }
676
- this.compiledPatterns.set(rule.id, ruleMap);
677
- }
678
- /** Get loaded rule count */
679
- getRuleCount() {
680
- return this.rules.length;
681
- }
682
- /** Get all loaded rules */
683
- getRules() {
684
- return this.rules;
685
- }
686
- /** Get a rule by ID */
687
- getRuleById(id) {
688
- return this.rules.find((r) => r.id === id);
689
- }
690
- /** Get rules by category */
691
- getRulesByCategory(category) {
692
- return this.rules.filter((r) => r.tags.category === category);
693
- }
694
- /**
695
- * Scan SKILL.md content for threats.
696
- * All rules fire with scanContext='skill':
697
- * - skill/both rules: native context, full confidence
698
- * - MCP-only rules: cross-context, confidence * 0.6
699
- * Also decodes base64 blocks and scans decoded content.
700
- */
701
- scanSkill(content) {
702
- const baseEvent = {
703
- type: 'mcp_exchange',
704
- timestamp: new Date().toISOString(),
705
- sessionId: 'skill-scan',
706
- fields: {},
707
- scanContext: 'skill',
708
- };
709
- const matches = this.evaluate({ ...baseEvent, content });
710
- // Scan base64-decoded blocks for hidden payloads
711
- for (const block of decodeBase64Blocks(content)) {
712
- for (const m of this.evaluate({ ...baseEvent, content: block })) {
713
- matches.push({
714
- ...m,
715
- matchedPatterns: [...m.matchedPatterns, '[decoded:base64]'],
716
- });
717
- }
718
- }
719
- return matches;
720
- }
721
- }
722
- function escapeRegex(str) {
723
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
724
- }
725
- /**
726
- * Strip inline flags like (?i) from regex patterns.
727
- * JavaScript RegExp uses flags as a constructor parameter, not inline.
728
- */
729
- function normalizeRegex(pattern) {
730
- return pattern.replace(/^\(\?[imsx]+\)/, '');
731
- }
732
- /**
733
- * Normalize Unicode text to NFC form and strip zero-width characters.
734
- * This prevents evasion via combining characters, zero-width joiners, etc.
735
- */
736
- function normalizeUnicode(text) {
737
- return text
738
- .normalize('NFC')
739
- .replace(
740
- // Zero-width and bidi control characters (listed individually to avoid misleading character class)
741
- /[\u200B\u200C\uFEFF\u2060\u180E\u200E\u200F]/gu, '')
742
- .replace(/[\u202A-\u202E\u2066-\u2069\u200D]/gu, '');
743
- }
744
- /** Maximum input length for regex evaluation to mitigate ReDoS */
745
- const MAX_EVAL_LENGTH = 100_000;
746
- /**
747
- * Safely test a regex pattern against input with length limits.
748
- * Returns false if input exceeds MAX_EVAL_LENGTH to prevent ReDoS.
749
- */
750
- function safeRegexTest(regex, input) {
751
- if (input.length > MAX_EVAL_LENGTH)
752
- return false;
753
- return regex.test(input);
754
- }
755
- function buildCodeBlockRanges(text) {
756
- const ranges = [];
757
- const fenced = /```[\s\S]*?```/g;
758
- let m;
759
- while ((m = fenced.exec(text)) !== null) {
760
- ranges.push([m.index, m.index + m[0].length]);
761
- }
762
- const inline = /`[^`\n]+`/g;
763
- while ((m = inline.exec(text)) !== null) {
764
- const pos = m.index;
765
- const inFenced = ranges.some(([start, end]) => pos >= start && pos < end);
766
- if (!inFenced) {
767
- ranges.push([pos, pos + m[0].length]);
768
- }
769
- }
770
- return ranges;
771
- }
772
- function isInsideCodeBlock(text, regex, codeRanges) {
773
- if (codeRanges.length === 0)
774
- return false;
775
- const searchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g');
776
- const m = searchRegex.exec(text);
777
- if (!m)
778
- return false;
779
- return codeRanges.some(([start, end]) => m.index >= start && m.index < end);
780
- }
781
- //# sourceMappingURL=engine.js.map