ferret-scan 2.2.0 → 2.4.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 +17 -0
- package/README.md +15 -11
- package/bin/ferret.js +104 -8
- 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/features/customRules.js +22 -29
- 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/intelligence/ThreatFeed.js +207 -62
- package/dist/remediation/Quarantine.js +24 -6
- 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/scanner/IAnalyzer.d.ts +19 -0
- package/dist/scanner/IAnalyzer.js +5 -0
- 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 +17 -0
- package/dist/types.js +1 -1
- package/dist/utils/safeRegex.d.ts +12 -51
- package/dist/utils/safeRegex.js +45 -62
- package/dist/utils/schemas.d.ts +64 -64
- package/package.json +25 -19
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Analysis buildFindingsAwareExcerpt Tests
|
|
3
|
+
* Tests the excerpt building logic for large files with existing findings
|
|
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: 500, // Small to force truncation
|
|
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/large-test.md',
|
|
39
|
+
relativePath: 'agents/large-test.md',
|
|
40
|
+
type: 'md',
|
|
41
|
+
component: 'agent',
|
|
42
|
+
size: 10000,
|
|
43
|
+
modified: new Date(),
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function makeFinding(lineNum, severity = 'HIGH') {
|
|
48
|
+
return {
|
|
49
|
+
ruleId: 'INJ-001',
|
|
50
|
+
ruleName: 'Injection',
|
|
51
|
+
severity,
|
|
52
|
+
category: 'injection',
|
|
53
|
+
file: '/project/.claude/agents/large-test.md',
|
|
54
|
+
relativePath: 'agents/large-test.md',
|
|
55
|
+
line: lineNum,
|
|
56
|
+
match: `finding at line ${lineNum}`,
|
|
57
|
+
context: [],
|
|
58
|
+
remediation: 'fix',
|
|
59
|
+
timestamp: new Date(),
|
|
60
|
+
riskScore: 75,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
describe('analyzeWithLlm - buildFindingsAwareExcerpt', () => {
|
|
64
|
+
let tmpDir;
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-excerpt-'));
|
|
67
|
+
});
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
it('handles large file with existing findings (triggers buildFindingsAwareExcerpt)', async () => {
|
|
72
|
+
const mockAnalyze = jest.fn().mockResolvedValue(JSON.stringify({ version: 1, findings: [] }));
|
|
73
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
74
|
+
const file = makeFile();
|
|
75
|
+
// Create a large file content (> maxInputChars to trigger truncation logic)
|
|
76
|
+
const largeContent = Array.from({ length: 200 }, (_, i) => `Line ${i + 1}: This is content on line ${i + 1} of the file`).join('\n');
|
|
77
|
+
// Provide existing findings at specific lines
|
|
78
|
+
const existingFindings = [
|
|
79
|
+
makeFinding(50, 'CRITICAL'),
|
|
80
|
+
makeFinding(100, 'HIGH'),
|
|
81
|
+
makeFinding(150, 'MEDIUM'),
|
|
82
|
+
];
|
|
83
|
+
const config = makeConfig({ cacheDir: tmpDir, maxInputChars: 500 });
|
|
84
|
+
const result = await analyzeWithLlm(provider, config, file, largeContent, existingFindings);
|
|
85
|
+
expect(result.ran).toBe(true);
|
|
86
|
+
expect(mockAnalyze).toHaveBeenCalled();
|
|
87
|
+
// The prompt should be truncated but include finding windows
|
|
88
|
+
const promptCall = mockAnalyze.mock.calls[0][0];
|
|
89
|
+
expect(promptCall.user.length).toBeLessThan(largeContent.length);
|
|
90
|
+
});
|
|
91
|
+
it('handles file smaller than maxInputChars without truncation', async () => {
|
|
92
|
+
const mockAnalyze = jest.fn().mockResolvedValue(JSON.stringify({ version: 1, findings: [] }));
|
|
93
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
94
|
+
const file = makeFile();
|
|
95
|
+
const smallContent = 'Line 1: Small content\nLine 2: More content\n';
|
|
96
|
+
const config = makeConfig({ cacheDir: tmpDir, maxInputChars: 10000 }); // Large enough
|
|
97
|
+
const result = await analyzeWithLlm(provider, config, file, smallContent, []);
|
|
98
|
+
expect(result.ran).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
it('handles large file with out-of-range finding lines', async () => {
|
|
101
|
+
const mockAnalyze = jest.fn().mockResolvedValue(JSON.stringify({ version: 1, findings: [] }));
|
|
102
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
103
|
+
const file = makeFile();
|
|
104
|
+
const content = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`).join('\n');
|
|
105
|
+
// Finding at line 9999 (out of range for 100-line file)
|
|
106
|
+
const findings = [makeFinding(9999)];
|
|
107
|
+
const config = makeConfig({ cacheDir: tmpDir, maxInputChars: 200 });
|
|
108
|
+
const result = await analyzeWithLlm(provider, config, file, content, findings);
|
|
109
|
+
expect(result.ran).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
it('prioritizes CRITICAL findings for excerpt windows', async () => {
|
|
112
|
+
let capturedPrompt = null;
|
|
113
|
+
const mockAnalyze = jest.fn().mockImplementation(async (prompt) => {
|
|
114
|
+
capturedPrompt = prompt;
|
|
115
|
+
return JSON.stringify({ version: 1, findings: [] });
|
|
116
|
+
});
|
|
117
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
118
|
+
const file = makeFile();
|
|
119
|
+
// Large content to force truncation
|
|
120
|
+
const content = Array.from({ length: 300 }, (_, i) => `Line ${i + 1}: content here for testing purposes`).join('\n');
|
|
121
|
+
// Critical finding at line 150 should be prioritized in excerpt
|
|
122
|
+
const findings = [
|
|
123
|
+
makeFinding(150, 'CRITICAL'), // Important - should be in excerpt
|
|
124
|
+
makeFinding(10, 'LOW'),
|
|
125
|
+
];
|
|
126
|
+
const config = makeConfig({ cacheDir: tmpDir, maxInputChars: 1000 });
|
|
127
|
+
await analyzeWithLlm(provider, config, file, content, findings);
|
|
128
|
+
// Verify the prompt was built
|
|
129
|
+
expect(capturedPrompt).not.toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
//# sourceMappingURL=llmAnalysisBuildExcerpt.test.js.map
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Additional LLM Analysis Tests
|
|
3
|
+
* Covers cache behavior, groq provider, and more analyzeWithLlm scenarios
|
|
4
|
+
*/
|
|
5
|
+
import { createLlmProvider, 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: 'OPENAI_API_KEY',
|
|
15
|
+
timeoutMs: 5000,
|
|
16
|
+
jsonMode: false,
|
|
17
|
+
maxInputChars: 10000,
|
|
18
|
+
maxOutputTokens: 500,
|
|
19
|
+
temperature: 0,
|
|
20
|
+
systemPromptAddendum: '',
|
|
21
|
+
includeMitreAtlasTechniques: false,
|
|
22
|
+
maxMitreAtlasTechniques: 0,
|
|
23
|
+
cacheDir: '/tmp/ferret-llm-test-cache',
|
|
24
|
+
cacheTtlHours: 1,
|
|
25
|
+
maxRetries: 0,
|
|
26
|
+
retryBackoffMs: 100,
|
|
27
|
+
retryMaxBackoffMs: 1000,
|
|
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 - caching', () => {
|
|
48
|
+
let tmpDir;
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-llm-cache-'));
|
|
51
|
+
jest.clearAllMocks();
|
|
52
|
+
});
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
it('uses cached result on second call with same content', async () => {
|
|
57
|
+
const mockAnalyze = jest.fn().mockResolvedValue(JSON.stringify({
|
|
58
|
+
version: 1,
|
|
59
|
+
findings: [{
|
|
60
|
+
title: 'Test Finding',
|
|
61
|
+
severity: 'HIGH',
|
|
62
|
+
category: 'injection',
|
|
63
|
+
match: 'bad',
|
|
64
|
+
remediation: 'fix',
|
|
65
|
+
confidence: 0.9,
|
|
66
|
+
}],
|
|
67
|
+
}));
|
|
68
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
69
|
+
const file = makeFile();
|
|
70
|
+
const content = 'test content for caching';
|
|
71
|
+
const config = makeConfig({ cacheDir: tmpDir, cacheTtlHours: 24 });
|
|
72
|
+
// First call
|
|
73
|
+
const result1 = await analyzeWithLlm(provider, config, file, content, []);
|
|
74
|
+
expect(mockAnalyze).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(result1.ran).toBe(true);
|
|
76
|
+
// Second call - should use cache
|
|
77
|
+
const result2 = await analyzeWithLlm(provider, config, file, content, []);
|
|
78
|
+
expect(mockAnalyze).toHaveBeenCalledTimes(1); // Not called again
|
|
79
|
+
expect(result2.ran).toBe(true);
|
|
80
|
+
expect(result2.findings).toHaveLength(result1.findings.length);
|
|
81
|
+
});
|
|
82
|
+
it('does not use cache for TTL=0', async () => {
|
|
83
|
+
const mockAnalyze = jest.fn().mockResolvedValue(JSON.stringify({
|
|
84
|
+
version: 1,
|
|
85
|
+
findings: [],
|
|
86
|
+
}));
|
|
87
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
88
|
+
const file = makeFile();
|
|
89
|
+
const content = 'content for no-cache test';
|
|
90
|
+
const config = makeConfig({ cacheDir: tmpDir, cacheTtlHours: 0 });
|
|
91
|
+
await analyzeWithLlm(provider, config, file, content, []);
|
|
92
|
+
await analyzeWithLlm(provider, config, file, content, []);
|
|
93
|
+
// With TTL=0, cache is always fresh (bypass) - check docs say ttl<=0 means always fresh
|
|
94
|
+
// The actual behavior: ttl=0 → always "fresh" → uses cache if present
|
|
95
|
+
expect(mockAnalyze.mock.calls.length).toBeGreaterThanOrEqual(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('analyzeWithLlm - retry behavior', () => {
|
|
99
|
+
it('retries on retryable status codes', async () => {
|
|
100
|
+
let callCount = 0;
|
|
101
|
+
const mockAnalyze = jest.fn().mockImplementation(async () => {
|
|
102
|
+
callCount++;
|
|
103
|
+
if (callCount === 1) {
|
|
104
|
+
const err = new Error('LLM HTTP 429: rate limited');
|
|
105
|
+
err.status = 429;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
return JSON.stringify({ version: 1, findings: [] });
|
|
109
|
+
});
|
|
110
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
111
|
+
const file = makeFile();
|
|
112
|
+
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-retry-'));
|
|
113
|
+
try {
|
|
114
|
+
const result = await analyzeWithLlm(provider, makeConfig({ maxRetries: 1, retryBackoffMs: 1, cacheDir }), file, 'test content', []);
|
|
115
|
+
// After retry, should succeed or fail gracefully
|
|
116
|
+
expect(typeof result.ran).toBe('boolean');
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
fs.rmSync(cacheDir, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('analyzeWithLlm - groq provider adaptations', () => {
|
|
124
|
+
it('uses groq-adapted token limits', async () => {
|
|
125
|
+
const mockAnalyze = jest.fn().mockResolvedValue(JSON.stringify({
|
|
126
|
+
version: 1,
|
|
127
|
+
findings: [],
|
|
128
|
+
}));
|
|
129
|
+
const provider = { name: 'openai-compatible', analyze: mockAnalyze };
|
|
130
|
+
const file = makeFile();
|
|
131
|
+
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-groq-'));
|
|
132
|
+
try {
|
|
133
|
+
const config = makeConfig({
|
|
134
|
+
baseUrl: 'https://api.groq.com/openai/v1/chat/completions',
|
|
135
|
+
maxOutputTokens: 1000, // Groq limits to 400
|
|
136
|
+
cacheDir,
|
|
137
|
+
});
|
|
138
|
+
process.env['TEST_GROQ_KEY'] = 'test-key';
|
|
139
|
+
const groqConfig = { ...config, apiKeyEnv: 'TEST_GROQ_KEY' };
|
|
140
|
+
const result = await analyzeWithLlm(provider, groqConfig, file, 'content', []);
|
|
141
|
+
expect(typeof result.ran).toBe('boolean');
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
delete process.env['TEST_GROQ_KEY'];
|
|
145
|
+
fs.rmSync(cacheDir, { recursive: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('analyzeWithLlm - systemPromptAddendum', () => {
|
|
150
|
+
it('includes custom addendum in prompt', async () => {
|
|
151
|
+
let capturedPrompt = null;
|
|
152
|
+
const mockAnalyze = jest.fn().mockImplementation(async (prompt) => {
|
|
153
|
+
capturedPrompt = prompt;
|
|
154
|
+
return JSON.stringify({ version: 1, findings: [] });
|
|
155
|
+
});
|
|
156
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
157
|
+
const file = makeFile();
|
|
158
|
+
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-addendum-'));
|
|
159
|
+
try {
|
|
160
|
+
await analyzeWithLlm(provider, makeConfig({ systemPromptAddendum: 'CUSTOM: Do extra checks for X', cacheDir }), file, 'file content', []);
|
|
161
|
+
const p = capturedPrompt;
|
|
162
|
+
expect(p?.system).toContain('CUSTOM: Do extra checks for X');
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
fs.rmSync(cacheDir, { recursive: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe('analyzeWithLlm - maxFindingsPerFile limit', () => {
|
|
170
|
+
it('limits findings to maxFindingsPerFile', async () => {
|
|
171
|
+
const manyFindings = Array.from({ length: 20 }, (_, i) => ({
|
|
172
|
+
title: `Finding ${i}`,
|
|
173
|
+
severity: 'MEDIUM',
|
|
174
|
+
category: 'injection',
|
|
175
|
+
match: `match${i}`,
|
|
176
|
+
remediation: 'fix',
|
|
177
|
+
confidence: 0.9,
|
|
178
|
+
}));
|
|
179
|
+
const mockAnalyze = jest.fn().mockResolvedValue(JSON.stringify({
|
|
180
|
+
version: 1,
|
|
181
|
+
findings: manyFindings,
|
|
182
|
+
}));
|
|
183
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
184
|
+
const file = makeFile();
|
|
185
|
+
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-limit-'));
|
|
186
|
+
try {
|
|
187
|
+
const result = await analyzeWithLlm(provider, makeConfig({ maxFindingsPerFile: 5, cacheDir }), file, 'many findings content', []);
|
|
188
|
+
if (result.ran) {
|
|
189
|
+
expect(result.findings.length).toBeLessThanOrEqual(5);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
fs.rmSync(cacheDir, { recursive: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
describe('createLlmProvider - edge cases', () => {
|
|
198
|
+
it('returns null for 127.0.0.1 is local but non-localhost detection', () => {
|
|
199
|
+
const provider = createLlmProvider(makeConfig({
|
|
200
|
+
baseUrl: 'http://127.0.0.1:11434/v1/chat/completions',
|
|
201
|
+
}));
|
|
202
|
+
expect(provider).not.toBeNull();
|
|
203
|
+
});
|
|
204
|
+
it('handles Groq provider with key', () => {
|
|
205
|
+
process.env['GROQ_API_KEY'] = 'gsk_test_key_123';
|
|
206
|
+
const provider = createLlmProvider(makeConfig({
|
|
207
|
+
baseUrl: 'https://api.groq.com/openai/v1/chat/completions',
|
|
208
|
+
apiKeyEnv: 'GROQ_API_KEY',
|
|
209
|
+
}));
|
|
210
|
+
expect(provider).not.toBeNull();
|
|
211
|
+
delete process.env['GROQ_API_KEY'];
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
//# sourceMappingURL=llmAnalysisExtra.test.js.map
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Analysis Filter Tests
|
|
3
|
+
* Tests for credential placeholder filtering and other edge cases
|
|
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: 10000,
|
|
18
|
+
maxOutputTokens: 500,
|
|
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() {
|
|
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
|
+
};
|
|
45
|
+
}
|
|
46
|
+
describe('analyzeWithLlm - credential placeholder filtering', () => {
|
|
47
|
+
let tmpDir;
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-llm-filter-'));
|
|
50
|
+
});
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
it('filters out env var placeholder false positives in credentials category', async () => {
|
|
55
|
+
const mockResponse = JSON.stringify({
|
|
56
|
+
version: 1,
|
|
57
|
+
findings: [
|
|
58
|
+
{
|
|
59
|
+
title: 'Env Placeholder',
|
|
60
|
+
severity: 'HIGH',
|
|
61
|
+
category: 'credentials',
|
|
62
|
+
match: '${MY_API_KEY}', // This is a placeholder, not an actual secret
|
|
63
|
+
remediation: 'fix',
|
|
64
|
+
confidence: 0.9,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
title: 'Real Secret',
|
|
68
|
+
severity: 'CRITICAL',
|
|
69
|
+
category: 'credentials',
|
|
70
|
+
match: 'sk-realSecretKey12345', // This is a real-looking secret
|
|
71
|
+
remediation: 'fix',
|
|
72
|
+
confidence: 0.9,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
const mockAnalyze = jest.fn().mockResolvedValue(mockResponse);
|
|
77
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
78
|
+
const config = makeConfig({ cacheDir: tmpDir });
|
|
79
|
+
const result = await analyzeWithLlm(provider, config, makeFile(), 'content', []);
|
|
80
|
+
if (result.ran) {
|
|
81
|
+
// Placeholder ${MY_API_KEY} should be filtered out
|
|
82
|
+
const placeholderFindings = result.findings.filter(f => f.match.includes('${MY_API_KEY}'));
|
|
83
|
+
expect(placeholderFindings).toHaveLength(0);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
it('keeps credential findings that look like actual secrets', async () => {
|
|
87
|
+
const mockResponse = JSON.stringify({
|
|
88
|
+
version: 1,
|
|
89
|
+
findings: [
|
|
90
|
+
{
|
|
91
|
+
title: 'GitHub Token Found',
|
|
92
|
+
severity: 'CRITICAL',
|
|
93
|
+
category: 'credentials',
|
|
94
|
+
match: 'ghp_realToken1234567890abcdefgh',
|
|
95
|
+
remediation: 'remove token',
|
|
96
|
+
confidence: 0.95,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
const mockAnalyze = jest.fn().mockResolvedValue(mockResponse);
|
|
101
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
102
|
+
const config = makeConfig({ cacheDir: tmpDir });
|
|
103
|
+
const result = await analyzeWithLlm(provider, config, makeFile(), 'content', []);
|
|
104
|
+
if (result.ran) {
|
|
105
|
+
// Real secrets should not be filtered
|
|
106
|
+
const tokenFindings = result.findings.filter(f => f.match.includes('ghp_'));
|
|
107
|
+
expect(tokenFindings.length).toBeGreaterThan(0);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
it('handles findings with notes field', async () => {
|
|
111
|
+
const mockResponse = JSON.stringify({
|
|
112
|
+
version: 1,
|
|
113
|
+
findings: [
|
|
114
|
+
{
|
|
115
|
+
title: 'Finding With Notes',
|
|
116
|
+
severity: 'MEDIUM',
|
|
117
|
+
category: 'injection',
|
|
118
|
+
match: 'suspicious content',
|
|
119
|
+
remediation: 'fix',
|
|
120
|
+
confidence: 0.8,
|
|
121
|
+
notes: 'This is a potential prompt injection attempt',
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
const mockAnalyze = jest.fn().mockResolvedValue(mockResponse);
|
|
126
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
127
|
+
const config = makeConfig({ cacheDir: tmpDir });
|
|
128
|
+
const result = await analyzeWithLlm(provider, config, makeFile(), 'content', []);
|
|
129
|
+
expect(Array.isArray(result.findings)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
it('handles onlyIfFindings=true with existing findings', async () => {
|
|
132
|
+
const mockAnalyze = jest.fn().mockResolvedValue(JSON.stringify({
|
|
133
|
+
version: 1, findings: [],
|
|
134
|
+
}));
|
|
135
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
136
|
+
const file = makeFile();
|
|
137
|
+
const config = makeConfig({ onlyIfFindings: true, cacheDir: tmpDir });
|
|
138
|
+
const existingFinding = {
|
|
139
|
+
ruleId: 'INJ-001',
|
|
140
|
+
ruleName: 'Test',
|
|
141
|
+
severity: 'HIGH',
|
|
142
|
+
category: 'injection',
|
|
143
|
+
file: '/project/.claude/agents/test.md',
|
|
144
|
+
relativePath: 'agents/test.md',
|
|
145
|
+
line: 1,
|
|
146
|
+
match: 'existing',
|
|
147
|
+
context: [],
|
|
148
|
+
remediation: 'fix',
|
|
149
|
+
timestamp: new Date(),
|
|
150
|
+
riskScore: 75,
|
|
151
|
+
};
|
|
152
|
+
const result = await analyzeWithLlm(provider, config, file, 'content', [existingFinding]);
|
|
153
|
+
// With onlyIfFindings=true and existing findings, should analyze
|
|
154
|
+
expect(result.ran).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
it('handles $VARIABLE placeholders filtering', async () => {
|
|
157
|
+
const mockResponse = JSON.stringify({
|
|
158
|
+
version: 1,
|
|
159
|
+
findings: [
|
|
160
|
+
{
|
|
161
|
+
title: 'Env Var Placeholder',
|
|
162
|
+
severity: 'HIGH',
|
|
163
|
+
category: 'credentials',
|
|
164
|
+
match: '$MY_TOKEN', // Dollar sign placeholder
|
|
165
|
+
remediation: 'fix',
|
|
166
|
+
confidence: 0.9,
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
const mockAnalyze = jest.fn().mockResolvedValue(mockResponse);
|
|
171
|
+
const provider = { name: 'test', analyze: mockAnalyze };
|
|
172
|
+
const config = makeConfig({ cacheDir: tmpDir });
|
|
173
|
+
const result = await analyzeWithLlm(provider, config, makeFile(), 'content', []);
|
|
174
|
+
if (result.ran) {
|
|
175
|
+
// $MY_TOKEN is a placeholder and should be filtered
|
|
176
|
+
const placeholderFindings = result.findings.filter(f => f.match === '$MY_TOKEN');
|
|
177
|
+
expect(placeholderFindings).toHaveLength(0);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
//# sourceMappingURL=llmAnalysisFilters.test.js.map
|