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,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* More Interactive TUI Command Tests
|
|
3
|
+
* Tests for files, export, clear, filter variations, and sort variations
|
|
4
|
+
*/
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
const mockRlInstance2 = new EventEmitter();
|
|
7
|
+
jest.mock('node:readline', () => ({
|
|
8
|
+
createInterface: jest.fn().mockReturnValue(mockRlInstance2),
|
|
9
|
+
}));
|
|
10
|
+
import { startInteractiveSession } from '../features/interactiveTui.js';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import * as os from 'node:os';
|
|
14
|
+
function makeFinding(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
ruleId: 'INJ-001',
|
|
17
|
+
ruleName: 'Injection Rule',
|
|
18
|
+
severity: 'HIGH',
|
|
19
|
+
category: 'injection',
|
|
20
|
+
file: '/project/test.md',
|
|
21
|
+
relativePath: 'test.md',
|
|
22
|
+
line: 5,
|
|
23
|
+
match: 'IGNORE PREVIOUS',
|
|
24
|
+
context: [],
|
|
25
|
+
remediation: 'Remove',
|
|
26
|
+
timestamp: new Date(),
|
|
27
|
+
riskScore: 75,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function makeScanResult(findings = []) {
|
|
32
|
+
return {
|
|
33
|
+
success: true,
|
|
34
|
+
startTime: new Date(),
|
|
35
|
+
endTime: new Date(),
|
|
36
|
+
duration: 1000,
|
|
37
|
+
scannedPaths: ['/project'],
|
|
38
|
+
totalFiles: 5,
|
|
39
|
+
analyzedFiles: 4,
|
|
40
|
+
skippedFiles: 1,
|
|
41
|
+
findings,
|
|
42
|
+
findingsBySeverity: {
|
|
43
|
+
CRITICAL: findings.filter(f => f.severity === 'CRITICAL'),
|
|
44
|
+
HIGH: findings.filter(f => f.severity === 'HIGH'),
|
|
45
|
+
MEDIUM: findings.filter(f => f.severity === 'MEDIUM'),
|
|
46
|
+
LOW: findings.filter(f => f.severity === 'LOW'),
|
|
47
|
+
INFO: [],
|
|
48
|
+
},
|
|
49
|
+
findingsByCategory: {},
|
|
50
|
+
overallRiskScore: 50,
|
|
51
|
+
summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, total: findings.length },
|
|
52
|
+
errors: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function runCommands(scanResult, commands) {
|
|
56
|
+
const outputs = [];
|
|
57
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation((msg) => {
|
|
58
|
+
if (msg)
|
|
59
|
+
outputs.push(msg);
|
|
60
|
+
});
|
|
61
|
+
let callCount = 0;
|
|
62
|
+
mockRlInstance2.question = jest.fn((_prompt, cb) => {
|
|
63
|
+
const cmd = commands[callCount++] ?? 'quit';
|
|
64
|
+
cb(cmd);
|
|
65
|
+
});
|
|
66
|
+
mockRlInstance2.close = jest.fn(() => {
|
|
67
|
+
mockRlInstance2.emit('close');
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
await startInteractiveSession(scanResult);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
consoleSpy.mockRestore();
|
|
74
|
+
}
|
|
75
|
+
return outputs;
|
|
76
|
+
}
|
|
77
|
+
describe('startInteractiveSession - additional commands', () => {
|
|
78
|
+
let tmpDir;
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
jest.clearAllMocks();
|
|
81
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-tui-more-'));
|
|
82
|
+
});
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
85
|
+
});
|
|
86
|
+
it('handles files command', async () => {
|
|
87
|
+
const findings = [
|
|
88
|
+
makeFinding({ relativePath: 'agents/agent1.md' }),
|
|
89
|
+
makeFinding({ relativePath: 'agents/agent2.md', ruleId: 'CRED-001' }),
|
|
90
|
+
makeFinding({ relativePath: 'agents/agent1.md', ruleId: 'CRED-002' }),
|
|
91
|
+
];
|
|
92
|
+
const result = makeScanResult(findings);
|
|
93
|
+
const outputs = await runCommands(result, ['files', 'quit']);
|
|
94
|
+
const allOutput = outputs.join('');
|
|
95
|
+
expect(allOutput).toContain('by file');
|
|
96
|
+
});
|
|
97
|
+
it('handles files command without scan result', async () => {
|
|
98
|
+
const outputs = await runCommands(null, ['files', 'quit']);
|
|
99
|
+
const allOutput = outputs.join('');
|
|
100
|
+
expect(allOutput).toContain('No scan results');
|
|
101
|
+
});
|
|
102
|
+
it('handles export command', async () => {
|
|
103
|
+
const result = makeScanResult([makeFinding()]);
|
|
104
|
+
const exportFile = path.join(tmpDir, 'test-export.json');
|
|
105
|
+
const outputs = await runCommands(result, [`export ${exportFile}`, 'quit']);
|
|
106
|
+
const allOutput = outputs.join('');
|
|
107
|
+
expect(allOutput).toContain('exported');
|
|
108
|
+
});
|
|
109
|
+
it('handles export without scan result', async () => {
|
|
110
|
+
const outputs = await runCommands(null, ['export', 'quit']);
|
|
111
|
+
const allOutput = outputs.join('');
|
|
112
|
+
expect(allOutput).toContain('No scan results');
|
|
113
|
+
});
|
|
114
|
+
it('handles clear command', async () => {
|
|
115
|
+
const result = makeScanResult();
|
|
116
|
+
// clear writes to stdout, just verify it doesn't throw
|
|
117
|
+
await expect(runCommands(result, ['clear', 'quit'])).resolves.toBeDefined();
|
|
118
|
+
});
|
|
119
|
+
it('handles filter command with no args (shows current filters)', async () => {
|
|
120
|
+
const result = makeScanResult([makeFinding()]);
|
|
121
|
+
const outputs = await runCommands(result, ['filter', 'quit']);
|
|
122
|
+
const allOutput = outputs.join('');
|
|
123
|
+
expect(allOutput).toContain('filter');
|
|
124
|
+
});
|
|
125
|
+
it('handles filter clear severity', async () => {
|
|
126
|
+
const result = makeScanResult([makeFinding()]);
|
|
127
|
+
const outputs = await runCommands(result, ['filter severity NONE', 'quit']);
|
|
128
|
+
const allOutput = outputs.join('');
|
|
129
|
+
expect(allOutput).toContain('cleared');
|
|
130
|
+
});
|
|
131
|
+
it('handles filter invalid severity value', async () => {
|
|
132
|
+
const result = makeScanResult([makeFinding()]);
|
|
133
|
+
const outputs = await runCommands(result, ['filter severity INVALID', 'quit']);
|
|
134
|
+
const allOutput = outputs.join('');
|
|
135
|
+
expect(allOutput).toContain('Invalid severity');
|
|
136
|
+
});
|
|
137
|
+
it('handles filter category', async () => {
|
|
138
|
+
const result = makeScanResult([makeFinding()]);
|
|
139
|
+
const outputs = await runCommands(result, ['filter category injection', 'quit']);
|
|
140
|
+
const allOutput = outputs.join('');
|
|
141
|
+
expect(allOutput).toContain('Filtering by category');
|
|
142
|
+
});
|
|
143
|
+
it('handles filter category clear', async () => {
|
|
144
|
+
const result = makeScanResult([makeFinding()]);
|
|
145
|
+
const outputs = await runCommands(result, ['filter cat NONE', 'quit']);
|
|
146
|
+
const allOutput = outputs.join('');
|
|
147
|
+
expect(allOutput).toContain('cleared');
|
|
148
|
+
});
|
|
149
|
+
it('handles filter unknown type', async () => {
|
|
150
|
+
const result = makeScanResult([makeFinding()]);
|
|
151
|
+
const outputs = await runCommands(result, ['filter unknowntype value', 'quit']);
|
|
152
|
+
const allOutput = outputs.join('');
|
|
153
|
+
expect(allOutput).toContain('Unknown filter');
|
|
154
|
+
});
|
|
155
|
+
it('handles sort riskscore', async () => {
|
|
156
|
+
const result = makeScanResult([makeFinding()]);
|
|
157
|
+
const outputs = await runCommands(result, ['sort riskscore', 'quit']);
|
|
158
|
+
const allOutput = outputs.join('');
|
|
159
|
+
expect(allOutput).toContain('Sorting');
|
|
160
|
+
});
|
|
161
|
+
it('handles sort risk (alias for riskscore)', async () => {
|
|
162
|
+
const result = makeScanResult([makeFinding()]);
|
|
163
|
+
const outputs = await runCommands(result, ['sort risk', 'quit']);
|
|
164
|
+
const allOutput = outputs.join('');
|
|
165
|
+
expect(allOutput).toContain('Sorting');
|
|
166
|
+
});
|
|
167
|
+
it('handles sort invalid option', async () => {
|
|
168
|
+
const result = makeScanResult([makeFinding()]);
|
|
169
|
+
const outputs = await runCommands(result, ['sort invalid', 'quit']);
|
|
170
|
+
const allOutput = outputs.join('');
|
|
171
|
+
expect(allOutput).toContain('Unknown sort');
|
|
172
|
+
});
|
|
173
|
+
it('handles by-file alias for files command', async () => {
|
|
174
|
+
const result = makeScanResult([makeFinding()]);
|
|
175
|
+
const outputs = await runCommands(result, ['by-file', 'quit']);
|
|
176
|
+
const allOutput = outputs.join('');
|
|
177
|
+
expect(allOutput).toContain('by file');
|
|
178
|
+
});
|
|
179
|
+
it('handles show with no args uses current index', async () => {
|
|
180
|
+
const findings = [makeFinding({ ruleId: 'INJ-001' })];
|
|
181
|
+
const result = makeScanResult(findings);
|
|
182
|
+
const outputs = await runCommands(result, ['show', 'quit']);
|
|
183
|
+
const allOutput = outputs.join('');
|
|
184
|
+
expect(allOutput).toContain('INJ-001');
|
|
185
|
+
});
|
|
186
|
+
it('handles files command with many findings per file (shows truncated)', async () => {
|
|
187
|
+
const findings = Array.from({ length: 10 }, (_, i) => makeFinding({ relativePath: 'same/file.md', ruleId: `RULE-${i}`, line: i + 1 }));
|
|
188
|
+
const result = makeScanResult(findings);
|
|
189
|
+
const outputs = await runCommands(result, ['files', 'quit']);
|
|
190
|
+
const allOutput = outputs.join('');
|
|
191
|
+
expect(allOutput).toContain('more');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
//# sourceMappingURL=interactiveTuiMore.test.js.map
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive TUI Session Tests
|
|
3
|
+
* Tests for startInteractiveSession with mocked readline
|
|
4
|
+
*/
|
|
5
|
+
import { EventEmitter } from 'events';
|
|
6
|
+
// Mock readline
|
|
7
|
+
const mockRlInstance = new EventEmitter();
|
|
8
|
+
mockRlInstance.question = jest.fn();
|
|
9
|
+
mockRlInstance.close = jest.fn(() => {
|
|
10
|
+
mockRlInstance.emit('close');
|
|
11
|
+
});
|
|
12
|
+
mockRlInstance.setPrompt = jest.fn();
|
|
13
|
+
mockRlInstance.prompt = jest.fn();
|
|
14
|
+
jest.mock('node:readline', () => ({
|
|
15
|
+
createInterface: jest.fn().mockReturnValue(mockRlInstance),
|
|
16
|
+
}));
|
|
17
|
+
import { startInteractiveSession } from '../features/interactiveTui.js';
|
|
18
|
+
function makeFinding(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
ruleId: 'INJ-001',
|
|
21
|
+
ruleName: 'Injection Rule',
|
|
22
|
+
severity: 'HIGH',
|
|
23
|
+
category: 'injection',
|
|
24
|
+
file: '/project/test.md',
|
|
25
|
+
relativePath: 'test.md',
|
|
26
|
+
line: 5,
|
|
27
|
+
match: 'IGNORE PREVIOUS',
|
|
28
|
+
context: [],
|
|
29
|
+
remediation: 'Remove',
|
|
30
|
+
timestamp: new Date(),
|
|
31
|
+
riskScore: 75,
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function makeScanResult(findings = []) {
|
|
36
|
+
return {
|
|
37
|
+
success: true,
|
|
38
|
+
startTime: new Date(),
|
|
39
|
+
endTime: new Date(),
|
|
40
|
+
duration: 1000,
|
|
41
|
+
scannedPaths: ['/project'],
|
|
42
|
+
totalFiles: 5,
|
|
43
|
+
analyzedFiles: 4,
|
|
44
|
+
skippedFiles: 1,
|
|
45
|
+
findings,
|
|
46
|
+
findingsBySeverity: {
|
|
47
|
+
CRITICAL: findings.filter(f => f.severity === 'CRITICAL'),
|
|
48
|
+
HIGH: findings.filter(f => f.severity === 'HIGH'),
|
|
49
|
+
MEDIUM: findings.filter(f => f.severity === 'MEDIUM'),
|
|
50
|
+
LOW: findings.filter(f => f.severity === 'LOW'),
|
|
51
|
+
INFO: findings.filter(f => f.severity === 'INFO'),
|
|
52
|
+
},
|
|
53
|
+
findingsByCategory: {},
|
|
54
|
+
overallRiskScore: 50,
|
|
55
|
+
summary: {
|
|
56
|
+
critical: 0,
|
|
57
|
+
high: findings.filter(f => f.severity === 'HIGH').length,
|
|
58
|
+
medium: 0, low: 0, info: 0,
|
|
59
|
+
total: findings.length,
|
|
60
|
+
},
|
|
61
|
+
errors: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
describe('startInteractiveSession', () => {
|
|
65
|
+
let consoleSpy;
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
jest.clearAllMocks();
|
|
68
|
+
consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
|
|
69
|
+
});
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
consoleSpy.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
it('starts session with null scan result', async () => {
|
|
74
|
+
// Immediately close the session
|
|
75
|
+
mockRlInstance.question = jest.fn((_prompt, cb) => {
|
|
76
|
+
cb('quit');
|
|
77
|
+
});
|
|
78
|
+
const sessionPromise = startInteractiveSession(null);
|
|
79
|
+
// The 'close' event should fire after quit
|
|
80
|
+
await sessionPromise;
|
|
81
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
it('starts session with scan result', async () => {
|
|
84
|
+
const result = makeScanResult([makeFinding()]);
|
|
85
|
+
mockRlInstance.question = jest.fn((_prompt, cb) => {
|
|
86
|
+
cb('quit');
|
|
87
|
+
});
|
|
88
|
+
await startInteractiveSession(result);
|
|
89
|
+
const output = consoleSpy.mock.calls.flat().join('');
|
|
90
|
+
expect(output).toContain('Ferret Security Scanner');
|
|
91
|
+
});
|
|
92
|
+
it('handles help command', async () => {
|
|
93
|
+
const result = makeScanResult();
|
|
94
|
+
let callCount = 0;
|
|
95
|
+
mockRlInstance.question = jest.fn((_prompt, cb) => {
|
|
96
|
+
callCount++;
|
|
97
|
+
if (callCount === 1) {
|
|
98
|
+
cb('help');
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
cb('quit');
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
await startInteractiveSession(result);
|
|
105
|
+
const output = consoleSpy.mock.calls.flat().join('');
|
|
106
|
+
expect(output).toContain('Commands');
|
|
107
|
+
});
|
|
108
|
+
it('handles unknown command', async () => {
|
|
109
|
+
const result = makeScanResult();
|
|
110
|
+
let callCount = 0;
|
|
111
|
+
mockRlInstance.question = jest.fn((_prompt, cb) => {
|
|
112
|
+
callCount++;
|
|
113
|
+
if (callCount === 1) {
|
|
114
|
+
cb('unknowncommand123');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
cb('quit');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
await startInteractiveSession(result);
|
|
121
|
+
const output = consoleSpy.mock.calls.flat().join('');
|
|
122
|
+
expect(output).toContain('Unknown command');
|
|
123
|
+
});
|
|
124
|
+
it('handles empty input (whitespace)', async () => {
|
|
125
|
+
const result = makeScanResult();
|
|
126
|
+
let callCount = 0;
|
|
127
|
+
mockRlInstance.question = jest.fn((_prompt, cb) => {
|
|
128
|
+
callCount++;
|
|
129
|
+
if (callCount === 1) {
|
|
130
|
+
cb(' '); // whitespace
|
|
131
|
+
}
|
|
132
|
+
else if (callCount === 2) {
|
|
133
|
+
cb('quit');
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
await startInteractiveSession(result);
|
|
137
|
+
// Should not have printed "Unknown command" for empty input
|
|
138
|
+
const output = consoleSpy.mock.calls.flat().join('');
|
|
139
|
+
expect(output).not.toContain('Unknown command: ');
|
|
140
|
+
});
|
|
141
|
+
it('handles list command', async () => {
|
|
142
|
+
const result = makeScanResult([makeFinding(), makeFinding({ ruleId: 'CRED-001' })]);
|
|
143
|
+
let callCount = 0;
|
|
144
|
+
mockRlInstance.question = jest.fn((_prompt, cb) => {
|
|
145
|
+
callCount++;
|
|
146
|
+
if (callCount === 1) {
|
|
147
|
+
cb('list');
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
cb('quit');
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
await startInteractiveSession(result);
|
|
154
|
+
// list command should work
|
|
155
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
it('handles show command for a finding', async () => {
|
|
158
|
+
const result = makeScanResult([makeFinding()]);
|
|
159
|
+
let callCount = 0;
|
|
160
|
+
mockRlInstance.question = jest.fn((_prompt, cb) => {
|
|
161
|
+
callCount++;
|
|
162
|
+
if (callCount === 1) {
|
|
163
|
+
cb('show 1');
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
cb('quit');
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
await startInteractiveSession(result);
|
|
170
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
//# sourceMappingURL=interactiveTuiSession.test.js.map
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Analysis Tests
|
|
3
|
+
* Tests for createLlmProvider, createOpenAICompatibleProvider, and analyzeWithLlm
|
|
4
|
+
*/
|
|
5
|
+
import { createLlmProvider, createOpenAICompatibleProvider, analyzeWithLlm } 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: 'OPENAI_API_KEY',
|
|
12
|
+
timeoutMs: 5000,
|
|
13
|
+
jsonMode: true,
|
|
14
|
+
maxInputChars: 10000,
|
|
15
|
+
maxOutputTokens: 500,
|
|
16
|
+
temperature: 0,
|
|
17
|
+
systemPromptAddendum: '',
|
|
18
|
+
includeMitreAtlasTechniques: false,
|
|
19
|
+
maxMitreAtlasTechniques: 0,
|
|
20
|
+
cacheDir: '/tmp/ferret-llm-test-cache',
|
|
21
|
+
cacheTtlHours: 1,
|
|
22
|
+
maxRetries: 0,
|
|
23
|
+
retryBackoffMs: 100,
|
|
24
|
+
retryMaxBackoffMs: 1000,
|
|
25
|
+
minRequestIntervalMs: 0,
|
|
26
|
+
onlyIfFindings: false,
|
|
27
|
+
maxFindingsPerFile: 10,
|
|
28
|
+
maxFiles: 5,
|
|
29
|
+
minConfidence: 0.6,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
describe('createLlmProvider', () => {
|
|
34
|
+
it('returns null for unknown provider', () => {
|
|
35
|
+
const provider = createLlmProvider(makeConfig({ provider: 'unknown' }));
|
|
36
|
+
expect(provider).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
it('returns a provider for openai-compatible with localhost URL (no API key needed)', () => {
|
|
39
|
+
const provider = createLlmProvider(makeConfig({
|
|
40
|
+
provider: 'openai-compatible',
|
|
41
|
+
baseUrl: 'http://localhost:11434/v1/chat/completions',
|
|
42
|
+
}));
|
|
43
|
+
expect(provider).not.toBeNull();
|
|
44
|
+
expect(provider?.name).toBe('openai-compatible');
|
|
45
|
+
});
|
|
46
|
+
it('returns null for openai-compatible with non-local URL and no API key', () => {
|
|
47
|
+
const origKey = process.env['OPENAI_API_KEY'];
|
|
48
|
+
delete process.env['OPENAI_API_KEY'];
|
|
49
|
+
const provider = createLlmProvider(makeConfig({
|
|
50
|
+
provider: 'openai-compatible',
|
|
51
|
+
baseUrl: 'https://api.openai.com/v1/chat/completions',
|
|
52
|
+
apiKeyEnv: 'OPENAI_API_KEY',
|
|
53
|
+
}));
|
|
54
|
+
expect(provider).toBeNull();
|
|
55
|
+
if (origKey !== undefined)
|
|
56
|
+
process.env['OPENAI_API_KEY'] = origKey;
|
|
57
|
+
});
|
|
58
|
+
it('returns a provider when API key env var is set', () => {
|
|
59
|
+
const origKey = process.env['TEST_LLM_API_KEY'];
|
|
60
|
+
process.env['TEST_LLM_API_KEY'] = 'test-api-key-123';
|
|
61
|
+
const provider = createLlmProvider(makeConfig({
|
|
62
|
+
provider: 'openai-compatible',
|
|
63
|
+
baseUrl: 'https://api.openai.com/v1/chat/completions',
|
|
64
|
+
apiKeyEnv: 'TEST_LLM_API_KEY',
|
|
65
|
+
}));
|
|
66
|
+
expect(provider).not.toBeNull();
|
|
67
|
+
expect(provider?.name).toBe('openai-compatible');
|
|
68
|
+
if (origKey !== undefined)
|
|
69
|
+
process.env['TEST_LLM_API_KEY'] = origKey;
|
|
70
|
+
else
|
|
71
|
+
delete process.env['TEST_LLM_API_KEY'];
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('createOpenAICompatibleProvider', () => {
|
|
75
|
+
it('returns an object with analyze method', () => {
|
|
76
|
+
const provider = createOpenAICompatibleProvider(makeConfig({
|
|
77
|
+
baseUrl: 'http://localhost:11434/v1/chat/completions',
|
|
78
|
+
}));
|
|
79
|
+
expect(provider).not.toBeNull();
|
|
80
|
+
expect(typeof provider?.analyze).toBe('function');
|
|
81
|
+
});
|
|
82
|
+
it('returns null when no API key and non-localhost URL', () => {
|
|
83
|
+
const origKey = process.env['MY_API_KEY'];
|
|
84
|
+
delete process.env['MY_API_KEY'];
|
|
85
|
+
const provider = createOpenAICompatibleProvider(makeConfig({
|
|
86
|
+
baseUrl: 'https://api.groq.com/openai/v1/chat/completions',
|
|
87
|
+
apiKeyEnv: 'MY_API_KEY',
|
|
88
|
+
}));
|
|
89
|
+
expect(provider).toBeNull();
|
|
90
|
+
if (origKey !== undefined)
|
|
91
|
+
process.env['MY_API_KEY'] = origKey;
|
|
92
|
+
});
|
|
93
|
+
it('provider.analyze calls fetch with correct structure', async () => {
|
|
94
|
+
const mockResponse = {
|
|
95
|
+
ok: true,
|
|
96
|
+
status: 200,
|
|
97
|
+
json: () => Promise.resolve({
|
|
98
|
+
choices: [{ message: { content: '{"version":1,"findings":[]}' } }],
|
|
99
|
+
}),
|
|
100
|
+
text: () => Promise.resolve(''),
|
|
101
|
+
};
|
|
102
|
+
globalThis.fetch = jest.fn().mockResolvedValue(mockResponse);
|
|
103
|
+
const provider = createOpenAICompatibleProvider(makeConfig({
|
|
104
|
+
baseUrl: 'http://localhost:11434/v1/chat/completions',
|
|
105
|
+
jsonMode: false,
|
|
106
|
+
}));
|
|
107
|
+
expect(provider).not.toBeNull();
|
|
108
|
+
const result = await provider.analyze({ system: 'system prompt', user: 'user content' });
|
|
109
|
+
expect(result).toBe('{"version":1,"findings":[]}');
|
|
110
|
+
expect(globalThis.fetch).toHaveBeenCalledWith('http://localhost:11434/v1/chat/completions', expect.objectContaining({
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: expect.objectContaining({ 'content-type': 'application/json' }),
|
|
113
|
+
}));
|
|
114
|
+
});
|
|
115
|
+
it('provider.analyze throws on HTTP error', async () => {
|
|
116
|
+
globalThis.fetch = jest.fn().mockResolvedValue({
|
|
117
|
+
ok: false,
|
|
118
|
+
status: 401,
|
|
119
|
+
headers: { get: () => null },
|
|
120
|
+
text: () => Promise.resolve('Unauthorized'),
|
|
121
|
+
});
|
|
122
|
+
const provider = createOpenAICompatibleProvider(makeConfig({
|
|
123
|
+
baseUrl: 'http://localhost:11434/v1/chat/completions',
|
|
124
|
+
maxRetries: 0,
|
|
125
|
+
jsonMode: false,
|
|
126
|
+
}));
|
|
127
|
+
expect(provider).not.toBeNull();
|
|
128
|
+
await expect(provider.analyze({ system: 'sys', user: 'usr' })).rejects.toThrow('LLM HTTP 401');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe('analyzeWithLlm', () => {
|
|
132
|
+
const mockProvider = {
|
|
133
|
+
name: 'mock',
|
|
134
|
+
analyze: jest.fn(),
|
|
135
|
+
};
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
jest.clearAllMocks();
|
|
138
|
+
});
|
|
139
|
+
function makeFile(overrides = {}) {
|
|
140
|
+
return {
|
|
141
|
+
path: '/project/.claude/agents/test.md',
|
|
142
|
+
relativePath: 'agents/test.md',
|
|
143
|
+
type: 'md',
|
|
144
|
+
component: 'agent',
|
|
145
|
+
size: 100,
|
|
146
|
+
modified: new Date(),
|
|
147
|
+
...overrides,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
it('returns ran=false for non-AI file types', async () => {
|
|
151
|
+
// Use a JSON file that is not in an AI config directory - analyzeWithLlm checks shouldAnalyzeFileWithLlm
|
|
152
|
+
const file = makeFile({ type: 'json', path: '/project/package.json', relativePath: 'package.json', component: 'settings' });
|
|
153
|
+
const result = await analyzeWithLlm(mockProvider, makeConfig(), file, 'content', []);
|
|
154
|
+
expect(result.ran).toBe(false);
|
|
155
|
+
expect(result.findings).toHaveLength(0);
|
|
156
|
+
expect(mockProvider.analyze).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
it('returns ran=false when onlyIfFindings=true and no existing findings', async () => {
|
|
159
|
+
const file = makeFile();
|
|
160
|
+
const result = await analyzeWithLlm(mockProvider, makeConfig({ onlyIfFindings: true }), file, 'content', []);
|
|
161
|
+
expect(result.ran).toBe(false);
|
|
162
|
+
expect(mockProvider.analyze).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
it('returns findings when provider returns valid response', async () => {
|
|
165
|
+
const mockResponse = JSON.stringify({
|
|
166
|
+
version: 1,
|
|
167
|
+
findings: [
|
|
168
|
+
{
|
|
169
|
+
title: 'Prompt Injection',
|
|
170
|
+
severity: 'HIGH',
|
|
171
|
+
category: 'injection',
|
|
172
|
+
line: 5,
|
|
173
|
+
match: 'IGNORE PREVIOUS INSTRUCTIONS',
|
|
174
|
+
remediation: 'Remove instruction override',
|
|
175
|
+
confidence: 0.9,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
mockProvider.analyze.mockResolvedValue(mockResponse);
|
|
180
|
+
const file = makeFile();
|
|
181
|
+
const result = await analyzeWithLlm(mockProvider, makeConfig({ cacheDir: '/tmp/ferret-llm-no-cache-test-' + Date.now() }), file, 'line 1\nIGNORE PREVIOUS INSTRUCTIONS\nline 3', []);
|
|
182
|
+
expect(result.ran).toBe(true);
|
|
183
|
+
expect(result.findings.length).toBeGreaterThan(0);
|
|
184
|
+
expect(result.findings[0]?.severity).toBe('HIGH');
|
|
185
|
+
});
|
|
186
|
+
it('returns error when provider throws', async () => {
|
|
187
|
+
mockProvider.analyze.mockRejectedValue(new Error('Network error'));
|
|
188
|
+
const file = makeFile();
|
|
189
|
+
const result = await analyzeWithLlm(mockProvider, makeConfig({ cacheDir: '/tmp/ferret-llm-no-cache-test-' + Date.now() }), file, 'file content', []);
|
|
190
|
+
// When provider throws, either ran=false with error or ran=true with empty findings
|
|
191
|
+
expect(result.findings).toHaveLength(0);
|
|
192
|
+
expect(result.error).toBeDefined();
|
|
193
|
+
});
|
|
194
|
+
it('filters findings below minConfidence', async () => {
|
|
195
|
+
const mockResponse = JSON.stringify({
|
|
196
|
+
version: 1,
|
|
197
|
+
findings: [
|
|
198
|
+
{
|
|
199
|
+
title: 'High Confidence Finding',
|
|
200
|
+
severity: 'HIGH',
|
|
201
|
+
category: 'injection',
|
|
202
|
+
match: 'bad',
|
|
203
|
+
remediation: 'fix',
|
|
204
|
+
confidence: 0.9,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
title: 'Low Confidence Finding',
|
|
208
|
+
severity: 'MEDIUM',
|
|
209
|
+
category: 'obfuscation',
|
|
210
|
+
match: 'maybe bad',
|
|
211
|
+
remediation: 'maybe fix',
|
|
212
|
+
confidence: 0.3,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
mockProvider.analyze.mockResolvedValue(mockResponse);
|
|
217
|
+
const file = makeFile();
|
|
218
|
+
const result = await analyzeWithLlm(mockProvider, makeConfig({
|
|
219
|
+
minConfidence: 0.6,
|
|
220
|
+
cacheDir: '/tmp/ferret-llm-no-cache-test-' + Date.now(),
|
|
221
|
+
}), file, 'bad content here', []);
|
|
222
|
+
expect(result.ran).toBe(true);
|
|
223
|
+
const highConf = result.findings.filter(f => f.ruleName?.includes('High Confidence'));
|
|
224
|
+
const lowConf = result.findings.filter(f => f.ruleName?.includes('Low Confidence'));
|
|
225
|
+
expect(highConf.length).toBeGreaterThan(0);
|
|
226
|
+
expect(lowConf.length).toBe(0);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
//# sourceMappingURL=llmAnalysis.test.js.map
|