praxis-sec 1.2.0 → 1.2.2
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/assets/praxis-architecture.svg +304 -0
- package/assets/praxis-logo.svg +38 -0
- package/cli/agents/abom-generator.js +1 -1
- package/cli/agents/agent-attestation-agent.js +10 -1
- package/cli/agents/agent-config-scanner.js +1 -1
- package/cli/agents/ai-infra-inventory-agent.js +482 -482
- package/cli/agents/base-agent.js +1 -1
- package/cli/agents/endpoint-agent-abuse-agent.js +1 -1
- package/cli/agents/html-reporter.js +10 -10
- package/cli/agents/index.js +2 -2
- package/cli/agents/injection-tester.js +8 -1
- package/cli/agents/mcp-security-agent.js +600 -594
- package/cli/agents/memory-poisoning-agent.js +1 -1
- package/cli/agents/model-file-scanner.js +1 -1
- package/cli/agents/orchestrator.js +375 -355
- package/cli/agents/prompt-injection-prober.js +228 -228
- package/cli/bin/praxis.js +7 -3
- package/cli/commands/agent-fix.js +1091 -1245
- package/cli/commands/audit.js +1232 -1216
- package/cli/commands/baseline.js +3 -2
- package/cli/commands/benchmark.js +1 -1
- package/cli/commands/ci.js +50 -25
- package/cli/commands/deps.js +11 -5
- package/cli/commands/diff.js +2 -1
- package/cli/commands/env-audit.js +1 -1
- package/cli/commands/fix.js +1 -1
- package/cli/commands/legal.js +2 -1
- package/cli/commands/mcp.js +3 -2
- package/cli/commands/openclaw.js +3 -6
- package/cli/commands/red-team.js +351 -350
- package/cli/commands/remediate.js +1 -1
- package/cli/commands/rotate.js +1 -1
- package/cli/commands/rules.js +1 -1
- package/cli/commands/scan-standard.js +3 -6
- package/cli/commands/scan.js +554 -554
- package/cli/commands/score.js +1 -1
- package/cli/commands/undo.js +22 -77
- package/cli/commands/vibe-check.js +3 -2
- package/cli/commands/watch.js +6 -5
- package/cli/core/fix-plan.js +274 -0
- package/cli/core/fs.js +27 -0
- package/cli/core/git-clone.js +8 -6
- package/cli/core/glob.js +56 -0
- package/cli/core/output/html-theme.js +158 -158
- package/cli/core/output/json.js +56 -48
- package/cli/core/output/sarif.js +2 -2
- package/cli/core/paths.js +91 -0
- package/cli/core/web/jobs.js +2 -2
- package/cli/core/web/server.js +19 -8
- package/cli/data/threatpacks/latest.json +41 -41
- package/cli/integrations/github-action.js +136 -0
- package/cli/utils/cache-manager.js +2 -1
- package/cli/utils/plugin-loader.js +15 -95
- package/cli/utils/rule-import.js +227 -227
- package/cli/utils/rule-registry.js +425 -425
- package/cli/utils/scan-fingerprint.js +1 -1
- package/cli/utils/score-history.js +118 -118
- package/docs/USAGE.md +16 -9
- package/docs/design/WEB-UI.md +4 -5
- package/package.json +81 -71
package/cli/commands/scan.js
CHANGED
|
@@ -1,554 +1,554 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Scan Command
|
|
3
|
-
* ============
|
|
4
|
-
*
|
|
5
|
-
* Scans a directory for leaked secrets using pattern matching + entropy scoring.
|
|
6
|
-
*
|
|
7
|
-
* USAGE:
|
|
8
|
-
* praxis scan [path] Scan specified path (default: current directory)
|
|
9
|
-
* praxis scan . -v Verbose mode (show files being scanned)
|
|
10
|
-
* praxis scan . --json Output as JSON (for CI integration)
|
|
11
|
-
* praxis scan . --include-tests Also scan test files (excluded by default)
|
|
12
|
-
*
|
|
13
|
-
* SUPPRESSING FALSE POSITIVES:
|
|
14
|
-
* Add # praxis-ignore as a comment on the same line to suppress a finding.
|
|
15
|
-
* Create a .praxisignore file (same syntax as .gitignore) to exclude paths.
|
|
16
|
-
*
|
|
17
|
-
* EXIT CODES:
|
|
18
|
-
* 0 - No secrets found
|
|
19
|
-
* 1 - Secrets found (or error)
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import fs from 'fs';
|
|
23
|
-
import path from 'path';
|
|
24
|
-
import { renderFindingsSARIF } from '../core/output/sarif.js';
|
|
25
|
-
import fg from '
|
|
26
|
-
import ora from 'ora';
|
|
27
|
-
import chalk from 'chalk';
|
|
28
|
-
import {
|
|
29
|
-
SECRET_PATTERNS,
|
|
30
|
-
SECURITY_PATTERNS,
|
|
31
|
-
SKIP_DIRS,
|
|
32
|
-
SKIP_EXTENSIONS,
|
|
33
|
-
SKIP_FILENAMES,
|
|
34
|
-
TEST_FILE_PATTERNS,
|
|
35
|
-
MAX_FILE_SIZE,
|
|
36
|
-
loadGitignorePatterns
|
|
37
|
-
} from '../utils/patterns.js';
|
|
38
|
-
import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
39
|
-
import * as output from '../utils/output.js';
|
|
40
|
-
import { CacheManager } from '../utils/cache-manager.js';
|
|
41
|
-
import { isGitUrl, cloneGitRepo } from '../core/git-clone.js';
|
|
42
|
-
|
|
43
|
-
// =============================================================================
|
|
44
|
-
// CUSTOM PATTERNS (.praxis.json)
|
|
45
|
-
// =============================================================================
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Load custom patterns from .praxis.json in the project root.
|
|
49
|
-
*
|
|
50
|
-
* Format:
|
|
51
|
-
* {
|
|
52
|
-
* "patterns": [
|
|
53
|
-
* {
|
|
54
|
-
* "name": "My Internal Key",
|
|
55
|
-
* "pattern": "MYAPP_[A-Z0-9]{32}",
|
|
56
|
-
* "severity": "high",
|
|
57
|
-
* "description": "Internal API key for myapp services."
|
|
58
|
-
* }
|
|
59
|
-
* ]
|
|
60
|
-
* }
|
|
61
|
-
*/
|
|
62
|
-
function loadCustomPatterns(rootPath) {
|
|
63
|
-
const configPath = path.join(rootPath, '.praxis.json');
|
|
64
|
-
if (!fs.existsSync(configPath)) return [];
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
68
|
-
if (!Array.isArray(config.patterns)) return [];
|
|
69
|
-
|
|
70
|
-
return config.patterns
|
|
71
|
-
.filter(p => p.name && p.pattern)
|
|
72
|
-
.map(p => ({
|
|
73
|
-
name: `[custom] ${p.name}`,
|
|
74
|
-
pattern: new RegExp(p.pattern, 'g'),
|
|
75
|
-
severity: p.severity || 'high',
|
|
76
|
-
description: p.description || `Custom pattern: ${p.name}`,
|
|
77
|
-
custom: true,
|
|
78
|
-
}));
|
|
79
|
-
} catch (err) {
|
|
80
|
-
output.warning(`.praxis.json parse error: ${err.message}`);
|
|
81
|
-
return [];
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// =============================================================================
|
|
86
|
-
// MAIN SCAN FUNCTION
|
|
87
|
-
// =============================================================================
|
|
88
|
-
|
|
89
|
-
export async function scanCommand(targetPath = '.', options = {}) {
|
|
90
|
-
let gitClone = null;
|
|
91
|
-
let effectivePath = targetPath;
|
|
92
|
-
|
|
93
|
-
if (isGitUrl(targetPath)) {
|
|
94
|
-
const gitSpinner = ora({ text: chalk.cyan(`Cloning remote Git repository: ${targetPath}...`), color: 'cyan' }).start();
|
|
95
|
-
try {
|
|
96
|
-
gitClone = cloneGitRepo(targetPath, {
|
|
97
|
-
branch: options.branch,
|
|
98
|
-
depth: options.depth,
|
|
99
|
-
gitToken: options.gitToken,
|
|
100
|
-
submodules: options.submodules,
|
|
101
|
-
});
|
|
102
|
-
effectivePath = gitClone.tempDir;
|
|
103
|
-
gitSpinner.succeed(chalk.green(`Cloned ${gitClone.repoName} (${gitClone.displayUrl})`));
|
|
104
|
-
} catch (err) {
|
|
105
|
-
gitSpinner.fail(chalk.red(err.message));
|
|
106
|
-
process.exit(1);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const cleanup = () => {
|
|
111
|
-
if (gitClone && !options.keepClone) {
|
|
112
|
-
gitClone.cleanup();
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const absolutePath = path.resolve(effectivePath);
|
|
117
|
-
|
|
118
|
-
// Validate path exists
|
|
119
|
-
if (!fs.existsSync(absolutePath)) {
|
|
120
|
-
cleanup();
|
|
121
|
-
output.error(`Path does not exist: ${absolutePath}`);
|
|
122
|
-
process.exit(1);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Load .praxisignore patterns
|
|
126
|
-
const ignorePatterns = loadIgnoreFile(absolutePath);
|
|
127
|
-
|
|
128
|
-
// Load custom patterns from .praxis.json
|
|
129
|
-
const customPatterns = loadCustomPatterns(absolutePath);
|
|
130
|
-
const allPatterns = [...SECRET_PATTERNS, ...SECURITY_PATTERNS, ...customPatterns];
|
|
131
|
-
|
|
132
|
-
if (customPatterns.length > 0 && options.verbose) {
|
|
133
|
-
output.info(`Loaded ${customPatterns.length} custom pattern(s) from .praxis.json`);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Start spinner
|
|
137
|
-
const spinner = ora({
|
|
138
|
-
text: 'Scanning for secrets and vulnerabilities...',
|
|
139
|
-
color: 'cyan'
|
|
140
|
-
}).start();
|
|
141
|
-
|
|
142
|
-
try {
|
|
143
|
-
// Find all files
|
|
144
|
-
const files = await findFiles(absolutePath, ignorePatterns, options);
|
|
145
|
-
|
|
146
|
-
// Cache: determine which files changed
|
|
147
|
-
const useCache = options.cache !== false;
|
|
148
|
-
const cache = new CacheManager(absolutePath);
|
|
149
|
-
const cacheData = useCache ? cache.load() : null;
|
|
150
|
-
let filesToScan = files;
|
|
151
|
-
let cacheDiff = null;
|
|
152
|
-
const cachedResults = [];
|
|
153
|
-
|
|
154
|
-
if (cacheData) {
|
|
155
|
-
cacheDiff = cache.diff(files);
|
|
156
|
-
filesToScan = cacheDiff.changedFiles;
|
|
157
|
-
|
|
158
|
-
// Group cached findings by file
|
|
159
|
-
const cachedByFile = {};
|
|
160
|
-
for (const f of cacheDiff.cachedFindings) {
|
|
161
|
-
if (!cachedByFile[f.file]) cachedByFile[f.file] = [];
|
|
162
|
-
cachedByFile[f.file].push({
|
|
163
|
-
line: f.line,
|
|
164
|
-
column: f.column,
|
|
165
|
-
matched: f.matched,
|
|
166
|
-
patternName: f.rule || f.title,
|
|
167
|
-
severity: f.severity,
|
|
168
|
-
confidence: f.confidence,
|
|
169
|
-
description: f.description,
|
|
170
|
-
category: f.category,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
for (const [file, findings] of Object.entries(cachedByFile)) {
|
|
174
|
-
cachedResults.push({ file, findings });
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const cacheNote = cacheDiff && filesToScan.length < files.length
|
|
179
|
-
? ` (${filesToScan.length} changed, ${cacheDiff.unchangedCount} cached)`
|
|
180
|
-
: '';
|
|
181
|
-
spinner.text = `Scanning ${filesToScan.length} files${cacheNote}...`;
|
|
182
|
-
|
|
183
|
-
// Scan each file
|
|
184
|
-
const results = [];
|
|
185
|
-
let scannedCount = 0;
|
|
186
|
-
|
|
187
|
-
for (const file of filesToScan) {
|
|
188
|
-
const findings = await scanFile(file, allPatterns);
|
|
189
|
-
if (findings.length > 0) {
|
|
190
|
-
results.push({ file, findings });
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
scannedCount++;
|
|
194
|
-
if (options.verbose) {
|
|
195
|
-
spinner.text = `Scanned ${scannedCount}/${filesToScan.length}: ${path.relative(absolutePath, file)}`;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Merge with cached results
|
|
200
|
-
const allResults = [...results, ...cachedResults];
|
|
201
|
-
|
|
202
|
-
// Save cache
|
|
203
|
-
if (useCache) {
|
|
204
|
-
try {
|
|
205
|
-
const allFindings = [];
|
|
206
|
-
for (const { file, findings } of allResults) {
|
|
207
|
-
for (const f of findings) {
|
|
208
|
-
allFindings.push({
|
|
209
|
-
file,
|
|
210
|
-
line: f.line,
|
|
211
|
-
column: f.column,
|
|
212
|
-
severity: f.severity,
|
|
213
|
-
category: f.category || 'secrets',
|
|
214
|
-
rule: f.patternName,
|
|
215
|
-
title: f.patternName,
|
|
216
|
-
description: f.description,
|
|
217
|
-
matched: f.matched,
|
|
218
|
-
confidence: f.confidence,
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
cache.save(files, allFindings, null, null);
|
|
223
|
-
} catch {
|
|
224
|
-
// Silent
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
spinner.stop();
|
|
229
|
-
|
|
230
|
-
// Output results
|
|
231
|
-
if (options.sarif) {
|
|
232
|
-
console.log(renderSARIF(allResults, absolutePath));
|
|
233
|
-
} else if (options.json) {
|
|
234
|
-
outputJSON(allResults, files.length);
|
|
235
|
-
} else {
|
|
236
|
-
outputPretty(allResults, files.length, absolutePath);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Exit with appropriate code
|
|
240
|
-
cleanup();
|
|
241
|
-
const hasFindings = allResults.length > 0;
|
|
242
|
-
process.exit(hasFindings ? 1 : 0);
|
|
243
|
-
|
|
244
|
-
} catch (err) {
|
|
245
|
-
cleanup();
|
|
246
|
-
spinner.fail('Scan failed');
|
|
247
|
-
output.error(err.message);
|
|
248
|
-
process.exit(1);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// =============================================================================
|
|
253
|
-
// .PRAXISIGNORE LOADING
|
|
254
|
-
// =============================================================================
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Load ignore patterns from .praxisignore file.
|
|
258
|
-
* Same syntax as .gitignore — glob patterns, one per line, # for comments.
|
|
259
|
-
*/
|
|
260
|
-
function loadIgnoreFile(rootPath) {
|
|
261
|
-
const ignorePath = path.join(rootPath, '.praxisignore');
|
|
262
|
-
|
|
263
|
-
if (!fs.existsSync(ignorePath)) return [];
|
|
264
|
-
|
|
265
|
-
try {
|
|
266
|
-
return fs.readFileSync(ignorePath, 'utf-8')
|
|
267
|
-
.split('\n')
|
|
268
|
-
.map(line => line.trim())
|
|
269
|
-
.filter(line => line && !line.startsWith('#'));
|
|
270
|
-
} catch {
|
|
271
|
-
return [];
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Check if a file path matches any ignore pattern.
|
|
277
|
-
* Supports: exact paths, glob patterns, and directory prefixes.
|
|
278
|
-
*/
|
|
279
|
-
function isIgnoredByFile(filePath, rootPath, ignorePatterns) {
|
|
280
|
-
if (ignorePatterns.length === 0) return false;
|
|
281
|
-
|
|
282
|
-
const relPath = path.relative(rootPath, filePath).replace(/\\/g, '/');
|
|
283
|
-
|
|
284
|
-
return ignorePatterns.some(pattern => {
|
|
285
|
-
// Directory prefix match: "tests/" ignores everything under tests/
|
|
286
|
-
if (pattern.endsWith('/')) {
|
|
287
|
-
return relPath.startsWith(pattern) || relPath.includes('/' + pattern);
|
|
288
|
-
}
|
|
289
|
-
// Simple glob: "**/fixtures/**" or "src/secrets.js"
|
|
290
|
-
const escaped = pattern
|
|
291
|
-
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
292
|
-
.replace(/\*/g, '[^/]*')
|
|
293
|
-
.replace(/\?/g, '[^/]');
|
|
294
|
-
return new RegExp(`(^|/)${escaped}($|/)`).test(relPath);
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// =============================================================================
|
|
299
|
-
// FILE DISCOVERY
|
|
300
|
-
// =============================================================================
|
|
301
|
-
|
|
302
|
-
async function findFiles(rootPath, ignorePatterns, options = {}) {
|
|
303
|
-
// Build ignore patterns from SKIP_DIRS
|
|
304
|
-
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
305
|
-
|
|
306
|
-
// Respect .gitignore patterns
|
|
307
|
-
const gitignoreGlobs = loadGitignorePatterns(rootPath);
|
|
308
|
-
globIgnore.push(...gitignoreGlobs);
|
|
309
|
-
|
|
310
|
-
// Find all files
|
|
311
|
-
const files = await fg('**/*', {
|
|
312
|
-
cwd: rootPath,
|
|
313
|
-
absolute: true,
|
|
314
|
-
onlyFiles: true,
|
|
315
|
-
ignore: globIgnore,
|
|
316
|
-
dot: true
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
const filtered = [];
|
|
320
|
-
|
|
321
|
-
for (const file of files) {
|
|
322
|
-
// Skip by extension
|
|
323
|
-
const ext = path.extname(file).toLowerCase();
|
|
324
|
-
if (SKIP_EXTENSIONS.has(ext)) continue;
|
|
325
|
-
if (SKIP_FILENAMES.has(path.basename(file))) continue;
|
|
326
|
-
|
|
327
|
-
// Handle compound extensions like .min.js
|
|
328
|
-
const basename = path.basename(file);
|
|
329
|
-
if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
|
|
330
|
-
|
|
331
|
-
// Skip test files by default (--include-tests to override)
|
|
332
|
-
if (!options.includeTests && isTestFile(file)) continue;
|
|
333
|
-
|
|
334
|
-
// Skip files matching .praxisignore
|
|
335
|
-
if (isIgnoredByFile(file, rootPath, ignorePatterns)) continue;
|
|
336
|
-
|
|
337
|
-
// Skip by size
|
|
338
|
-
try {
|
|
339
|
-
const stats = fs.statSync(file);
|
|
340
|
-
if (stats.size > MAX_FILE_SIZE) continue;
|
|
341
|
-
} catch {
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
filtered.push(file);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
return filtered;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function isTestFile(filePath) {
|
|
352
|
-
return TEST_FILE_PATTERNS.some(pattern => pattern.test(filePath));
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// =============================================================================
|
|
356
|
-
// FILE SCANNING
|
|
357
|
-
// =============================================================================
|
|
358
|
-
|
|
359
|
-
async function scanFile(filePath, patterns = SECRET_PATTERNS) {
|
|
360
|
-
const findings = [];
|
|
361
|
-
|
|
362
|
-
try {
|
|
363
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
364
|
-
const lines = content.split('\n');
|
|
365
|
-
|
|
366
|
-
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
367
|
-
const line = lines[lineNum];
|
|
368
|
-
|
|
369
|
-
// Inline suppression: # praxis-ignore on the same line
|
|
370
|
-
if (/praxis-ignore/i.test(line)) continue;
|
|
371
|
-
|
|
372
|
-
for (const pattern of patterns) {
|
|
373
|
-
// Reset regex state (important for global regexes)
|
|
374
|
-
pattern.pattern.lastIndex = 0;
|
|
375
|
-
|
|
376
|
-
let match;
|
|
377
|
-
while ((match = pattern.pattern.exec(line)) !== null) {
|
|
378
|
-
// For generic patterns, apply entropy check to filter placeholders
|
|
379
|
-
if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const confidence = getConfidence(pattern, match[0]);
|
|
384
|
-
|
|
385
|
-
findings.push({
|
|
386
|
-
line: lineNum + 1,
|
|
387
|
-
column: match.index + 1,
|
|
388
|
-
matched: match[0],
|
|
389
|
-
patternName: pattern.name,
|
|
390
|
-
severity: pattern.severity,
|
|
391
|
-
confidence,
|
|
392
|
-
description: pattern.description,
|
|
393
|
-
category: pattern.category || 'secret'
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
} catch {
|
|
399
|
-
// Skip files that can't be read (binary, permissions, etc.)
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Deduplicate: multiple patterns can match the same secret on the same line
|
|
403
|
-
// (e.g. Stripe and Clerk both match sk_live_...). Keep one finding per
|
|
404
|
-
// unique (line, matched-text) pair — first match wins (patterns are ordered
|
|
405
|
-
// by severity: critical → high → medium).
|
|
406
|
-
const seen = new Set();
|
|
407
|
-
return findings.filter(f => {
|
|
408
|
-
const key = `${f.line}:${f.matched}`;
|
|
409
|
-
if (seen.has(key)) return false;
|
|
410
|
-
seen.add(key);
|
|
411
|
-
return true;
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// =============================================================================
|
|
416
|
-
// OUTPUT FORMATTING
|
|
417
|
-
// =============================================================================
|
|
418
|
-
|
|
419
|
-
function outputPretty(results, filesScanned, rootPath) {
|
|
420
|
-
// Separate findings into secrets and code vulnerabilities
|
|
421
|
-
const secretResults = [];
|
|
422
|
-
const vulnResults = [];
|
|
423
|
-
|
|
424
|
-
for (const { file, findings } of results) {
|
|
425
|
-
const secrets = findings.filter(f => f.category !== 'vulnerability');
|
|
426
|
-
const vulns = findings.filter(f => f.category === 'vulnerability');
|
|
427
|
-
if (secrets.length > 0) secretResults.push({ file, findings: secrets });
|
|
428
|
-
if (vulns.length > 0) vulnResults.push({ file, findings: vulns });
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const stats = {
|
|
432
|
-
total: 0,
|
|
433
|
-
critical: 0,
|
|
434
|
-
high: 0,
|
|
435
|
-
medium: 0,
|
|
436
|
-
secretsTotal: 0,
|
|
437
|
-
vulnsTotal: 0,
|
|
438
|
-
filesScanned
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
for (const { findings } of results) {
|
|
442
|
-
for (const f of findings) {
|
|
443
|
-
stats.total++;
|
|
444
|
-
stats[f.severity] = (stats[f.severity] || 0) + 1;
|
|
445
|
-
if (f.category === 'vulnerability') stats.vulnsTotal++;
|
|
446
|
-
else stats.secretsTotal++;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
output.header('Scan Results');
|
|
451
|
-
|
|
452
|
-
if (results.length === 0) {
|
|
453
|
-
output.success('No secrets or vulnerabilities detected in your codebase!');
|
|
454
|
-
console.log();
|
|
455
|
-
console.log(chalk.gray('Note: Uses pattern matching + entropy scoring. Test files excluded by default.'));
|
|
456
|
-
console.log(chalk.gray('Tip: Run with --include-tests to also scan test files.'));
|
|
457
|
-
console.log(chalk.gray('Tip: Add a .praxisignore file to exclude paths.'));
|
|
458
|
-
} else {
|
|
459
|
-
// ── Secrets section ────────────────────────────────────────────────────
|
|
460
|
-
if (secretResults.length > 0) {
|
|
461
|
-
console.log();
|
|
462
|
-
console.log(chalk.red.bold(` Secrets (${stats.secretsTotal})`));
|
|
463
|
-
console.log(chalk.red(' ' + '─'.repeat(58)));
|
|
464
|
-
|
|
465
|
-
for (const { file, findings } of secretResults) {
|
|
466
|
-
const relPath = path.relative(rootPath, file);
|
|
467
|
-
for (const f of findings) {
|
|
468
|
-
output.finding(relPath, f.line, f.patternName, f.severity, f.matched, f.description, f.confidence);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// ── Code Vulnerabilities section ───────────────────────────────────────
|
|
474
|
-
if (vulnResults.length > 0) {
|
|
475
|
-
console.log();
|
|
476
|
-
console.log(chalk.yellow.bold(` Code Vulnerabilities (${stats.vulnsTotal})`));
|
|
477
|
-
console.log(chalk.yellow(' ' + '─'.repeat(58)));
|
|
478
|
-
|
|
479
|
-
for (const { file, findings } of vulnResults) {
|
|
480
|
-
const relPath = path.relative(rootPath, file);
|
|
481
|
-
for (const f of findings) {
|
|
482
|
-
output.vulnerabilityFinding(relPath, f.line, f.patternName, f.severity, f.matched, f.description);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Remind about suppressions
|
|
488
|
-
console.log();
|
|
489
|
-
console.log(chalk.gray('Suppress a finding: add # praxis-ignore as a comment on that line'));
|
|
490
|
-
console.log(chalk.gray('Exclude a path: add it to .praxisignore'));
|
|
491
|
-
console.log();
|
|
492
|
-
console.log(chalk.gray('Track findings over time: ') + chalk.cyan(''));
|
|
493
|
-
|
|
494
|
-
if (secretResults.length > 0) output.recommendations();
|
|
495
|
-
if (vulnResults.length > 0) output.vulnRecommendations();
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
output.summary(stats);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function outputJSON(results, filesScanned) {
|
|
502
|
-
const jsonOutput = {
|
|
503
|
-
success: results.length === 0,
|
|
504
|
-
filesScanned,
|
|
505
|
-
totalFindings: 0,
|
|
506
|
-
findings: []
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
for (const { file, findings } of results) {
|
|
510
|
-
for (const f of findings) {
|
|
511
|
-
jsonOutput.totalFindings++;
|
|
512
|
-
jsonOutput.findings.push({
|
|
513
|
-
file,
|
|
514
|
-
line: f.line,
|
|
515
|
-
column: f.column,
|
|
516
|
-
category: f.category || 'secret',
|
|
517
|
-
severity: f.severity,
|
|
518
|
-
confidence: f.confidence,
|
|
519
|
-
type: f.patternName,
|
|
520
|
-
matched: f.category === 'vulnerability' ? f.matched : output.maskSecret(f.matched),
|
|
521
|
-
description: f.description
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// =============================================================================
|
|
530
|
-
// SARIF OUTPUT (GitHub Code Scanning compatible)
|
|
531
|
-
// =============================================================================
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Output findings in SARIF 2.1.0 format.
|
|
535
|
-
* Feed this into GitHub's Security tab:
|
|
536
|
-
* npx praxis-sec scan . --sarif > results.sarif
|
|
537
|
-
*
|
|
538
|
-
* Then upload via:
|
|
539
|
-
* github/codeql-action/upload-sarif@v3
|
|
540
|
-
*/
|
|
541
|
-
/**
|
|
542
|
-
* SARIF output for GitHub Code Scanning.
|
|
543
|
-
*
|
|
544
|
-
* Delegates to the shared serializer in `cli/core/output/sarif.js
|
|
545
|
-
* to be a private copy. That copy also disagreed with itself — it gave a `low` finding
|
|
546
|
-
* rule-level `note` but result-level `warning` — and hardcoded the SARIF spec version
|
|
547
|
-
* `2.1.0` as the tool driver version.
|
|
548
|
-
*
|
|
549
|
-
* `allResults` arrives as `[{ file, findings }]`, so findings are flattened with their
|
|
550
|
-
* file attached before serializing. `--sarif` writes to stdout; redirect it.
|
|
551
|
-
*/
|
|
552
|
-
function renderSARIF(allResults, rootPath) {
|
|
553
|
-
return renderFindingsSARIF(allResults, { rootPath });
|
|
554
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Scan Command
|
|
3
|
+
* ============
|
|
4
|
+
*
|
|
5
|
+
* Scans a directory for leaked secrets using pattern matching + entropy scoring.
|
|
6
|
+
*
|
|
7
|
+
* USAGE:
|
|
8
|
+
* praxis scan [path] Scan specified path (default: current directory)
|
|
9
|
+
* praxis scan . -v Verbose mode (show files being scanned)
|
|
10
|
+
* praxis scan . --json Output as JSON (for CI integration)
|
|
11
|
+
* praxis scan . --include-tests Also scan test files (excluded by default)
|
|
12
|
+
*
|
|
13
|
+
* SUPPRESSING FALSE POSITIVES:
|
|
14
|
+
* Add # praxis-ignore as a comment on the same line to suppress a finding.
|
|
15
|
+
* Create a .praxisignore file (same syntax as .gitignore) to exclude paths.
|
|
16
|
+
*
|
|
17
|
+
* EXIT CODES:
|
|
18
|
+
* 0 - No secrets found
|
|
19
|
+
* 1 - Secrets found (or error)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import { renderFindingsSARIF } from '../core/output/sarif.js';
|
|
25
|
+
import fg from '../core/glob.js';
|
|
26
|
+
import ora from 'ora';
|
|
27
|
+
import chalk from 'chalk';
|
|
28
|
+
import {
|
|
29
|
+
SECRET_PATTERNS,
|
|
30
|
+
SECURITY_PATTERNS,
|
|
31
|
+
SKIP_DIRS,
|
|
32
|
+
SKIP_EXTENSIONS,
|
|
33
|
+
SKIP_FILENAMES,
|
|
34
|
+
TEST_FILE_PATTERNS,
|
|
35
|
+
MAX_FILE_SIZE,
|
|
36
|
+
loadGitignorePatterns
|
|
37
|
+
} from '../utils/patterns.js';
|
|
38
|
+
import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
39
|
+
import * as output from '../utils/output.js';
|
|
40
|
+
import { CacheManager } from '../utils/cache-manager.js';
|
|
41
|
+
import { isGitUrl, cloneGitRepo } from '../core/git-clone.js';
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// CUSTOM PATTERNS (.praxis.json)
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load custom patterns from .praxis.json in the project root.
|
|
49
|
+
*
|
|
50
|
+
* Format:
|
|
51
|
+
* {
|
|
52
|
+
* "patterns": [
|
|
53
|
+
* {
|
|
54
|
+
* "name": "My Internal Key",
|
|
55
|
+
* "pattern": "MYAPP_[A-Z0-9]{32}",
|
|
56
|
+
* "severity": "high",
|
|
57
|
+
* "description": "Internal API key for myapp services."
|
|
58
|
+
* }
|
|
59
|
+
* ]
|
|
60
|
+
* }
|
|
61
|
+
*/
|
|
62
|
+
function loadCustomPatterns(rootPath) {
|
|
63
|
+
const configPath = path.join(rootPath, '.praxis.json');
|
|
64
|
+
if (!fs.existsSync(configPath)) return [];
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
68
|
+
if (!Array.isArray(config.patterns)) return [];
|
|
69
|
+
|
|
70
|
+
return config.patterns
|
|
71
|
+
.filter(p => p.name && p.pattern)
|
|
72
|
+
.map(p => ({
|
|
73
|
+
name: `[custom] ${p.name}`,
|
|
74
|
+
pattern: new RegExp(p.pattern, 'g'),
|
|
75
|
+
severity: p.severity || 'high',
|
|
76
|
+
description: p.description || `Custom pattern: ${p.name}`,
|
|
77
|
+
custom: true,
|
|
78
|
+
}));
|
|
79
|
+
} catch (err) {
|
|
80
|
+
output.warning(`.praxis.json parse error: ${err.message}`);
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// MAIN SCAN FUNCTION
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
export async function scanCommand(targetPath = '.', options = {}) {
|
|
90
|
+
let gitClone = null;
|
|
91
|
+
let effectivePath = targetPath;
|
|
92
|
+
|
|
93
|
+
if (isGitUrl(targetPath)) {
|
|
94
|
+
const gitSpinner = ora({ text: chalk.cyan(`Cloning remote Git repository: ${targetPath}...`), color: 'cyan' }).start();
|
|
95
|
+
try {
|
|
96
|
+
gitClone = cloneGitRepo(targetPath, {
|
|
97
|
+
branch: options.branch,
|
|
98
|
+
depth: options.depth,
|
|
99
|
+
gitToken: options.gitToken,
|
|
100
|
+
submodules: options.submodules,
|
|
101
|
+
});
|
|
102
|
+
effectivePath = gitClone.tempDir;
|
|
103
|
+
gitSpinner.succeed(chalk.green(`Cloned ${gitClone.repoName} (${gitClone.displayUrl})`));
|
|
104
|
+
} catch (err) {
|
|
105
|
+
gitSpinner.fail(chalk.red(err.message));
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cleanup = () => {
|
|
111
|
+
if (gitClone && !options.keepClone) {
|
|
112
|
+
gitClone.cleanup();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const absolutePath = path.resolve(effectivePath);
|
|
117
|
+
|
|
118
|
+
// Validate path exists
|
|
119
|
+
if (!fs.existsSync(absolutePath)) {
|
|
120
|
+
cleanup();
|
|
121
|
+
output.error(`Path does not exist: ${absolutePath}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Load .praxisignore patterns
|
|
126
|
+
const ignorePatterns = loadIgnoreFile(absolutePath);
|
|
127
|
+
|
|
128
|
+
// Load custom patterns from .praxis.json
|
|
129
|
+
const customPatterns = loadCustomPatterns(absolutePath);
|
|
130
|
+
const allPatterns = [...SECRET_PATTERNS, ...SECURITY_PATTERNS, ...customPatterns];
|
|
131
|
+
|
|
132
|
+
if (customPatterns.length > 0 && options.verbose) {
|
|
133
|
+
output.info(`Loaded ${customPatterns.length} custom pattern(s) from .praxis.json`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Start spinner
|
|
137
|
+
const spinner = ora({
|
|
138
|
+
text: 'Scanning for secrets and vulnerabilities...',
|
|
139
|
+
color: 'cyan'
|
|
140
|
+
}).start();
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// Find all files
|
|
144
|
+
const files = await findFiles(absolutePath, ignorePatterns, options);
|
|
145
|
+
|
|
146
|
+
// Cache: determine which files changed
|
|
147
|
+
const useCache = options.cache !== false;
|
|
148
|
+
const cache = new CacheManager(absolutePath);
|
|
149
|
+
const cacheData = useCache ? cache.load() : null;
|
|
150
|
+
let filesToScan = files;
|
|
151
|
+
let cacheDiff = null;
|
|
152
|
+
const cachedResults = [];
|
|
153
|
+
|
|
154
|
+
if (cacheData) {
|
|
155
|
+
cacheDiff = cache.diff(files);
|
|
156
|
+
filesToScan = cacheDiff.changedFiles;
|
|
157
|
+
|
|
158
|
+
// Group cached findings by file
|
|
159
|
+
const cachedByFile = {};
|
|
160
|
+
for (const f of cacheDiff.cachedFindings) {
|
|
161
|
+
if (!cachedByFile[f.file]) cachedByFile[f.file] = [];
|
|
162
|
+
cachedByFile[f.file].push({
|
|
163
|
+
line: f.line,
|
|
164
|
+
column: f.column,
|
|
165
|
+
matched: f.matched,
|
|
166
|
+
patternName: f.rule || f.title,
|
|
167
|
+
severity: f.severity,
|
|
168
|
+
confidence: f.confidence,
|
|
169
|
+
description: f.description,
|
|
170
|
+
category: f.category,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
for (const [file, findings] of Object.entries(cachedByFile)) {
|
|
174
|
+
cachedResults.push({ file, findings });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const cacheNote = cacheDiff && filesToScan.length < files.length
|
|
179
|
+
? ` (${filesToScan.length} changed, ${cacheDiff.unchangedCount} cached)`
|
|
180
|
+
: '';
|
|
181
|
+
spinner.text = `Scanning ${filesToScan.length} files${cacheNote}...`;
|
|
182
|
+
|
|
183
|
+
// Scan each file
|
|
184
|
+
const results = [];
|
|
185
|
+
let scannedCount = 0;
|
|
186
|
+
|
|
187
|
+
for (const file of filesToScan) {
|
|
188
|
+
const findings = await scanFile(file, allPatterns);
|
|
189
|
+
if (findings.length > 0) {
|
|
190
|
+
results.push({ file, findings });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
scannedCount++;
|
|
194
|
+
if (options.verbose) {
|
|
195
|
+
spinner.text = `Scanned ${scannedCount}/${filesToScan.length}: ${path.relative(absolutePath, file)}`;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Merge with cached results
|
|
200
|
+
const allResults = [...results, ...cachedResults];
|
|
201
|
+
|
|
202
|
+
// Save cache
|
|
203
|
+
if (useCache) {
|
|
204
|
+
try {
|
|
205
|
+
const allFindings = [];
|
|
206
|
+
for (const { file, findings } of allResults) {
|
|
207
|
+
for (const f of findings) {
|
|
208
|
+
allFindings.push({
|
|
209
|
+
file,
|
|
210
|
+
line: f.line,
|
|
211
|
+
column: f.column,
|
|
212
|
+
severity: f.severity,
|
|
213
|
+
category: f.category || 'secrets',
|
|
214
|
+
rule: f.patternName,
|
|
215
|
+
title: f.patternName,
|
|
216
|
+
description: f.description,
|
|
217
|
+
matched: f.matched,
|
|
218
|
+
confidence: f.confidence,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
cache.save(files, allFindings, null, null);
|
|
223
|
+
} catch {
|
|
224
|
+
// Silent
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
spinner.stop();
|
|
229
|
+
|
|
230
|
+
// Output results
|
|
231
|
+
if (options.sarif) {
|
|
232
|
+
console.log(renderSARIF(allResults, absolutePath));
|
|
233
|
+
} else if (options.json) {
|
|
234
|
+
outputJSON(allResults, files.length);
|
|
235
|
+
} else {
|
|
236
|
+
outputPretty(allResults, files.length, absolutePath);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Exit with appropriate code
|
|
240
|
+
cleanup();
|
|
241
|
+
const hasFindings = allResults.length > 0;
|
|
242
|
+
process.exit(hasFindings ? 1 : 0);
|
|
243
|
+
|
|
244
|
+
} catch (err) {
|
|
245
|
+
cleanup();
|
|
246
|
+
spinner.fail('Scan failed');
|
|
247
|
+
output.error(err.message);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// =============================================================================
|
|
253
|
+
// .PRAXISIGNORE LOADING
|
|
254
|
+
// =============================================================================
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Load ignore patterns from .praxisignore file.
|
|
258
|
+
* Same syntax as .gitignore — glob patterns, one per line, # for comments.
|
|
259
|
+
*/
|
|
260
|
+
function loadIgnoreFile(rootPath) {
|
|
261
|
+
const ignorePath = path.join(rootPath, '.praxisignore');
|
|
262
|
+
|
|
263
|
+
if (!fs.existsSync(ignorePath)) return [];
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
return fs.readFileSync(ignorePath, 'utf-8')
|
|
267
|
+
.split('\n')
|
|
268
|
+
.map(line => line.trim())
|
|
269
|
+
.filter(line => line && !line.startsWith('#'));
|
|
270
|
+
} catch {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Check if a file path matches any ignore pattern.
|
|
277
|
+
* Supports: exact paths, glob patterns, and directory prefixes.
|
|
278
|
+
*/
|
|
279
|
+
function isIgnoredByFile(filePath, rootPath, ignorePatterns) {
|
|
280
|
+
if (ignorePatterns.length === 0) return false;
|
|
281
|
+
|
|
282
|
+
const relPath = path.relative(rootPath, filePath).replace(/\\/g, '/');
|
|
283
|
+
|
|
284
|
+
return ignorePatterns.some(pattern => {
|
|
285
|
+
// Directory prefix match: "tests/" ignores everything under tests/
|
|
286
|
+
if (pattern.endsWith('/')) {
|
|
287
|
+
return relPath.startsWith(pattern) || relPath.includes('/' + pattern);
|
|
288
|
+
}
|
|
289
|
+
// Simple glob: "**/fixtures/**" or "src/secrets.js"
|
|
290
|
+
const escaped = pattern
|
|
291
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
292
|
+
.replace(/\*/g, '[^/]*')
|
|
293
|
+
.replace(/\?/g, '[^/]');
|
|
294
|
+
return new RegExp(`(^|/)${escaped}($|/)`).test(relPath);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// =============================================================================
|
|
299
|
+
// FILE DISCOVERY
|
|
300
|
+
// =============================================================================
|
|
301
|
+
|
|
302
|
+
async function findFiles(rootPath, ignorePatterns, options = {}) {
|
|
303
|
+
// Build ignore patterns from SKIP_DIRS
|
|
304
|
+
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
305
|
+
|
|
306
|
+
// Respect .gitignore patterns
|
|
307
|
+
const gitignoreGlobs = loadGitignorePatterns(rootPath);
|
|
308
|
+
globIgnore.push(...gitignoreGlobs);
|
|
309
|
+
|
|
310
|
+
// Find all files
|
|
311
|
+
const files = await fg('**/*', {
|
|
312
|
+
cwd: rootPath,
|
|
313
|
+
absolute: true,
|
|
314
|
+
onlyFiles: true,
|
|
315
|
+
ignore: globIgnore,
|
|
316
|
+
dot: true
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const filtered = [];
|
|
320
|
+
|
|
321
|
+
for (const file of files) {
|
|
322
|
+
// Skip by extension
|
|
323
|
+
const ext = path.extname(file).toLowerCase();
|
|
324
|
+
if (SKIP_EXTENSIONS.has(ext)) continue;
|
|
325
|
+
if (SKIP_FILENAMES.has(path.basename(file))) continue;
|
|
326
|
+
|
|
327
|
+
// Handle compound extensions like .min.js
|
|
328
|
+
const basename = path.basename(file);
|
|
329
|
+
if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
|
|
330
|
+
|
|
331
|
+
// Skip test files by default (--include-tests to override)
|
|
332
|
+
if (!options.includeTests && isTestFile(file)) continue;
|
|
333
|
+
|
|
334
|
+
// Skip files matching .praxisignore
|
|
335
|
+
if (isIgnoredByFile(file, rootPath, ignorePatterns)) continue;
|
|
336
|
+
|
|
337
|
+
// Skip by size
|
|
338
|
+
try {
|
|
339
|
+
const stats = fs.statSync(file);
|
|
340
|
+
if (stats.size > MAX_FILE_SIZE) continue;
|
|
341
|
+
} catch {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
filtered.push(file);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return filtered;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function isTestFile(filePath) {
|
|
352
|
+
return TEST_FILE_PATTERNS.some(pattern => pattern.test(filePath));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// =============================================================================
|
|
356
|
+
// FILE SCANNING
|
|
357
|
+
// =============================================================================
|
|
358
|
+
|
|
359
|
+
async function scanFile(filePath, patterns = SECRET_PATTERNS) {
|
|
360
|
+
const findings = [];
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
364
|
+
const lines = content.split('\n');
|
|
365
|
+
|
|
366
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
367
|
+
const line = lines[lineNum];
|
|
368
|
+
|
|
369
|
+
// Inline suppression: # praxis-ignore on the same line
|
|
370
|
+
if (/praxis-ignore/i.test(line)) continue;
|
|
371
|
+
|
|
372
|
+
for (const pattern of patterns) {
|
|
373
|
+
// Reset regex state (important for global regexes)
|
|
374
|
+
pattern.pattern.lastIndex = 0;
|
|
375
|
+
|
|
376
|
+
let match;
|
|
377
|
+
while ((match = pattern.pattern.exec(line)) !== null) {
|
|
378
|
+
// For generic patterns, apply entropy check to filter placeholders
|
|
379
|
+
if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const confidence = getConfidence(pattern, match[0]);
|
|
384
|
+
|
|
385
|
+
findings.push({
|
|
386
|
+
line: lineNum + 1,
|
|
387
|
+
column: match.index + 1,
|
|
388
|
+
matched: match[0],
|
|
389
|
+
patternName: pattern.name,
|
|
390
|
+
severity: pattern.severity,
|
|
391
|
+
confidence,
|
|
392
|
+
description: pattern.description,
|
|
393
|
+
category: pattern.category || 'secret'
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} catch {
|
|
399
|
+
// Skip files that can't be read (binary, permissions, etc.)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Deduplicate: multiple patterns can match the same secret on the same line
|
|
403
|
+
// (e.g. Stripe and Clerk both match sk_live_...). Keep one finding per
|
|
404
|
+
// unique (line, matched-text) pair — first match wins (patterns are ordered
|
|
405
|
+
// by severity: critical → high → medium).
|
|
406
|
+
const seen = new Set();
|
|
407
|
+
return findings.filter(f => {
|
|
408
|
+
const key = `${f.line}:${f.matched}`;
|
|
409
|
+
if (seen.has(key)) return false;
|
|
410
|
+
seen.add(key);
|
|
411
|
+
return true;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// =============================================================================
|
|
416
|
+
// OUTPUT FORMATTING
|
|
417
|
+
// =============================================================================
|
|
418
|
+
|
|
419
|
+
function outputPretty(results, filesScanned, rootPath) {
|
|
420
|
+
// Separate findings into secrets and code vulnerabilities
|
|
421
|
+
const secretResults = [];
|
|
422
|
+
const vulnResults = [];
|
|
423
|
+
|
|
424
|
+
for (const { file, findings } of results) {
|
|
425
|
+
const secrets = findings.filter(f => f.category !== 'vulnerability');
|
|
426
|
+
const vulns = findings.filter(f => f.category === 'vulnerability');
|
|
427
|
+
if (secrets.length > 0) secretResults.push({ file, findings: secrets });
|
|
428
|
+
if (vulns.length > 0) vulnResults.push({ file, findings: vulns });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const stats = {
|
|
432
|
+
total: 0,
|
|
433
|
+
critical: 0,
|
|
434
|
+
high: 0,
|
|
435
|
+
medium: 0,
|
|
436
|
+
secretsTotal: 0,
|
|
437
|
+
vulnsTotal: 0,
|
|
438
|
+
filesScanned
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
for (const { findings } of results) {
|
|
442
|
+
for (const f of findings) {
|
|
443
|
+
stats.total++;
|
|
444
|
+
stats[f.severity] = (stats[f.severity] || 0) + 1;
|
|
445
|
+
if (f.category === 'vulnerability') stats.vulnsTotal++;
|
|
446
|
+
else stats.secretsTotal++;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
output.header('Scan Results');
|
|
451
|
+
|
|
452
|
+
if (results.length === 0) {
|
|
453
|
+
output.success('No secrets or vulnerabilities detected in your codebase!');
|
|
454
|
+
console.log();
|
|
455
|
+
console.log(chalk.gray('Note: Uses pattern matching + entropy scoring. Test files excluded by default.'));
|
|
456
|
+
console.log(chalk.gray('Tip: Run with --include-tests to also scan test files.'));
|
|
457
|
+
console.log(chalk.gray('Tip: Add a .praxisignore file to exclude paths.'));
|
|
458
|
+
} else {
|
|
459
|
+
// ── Secrets section ────────────────────────────────────────────────────
|
|
460
|
+
if (secretResults.length > 0) {
|
|
461
|
+
console.log();
|
|
462
|
+
console.log(chalk.red.bold(` Secrets (${stats.secretsTotal})`));
|
|
463
|
+
console.log(chalk.red(' ' + '─'.repeat(58)));
|
|
464
|
+
|
|
465
|
+
for (const { file, findings } of secretResults) {
|
|
466
|
+
const relPath = path.relative(rootPath, file);
|
|
467
|
+
for (const f of findings) {
|
|
468
|
+
output.finding(relPath, f.line, f.patternName, f.severity, f.matched, f.description, f.confidence);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Code Vulnerabilities section ───────────────────────────────────────
|
|
474
|
+
if (vulnResults.length > 0) {
|
|
475
|
+
console.log();
|
|
476
|
+
console.log(chalk.yellow.bold(` Code Vulnerabilities (${stats.vulnsTotal})`));
|
|
477
|
+
console.log(chalk.yellow(' ' + '─'.repeat(58)));
|
|
478
|
+
|
|
479
|
+
for (const { file, findings } of vulnResults) {
|
|
480
|
+
const relPath = path.relative(rootPath, file);
|
|
481
|
+
for (const f of findings) {
|
|
482
|
+
output.vulnerabilityFinding(relPath, f.line, f.patternName, f.severity, f.matched, f.description);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Remind about suppressions
|
|
488
|
+
console.log();
|
|
489
|
+
console.log(chalk.gray('Suppress a finding: add # praxis-ignore as a comment on that line'));
|
|
490
|
+
console.log(chalk.gray('Exclude a path: add it to .praxisignore'));
|
|
491
|
+
console.log();
|
|
492
|
+
console.log(chalk.gray('Track findings over time: ') + chalk.cyan(''));
|
|
493
|
+
|
|
494
|
+
if (secretResults.length > 0) output.recommendations();
|
|
495
|
+
if (vulnResults.length > 0) output.vulnRecommendations();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
output.summary(stats);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function outputJSON(results, filesScanned) {
|
|
502
|
+
const jsonOutput = {
|
|
503
|
+
success: results.length === 0,
|
|
504
|
+
filesScanned,
|
|
505
|
+
totalFindings: 0,
|
|
506
|
+
findings: []
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
for (const { file, findings } of results) {
|
|
510
|
+
for (const f of findings) {
|
|
511
|
+
jsonOutput.totalFindings++;
|
|
512
|
+
jsonOutput.findings.push({
|
|
513
|
+
file,
|
|
514
|
+
line: f.line,
|
|
515
|
+
column: f.column,
|
|
516
|
+
category: f.category || 'secret',
|
|
517
|
+
severity: f.severity,
|
|
518
|
+
confidence: f.confidence,
|
|
519
|
+
type: f.patternName,
|
|
520
|
+
matched: f.category === 'vulnerability' ? f.matched : output.maskSecret(f.matched),
|
|
521
|
+
description: f.description
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// =============================================================================
|
|
530
|
+
// SARIF OUTPUT (GitHub Code Scanning compatible)
|
|
531
|
+
// =============================================================================
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Output findings in SARIF 2.1.0 format.
|
|
535
|
+
* Feed this into GitHub's Security tab:
|
|
536
|
+
* npx praxis-sec scan . --sarif > results.sarif
|
|
537
|
+
*
|
|
538
|
+
* Then upload via:
|
|
539
|
+
* github/codeql-action/upload-sarif@v3
|
|
540
|
+
*/
|
|
541
|
+
/**
|
|
542
|
+
* SARIF output for GitHub Code Scanning.
|
|
543
|
+
*
|
|
544
|
+
* Delegates to the shared serializer in `cli/core/output/sarif.js`; this used
|
|
545
|
+
* to be a private copy. That copy also disagreed with itself — it gave a `low` finding
|
|
546
|
+
* rule-level `note` but result-level `warning` — and hardcoded the SARIF spec version
|
|
547
|
+
* `2.1.0` as the tool driver version.
|
|
548
|
+
*
|
|
549
|
+
* `allResults` arrives as `[{ file, findings }]`, so findings are flattened with their
|
|
550
|
+
* file attached before serializing. `--sarif` writes to stdout; redirect it.
|
|
551
|
+
*/
|
|
552
|
+
function renderSARIF(allResults, rootPath) {
|
|
553
|
+
return renderFindingsSARIF(allResults, { rootPath });
|
|
554
|
+
}
|