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 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 { runScan, RULES } = require('../lib/security-scan');
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 = { high: 0, medium: 1, low: 2 };
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
- result = runScan({ root, paths, staged });
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 { findings, counts, scanned } = result;
48
- findings.sort((a, b) => (SEV_ORDER[a.sev] - SEV_ORDER[b.sev]) || a.file.localeCompare(b.file) || (a.line - b.line));
49
- const failThreshold = strict ? ['high', 'medium'] : ['high'];
50
- const failing = findings.filter((f) => failThreshold.includes(f.sev)).length;
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
- if (json) {
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: failing === 0,
55
- scanned,
162
+ ok,
163
+ scanned: raw.scanned,
56
164
  counts,
57
- fail_threshold: strict ? 'medium' : 'high',
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 failing ? 1 : 0;
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 (!quiet) {
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'}\n`);
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 ${strict ? 'MEDIUM' : 'HIGH'} threshold · exit 1`);
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 ${strict ? 'MEDIUM' : 'HIGH'} threshold · exit 0\n`);
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
- return failing ? 1 : 0;
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 r of RULES) console.log(` ${r.sev.toUpperCase().padEnd(6)} ${r.cat.padEnd(8)} ${r.id.padEnd(22)} ${r.why}`);
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 hardcoded secrets, API keys, personal data, and tracked sensitive
127
- files. exit 0 = clean, 1 = found. Wire into the autopilot/mission gate and CI.
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
  }
@@ -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(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'out', 'vendor', '.cache', '__pycache__']);
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|dummy|<[a-z_-]+>|process\.env|getenv|os\.environ|import\.meta\.env)\b/i;
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. severity high => fails the gate.
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: 'high', cat: 'secret', re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA |ENCRYPTED )?PRIVATE KEY-----/, why: 'private key committed in source' },
30
- { id: 'aws-access-key-id', sev: 'high', cat: 'secret', re: /\bAKIA[0-9A-Z]{16}\b/, why: 'AWS access key id' },
31
- { id: 'github-token', sev: 'high', cat: 'secret', re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/, why: 'GitHub personal/OAuth token' },
32
- { id: 'slack-token', sev: 'high', cat: 'secret', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, why: 'Slack token' },
33
- { id: 'slack-webhook', sev: 'high', cat: 'secret', re: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]{20,}/, why: 'Slack incoming webhook url' },
34
- { id: 'openai-key', sev: 'high', cat: 'secret', re: /\bsk-(?:proj-)?[A-Za-z0-9]{20,}\b/, why: 'OpenAI-style API key' },
35
- { id: 'anthropic-key', sev: 'high', cat: 'secret', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/, why: 'Anthropic API key' },
36
- { id: 'google-api-key', sev: 'high', cat: 'secret', re: /\bAIza[0-9A-Za-z_-]{35}\b/, why: 'Google API key' },
37
- { id: 'stripe-key', sev: 'high', cat: 'secret', re: /\b[rs]k_live_[A-Za-z0-9]{20,}\b/, why: 'Stripe live key' },
38
- { id: 'npm-token', sev: 'high', cat: 'secret', re: /\bnpm_[A-Za-z0-9]{36}\b/, why: 'npm access token' },
39
- { id: 'jwt', sev: 'medium', cat: 'secret', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}\b/, why: 'JWT (often carries claims/PII)' },
40
- { id: 'bearer-token', sev: 'medium', cat: 'secret', re: /\bBearer\s+[A-Za-z0-9._-]{24,}/, why: 'hardcoded Bearer token' },
41
- { id: 'assigned-secret', sev: 'high', cat: 'secret', re: /(?:password|passwd|pwd|secret|api[_-]?key|apikey|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key)\s*[:=]\s*['"][^'"\s]{8,}['"]/i, why: 'hardcoded credential assignment' },
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: 'high', cat: 'code', re: /(?<![.\w])eval\((?!\s*\))/, why: 'eval() executes arbitrary code' },
59
- { id: 'new-function', sev: 'high', cat: 'code', re: /\bnew\s+Function\(/, why: 'new Function() executes arbitrary code' },
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
- // Secrets in obvious placeholder/env-read lines are not real leaks.
81
- if (rule.cat === 'secret' && placeholder) continue;
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
- return scanText(text, rules || rulesForFile(file)).map((f) => ({ ...f, file }));
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), [])).filter(scannable);
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 files = tracked.filter(scannable).map((p) => path.join(root, p)).filter((f) => fs.existsSync(f));
161
- return { files, relPaths: tracked };
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 counts = { high: 0, medium: 0, low: 0 };
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 { findings, counts, scanned: files.length };
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.28.0",
3
+ "version": "3.30.0",
4
4
  "main": "bin/atris.js",
5
5
  "bin": {
6
6
  "atris": "bin/atris.js",