ferret-scan 2.2.0 → 2.3.0

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 (159) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +15 -11
  3. package/bin/ferret.js +104 -8
  4. package/dist/__tests__/AgentMonitor.test.d.ts +6 -0
  5. package/dist/__tests__/AgentMonitor.test.js +235 -0
  6. package/dist/__tests__/AtlasNavigatorReporter.test.d.ts +6 -0
  7. package/dist/__tests__/AtlasNavigatorReporter.test.js +193 -0
  8. package/dist/__tests__/CorrelationAnalyzer.test.d.ts +6 -0
  9. package/dist/__tests__/CorrelationAnalyzer.test.js +211 -0
  10. package/dist/__tests__/IndicatorMatcher.test.d.ts +6 -0
  11. package/dist/__tests__/IndicatorMatcher.test.js +245 -0
  12. package/dist/__tests__/MarketplaceScanner.test.d.ts +5 -0
  13. package/dist/__tests__/MarketplaceScanner.test.js +212 -0
  14. package/dist/__tests__/RuleGenerator.test.d.ts +6 -0
  15. package/dist/__tests__/RuleGenerator.test.js +207 -0
  16. package/dist/__tests__/ThreatFeed.test.d.ts +6 -0
  17. package/dist/__tests__/ThreatFeed.test.js +359 -0
  18. package/dist/__tests__/WatchMode.test.d.ts +6 -0
  19. package/dist/__tests__/WatchMode.test.js +104 -0
  20. package/dist/__tests__/astAnalyzerExtra.test.d.ts +6 -0
  21. package/dist/__tests__/astAnalyzerExtra.test.js +67 -0
  22. package/dist/__tests__/astAnalyzerFull.test.d.ts +6 -0
  23. package/dist/__tests__/astAnalyzerFull.test.js +138 -0
  24. package/dist/__tests__/astAnalyzerPatterns.test.d.ts +6 -0
  25. package/dist/__tests__/astAnalyzerPatterns.test.js +143 -0
  26. package/dist/__tests__/atlas.test.d.ts +6 -0
  27. package/dist/__tests__/atlas.test.js +319 -0
  28. package/dist/__tests__/atlasCatalog.test.d.ts +6 -0
  29. package/dist/__tests__/atlasCatalog.test.js +200 -0
  30. package/dist/__tests__/atlasCatalogExtra.test.d.ts +6 -0
  31. package/dist/__tests__/atlasCatalogExtra.test.js +215 -0
  32. package/dist/__tests__/baseline.test.d.ts +6 -0
  33. package/dist/__tests__/baseline.test.js +321 -0
  34. package/dist/__tests__/baselineExtra.test.d.ts +6 -0
  35. package/dist/__tests__/baselineExtra.test.js +317 -0
  36. package/dist/__tests__/capabilityMapping.test.d.ts +5 -0
  37. package/dist/__tests__/capabilityMapping.test.js +49 -0
  38. package/dist/__tests__/capabilityMappingExtra.test.d.ts +5 -0
  39. package/dist/__tests__/capabilityMappingExtra.test.js +200 -0
  40. package/dist/__tests__/complianceExtra.test.d.ts +6 -0
  41. package/dist/__tests__/complianceExtra.test.js +121 -0
  42. package/dist/__tests__/config.test.js +1 -1
  43. package/dist/__tests__/configLoader.test.d.ts +6 -0
  44. package/dist/__tests__/configLoader.test.js +225 -0
  45. package/dist/__tests__/configLoaderExtra.test.d.ts +6 -0
  46. package/dist/__tests__/configLoaderExtra.test.js +186 -0
  47. package/dist/__tests__/correlationAnalyzerExtra.test.d.ts +5 -0
  48. package/dist/__tests__/correlationAnalyzerExtra.test.js +98 -0
  49. package/dist/__tests__/correlationAnalyzerFull.test.d.ts +6 -0
  50. package/dist/__tests__/correlationAnalyzerFull.test.js +154 -0
  51. package/dist/__tests__/customRules.extra.test.d.ts +6 -0
  52. package/dist/__tests__/customRules.extra.test.js +245 -0
  53. package/dist/__tests__/customRules.test.d.ts +7 -0
  54. package/dist/__tests__/customRules.test.js +347 -0
  55. package/dist/__tests__/dependencyRisk.test.d.ts +5 -0
  56. package/dist/__tests__/dependencyRisk.test.js +248 -0
  57. package/dist/__tests__/dependencyRiskExtra.test.d.ts +6 -0
  58. package/dist/__tests__/dependencyRiskExtra.test.js +177 -0
  59. package/dist/__tests__/featureExitCodes.test.d.ts +7 -0
  60. package/dist/__tests__/featureExitCodes.test.js +332 -0
  61. package/dist/__tests__/fileDiscoveryConfigOnly.test.d.ts +6 -0
  62. package/dist/__tests__/fileDiscoveryConfigOnly.test.js +195 -0
  63. package/dist/__tests__/fileDiscoveryExtra.test.d.ts +6 -0
  64. package/dist/__tests__/fileDiscoveryExtra.test.js +149 -0
  65. package/dist/__tests__/fixer.extra.test.d.ts +6 -0
  66. package/dist/__tests__/fixer.extra.test.js +135 -0
  67. package/dist/__tests__/fixerApply.test.d.ts +6 -0
  68. package/dist/__tests__/fixerApply.test.js +132 -0
  69. package/dist/__tests__/gitHooks.test.d.ts +7 -0
  70. package/dist/__tests__/gitHooks.test.js +188 -0
  71. package/dist/__tests__/htmlReporter.extra.test.d.ts +5 -0
  72. package/dist/__tests__/htmlReporter.extra.test.js +126 -0
  73. package/dist/__tests__/interactiveTui.test.d.ts +6 -0
  74. package/dist/__tests__/interactiveTui.test.js +180 -0
  75. package/dist/__tests__/interactiveTuiCommands.test.d.ts +6 -0
  76. package/dist/__tests__/interactiveTuiCommands.test.js +187 -0
  77. package/dist/__tests__/interactiveTuiMore.test.d.ts +6 -0
  78. package/dist/__tests__/interactiveTuiMore.test.js +194 -0
  79. package/dist/__tests__/interactiveTuiSession.test.d.ts +6 -0
  80. package/dist/__tests__/interactiveTuiSession.test.js +173 -0
  81. package/dist/__tests__/llmAnalysis.test.d.ts +6 -0
  82. package/dist/__tests__/llmAnalysis.test.js +229 -0
  83. package/dist/__tests__/llmAnalysisBuildExcerpt.test.d.ts +6 -0
  84. package/dist/__tests__/llmAnalysisBuildExcerpt.test.js +132 -0
  85. package/dist/__tests__/llmAnalysisExtra.test.d.ts +6 -0
  86. package/dist/__tests__/llmAnalysisExtra.test.js +214 -0
  87. package/dist/__tests__/llmAnalysisFilters.test.d.ts +6 -0
  88. package/dist/__tests__/llmAnalysisFilters.test.js +181 -0
  89. package/dist/__tests__/llmAnalysisMitre.test.d.ts +6 -0
  90. package/dist/__tests__/llmAnalysisMitre.test.js +192 -0
  91. package/dist/__tests__/llmGroqTPM.test.d.ts +6 -0
  92. package/dist/__tests__/llmGroqTPM.test.js +89 -0
  93. package/dist/__tests__/llmProviderRetry.test.d.ts +6 -0
  94. package/dist/__tests__/llmProviderRetry.test.js +172 -0
  95. package/dist/__tests__/mcpValidator.extra.test.d.ts +5 -0
  96. package/dist/__tests__/mcpValidator.extra.test.js +270 -0
  97. package/dist/__tests__/patternMatcherExtra.test.d.ts +7 -0
  98. package/dist/__tests__/patternMatcherExtra.test.js +198 -0
  99. package/dist/__tests__/patternsCommon.test.d.ts +6 -0
  100. package/dist/__tests__/patternsCommon.test.js +107 -0
  101. package/dist/__tests__/policyEnforcement.test.d.ts +5 -0
  102. package/dist/__tests__/policyEnforcement.test.js +510 -0
  103. package/dist/__tests__/quarantineExtra.test.d.ts +5 -0
  104. package/dist/__tests__/quarantineExtra.test.js +214 -0
  105. package/dist/__tests__/redactionExtra.test.d.ts +6 -0
  106. package/dist/__tests__/redactionExtra.test.js +228 -0
  107. package/dist/__tests__/scanDiff.test.d.ts +7 -0
  108. package/dist/__tests__/scanDiff.test.js +266 -0
  109. package/dist/__tests__/scanFull.test.d.ts +6 -0
  110. package/dist/__tests__/scanFull.test.js +158 -0
  111. package/dist/__tests__/scannerDampening.test.d.ts +6 -0
  112. package/dist/__tests__/scannerDampening.test.js +160 -0
  113. package/dist/__tests__/scannerExtra.test.d.ts +6 -0
  114. package/dist/__tests__/scannerExtra.test.js +194 -0
  115. package/dist/__tests__/scannerMitre.test.d.ts +5 -0
  116. package/dist/__tests__/scannerMitre.test.js +141 -0
  117. package/dist/__tests__/scannerSSRF.test.d.ts +5 -0
  118. package/dist/__tests__/scannerSSRF.test.js +149 -0
  119. package/dist/__tests__/schemas.test.d.ts +6 -0
  120. package/dist/__tests__/schemas.test.js +125 -0
  121. package/dist/__tests__/webhooks.extra.test.d.ts +6 -0
  122. package/dist/__tests__/webhooks.extra.test.js +144 -0
  123. package/dist/__tests__/webhooks.test.d.ts +6 -0
  124. package/dist/__tests__/webhooks.test.js +154 -0
  125. package/dist/features/customRules.js +22 -29
  126. package/dist/features/mcpTrustScore.d.ts +17 -0
  127. package/dist/features/mcpTrustScore.js +74 -0
  128. package/dist/features/mcpValidator.d.ts +2 -0
  129. package/dist/features/mcpValidator.js +13 -0
  130. package/dist/features/policyEnforcement.d.ts +22 -22
  131. package/dist/intelligence/ThreatFeed.js +207 -62
  132. package/dist/remediation/Quarantine.js +24 -6
  133. package/dist/reporters/ConsoleReporter.js +10 -0
  134. package/dist/reporters/HtmlReporter.js +5 -0
  135. package/dist/reporters/SarifReporter.d.ts +1 -0
  136. package/dist/reporters/SarifReporter.js +1 -0
  137. package/dist/scanner/IAnalyzer.d.ts +19 -0
  138. package/dist/scanner/IAnalyzer.js +5 -0
  139. package/dist/scanner/Scanner.js +64 -125
  140. package/dist/scanner/analyzers/CapabilityAnalyzer.d.ts +8 -0
  141. package/dist/scanner/analyzers/CapabilityAnalyzer.js +19 -0
  142. package/dist/scanner/analyzers/DependencyAnalyzer.d.ts +8 -0
  143. package/dist/scanner/analyzers/DependencyAnalyzer.js +18 -0
  144. package/dist/scanner/analyzers/EntropyAnalyzer.d.ts +8 -0
  145. package/dist/scanner/analyzers/EntropyAnalyzer.js +12 -0
  146. package/dist/scanner/analyzers/LlmAnalyzer.d.ts +17 -0
  147. package/dist/scanner/analyzers/LlmAnalyzer.js +36 -0
  148. package/dist/scanner/analyzers/McpAnalyzer.d.ts +8 -0
  149. package/dist/scanner/analyzers/McpAnalyzer.js +19 -0
  150. package/dist/scanner/analyzers/SemanticAnalyzer.d.ts +8 -0
  151. package/dist/scanner/analyzers/SemanticAnalyzer.js +21 -0
  152. package/dist/scanner/analyzers/ThreatIntelAnalyzer.d.ts +8 -0
  153. package/dist/scanner/analyzers/ThreatIntelAnalyzer.js +21 -0
  154. package/dist/types.d.ts +17 -0
  155. package/dist/types.js +1 -1
  156. package/dist/utils/safeRegex.d.ts +12 -51
  157. package/dist/utils/safeRegex.js +45 -62
  158. package/dist/utils/schemas.d.ts +64 -64
  159. package/package.json +24 -18
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Additional Custom Rules Tests
3
+ * Focuses on loadCustomRulesFile, loadCustomRulesSource, generateExampleRulesFile
4
+ */
5
+ import { loadCustomRulesFile, loadCustomRulesSource, loadCustomRules, generateExampleRulesFile, validateCustomRulesFile, } from '../features/customRules.js';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as os from 'node:os';
9
+ const VALID_RULE_JSON = JSON.stringify({
10
+ version: '1.0',
11
+ description: 'Test rules',
12
+ rules: [
13
+ {
14
+ id: 'CUSTOM-001',
15
+ name: 'Test Rule',
16
+ category: 'injection',
17
+ severity: 'HIGH',
18
+ description: 'Detects test patterns',
19
+ patterns: ['test.*pattern'],
20
+ fileTypes: ['md', 'json'],
21
+ components: ['agent', 'skill'],
22
+ remediation: 'Fix it',
23
+ },
24
+ ],
25
+ });
26
+ const VALID_RULE_YAML = `
27
+ version: "1.0"
28
+ description: Test rules
29
+ rules:
30
+ - id: CUSTOM-001
31
+ name: Test Rule
32
+ category: injection
33
+ severity: HIGH
34
+ description: Detects test patterns
35
+ patterns:
36
+ - "test.*pattern"
37
+ fileTypes:
38
+ - md
39
+ - json
40
+ components:
41
+ - agent
42
+ remediation: Fix it
43
+ `;
44
+ describe('loadCustomRulesFile', () => {
45
+ let tmpDir;
46
+ beforeEach(() => {
47
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-rules-'));
48
+ });
49
+ afterEach(() => {
50
+ fs.rmSync(tmpDir, { recursive: true, force: true });
51
+ });
52
+ it('returns error for non-existent file', () => {
53
+ const result = loadCustomRulesFile('/nonexistent/rules.json');
54
+ expect(result.success).toBe(false);
55
+ expect(result.rules).toHaveLength(0);
56
+ expect(result.errors.length).toBeGreaterThan(0);
57
+ expect(result.errors[0]).toContain('not found');
58
+ });
59
+ it('loads valid JSON rules file', () => {
60
+ const filePath = path.join(tmpDir, 'rules.json');
61
+ fs.writeFileSync(filePath, VALID_RULE_JSON);
62
+ const result = loadCustomRulesFile(filePath);
63
+ expect(result.success).toBe(true);
64
+ expect(result.rules).toHaveLength(1);
65
+ expect(result.rules[0]?.id).toBe('CUSTOM-001');
66
+ // RE2 instances satisfy the RegExp interface but are not instanceof RegExp.
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ expect(typeof result.rules[0]?.patterns[0]?.exec).toBe('function');
69
+ });
70
+ it('loads valid YAML rules file', () => {
71
+ const filePath = path.join(tmpDir, 'rules.yaml');
72
+ fs.writeFileSync(filePath, VALID_RULE_YAML);
73
+ const result = loadCustomRulesFile(filePath);
74
+ expect(result.success).toBe(true);
75
+ expect(result.rules).toHaveLength(1);
76
+ expect(result.rules[0]?.id).toBe('CUSTOM-001');
77
+ });
78
+ it('loads valid .yml rules file', () => {
79
+ const filePath = path.join(tmpDir, 'rules.yml');
80
+ fs.writeFileSync(filePath, VALID_RULE_YAML);
81
+ const result = loadCustomRulesFile(filePath);
82
+ expect(result.success).toBe(true);
83
+ expect(result.rules).toHaveLength(1);
84
+ });
85
+ it('returns error for unsupported file format', () => {
86
+ const filePath = path.join(tmpDir, 'rules.txt');
87
+ fs.writeFileSync(filePath, VALID_RULE_JSON);
88
+ const result = loadCustomRulesFile(filePath);
89
+ expect(result.success).toBe(false);
90
+ expect(result.errors[0]).toContain('Unsupported file format');
91
+ });
92
+ it('returns error for invalid JSON', () => {
93
+ const filePath = path.join(tmpDir, 'rules.json');
94
+ fs.writeFileSync(filePath, 'invalid json {{{');
95
+ const result = loadCustomRulesFile(filePath);
96
+ expect(result.success).toBe(false);
97
+ expect(result.errors.length).toBeGreaterThan(0);
98
+ });
99
+ it('returns error for schema validation failure', () => {
100
+ const filePath = path.join(tmpDir, 'rules.json');
101
+ fs.writeFileSync(filePath, JSON.stringify({ rules: [] })); // empty rules array fails min(1)
102
+ const result = loadCustomRulesFile(filePath);
103
+ expect(result.success).toBe(false);
104
+ expect(result.errors.length).toBeGreaterThan(0);
105
+ });
106
+ it('returns error for invalid rule id format', () => {
107
+ const filePath = path.join(tmpDir, 'rules.json');
108
+ fs.writeFileSync(filePath, JSON.stringify({
109
+ rules: [{
110
+ id: 'invalid-id', // should be like CUSTOM-001
111
+ name: 'Test',
112
+ category: 'injection',
113
+ severity: 'HIGH',
114
+ description: 'Test',
115
+ patterns: ['test'],
116
+ }],
117
+ }));
118
+ const result = loadCustomRulesFile(filePath);
119
+ expect(result.success).toBe(false);
120
+ });
121
+ });
122
+ describe('loadCustomRulesSource', () => {
123
+ let tmpDir;
124
+ beforeEach(() => {
125
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-rules-'));
126
+ });
127
+ afterEach(() => {
128
+ fs.rmSync(tmpDir, { recursive: true, force: true });
129
+ });
130
+ it('loads from local file path', async () => {
131
+ const filePath = path.join(tmpDir, 'rules.json');
132
+ fs.writeFileSync(filePath, VALID_RULE_JSON);
133
+ const result = await loadCustomRulesSource(filePath);
134
+ expect(result.success).toBe(true);
135
+ expect(result.rules).toHaveLength(1);
136
+ });
137
+ it('returns error for non-existent local file', async () => {
138
+ const result = await loadCustomRulesSource('/nonexistent/rules.json');
139
+ expect(result.success).toBe(false);
140
+ expect(result.errors.length).toBeGreaterThan(0);
141
+ });
142
+ it('fetches from URL and caches', async () => {
143
+ globalThis.fetch = jest.fn().mockResolvedValue({
144
+ ok: true,
145
+ text: () => Promise.resolve(VALID_RULE_JSON),
146
+ });
147
+ const cacheDir = path.join(tmpDir, 'cache');
148
+ const result = await loadCustomRulesSource('https://example.com/rules.json', { cacheDir, cacheTtlHours: 24, timeoutMs: 5000 });
149
+ expect(result.success).toBe(true);
150
+ expect(result.rules).toHaveLength(1);
151
+ // Cache should exist
152
+ expect(fs.existsSync(cacheDir)).toBe(true);
153
+ });
154
+ it('returns error when URL fetch fails with no cache', async () => {
155
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
156
+ const cacheDir = path.join(tmpDir, 'empty-cache');
157
+ const result = await loadCustomRulesSource('https://example.com/rules.json', { cacheDir, cacheTtlHours: 24, timeoutMs: 1000 });
158
+ expect(result.success).toBe(false);
159
+ expect(result.errors[0]).toContain('Failed to fetch');
160
+ });
161
+ it('uses cached version when URL fetch fails', async () => {
162
+ // First create a cache file
163
+ const cacheDir = path.join(tmpDir, 'stale-cache');
164
+ fs.mkdirSync(cacheDir, { recursive: true });
165
+ // Write stale content to cache with hash matching the URL
166
+ const { createHash } = await import('node:crypto');
167
+ const url = 'https://example.com/stale-rules.json';
168
+ const cacheKey = createHash('sha256').update(url, 'utf8').digest('hex');
169
+ const cachePath = path.join(cacheDir, `${cacheKey}.json`);
170
+ fs.writeFileSync(cachePath, VALID_RULE_JSON);
171
+ // Set TTL to 0 so cache is always "stale" (force refetch)
172
+ // But fetch will fail so it should use stale cache
173
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error('Network unreachable'));
174
+ const result = await loadCustomRulesSource(url, { cacheDir, cacheTtlHours: 0, timeoutMs: 1000 });
175
+ // Should succeed using stale cache
176
+ expect(result.success).toBe(true);
177
+ expect(result.rules).toHaveLength(1);
178
+ });
179
+ });
180
+ describe('loadCustomRules', () => {
181
+ it('returns empty array when no rules files found', () => {
182
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-empty-'));
183
+ const rules = loadCustomRules(tmpDir);
184
+ expect(rules).toHaveLength(0);
185
+ fs.rmSync(tmpDir, { recursive: true });
186
+ });
187
+ it('loads rules from .ferret/rules.json', () => {
188
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-rules-'));
189
+ const ferretDir = path.join(tmpDir, '.ferret');
190
+ fs.mkdirSync(ferretDir);
191
+ fs.writeFileSync(path.join(ferretDir, 'rules.json'), VALID_RULE_JSON);
192
+ const rules = loadCustomRules(tmpDir);
193
+ expect(rules).toHaveLength(1);
194
+ fs.rmSync(tmpDir, { recursive: true });
195
+ });
196
+ it('loads rules from ferret-rules.json at root', () => {
197
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-rules-'));
198
+ fs.writeFileSync(path.join(tmpDir, 'ferret-rules.json'), VALID_RULE_JSON);
199
+ const rules = loadCustomRules(tmpDir);
200
+ expect(rules).toHaveLength(1);
201
+ fs.rmSync(tmpDir, { recursive: true });
202
+ });
203
+ });
204
+ describe('generateExampleRulesFile', () => {
205
+ it('returns a string', () => {
206
+ const content = generateExampleRulesFile();
207
+ expect(typeof content).toBe('string');
208
+ expect(content.length).toBeGreaterThan(0);
209
+ });
210
+ it('contains valid YAML with rules', () => {
211
+ const content = generateExampleRulesFile();
212
+ expect(content).toContain('rules:');
213
+ expect(content).toContain('id:');
214
+ expect(content).toContain('severity:');
215
+ });
216
+ });
217
+ describe('validateCustomRulesFile', () => {
218
+ let tmpDir;
219
+ beforeEach(() => {
220
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-validate-'));
221
+ });
222
+ afterEach(() => {
223
+ fs.rmSync(tmpDir, { recursive: true, force: true });
224
+ });
225
+ it('validates a valid rules file', () => {
226
+ const filePath = path.join(tmpDir, 'rules.json');
227
+ fs.writeFileSync(filePath, VALID_RULE_JSON);
228
+ const result = validateCustomRulesFile(filePath);
229
+ expect(result.valid).toBe(true);
230
+ expect(result.errors).toHaveLength(0);
231
+ });
232
+ it('returns errors for invalid rules file', () => {
233
+ const filePath = path.join(tmpDir, 'rules.json');
234
+ fs.writeFileSync(filePath, JSON.stringify({ rules: [] }));
235
+ const result = validateCustomRulesFile(filePath);
236
+ expect(result.valid).toBe(false);
237
+ expect(result.errors.length).toBeGreaterThan(0);
238
+ });
239
+ it('returns error for non-existent file', () => {
240
+ const result = validateCustomRulesFile('/nonexistent/rules.json');
241
+ expect(result.valid).toBe(false);
242
+ expect(result.errors.length).toBeGreaterThan(0);
243
+ });
244
+ });
245
+ //# sourceMappingURL=customRules.extra.test.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Custom Rules Tests
3
+ * Tests for customRules loader, including compileSafePattern screening,
4
+ * validation, and file loading/parsing logic.
5
+ */
6
+ export {};
7
+ //# sourceMappingURL=customRules.test.d.ts.map
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Custom Rules Tests
3
+ * Tests for customRules loader, including compileSafePattern screening,
4
+ * validation, and file loading/parsing logic.
5
+ */
6
+ jest.mock('node:fs');
7
+ jest.mock('yaml');
8
+ import * as fs from 'node:fs';
9
+ import * as yaml from 'yaml';
10
+ import { isRE2Active } from '../../src/utils/safeRegex.js';
11
+ import { loadCustomRulesFile, loadCustomRules, validateCustomRulesFile, generateExampleRulesFile, loadCustomRulesSource, } from '../features/customRules.js';
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ const mockFs = fs;
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ const mockYaml = yaml;
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+ const VALID_RULE_JSON = JSON.stringify({
20
+ version: '1.0',
21
+ rules: [
22
+ {
23
+ id: 'CUSTOM-001',
24
+ name: 'Test Rule',
25
+ category: 'injection',
26
+ severity: 'HIGH',
27
+ description: 'A test custom rule',
28
+ patterns: ['test\\d+', 'foo.*bar'],
29
+ },
30
+ ],
31
+ });
32
+ const VALID_RULE_WITH_OPTIONS_JSON = JSON.stringify({
33
+ version: '1.0',
34
+ rules: [
35
+ {
36
+ id: 'CUSTOM-002',
37
+ name: 'Rule with Options',
38
+ category: 'credentials',
39
+ severity: 'CRITICAL',
40
+ description: 'Rule with all optional fields',
41
+ patterns: ['secret\\s*=\\s*\\w+'],
42
+ fileTypes: ['md', 'json'],
43
+ components: ['skill', 'agent'],
44
+ remediation: 'Remove the secret.',
45
+ references: ['https://example.com/guide'],
46
+ enabled: false,
47
+ excludePatterns: ['test.*secret'],
48
+ requireContext: ['config'],
49
+ excludeContext: ['documentation'],
50
+ minMatchLength: 10,
51
+ },
52
+ ],
53
+ });
54
+ // ---------------------------------------------------------------------------
55
+ // loadCustomRulesFile — JSON
56
+ // ---------------------------------------------------------------------------
57
+ describe('loadCustomRulesFile', () => {
58
+ beforeEach(() => {
59
+ jest.clearAllMocks();
60
+ });
61
+ it('returns error when file does not exist', () => {
62
+ mockFs.existsSync.mockReturnValue(false);
63
+ const result = loadCustomRulesFile('/nonexistent/rules.json');
64
+ expect(result.success).toBe(false);
65
+ expect(result.rules).toHaveLength(0);
66
+ expect(result.errors[0]).toContain('not found');
67
+ });
68
+ it('loads valid JSON rules file successfully', () => {
69
+ mockFs.existsSync.mockReturnValue(true);
70
+ mockFs.readFileSync.mockReturnValue(VALID_RULE_JSON);
71
+ const result = loadCustomRulesFile('/path/rules.json');
72
+ expect(result.success).toBe(true);
73
+ expect(result.rules).toHaveLength(1);
74
+ expect(result.rules[0].id).toBe('CUSTOM-001');
75
+ expect(result.rules[0].severity).toBe('HIGH');
76
+ expect(result.errors).toHaveLength(0);
77
+ });
78
+ it('loads YAML rules file successfully', () => {
79
+ mockFs.existsSync.mockReturnValue(true);
80
+ mockFs.readFileSync.mockReturnValue('content');
81
+ mockYaml.parse.mockReturnValue(JSON.parse(VALID_RULE_JSON));
82
+ const result = loadCustomRulesFile('/path/rules.yaml');
83
+ expect(result.success).toBe(true);
84
+ expect(result.rules).toHaveLength(1);
85
+ });
86
+ it('loads .yml extension', () => {
87
+ mockFs.existsSync.mockReturnValue(true);
88
+ mockFs.readFileSync.mockReturnValue('content');
89
+ mockYaml.parse.mockReturnValue(JSON.parse(VALID_RULE_JSON));
90
+ const result = loadCustomRulesFile('/path/rules.yml');
91
+ expect(result.success).toBe(true);
92
+ expect(result.rules).toHaveLength(1);
93
+ });
94
+ it('returns error for unsupported file extension', () => {
95
+ mockFs.existsSync.mockReturnValue(true);
96
+ const result = loadCustomRulesFile('/path/rules.xml');
97
+ expect(result.success).toBe(false);
98
+ expect(result.errors[0]).toContain('Unsupported file format');
99
+ });
100
+ it('returns error when schema validation fails', () => {
101
+ mockFs.existsSync.mockReturnValue(true);
102
+ mockFs.readFileSync.mockReturnValue(JSON.stringify({ rules: [] }));
103
+ const result = loadCustomRulesFile('/path/rules.json');
104
+ expect(result.success).toBe(false);
105
+ expect(result.errors.length).toBeGreaterThan(0);
106
+ });
107
+ it('returns error when JSON is malformed', () => {
108
+ mockFs.existsSync.mockReturnValue(true);
109
+ mockFs.readFileSync.mockReturnValue('{ invalid json');
110
+ const result = loadCustomRulesFile('/path/rules.json');
111
+ expect(result.success).toBe(false);
112
+ expect(result.errors[0]).toContain('Failed to parse');
113
+ });
114
+ it('loads rule with all optional fields', () => {
115
+ mockFs.existsSync.mockReturnValue(true);
116
+ mockFs.readFileSync.mockReturnValue(VALID_RULE_WITH_OPTIONS_JSON);
117
+ const result = loadCustomRulesFile('/path/rules.json');
118
+ expect(result.success).toBe(true);
119
+ const rule = result.rules[0];
120
+ expect(rule.enabled).toBe(false);
121
+ expect(rule.fileTypes).toContain('md');
122
+ expect(rule.components).toContain('skill');
123
+ expect(rule.remediation).toBe('Remove the secret.');
124
+ expect(rule.excludePatterns).toHaveLength(1);
125
+ expect(rule.requireContext).toHaveLength(1);
126
+ expect(rule.excludeContext).toHaveLength(1);
127
+ expect(rule.minMatchLength).toBe(10);
128
+ });
129
+ it('skips rules with invalid (ReDoS) patterns and tracks errors (native) or loads them (RE2)', () => {
130
+ const withReDoS = JSON.stringify({
131
+ rules: [
132
+ {
133
+ id: 'CUSTOM-001',
134
+ name: 'ReDoS Rule',
135
+ category: 'injection',
136
+ severity: 'HIGH',
137
+ description: 'rule with unsafe pattern',
138
+ patterns: ['(a+)+b'], // ReDoS pattern — unsafe in native JS, safe in RE2
139
+ },
140
+ ],
141
+ });
142
+ mockFs.existsSync.mockReturnValue(true);
143
+ mockFs.readFileSync.mockReturnValue(withReDoS);
144
+ const result = loadCustomRulesFile('/path/rules.json');
145
+ if (isRE2Active()) {
146
+ // RE2 compiles this safely — rule should load successfully
147
+ expect(result.success).toBe(true);
148
+ }
149
+ else {
150
+ // Static screener rejects — rule should fail
151
+ expect(result.success).toBe(false);
152
+ expect(result.errors.length).toBeGreaterThan(0);
153
+ }
154
+ });
155
+ it('reports error for rule with missing required fields', () => {
156
+ const missingFields = JSON.stringify({
157
+ rules: [
158
+ {
159
+ id: 'CUSTOM-001',
160
+ // name missing
161
+ category: 'injection',
162
+ severity: 'HIGH',
163
+ description: 'A rule',
164
+ patterns: ['test'],
165
+ },
166
+ ],
167
+ });
168
+ mockFs.existsSync.mockReturnValue(true);
169
+ mockFs.readFileSync.mockReturnValue(missingFields);
170
+ const result = loadCustomRulesFile('/path/rules.json');
171
+ expect(result.success).toBe(false);
172
+ });
173
+ });
174
+ // ---------------------------------------------------------------------------
175
+ // validateCustomRulesFile
176
+ // ---------------------------------------------------------------------------
177
+ describe('validateCustomRulesFile', () => {
178
+ beforeEach(() => {
179
+ jest.clearAllMocks();
180
+ });
181
+ it('returns invalid when file does not exist', () => {
182
+ mockFs.existsSync.mockReturnValue(false);
183
+ const result = validateCustomRulesFile('/nonexistent.json');
184
+ expect(result.valid).toBe(false);
185
+ expect(result.errors[0]).toContain('File not found');
186
+ });
187
+ it('validates a correct JSON file successfully', () => {
188
+ mockFs.existsSync.mockReturnValue(true);
189
+ mockFs.readFileSync.mockReturnValue(VALID_RULE_JSON);
190
+ const result = validateCustomRulesFile('/path/rules.json');
191
+ expect(result.valid).toBe(true);
192
+ expect(result.ruleCount).toBe(1);
193
+ expect(result.errors).toHaveLength(0);
194
+ });
195
+ it('rejects unsafe regex patterns (native) or validates them (RE2)', () => {
196
+ const withReDoS = JSON.stringify({
197
+ rules: [
198
+ {
199
+ id: 'CUSTOM-001',
200
+ name: 'Test',
201
+ category: 'injection',
202
+ severity: 'HIGH',
203
+ description: 'Test rule',
204
+ patterns: ['(a+)+b'],
205
+ },
206
+ ],
207
+ });
208
+ mockFs.existsSync.mockReturnValue(true);
209
+ mockFs.readFileSync.mockReturnValue(withReDoS);
210
+ const result = validateCustomRulesFile('/path/rules.json');
211
+ if (isRE2Active()) {
212
+ // RE2 handles this safely — should be valid
213
+ expect(result.valid).toBe(true);
214
+ }
215
+ else {
216
+ expect(result.valid).toBe(false);
217
+ expect(result.errors.some(e => e.includes('Unsafe or invalid regex'))).toBe(true);
218
+ }
219
+ });
220
+ it('detects duplicate rule IDs', () => {
221
+ const withDups = JSON.stringify({
222
+ rules: [
223
+ {
224
+ id: 'CUSTOM-001',
225
+ name: 'Rule 1',
226
+ category: 'injection',
227
+ severity: 'HIGH',
228
+ description: 'First rule',
229
+ patterns: ['pattern1'],
230
+ },
231
+ {
232
+ id: 'CUSTOM-001',
233
+ name: 'Rule 2',
234
+ category: 'injection',
235
+ severity: 'MEDIUM',
236
+ description: 'Second rule with same id',
237
+ patterns: ['pattern2'],
238
+ },
239
+ ],
240
+ });
241
+ mockFs.existsSync.mockReturnValue(true);
242
+ mockFs.readFileSync.mockReturnValue(withDups);
243
+ const result = validateCustomRulesFile('/path/rules.json');
244
+ expect(result.valid).toBe(false);
245
+ expect(result.errors.some(e => e.includes('Duplicate rule IDs'))).toBe(true);
246
+ });
247
+ it('returns unsupported format error for .xml', () => {
248
+ mockFs.existsSync.mockReturnValue(true);
249
+ const result = validateCustomRulesFile('/path/rules.xml');
250
+ expect(result.valid).toBe(false);
251
+ expect(result.errors[0]).toContain('Unsupported file format');
252
+ });
253
+ it('validates YAML file', () => {
254
+ mockFs.existsSync.mockReturnValue(true);
255
+ mockFs.readFileSync.mockReturnValue('content');
256
+ mockYaml.parse.mockReturnValue(JSON.parse(VALID_RULE_JSON));
257
+ const result = validateCustomRulesFile('/path/rules.yaml');
258
+ expect(result.valid).toBe(true);
259
+ expect(result.ruleCount).toBe(1);
260
+ });
261
+ it('returns parse error for invalid JSON', () => {
262
+ mockFs.existsSync.mockReturnValue(true);
263
+ mockFs.readFileSync.mockReturnValue('{bad json');
264
+ const result = validateCustomRulesFile('/path/rules.json');
265
+ expect(result.valid).toBe(false);
266
+ expect(result.errors[0]).toContain('Failed to parse');
267
+ });
268
+ });
269
+ // ---------------------------------------------------------------------------
270
+ // loadCustomRules (search standard paths)
271
+ // ---------------------------------------------------------------------------
272
+ describe('loadCustomRules', () => {
273
+ beforeEach(() => {
274
+ jest.clearAllMocks();
275
+ });
276
+ it('returns empty array when no standard rules files exist', () => {
277
+ mockFs.existsSync.mockReturnValue(false);
278
+ const rules = loadCustomRules('/some/dir');
279
+ expect(rules).toHaveLength(0);
280
+ });
281
+ it('loads rules from first found standard path', () => {
282
+ // Only the first candidate exists
283
+ mockFs.existsSync.mockImplementation((p) => {
284
+ return String(p).endsWith('.ferret/rules.yaml');
285
+ });
286
+ mockFs.readFileSync.mockReturnValue('content');
287
+ mockYaml.parse.mockReturnValue(JSON.parse(VALID_RULE_JSON));
288
+ const rules = loadCustomRules('/project');
289
+ expect(rules.length).toBeGreaterThan(0);
290
+ });
291
+ it('accumulates rules from multiple found paths', () => {
292
+ // Two paths exist
293
+ let callCount = 0;
294
+ mockFs.existsSync.mockImplementation((p) => {
295
+ const s = String(p);
296
+ return s.endsWith('.ferret/rules.yaml') || s.endsWith('.ferret/custom-rules.yaml');
297
+ });
298
+ mockFs.readFileSync.mockImplementation(() => {
299
+ callCount++;
300
+ return 'content';
301
+ });
302
+ mockYaml.parse.mockReturnValue(JSON.parse(VALID_RULE_JSON));
303
+ const rules = loadCustomRules('/project');
304
+ // Should have loaded 2 sets of 1 rule each
305
+ expect(rules.length).toBe(2);
306
+ });
307
+ });
308
+ // ---------------------------------------------------------------------------
309
+ // generateExampleRulesFile
310
+ // ---------------------------------------------------------------------------
311
+ describe('generateExampleRulesFile', () => {
312
+ it('returns a non-empty string', () => {
313
+ const content = generateExampleRulesFile();
314
+ expect(typeof content).toBe('string');
315
+ expect(content.length).toBeGreaterThan(0);
316
+ });
317
+ it('contains CUSTOM-001 example', () => {
318
+ const content = generateExampleRulesFile();
319
+ expect(content).toContain('CUSTOM-001');
320
+ });
321
+ it('is valid YAML-like structure (contains "rules:" key)', () => {
322
+ const content = generateExampleRulesFile();
323
+ expect(content).toContain('rules:');
324
+ });
325
+ });
326
+ // ---------------------------------------------------------------------------
327
+ // loadCustomRulesSource — local file delegation
328
+ // ---------------------------------------------------------------------------
329
+ describe('loadCustomRulesSource', () => {
330
+ beforeEach(() => {
331
+ jest.clearAllMocks();
332
+ });
333
+ it('delegates to loadCustomRulesFile for local paths', async () => {
334
+ mockFs.existsSync.mockReturnValue(true);
335
+ mockFs.readFileSync.mockReturnValue(VALID_RULE_JSON);
336
+ const result = await loadCustomRulesSource('/path/rules.json');
337
+ expect(result.success).toBe(true);
338
+ expect(result.rules).toHaveLength(1);
339
+ });
340
+ it('returns error for missing local file', async () => {
341
+ mockFs.existsSync.mockReturnValue(false);
342
+ const result = await loadCustomRulesSource('/nonexistent.json');
343
+ expect(result.success).toBe(false);
344
+ expect(result.errors[0]).toContain('not found');
345
+ });
346
+ });
347
+ //# sourceMappingURL=customRules.test.js.map
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Dependency Risk Analysis Tests
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=dependencyRisk.test.d.ts.map