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.
Files changed (181) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +15 -11
  3. package/bin/ferret.js +109 -13
  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/analyzers/AstAnalyzer.d.ts +5 -1
  126. package/dist/analyzers/AstAnalyzer.js +25 -4
  127. package/dist/features/customRules.js +22 -29
  128. package/dist/features/ignoreComments.js +5 -5
  129. package/dist/features/mcpTrustScore.d.ts +17 -0
  130. package/dist/features/mcpTrustScore.js +74 -0
  131. package/dist/features/mcpValidator.d.ts +2 -0
  132. package/dist/features/mcpValidator.js +13 -0
  133. package/dist/features/policyEnforcement.d.ts +22 -22
  134. package/dist/features/policyEnforcement.js +3 -2
  135. package/dist/intelligence/ThreatFeed.js +207 -62
  136. package/dist/remediation/Fixer.js +56 -30
  137. package/dist/remediation/Quarantine.js +79 -11
  138. package/dist/reporters/ConsoleReporter.js +10 -0
  139. package/dist/reporters/HtmlReporter.js +5 -0
  140. package/dist/reporters/SarifReporter.d.ts +1 -0
  141. package/dist/reporters/SarifReporter.js +1 -0
  142. package/dist/rules/ai-specific.js +8 -8
  143. package/dist/rules/backdoors.js +12 -12
  144. package/dist/rules/correlationRules.js +6 -6
  145. package/dist/rules/index.d.ts +1 -0
  146. package/dist/rules/index.js +10 -1
  147. package/dist/rules/injection.js +8 -8
  148. package/dist/rules/patterns/common.d.ts +34 -0
  149. package/dist/rules/patterns/common.js +48 -0
  150. package/dist/scanner/IAnalyzer.d.ts +19 -0
  151. package/dist/scanner/IAnalyzer.js +5 -0
  152. package/dist/scanner/PatternMatcher.js +19 -2
  153. package/dist/scanner/Scanner.js +64 -125
  154. package/dist/scanner/analyzers/CapabilityAnalyzer.d.ts +8 -0
  155. package/dist/scanner/analyzers/CapabilityAnalyzer.js +19 -0
  156. package/dist/scanner/analyzers/DependencyAnalyzer.d.ts +8 -0
  157. package/dist/scanner/analyzers/DependencyAnalyzer.js +18 -0
  158. package/dist/scanner/analyzers/EntropyAnalyzer.d.ts +8 -0
  159. package/dist/scanner/analyzers/EntropyAnalyzer.js +12 -0
  160. package/dist/scanner/analyzers/LlmAnalyzer.d.ts +17 -0
  161. package/dist/scanner/analyzers/LlmAnalyzer.js +36 -0
  162. package/dist/scanner/analyzers/McpAnalyzer.d.ts +8 -0
  163. package/dist/scanner/analyzers/McpAnalyzer.js +19 -0
  164. package/dist/scanner/analyzers/SemanticAnalyzer.d.ts +8 -0
  165. package/dist/scanner/analyzers/SemanticAnalyzer.js +21 -0
  166. package/dist/scanner/analyzers/ThreatIntelAnalyzer.d.ts +8 -0
  167. package/dist/scanner/analyzers/ThreatIntelAnalyzer.js +21 -0
  168. package/dist/types.d.ts +23 -0
  169. package/dist/types.js +1 -1
  170. package/dist/utils/baseline.d.ts +15 -2
  171. package/dist/utils/baseline.js +50 -19
  172. package/dist/utils/contentCache.d.ts +39 -0
  173. package/dist/utils/contentCache.js +77 -0
  174. package/dist/utils/glob.d.ts +50 -0
  175. package/dist/utils/glob.js +84 -0
  176. package/dist/utils/pathSecurity.js +1 -0
  177. package/dist/utils/safeRegex.d.ts +55 -0
  178. package/dist/utils/safeRegex.js +130 -0
  179. package/dist/utils/schemas.d.ts +70 -64
  180. package/dist/utils/schemas.js +13 -0
  181. package/package.json +34 -19
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Feature ExitCodes Tests
3
+ * Tests for features/exitCodes.ts: determineExitCode, generateExitCodeSummary,
4
+ * formatExitCodeForCI, parseExitCodesFromEnv, validateExitCodes.
5
+ */
6
+ import { DEFAULT_EXIT_CODES, determineExitCode, getExitReasonDescription, generateExitCodeSummary, formatExitCodeForCI, parseExitCodesFromEnv, validateExitCodes, } from '../features/exitCodes.js';
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+ function makeFinding(overrides = {}) {
11
+ return {
12
+ ruleId: 'INJ-001',
13
+ ruleName: 'Test',
14
+ severity: 'HIGH',
15
+ category: 'injection',
16
+ file: '/test.md',
17
+ relativePath: 'test.md',
18
+ line: 1,
19
+ match: 'bad',
20
+ context: [],
21
+ remediation: 'fix',
22
+ timestamp: new Date(),
23
+ riskScore: 50,
24
+ ...overrides,
25
+ };
26
+ }
27
+ function makeScanResult(findings = []) {
28
+ return {
29
+ success: true,
30
+ startTime: new Date(),
31
+ endTime: new Date(),
32
+ duration: 100,
33
+ scannedPaths: ['/project'],
34
+ totalFiles: 5,
35
+ analyzedFiles: 4,
36
+ skippedFiles: 1,
37
+ findings,
38
+ findingsBySeverity: {
39
+ CRITICAL: findings.filter(f => f.severity === 'CRITICAL'),
40
+ HIGH: findings.filter(f => f.severity === 'HIGH'),
41
+ MEDIUM: findings.filter(f => f.severity === 'MEDIUM'),
42
+ LOW: findings.filter(f => f.severity === 'LOW'),
43
+ INFO: findings.filter(f => f.severity === 'INFO'),
44
+ },
45
+ findingsByCategory: {},
46
+ overallRiskScore: 50,
47
+ summary: {
48
+ critical: findings.filter(f => f.severity === 'CRITICAL').length,
49
+ high: findings.filter(f => f.severity === 'HIGH').length,
50
+ medium: findings.filter(f => f.severity === 'MEDIUM').length,
51
+ low: findings.filter(f => f.severity === 'LOW').length,
52
+ info: findings.filter(f => f.severity === 'INFO').length,
53
+ total: findings.length,
54
+ },
55
+ errors: [],
56
+ };
57
+ }
58
+ function makePolicyResult(passed, blockerCount = 0) {
59
+ return {
60
+ passed,
61
+ violations: [],
62
+ blockers: Array(blockerCount).fill({ policyId: 'P-001', rule: null, message: 'block' }),
63
+ warnings: [],
64
+ exitCode: passed ? 0 : 2,
65
+ summary: {
66
+ totalRules: 1,
67
+ passedRules: passed ? 1 : 0,
68
+ failedRules: passed ? 0 : 1,
69
+ blockedRules: blockerCount,
70
+ warnedRules: 0,
71
+ },
72
+ };
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // DEFAULT_EXIT_CODES
76
+ // ---------------------------------------------------------------------------
77
+ describe('DEFAULT_EXIT_CODES', () => {
78
+ it('has success = 0', () => expect(DEFAULT_EXIT_CODES.success).toBe(0));
79
+ it('has findingsFound = 1', () => expect(DEFAULT_EXIT_CODES.findingsFound).toBe(1));
80
+ it('has policyViolation = 2', () => expect(DEFAULT_EXIT_CODES.policyViolation).toBe(2));
81
+ it('has scanError = 3', () => expect(DEFAULT_EXIT_CODES.scanError).toBe(3));
82
+ it('has interrupted = 130', () => expect(DEFAULT_EXIT_CODES.interrupted).toBe(130));
83
+ });
84
+ // ---------------------------------------------------------------------------
85
+ // determineExitCode
86
+ // ---------------------------------------------------------------------------
87
+ describe('determineExitCode', () => {
88
+ it('returns success (0) when no findings', () => {
89
+ const result = makeScanResult([]);
90
+ const { code, reason } = determineExitCode(result);
91
+ expect(code).toBe(0);
92
+ expect(reason).toBe('success');
93
+ });
94
+ it('returns findingsFound (1) when HIGH finding present with default threshold', () => {
95
+ const result = makeScanResult([makeFinding({ severity: 'HIGH' })]);
96
+ const { code, reason } = determineExitCode(result);
97
+ expect(code).toBe(1);
98
+ expect(reason).toBe('findings_found');
99
+ });
100
+ it('returns success when only LOW finding and threshold is HIGH', () => {
101
+ const result = makeScanResult([makeFinding({ severity: 'LOW' })]);
102
+ const { code, reason } = determineExitCode(result, {
103
+ severityThreshold: { failOn: 'HIGH' },
104
+ });
105
+ expect(code).toBe(0);
106
+ expect(reason).toBe('success');
107
+ });
108
+ it('returns success when threshold is never regardless of findings', () => {
109
+ const result = makeScanResult([makeFinding({ severity: 'CRITICAL' })]);
110
+ const { code, reason } = determineExitCode(result, {
111
+ severityThreshold: { failOn: 'never' },
112
+ });
113
+ expect(code).toBe(0);
114
+ expect(reason).toBe('success');
115
+ });
116
+ it('returns policy_violation when policy fails', () => {
117
+ const result = makeScanResult([]);
118
+ const { code, reason } = determineExitCode(result, {
119
+ policyResult: makePolicyResult(false),
120
+ });
121
+ expect(code).toBe(2);
122
+ expect(reason).toBe('policy_violation');
123
+ });
124
+ it('uses policy result exitCode when available', () => {
125
+ const result = makeScanResult([]);
126
+ const policyResult = { ...makePolicyResult(false), exitCode: 99 };
127
+ const { code } = determineExitCode(result, { policyResult });
128
+ expect(code).toBe(99);
129
+ });
130
+ it('respects custom exit codes', () => {
131
+ const result = makeScanResult([makeFinding({ severity: 'HIGH' })]);
132
+ const { code } = determineExitCode(result, {
133
+ exitCodes: { findingsFound: 42 },
134
+ });
135
+ expect(code).toBe(42);
136
+ });
137
+ it('CRITICAL finding meets HIGH threshold', () => {
138
+ const result = makeScanResult([makeFinding({ severity: 'CRITICAL' })]);
139
+ const { code } = determineExitCode(result, {
140
+ severityThreshold: { failOn: 'HIGH' },
141
+ });
142
+ expect(code).toBe(1);
143
+ });
144
+ it('MEDIUM finding does not meet HIGH threshold', () => {
145
+ const result = makeScanResult([makeFinding({ severity: 'MEDIUM' })]);
146
+ const { code } = determineExitCode(result, {
147
+ severityThreshold: { failOn: 'HIGH' },
148
+ });
149
+ expect(code).toBe(0);
150
+ });
151
+ it('policy check takes priority over finding severity', () => {
152
+ const result = makeScanResult([makeFinding({ severity: 'CRITICAL' })]);
153
+ const { reason } = determineExitCode(result, {
154
+ policyResult: makePolicyResult(false),
155
+ });
156
+ expect(reason).toBe('policy_violation');
157
+ });
158
+ });
159
+ // ---------------------------------------------------------------------------
160
+ // getExitReasonDescription
161
+ // ---------------------------------------------------------------------------
162
+ describe('getExitReasonDescription', () => {
163
+ it('returns description for success', () => {
164
+ expect(getExitReasonDescription('success')).toContain('successfully');
165
+ });
166
+ it('returns description for findings_found', () => {
167
+ expect(getExitReasonDescription('findings_found')).toContain('Security findings');
168
+ });
169
+ it('returns description for policy_violation', () => {
170
+ expect(getExitReasonDescription('policy_violation')).toContain('Policy violations');
171
+ });
172
+ it('returns description for scan_error', () => {
173
+ expect(getExitReasonDescription('scan_error')).toContain('error');
174
+ });
175
+ it('returns description for config_error', () => {
176
+ expect(getExitReasonDescription('config_error')).toContain('Configuration');
177
+ });
178
+ it('returns description for timeout', () => {
179
+ expect(getExitReasonDescription('timeout')).toContain('timed out');
180
+ });
181
+ it('returns description for interrupted', () => {
182
+ expect(getExitReasonDescription('interrupted')).toContain('interrupted');
183
+ });
184
+ });
185
+ // ---------------------------------------------------------------------------
186
+ // generateExitCodeSummary
187
+ // ---------------------------------------------------------------------------
188
+ describe('generateExitCodeSummary', () => {
189
+ it('includes code and reason', () => {
190
+ const summary = generateExitCodeSummary(makeScanResult());
191
+ expect(summary.code).toBe(0);
192
+ expect(summary.reason).toBe('success');
193
+ });
194
+ it('includes description', () => {
195
+ const summary = generateExitCodeSummary(makeScanResult());
196
+ expect(typeof summary.description).toBe('string');
197
+ expect(summary.description.length).toBeGreaterThan(0);
198
+ });
199
+ it('includes findings summary', () => {
200
+ const result = makeScanResult([makeFinding({ severity: 'HIGH' })]);
201
+ const summary = generateExitCodeSummary(result);
202
+ expect(summary.findingsSummary.total).toBe(1);
203
+ expect(summary.findingsSummary.blocking).toBe(1);
204
+ });
205
+ it('blocking count is 0 when threshold is never', () => {
206
+ const result = makeScanResult([makeFinding({ severity: 'CRITICAL' })]);
207
+ const summary = generateExitCodeSummary(result, {
208
+ severityThreshold: { failOn: 'never' },
209
+ });
210
+ expect(summary.findingsSummary.blocking).toBe(0);
211
+ });
212
+ it('includes policy violations count', () => {
213
+ const summary = generateExitCodeSummary(makeScanResult(), {
214
+ policyResult: makePolicyResult(false, 3),
215
+ });
216
+ expect(summary.policyViolations).toBe(3);
217
+ });
218
+ });
219
+ // ---------------------------------------------------------------------------
220
+ // formatExitCodeForCI
221
+ // ---------------------------------------------------------------------------
222
+ describe('formatExitCodeForCI', () => {
223
+ function makeSummary(overrides = {}) {
224
+ return {
225
+ code: 0,
226
+ reason: 'success',
227
+ description: 'All good',
228
+ findingsSummary: {
229
+ total: 0,
230
+ blocking: 0,
231
+ byeSeverity: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 },
232
+ },
233
+ ...overrides,
234
+ };
235
+ }
236
+ it('includes exit code', () => {
237
+ expect(formatExitCodeForCI(makeSummary({ code: 1 }))).toContain('Exit Code: 1');
238
+ });
239
+ it('includes reason description', () => {
240
+ expect(formatExitCodeForCI(makeSummary({ description: 'Scan failed' }))).toContain('Scan failed');
241
+ });
242
+ it('includes total findings', () => {
243
+ const output = formatExitCodeForCI(makeSummary({
244
+ findingsSummary: { total: 5, blocking: 3, byeSeverity: { CRITICAL: 1, HIGH: 2, MEDIUM: 2, LOW: 0, INFO: 0 } },
245
+ }));
246
+ expect(output).toContain('Total Findings: 5');
247
+ expect(output).toContain('Blocking Findings: 3');
248
+ });
249
+ it('includes policy violations when positive', () => {
250
+ expect(formatExitCodeForCI(makeSummary({ policyViolations: 2 }))).toContain('Policy Violations: 2');
251
+ });
252
+ it('omits policy violations line when 0', () => {
253
+ expect(formatExitCodeForCI(makeSummary({ policyViolations: 0 }))).not.toContain('Policy Violations');
254
+ });
255
+ });
256
+ // ---------------------------------------------------------------------------
257
+ // parseExitCodesFromEnv
258
+ // ---------------------------------------------------------------------------
259
+ describe('parseExitCodesFromEnv', () => {
260
+ const ENV_VARS = [
261
+ 'FERRET_EXIT_SUCCESS', 'FERRET_EXIT_FINDINGS', 'FERRET_EXIT_POLICY',
262
+ 'FERRET_EXIT_ERROR', 'FERRET_EXIT_CONFIG', 'FERRET_EXIT_TIMEOUT',
263
+ ];
264
+ afterEach(() => {
265
+ for (const v of ENV_VARS)
266
+ delete process.env[v];
267
+ });
268
+ it('returns empty object when no env vars set', () => {
269
+ expect(parseExitCodesFromEnv()).toEqual({});
270
+ });
271
+ it('parses FERRET_EXIT_SUCCESS', () => {
272
+ process.env['FERRET_EXIT_SUCCESS'] = '0';
273
+ expect(parseExitCodesFromEnv().success).toBe(0);
274
+ });
275
+ it('parses FERRET_EXIT_FINDINGS', () => {
276
+ process.env['FERRET_EXIT_FINDINGS'] = '10';
277
+ expect(parseExitCodesFromEnv().findingsFound).toBe(10);
278
+ });
279
+ it('parses FERRET_EXIT_POLICY', () => {
280
+ process.env['FERRET_EXIT_POLICY'] = '5';
281
+ expect(parseExitCodesFromEnv().policyViolation).toBe(5);
282
+ });
283
+ it('parses FERRET_EXIT_ERROR', () => {
284
+ process.env['FERRET_EXIT_ERROR'] = '7';
285
+ expect(parseExitCodesFromEnv().scanError).toBe(7);
286
+ });
287
+ it('parses FERRET_EXIT_CONFIG', () => {
288
+ process.env['FERRET_EXIT_CONFIG'] = '8';
289
+ expect(parseExitCodesFromEnv().configError).toBe(8);
290
+ });
291
+ it('parses FERRET_EXIT_TIMEOUT', () => {
292
+ process.env['FERRET_EXIT_TIMEOUT'] = '9';
293
+ expect(parseExitCodesFromEnv().timeout).toBe(9);
294
+ });
295
+ it('ignores invalid (non-numeric) values', () => {
296
+ process.env['FERRET_EXIT_SUCCESS'] = 'not-a-number';
297
+ expect(parseExitCodesFromEnv().success).toBeUndefined();
298
+ });
299
+ it('ignores values out of 0-255 range', () => {
300
+ process.env['FERRET_EXIT_FINDINGS'] = '999';
301
+ expect(parseExitCodesFromEnv().findingsFound).toBeUndefined();
302
+ });
303
+ });
304
+ // ---------------------------------------------------------------------------
305
+ // validateExitCodes
306
+ // ---------------------------------------------------------------------------
307
+ describe('validateExitCodes', () => {
308
+ it('returns valid for empty config', () => {
309
+ const { valid, errors } = validateExitCodes({});
310
+ expect(valid).toBe(true);
311
+ expect(errors).toHaveLength(0);
312
+ });
313
+ it('returns valid for correct codes', () => {
314
+ expect(validateExitCodes({ success: 0, findingsFound: 1 }).valid).toBe(true);
315
+ });
316
+ it('returns error for code below 0', () => {
317
+ const { valid, errors } = validateExitCodes({ success: -1 });
318
+ expect(valid).toBe(false);
319
+ expect(errors.length).toBeGreaterThan(0);
320
+ });
321
+ it('returns error for code above 255', () => {
322
+ const { valid, errors } = validateExitCodes({ success: 256 });
323
+ expect(valid).toBe(false);
324
+ expect(errors.length).toBeGreaterThan(0);
325
+ });
326
+ it('returns error for non-integer code', () => {
327
+ const { valid, errors } = validateExitCodes({ success: 1.5 });
328
+ expect(valid).toBe(false);
329
+ expect(errors.length).toBeGreaterThan(0);
330
+ });
331
+ });
332
+ //# sourceMappingURL=featureExitCodes.test.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * FileDiscovery configOnly mode tests
3
+ * Tests the configOnly-specific branches in isAnalyzableFile
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=fileDiscoveryConfigOnly.test.d.ts.map
@@ -0,0 +1,195 @@
1
+ /**
2
+ * FileDiscovery configOnly mode tests
3
+ * Tests the configOnly-specific branches in isAnalyzableFile
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 CONFIG_ONLY_OPTIONS = {
10
+ maxFileSize: 1024 * 1024,
11
+ ignore: [],
12
+ configOnly: true,
13
+ marketplaceMode: 'configs',
14
+ };
15
+ describe('discoverFiles - configOnly mode', () => {
16
+ let tmpDir;
17
+ beforeEach(() => {
18
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-configonly-'));
19
+ });
20
+ afterEach(() => {
21
+ fs.rmSync(tmpDir, { recursive: true, force: true });
22
+ });
23
+ it('includes .env files in configOnly mode', async () => {
24
+ fs.writeFileSync(path.join(tmpDir, '.env'), 'SECRET=value123');
25
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
26
+ const envFiles = result.files.filter(f => f.path.includes('.env'));
27
+ expect(envFiles.length).toBeGreaterThan(0);
28
+ });
29
+ it('includes .env.local in configOnly mode', async () => {
30
+ fs.writeFileSync(path.join(tmpDir, '.env.local'), 'LOCAL=test');
31
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
32
+ const envFiles = result.files.filter(f => f.path.includes('.env.local'));
33
+ expect(envFiles.length).toBeGreaterThan(0);
34
+ });
35
+ it('includes secrets.env in configOnly mode', async () => {
36
+ fs.writeFileSync(path.join(tmpDir, 'secrets.env'), 'API_KEY=secret');
37
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
38
+ const envFiles = result.files.filter(f => f.path.includes('secrets.env'));
39
+ expect(envFiles.length).toBeGreaterThan(0);
40
+ });
41
+ it('includes .claude/agents/ files in configOnly mode', async () => {
42
+ const agentsDir = path.join(tmpDir, '.claude', 'agents');
43
+ fs.mkdirSync(agentsDir, { recursive: true });
44
+ fs.writeFileSync(path.join(agentsDir, 'my-agent.md'), '# My Agent');
45
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
46
+ const agentFiles = result.files.filter(f => f.path.includes('agents'));
47
+ expect(agentFiles.length).toBeGreaterThan(0);
48
+ });
49
+ it('includes .claude/hooks/ files in configOnly mode', async () => {
50
+ const hooksDir = path.join(tmpDir, '.claude', 'hooks');
51
+ fs.mkdirSync(hooksDir, { recursive: true });
52
+ fs.writeFileSync(path.join(hooksDir, 'my-hook.sh'), '#!/bin/bash\necho test');
53
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
54
+ const hookFiles = result.files.filter(f => f.path.includes('hooks'));
55
+ expect(hookFiles.length).toBeGreaterThan(0);
56
+ });
57
+ it('includes .claude/skills/ files in configOnly mode', async () => {
58
+ const skillsDir = path.join(tmpDir, '.claude', 'skills');
59
+ fs.mkdirSync(skillsDir, { recursive: true });
60
+ fs.writeFileSync(path.join(skillsDir, 'my-skill.md'), '# My Skill');
61
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
62
+ const skillFiles = result.files.filter(f => f.path.includes('skills'));
63
+ expect(skillFiles.length).toBeGreaterThan(0);
64
+ });
65
+ it('includes .claude/commands/ files in configOnly mode', async () => {
66
+ const commandsDir = path.join(tmpDir, '.claude', 'commands');
67
+ fs.mkdirSync(commandsDir, { recursive: true });
68
+ fs.writeFileSync(path.join(commandsDir, 'my-cmd.md'), '# My Command');
69
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
70
+ const cmdFiles = result.files.filter(f => f.path.includes('commands'));
71
+ expect(cmdFiles.length).toBeGreaterThan(0);
72
+ });
73
+ it('includes .claude settings.json in configOnly mode', async () => {
74
+ const claudeDir = path.join(tmpDir, '.claude');
75
+ fs.mkdirSync(claudeDir);
76
+ fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{"allowedTools":[]}');
77
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
78
+ const settingsFiles = result.files.filter(f => f.path.includes('settings.json'));
79
+ expect(settingsFiles.length).toBeGreaterThan(0);
80
+ });
81
+ it('excludes .claude/plugins/cache/ in configOnly mode', async () => {
82
+ const cacheDir = path.join(tmpDir, '.claude', 'plugins', 'cache');
83
+ fs.mkdirSync(cacheDir, { recursive: true });
84
+ fs.writeFileSync(path.join(cacheDir, 'plugin.md'), '# Cached Plugin');
85
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
86
+ const cacheFiles = result.files.filter(f => f.path.includes('plugins/cache'));
87
+ expect(cacheFiles).toHaveLength(0);
88
+ });
89
+ it('excludes arbitrary .claude files in configOnly mode', async () => {
90
+ const claudeDir = path.join(tmpDir, '.claude');
91
+ fs.mkdirSync(claudeDir);
92
+ fs.writeFileSync(path.join(claudeDir, 'random.txt'), 'random content');
93
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
94
+ const txtFiles = result.files.filter(f => f.path.includes('random.txt'));
95
+ expect(txtFiles).toHaveLength(0);
96
+ });
97
+ it('excludes non-AI files in configOnly mode (default return false)', async () => {
98
+ fs.writeFileSync(path.join(tmpDir, 'random-file.ts'), 'const x = 1;');
99
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
100
+ // random TypeScript files outside known directories should be excluded
101
+ const tsFiles = result.files.filter(f => f.path.includes('random-file.ts'));
102
+ expect(tsFiles).toHaveLength(0);
103
+ });
104
+ it('handles marketplaceMode=off vs configs for .claude/plugins', async () => {
105
+ const marketplaceDir = path.join(tmpDir, '.claude', 'plugins', 'marketplaces', 'testplugin', 'agents');
106
+ fs.mkdirSync(marketplaceDir, { recursive: true });
107
+ fs.writeFileSync(path.join(marketplaceDir, 'plugin-agent.md'), '# Plugin Agent');
108
+ // marketplaceMode=off should exclude marketplace
109
+ const resultOff = await discoverFiles([tmpDir], {
110
+ ...CONFIG_ONLY_OPTIONS,
111
+ marketplaceMode: 'off',
112
+ });
113
+ const offFiles = resultOff.files.filter(f => f.path.includes('marketplaces'));
114
+ expect(offFiles).toHaveLength(0);
115
+ });
116
+ it('discovers files from .openclaw/agents/ in configOnly mode', async () => {
117
+ const agentsDir = path.join(tmpDir, '.openclaw', 'agents');
118
+ fs.mkdirSync(agentsDir, { recursive: true });
119
+ fs.writeFileSync(path.join(agentsDir, 'my-agent.json'), '{}');
120
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
121
+ const agentFiles = result.files.filter(f => f.path.includes('.openclaw'));
122
+ expect(agentFiles.length).toBeGreaterThan(0);
123
+ });
124
+ it('excludes .openclaw/workspace/ in configOnly mode', async () => {
125
+ const workspaceDir = path.join(tmpDir, '.openclaw', 'workspace');
126
+ fs.mkdirSync(workspaceDir, { recursive: true });
127
+ fs.writeFileSync(path.join(workspaceDir, 'data.json'), '{}');
128
+ const result = await discoverFiles([tmpDir], CONFIG_ONLY_OPTIONS);
129
+ const workspaceFiles = result.files.filter(f => f.path.includes('workspace'));
130
+ expect(workspaceFiles).toHaveLength(0);
131
+ });
132
+ });
133
+ describe('discoverFiles - marketplace mode variations', () => {
134
+ let tmpDir;
135
+ beforeEach(() => {
136
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-marketplace-'));
137
+ });
138
+ afterEach(() => {
139
+ fs.rmSync(tmpDir, { recursive: true, force: true });
140
+ });
141
+ it('excludes marketplace reference docs in "configs" mode', async () => {
142
+ const refDir = path.join(tmpDir, '.claude', 'plugins', 'marketplaces', 'plugin1', 'references');
143
+ fs.mkdirSync(refDir, { recursive: true });
144
+ fs.writeFileSync(path.join(refDir, 'api.md'), '# API Reference');
145
+ const result = await discoverFiles([tmpDir], {
146
+ maxFileSize: 1024 * 1024,
147
+ ignore: [],
148
+ configOnly: false,
149
+ marketplaceMode: 'configs',
150
+ });
151
+ const refFiles = result.files.filter(f => f.path.includes('references'));
152
+ expect(refFiles).toHaveLength(0);
153
+ });
154
+ it('excludes low-signal readme.md from marketplace in "configs" mode', async () => {
155
+ const pluginDir = path.join(tmpDir, '.claude', 'plugins', 'marketplaces', 'plugin1');
156
+ fs.mkdirSync(pluginDir, { recursive: true });
157
+ fs.writeFileSync(path.join(pluginDir, 'readme.md'), '# Plugin Readme');
158
+ const result = await discoverFiles([tmpDir], {
159
+ maxFileSize: 1024 * 1024,
160
+ ignore: [],
161
+ configOnly: false,
162
+ marketplaceMode: 'configs',
163
+ });
164
+ const readmeFiles = result.files.filter(f => f.path.includes('readme.md'));
165
+ expect(readmeFiles).toHaveLength(0);
166
+ });
167
+ it('includes all files in marketplace "all" mode', async () => {
168
+ const pluginDir = path.join(tmpDir, '.claude', 'plugins', 'marketplaces', 'plugin1');
169
+ fs.mkdirSync(pluginDir, { recursive: true });
170
+ fs.writeFileSync(path.join(pluginDir, 'readme.md'), '# Plugin Readme');
171
+ const result = await discoverFiles([tmpDir], {
172
+ maxFileSize: 1024 * 1024,
173
+ ignore: [],
174
+ configOnly: false,
175
+ marketplaceMode: 'all',
176
+ });
177
+ const readmeFiles = result.files.filter(f => f.path.includes('readme.md'));
178
+ expect(readmeFiles.length).toBeGreaterThan(0);
179
+ });
180
+ it('excludes non-agent marketplace files in "off" mode', async () => {
181
+ // In 'off' mode, marketplace ts/js files should be excluded (non-config types)
182
+ const pluginDir = path.join(tmpDir, '.claude', 'plugins', 'marketplaces', 'plugin1');
183
+ fs.mkdirSync(pluginDir, { recursive: true });
184
+ fs.writeFileSync(path.join(pluginDir, 'index.ts'), '// code');
185
+ const result = await discoverFiles([tmpDir], {
186
+ maxFileSize: 1024 * 1024,
187
+ ignore: [],
188
+ configOnly: false,
189
+ marketplaceMode: 'off',
190
+ });
191
+ const tsFiles = result.files.filter(f => f.path.includes('marketplaces') && f.type === 'ts');
192
+ expect(tsFiles).toHaveLength(0);
193
+ });
194
+ });
195
+ //# sourceMappingURL=fileDiscoveryConfigOnly.test.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Additional FileDiscovery Tests
3
+ * Tests for discoverFiles function with real file system
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=fileDiscoveryExtra.test.d.ts.map