atris 3.28.0 → 3.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -136,12 +136,10 @@ Load these first:
136
136
  - `atris/now.md`
137
137
  - `atris/brain/STATUS.md`
138
138
  - `atris/brain/self_improvement_ledger.md`
139
- - `atris/wiki/concepts/agent-activation-contract.md`
140
139
  - `atris/skills/atris/SKILL.md`
141
140
  - `atris/PERSONA.md`
142
141
  - `atris/MAP.md`
143
142
  - `atris/TODO.md`
144
- - `atris/wiki/index.md`
145
143
 
146
144
  First-message rule: lead with the move before writing to the operator.
147
145
  Purpose: optimize for decision-speed; lead with the move, then use descriptions only when they help the operator act.
@@ -12,16 +12,105 @@
12
12
  // atris security-review --staged scan staged changes (pre-commit gate)
13
13
  // atris security-review --json machine output for CI / the loop
14
14
  // atris security-review --strict also fail on MEDIUM (PII/paths)
15
+ // atris security-review --deep model-review prompt + evidence
16
+ // atris security-review --update-baseline accept current findings
15
17
  // atris security-review hook install a pre-commit gate
16
18
  //
17
- // Exit code: 0 = clean, 1 = findings at/over the fail threshold, 2 = bad usage.
19
+ // Exit code: 0 = clean, 1 = active findings at/over the fail threshold, 2 = bad usage.
18
20
 
19
21
  const fs = require('fs');
20
22
  const path = require('path');
21
- const { 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
+ } = require('../lib/security-scan');
22
35
 
23
- const ICON = { high: '✗', medium: '!', low: '·', privacy: '✗', secret: '✗', pii: '!' };
24
- const SEV_ORDER = { high: 0, medium: 1, low: 2 };
36
+ const ICON = { critical: '✗', high: '✗', medium: '!', low: '·', privacy: '✗', secret: '✗', pii: '!' };
37
+ const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
38
+
39
+ function parseArgs(argv) {
40
+ const opts = {
41
+ json: false,
42
+ quiet: false,
43
+ strict: false,
44
+ staged: false,
45
+ updateBaseline: false,
46
+ noBaseline: false,
47
+ deep: false,
48
+ md: false,
49
+ baseline: DEFAULT_BASELINE,
50
+ paths: [],
51
+ };
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const arg = argv[i];
54
+ if (arg === 'scan') continue;
55
+ if (arg === '--json') opts.json = true;
56
+ else if (arg === '--quiet') opts.quiet = true;
57
+ else if (arg === '--strict') opts.strict = true;
58
+ else if (arg === '--staged') opts.staged = true;
59
+ else if (arg === '--update-baseline') opts.updateBaseline = true;
60
+ else if (arg === '--no-baseline') opts.noBaseline = true;
61
+ else if (arg === '--deep') opts.deep = true;
62
+ else if (arg === '--md' || arg === '--markdown') opts.md = true;
63
+ else if (arg === '--baseline') {
64
+ if (!argv[i + 1] || argv[i + 1].startsWith('-')) throw new Error('--baseline requires a path');
65
+ opts.baseline = argv[++i];
66
+ } else if (arg.startsWith('--baseline=')) {
67
+ opts.baseline = arg.slice('--baseline='.length);
68
+ } else if (arg.startsWith('-')) {
69
+ throw new Error(`unknown flag: ${arg}`);
70
+ } else {
71
+ opts.paths.push(arg);
72
+ }
73
+ }
74
+ if (opts.noBaseline && opts.updateBaseline) throw new Error('--update-baseline cannot be combined with --no-baseline');
75
+ return opts;
76
+ }
77
+
78
+ function sortFindings(findings) {
79
+ findings.sort((a, b) => (SEV_ORDER[a.sev] - SEV_ORDER[b.sev]) || a.file.localeCompare(b.file) || (a.line - b.line));
80
+ return findings;
81
+ }
82
+
83
+ function failThreshold(strict) {
84
+ return strict ? 'medium' : 'high';
85
+ }
86
+
87
+ function failingCount(findings, threshold) {
88
+ return findings.filter((f) => SEVERITY_RANK[f.sev] >= SEVERITY_RANK[threshold]).length;
89
+ }
90
+
91
+ function applyBaselineOptions(root, rawFindings, opts) {
92
+ if (opts.noBaseline) {
93
+ const active = applyBaseline(rawFindings, []);
94
+ return { ...active, baseline: { enabled: false, path: null, updated: false } };
95
+ }
96
+
97
+ let baseline = loadBaseline(root, opts.baseline);
98
+ let updated = false;
99
+ if (opts.updateBaseline) {
100
+ baseline = writeBaseline(root, rawFindings, opts.baseline);
101
+ updated = true;
102
+ }
103
+ const applied = applyBaseline(rawFindings, baseline.fingerprints);
104
+ return {
105
+ ...applied,
106
+ baseline: {
107
+ enabled: true,
108
+ path: path.relative(root, baseline.file) || opts.baseline,
109
+ updated,
110
+ fingerprints: baseline.fingerprints.length,
111
+ },
112
+ };
113
+ }
25
114
 
26
115
  function securityReviewCommand(argv = []) {
27
116
  const sub = argv[0];
@@ -29,42 +118,72 @@ function securityReviewCommand(argv = []) {
29
118
  if (sub === 'rules') return printRules();
30
119
  if (sub === 'hook' || sub === 'install-hook') return installHook();
31
120
 
32
- const json = argv.includes('--json');
33
- const quiet = argv.includes('--quiet');
34
- const strict = argv.includes('--strict');
35
- const staged = argv.includes('--staged');
36
- const paths = argv.filter((a) => !a.startsWith('-') && a !== 'scan');
37
121
  const root = process.cwd();
122
+ let opts;
123
+ try {
124
+ opts = parseArgs(argv);
125
+ } catch (e) {
126
+ console.error(`security-review: ${e.message}`);
127
+ return 2;
128
+ }
38
129
 
130
+ let raw;
39
131
  let result;
40
132
  try {
41
- result = runScan({ root, paths, staged });
133
+ raw = runScan({ root, paths: opts.paths, staged: opts.staged });
134
+ result = applyBaselineOptions(root, raw.findings, opts);
42
135
  } catch (e) {
43
136
  console.error(`security-review: ${e.message}`);
44
137
  return 2;
45
138
  }
46
139
 
47
- const { 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;
140
+ const findings = sortFindings(result.findings);
141
+ const allFindings = sortFindings(result.allFindings);
142
+ const counts = result.counts;
143
+ const threshold = failThreshold(opts.strict);
144
+ const failing = failingCount(findings, threshold);
145
+ const ok = !shouldFail(findings, threshold);
51
146
 
52
- if (json) {
147
+ if (opts.json) {
53
148
  console.log(JSON.stringify({
54
- ok: failing === 0,
55
- scanned,
149
+ ok,
150
+ scanned: raw.scanned,
56
151
  counts,
57
- fail_threshold: strict ? 'medium' : 'high',
152
+ counts_all: result.countsAll,
153
+ suppressed: result.suppressed,
154
+ baseline: result.baseline,
155
+ score: scoreFindings(findings),
156
+ score_all: scoreFindings(allFindings),
157
+ fail_threshold: threshold,
58
158
  findings,
159
+ deep_review: opts.deep ? deepReviewPayload({ findings, counts, scanned: raw.scanned, suppressed: result.suppressed }) : undefined,
59
160
  generated_for: 'soc2-evidence',
60
161
  }, null, 2));
61
- return failing ? 1 : 0;
162
+ return ok ? 0 : 1;
163
+ }
164
+
165
+ if (opts.md) {
166
+ console.log(renderMarkdownReport({
167
+ findings, allFindings, counts, countsAll: result.countsAll, scanned: raw.scanned,
168
+ threshold, failing, baseline: result.baseline, suppressed: result.suppressed,
169
+ includeDeep: opts.deep,
170
+ }));
171
+ return ok ? 0 : 1;
62
172
  }
63
173
 
64
- if (!quiet) {
174
+ if (opts.deep) {
175
+ console.log(renderDeepReview({
176
+ findings, counts, scanned: raw.scanned, threshold, suppressed: result.suppressed, baseline: result.baseline,
177
+ }));
178
+ return ok ? 0 : 1;
179
+ }
180
+
181
+ if (!opts.quiet) {
65
182
  console.log('\n ◉ atris security review');
66
183
  if (!findings.length) {
67
- console.log(`\n ✓ clean — no secrets, PII, or sensitive files in ${scanned} tracked file${scanned === 1 ? '' : 's'}\n`);
184
+ console.log(`\n ✓ clean — no active secrets, PII, or sensitive files in ${raw.scanned} tracked file${raw.scanned === 1 ? '' : 's'}`);
185
+ if (result.suppressed) console.log(` ${result.suppressed} accepted finding${result.suppressed === 1 ? '' : 's'} suppressed by ${result.baseline.path}`);
186
+ console.log('');
68
187
  return 0;
69
188
  }
70
189
  console.log('');
@@ -74,23 +193,125 @@ function securityReviewCommand(argv = []) {
74
193
  console.log(` ${ICON[f.sev] || '·'} ${f.sev.toUpperCase().padEnd(6)} ${loc} ${f.rule.padEnd(22)} ${f.why}`);
75
194
  }
76
195
  }
77
- console.log(`\n ${counts.high || 0} high · ${counts.medium || 0} medium · ${counts.low || 0} low across ${scanned} file${scanned === 1 ? '' : 's'}`);
196
+ console.log(`\n ${counts.critical || 0} critical · ${counts.high || 0} high · ${counts.medium || 0} medium · ${counts.low || 0} low across ${raw.scanned} file${raw.scanned === 1 ? '' : 's'}`);
197
+ if (result.suppressed) console.log(` ${result.suppressed} accepted finding${result.suppressed === 1 ? '' : 's'} suppressed by ${result.baseline.path}`);
198
+ if (result.baseline.updated) console.log(` baseline updated: ${result.baseline.path} (${result.baseline.fingerprints} fingerprint${result.baseline.fingerprints === 1 ? '' : 's'})`);
78
199
  if (failing) {
79
- console.log(` ${failing} finding${failing === 1 ? '' : 's'} at/over the ${strict ? 'MEDIUM' : 'HIGH'} threshold · exit 1`);
200
+ console.log(` ${failing} finding${failing === 1 ? '' : 's'} at/over the ${threshold.toUpperCase()} threshold · exit 1`);
80
201
  console.log(' fix or suppress (trailing `atris-allow-secret`), then re-run.\n');
81
202
  } else {
82
- console.log(` no findings at the ${strict ? 'MEDIUM' : 'HIGH'} threshold · exit 0\n`);
203
+ console.log(` no findings at the ${threshold.toUpperCase()} threshold · exit 0\n`);
83
204
  }
84
- return failing ? 1 : 0;
205
+ return ok ? 0 : 1;
85
206
  }
86
207
 
87
208
  function printRules() {
88
209
  console.log('\n atris security-review — deterministic rules:\n');
89
- for (const r of RULES) console.log(` ${r.sev.toUpperCase().padEnd(6)} ${r.cat.padEnd(8)} ${r.id.padEnd(22)} ${r.why}`);
210
+ for (const sev of SEVERITIES) {
211
+ for (const r of RULES.filter((rule) => rule.sev === sev)) {
212
+ console.log(` ${r.sev.toUpperCase().padEnd(8)} ${r.cat.padEnd(8)} ${r.id.padEnd(22)} ${r.why}`);
213
+ }
214
+ }
90
215
  console.log(`\n ${RULES.length} rules + tracked-sensitive-file check. Suppress a line with a trailing \`atris-allow-secret\`.\n`);
91
216
  return 0;
92
217
  }
93
218
 
219
+ const DEEP_DIMENSIONS = [
220
+ ['secrets & keys', 'Look for real keys, tokens, private keys, credential files, and places where logs or docs could expose them.'],
221
+ ['who-can-do-what', 'Check whether users, agents, missions, and local commands can only do the actions they should be allowed to do.'],
222
+ ['untrusted input to code/shell/path/web requests', 'Trace user-controlled values into eval, new Function, child_process, file paths, URLs, redirects, and fetch/request calls.'],
223
+ ['data exposure', 'Check logs, responses, reports, task state, prompts, and errors for personal data, secrets, or private workspace paths.'],
224
+ ['dependencies', 'Check package and script changes for risky install hooks, unpinned tools, vendored code, or unexpected network execution.'],
225
+ ['crypto & randomness', 'Check token generation, signing, hashing, random IDs, and compare logic for weak or predictable behavior.'],
226
+ ['config & defaults', 'Check debug flags, CORS, open ports, default credentials, unsafe local paths, and flags that bypass safety gates.'],
227
+ ];
228
+
229
+ function evidenceLines(findings, limit = 80) {
230
+ if (!findings.length) return ['- No deterministic findings after baseline suppression.'];
231
+ return findings.slice(0, limit).map((f) => {
232
+ const loc = `${f.file}${f.line ? ':' + f.line : ''}`;
233
+ return `- ${f.sev.toUpperCase()} ${loc} ${f.rule}: ${f.why}`;
234
+ });
235
+ }
236
+
237
+ function deepReviewPayload({ findings, counts, scanned, suppressed }) {
238
+ return {
239
+ instruction: 'Answer each dimension with PASS or CONCERN. If CONCERN, cite file:line and the concrete risk. Do not speculate beyond the code.',
240
+ dimensions: DEEP_DIMENSIONS.map(([name, check]) => ({ name, check })),
241
+ evidence: {
242
+ scanned,
243
+ counts,
244
+ suppressed,
245
+ findings,
246
+ },
247
+ };
248
+ }
249
+
250
+ function renderDeepReview({ findings, counts, scanned, threshold, suppressed, baseline }) {
251
+ const lines = [
252
+ '# Atris Deep Security Review',
253
+ '',
254
+ 'Use this as a second-pass review prompt for a capable model.',
255
+ 'Answer every dimension with PASS or CONCERN, then give the file:line evidence.',
256
+ 'Do not invent issues. If the code does not prove the issue, mark PASS or say what proof is missing.',
257
+ '',
258
+ 'Deterministic evidence:',
259
+ `- scanned files: ${scanned}`,
260
+ `- active counts: ${counts.critical || 0} critical, ${counts.high || 0} high, ${counts.medium || 0} medium, ${counts.low || 0} low`,
261
+ `- fail threshold: ${threshold}`,
262
+ `- suppressed by baseline: ${suppressed}`,
263
+ `- baseline: ${baseline.enabled ? baseline.path : 'off'}`,
264
+ '',
265
+ 'Findings:',
266
+ ...evidenceLines(findings),
267
+ '',
268
+ 'Review dimensions:',
269
+ ];
270
+ for (const [name, check] of DEEP_DIMENSIONS) {
271
+ lines.push('', `## ${name}`, check, 'Answer: PASS or CONCERN', 'Specific evidence:');
272
+ }
273
+ return `${lines.join('\n')}\n`;
274
+ }
275
+
276
+ function renderMarkdownReport({ findings, allFindings, counts, countsAll, scanned, threshold, failing, baseline, suppressed, includeDeep }) {
277
+ const status = failing ? 'FAIL' : 'PASS';
278
+ const lines = [
279
+ '# Atris Security Review',
280
+ '',
281
+ `Status: ${status}`,
282
+ `Fail threshold: ${threshold}`,
283
+ `Scanned files: ${scanned}`,
284
+ `Baseline: ${baseline.enabled ? baseline.path : 'off'}`,
285
+ `Suppressed: ${suppressed}`,
286
+ '',
287
+ '## Active Counts',
288
+ '',
289
+ `- critical: ${counts.critical || 0}`,
290
+ `- high: ${counts.high || 0}`,
291
+ `- medium: ${counts.medium || 0}`,
292
+ `- low: ${counts.low || 0}`,
293
+ `- score: ${scoreFindings(findings)}`,
294
+ '',
295
+ '## All Counts',
296
+ '',
297
+ `- critical: ${countsAll.critical || 0}`,
298
+ `- high: ${countsAll.high || 0}`,
299
+ `- medium: ${countsAll.medium || 0}`,
300
+ `- low: ${countsAll.low || 0}`,
301
+ `- score: ${scoreFindings(allFindings)}`,
302
+ '',
303
+ '## Findings',
304
+ '',
305
+ ...evidenceLines(findings),
306
+ ];
307
+ if (includeDeep) {
308
+ lines.push('', '## Deep Review Prompt', '', renderDeepReview({
309
+ findings, counts, scanned, threshold, suppressed, baseline,
310
+ }).trim());
311
+ }
312
+ return `${lines.join('\n')}\n`;
313
+ }
314
+
94
315
  function installHook() {
95
316
  const root = process.cwd();
96
317
  const hookDir = path.join(root, '.git', 'hooks');
@@ -120,11 +341,18 @@ function printHelp() {
120
341
  atris security-review --staged scan staged changes (pre-commit gate)
121
342
  atris security-review --strict also fail on MEDIUM (PII / personal paths)
122
343
  atris security-review --json machine output / SOC 2 evidence artifact
344
+ atris security-review --md markdown evidence report
345
+ atris security-review --deep prompt a stronger model with framework + evidence
346
+ atris security-review --update-baseline
347
+ accept current findings in .security-review.baseline.json
348
+ atris security-review --no-baseline
349
+ ignore .security-review.baseline.json
123
350
  atris security-review rules list the active detectors
124
351
  atris security-review hook install a pre-commit gate
125
352
 
126
- Scans for hardcoded secrets, API keys, personal data, and tracked sensitive
127
- files. exit 0 = clean, 1 = found. Wire into the autopilot/mission gate and CI.
353
+ Scans for real-looking secrets, API keys, personal data, tracked sensitive
354
+ files, and code-exec review evidence. exit 0 = clean, 1 = found. Wire into
355
+ the autopilot/mission gate and CI.
128
356
  `);
129
357
  return 0;
130
358
  }
@@ -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,71 @@ function runScan({ root = process.cwd(), paths = [], staged = false } = {}) {
177
370
  for (const finding of scanFile(f)) findings.push({ ...finding, file: path.relative(root, finding.file) });
178
371
  }
179
372
  findings.push(...sensitiveFileFindings(relPaths.filter((p) => !SELF_FILES.has(p))));
180
- const 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);
183
432
  }
184
433
 
185
434
  module.exports = {
186
435
  scanLine, scanText, scanFile, sensitiveFileFindings, gitTrackedFiles,
187
- resolveTargets, runScan, SECRET_RULES, PII_RULES, RULES,
436
+ resolveTargets, runScan, SECRET_RULES, PII_RULES, RULES, CODE_RULES,
437
+ DEFAULT_BASELINE, SEVERITIES, SEVERITY_RANK, normalizeSnippet,
438
+ shannonEntropy, secretQuality, findingFingerprint, summarizeFindings,
439
+ loadBaseline, writeBaseline, applyBaseline, shouldFail, scoreFindings,
188
440
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.28.0",
3
+ "version": "3.29.0",
4
4
  "main": "bin/atris.js",
5
5
  "bin": {
6
6
  "atris": "bin/atris.js",