atris 3.28.0 → 3.30.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 +320 -28
- package/lib/security-scan.js +354 -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,111 @@
|
|
|
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
|
+
recordRun,
|
|
35
|
+
buildLanding,
|
|
36
|
+
} = require('../lib/security-scan');
|
|
22
37
|
|
|
23
|
-
const ICON = { high: '✗', medium: '!', low: '·', privacy: '✗', secret: '✗', pii: '!' };
|
|
24
|
-
const SEV_ORDER = {
|
|
38
|
+
const ICON = { critical: '✗', high: '✗', medium: '!', low: '·', privacy: '✗', secret: '✗', pii: '!' };
|
|
39
|
+
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
40
|
+
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
const opts = {
|
|
43
|
+
json: false,
|
|
44
|
+
quiet: false,
|
|
45
|
+
strict: false,
|
|
46
|
+
staged: false,
|
|
47
|
+
updateBaseline: false,
|
|
48
|
+
noBaseline: false,
|
|
49
|
+
deep: false,
|
|
50
|
+
md: false,
|
|
51
|
+
land: false,
|
|
52
|
+
noRecord: false,
|
|
53
|
+
baseline: DEFAULT_BASELINE,
|
|
54
|
+
paths: [],
|
|
55
|
+
};
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const arg = argv[i];
|
|
58
|
+
if (arg === 'scan') continue;
|
|
59
|
+
if (arg === '--json') opts.json = true;
|
|
60
|
+
else if (arg === '--quiet') opts.quiet = true;
|
|
61
|
+
else if (arg === '--strict') opts.strict = true;
|
|
62
|
+
else if (arg === '--staged') opts.staged = true;
|
|
63
|
+
else if (arg === '--update-baseline') opts.updateBaseline = true;
|
|
64
|
+
else if (arg === '--no-baseline') opts.noBaseline = true;
|
|
65
|
+
else if (arg === '--deep') opts.deep = true;
|
|
66
|
+
else if (arg === '--md' || arg === '--markdown') opts.md = true;
|
|
67
|
+
else if (arg === '--land' || arg === '--landing') opts.land = true;
|
|
68
|
+
else if (arg === '--no-record') opts.noRecord = true;
|
|
69
|
+
else if (arg === '--baseline') {
|
|
70
|
+
if (!argv[i + 1] || argv[i + 1].startsWith('-')) throw new Error('--baseline requires a path');
|
|
71
|
+
opts.baseline = argv[++i];
|
|
72
|
+
} else if (arg.startsWith('--baseline=')) {
|
|
73
|
+
opts.baseline = arg.slice('--baseline='.length);
|
|
74
|
+
} else if (arg.startsWith('-')) {
|
|
75
|
+
throw new Error(`unknown flag: ${arg}`);
|
|
76
|
+
} else {
|
|
77
|
+
opts.paths.push(arg);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (opts.noBaseline && opts.updateBaseline) throw new Error('--update-baseline cannot be combined with --no-baseline');
|
|
81
|
+
return opts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sortFindings(findings) {
|
|
85
|
+
findings.sort((a, b) => (SEV_ORDER[a.sev] - SEV_ORDER[b.sev]) || a.file.localeCompare(b.file) || (a.line - b.line));
|
|
86
|
+
return findings;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function failThreshold(strict) {
|
|
90
|
+
return strict ? 'medium' : 'high';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function failingCount(findings, threshold) {
|
|
94
|
+
return findings.filter((f) => SEVERITY_RANK[f.sev] >= SEVERITY_RANK[threshold]).length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function applyBaselineOptions(root, rawFindings, opts) {
|
|
98
|
+
if (opts.noBaseline) {
|
|
99
|
+
const active = applyBaseline(rawFindings, []);
|
|
100
|
+
return { ...active, baseline: { enabled: false, path: null, updated: false } };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let baseline = loadBaseline(root, opts.baseline);
|
|
104
|
+
let updated = false;
|
|
105
|
+
if (opts.updateBaseline) {
|
|
106
|
+
baseline = writeBaseline(root, rawFindings, opts.baseline);
|
|
107
|
+
updated = true;
|
|
108
|
+
}
|
|
109
|
+
const applied = applyBaseline(rawFindings, baseline.fingerprints);
|
|
110
|
+
return {
|
|
111
|
+
...applied,
|
|
112
|
+
baseline: {
|
|
113
|
+
enabled: true,
|
|
114
|
+
path: path.relative(root, baseline.file) || opts.baseline,
|
|
115
|
+
updated,
|
|
116
|
+
fingerprints: baseline.fingerprints.length,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
25
120
|
|
|
26
121
|
function securityReviewCommand(argv = []) {
|
|
27
122
|
const sub = argv[0];
|
|
@@ -29,42 +124,85 @@ function securityReviewCommand(argv = []) {
|
|
|
29
124
|
if (sub === 'rules') return printRules();
|
|
30
125
|
if (sub === 'hook' || sub === 'install-hook') return installHook();
|
|
31
126
|
|
|
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
127
|
const root = process.cwd();
|
|
128
|
+
let opts;
|
|
129
|
+
try {
|
|
130
|
+
opts = parseArgs(argv);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error(`security-review: ${e.message}`);
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
38
135
|
|
|
136
|
+
let raw;
|
|
39
137
|
let result;
|
|
40
138
|
try {
|
|
41
|
-
|
|
139
|
+
raw = runScan({ root, paths: opts.paths, staged: opts.staged });
|
|
140
|
+
result = applyBaselineOptions(root, raw.findings, opts);
|
|
42
141
|
} catch (e) {
|
|
43
142
|
console.error(`security-review: ${e.message}`);
|
|
44
143
|
return 2;
|
|
45
144
|
}
|
|
46
145
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
146
|
+
const findings = sortFindings(result.findings);
|
|
147
|
+
const allFindings = sortFindings(result.allFindings);
|
|
148
|
+
const counts = result.counts;
|
|
149
|
+
const threshold = failThreshold(opts.strict);
|
|
150
|
+
const failing = failingCount(findings, threshold);
|
|
151
|
+
const ok = !shouldFail(findings, threshold);
|
|
51
152
|
|
|
52
|
-
|
|
153
|
+
// Flight recorder + landing: the loop appends one row per run; the landing
|
|
154
|
+
// compares this run to the last one. buildLanding must read the ledger BEFORE
|
|
155
|
+
// recordRun appends this run.
|
|
156
|
+
const scanResult = { findings, counts, scanned: raw.scanned, suppressed: result.suppressed };
|
|
157
|
+
const landing = buildLanding(root, scanResult, { failOn: threshold });
|
|
158
|
+
if (!opts.noRecord) recordRun(root, scanResult, { failOn: threshold });
|
|
159
|
+
|
|
160
|
+
if (opts.json) {
|
|
53
161
|
console.log(JSON.stringify({
|
|
54
|
-
ok
|
|
55
|
-
scanned,
|
|
162
|
+
ok,
|
|
163
|
+
scanned: raw.scanned,
|
|
56
164
|
counts,
|
|
57
|
-
|
|
165
|
+
counts_all: result.countsAll,
|
|
166
|
+
suppressed: result.suppressed,
|
|
167
|
+
baseline: result.baseline,
|
|
168
|
+
score: scoreFindings(findings),
|
|
169
|
+
score_all: scoreFindings(allFindings),
|
|
170
|
+
fail_threshold: threshold,
|
|
171
|
+
landing,
|
|
58
172
|
findings,
|
|
173
|
+
deep_review: opts.deep ? deepReviewPayload({ findings, counts, scanned: raw.scanned, suppressed: result.suppressed }) : undefined,
|
|
59
174
|
generated_for: 'soc2-evidence',
|
|
60
175
|
}, null, 2));
|
|
61
|
-
return
|
|
176
|
+
return ok ? 0 : 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (opts.md) {
|
|
180
|
+
console.log(renderMarkdownReport({
|
|
181
|
+
findings, allFindings, counts, countsAll: result.countsAll, scanned: raw.scanned,
|
|
182
|
+
threshold, failing, baseline: result.baseline, suppressed: result.suppressed,
|
|
183
|
+
includeDeep: opts.deep,
|
|
184
|
+
}));
|
|
185
|
+
return ok ? 0 : 1;
|
|
62
186
|
}
|
|
63
187
|
|
|
64
|
-
if (
|
|
188
|
+
if (opts.deep) {
|
|
189
|
+
console.log(renderDeepReview({
|
|
190
|
+
findings, counts, scanned: raw.scanned, threshold, suppressed: result.suppressed, baseline: result.baseline,
|
|
191
|
+
}));
|
|
192
|
+
return ok ? 0 : 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (opts.land) {
|
|
196
|
+
console.log(renderLanding(landing, threshold));
|
|
197
|
+
return ok ? 0 : 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!opts.quiet) {
|
|
65
201
|
console.log('\n ◉ atris security review');
|
|
66
202
|
if (!findings.length) {
|
|
67
|
-
console.log(`\n ✓ clean — no secrets, PII, or sensitive files in ${scanned} tracked file${scanned === 1 ? '' : 's'}
|
|
203
|
+
console.log(`\n ✓ clean — no active secrets, PII, or sensitive files in ${raw.scanned} tracked file${raw.scanned === 1 ? '' : 's'}`);
|
|
204
|
+
if (result.suppressed) console.log(` ${result.suppressed} accepted finding${result.suppressed === 1 ? '' : 's'} suppressed by ${result.baseline.path}`);
|
|
205
|
+
console.log('');
|
|
68
206
|
return 0;
|
|
69
207
|
}
|
|
70
208
|
console.log('');
|
|
@@ -74,23 +212,168 @@ function securityReviewCommand(argv = []) {
|
|
|
74
212
|
console.log(` ${ICON[f.sev] || '·'} ${f.sev.toUpperCase().padEnd(6)} ${loc} ${f.rule.padEnd(22)} ${f.why}`);
|
|
75
213
|
}
|
|
76
214
|
}
|
|
77
|
-
console.log(`\n ${counts.high || 0} high · ${counts.medium || 0} medium · ${counts.low || 0} low across ${scanned} file${scanned === 1 ? '' : 's'}`);
|
|
215
|
+
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'}`);
|
|
216
|
+
if (result.suppressed) console.log(` ${result.suppressed} accepted finding${result.suppressed === 1 ? '' : 's'} suppressed by ${result.baseline.path}`);
|
|
217
|
+
if (result.baseline.updated) console.log(` baseline updated: ${result.baseline.path} (${result.baseline.fingerprints} fingerprint${result.baseline.fingerprints === 1 ? '' : 's'})`);
|
|
78
218
|
if (failing) {
|
|
79
|
-
console.log(` ${failing} finding${failing === 1 ? '' : 's'} at/over the ${
|
|
219
|
+
console.log(` ${failing} finding${failing === 1 ? '' : 's'} at/over the ${threshold.toUpperCase()} threshold · exit 1`);
|
|
80
220
|
console.log(' fix or suppress (trailing `atris-allow-secret`), then re-run.\n');
|
|
81
221
|
} else {
|
|
82
|
-
console.log(` no findings at the ${
|
|
222
|
+
console.log(` no findings at the ${threshold.toUpperCase()} threshold · exit 0\n`);
|
|
223
|
+
}
|
|
224
|
+
return ok ? 0 : 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// The landing: short, true, decision-ready. What you read after the overnight
|
|
228
|
+
// loop — the opposite of a finding dump.
|
|
229
|
+
function renderLanding(landing, threshold) {
|
|
230
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
231
|
+
const L = ['', ` ✈ security landing — ${date}`, ''];
|
|
232
|
+
if (landing.cleared) {
|
|
233
|
+
L.push(` CLEARED TO SHIP no unresolved findings at the ${threshold.toUpperCase()} line`);
|
|
234
|
+
} else {
|
|
235
|
+
const n = landing.open.length;
|
|
236
|
+
L.push(` HOLD ${n} finding${n === 1 ? '' : 's'} need a decision before ship`);
|
|
237
|
+
}
|
|
238
|
+
L.push('');
|
|
239
|
+
if (landing.hadPrevRun) {
|
|
240
|
+
L.push(' since last run:');
|
|
241
|
+
L.push(` fixed ${landing.fixed}`);
|
|
242
|
+
L.push(` new ${landing.appeared}`);
|
|
243
|
+
L.push(` accepted ${landing.accepted} (known-safe, in baseline)`);
|
|
244
|
+
} else {
|
|
245
|
+
L.push(` first run: ${landing.open.length} open · ${landing.accepted} accepted (baseline)`);
|
|
83
246
|
}
|
|
84
|
-
|
|
247
|
+
L.push('');
|
|
248
|
+
if (landing.open.length) {
|
|
249
|
+
L.push(' needs you:');
|
|
250
|
+
for (const f of landing.open.slice(0, 10)) {
|
|
251
|
+
L.push(` ${f.sev.toUpperCase().padEnd(8)} ${f.file}${f.line ? ':' + f.line : ''} — ${f.why}`);
|
|
252
|
+
}
|
|
253
|
+
if (landing.open.length > 10) L.push(` … and ${landing.open.length - 10} more`);
|
|
254
|
+
} else {
|
|
255
|
+
L.push(' needs you: nothing');
|
|
256
|
+
}
|
|
257
|
+
L.push('');
|
|
258
|
+
if (landing.trend.length > 1) {
|
|
259
|
+
const a = landing.trend[0], b = landing.trend[landing.trend.length - 1];
|
|
260
|
+
const da = a.critical + a.high, db = b.critical + b.high;
|
|
261
|
+
const dir = db < da ? 'improving' : db > da ? 'worsening' : 'steady';
|
|
262
|
+
L.push(` posture: critical ${a.critical}→${b.critical}, high ${a.high}→${b.high} over ${landing.trend.length} runs (${dir})`);
|
|
263
|
+
} else {
|
|
264
|
+
L.push(` posture: ${landing.runs} run${landing.runs === 1 ? '' : 's'} recorded · scanned ${landing.scanned} files`);
|
|
265
|
+
}
|
|
266
|
+
L.push('');
|
|
267
|
+
return L.join('\n');
|
|
85
268
|
}
|
|
86
269
|
|
|
87
270
|
function printRules() {
|
|
88
271
|
console.log('\n atris security-review — deterministic rules:\n');
|
|
89
|
-
for (const
|
|
272
|
+
for (const sev of SEVERITIES) {
|
|
273
|
+
for (const r of RULES.filter((rule) => rule.sev === sev)) {
|
|
274
|
+
console.log(` ${r.sev.toUpperCase().padEnd(8)} ${r.cat.padEnd(8)} ${r.id.padEnd(22)} ${r.why}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
90
277
|
console.log(`\n ${RULES.length} rules + tracked-sensitive-file check. Suppress a line with a trailing \`atris-allow-secret\`.\n`);
|
|
91
278
|
return 0;
|
|
92
279
|
}
|
|
93
280
|
|
|
281
|
+
const DEEP_DIMENSIONS = [
|
|
282
|
+
['secrets & keys', 'Look for real keys, tokens, private keys, credential files, and places where logs or docs could expose them.'],
|
|
283
|
+
['who-can-do-what', 'Check whether users, agents, missions, and local commands can only do the actions they should be allowed to do.'],
|
|
284
|
+
['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.'],
|
|
285
|
+
['data exposure', 'Check logs, responses, reports, task state, prompts, and errors for personal data, secrets, or private workspace paths.'],
|
|
286
|
+
['dependencies', 'Check package and script changes for risky install hooks, unpinned tools, vendored code, or unexpected network execution.'],
|
|
287
|
+
['crypto & randomness', 'Check token generation, signing, hashing, random IDs, and compare logic for weak or predictable behavior.'],
|
|
288
|
+
['config & defaults', 'Check debug flags, CORS, open ports, default credentials, unsafe local paths, and flags that bypass safety gates.'],
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
function evidenceLines(findings, limit = 80) {
|
|
292
|
+
if (!findings.length) return ['- No deterministic findings after baseline suppression.'];
|
|
293
|
+
return findings.slice(0, limit).map((f) => {
|
|
294
|
+
const loc = `${f.file}${f.line ? ':' + f.line : ''}`;
|
|
295
|
+
return `- ${f.sev.toUpperCase()} ${loc} ${f.rule}: ${f.why}`;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function deepReviewPayload({ findings, counts, scanned, suppressed }) {
|
|
300
|
+
return {
|
|
301
|
+
instruction: 'Answer each dimension with PASS or CONCERN. If CONCERN, cite file:line and the concrete risk. Do not speculate beyond the code.',
|
|
302
|
+
dimensions: DEEP_DIMENSIONS.map(([name, check]) => ({ name, check })),
|
|
303
|
+
evidence: {
|
|
304
|
+
scanned,
|
|
305
|
+
counts,
|
|
306
|
+
suppressed,
|
|
307
|
+
findings,
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function renderDeepReview({ findings, counts, scanned, threshold, suppressed, baseline }) {
|
|
313
|
+
const lines = [
|
|
314
|
+
'# Atris Deep Security Review',
|
|
315
|
+
'',
|
|
316
|
+
'Use this as a second-pass review prompt for a capable model.',
|
|
317
|
+
'Answer every dimension with PASS or CONCERN, then give the file:line evidence.',
|
|
318
|
+
'Do not invent issues. If the code does not prove the issue, mark PASS or say what proof is missing.',
|
|
319
|
+
'',
|
|
320
|
+
'Deterministic evidence:',
|
|
321
|
+
`- scanned files: ${scanned}`,
|
|
322
|
+
`- active counts: ${counts.critical || 0} critical, ${counts.high || 0} high, ${counts.medium || 0} medium, ${counts.low || 0} low`,
|
|
323
|
+
`- fail threshold: ${threshold}`,
|
|
324
|
+
`- suppressed by baseline: ${suppressed}`,
|
|
325
|
+
`- baseline: ${baseline.enabled ? baseline.path : 'off'}`,
|
|
326
|
+
'',
|
|
327
|
+
'Findings:',
|
|
328
|
+
...evidenceLines(findings),
|
|
329
|
+
'',
|
|
330
|
+
'Review dimensions:',
|
|
331
|
+
];
|
|
332
|
+
for (const [name, check] of DEEP_DIMENSIONS) {
|
|
333
|
+
lines.push('', `## ${name}`, check, 'Answer: PASS or CONCERN', 'Specific evidence:');
|
|
334
|
+
}
|
|
335
|
+
return `${lines.join('\n')}\n`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderMarkdownReport({ findings, allFindings, counts, countsAll, scanned, threshold, failing, baseline, suppressed, includeDeep }) {
|
|
339
|
+
const status = failing ? 'FAIL' : 'PASS';
|
|
340
|
+
const lines = [
|
|
341
|
+
'# Atris Security Review',
|
|
342
|
+
'',
|
|
343
|
+
`Status: ${status}`,
|
|
344
|
+
`Fail threshold: ${threshold}`,
|
|
345
|
+
`Scanned files: ${scanned}`,
|
|
346
|
+
`Baseline: ${baseline.enabled ? baseline.path : 'off'}`,
|
|
347
|
+
`Suppressed: ${suppressed}`,
|
|
348
|
+
'',
|
|
349
|
+
'## Active Counts',
|
|
350
|
+
'',
|
|
351
|
+
`- critical: ${counts.critical || 0}`,
|
|
352
|
+
`- high: ${counts.high || 0}`,
|
|
353
|
+
`- medium: ${counts.medium || 0}`,
|
|
354
|
+
`- low: ${counts.low || 0}`,
|
|
355
|
+
`- score: ${scoreFindings(findings)}`,
|
|
356
|
+
'',
|
|
357
|
+
'## All Counts',
|
|
358
|
+
'',
|
|
359
|
+
`- critical: ${countsAll.critical || 0}`,
|
|
360
|
+
`- high: ${countsAll.high || 0}`,
|
|
361
|
+
`- medium: ${countsAll.medium || 0}`,
|
|
362
|
+
`- low: ${countsAll.low || 0}`,
|
|
363
|
+
`- score: ${scoreFindings(allFindings)}`,
|
|
364
|
+
'',
|
|
365
|
+
'## Findings',
|
|
366
|
+
'',
|
|
367
|
+
...evidenceLines(findings),
|
|
368
|
+
];
|
|
369
|
+
if (includeDeep) {
|
|
370
|
+
lines.push('', '## Deep Review Prompt', '', renderDeepReview({
|
|
371
|
+
findings, counts, scanned, threshold, suppressed, baseline,
|
|
372
|
+
}).trim());
|
|
373
|
+
}
|
|
374
|
+
return `${lines.join('\n')}\n`;
|
|
375
|
+
}
|
|
376
|
+
|
|
94
377
|
function installHook() {
|
|
95
378
|
const root = process.cwd();
|
|
96
379
|
const hookDir = path.join(root, '.git', 'hooks');
|
|
@@ -120,11 +403,20 @@ function printHelp() {
|
|
|
120
403
|
atris security-review --staged scan staged changes (pre-commit gate)
|
|
121
404
|
atris security-review --strict also fail on MEDIUM (PII / personal paths)
|
|
122
405
|
atris security-review --json machine output / SOC 2 evidence artifact
|
|
406
|
+
atris security-review --md markdown evidence report
|
|
407
|
+
atris security-review --deep prompt a stronger model with framework + evidence
|
|
408
|
+
atris security-review --land the landing: cleared-to-ship or hold, what changed,
|
|
409
|
+
what needs you, the trend (read this after the loop)
|
|
410
|
+
atris security-review --update-baseline
|
|
411
|
+
accept current findings in .security-review.baseline.json
|
|
412
|
+
atris security-review --no-baseline
|
|
413
|
+
ignore .security-review.baseline.json
|
|
123
414
|
atris security-review rules list the active detectors
|
|
124
415
|
atris security-review hook install a pre-commit gate
|
|
125
416
|
|
|
126
|
-
Scans for
|
|
127
|
-
files. exit 0 = clean, 1 = found. Wire into
|
|
417
|
+
Scans for real-looking secrets, API keys, personal data, tracked sensitive
|
|
418
|
+
files, and code-exec review evidence. exit 0 = clean, 1 = found. Wire into
|
|
419
|
+
the autopilot/mission gate and CI.
|
|
128
420
|
`);
|
|
129
421
|
return 0;
|
|
130
422
|
}
|
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,143 @@ 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);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- the loop's flight recorder + landing ---
|
|
435
|
+
// Takeoff = the overnight loop grinding the repo. Landing = the one clean
|
|
436
|
+
// artifact a human reads after: what changed, what still needs you, the trend.
|
|
437
|
+
// One compact row per run keeps it honest without noise.
|
|
438
|
+
const DEFAULT_LEDGER = path.join('.atris', 'state', 'security_review.jsonl');
|
|
439
|
+
|
|
440
|
+
function gitCommit(root) {
|
|
441
|
+
try { return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
|
|
442
|
+
catch { return null; }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function openFingerprints(findings, failOn = 'high') {
|
|
446
|
+
const threshold = SEVERITY_RANK[failOn] == null ? SEVERITY_RANK.high : SEVERITY_RANK[failOn];
|
|
447
|
+
return findings.filter((f) => SEVERITY_RANK[f.sev] >= threshold)
|
|
448
|
+
.map((f) => f.fingerprint || findingFingerprint(f));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function recordRun(root, scan, { failOn = 'high' } = {}) {
|
|
452
|
+
try {
|
|
453
|
+
const findings = scan.findings || [];
|
|
454
|
+
const row = {
|
|
455
|
+
ts: new Date().toISOString(),
|
|
456
|
+
commit: gitCommit(root),
|
|
457
|
+
scanned: scan.scanned || 0,
|
|
458
|
+
counts: scan.counts || summarizeFindings(findings),
|
|
459
|
+
accepted: scan.suppressed || 0,
|
|
460
|
+
open: [...new Set(openFingerprints(findings, failOn))].sort(),
|
|
461
|
+
};
|
|
462
|
+
const file = path.join(root, DEFAULT_LEDGER);
|
|
463
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
464
|
+
fs.appendFileSync(file, JSON.stringify(row) + '\n');
|
|
465
|
+
return row;
|
|
466
|
+
} catch { return null; }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function loadLedger(root) {
|
|
470
|
+
try {
|
|
471
|
+
return fs.readFileSync(path.join(root, DEFAULT_LEDGER), 'utf8')
|
|
472
|
+
.split('\n').filter(Boolean)
|
|
473
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
474
|
+
.filter(Boolean);
|
|
475
|
+
} catch { return []; }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Compare the current scan to the last recorded run. Returns the decision-ready
|
|
479
|
+
// landing: cleared-to-ship or hold, what was fixed/appeared, what still needs a
|
|
480
|
+
// human, and the trend over recent runs.
|
|
481
|
+
function buildLanding(root, scan, { failOn = 'high' } = {}) {
|
|
482
|
+
const ledger = loadLedger(root);
|
|
483
|
+
const prev = ledger.length ? ledger[ledger.length - 1] : null;
|
|
484
|
+
const findings = scan.findings || [];
|
|
485
|
+
const open = findings.filter((f) => SEVERITY_RANK[f.sev] >= (SEVERITY_RANK[failOn] ?? SEVERITY_RANK.high));
|
|
486
|
+
const openNow = new Set(open.map((f) => f.fingerprint || findingFingerprint(f)));
|
|
487
|
+
const prevOpen = new Set(prev ? prev.open || [] : []);
|
|
488
|
+
return {
|
|
489
|
+
cleared: openNow.size === 0,
|
|
490
|
+
open,
|
|
491
|
+
fixed: [...prevOpen].filter((fp) => !openNow.has(fp)).length,
|
|
492
|
+
appeared: prev ? [...openNow].filter((fp) => !prevOpen.has(fp)).length : openNow.size,
|
|
493
|
+
accepted: scan.suppressed || 0,
|
|
494
|
+
scanned: scan.scanned || 0,
|
|
495
|
+
runs: ledger.length + 1,
|
|
496
|
+
hadPrevRun: Boolean(prev),
|
|
497
|
+
// include THIS run as the latest trend point so the posture line is current
|
|
498
|
+
trend: [
|
|
499
|
+
...ledger.slice(-4).map((r) => ({ ts: r.ts, critical: (r.counts && r.counts.critical) || 0, high: (r.counts && r.counts.high) || 0 })),
|
|
500
|
+
{ ts: new Date().toISOString(), critical: (scan.counts && scan.counts.critical) || 0, high: (scan.counts && scan.counts.high) || 0 },
|
|
501
|
+
],
|
|
502
|
+
};
|
|
183
503
|
}
|
|
184
504
|
|
|
185
505
|
module.exports = {
|
|
186
506
|
scanLine, scanText, scanFile, sensitiveFileFindings, gitTrackedFiles,
|
|
187
|
-
resolveTargets, runScan, SECRET_RULES, PII_RULES, RULES,
|
|
507
|
+
resolveTargets, runScan, SECRET_RULES, PII_RULES, RULES, CODE_RULES,
|
|
508
|
+
DEFAULT_BASELINE, DEFAULT_LEDGER, SEVERITIES, SEVERITY_RANK, normalizeSnippet,
|
|
509
|
+
shannonEntropy, secretQuality, findingFingerprint, summarizeFindings,
|
|
510
|
+
loadBaseline, writeBaseline, applyBaseline, shouldFail, scoreFindings,
|
|
511
|
+
recordRun, loadLedger, buildLanding,
|
|
188
512
|
};
|