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,192 @@
1
+ /**
2
+ * LLM Analysis MITRE Atlas Tests
3
+ * Tests for analyzeWithLlm with MITRE atlas options
4
+ */
5
+ import { analyzeWithLlm } from '../features/llmAnalysis.js';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as os from 'node:os';
9
+ function makeConfig(overrides = {}) {
10
+ return {
11
+ provider: 'openai-compatible',
12
+ baseUrl: 'http://localhost:11434/v1/chat/completions',
13
+ model: 'llama3',
14
+ apiKeyEnv: 'DUMMY_KEY',
15
+ timeoutMs: 5000,
16
+ jsonMode: false,
17
+ maxInputChars: 1000,
18
+ maxOutputTokens: 200,
19
+ temperature: 0,
20
+ systemPromptAddendum: '',
21
+ includeMitreAtlasTechniques: false,
22
+ maxMitreAtlasTechniques: 0,
23
+ cacheDir: '/tmp/ferret-llm-cache',
24
+ cacheTtlHours: 1,
25
+ maxRetries: 0,
26
+ retryBackoffMs: 1,
27
+ retryMaxBackoffMs: 10,
28
+ minRequestIntervalMs: 0,
29
+ onlyIfFindings: false,
30
+ maxFindingsPerFile: 10,
31
+ maxFiles: 5,
32
+ minConfidence: 0.5,
33
+ ...overrides,
34
+ };
35
+ }
36
+ function makeFile(overrides = {}) {
37
+ return {
38
+ path: '/project/.claude/agents/test.md',
39
+ relativePath: 'agents/test.md',
40
+ type: 'md',
41
+ component: 'agent',
42
+ size: 100,
43
+ modified: new Date(),
44
+ ...overrides,
45
+ };
46
+ }
47
+ describe('analyzeWithLlm - MITRE atlas integration', () => {
48
+ let tmpDir;
49
+ beforeEach(() => {
50
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-llm-mitre-'));
51
+ });
52
+ afterEach(() => {
53
+ fs.rmSync(tmpDir, { recursive: true, force: true });
54
+ });
55
+ it('includes MITRE techniques when includeMitreAtlasTechniques=true', async () => {
56
+ let capturedPrompt = null;
57
+ const mockAnalyze = jest.fn().mockImplementation(async (prompt) => {
58
+ capturedPrompt = prompt;
59
+ return JSON.stringify({ version: 1, findings: [] });
60
+ });
61
+ const provider = { name: 'test', analyze: mockAnalyze };
62
+ const file = makeFile();
63
+ const config = makeConfig({
64
+ includeMitreAtlasTechniques: true,
65
+ maxMitreAtlasTechniques: 10,
66
+ cacheDir: tmpDir,
67
+ });
68
+ await analyzeWithLlm(provider, config, file, 'file content here', []);
69
+ expect(capturedPrompt).not.toBeNull();
70
+ // When MITRE included, system prompt should reference atlas techniques
71
+ const p = capturedPrompt;
72
+ expect(typeof p?.system).toBe('string');
73
+ });
74
+ it('returns findings with MITRE atlas IDs', async () => {
75
+ const mockResponse = JSON.stringify({
76
+ version: 1,
77
+ findings: [
78
+ {
79
+ title: 'Prompt Injection',
80
+ severity: 'HIGH',
81
+ category: 'injection',
82
+ match: 'IGNORE PREVIOUS',
83
+ remediation: 'fix',
84
+ confidence: 0.9,
85
+ mitre_atlas: ['AML.T0051'],
86
+ },
87
+ ],
88
+ });
89
+ const mockAnalyze = jest.fn().mockResolvedValue(mockResponse);
90
+ const provider = { name: 'test', analyze: mockAnalyze };
91
+ const file = makeFile();
92
+ const config = makeConfig({ cacheDir: tmpDir });
93
+ const result = await analyzeWithLlm(provider, config, file, 'IGNORE PREVIOUS instructions', []);
94
+ expect(result.ran).toBe(true);
95
+ expect(result.findings.length).toBeGreaterThan(0);
96
+ });
97
+ it('handles findings with invalid MITRE IDs gracefully', async () => {
98
+ const mockResponse = JSON.stringify({
99
+ version: 1,
100
+ findings: [
101
+ {
102
+ title: 'Test Finding',
103
+ severity: 'MEDIUM',
104
+ category: 'injection',
105
+ match: 'bad content',
106
+ remediation: 'fix',
107
+ confidence: 0.8,
108
+ mitre_atlas: ['INVALID-001', 'AML.T0051'],
109
+ },
110
+ ],
111
+ });
112
+ const mockAnalyze = jest.fn().mockResolvedValue(mockResponse);
113
+ const provider = { name: 'test', analyze: mockAnalyze };
114
+ const file = makeFile();
115
+ const result = await analyzeWithLlm(provider, makeConfig({ cacheDir: tmpDir }), file, 'bad content here', []);
116
+ // Invalid IDs should not cause errors
117
+ expect(Array.isArray(result.findings)).toBe(true);
118
+ });
119
+ it('analyzes large files with excerpt truncation', async () => {
120
+ const mockAnalyze = jest.fn().mockResolvedValue(JSON.stringify({ version: 1, findings: [] }));
121
+ const provider = { name: 'test', analyze: mockAnalyze };
122
+ const file = makeFile();
123
+ // Create very long content
124
+ const longContent = 'line content here\n'.repeat(2000);
125
+ const config = makeConfig({ maxInputChars: 1000, cacheDir: tmpDir });
126
+ const result = await analyzeWithLlm(provider, config, file, longContent, []);
127
+ expect(result.ran).toBe(true);
128
+ });
129
+ it('uses category aliases correctly', async () => {
130
+ const mockResponse = JSON.stringify({
131
+ version: 1,
132
+ findings: [
133
+ {
134
+ title: 'Prompt Test',
135
+ severity: 'HIGH',
136
+ category: 'prompt-injection', // should alias to 'injection'
137
+ match: 'test',
138
+ remediation: 'fix',
139
+ confidence: 0.8,
140
+ },
141
+ {
142
+ title: 'Credential Test',
143
+ severity: 'HIGH',
144
+ category: 'secret', // should alias to 'credentials'
145
+ match: 'secret',
146
+ remediation: 'fix',
147
+ confidence: 0.8,
148
+ },
149
+ {
150
+ title: 'Exfil Test',
151
+ severity: 'HIGH',
152
+ category: 'exfiltration-attempt', // should alias to 'exfiltration'
153
+ match: 'exfil',
154
+ remediation: 'fix',
155
+ confidence: 0.8,
156
+ },
157
+ ],
158
+ });
159
+ const mockAnalyze = jest.fn().mockResolvedValue(mockResponse);
160
+ const provider = { name: 'test', analyze: mockAnalyze };
161
+ const file = makeFile();
162
+ const config = makeConfig({ cacheDir: tmpDir });
163
+ const result = await analyzeWithLlm(provider, config, file, 'test content', []);
164
+ if (result.ran && result.findings.length > 0) {
165
+ expect(result.findings[0]?.category).toBe('injection');
166
+ expect(result.findings[1]?.category).toBe('credentials');
167
+ expect(result.findings[2]?.category).toBe('exfiltration');
168
+ }
169
+ });
170
+ it('handles extractJson with code fences', async () => {
171
+ // LLM often returns JSON wrapped in code fences
172
+ const mockResponse = '```json\n{"version":1,"findings":[]}\n```';
173
+ const mockAnalyze = jest.fn().mockResolvedValue(mockResponse);
174
+ const provider = { name: 'test', analyze: mockAnalyze };
175
+ const file = makeFile();
176
+ const config = makeConfig({ cacheDir: tmpDir });
177
+ const result = await analyzeWithLlm(provider, config, file, 'content', []);
178
+ expect(result.ran).toBe(true);
179
+ expect(result.findings).toHaveLength(0);
180
+ });
181
+ it('handles extractJson with best-effort extraction', async () => {
182
+ // LLM returns JSON embedded in text
183
+ const mockResponse = 'Here are the findings: {"version":1,"findings":[]} Done.';
184
+ const mockAnalyze = jest.fn().mockResolvedValue(mockResponse);
185
+ const provider = { name: 'test', analyze: mockAnalyze };
186
+ const file = makeFile();
187
+ const config = makeConfig({ cacheDir: tmpDir });
188
+ const result = await analyzeWithLlm(provider, config, file, 'content', []);
189
+ expect(result.ran).toBe(true);
190
+ });
191
+ });
192
+ //# sourceMappingURL=llmAnalysisMitre.test.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * LLM Groq TPM Throttle Tests
3
+ * Tests for the Groq-specific TPM throttling in createOpenAICompatibleProvider
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=llmGroqTPM.test.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * LLM Groq TPM Throttle Tests
3
+ * Tests for the Groq-specific TPM throttling in createOpenAICompatibleProvider
4
+ */
5
+ import { createOpenAICompatibleProvider } from '../features/llmAnalysis.js';
6
+ function makeGroqConfig(overrides = {}) {
7
+ return {
8
+ provider: 'openai-compatible',
9
+ baseUrl: 'https://api.groq.com/openai/v1/chat/completions',
10
+ model: 'llama-3.3-70b',
11
+ apiKeyEnv: 'GROQ_TEST_KEY',
12
+ timeoutMs: 5000,
13
+ jsonMode: false,
14
+ maxInputChars: 500,
15
+ maxOutputTokens: 100,
16
+ temperature: 0,
17
+ systemPromptAddendum: '',
18
+ includeMitreAtlasTechniques: false,
19
+ maxMitreAtlasTechniques: 0,
20
+ cacheDir: '/tmp/ferret-llm-cache',
21
+ cacheTtlHours: 1,
22
+ maxRetries: 0,
23
+ retryBackoffMs: 1,
24
+ retryMaxBackoffMs: 10,
25
+ minRequestIntervalMs: 1,
26
+ onlyIfFindings: false,
27
+ maxFindingsPerFile: 10,
28
+ maxFiles: 5,
29
+ minConfidence: 0.5,
30
+ ...overrides,
31
+ };
32
+ }
33
+ describe('createOpenAICompatibleProvider - Groq adaptations', () => {
34
+ beforeEach(() => {
35
+ process.env['GROQ_TEST_KEY'] = 'gsk_test_key_for_groq_tests_abc123';
36
+ });
37
+ afterEach(() => {
38
+ delete process.env['GROQ_TEST_KEY'];
39
+ });
40
+ it('creates a Groq provider with reduced output tokens', async () => {
41
+ const provider = createOpenAICompatibleProvider(makeGroqConfig({
42
+ maxOutputTokens: 1000, // Should be reduced to 400 for Groq
43
+ }));
44
+ expect(provider).not.toBeNull();
45
+ expect(provider?.name).toBe('openai-compatible');
46
+ });
47
+ it('provider analyze calls fetch for Groq endpoint', async () => {
48
+ globalThis.fetch = jest.fn().mockResolvedValue({
49
+ ok: true,
50
+ status: 200,
51
+ json: () => Promise.resolve({
52
+ choices: [{ message: { content: '{"version":1,"findings":[]}' } }],
53
+ }),
54
+ });
55
+ const provider = createOpenAICompatibleProvider(makeGroqConfig());
56
+ expect(provider).not.toBeNull();
57
+ const result = await provider.analyze({ system: 'test', user: 'content' });
58
+ expect(typeof result).toBe('string');
59
+ expect(globalThis.fetch).toHaveBeenCalledWith(expect.stringContaining('groq.com'), expect.any(Object));
60
+ });
61
+ it('uses larger minRequestIntervalMs for Groq (at least 1000ms)', async () => {
62
+ // Create with small interval but Groq endpoint should enforce minimum
63
+ const provider = createOpenAICompatibleProvider(makeGroqConfig({
64
+ minRequestIntervalMs: 50, // Should be bumped to 1000ms for Groq
65
+ }));
66
+ expect(provider).not.toBeNull();
67
+ // Just verify it was created
68
+ expect(provider?.name).toBe('openai-compatible');
69
+ });
70
+ it('handles very large prompt estimates exceeding TPM limit', async () => {
71
+ globalThis.fetch = jest.fn().mockResolvedValue({
72
+ ok: true,
73
+ status: 200,
74
+ json: () => Promise.resolve({
75
+ choices: [{ message: { content: '{}' } }],
76
+ }),
77
+ });
78
+ const provider = createOpenAICompatibleProvider(makeGroqConfig({
79
+ maxInputChars: 100000, // Very large input that would exceed Groq TPM
80
+ maxOutputTokens: 400,
81
+ }));
82
+ expect(provider).not.toBeNull();
83
+ // Large prompt - should warn about exceeding limit but still attempt
84
+ const largePrompt = { system: 'x'.repeat(10000), user: 'y'.repeat(10000) };
85
+ const result = await provider.analyze(largePrompt);
86
+ expect(typeof result).toBe('string');
87
+ });
88
+ });
89
+ //# sourceMappingURL=llmGroqTPM.test.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * LLM Provider Retry Tests
3
+ * Tests for retry behavior in createOpenAICompatibleProvider
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=llmProviderRetry.test.d.ts.map
@@ -0,0 +1,172 @@
1
+ /**
2
+ * LLM Provider Retry Tests
3
+ * Tests for retry behavior in createOpenAICompatibleProvider
4
+ */
5
+ import { createOpenAICompatibleProvider } from '../features/llmAnalysis.js';
6
+ function makeConfig(overrides = {}) {
7
+ return {
8
+ provider: 'openai-compatible',
9
+ baseUrl: 'http://localhost:11434/v1/chat/completions',
10
+ model: 'llama3',
11
+ apiKeyEnv: 'DUMMY_KEY',
12
+ timeoutMs: 5000,
13
+ jsonMode: false,
14
+ maxInputChars: 1000,
15
+ maxOutputTokens: 100,
16
+ temperature: 0,
17
+ systemPromptAddendum: '',
18
+ includeMitreAtlasTechniques: false,
19
+ maxMitreAtlasTechniques: 0,
20
+ cacheDir: '/tmp/ferret-llm-cache',
21
+ cacheTtlHours: 1,
22
+ maxRetries: 2,
23
+ retryBackoffMs: 1,
24
+ retryMaxBackoffMs: 10,
25
+ minRequestIntervalMs: 0,
26
+ onlyIfFindings: false,
27
+ maxFindingsPerFile: 10,
28
+ maxFiles: 5,
29
+ minConfidence: 0.5,
30
+ ...overrides,
31
+ };
32
+ }
33
+ describe('createOpenAICompatibleProvider - retry behavior', () => {
34
+ it('retries on 429 rate limit and succeeds on second attempt', async () => {
35
+ let callCount = 0;
36
+ globalThis.fetch = jest.fn().mockImplementation(async () => {
37
+ callCount++;
38
+ if (callCount === 1) {
39
+ return {
40
+ ok: false,
41
+ status: 429,
42
+ headers: { get: () => null },
43
+ text: () => Promise.resolve('Rate limit exceeded'),
44
+ };
45
+ }
46
+ return {
47
+ ok: true,
48
+ status: 200,
49
+ json: () => Promise.resolve({
50
+ choices: [{ message: { content: '{"version":1,"findings":[]}' } }],
51
+ }),
52
+ };
53
+ });
54
+ const provider = createOpenAICompatibleProvider(makeConfig({ maxRetries: 2 }));
55
+ expect(provider).not.toBeNull();
56
+ const result = await provider.analyze({ system: 'sys', user: 'usr' });
57
+ expect(callCount).toBe(2);
58
+ expect(result).toContain('findings');
59
+ });
60
+ it('retries on 500 server error', async () => {
61
+ let callCount = 0;
62
+ globalThis.fetch = jest.fn().mockImplementation(async () => {
63
+ callCount++;
64
+ if (callCount < 2) {
65
+ return {
66
+ ok: false,
67
+ status: 500,
68
+ headers: { get: () => null },
69
+ text: () => Promise.resolve('Server error'),
70
+ };
71
+ }
72
+ return {
73
+ ok: true,
74
+ status: 200,
75
+ json: () => Promise.resolve({
76
+ choices: [{ message: { content: '{}' } }],
77
+ }),
78
+ };
79
+ });
80
+ const provider = createOpenAICompatibleProvider(makeConfig({ maxRetries: 2 }));
81
+ const result = await provider.analyze({ system: 'sys', user: 'usr' });
82
+ expect(callCount).toBe(2);
83
+ expect(typeof result).toBe('string');
84
+ });
85
+ it('throws after exhausting retries', async () => {
86
+ globalThis.fetch = jest.fn().mockResolvedValue({
87
+ ok: false,
88
+ status: 429,
89
+ headers: { get: () => null },
90
+ text: () => Promise.resolve('Rate limit'),
91
+ });
92
+ const provider = createOpenAICompatibleProvider(makeConfig({ maxRetries: 1 }));
93
+ await expect(provider.analyze({ system: 'sys', user: 'usr' })).rejects.toThrow('LLM HTTP 429');
94
+ });
95
+ it('throws immediately for non-retryable 400 errors', async () => {
96
+ globalThis.fetch = jest.fn().mockResolvedValue({
97
+ ok: false,
98
+ status: 400,
99
+ headers: { get: () => null },
100
+ text: () => Promise.resolve('Bad request'),
101
+ });
102
+ const provider = createOpenAICompatibleProvider(makeConfig({ maxRetries: 3 }));
103
+ await expect(provider.analyze({ system: 'sys', user: 'usr' })).rejects.toThrow('LLM HTTP 400');
104
+ // Should only have called fetch once (no retry for 400)
105
+ expect(globalThis.fetch.mock.calls).toHaveLength(1);
106
+ });
107
+ it('respects Retry-After header', async () => {
108
+ let callCount = 0;
109
+ globalThis.fetch = jest.fn().mockImplementation(async () => {
110
+ callCount++;
111
+ if (callCount === 1) {
112
+ return {
113
+ ok: false,
114
+ status: 429,
115
+ headers: { get: (name) => name === 'retry-after' ? '0' : null },
116
+ text: () => Promise.resolve('Rate limited'),
117
+ };
118
+ }
119
+ return {
120
+ ok: true,
121
+ status: 200,
122
+ json: () => Promise.resolve({
123
+ choices: [{ message: { content: '{}' } }],
124
+ }),
125
+ };
126
+ });
127
+ const provider = createOpenAICompatibleProvider(makeConfig({ maxRetries: 2 }));
128
+ const result = await provider.analyze({ system: 'sys', user: 'usr' });
129
+ expect(typeof result).toBe('string');
130
+ });
131
+ it('falls back from jsonMode when unsupported (HTTP 400 with response_format error)', async () => {
132
+ let callCount = 0;
133
+ globalThis.fetch = jest.fn().mockImplementation(async (_url, opts) => {
134
+ callCount++;
135
+ const body = JSON.parse(opts.body);
136
+ if (callCount === 1 && body.response_format) {
137
+ // Simulate provider returning a 400 with response_format rejection
138
+ return {
139
+ ok: false,
140
+ status: 400,
141
+ headers: { get: () => null },
142
+ text: () => Promise.resolve('unknown field: response_format - json_validate_failed'),
143
+ };
144
+ }
145
+ return {
146
+ ok: true,
147
+ status: 200,
148
+ json: () => Promise.resolve({
149
+ choices: [{ message: { content: '{"findings":[]}' } }],
150
+ }),
151
+ };
152
+ });
153
+ // Enable jsonMode - it should fallback when unsupported (400 with response_format error message)
154
+ const provider = createOpenAICompatibleProvider(makeConfig({ jsonMode: true, maxRetries: 0 }));
155
+ const result = await provider.analyze({ system: 'sys', user: 'usr' });
156
+ expect(typeof result).toBe('string');
157
+ expect(callCount).toBe(2); // First with jsonMode, second without
158
+ });
159
+ it('handles minRequestIntervalMs throttling', async () => {
160
+ globalThis.fetch = jest.fn().mockResolvedValue({
161
+ ok: true,
162
+ status: 200,
163
+ json: () => Promise.resolve({
164
+ choices: [{ message: { content: '{}' } }],
165
+ }),
166
+ });
167
+ const provider = createOpenAICompatibleProvider(makeConfig({ minRequestIntervalMs: 0 }));
168
+ const result = await provider.analyze({ system: 'sys', user: 'usr' });
169
+ expect(typeof result).toBe('string');
170
+ });
171
+ });
172
+ //# sourceMappingURL=llmProviderRetry.test.js.map
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Additional MCP Validator Tests
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=mcpValidator.extra.test.d.ts.map