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,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,6 @@
1
+ /**
2
+ * Additional LLM Analysis Tests
3
+ * Covers cache behavior, groq provider, and more analyzeWithLlm scenarios
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=llmAnalysisExtra.test.d.ts.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,6 @@
1
+ /**
2
+ * LLM Analysis Filter Tests
3
+ * Tests for credential placeholder filtering and other edge cases
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=llmAnalysisFilters.test.d.ts.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
@@ -0,0 +1,6 @@
1
+ /**
2
+ * LLM Analysis MITRE Atlas Tests
3
+ * Tests for analyzeWithLlm with MITRE atlas options
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=llmAnalysisMitre.test.d.ts.map