ferret-scan 1.0.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 +51 -0
- package/LICENSE +21 -0
- package/README.md +416 -0
- package/bin/ferret.js +822 -0
- package/dist/__tests__/basic.test.d.ts +6 -0
- package/dist/__tests__/basic.test.js +80 -0
- package/dist/analyzers/AstAnalyzer.d.ts +30 -0
- package/dist/analyzers/AstAnalyzer.js +332 -0
- package/dist/analyzers/CorrelationAnalyzer.d.ts +21 -0
- package/dist/analyzers/CorrelationAnalyzer.js +288 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +22 -0
- package/dist/intelligence/IndicatorMatcher.d.ts +50 -0
- package/dist/intelligence/IndicatorMatcher.js +285 -0
- package/dist/intelligence/ThreatFeed.d.ts +99 -0
- package/dist/intelligence/ThreatFeed.js +296 -0
- package/dist/remediation/Fixer.d.ts +71 -0
- package/dist/remediation/Fixer.js +391 -0
- package/dist/remediation/Quarantine.d.ts +102 -0
- package/dist/remediation/Quarantine.js +329 -0
- package/dist/reporters/ConsoleReporter.d.ts +13 -0
- package/dist/reporters/ConsoleReporter.js +185 -0
- package/dist/reporters/HtmlReporter.d.ts +25 -0
- package/dist/reporters/HtmlReporter.js +604 -0
- package/dist/reporters/SarifReporter.d.ts +86 -0
- package/dist/reporters/SarifReporter.js +117 -0
- package/dist/rules/ai-specific.d.ts +8 -0
- package/dist/rules/ai-specific.js +221 -0
- package/dist/rules/backdoors.d.ts +8 -0
- package/dist/rules/backdoors.js +134 -0
- package/dist/rules/correlationRules.d.ts +8 -0
- package/dist/rules/correlationRules.js +227 -0
- package/dist/rules/credentials.d.ts +8 -0
- package/dist/rules/credentials.js +194 -0
- package/dist/rules/exfiltration.d.ts +8 -0
- package/dist/rules/exfiltration.js +139 -0
- package/dist/rules/index.d.ts +51 -0
- package/dist/rules/index.js +97 -0
- package/dist/rules/injection.d.ts +8 -0
- package/dist/rules/injection.js +136 -0
- package/dist/rules/obfuscation.d.ts +8 -0
- package/dist/rules/obfuscation.js +159 -0
- package/dist/rules/permissions.d.ts +8 -0
- package/dist/rules/permissions.js +129 -0
- package/dist/rules/persistence.d.ts +8 -0
- package/dist/rules/persistence.js +117 -0
- package/dist/rules/semanticRules.d.ts +10 -0
- package/dist/rules/semanticRules.js +212 -0
- package/dist/rules/supply-chain.d.ts +8 -0
- package/dist/rules/supply-chain.js +148 -0
- package/dist/scanner/FileDiscovery.d.ts +24 -0
- package/dist/scanner/FileDiscovery.js +282 -0
- package/dist/scanner/PatternMatcher.d.ts +25 -0
- package/dist/scanner/PatternMatcher.js +206 -0
- package/dist/scanner/Scanner.d.ts +14 -0
- package/dist/scanner/Scanner.js +266 -0
- package/dist/scanner/WatchMode.d.ts +29 -0
- package/dist/scanner/WatchMode.js +195 -0
- package/dist/types.d.ts +332 -0
- package/dist/types.js +53 -0
- package/dist/utils/baseline.d.ts +80 -0
- package/dist/utils/baseline.js +276 -0
- package/dist/utils/config.d.ts +21 -0
- package/dist/utils/config.js +247 -0
- package/dist/utils/ignore.d.ts +18 -0
- package/dist/utils/ignore.js +82 -0
- package/dist/utils/logger.d.ts +32 -0
- package/dist/utils/logger.js +75 -0
- package/package.json +119 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baseline Management - Track and ignore accepted findings
|
|
3
|
+
* Allows users to create baselines of known/accepted security findings
|
|
4
|
+
*/
|
|
5
|
+
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { resolve, dirname } from 'node:path';
|
|
7
|
+
import { mkdirSync } from 'node:fs';
|
|
8
|
+
import logger from './logger.js';
|
|
9
|
+
/**
|
|
10
|
+
* Generate a hash for a finding to uniquely identify it
|
|
11
|
+
*/
|
|
12
|
+
function generateFindingHash(finding) {
|
|
13
|
+
const content = `${finding.ruleId}:${finding.relativePath}:${finding.line}:${finding.match}`;
|
|
14
|
+
// Simple hash function (could use crypto for better security)
|
|
15
|
+
let hash = 0;
|
|
16
|
+
for (let i = 0; i < content.length; i++) {
|
|
17
|
+
const char = content.charCodeAt(i);
|
|
18
|
+
hash = ((hash << 5) - hash) + char;
|
|
19
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
20
|
+
}
|
|
21
|
+
return Math.abs(hash).toString(36);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Load baseline from file
|
|
25
|
+
*/
|
|
26
|
+
export function loadBaseline(baselinePath) {
|
|
27
|
+
try {
|
|
28
|
+
if (!existsSync(baselinePath)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const content = readFileSync(baselinePath, 'utf-8');
|
|
32
|
+
const baseline = JSON.parse(content);
|
|
33
|
+
// Validate baseline structure
|
|
34
|
+
if (!baseline.version || !baseline.findings || !Array.isArray(baseline.findings)) {
|
|
35
|
+
throw new Error('Invalid baseline format');
|
|
36
|
+
}
|
|
37
|
+
logger.debug(`Loaded baseline with ${baseline.findings.length} accepted findings`);
|
|
38
|
+
return baseline;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
logger.error(`Failed to load baseline from ${baselinePath}:`, error);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Save baseline to file
|
|
47
|
+
*/
|
|
48
|
+
export function saveBaseline(baseline, baselinePath) {
|
|
49
|
+
try {
|
|
50
|
+
// Ensure directory exists
|
|
51
|
+
const dir = dirname(baselinePath);
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
// Update lastUpdated timestamp
|
|
54
|
+
baseline.lastUpdated = new Date().toISOString();
|
|
55
|
+
// Write baseline file
|
|
56
|
+
const content = JSON.stringify(baseline, null, 2);
|
|
57
|
+
writeFileSync(baselinePath, content, 'utf-8');
|
|
58
|
+
logger.info(`Baseline saved to ${baselinePath} with ${baseline.findings.length} findings`);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
logger.error(`Failed to save baseline to ${baselinePath}:`, error);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Create a new baseline from scan results
|
|
67
|
+
*/
|
|
68
|
+
export function createBaseline(result, description) {
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
const baselineFindings = result.findings.map(finding => ({
|
|
71
|
+
ruleId: finding.ruleId,
|
|
72
|
+
file: finding.relativePath,
|
|
73
|
+
line: finding.line,
|
|
74
|
+
match: finding.match,
|
|
75
|
+
hash: generateFindingHash(finding),
|
|
76
|
+
acceptedDate: now,
|
|
77
|
+
}));
|
|
78
|
+
return {
|
|
79
|
+
version: '1.0',
|
|
80
|
+
createdDate: now,
|
|
81
|
+
lastUpdated: now,
|
|
82
|
+
description: description ?? `Baseline created from scan of ${result.scannedPaths.join(', ')}`,
|
|
83
|
+
findings: baselineFindings,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Add findings to an existing baseline
|
|
88
|
+
*/
|
|
89
|
+
export function addToBaseline(baseline, findings, reason) {
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
const existingHashes = new Set(baseline.findings.map(f => f.hash));
|
|
92
|
+
const newFindings = findings
|
|
93
|
+
.filter(finding => {
|
|
94
|
+
const hash = generateFindingHash(finding);
|
|
95
|
+
return !existingHashes.has(hash);
|
|
96
|
+
})
|
|
97
|
+
.map(finding => ({
|
|
98
|
+
ruleId: finding.ruleId,
|
|
99
|
+
file: finding.relativePath,
|
|
100
|
+
line: finding.line,
|
|
101
|
+
match: finding.match,
|
|
102
|
+
hash: generateFindingHash(finding),
|
|
103
|
+
acceptedDate: now,
|
|
104
|
+
...(reason && { reason }),
|
|
105
|
+
}));
|
|
106
|
+
logger.info(`Adding ${newFindings.length} new findings to baseline`);
|
|
107
|
+
return {
|
|
108
|
+
...baseline,
|
|
109
|
+
lastUpdated: now,
|
|
110
|
+
findings: [...baseline.findings, ...newFindings],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Remove findings from baseline
|
|
115
|
+
*/
|
|
116
|
+
export function removeFromBaseline(baseline, findingHashes) {
|
|
117
|
+
const hashSet = new Set(findingHashes);
|
|
118
|
+
const filteredFindings = baseline.findings.filter(f => !hashSet.has(f.hash));
|
|
119
|
+
const removedCount = baseline.findings.length - filteredFindings.length;
|
|
120
|
+
logger.info(`Removed ${removedCount} findings from baseline`);
|
|
121
|
+
return {
|
|
122
|
+
...baseline,
|
|
123
|
+
lastUpdated: new Date().toISOString(),
|
|
124
|
+
findings: filteredFindings,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Filter scan results against baseline
|
|
129
|
+
*/
|
|
130
|
+
export function filterAgainstBaseline(result, baseline) {
|
|
131
|
+
if (!baseline || baseline.findings.length === 0) {
|
|
132
|
+
return result; // No filtering needed
|
|
133
|
+
}
|
|
134
|
+
// Create hash set of baseline findings for fast lookup
|
|
135
|
+
const baselineHashes = new Set(baseline.findings.map(f => f.hash));
|
|
136
|
+
// Filter out findings that exist in baseline
|
|
137
|
+
const filteredFindings = result.findings.filter(finding => {
|
|
138
|
+
const hash = generateFindingHash(finding);
|
|
139
|
+
const isInBaseline = baselineHashes.has(hash);
|
|
140
|
+
if (isInBaseline) {
|
|
141
|
+
logger.debug(`Filtered baseline finding: ${finding.ruleId} in ${finding.relativePath}:${finding.line}`);
|
|
142
|
+
}
|
|
143
|
+
return !isInBaseline;
|
|
144
|
+
});
|
|
145
|
+
const filteredCount = result.findings.length - filteredFindings.length;
|
|
146
|
+
logger.info(`Filtered ${filteredCount} baseline findings, ${filteredFindings.length} new findings remain`);
|
|
147
|
+
// Recalculate summary and groupings
|
|
148
|
+
const newSummary = {
|
|
149
|
+
critical: 0,
|
|
150
|
+
high: 0,
|
|
151
|
+
medium: 0,
|
|
152
|
+
low: 0,
|
|
153
|
+
info: 0,
|
|
154
|
+
total: filteredFindings.length,
|
|
155
|
+
};
|
|
156
|
+
const newFindingsBySeverity = {
|
|
157
|
+
CRITICAL: [],
|
|
158
|
+
HIGH: [],
|
|
159
|
+
MEDIUM: [],
|
|
160
|
+
LOW: [],
|
|
161
|
+
INFO: [],
|
|
162
|
+
};
|
|
163
|
+
const newFindingsByCategory = {
|
|
164
|
+
injection: [],
|
|
165
|
+
credentials: [],
|
|
166
|
+
backdoors: [],
|
|
167
|
+
'supply-chain': [],
|
|
168
|
+
permissions: [],
|
|
169
|
+
persistence: [],
|
|
170
|
+
obfuscation: [],
|
|
171
|
+
'ai-specific': [],
|
|
172
|
+
'advanced-hiding': [],
|
|
173
|
+
behavioral: [],
|
|
174
|
+
exfiltration: [],
|
|
175
|
+
};
|
|
176
|
+
for (const finding of filteredFindings) {
|
|
177
|
+
// Update summary
|
|
178
|
+
switch (finding.severity) {
|
|
179
|
+
case 'CRITICAL':
|
|
180
|
+
newSummary.critical++;
|
|
181
|
+
break;
|
|
182
|
+
case 'HIGH':
|
|
183
|
+
newSummary.high++;
|
|
184
|
+
break;
|
|
185
|
+
case 'MEDIUM':
|
|
186
|
+
newSummary.medium++;
|
|
187
|
+
break;
|
|
188
|
+
case 'LOW':
|
|
189
|
+
newSummary.low++;
|
|
190
|
+
break;
|
|
191
|
+
case 'INFO':
|
|
192
|
+
newSummary.info++;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
// Group by severity and category
|
|
196
|
+
newFindingsBySeverity[finding.severity]?.push(finding);
|
|
197
|
+
newFindingsByCategory[finding.category]?.push(finding);
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
...result,
|
|
201
|
+
findings: filteredFindings,
|
|
202
|
+
summary: newSummary,
|
|
203
|
+
findingsBySeverity: newFindingsBySeverity,
|
|
204
|
+
findingsByCategory: newFindingsByCategory,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Check if baseline findings are still valid
|
|
209
|
+
*/
|
|
210
|
+
export function validateBaseline(baseline, currentResult) {
|
|
211
|
+
const currentHashes = new Set(currentResult.findings.map(finding => generateFindingHash(finding)));
|
|
212
|
+
const valid = [];
|
|
213
|
+
const invalid = [];
|
|
214
|
+
for (const baselineFinding of baseline.findings) {
|
|
215
|
+
if (currentHashes.has(baselineFinding.hash)) {
|
|
216
|
+
valid.push(baselineFinding);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
invalid.push(baselineFinding);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (invalid.length > 0) {
|
|
223
|
+
logger.info(`Baseline validation: ${valid.length} valid, ${invalid.length} invalid findings`);
|
|
224
|
+
}
|
|
225
|
+
return { valid, invalid };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Get default baseline path for a project
|
|
229
|
+
*/
|
|
230
|
+
export function getDefaultBaselinePath(scanPaths) {
|
|
231
|
+
// Try to find a good location for baseline file
|
|
232
|
+
const firstPath = scanPaths[0] ?? process.cwd();
|
|
233
|
+
return resolve(firstPath, '.ferret-baseline.json');
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Baseline statistics
|
|
237
|
+
*/
|
|
238
|
+
export function getBaselineStats(baseline) {
|
|
239
|
+
const byRule = {};
|
|
240
|
+
const bySeverity = {};
|
|
241
|
+
let oldestDate = new Date().toISOString();
|
|
242
|
+
let newestDate = '1970-01-01T00:00:00.000Z';
|
|
243
|
+
for (const finding of baseline.findings) {
|
|
244
|
+
// Count by rule
|
|
245
|
+
byRule[finding.ruleId] = (byRule[finding.ruleId] ?? 0) + 1;
|
|
246
|
+
// Extract severity from rule ID (if follows pattern like CRED-001)
|
|
247
|
+
const severity = finding.ruleId.split('-')[0] ?? 'UNKNOWN';
|
|
248
|
+
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
|
|
249
|
+
// Track date range
|
|
250
|
+
if (finding.acceptedDate < oldestDate) {
|
|
251
|
+
oldestDate = finding.acceptedDate;
|
|
252
|
+
}
|
|
253
|
+
if (finding.acceptedDate > newestDate) {
|
|
254
|
+
newestDate = finding.acceptedDate;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
totalFindings: baseline.findings.length,
|
|
259
|
+
byRule,
|
|
260
|
+
bySeverity,
|
|
261
|
+
oldestFinding: oldestDate,
|
|
262
|
+
newestFinding: newestDate,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
export default {
|
|
266
|
+
loadBaseline,
|
|
267
|
+
saveBaseline,
|
|
268
|
+
createBaseline,
|
|
269
|
+
addToBaseline,
|
|
270
|
+
removeFromBaseline,
|
|
271
|
+
filterAgainstBaseline,
|
|
272
|
+
validateBaseline,
|
|
273
|
+
getDefaultBaselinePath,
|
|
274
|
+
getBaselineStats,
|
|
275
|
+
};
|
|
276
|
+
//# sourceMappingURL=baseline.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration loader for Ferret-Scan
|
|
3
|
+
* Loads and merges configuration from files and CLI options
|
|
4
|
+
*/
|
|
5
|
+
import type { ScannerConfig, CliOptions } from '../types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Get AI CLI configuration paths
|
|
8
|
+
* Detects configurations for Claude Code, Cursor, Windsurf, Continue, Aider, Cline, and generic AI configs
|
|
9
|
+
*/
|
|
10
|
+
export declare function getAIConfigPaths(): string[];
|
|
11
|
+
/**
|
|
12
|
+
* Get Claude Code configuration paths
|
|
13
|
+
* @deprecated Use getAIConfigPaths() instead
|
|
14
|
+
*/
|
|
15
|
+
export declare function getClaudeConfigPaths(): string[];
|
|
16
|
+
/**
|
|
17
|
+
* Load and merge configuration from all sources
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadConfig(cliOptions: CliOptions): ScannerConfig;
|
|
20
|
+
export default loadConfig;
|
|
21
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration loader for Ferret-Scan
|
|
3
|
+
* Loads and merges configuration from files and CLI options
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { resolve, dirname } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { DEFAULT_CONFIG } from '../types.js';
|
|
9
|
+
import logger from './logger.js';
|
|
10
|
+
const CONFIG_FILE_NAMES = [
|
|
11
|
+
'.ferretrc.json',
|
|
12
|
+
'.ferretrc',
|
|
13
|
+
'ferret.config.json',
|
|
14
|
+
'.ferret/config.json',
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Find configuration file starting from a directory and walking up
|
|
18
|
+
*/
|
|
19
|
+
function findConfigFile(startDir) {
|
|
20
|
+
let currentDir = resolve(startDir);
|
|
21
|
+
const root = dirname(currentDir);
|
|
22
|
+
while (currentDir !== root) {
|
|
23
|
+
for (const configName of CONFIG_FILE_NAMES) {
|
|
24
|
+
const configPath = resolve(currentDir, configName);
|
|
25
|
+
if (existsSync(configPath)) {
|
|
26
|
+
logger.debug(`Found config file: ${configPath}`);
|
|
27
|
+
return configPath;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
currentDir = dirname(currentDir);
|
|
31
|
+
}
|
|
32
|
+
// Check home directory
|
|
33
|
+
const homeConfig = resolve(homedir(), '.ferretrc.json');
|
|
34
|
+
if (existsSync(homeConfig)) {
|
|
35
|
+
logger.debug(`Found home config: ${homeConfig}`);
|
|
36
|
+
return homeConfig;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Load configuration file
|
|
42
|
+
*/
|
|
43
|
+
function loadConfigFile(configPath) {
|
|
44
|
+
try {
|
|
45
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
46
|
+
const config = JSON.parse(content);
|
|
47
|
+
logger.debug(`Loaded config from: ${configPath}`);
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
52
|
+
logger.warn(`Failed to load config file ${configPath}: ${message}`);
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parse severity string to array
|
|
58
|
+
*/
|
|
59
|
+
function parseSeverities(severityStr) {
|
|
60
|
+
if (!severityStr)
|
|
61
|
+
return undefined;
|
|
62
|
+
const severities = severityStr.split(',').map(s => s.trim().toUpperCase());
|
|
63
|
+
const validSeverities = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
|
|
64
|
+
return severities.filter(s => validSeverities.includes(s));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parse categories string to array
|
|
68
|
+
*/
|
|
69
|
+
function parseCategories(categoriesStr) {
|
|
70
|
+
if (!categoriesStr)
|
|
71
|
+
return undefined;
|
|
72
|
+
const categories = categoriesStr.split(',').map(c => c.trim().toLowerCase());
|
|
73
|
+
const validCategories = [
|
|
74
|
+
'exfiltration', 'credentials', 'injection', 'backdoors',
|
|
75
|
+
'supply-chain', 'permissions', 'persistence', 'obfuscation',
|
|
76
|
+
'ai-specific', 'advanced-hiding', 'behavioral'
|
|
77
|
+
];
|
|
78
|
+
return categories.filter(c => validCategories.includes(c));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* AI CLI configuration directory patterns
|
|
82
|
+
* Supports multiple AI assistants and their config locations
|
|
83
|
+
*/
|
|
84
|
+
const AI_CLI_PATTERNS = {
|
|
85
|
+
// Claude Code
|
|
86
|
+
claude: {
|
|
87
|
+
dirs: ['.claude'],
|
|
88
|
+
files: ['CLAUDE.md', '.mcp.json'],
|
|
89
|
+
},
|
|
90
|
+
// Cursor
|
|
91
|
+
cursor: {
|
|
92
|
+
dirs: ['.cursor'],
|
|
93
|
+
files: ['.cursorrules'],
|
|
94
|
+
},
|
|
95
|
+
// Windsurf
|
|
96
|
+
windsurf: {
|
|
97
|
+
dirs: ['.windsurf'],
|
|
98
|
+
files: ['.windsurfrules'],
|
|
99
|
+
},
|
|
100
|
+
// Continue
|
|
101
|
+
continue: {
|
|
102
|
+
dirs: ['.continue'],
|
|
103
|
+
files: [],
|
|
104
|
+
},
|
|
105
|
+
// Aider
|
|
106
|
+
aider: {
|
|
107
|
+
dirs: ['.aider'],
|
|
108
|
+
files: ['.aider.conf.yml', '.aiderignore'],
|
|
109
|
+
},
|
|
110
|
+
// Cline
|
|
111
|
+
cline: {
|
|
112
|
+
dirs: ['.cline'],
|
|
113
|
+
files: ['.clinerules'],
|
|
114
|
+
},
|
|
115
|
+
// Generic AI
|
|
116
|
+
generic: {
|
|
117
|
+
dirs: ['.ai'],
|
|
118
|
+
files: ['AI.md', 'AGENT.md', 'AGENTS.md'],
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Get AI CLI configuration paths
|
|
123
|
+
* Detects configurations for Claude Code, Cursor, Windsurf, Continue, Aider, Cline, and generic AI configs
|
|
124
|
+
*/
|
|
125
|
+
export function getAIConfigPaths() {
|
|
126
|
+
const paths = [];
|
|
127
|
+
const cwd = process.cwd();
|
|
128
|
+
const home = homedir();
|
|
129
|
+
// Check all AI CLI patterns
|
|
130
|
+
for (const cli of Object.values(AI_CLI_PATTERNS)) {
|
|
131
|
+
// Check directories (both global and project-level)
|
|
132
|
+
for (const dir of cli.dirs) {
|
|
133
|
+
const globalDir = resolve(home, dir);
|
|
134
|
+
if (existsSync(globalDir)) {
|
|
135
|
+
paths.push(globalDir);
|
|
136
|
+
}
|
|
137
|
+
const projectDir = resolve(cwd, dir);
|
|
138
|
+
if (existsSync(projectDir)) {
|
|
139
|
+
paths.push(projectDir);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Check files (project-level only)
|
|
143
|
+
for (const file of cli.files) {
|
|
144
|
+
const projectFile = resolve(cwd, file);
|
|
145
|
+
if (existsSync(projectFile)) {
|
|
146
|
+
paths.push(projectFile);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return paths;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Get Claude Code configuration paths
|
|
154
|
+
* @deprecated Use getAIConfigPaths() instead
|
|
155
|
+
*/
|
|
156
|
+
export function getClaudeConfigPaths() {
|
|
157
|
+
return getAIConfigPaths();
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Load and merge configuration from all sources
|
|
161
|
+
*/
|
|
162
|
+
export function loadConfig(cliOptions) {
|
|
163
|
+
// Start with defaults
|
|
164
|
+
const config = { ...DEFAULT_CONFIG };
|
|
165
|
+
// Load config file if exists
|
|
166
|
+
const configPath = cliOptions.config ?? findConfigFile(process.cwd());
|
|
167
|
+
if (configPath) {
|
|
168
|
+
const fileConfig = loadConfigFile(configPath);
|
|
169
|
+
// Merge file config
|
|
170
|
+
if (fileConfig.severity) {
|
|
171
|
+
config.severities = fileConfig.severity;
|
|
172
|
+
}
|
|
173
|
+
if (fileConfig.categories) {
|
|
174
|
+
config.categories = fileConfig.categories;
|
|
175
|
+
}
|
|
176
|
+
if (fileConfig.ignore) {
|
|
177
|
+
config.ignore = [...config.ignore, ...fileConfig.ignore];
|
|
178
|
+
}
|
|
179
|
+
if (fileConfig.customRules) {
|
|
180
|
+
config.customRules = fileConfig.customRules;
|
|
181
|
+
}
|
|
182
|
+
if (fileConfig.failOn) {
|
|
183
|
+
config.failOn = fileConfig.failOn;
|
|
184
|
+
}
|
|
185
|
+
if (fileConfig.aiDetection?.enabled !== undefined) {
|
|
186
|
+
config.aiDetection = fileConfig.aiDetection.enabled;
|
|
187
|
+
}
|
|
188
|
+
if (fileConfig.threatIntelligence?.enabled !== undefined) {
|
|
189
|
+
config.threatIntel = fileConfig.threatIntelligence.enabled;
|
|
190
|
+
}
|
|
191
|
+
if (fileConfig.behaviorAnalysis?.enabled !== undefined) {
|
|
192
|
+
config.behaviorAnalysis = fileConfig.behaviorAnalysis.enabled;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Apply CLI options (highest priority)
|
|
196
|
+
if (cliOptions.path) {
|
|
197
|
+
config.paths = [resolve(cliOptions.path)];
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
// Default to AI CLI config paths
|
|
201
|
+
config.paths = getAIConfigPaths();
|
|
202
|
+
}
|
|
203
|
+
const parsedSeverities = parseSeverities(cliOptions.severity);
|
|
204
|
+
if (parsedSeverities?.length) {
|
|
205
|
+
config.severities = parsedSeverities;
|
|
206
|
+
}
|
|
207
|
+
const parsedCategories = parseCategories(cliOptions.categories);
|
|
208
|
+
if (parsedCategories?.length) {
|
|
209
|
+
config.categories = parsedCategories;
|
|
210
|
+
}
|
|
211
|
+
if (cliOptions.failOn) {
|
|
212
|
+
config.failOn = cliOptions.failOn.toUpperCase();
|
|
213
|
+
}
|
|
214
|
+
if (cliOptions.format) {
|
|
215
|
+
config.format = cliOptions.format;
|
|
216
|
+
}
|
|
217
|
+
if (cliOptions.output) {
|
|
218
|
+
config.outputFile = cliOptions.output;
|
|
219
|
+
}
|
|
220
|
+
if (cliOptions.watch !== undefined) {
|
|
221
|
+
config.watch = cliOptions.watch;
|
|
222
|
+
}
|
|
223
|
+
if (cliOptions.ci !== undefined) {
|
|
224
|
+
config.ci = cliOptions.ci;
|
|
225
|
+
}
|
|
226
|
+
if (cliOptions.verbose !== undefined) {
|
|
227
|
+
config.verbose = cliOptions.verbose;
|
|
228
|
+
}
|
|
229
|
+
if (cliOptions.aiDetection !== undefined) {
|
|
230
|
+
config.aiDetection = cliOptions.aiDetection;
|
|
231
|
+
}
|
|
232
|
+
if (cliOptions.threatIntel !== undefined) {
|
|
233
|
+
config.threatIntel = cliOptions.threatIntel;
|
|
234
|
+
}
|
|
235
|
+
if (cliOptions.semanticAnalysis !== undefined) {
|
|
236
|
+
config.semanticAnalysis = cliOptions.semanticAnalysis;
|
|
237
|
+
}
|
|
238
|
+
if (cliOptions.correlationAnalysis !== undefined) {
|
|
239
|
+
config.correlationAnalysis = cliOptions.correlationAnalysis;
|
|
240
|
+
}
|
|
241
|
+
if (cliOptions.autoRemediation !== undefined) {
|
|
242
|
+
config.autoRemediation = cliOptions.autoRemediation;
|
|
243
|
+
}
|
|
244
|
+
return config;
|
|
245
|
+
}
|
|
246
|
+
export default loadConfig;
|
|
247
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ignore file parser for Ferret-Scan
|
|
3
|
+
* Handles .ferretignore files similar to .gitignore
|
|
4
|
+
*/
|
|
5
|
+
export interface Ignore {
|
|
6
|
+
add(patterns: string | string[]): Ignore;
|
|
7
|
+
ignores(path: string): boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Create an ignore filter instance
|
|
11
|
+
*/
|
|
12
|
+
export declare function createIgnoreFilter(baseDir: string, additionalPatterns?: string[]): Ignore;
|
|
13
|
+
/**
|
|
14
|
+
* Check if a path should be ignored
|
|
15
|
+
*/
|
|
16
|
+
export declare function shouldIgnore(ig: Ignore, filePath: string, baseDir: string): boolean;
|
|
17
|
+
export default createIgnoreFilter;
|
|
18
|
+
//# sourceMappingURL=ignore.d.ts.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ignore file parser for Ferret-Scan
|
|
3
|
+
* Handles .ferretignore files similar to .gitignore
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { resolve, dirname, relative } from 'node:path';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const ignoreFactory = require('ignore');
|
|
10
|
+
import logger from './logger.js';
|
|
11
|
+
const IGNORE_FILE_NAMES = [
|
|
12
|
+
'.ferretignore',
|
|
13
|
+
'.ferret/ignore',
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Find and load ignore patterns from files
|
|
17
|
+
*/
|
|
18
|
+
function findIgnoreFiles(startDir) {
|
|
19
|
+
const files = [];
|
|
20
|
+
let currentDir = resolve(startDir);
|
|
21
|
+
const root = dirname(currentDir);
|
|
22
|
+
while (currentDir !== root) {
|
|
23
|
+
for (const ignoreName of IGNORE_FILE_NAMES) {
|
|
24
|
+
const ignorePath = resolve(currentDir, ignoreName);
|
|
25
|
+
if (existsSync(ignorePath)) {
|
|
26
|
+
files.push(ignorePath);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
currentDir = dirname(currentDir);
|
|
30
|
+
}
|
|
31
|
+
return files;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Load patterns from an ignore file
|
|
35
|
+
*/
|
|
36
|
+
function loadIgnorePatterns(filePath) {
|
|
37
|
+
try {
|
|
38
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
39
|
+
return content
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map(line => line.trim())
|
|
42
|
+
.filter(line => line && !line.startsWith('#'));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
46
|
+
logger.warn(`Failed to load ignore file ${filePath}: ${message}`);
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Create an ignore filter instance
|
|
52
|
+
*/
|
|
53
|
+
export function createIgnoreFilter(baseDir, additionalPatterns = []) {
|
|
54
|
+
const ig = ignoreFactory();
|
|
55
|
+
// Load patterns from ignore files
|
|
56
|
+
const ignoreFiles = findIgnoreFiles(baseDir);
|
|
57
|
+
for (const file of ignoreFiles) {
|
|
58
|
+
const patterns = loadIgnorePatterns(file);
|
|
59
|
+
logger.debug(`Loaded ${patterns.length} patterns from ${file}`);
|
|
60
|
+
ig.add(patterns);
|
|
61
|
+
}
|
|
62
|
+
// Add additional patterns (from config)
|
|
63
|
+
if (additionalPatterns.length > 0) {
|
|
64
|
+
ig.add(additionalPatterns);
|
|
65
|
+
}
|
|
66
|
+
// Always ignore certain patterns
|
|
67
|
+
ig.add([
|
|
68
|
+
'.git',
|
|
69
|
+
'node_modules',
|
|
70
|
+
'.ferret-quarantine',
|
|
71
|
+
]);
|
|
72
|
+
return ig;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check if a path should be ignored
|
|
76
|
+
*/
|
|
77
|
+
export function shouldIgnore(ig, filePath, baseDir) {
|
|
78
|
+
const relativePath = relative(baseDir, filePath);
|
|
79
|
+
return ig.ignores(relativePath);
|
|
80
|
+
}
|
|
81
|
+
export default createIgnoreFilter;
|
|
82
|
+
//# sourceMappingURL=ignore.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger utility for Ferret-Scan
|
|
3
|
+
* Provides consistent logging with levels and formatting
|
|
4
|
+
*/
|
|
5
|
+
import type { Severity } from '../types.js';
|
|
6
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
|
7
|
+
interface LoggerConfig {
|
|
8
|
+
level: LogLevel;
|
|
9
|
+
verbose: boolean;
|
|
10
|
+
ci: boolean;
|
|
11
|
+
}
|
|
12
|
+
declare class Logger {
|
|
13
|
+
private config;
|
|
14
|
+
configure(config: Partial<LoggerConfig>): void;
|
|
15
|
+
private shouldLog;
|
|
16
|
+
private formatMessage;
|
|
17
|
+
debug(message: string, ...args: unknown[]): void;
|
|
18
|
+
info(message: string, ...args: unknown[]): void;
|
|
19
|
+
warn(message: string, ...args: unknown[]): void;
|
|
20
|
+
error(message: string, ...args: unknown[]): void;
|
|
21
|
+
/** Log without any formatting - for direct output */
|
|
22
|
+
raw(message: string): void;
|
|
23
|
+
/** Log finding with severity-appropriate formatting */
|
|
24
|
+
finding(severity: Severity, message: string): void;
|
|
25
|
+
/** Get current log level */
|
|
26
|
+
getLevel(): LogLevel;
|
|
27
|
+
/** Check if verbose mode is enabled */
|
|
28
|
+
isVerbose(): boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare const logger: Logger;
|
|
31
|
+
export default logger;
|
|
32
|
+
//# sourceMappingURL=logger.d.ts.map
|