atris 3.28.0 → 3.29.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/AGENTS.md +0 -2
- package/commands/security-review.js +256 -28
- package/lib/security-scan.js +282 -30
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -136,12 +136,10 @@ Load these first:
|
|
|
136
136
|
- `atris/now.md`
|
|
137
137
|
- `atris/brain/STATUS.md`
|
|
138
138
|
- `atris/brain/self_improvement_ledger.md`
|
|
139
|
-
- `atris/wiki/concepts/agent-activation-contract.md`
|
|
140
139
|
- `atris/skills/atris/SKILL.md`
|
|
141
140
|
- `atris/PERSONA.md`
|
|
142
141
|
- `atris/MAP.md`
|
|
143
142
|
- `atris/TODO.md`
|
|
144
|
-
- `atris/wiki/index.md`
|
|
145
143
|
|
|
146
144
|
First-message rule: lead with the move before writing to the operator.
|
|
147
145
|
Purpose: optimize for decision-speed; lead with the move, then use descriptions only when they help the operator act.
|
|
@@ -12,16 +12,105 @@
|
|
|
12
12
|
// atris security-review --staged scan staged changes (pre-commit gate)
|
|
13
13
|
// atris security-review --json machine output for CI / the loop
|
|
14
14
|
// atris security-review --strict also fail on MEDIUM (PII/paths)
|
|
15
|
+
// atris security-review --deep model-review prompt + evidence
|
|
16
|
+
// atris security-review --update-baseline accept current findings
|
|
15
17
|
// atris security-review hook install a pre-commit gate
|
|
16
18
|
//
|
|
17
|
-
// Exit code: 0 = clean, 1 = findings at/over the fail threshold, 2 = bad usage.
|
|
19
|
+
// Exit code: 0 = clean, 1 = active findings at/over the fail threshold, 2 = bad usage.
|
|
18
20
|
|
|
19
21
|
const fs = require('fs');
|
|
20
22
|
const path = require('path');
|
|
21
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
runScan,
|
|
25
|
+
RULES,
|
|
26
|
+
DEFAULT_BASELINE,
|
|
27
|
+
SEVERITIES,
|
|
28
|
+
SEVERITY_RANK,
|
|
29
|
+
loadBaseline,
|
|
30
|
+
writeBaseline,
|
|
31
|
+
applyBaseline,
|
|
32
|
+
shouldFail,
|
|
33
|
+
scoreFindings,
|
|
34
|
+
} = require('../lib/security-scan');
|
|
22
35
|
|
|
23
|
-
const ICON = { high: '✗', medium: '!', low: '·', privacy: '✗', secret: '✗', pii: '!' };
|
|
24
|
-
const SEV_ORDER = {
|
|
36
|
+
const ICON = { critical: '✗', high: '✗', medium: '!', low: '·', privacy: '✗', secret: '✗', pii: '!' };
|
|
37
|
+
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
38
|
+
|
|
39
|
+
function parseArgs(argv) {
|
|
40
|
+
const opts = {
|
|
41
|
+
json: false,
|
|
42
|
+
quiet: false,
|
|
43
|
+
strict: false,
|
|
44
|
+
staged: false,
|
|
45
|
+
updateBaseline: false,
|
|
46
|
+
noBaseline: false,
|
|
47
|
+
deep: false,
|
|
48
|
+
md: false,
|
|
49
|
+
baseline: DEFAULT_BASELINE,
|
|
50
|
+
paths: [],
|
|
51
|
+
};
|
|
52
|
+
for (let i = 0; i < argv.length; i++) {
|
|
53
|
+
const arg = argv[i];
|
|
54
|
+
if (arg === 'scan') continue;
|
|
55
|
+
if (arg === '--json') opts.json = true;
|
|
56
|
+
else if (arg === '--quiet') opts.quiet = true;
|
|
57
|
+
else if (arg === '--strict') opts.strict = true;
|
|
58
|
+
else if (arg === '--staged') opts.staged = true;
|
|
59
|
+
else if (arg === '--update-baseline') opts.updateBaseline = true;
|
|
60
|
+
else if (arg === '--no-baseline') opts.noBaseline = true;
|
|
61
|
+
else if (arg === '--deep') opts.deep = true;
|
|
62
|
+
else if (arg === '--md' || arg === '--markdown') opts.md = true;
|
|
63
|
+
else if (arg === '--baseline') {
|
|
64
|
+
if (!argv[i + 1] || argv[i + 1].startsWith('-')) throw new Error('--baseline requires a path');
|
|
65
|
+
opts.baseline = argv[++i];
|
|
66
|
+
} else if (arg.startsWith('--baseline=')) {
|
|
67
|
+
opts.baseline = arg.slice('--baseline='.length);
|
|
68
|
+
} else if (arg.startsWith('-')) {
|
|
69
|
+
throw new Error(`unknown flag: ${arg}`);
|
|
70
|
+
} else {
|
|
71
|
+
opts.paths.push(arg);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (opts.noBaseline && opts.updateBaseline) throw new Error('--update-baseline cannot be combined with --no-baseline');
|
|
75
|
+
return opts;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sortFindings(findings) {
|
|
79
|
+
findings.sort((a, b) => (SEV_ORDER[a.sev] - SEV_ORDER[b.sev]) || a.file.localeCompare(b.file) || (a.line - b.line));
|
|
80
|
+
return findings;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function failThreshold(strict) {
|
|
84
|
+
return strict ? 'medium' : 'high';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function failingCount(findings, threshold) {
|
|
88
|
+
return findings.filter((f) => SEVERITY_RANK[f.sev] >= SEVERITY_RANK[threshold]).length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function applyBaselineOptions(root, rawFindings, opts) {
|
|
92
|
+
if (opts.noBaseline) {
|
|
93
|
+
const active = applyBaseline(rawFindings, []);
|
|
94
|
+
return { ...active, baseline: { enabled: false, path: null, updated: false } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let baseline = loadBaseline(root, opts.baseline);
|
|
98
|
+
let updated = false;
|
|
99
|
+
if (opts.updateBaseline) {
|
|
100
|
+
baseline = writeBaseline(root, rawFindings, opts.baseline);
|
|
101
|
+
updated = true;
|
|
102
|
+
}
|
|
103
|
+
const applied = applyBaseline(rawFindings, baseline.fingerprints);
|
|
104
|
+
return {
|
|
105
|
+
...applied,
|
|
106
|
+
baseline: {
|
|
107
|
+
enabled: true,
|
|
108
|
+
path: path.relative(root, baseline.file) || opts.baseline,
|
|
109
|
+
updated,
|
|
110
|
+
fingerprints: baseline.fingerprints.length,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
25
114
|
|
|
26
115
|
function securityReviewCommand(argv = []) {
|
|
27
116
|
const sub = argv[0];
|
|
@@ -29,42 +118,72 @@ function securityReviewCommand(argv = []) {
|
|
|
29
118
|
if (sub === 'rules') return printRules();
|
|
30
119
|
if (sub === 'hook' || sub === 'install-hook') return installHook();
|
|
31
120
|
|
|
32
|
-
const json = argv.includes('--json');
|
|
33
|
-
const quiet = argv.includes('--quiet');
|
|
34
|
-
const strict = argv.includes('--strict');
|
|
35
|
-
const staged = argv.includes('--staged');
|
|
36
|
-
const paths = argv.filter((a) => !a.startsWith('-') && a !== 'scan');
|
|
37
121
|
const root = process.cwd();
|
|
122
|
+
let opts;
|
|
123
|
+
try {
|
|
124
|
+
opts = parseArgs(argv);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.error(`security-review: ${e.message}`);
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
38
129
|
|
|
130
|
+
let raw;
|
|
39
131
|
let result;
|
|
40
132
|
try {
|
|
41
|
-
|
|
133
|
+
raw = runScan({ root, paths: opts.paths, staged: opts.staged });
|
|
134
|
+
result = applyBaselineOptions(root, raw.findings, opts);
|
|
42
135
|
} catch (e) {
|
|
43
136
|
console.error(`security-review: ${e.message}`);
|
|
44
137
|
return 2;
|
|
45
138
|
}
|
|
46
139
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
140
|
+
const findings = sortFindings(result.findings);
|
|
141
|
+
const allFindings = sortFindings(result.allFindings);
|
|
142
|
+
const counts = result.counts;
|
|
143
|
+
const threshold = failThreshold(opts.strict);
|
|
144
|
+
const failing = failingCount(findings, threshold);
|
|
145
|
+
const ok = !shouldFail(findings, threshold);
|
|
51
146
|
|
|
52
|
-
if (json) {
|
|
147
|
+
if (opts.json) {
|
|
53
148
|
console.log(JSON.stringify({
|
|
54
|
-
ok
|
|
55
|
-
scanned,
|
|
149
|
+
ok,
|
|
150
|
+
scanned: raw.scanned,
|
|
56
151
|
counts,
|
|
57
|
-
|
|
152
|
+
counts_all: result.countsAll,
|
|
153
|
+
suppressed: result.suppressed,
|
|
154
|
+
baseline: result.baseline,
|
|
155
|
+
score: scoreFindings(findings),
|
|
156
|
+
score_all: scoreFindings(allFindings),
|
|
157
|
+
fail_threshold: threshold,
|
|
58
158
|
findings,
|
|
159
|
+
deep_review: opts.deep ? deepReviewPayload({ findings, counts, scanned: raw.scanned, suppressed: result.suppressed }) : undefined,
|
|
59
160
|
generated_for: 'soc2-evidence',
|
|
60
161
|
}, null, 2));
|
|
61
|
-
return
|
|
162
|
+
return ok ? 0 : 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (opts.md) {
|
|
166
|
+
console.log(renderMarkdownReport({
|
|
167
|
+
findings, allFindings, counts, countsAll: result.countsAll, scanned: raw.scanned,
|
|
168
|
+
threshold, failing, baseline: result.baseline, suppressed: result.suppressed,
|
|
169
|
+
includeDeep: opts.deep,
|
|
170
|
+
}));
|
|
171
|
+
return ok ? 0 : 1;
|
|
62
172
|
}
|
|
63
173
|
|
|
64
|
-
if (
|
|
174
|
+
if (opts.deep) {
|
|
175
|
+
console.log(renderDeepReview({
|
|
176
|
+
findings, counts, scanned: raw.scanned, threshold, suppressed: result.suppressed, baseline: result.baseline,
|
|
177
|
+
}));
|
|
178
|
+
return ok ? 0 : 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!opts.quiet) {
|
|
65
182
|
console.log('\n ◉ atris security review');
|
|
66
183
|
if (!findings.length) {
|
|
67
|
-
console.log(`\n ✓ clean — no secrets, PII, or sensitive files in ${scanned} tracked file${scanned === 1 ? '' : 's'}
|
|
184
|
+
console.log(`\n ✓ clean — no active secrets, PII, or sensitive files in ${raw.scanned} tracked file${raw.scanned === 1 ? '' : 's'}`);
|
|
185
|
+
if (result.suppressed) console.log(` ${result.suppressed} accepted finding${result.suppressed === 1 ? '' : 's'} suppressed by ${result.baseline.path}`);
|
|
186
|
+
console.log('');
|
|
68
187
|
return 0;
|
|
69
188
|
}
|
|
70
189
|
console.log('');
|
|
@@ -74,23 +193,125 @@ function securityReviewCommand(argv = []) {
|
|
|
74
193
|
console.log(` ${ICON[f.sev] || '·'} ${f.sev.toUpperCase().padEnd(6)} ${loc} ${f.rule.padEnd(22)} ${f.why}`);
|
|
75
194
|
}
|
|
76
195
|
}
|
|
77
|
-
console.log(`\n ${counts.high || 0} high · ${counts.medium || 0} medium · ${counts.low || 0} low across ${scanned} file${scanned === 1 ? '' : 's'}`);
|
|
196
|
+
console.log(`\n ${counts.critical || 0} critical · ${counts.high || 0} high · ${counts.medium || 0} medium · ${counts.low || 0} low across ${raw.scanned} file${raw.scanned === 1 ? '' : 's'}`);
|
|
197
|
+
if (result.suppressed) console.log(` ${result.suppressed} accepted finding${result.suppressed === 1 ? '' : 's'} suppressed by ${result.baseline.path}`);
|
|
198
|
+
if (result.baseline.updated) console.log(` baseline updated: ${result.baseline.path} (${result.baseline.fingerprints} fingerprint${result.baseline.fingerprints === 1 ? '' : 's'})`);
|
|
78
199
|
if (failing) {
|
|
79
|
-
console.log(` ${failing} finding${failing === 1 ? '' : 's'} at/over the ${
|
|
200
|
+
console.log(` ${failing} finding${failing === 1 ? '' : 's'} at/over the ${threshold.toUpperCase()} threshold · exit 1`);
|
|
80
201
|
console.log(' fix or suppress (trailing `atris-allow-secret`), then re-run.\n');
|
|
81
202
|
} else {
|
|
82
|
-
console.log(` no findings at the ${
|
|
203
|
+
console.log(` no findings at the ${threshold.toUpperCase()} threshold · exit 0\n`);
|
|
83
204
|
}
|
|
84
|
-
return
|
|
205
|
+
return ok ? 0 : 1;
|
|
85
206
|
}
|
|
86
207
|
|
|
87
208
|
function printRules() {
|
|
88
209
|
console.log('\n atris security-review — deterministic rules:\n');
|
|
89
|
-
for (const
|
|
210
|
+
for (const sev of SEVERITIES) {
|
|
211
|
+
for (const r of RULES.filter((rule) => rule.sev === sev)) {
|
|
212
|
+
console.log(` ${r.sev.toUpperCase().padEnd(8)} ${r.cat.padEnd(8)} ${r.id.padEnd(22)} ${r.why}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
90
215
|
console.log(`\n ${RULES.length} rules + tracked-sensitive-file check. Suppress a line with a trailing \`atris-allow-secret\`.\n`);
|
|
91
216
|
return 0;
|
|
92
217
|
}
|
|
93
218
|
|
|
219
|
+
const DEEP_DIMENSIONS = [
|
|
220
|
+
['secrets & keys', 'Look for real keys, tokens, private keys, credential files, and places where logs or docs could expose them.'],
|
|
221
|
+
['who-can-do-what', 'Check whether users, agents, missions, and local commands can only do the actions they should be allowed to do.'],
|
|
222
|
+
['untrusted input to code/shell/path/web requests', 'Trace user-controlled values into eval, new Function, child_process, file paths, URLs, redirects, and fetch/request calls.'],
|
|
223
|
+
['data exposure', 'Check logs, responses, reports, task state, prompts, and errors for personal data, secrets, or private workspace paths.'],
|
|
224
|
+
['dependencies', 'Check package and script changes for risky install hooks, unpinned tools, vendored code, or unexpected network execution.'],
|
|
225
|
+
['crypto & randomness', 'Check token generation, signing, hashing, random IDs, and compare logic for weak or predictable behavior.'],
|
|
226
|
+
['config & defaults', 'Check debug flags, CORS, open ports, default credentials, unsafe local paths, and flags that bypass safety gates.'],
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
function evidenceLines(findings, limit = 80) {
|
|
230
|
+
if (!findings.length) return ['- No deterministic findings after baseline suppression.'];
|
|
231
|
+
return findings.slice(0, limit).map((f) => {
|
|
232
|
+
const loc = `${f.file}${f.line ? ':' + f.line : ''}`;
|
|
233
|
+
return `- ${f.sev.toUpperCase()} ${loc} ${f.rule}: ${f.why}`;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function deepReviewPayload({ findings, counts, scanned, suppressed }) {
|
|
238
|
+
return {
|
|
239
|
+
instruction: 'Answer each dimension with PASS or CONCERN. If CONCERN, cite file:line and the concrete risk. Do not speculate beyond the code.',
|
|
240
|
+
dimensions: DEEP_DIMENSIONS.map(([name, check]) => ({ name, check })),
|
|
241
|
+
evidence: {
|
|
242
|
+
scanned,
|
|
243
|
+
counts,
|
|
244
|
+
suppressed,
|
|
245
|
+
findings,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function renderDeepReview({ findings, counts, scanned, threshold, suppressed, baseline }) {
|
|
251
|
+
const lines = [
|
|
252
|
+
'# Atris Deep Security Review',
|
|
253
|
+
'',
|
|
254
|
+
'Use this as a second-pass review prompt for a capable model.',
|
|
255
|
+
'Answer every dimension with PASS or CONCERN, then give the file:line evidence.',
|
|
256
|
+
'Do not invent issues. If the code does not prove the issue, mark PASS or say what proof is missing.',
|
|
257
|
+
'',
|
|
258
|
+
'Deterministic evidence:',
|
|
259
|
+
`- scanned files: ${scanned}`,
|
|
260
|
+
`- active counts: ${counts.critical || 0} critical, ${counts.high || 0} high, ${counts.medium || 0} medium, ${counts.low || 0} low`,
|
|
261
|
+
`- fail threshold: ${threshold}`,
|
|
262
|
+
`- suppressed by baseline: ${suppressed}`,
|
|
263
|
+
`- baseline: ${baseline.enabled ? baseline.path : 'off'}`,
|
|
264
|
+
'',
|
|
265
|
+
'Findings:',
|
|
266
|
+
...evidenceLines(findings),
|
|
267
|
+
'',
|
|
268
|
+
'Review dimensions:',
|
|
269
|
+
];
|
|
270
|
+
for (const [name, check] of DEEP_DIMENSIONS) {
|
|
271
|
+
lines.push('', `## ${name}`, check, 'Answer: PASS or CONCERN', 'Specific evidence:');
|
|
272
|
+
}
|
|
273
|
+
return `${lines.join('\n')}\n`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderMarkdownReport({ findings, allFindings, counts, countsAll, scanned, threshold, failing, baseline, suppressed, includeDeep }) {
|
|
277
|
+
const status = failing ? 'FAIL' : 'PASS';
|
|
278
|
+
const lines = [
|
|
279
|
+
'# Atris Security Review',
|
|
280
|
+
'',
|
|
281
|
+
`Status: ${status}`,
|
|
282
|
+
`Fail threshold: ${threshold}`,
|
|
283
|
+
`Scanned files: ${scanned}`,
|
|
284
|
+
`Baseline: ${baseline.enabled ? baseline.path : 'off'}`,
|
|
285
|
+
`Suppressed: ${suppressed}`,
|
|
286
|
+
'',
|
|
287
|
+
'## Active Counts',
|
|
288
|
+
'',
|
|
289
|
+
`- critical: ${counts.critical || 0}`,
|
|
290
|
+
`- high: ${counts.high || 0}`,
|
|
291
|
+
`- medium: ${counts.medium || 0}`,
|
|
292
|
+
`- low: ${counts.low || 0}`,
|
|
293
|
+
`- score: ${scoreFindings(findings)}`,
|
|
294
|
+
'',
|
|
295
|
+
'## All Counts',
|
|
296
|
+
'',
|
|
297
|
+
`- critical: ${countsAll.critical || 0}`,
|
|
298
|
+
`- high: ${countsAll.high || 0}`,
|
|
299
|
+
`- medium: ${countsAll.medium || 0}`,
|
|
300
|
+
`- low: ${countsAll.low || 0}`,
|
|
301
|
+
`- score: ${scoreFindings(allFindings)}`,
|
|
302
|
+
'',
|
|
303
|
+
'## Findings',
|
|
304
|
+
'',
|
|
305
|
+
...evidenceLines(findings),
|
|
306
|
+
];
|
|
307
|
+
if (includeDeep) {
|
|
308
|
+
lines.push('', '## Deep Review Prompt', '', renderDeepReview({
|
|
309
|
+
findings, counts, scanned, threshold, suppressed, baseline,
|
|
310
|
+
}).trim());
|
|
311
|
+
}
|
|
312
|
+
return `${lines.join('\n')}\n`;
|
|
313
|
+
}
|
|
314
|
+
|
|
94
315
|
function installHook() {
|
|
95
316
|
const root = process.cwd();
|
|
96
317
|
const hookDir = path.join(root, '.git', 'hooks');
|
|
@@ -120,11 +341,18 @@ function printHelp() {
|
|
|
120
341
|
atris security-review --staged scan staged changes (pre-commit gate)
|
|
121
342
|
atris security-review --strict also fail on MEDIUM (PII / personal paths)
|
|
122
343
|
atris security-review --json machine output / SOC 2 evidence artifact
|
|
344
|
+
atris security-review --md markdown evidence report
|
|
345
|
+
atris security-review --deep prompt a stronger model with framework + evidence
|
|
346
|
+
atris security-review --update-baseline
|
|
347
|
+
accept current findings in .security-review.baseline.json
|
|
348
|
+
atris security-review --no-baseline
|
|
349
|
+
ignore .security-review.baseline.json
|
|
123
350
|
atris security-review rules list the active detectors
|
|
124
351
|
atris security-review hook install a pre-commit gate
|
|
125
352
|
|
|
126
|
-
Scans for
|
|
127
|
-
files. exit 0 = clean, 1 = found. Wire into
|
|
353
|
+
Scans for real-looking secrets, API keys, personal data, tracked sensitive
|
|
354
|
+
files, and code-exec review evidence. exit 0 = clean, 1 = found. Wire into
|
|
355
|
+
the autopilot/mission gate and CI.
|
|
128
356
|
`);
|
|
129
357
|
return 0;
|
|
130
358
|
}
|
package/lib/security-scan.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
|
+
const crypto = require('crypto');
|
|
14
15
|
const { execFileSync } = require('child_process');
|
|
15
16
|
|
|
16
17
|
const SCAN_EXTS = new Set([
|
|
@@ -18,27 +19,51 @@ const SCAN_EXTS = new Set([
|
|
|
18
19
|
'.yml', '.yaml', '.env', '.sh', '.bash', '.zsh', '.py', '.rb', '.go', '.java',
|
|
19
20
|
'.php', '.toml', '.ini', '.cfg', '.conf', '.html', '.vue', '.svelte', '.patch',
|
|
20
21
|
]);
|
|
21
|
-
const SKIP_DIRS = new Set([
|
|
22
|
+
const SKIP_DIRS = new Set([
|
|
23
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'out', 'vendor',
|
|
24
|
+
'.cache', '__pycache__', '_archive', 'fixtures', 'fixture', 'snapshots',
|
|
25
|
+
'snapshot', '__snapshots__', 'testdata',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const DEFAULT_BASELINE = '.security-review.baseline.json';
|
|
22
29
|
|
|
23
30
|
// Lines that opt out, and placeholder noise we never flag.
|
|
24
|
-
const ALLOW_MARKER = /atris-allow-secret|atris-security-ignore/i;
|
|
25
|
-
const PLACEHOLDER = /\b(?:example|placeholder|your[_-]?|my[_-]?key|xxx+|redacted|dummy|sample|changeme|test[_-]?key|fake|
|
|
31
|
+
const ALLOW_MARKER = /atris-allow-secret|atris-security-ignore|#\s*nosec/i;
|
|
32
|
+
const PLACEHOLDER = /\b(?:example|placeholder|your[_-]?|my[_-]?key|xxx+|redacted|dummy|sample|changeme|test[_-]?key|fake|fixture|benchmark|mock|not[-_ ]?real|should[-_ ]?not[-_ ]?leak|should[-_ ]?redact|no\s+matches|process\.env|getenv|os\.environ|import\.meta\.env)\b|<[a-z0-9_-]+>/i;
|
|
33
|
+
const PLACEHOLDER_VALUE_FRAGMENTS = [
|
|
34
|
+
'should-not-leak', 'should_redact', 'fixture-token', 'xoxb-secret',
|
|
35
|
+
'xoxb-should-not-leak', 'xoxb-fixture-token', 'your_api_key',
|
|
36
|
+
'your-api-key', 'your_key_here', 'your-token', 'secret_yyy', 'secret-yyy',
|
|
37
|
+
'paid-audit-api-key', 'prod-api-key', 'free-plan-key', 'test1234567890',
|
|
38
|
+
'abc123def456', 'abcdefghijklmnopqrstuvwxyz', 'abcdef1234567890',
|
|
39
|
+
'sk-test', 'sk-abc', 'dev-secret', 'test-secret', 'my-super-secret',
|
|
40
|
+
'same-secret', 'correct_secret', 'oauth_access_token', 'some_secret',
|
|
41
|
+
'private-key-pem', 'shared-secret', 'oauth-access', 'ext-oauth',
|
|
42
|
+
'environment-secret', 'immutable-secret', 'member-secret', 'atris_sk_test',
|
|
43
|
+
];
|
|
44
|
+
const COMMON_PLACEHOLDER_VALUES = new Set([
|
|
45
|
+
'password', 'passw0rd', 'hunter2', 'hunter2hunter2', 'secret', 'secret123',
|
|
46
|
+
'test', 'testpass123', 'test_token_123', 'dev-user', 'dev-secret-do-not-use-in-prod',
|
|
47
|
+
'sovereign-local', 'my-hmac-key', 'env-token', 'audit-key',
|
|
48
|
+
]);
|
|
26
49
|
|
|
27
|
-
// High-precision secret patterns.
|
|
50
|
+
// High-precision secret patterns. Known-provider keys become critical only when
|
|
51
|
+
// their value quality check says the variable part looks real.
|
|
28
52
|
const SECRET_RULES = [
|
|
29
|
-
{ id: 'private-key', sev: '
|
|
30
|
-
{ id: 'aws-access-key-id', sev: '
|
|
31
|
-
{ id: 'github-token', sev: '
|
|
32
|
-
{ id: 'slack-token', sev: '
|
|
33
|
-
{ id: 'slack-webhook', sev: '
|
|
34
|
-
{ id: 'openai-key', sev: '
|
|
35
|
-
{ id: 'anthropic-key', sev: '
|
|
36
|
-
{ id: 'google-api-key', sev: '
|
|
37
|
-
{ id: 'stripe-key', sev: '
|
|
38
|
-
{ id: 'npm-token', sev: '
|
|
39
|
-
{ id: '
|
|
40
|
-
{ id: '
|
|
41
|
-
{ id: '
|
|
53
|
+
{ id: 'private-key', sev: 'critical', cat: 'secret', re: /(-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA |ENCRYPTED )?PRIVATE KEY-----)/, known: true, noEntropy: true, why: 'private key committed in source' },
|
|
54
|
+
{ id: 'aws-access-key-id', sev: 'critical', cat: 'secret', re: /\b((?:AKIA|ASIA)[0-9A-Z]{16})\b/, known: true, why: 'real-looking AWS access key id' },
|
|
55
|
+
{ id: 'github-token', sev: 'critical', cat: 'secret', re: /\b(gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/, known: true, why: 'real-looking GitHub token' },
|
|
56
|
+
{ id: 'slack-token', sev: 'critical', cat: 'secret', re: /\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/, known: true, why: 'real-looking Slack token' },
|
|
57
|
+
{ id: 'slack-webhook', sev: 'critical', cat: 'secret', re: /(https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]{20,})/, known: true, why: 'real-looking Slack incoming webhook url' },
|
|
58
|
+
{ id: 'openai-key', sev: 'critical', cat: 'secret', re: /\b(sk-proj-[A-Za-z0-9_-]{32,}|sk-[A-Za-z0-9]{32,})\b/, known: true, why: 'real-looking OpenAI-style API key' },
|
|
59
|
+
{ id: 'anthropic-key', sev: 'critical', cat: 'secret', re: /\b(sk-ant-[A-Za-z0-9_-]{20,})\b/, known: true, why: 'real-looking Anthropic API key' },
|
|
60
|
+
{ id: 'google-api-key', sev: 'critical', cat: 'secret', re: /\b(AIza[0-9A-Za-z_-]{35})\b/, known: true, why: 'real-looking Google API key' },
|
|
61
|
+
{ id: 'stripe-key', sev: 'critical', cat: 'secret', re: /\b([rs]k_live_[A-Za-z0-9]{20,})\b/, known: true, why: 'real-looking Stripe live key' },
|
|
62
|
+
{ id: 'npm-token', sev: 'critical', cat: 'secret', re: /\b(npm_[A-Za-z0-9]{36})\b/, known: true, why: 'real-looking npm access token' },
|
|
63
|
+
{ id: 'sendgrid-key', sev: 'critical', cat: 'secret', re: /\b(SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43})\b/, known: true, why: 'real-looking SendGrid API key' },
|
|
64
|
+
{ id: 'jwt', sev: 'medium', cat: 'secret', re: /\b(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,})\b/, why: 'JWT-like token in source' },
|
|
65
|
+
{ id: 'bearer-token', sev: 'medium', cat: 'secret', re: /\bBearer\s+([A-Za-z0-9._~+/=-]{24,})/, why: 'hardcoded Bearer token' },
|
|
66
|
+
{ id: 'assigned-secret', sev: 'high', cat: 'secret', re: /\b(?:password|passwd|pwd|secret|api[_-]?key|apikey|apiKey|access[_-]?token|accessToken|auth[_-]?token|authToken|client[_-]?secret|clientSecret|private[_-]?key|privateKey)\b\s*[:=]\s*(['"`])([^'"`\s]{8,})\1/i, valueGroup: 2, why: 'real-looking hardcoded credential value' },
|
|
42
67
|
];
|
|
43
68
|
|
|
44
69
|
// Personal data. Home-path is the recurring leak (a username + local layout).
|
|
@@ -55,8 +80,8 @@ const PII_RULES = [
|
|
|
55
80
|
// command-injection class (MEDIUM — common and sometimes safe, so it asks for a
|
|
56
81
|
// human look rather than hard-failing the gate).
|
|
57
82
|
const CODE_RULES = [
|
|
58
|
-
{ id: 'eval-call', sev: '
|
|
59
|
-
{ id: 'new-function', sev: '
|
|
83
|
+
{ id: 'eval-call', sev: 'medium', cat: 'code', re: /(?<![.\w])eval\((?!\s*\))/, why: 'eval() can run untrusted code' },
|
|
84
|
+
{ id: 'new-function', sev: 'medium', cat: 'code', re: /\bnew\s+Function\(/, why: 'new Function() can run untrusted code' },
|
|
60
85
|
{ id: 'shell-exec-interpolation', sev: 'medium', cat: 'code', re: /\b(?:exec|execSync)\s*\(\s*[`'"][^`'"]*(?:\$\{|"\s*\+|'\s*\+)/, why: 'shell exec with interpolated input (command-injection risk; prefer execFile/spawn with an args array)' },
|
|
61
86
|
{ id: 'child-process-shell-true', sev: 'medium', cat: 'code', re: /\bshell\s*:\s*true\b/, why: 'child_process shell:true enables shell interpretation of args' },
|
|
62
87
|
];
|
|
@@ -65,20 +90,172 @@ const CODE_RULES = [
|
|
|
65
90
|
const EMAIL_SAFE = /\b(?:noreply|no-reply|support|hello|info|contact|team|hi|admin|press)@/i;
|
|
66
91
|
|
|
67
92
|
const RULES = [...SECRET_RULES, ...PII_RULES, ...CODE_RULES];
|
|
93
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low'];
|
|
94
|
+
const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
68
95
|
|
|
69
96
|
// Filenames that should never be committed (checked against the tracked file list).
|
|
70
97
|
const SENSITIVE_FILE_RE = /(?:^|\/)(?:\.env(?:\.[\w.-]+)?|id_rsa|id_dsa|id_ed25519|.*\.pem|.*\.pfx|.*\.p12|.*\.keystore|credentials\.json|\.npmrc|\.pypirc|\.netrc|secrets?\.(?:json|ya?ml|env))$/i;
|
|
71
98
|
const SENSITIVE_FILE_ALLOW = /\.env\.example$|\.env\.sample$|\.env\.template$/i;
|
|
72
99
|
|
|
100
|
+
function normalizeSnippet(snippet) {
|
|
101
|
+
return String(snippet || '').replace(/\s+/g, ' ').trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sha256(value) {
|
|
105
|
+
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function shannonEntropy(value) {
|
|
109
|
+
const s = String(value || '');
|
|
110
|
+
if (!s) return 0;
|
|
111
|
+
const freq = new Map();
|
|
112
|
+
for (const ch of s) freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
113
|
+
let entropy = 0;
|
|
114
|
+
for (const count of freq.values()) {
|
|
115
|
+
const p = count / s.length;
|
|
116
|
+
entropy -= p * Math.log2(p);
|
|
117
|
+
}
|
|
118
|
+
return entropy;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function charClassCount(value) {
|
|
122
|
+
const s = String(value || '');
|
|
123
|
+
let count = 0;
|
|
124
|
+
if (/[a-z]/.test(s)) count++;
|
|
125
|
+
if (/[A-Z]/.test(s)) count++;
|
|
126
|
+
if (/[0-9]/.test(s)) count++;
|
|
127
|
+
if (/[^A-Za-z0-9]/.test(s)) count++;
|
|
128
|
+
return count;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function uniqueChars(value) {
|
|
132
|
+
return new Set(String(value || '').split('')).size;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function hasRepeatedChunk(value) {
|
|
136
|
+
const s = String(value || '');
|
|
137
|
+
if (/^(.)\1{7,}$/.test(s)) return true;
|
|
138
|
+
for (let size = 2; size <= Math.min(24, Math.floor(s.length / 2)); size++) {
|
|
139
|
+
if (s.length % size !== 0) continue;
|
|
140
|
+
const chunk = s.slice(0, size);
|
|
141
|
+
if (chunk.repeat(s.length / size) === s) return true;
|
|
142
|
+
}
|
|
143
|
+
return /(.{8,})\1/.test(s);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function hasSequentialRun(value) {
|
|
147
|
+
const s = String(value || '').toLowerCase();
|
|
148
|
+
const sequences = [
|
|
149
|
+
'abcdefghijklmnopqrstuvwxyz',
|
|
150
|
+
'zyxwvutsrqponmlkjihgfedcba',
|
|
151
|
+
'0123456789',
|
|
152
|
+
'9876543210',
|
|
153
|
+
'qwertyuiopasdfghjklzxcvbnm',
|
|
154
|
+
];
|
|
155
|
+
for (const seq of sequences) {
|
|
156
|
+
for (let i = 0; i <= seq.length - 8; i++) {
|
|
157
|
+
if (s.includes(seq.slice(i, i + 8))) return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function stripKnownPrefix(value) {
|
|
164
|
+
return String(value || '')
|
|
165
|
+
.replace(/^(?:AKIA|ASIA)/, '')
|
|
166
|
+
.replace(/^github_pat_/, '')
|
|
167
|
+
.replace(/^gh[pousr]_/, '')
|
|
168
|
+
.replace(/^xox[baprs]-/, '')
|
|
169
|
+
.replace(/^https:\/\/hooks\.slack\.com\/services\//, '')
|
|
170
|
+
.replace(/^sk-proj-/, '')
|
|
171
|
+
.replace(/^sk-ant-/, '')
|
|
172
|
+
.replace(/^sk-/, '')
|
|
173
|
+
.replace(/^AIza/, '')
|
|
174
|
+
.replace(/^[rs]k_live_/, '')
|
|
175
|
+
.replace(/^npm_/, '')
|
|
176
|
+
.replace(/^SG\./, '');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isPlaceholderSecretValue(value, line = '') {
|
|
180
|
+
const raw = String(value || '');
|
|
181
|
+
const lowValue = raw.toLowerCase();
|
|
182
|
+
const lowLine = String(line || '').toLowerCase();
|
|
183
|
+
if (!raw) return true;
|
|
184
|
+
if (PLACEHOLDER.test(lowLine) || PLACEHOLDER.test(lowValue)) return true;
|
|
185
|
+
if (COMMON_PLACEHOLDER_VALUES.has(lowValue)) return true;
|
|
186
|
+
if (/^<[^>]+>$/.test(raw) || /^\$\{?[A-Z0-9_]+\}?$/.test(raw)) return true;
|
|
187
|
+
if (/^(?:x+|\*+|\.+|-+|_+)$/.test(raw)) return true;
|
|
188
|
+
if (PLACEHOLDER_VALUE_FRAGMENTS.some((fragment) => lowValue.includes(fragment) || lowLine.includes(fragment))) return true;
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function secretQuality(value, { known = false, noEntropy = false } = {}, line = '') {
|
|
193
|
+
const raw = String(value || '').trim();
|
|
194
|
+
if (isPlaceholderSecretValue(raw, line)) return { ok: false, reason: 'placeholder' };
|
|
195
|
+
if (noEntropy) return { ok: true, entropy: null, reason: 'private-key-marker' };
|
|
196
|
+
|
|
197
|
+
const core = stripKnownPrefix(raw).replace(/[-_.:/]/g, '');
|
|
198
|
+
const entropy = shannonEntropy(core);
|
|
199
|
+
const minLength = known ? 16 : 18;
|
|
200
|
+
const minEntropy = known ? 3.0 : 3.35;
|
|
201
|
+
const minUnique = known ? 8 : 10;
|
|
202
|
+
|
|
203
|
+
if (core.length < minLength) return { ok: false, entropy, reason: 'too-short' };
|
|
204
|
+
if (uniqueChars(core) < minUnique) return { ok: false, entropy, reason: 'low-variety' };
|
|
205
|
+
if (charClassCount(core) < 2) return { ok: false, entropy, reason: 'single-charset' };
|
|
206
|
+
if (entropy < minEntropy) return { ok: false, entropy, reason: 'low-entropy' };
|
|
207
|
+
if (hasRepeatedChunk(core)) return { ok: false, entropy, reason: 'repeating' };
|
|
208
|
+
if (hasSequentialRun(core)) return { ok: false, entropy, reason: 'sequential' };
|
|
209
|
+
return { ok: true, entropy, reason: 'real-shaped' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function extractSecretValue(rule, match) {
|
|
213
|
+
if (!match) return '';
|
|
214
|
+
if (rule.valueGroup != null) return match[rule.valueGroup] || '';
|
|
215
|
+
return match[1] || match[0] || '';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function secretSnippet(rule, value) {
|
|
219
|
+
const safeValue = String(value || '');
|
|
220
|
+
if (rule.id === 'private-key') return 'private key header';
|
|
221
|
+
const prefix = safeValue.slice(0, Math.min(12, safeValue.length));
|
|
222
|
+
return `${prefix}${safeValue.length > prefix.length ? '...' : ''} sha256:${sha256(safeValue).slice(0, 12)}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function findingFingerprint(finding) {
|
|
226
|
+
const raw = [
|
|
227
|
+
finding.rule,
|
|
228
|
+
finding.cat,
|
|
229
|
+
finding.file || '',
|
|
230
|
+
normalizeSnippet(finding.snippet),
|
|
231
|
+
].join('|');
|
|
232
|
+
return sha256(raw);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function withFingerprint(finding) {
|
|
236
|
+
return { ...finding, fingerprint: findingFingerprint(finding), suppressed: false };
|
|
237
|
+
}
|
|
238
|
+
|
|
73
239
|
function scanLine(line, rules = RULES) {
|
|
74
240
|
if (ALLOW_MARKER.test(line)) return [];
|
|
75
241
|
const findings = [];
|
|
76
|
-
const placeholder = PLACEHOLDER.test(line);
|
|
77
242
|
for (const rule of rules) {
|
|
78
243
|
const m = rule.re.exec(line);
|
|
79
244
|
if (!m) continue;
|
|
80
|
-
|
|
81
|
-
|
|
245
|
+
if (rule.cat === 'secret') {
|
|
246
|
+
const value = extractSecretValue(rule, m);
|
|
247
|
+
const quality = secretQuality(value, rule, line);
|
|
248
|
+
if (!quality.ok) continue;
|
|
249
|
+
findings.push({
|
|
250
|
+
rule: rule.id,
|
|
251
|
+
sev: rule.sev,
|
|
252
|
+
cat: rule.cat,
|
|
253
|
+
why: rule.why,
|
|
254
|
+
snippet: secretSnippet(rule, value),
|
|
255
|
+
entropy: quality.entropy == null ? null : Number(quality.entropy.toFixed(2)),
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
82
259
|
if (rule.id === 'email' && EMAIL_SAFE.test(line)) continue;
|
|
83
260
|
findings.push({ rule: rule.id, sev: rule.sev, cat: rule.cat, why: rule.why, snippet: m[0].trim().slice(0, 60) });
|
|
84
261
|
}
|
|
@@ -97,6 +274,7 @@ function scanText(text, rules = RULES) {
|
|
|
97
274
|
// Code-execution rules only make sense in actual code files — `eval(` written in
|
|
98
275
|
// a markdown doc is prose, not a vuln.
|
|
99
276
|
const CODE_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.java', '.php', '.sh', '.bash', '.zsh']);
|
|
277
|
+
const DOC_EXTS = new Set(['.md', '.mdx', '.txt', '.patch']);
|
|
100
278
|
|
|
101
279
|
function rulesForFile(file) {
|
|
102
280
|
return CODE_EXTS.has(path.extname(file)) ? RULES : RULES.filter((r) => r.cat !== 'code');
|
|
@@ -105,7 +283,13 @@ function rulesForFile(file) {
|
|
|
105
283
|
function scanFile(file, rules) {
|
|
106
284
|
let text;
|
|
107
285
|
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
108
|
-
|
|
286
|
+
const ext = path.extname(file);
|
|
287
|
+
return scanText(text, rules || rulesForFile(file)).map((f) => {
|
|
288
|
+
if (DOC_EXTS.has(ext) && f.rule === 'assigned-secret' && f.sev === 'high') {
|
|
289
|
+
return { ...f, sev: 'medium', why: 'credential-looking value in documentation; verify before publishing', file };
|
|
290
|
+
}
|
|
291
|
+
return { ...f, file };
|
|
292
|
+
});
|
|
109
293
|
}
|
|
110
294
|
|
|
111
295
|
// Repo-level hygiene: a sensitive file being tracked at all is a finding,
|
|
@@ -113,6 +297,7 @@ function scanFile(file, rules) {
|
|
|
113
297
|
function sensitiveFileFindings(relPaths) {
|
|
114
298
|
const out = [];
|
|
115
299
|
for (const p of relPaths) {
|
|
300
|
+
if (shouldSkipRelPath(p)) continue;
|
|
116
301
|
if (SENSITIVE_FILE_RE.test(p) && !SENSITIVE_FILE_ALLOW.test(p)) {
|
|
117
302
|
out.push({ file: p, line: 0, rule: 'tracked-sensitive-file', sev: 'high', cat: 'privacy', why: 'sensitive file is tracked in git (gitignore + remove it)', snippet: path.basename(p) });
|
|
118
303
|
}
|
|
@@ -149,18 +334,26 @@ function scannable(file) {
|
|
|
149
334
|
return SCAN_EXTS.has(ext) || path.basename(file).startsWith('.env');
|
|
150
335
|
}
|
|
151
336
|
|
|
337
|
+
function shouldSkipRelPath(relPath) {
|
|
338
|
+
const parts = String(relPath || '').split(/[\\/]+/).filter(Boolean);
|
|
339
|
+
return parts.some((part) => SKIP_DIRS.has(part));
|
|
340
|
+
}
|
|
341
|
+
|
|
152
342
|
// Resolve the set of files to scan + the relative-path list for hygiene checks.
|
|
153
343
|
function resolveTargets({ root = process.cwd(), paths = [], staged = false } = {}) {
|
|
154
344
|
if (paths.length) {
|
|
155
|
-
const files = paths.flatMap((p) => walk(path.resolve(root, p), []))
|
|
345
|
+
const files = paths.flatMap((p) => walk(path.resolve(root, p), []))
|
|
346
|
+
.filter(scannable)
|
|
347
|
+
.filter((f) => !shouldSkipRelPath(path.relative(root, f)));
|
|
156
348
|
return { files, relPaths: files.map((f) => path.relative(root, f)) };
|
|
157
349
|
}
|
|
158
350
|
const tracked = gitTrackedFiles(root, { staged });
|
|
159
351
|
if (tracked) {
|
|
160
|
-
const
|
|
161
|
-
|
|
352
|
+
const relPaths = tracked.filter((p) => !shouldSkipRelPath(p));
|
|
353
|
+
const files = relPaths.filter(scannable).map((p) => path.join(root, p)).filter((f) => fs.existsSync(f));
|
|
354
|
+
return { files, relPaths };
|
|
162
355
|
}
|
|
163
|
-
const files = walk(root, []).filter(scannable);
|
|
356
|
+
const files = walk(root, []).filter(scannable).filter((f) => !shouldSkipRelPath(path.relative(root, f)));
|
|
164
357
|
return { files, relPaths: files.map((f) => path.relative(root, f)) };
|
|
165
358
|
}
|
|
166
359
|
|
|
@@ -177,12 +370,71 @@ function runScan({ root = process.cwd(), paths = [], staged = false } = {}) {
|
|
|
177
370
|
for (const finding of scanFile(f)) findings.push({ ...finding, file: path.relative(root, finding.file) });
|
|
178
371
|
}
|
|
179
372
|
findings.push(...sensitiveFileFindings(relPaths.filter((p) => !SELF_FILES.has(p))));
|
|
180
|
-
const
|
|
373
|
+
const withIds = findings.map(withFingerprint);
|
|
374
|
+
const counts = summarizeFindings(withIds);
|
|
375
|
+
return { findings: withIds, counts, scanned: files.length };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function summarizeFindings(findings) {
|
|
379
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
181
380
|
for (const f of findings) counts[f.sev] = (counts[f.sev] || 0) + 1;
|
|
182
|
-
return
|
|
381
|
+
return counts;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function loadBaseline(root = process.cwd(), baselineFile = DEFAULT_BASELINE) {
|
|
385
|
+
const file = path.isAbsolute(baselineFile) ? baselineFile : path.join(root, baselineFile);
|
|
386
|
+
if (!fs.existsSync(file)) return { file, fingerprints: [] };
|
|
387
|
+
let raw;
|
|
388
|
+
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
389
|
+
catch (e) { throw new Error(`invalid baseline json: ${path.relative(root, file) || file} (${e.message})`); }
|
|
390
|
+
const values = Array.isArray(raw) ? raw : raw && raw.fingerprints;
|
|
391
|
+
if (!Array.isArray(values)) throw new Error('invalid baseline json: expected {"fingerprints":[...]}');
|
|
392
|
+
return { file, fingerprints: values.filter((v) => typeof v === 'string') };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function writeBaseline(root = process.cwd(), findings = [], baselineFile = DEFAULT_BASELINE) {
|
|
396
|
+
const file = path.isAbsolute(baselineFile) ? baselineFile : path.join(root, baselineFile);
|
|
397
|
+
const payload = {
|
|
398
|
+
generated_at: new Date().toISOString(),
|
|
399
|
+
fingerprints: [...new Set(findings.map((f) => f.fingerprint || findingFingerprint(f)))].sort(),
|
|
400
|
+
};
|
|
401
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2) + '\n');
|
|
402
|
+
return { file, fingerprints: payload.fingerprints };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function applyBaseline(findings, fingerprints = []) {
|
|
406
|
+
const accepted = new Set(fingerprints);
|
|
407
|
+
let suppressed = 0;
|
|
408
|
+
const all = findings.map((finding) => {
|
|
409
|
+
const fp = finding.fingerprint || findingFingerprint(finding);
|
|
410
|
+
const isSuppressed = accepted.has(fp);
|
|
411
|
+
if (isSuppressed) suppressed++;
|
|
412
|
+
return { ...finding, fingerprint: fp, suppressed: isSuppressed };
|
|
413
|
+
});
|
|
414
|
+
const active = all.filter((f) => !f.suppressed);
|
|
415
|
+
return {
|
|
416
|
+
findings: active,
|
|
417
|
+
allFindings: all,
|
|
418
|
+
suppressed,
|
|
419
|
+
counts: summarizeFindings(active),
|
|
420
|
+
countsAll: summarizeFindings(all),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function shouldFail(findings, failOn = 'high') {
|
|
425
|
+
const threshold = SEVERITY_RANK[failOn] == null ? SEVERITY_RANK.high : SEVERITY_RANK[failOn];
|
|
426
|
+
return findings.some((f) => SEVERITY_RANK[f.sev] >= threshold);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function scoreFindings(findings) {
|
|
430
|
+
const score = { low: 1, medium: 10, high: 50, critical: 100 };
|
|
431
|
+
return findings.reduce((sum, f) => sum + (score[f.sev] || 0), 0);
|
|
183
432
|
}
|
|
184
433
|
|
|
185
434
|
module.exports = {
|
|
186
435
|
scanLine, scanText, scanFile, sensitiveFileFindings, gitTrackedFiles,
|
|
187
|
-
resolveTargets, runScan, SECRET_RULES, PII_RULES, RULES,
|
|
436
|
+
resolveTargets, runScan, SECRET_RULES, PII_RULES, RULES, CODE_RULES,
|
|
437
|
+
DEFAULT_BASELINE, SEVERITIES, SEVERITY_RANK, normalizeSnippet,
|
|
438
|
+
shannonEntropy, secretQuality, findingFingerprint, summarizeFindings,
|
|
439
|
+
loadBaseline, writeBaseline, applyBaseline, shouldFail, scoreFindings,
|
|
188
440
|
};
|