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,149 @@
1
+ /**
2
+ * Additional FileDiscovery Tests
3
+ * Tests for discoverFiles function with real file system
4
+ */
5
+ import { discoverFiles } from '../scanner/FileDiscovery.js';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as os from 'node:os';
9
+ const DEFAULT_OPTIONS = {
10
+ maxFileSize: 1024 * 1024,
11
+ ignore: [],
12
+ configOnly: false,
13
+ marketplaceMode: 'configs',
14
+ };
15
+ describe('discoverFiles', () => {
16
+ let tmpDir;
17
+ beforeEach(() => {
18
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-discover-'));
19
+ });
20
+ afterEach(() => {
21
+ fs.rmSync(tmpDir, { recursive: true, force: true });
22
+ });
23
+ it('returns empty result for empty paths array', async () => {
24
+ const result = await discoverFiles([], DEFAULT_OPTIONS);
25
+ expect(result.files).toHaveLength(0);
26
+ expect(result.errors).toHaveLength(0);
27
+ });
28
+ it('returns error for non-existent path', async () => {
29
+ const result = await discoverFiles(['/nonexistent/path'], DEFAULT_OPTIONS);
30
+ expect(result.errors).toHaveLength(1);
31
+ expect(result.errors[0]?.error).toContain('does not exist');
32
+ });
33
+ it('discovers a single markdown file', async () => {
34
+ const filePath = path.join(tmpDir, 'CLAUDE.md');
35
+ fs.writeFileSync(filePath, '# Test Agent Config');
36
+ const result = await discoverFiles([filePath], DEFAULT_OPTIONS);
37
+ expect(result.files).toHaveLength(1);
38
+ expect(result.files[0]?.type).toBe('md');
39
+ expect(result.files[0]?.relativePath).toBe('CLAUDE.md');
40
+ });
41
+ it('discovers a JSON config file', async () => {
42
+ const filePath = path.join(tmpDir, '.mcp.json');
43
+ fs.writeFileSync(filePath, '{"mcpServers":{}}');
44
+ const result = await discoverFiles([filePath], DEFAULT_OPTIONS);
45
+ expect(result.files).toHaveLength(1);
46
+ expect(result.files[0]?.type).toBe('json');
47
+ expect(result.files[0]?.component).toBe('mcp');
48
+ });
49
+ it('discovers files in a directory', async () => {
50
+ const agentsDir = path.join(tmpDir, '.claude', 'agents');
51
+ fs.mkdirSync(agentsDir, { recursive: true });
52
+ fs.writeFileSync(path.join(agentsDir, 'my-agent.md'), '# My Agent');
53
+ fs.writeFileSync(path.join(agentsDir, 'another.md'), '# Another');
54
+ const result = await discoverFiles([tmpDir], DEFAULT_OPTIONS);
55
+ const mdFiles = result.files.filter(f => f.type === 'md');
56
+ expect(mdFiles.length).toBeGreaterThanOrEqual(2);
57
+ });
58
+ it('skips files over maxFileSize', async () => {
59
+ const filePath = path.join(tmpDir, '.mcp.json');
60
+ fs.writeFileSync(filePath, '{"mcpServers":{}}');
61
+ const result = await discoverFiles([filePath], {
62
+ ...DEFAULT_OPTIONS,
63
+ maxFileSize: 5, // Only 5 bytes allowed
64
+ });
65
+ expect(result.files).toHaveLength(0);
66
+ expect(result.skipped).toBeGreaterThan(0);
67
+ });
68
+ it('respects ignore patterns', async () => {
69
+ const nodeModulesDir = path.join(tmpDir, 'node_modules');
70
+ fs.mkdirSync(nodeModulesDir);
71
+ fs.writeFileSync(path.join(nodeModulesDir, 'settings.json'), '{}');
72
+ const result = await discoverFiles([tmpDir], {
73
+ ...DEFAULT_OPTIONS,
74
+ ignore: ['node_modules/**'],
75
+ });
76
+ const nodeModulesFiles = result.files.filter(f => f.path.includes('node_modules'));
77
+ expect(nodeModulesFiles).toHaveLength(0);
78
+ });
79
+ it('detects component type correctly for skills', async () => {
80
+ const skillsDir = path.join(tmpDir, '.claude', 'skills');
81
+ fs.mkdirSync(skillsDir, { recursive: true });
82
+ fs.writeFileSync(path.join(skillsDir, 'my-skill.md'), '# My Skill');
83
+ const result = await discoverFiles([tmpDir], DEFAULT_OPTIONS);
84
+ const skillFiles = result.files.filter(f => f.component === 'skill');
85
+ expect(skillFiles.length).toBeGreaterThan(0);
86
+ });
87
+ it('detects component type for hooks', async () => {
88
+ const hooksDir = path.join(tmpDir, '.claude', 'hooks');
89
+ fs.mkdirSync(hooksDir, { recursive: true });
90
+ fs.writeFileSync(path.join(hooksDir, 'pre-commit.sh'), '#!/bin/bash\necho test');
91
+ const result = await discoverFiles([tmpDir], DEFAULT_OPTIONS);
92
+ const hookFiles = result.files.filter(f => f.component === 'hook');
93
+ expect(hookFiles.length).toBeGreaterThan(0);
94
+ });
95
+ it('discovers .env files', async () => {
96
+ fs.writeFileSync(path.join(tmpDir, '.env'), 'SECRET=value123');
97
+ fs.writeFileSync(path.join(tmpDir, '.env.local'), 'LOCAL_VAR=test');
98
+ const result = await discoverFiles([tmpDir], DEFAULT_OPTIONS);
99
+ const envFiles = result.files.filter(f => f.type === 'sh' && f.path.includes('.env'));
100
+ expect(envFiles.length).toBeGreaterThanOrEqual(2);
101
+ });
102
+ it('discovers YAML files', async () => {
103
+ fs.writeFileSync(path.join(tmpDir, '.aider.conf.yml'), 'auto-commits: true');
104
+ const result = await discoverFiles([tmpDir], DEFAULT_OPTIONS);
105
+ const yamlFiles = result.files.filter(f => f.type === 'yml' || f.type === 'yaml');
106
+ expect(yamlFiles.length).toBeGreaterThan(0);
107
+ });
108
+ it('sorts discovered files', async () => {
109
+ // Create files in various components
110
+ const agentsDir = path.join(tmpDir, '.claude', 'agents');
111
+ const skillsDir = path.join(tmpDir, '.claude', 'skills');
112
+ fs.mkdirSync(agentsDir, { recursive: true });
113
+ fs.mkdirSync(skillsDir, { recursive: true });
114
+ fs.writeFileSync(path.join(agentsDir, 'z-agent.md'), '# Z Agent');
115
+ fs.writeFileSync(path.join(skillsDir, 'a-skill.md'), '# A Skill');
116
+ const result = await discoverFiles([tmpDir], DEFAULT_OPTIONS);
117
+ // Files should be sorted by component then path
118
+ const sortedPaths = result.files.map(f => f.component + ':' + f.relativePath);
119
+ const sortedCopy = [...sortedPaths].sort();
120
+ expect(sortedPaths).toEqual(sortedCopy);
121
+ });
122
+ it('discovers TypeScript files in non-configOnly mode', async () => {
123
+ fs.writeFileSync(path.join(tmpDir, 'settings.json'), '{"allowedTools":["Bash"]}');
124
+ // TypeScript should be discovered in non-configOnly mode
125
+ const tsFile = path.join(tmpDir, 'test.ts');
126
+ fs.writeFileSync(tsFile, 'export const x = 1;');
127
+ const result = await discoverFiles([tsFile], DEFAULT_OPTIONS);
128
+ expect(result.files.some(f => f.type === 'ts')).toBe(true);
129
+ });
130
+ it('handles configOnly mode', async () => {
131
+ const claudeDir = path.join(tmpDir, '.claude');
132
+ fs.mkdirSync(claudeDir);
133
+ fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{"allowedTools":[]}');
134
+ const result = await discoverFiles([tmpDir], {
135
+ ...DEFAULT_OPTIONS,
136
+ configOnly: true,
137
+ });
138
+ // In configOnly mode, settings.json in .claude should be included
139
+ expect(result.files.length).toBeGreaterThanOrEqual(0); // May or may not find it
140
+ });
141
+ it('discovers .cursorrules file when scanned directly', async () => {
142
+ const filePath = path.join(tmpDir, '.cursorrules');
143
+ fs.writeFileSync(filePath, '# Cursor Rules');
144
+ const result = await discoverFiles([filePath], DEFAULT_OPTIONS);
145
+ // When scanning a direct file path, it should be discoverable
146
+ expect(result.files.length + result.skipped).toBeGreaterThan(0);
147
+ });
148
+ });
149
+ //# sourceMappingURL=fileDiscoveryExtra.test.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Additional Fixer Tests
3
+ * Tests for canAutoRemediate, restoreFromBackup, previewRemediation
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=fixer.extra.test.d.ts.map
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Additional Fixer Tests
3
+ * Tests for canAutoRemediate, restoreFromBackup, previewRemediation
4
+ */
5
+ import { canAutoRemediate, restoreFromBackup, previewRemediation, applyRemediationBatch, } from '../remediation/Fixer.js';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as os from 'node:os';
9
+ function makeFinding(overrides = {}) {
10
+ return {
11
+ ruleId: 'INJ-001',
12
+ ruleName: 'Test Rule',
13
+ severity: 'HIGH',
14
+ category: 'injection',
15
+ file: '/project/test.md',
16
+ relativePath: 'test.md',
17
+ line: 1,
18
+ match: 'IGNORE PREVIOUS INSTRUCTIONS',
19
+ context: [{ lineNumber: 1, content: 'IGNORE PREVIOUS INSTRUCTIONS', isMatch: true }],
20
+ remediation: 'fix it',
21
+ timestamp: new Date(),
22
+ riskScore: 75,
23
+ ...overrides,
24
+ };
25
+ }
26
+ describe('canAutoRemediate', () => {
27
+ it('returns true for jailbreak-pattern findings (ignore previous instructions)', () => {
28
+ const finding = makeFinding({ match: 'ignore previous instructions' });
29
+ expect(canAutoRemediate(finding)).toBe(true);
30
+ });
31
+ it('returns true for CRED-001 CRITICAL findings with hardcoded credentials', () => {
32
+ const finding = makeFinding({
33
+ ruleId: 'CRED-001',
34
+ match: 'password: hardcoded123',
35
+ });
36
+ // CRED-001 has high-safety automatic fixes
37
+ const result = canAutoRemediate(finding);
38
+ expect(typeof result).toBe('boolean');
39
+ });
40
+ it('returns a boolean for any finding', () => {
41
+ const finding = makeFinding({
42
+ ruleId: 'BEHAVIORAL-001',
43
+ match: 'some unrelated content',
44
+ severity: 'LOW',
45
+ });
46
+ expect(typeof canAutoRemediate(finding)).toBe('boolean');
47
+ });
48
+ });
49
+ describe('restoreFromBackup', () => {
50
+ let tmpDir;
51
+ beforeEach(() => {
52
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-backup-'));
53
+ });
54
+ afterEach(() => {
55
+ fs.rmSync(tmpDir, { recursive: true, force: true });
56
+ });
57
+ it('returns false when backup file does not exist', () => {
58
+ const result = restoreFromBackup('/nonexistent/backup.md.bak', '/project/file.md');
59
+ expect(result).toBe(false);
60
+ });
61
+ it('returns true and restores file when backup exists', () => {
62
+ const backupPath = path.join(tmpDir, 'file.md.backup');
63
+ const originalPath = path.join(tmpDir, 'file.md');
64
+ fs.writeFileSync(backupPath, 'backup content');
65
+ fs.writeFileSync(originalPath, 'modified content');
66
+ const result = restoreFromBackup(backupPath, originalPath);
67
+ expect(result).toBe(true);
68
+ expect(fs.readFileSync(originalPath, 'utf-8')).toBe('backup content');
69
+ });
70
+ });
71
+ describe('previewRemediation', () => {
72
+ let tmpDir;
73
+ beforeEach(() => {
74
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-preview-'));
75
+ });
76
+ afterEach(() => {
77
+ fs.rmSync(tmpDir, { recursive: true, force: true });
78
+ });
79
+ it('returns previewRemediation result for any finding', async () => {
80
+ const finding = makeFinding({
81
+ ruleId: 'BEHAVIORAL-001',
82
+ match: 'some unrelated content',
83
+ severity: 'LOW',
84
+ });
85
+ const result = await previewRemediation(finding);
86
+ expect(typeof result.canFix).toBe('boolean');
87
+ expect(Array.isArray(result.fixes)).toBe(true);
88
+ });
89
+ it('returns canFix=true and preview for jailbreak finding when file exists', async () => {
90
+ const filePath = path.join(tmpDir, 'test.md');
91
+ fs.writeFileSync(filePath, 'ignore previous instructions\nsome content');
92
+ const finding = makeFinding({
93
+ file: filePath,
94
+ match: 'ignore previous instructions',
95
+ context: [
96
+ { lineNumber: 1, content: 'ignore previous instructions', isMatch: true },
97
+ ],
98
+ });
99
+ const result = await previewRemediation(finding);
100
+ expect(result.canFix).toBe(true);
101
+ expect(result.preview).toBeDefined();
102
+ if (result.preview) {
103
+ expect(result.preview.originalLine).toBe('ignore previous instructions');
104
+ }
105
+ });
106
+ it('returns canFix=true without preview when file does not exist', async () => {
107
+ const finding = makeFinding({
108
+ file: '/nonexistent/test.md',
109
+ match: 'ignore previous instructions',
110
+ context: [
111
+ { lineNumber: 1, content: 'ignore previous instructions', isMatch: true },
112
+ ],
113
+ });
114
+ const result = await previewRemediation(finding);
115
+ expect(result.canFix).toBe(true);
116
+ // No preview since file doesn't exist
117
+ });
118
+ });
119
+ describe('applyRemediationBatch', () => {
120
+ it('returns empty results for empty findings array', async () => {
121
+ const results = await applyRemediationBatch([]);
122
+ expect(results).toHaveLength(0);
123
+ });
124
+ it('processes multiple findings', async () => {
125
+ const findings = [
126
+ makeFinding({ file: '/nonexistent/file1.md' }),
127
+ makeFinding({ file: '/nonexistent/file2.md' }),
128
+ ];
129
+ const results = await applyRemediationBatch(findings);
130
+ expect(results).toHaveLength(2);
131
+ // Both should fail since files don't exist
132
+ expect(results.every(r => !r.success)).toBe(true);
133
+ });
134
+ });
135
+ //# sourceMappingURL=fixer.extra.test.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Additional Fixer Tests - applyRemediation
3
+ * Tests for the security whitelist, file existence, and fix application paths
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=fixerApply.test.d.ts.map
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Additional Fixer Tests - applyRemediation
3
+ * Tests for the security whitelist, file existence, and fix application paths
4
+ */
5
+ import { applyRemediation } from '../remediation/Fixer.js';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as os from 'node:os';
9
+ function makeFinding(overrides = {}) {
10
+ return {
11
+ ruleId: 'INJ-001',
12
+ ruleName: 'Test Rule',
13
+ severity: 'HIGH',
14
+ category: 'injection',
15
+ file: '/project/test.md',
16
+ relativePath: 'test.md',
17
+ line: 1,
18
+ match: 'IGNORE PREVIOUS INSTRUCTIONS',
19
+ context: [{ lineNumber: 1, content: 'IGNORE PREVIOUS INSTRUCTIONS', isMatch: true }],
20
+ remediation: 'fix it',
21
+ timestamp: new Date(),
22
+ riskScore: 75,
23
+ ...overrides,
24
+ };
25
+ }
26
+ describe('applyRemediation', () => {
27
+ let tmpDir;
28
+ beforeEach(() => {
29
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-fixer-'));
30
+ });
31
+ afterEach(() => {
32
+ fs.rmSync(tmpDir, { recursive: true, force: true });
33
+ });
34
+ it('returns error when file does not exist', async () => {
35
+ const finding = makeFinding({ file: '/nonexistent/file.md' });
36
+ const result = await applyRemediation(finding);
37
+ expect(result.success).toBe(false);
38
+ expect(result.error).toBeDefined();
39
+ });
40
+ it('blocks remediation when file not in whitelist', async () => {
41
+ const filePath = path.join(tmpDir, 'test.md');
42
+ fs.writeFileSync(filePath, 'IGNORE PREVIOUS INSTRUCTIONS');
43
+ const finding = makeFinding({ file: filePath });
44
+ const whitelist = new Set(['/other/path.md']); // Not including filePath
45
+ const result = await applyRemediation(finding, {
46
+ scannedFilesWhitelist: whitelist,
47
+ });
48
+ expect(result.success).toBe(false);
49
+ expect(result.error).toContain('not part of the original scan');
50
+ });
51
+ it('applies fix when file is in whitelist', async () => {
52
+ const filePath = path.join(tmpDir, 'test.md');
53
+ const content = 'IGNORE PREVIOUS INSTRUCTIONS\nKeep this line.';
54
+ fs.writeFileSync(filePath, content);
55
+ const finding = makeFinding({ file: filePath });
56
+ const whitelist = new Set([path.resolve(filePath)]);
57
+ const result = await applyRemediation(finding, {
58
+ scannedFilesWhitelist: whitelist,
59
+ createBackups: false,
60
+ });
61
+ // Should succeed since IGNORE PREVIOUS INSTRUCTIONS has a high-safety fix
62
+ expect(typeof result.success).toBe('boolean');
63
+ });
64
+ it('blocks remediation when file outside allowed write base', async () => {
65
+ const filePath = path.join(tmpDir, 'test.md');
66
+ fs.writeFileSync(filePath, 'IGNORE PREVIOUS INSTRUCTIONS');
67
+ const finding = makeFinding({ file: filePath });
68
+ const result = await applyRemediation(finding, {
69
+ allowedWriteBase: '/other/directory',
70
+ });
71
+ expect(result.success).toBe(false);
72
+ expect(result.error).toContain('outside allowed');
73
+ });
74
+ it('returns error for large file', async () => {
75
+ const filePath = path.join(tmpDir, 'large.md');
76
+ // Write minimal content
77
+ fs.writeFileSync(filePath, 'IGNORE PREVIOUS INSTRUCTIONS');
78
+ const finding = makeFinding({ file: filePath });
79
+ const result = await applyRemediation(finding, {
80
+ maxFileSizeMB: 0.0000001, // Tiny limit
81
+ });
82
+ expect(result.success).toBe(false);
83
+ expect(result.error).toContain('large');
84
+ });
85
+ it('creates backup when createBackup=true', async () => {
86
+ const filePath = path.join(tmpDir, 'test.md');
87
+ fs.writeFileSync(filePath, 'IGNORE PREVIOUS INSTRUCTIONS\nKeep this.');
88
+ const finding = makeFinding({ file: filePath });
89
+ const backupDir = path.join(tmpDir, 'backups');
90
+ fs.mkdirSync(backupDir);
91
+ const result = await applyRemediation(finding, {
92
+ createBackups: true,
93
+ backupDir,
94
+ });
95
+ // If fix succeeded, backup should exist
96
+ if (result.success && result.backupPath) {
97
+ expect(fs.existsSync(result.backupPath)).toBe(true);
98
+ }
99
+ });
100
+ it('applies fix to file with jailbreak pattern', async () => {
101
+ const filePath = path.join(tmpDir, 'agent.md');
102
+ fs.writeFileSync(filePath, '# Agent\nignore previous instructions\nNormal content.');
103
+ const finding = makeFinding({
104
+ file: filePath,
105
+ match: 'ignore previous instructions',
106
+ context: [{ lineNumber: 2, content: 'ignore previous instructions', isMatch: true }],
107
+ });
108
+ const result = await applyRemediation(finding, { createBackups: false });
109
+ // Should succeed in applying the jailbreak removal fix
110
+ if (result.success) {
111
+ const newContent = fs.readFileSync(filePath, 'utf-8');
112
+ expect(newContent).not.toContain('ignore previous instructions');
113
+ }
114
+ expect(typeof result.success).toBe('boolean');
115
+ });
116
+ it('handles finding with no applicable fixes', async () => {
117
+ const filePath = path.join(tmpDir, 'test.md');
118
+ fs.writeFileSync(filePath, 'Some unknown content pattern xyzabc123');
119
+ const finding = makeFinding({
120
+ file: filePath,
121
+ ruleId: 'BEHAVIORAL-999',
122
+ match: 'unknown pattern with no fix',
123
+ context: [{ lineNumber: 1, content: 'unknown pattern with no fix', isMatch: true }],
124
+ });
125
+ const result = await applyRemediation(finding);
126
+ // Should fail gracefully with "no fixes" message
127
+ if (!result.success) {
128
+ expect(result.error).toBeDefined();
129
+ }
130
+ });
131
+ });
132
+ //# sourceMappingURL=fixerApply.test.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * GitHooks Tests
3
+ * Tests for installHooks, uninstallHooks, getHookStatus, isGitRepository,
4
+ * getStagedFiles, and getChangedFiles.
5
+ */
6
+ export {};
7
+ //# sourceMappingURL=gitHooks.test.d.ts.map
@@ -0,0 +1,188 @@
1
+ /**
2
+ * GitHooks Tests
3
+ * Tests for installHooks, uninstallHooks, getHookStatus, isGitRepository,
4
+ * getStagedFiles, and getChangedFiles.
5
+ */
6
+ jest.mock('node:fs');
7
+ jest.mock('node:child_process');
8
+ import * as fs from 'node:fs';
9
+ import * as child_process from 'node:child_process';
10
+ import { isGitRepository, getStagedFiles, getChangedFiles, installHooks, uninstallHooks, getHookStatus, } from '../features/gitHooks.js';
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ const mockFs = fs;
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ const mockChildProcess = child_process;
15
+ // ---------------------------------------------------------------------------
16
+ // isGitRepository
17
+ // ---------------------------------------------------------------------------
18
+ describe('isGitRepository', () => {
19
+ it('returns true when git rev-parse succeeds', () => {
20
+ mockChildProcess.execSync.mockReturnValue(undefined);
21
+ expect(isGitRepository()).toBe(true);
22
+ });
23
+ it('returns false when git rev-parse throws', () => {
24
+ mockChildProcess.execSync.mockImplementation(() => { throw new Error('not a git repo'); });
25
+ expect(isGitRepository()).toBe(false);
26
+ });
27
+ });
28
+ // ---------------------------------------------------------------------------
29
+ // getStagedFiles
30
+ // ---------------------------------------------------------------------------
31
+ describe('getStagedFiles', () => {
32
+ it('returns list of staged files', () => {
33
+ mockChildProcess.execSync.mockReturnValue('src/file.ts\nsrc/other.ts\n');
34
+ const files = getStagedFiles();
35
+ expect(files).toEqual(['src/file.ts', 'src/other.ts']);
36
+ });
37
+ it('returns empty array when no staged files', () => {
38
+ mockChildProcess.execSync.mockReturnValue('\n');
39
+ const files = getStagedFiles();
40
+ expect(files).toHaveLength(0);
41
+ });
42
+ it('returns empty array on error', () => {
43
+ mockChildProcess.execSync.mockImplementation(() => { throw new Error('failed'); });
44
+ const files = getStagedFiles();
45
+ expect(files).toHaveLength(0);
46
+ });
47
+ });
48
+ // ---------------------------------------------------------------------------
49
+ // getChangedFiles
50
+ // ---------------------------------------------------------------------------
51
+ describe('getChangedFiles', () => {
52
+ it('returns list of changed files', () => {
53
+ mockChildProcess.execSync.mockReturnValue('src/a.ts\nsrc/b.ts\n');
54
+ const files = getChangedFiles('abc123');
55
+ expect(files).toEqual(['src/a.ts', 'src/b.ts']);
56
+ });
57
+ it('returns empty array on error', () => {
58
+ mockChildProcess.execSync.mockImplementation(() => { throw new Error('failed'); });
59
+ const files = getChangedFiles('abc123');
60
+ expect(files).toHaveLength(0);
61
+ });
62
+ });
63
+ // ---------------------------------------------------------------------------
64
+ // installHooks
65
+ // ---------------------------------------------------------------------------
66
+ describe('installHooks', () => {
67
+ beforeEach(() => {
68
+ jest.clearAllMocks();
69
+ mockFs.mkdirSync.mockReturnValue(undefined);
70
+ mockFs.writeFileSync.mockReturnValue(undefined);
71
+ mockFs.chmodSync.mockReturnValue(undefined);
72
+ });
73
+ it('returns error when not in a git repository', () => {
74
+ // findGitHooksDir returns null when execSync throws
75
+ mockChildProcess.execSync.mockImplementation(() => { throw new Error('not git'); });
76
+ const result = installHooks();
77
+ expect(result.success).toBe(false);
78
+ expect(result.errors[0]).toContain('Not a git repository');
79
+ });
80
+ it('installs pre-commit and pre-push hooks successfully', () => {
81
+ // findGitHooksDir: git rev-parse succeeds, returns string
82
+ mockChildProcess.execSync.mockReturnValue('/project/.git\n');
83
+ mockFs.existsSync.mockReturnValue(false); // hooks don't exist yet
84
+ const result = installHooks({ preCommit: true, prePush: true });
85
+ expect(result.success).toBe(true);
86
+ expect(result.installed).toContain('pre-commit');
87
+ expect(result.installed).toContain('pre-push');
88
+ });
89
+ it('reports error when pre-commit hook exists and force=false', () => {
90
+ mockChildProcess.execSync.mockReturnValue('/project/.git\n');
91
+ // pre-commit exists with non-ferret content
92
+ mockFs.existsSync.mockImplementation((p) => String(p).includes('pre-commit'));
93
+ mockFs.readFileSync.mockReturnValue('#!/bin/sh\n# some other hook\n');
94
+ const result = installHooks({ preCommit: true, prePush: false, force: false });
95
+ expect(result.errors.some(e => e.includes('pre-commit hook already exists'))).toBe(true);
96
+ });
97
+ it('reinstalls existing ferret hook without error', () => {
98
+ mockChildProcess.execSync.mockReturnValue('/project/.git\n');
99
+ mockFs.existsSync.mockReturnValue(true);
100
+ mockFs.readFileSync.mockReturnValue('#!/bin/sh\n# Ferret Security Scanner\n');
101
+ const result = installHooks({ preCommit: true, prePush: false });
102
+ expect(result.success).toBe(true);
103
+ expect(result.installed).toContain('pre-commit');
104
+ });
105
+ it('force-installs over existing hook', () => {
106
+ mockChildProcess.execSync.mockReturnValue('/project/.git\n');
107
+ mockFs.existsSync.mockReturnValue(true);
108
+ mockFs.readFileSync.mockReturnValue('#!/bin/sh\n# some other hook\n');
109
+ const result = installHooks({ preCommit: true, prePush: false, force: true });
110
+ expect(result.success).toBe(true);
111
+ expect(result.installed).toContain('pre-commit');
112
+ });
113
+ });
114
+ // ---------------------------------------------------------------------------
115
+ // uninstallHooks
116
+ // ---------------------------------------------------------------------------
117
+ describe('uninstallHooks', () => {
118
+ beforeEach(() => {
119
+ jest.clearAllMocks();
120
+ mockFs.unlinkSync.mockReturnValue(undefined);
121
+ });
122
+ it('returns error when not in a git repository', () => {
123
+ mockChildProcess.execSync.mockImplementation(() => { throw new Error('not git'); });
124
+ const result = uninstallHooks();
125
+ expect(result.success).toBe(false);
126
+ expect(result.errors[0]).toContain('Not a git repository');
127
+ });
128
+ it('removes ferret hooks when present', () => {
129
+ mockChildProcess.execSync.mockReturnValue('/project/.git\n');
130
+ mockFs.existsSync.mockReturnValue(true);
131
+ mockFs.readFileSync.mockReturnValue('#!/bin/sh\n# Ferret Security Scanner\n');
132
+ const result = uninstallHooks();
133
+ expect(result.success).toBe(true);
134
+ expect(result.removed.length).toBeGreaterThan(0);
135
+ });
136
+ it('does not remove non-ferret hooks', () => {
137
+ mockChildProcess.execSync.mockReturnValue('/project/.git\n');
138
+ mockFs.existsSync.mockReturnValue(true);
139
+ mockFs.readFileSync.mockReturnValue('#!/bin/sh\n# some other tool\n');
140
+ const result = uninstallHooks();
141
+ expect(result.removed).toHaveLength(0);
142
+ });
143
+ it('handles missing hooks gracefully', () => {
144
+ mockChildProcess.execSync.mockReturnValue('/project/.git\n');
145
+ mockFs.existsSync.mockReturnValue(false);
146
+ const result = uninstallHooks();
147
+ expect(result.success).toBe(true);
148
+ expect(result.removed).toHaveLength(0);
149
+ });
150
+ });
151
+ // ---------------------------------------------------------------------------
152
+ // getHookStatus
153
+ // ---------------------------------------------------------------------------
154
+ describe('getHookStatus', () => {
155
+ beforeEach(() => {
156
+ jest.clearAllMocks();
157
+ });
158
+ it('returns not-installed when git dir not found', () => {
159
+ mockChildProcess.execSync.mockImplementation(() => { throw new Error('not git'); });
160
+ const status = getHookStatus();
161
+ expect(status.preCommit).toBe('not-installed');
162
+ expect(status.prePush).toBe('not-installed');
163
+ });
164
+ it('returns installed for ferret hooks', () => {
165
+ mockChildProcess.execSync.mockReturnValue('/project/.git\n');
166
+ mockFs.existsSync.mockReturnValue(true);
167
+ mockFs.readFileSync.mockReturnValue('#!/bin/sh\n# Ferret Security Scanner\n');
168
+ const status = getHookStatus();
169
+ expect(status.preCommit).toBe('installed');
170
+ expect(status.prePush).toBe('installed');
171
+ });
172
+ it('returns other for non-ferret hooks', () => {
173
+ mockChildProcess.execSync.mockReturnValue('/project/.git\n');
174
+ mockFs.existsSync.mockReturnValue(true);
175
+ mockFs.readFileSync.mockReturnValue('#!/bin/sh\n# some other hook\n');
176
+ const status = getHookStatus();
177
+ expect(status.preCommit).toBe('other');
178
+ expect(status.prePush).toBe('other');
179
+ });
180
+ it('returns not-installed when hook file does not exist', () => {
181
+ mockChildProcess.execSync.mockReturnValue('/project/.git\n');
182
+ mockFs.existsSync.mockReturnValue(false);
183
+ const status = getHookStatus();
184
+ expect(status.preCommit).toBe('not-installed');
185
+ expect(status.prePush).toBe('not-installed');
186
+ });
187
+ });
188
+ //# sourceMappingURL=gitHooks.test.js.map
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Additional HTML Reporter Tests
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=htmlReporter.extra.test.d.ts.map