ferret-scan 2.1.2 → 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.
- package/CHANGELOG.md +35 -0
- package/README.md +15 -11
- package/bin/ferret.js +109 -13
- package/dist/__tests__/AgentMonitor.test.d.ts +6 -0
- package/dist/__tests__/AgentMonitor.test.js +235 -0
- package/dist/__tests__/AtlasNavigatorReporter.test.d.ts +6 -0
- package/dist/__tests__/AtlasNavigatorReporter.test.js +193 -0
- package/dist/__tests__/CorrelationAnalyzer.test.d.ts +6 -0
- package/dist/__tests__/CorrelationAnalyzer.test.js +211 -0
- package/dist/__tests__/IndicatorMatcher.test.d.ts +6 -0
- package/dist/__tests__/IndicatorMatcher.test.js +245 -0
- package/dist/__tests__/MarketplaceScanner.test.d.ts +5 -0
- package/dist/__tests__/MarketplaceScanner.test.js +212 -0
- package/dist/__tests__/RuleGenerator.test.d.ts +6 -0
- package/dist/__tests__/RuleGenerator.test.js +207 -0
- package/dist/__tests__/ThreatFeed.test.d.ts +6 -0
- package/dist/__tests__/ThreatFeed.test.js +359 -0
- package/dist/__tests__/WatchMode.test.d.ts +6 -0
- package/dist/__tests__/WatchMode.test.js +104 -0
- package/dist/__tests__/astAnalyzerExtra.test.d.ts +6 -0
- package/dist/__tests__/astAnalyzerExtra.test.js +67 -0
- package/dist/__tests__/astAnalyzerFull.test.d.ts +6 -0
- package/dist/__tests__/astAnalyzerFull.test.js +138 -0
- package/dist/__tests__/astAnalyzerPatterns.test.d.ts +6 -0
- package/dist/__tests__/astAnalyzerPatterns.test.js +143 -0
- package/dist/__tests__/atlas.test.d.ts +6 -0
- package/dist/__tests__/atlas.test.js +319 -0
- package/dist/__tests__/atlasCatalog.test.d.ts +6 -0
- package/dist/__tests__/atlasCatalog.test.js +200 -0
- package/dist/__tests__/atlasCatalogExtra.test.d.ts +6 -0
- package/dist/__tests__/atlasCatalogExtra.test.js +215 -0
- package/dist/__tests__/baseline.test.d.ts +6 -0
- package/dist/__tests__/baseline.test.js +321 -0
- package/dist/__tests__/baselineExtra.test.d.ts +6 -0
- package/dist/__tests__/baselineExtra.test.js +317 -0
- package/dist/__tests__/capabilityMapping.test.d.ts +5 -0
- package/dist/__tests__/capabilityMapping.test.js +49 -0
- package/dist/__tests__/capabilityMappingExtra.test.d.ts +5 -0
- package/dist/__tests__/capabilityMappingExtra.test.js +200 -0
- package/dist/__tests__/complianceExtra.test.d.ts +6 -0
- package/dist/__tests__/complianceExtra.test.js +121 -0
- package/dist/__tests__/config.test.js +1 -1
- package/dist/__tests__/configLoader.test.d.ts +6 -0
- package/dist/__tests__/configLoader.test.js +225 -0
- package/dist/__tests__/configLoaderExtra.test.d.ts +6 -0
- package/dist/__tests__/configLoaderExtra.test.js +186 -0
- package/dist/__tests__/correlationAnalyzerExtra.test.d.ts +5 -0
- package/dist/__tests__/correlationAnalyzerExtra.test.js +98 -0
- package/dist/__tests__/correlationAnalyzerFull.test.d.ts +6 -0
- package/dist/__tests__/correlationAnalyzerFull.test.js +154 -0
- package/dist/__tests__/customRules.extra.test.d.ts +6 -0
- package/dist/__tests__/customRules.extra.test.js +245 -0
- package/dist/__tests__/customRules.test.d.ts +7 -0
- package/dist/__tests__/customRules.test.js +347 -0
- package/dist/__tests__/dependencyRisk.test.d.ts +5 -0
- package/dist/__tests__/dependencyRisk.test.js +248 -0
- package/dist/__tests__/dependencyRiskExtra.test.d.ts +6 -0
- package/dist/__tests__/dependencyRiskExtra.test.js +177 -0
- package/dist/__tests__/featureExitCodes.test.d.ts +7 -0
- package/dist/__tests__/featureExitCodes.test.js +332 -0
- package/dist/__tests__/fileDiscoveryConfigOnly.test.d.ts +6 -0
- package/dist/__tests__/fileDiscoveryConfigOnly.test.js +195 -0
- package/dist/__tests__/fileDiscoveryExtra.test.d.ts +6 -0
- package/dist/__tests__/fileDiscoveryExtra.test.js +149 -0
- package/dist/__tests__/fixer.extra.test.d.ts +6 -0
- package/dist/__tests__/fixer.extra.test.js +135 -0
- package/dist/__tests__/fixerApply.test.d.ts +6 -0
- package/dist/__tests__/fixerApply.test.js +132 -0
- package/dist/__tests__/gitHooks.test.d.ts +7 -0
- package/dist/__tests__/gitHooks.test.js +188 -0
- package/dist/__tests__/htmlReporter.extra.test.d.ts +5 -0
- package/dist/__tests__/htmlReporter.extra.test.js +126 -0
- package/dist/__tests__/interactiveTui.test.d.ts +6 -0
- package/dist/__tests__/interactiveTui.test.js +180 -0
- package/dist/__tests__/interactiveTuiCommands.test.d.ts +6 -0
- package/dist/__tests__/interactiveTuiCommands.test.js +187 -0
- package/dist/__tests__/interactiveTuiMore.test.d.ts +6 -0
- package/dist/__tests__/interactiveTuiMore.test.js +194 -0
- package/dist/__tests__/interactiveTuiSession.test.d.ts +6 -0
- package/dist/__tests__/interactiveTuiSession.test.js +173 -0
- package/dist/__tests__/llmAnalysis.test.d.ts +6 -0
- package/dist/__tests__/llmAnalysis.test.js +229 -0
- package/dist/__tests__/llmAnalysisBuildExcerpt.test.d.ts +6 -0
- package/dist/__tests__/llmAnalysisBuildExcerpt.test.js +132 -0
- package/dist/__tests__/llmAnalysisExtra.test.d.ts +6 -0
- package/dist/__tests__/llmAnalysisExtra.test.js +214 -0
- package/dist/__tests__/llmAnalysisFilters.test.d.ts +6 -0
- package/dist/__tests__/llmAnalysisFilters.test.js +181 -0
- package/dist/__tests__/llmAnalysisMitre.test.d.ts +6 -0
- package/dist/__tests__/llmAnalysisMitre.test.js +192 -0
- package/dist/__tests__/llmGroqTPM.test.d.ts +6 -0
- package/dist/__tests__/llmGroqTPM.test.js +89 -0
- package/dist/__tests__/llmProviderRetry.test.d.ts +6 -0
- package/dist/__tests__/llmProviderRetry.test.js +172 -0
- package/dist/__tests__/mcpValidator.extra.test.d.ts +5 -0
- package/dist/__tests__/mcpValidator.extra.test.js +270 -0
- package/dist/__tests__/patternMatcherExtra.test.d.ts +7 -0
- package/dist/__tests__/patternMatcherExtra.test.js +198 -0
- package/dist/__tests__/patternsCommon.test.d.ts +6 -0
- package/dist/__tests__/patternsCommon.test.js +107 -0
- package/dist/__tests__/policyEnforcement.test.d.ts +5 -0
- package/dist/__tests__/policyEnforcement.test.js +510 -0
- package/dist/__tests__/quarantineExtra.test.d.ts +5 -0
- package/dist/__tests__/quarantineExtra.test.js +214 -0
- package/dist/__tests__/redactionExtra.test.d.ts +6 -0
- package/dist/__tests__/redactionExtra.test.js +228 -0
- package/dist/__tests__/scanDiff.test.d.ts +7 -0
- package/dist/__tests__/scanDiff.test.js +266 -0
- package/dist/__tests__/scanFull.test.d.ts +6 -0
- package/dist/__tests__/scanFull.test.js +158 -0
- package/dist/__tests__/scannerDampening.test.d.ts +6 -0
- package/dist/__tests__/scannerDampening.test.js +160 -0
- package/dist/__tests__/scannerExtra.test.d.ts +6 -0
- package/dist/__tests__/scannerExtra.test.js +194 -0
- package/dist/__tests__/scannerMitre.test.d.ts +5 -0
- package/dist/__tests__/scannerMitre.test.js +141 -0
- package/dist/__tests__/scannerSSRF.test.d.ts +5 -0
- package/dist/__tests__/scannerSSRF.test.js +149 -0
- package/dist/__tests__/schemas.test.d.ts +6 -0
- package/dist/__tests__/schemas.test.js +125 -0
- package/dist/__tests__/webhooks.extra.test.d.ts +6 -0
- package/dist/__tests__/webhooks.extra.test.js +144 -0
- package/dist/__tests__/webhooks.test.d.ts +6 -0
- package/dist/__tests__/webhooks.test.js +154 -0
- package/dist/analyzers/AstAnalyzer.d.ts +5 -1
- package/dist/analyzers/AstAnalyzer.js +25 -4
- package/dist/features/customRules.js +22 -29
- package/dist/features/ignoreComments.js +5 -5
- package/dist/features/mcpTrustScore.d.ts +17 -0
- package/dist/features/mcpTrustScore.js +74 -0
- package/dist/features/mcpValidator.d.ts +2 -0
- package/dist/features/mcpValidator.js +13 -0
- package/dist/features/policyEnforcement.d.ts +22 -22
- package/dist/features/policyEnforcement.js +3 -2
- package/dist/intelligence/ThreatFeed.js +207 -62
- package/dist/remediation/Fixer.js +56 -30
- package/dist/remediation/Quarantine.js +79 -11
- package/dist/reporters/ConsoleReporter.js +10 -0
- package/dist/reporters/HtmlReporter.js +5 -0
- package/dist/reporters/SarifReporter.d.ts +1 -0
- package/dist/reporters/SarifReporter.js +1 -0
- package/dist/rules/ai-specific.js +8 -8
- package/dist/rules/backdoors.js +12 -12
- package/dist/rules/correlationRules.js +6 -6
- package/dist/rules/index.d.ts +1 -0
- package/dist/rules/index.js +10 -1
- package/dist/rules/injection.js +8 -8
- package/dist/rules/patterns/common.d.ts +34 -0
- package/dist/rules/patterns/common.js +48 -0
- package/dist/scanner/IAnalyzer.d.ts +19 -0
- package/dist/scanner/IAnalyzer.js +5 -0
- package/dist/scanner/PatternMatcher.js +19 -2
- package/dist/scanner/Scanner.js +64 -125
- package/dist/scanner/analyzers/CapabilityAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/CapabilityAnalyzer.js +19 -0
- package/dist/scanner/analyzers/DependencyAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/DependencyAnalyzer.js +18 -0
- package/dist/scanner/analyzers/EntropyAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/EntropyAnalyzer.js +12 -0
- package/dist/scanner/analyzers/LlmAnalyzer.d.ts +17 -0
- package/dist/scanner/analyzers/LlmAnalyzer.js +36 -0
- package/dist/scanner/analyzers/McpAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/McpAnalyzer.js +19 -0
- package/dist/scanner/analyzers/SemanticAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/SemanticAnalyzer.js +21 -0
- package/dist/scanner/analyzers/ThreatIntelAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/ThreatIntelAnalyzer.js +21 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.js +1 -1
- package/dist/utils/baseline.d.ts +15 -2
- package/dist/utils/baseline.js +50 -19
- package/dist/utils/contentCache.d.ts +39 -0
- package/dist/utils/contentCache.js +77 -0
- package/dist/utils/glob.d.ts +50 -0
- package/dist/utils/glob.js +84 -0
- package/dist/utils/pathSecurity.js +1 -0
- package/dist/utils/safeRegex.d.ts +55 -0
- package/dist/utils/safeRegex.js +130 -0
- package/dist/utils/schemas.d.ts +70 -64
- package/dist/utils/schemas.js +13 -0
- package/package.json +34 -19
|
@@ -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,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,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
|