atris 3.80.0 → 3.80.1

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.
@@ -8,7 +8,7 @@
8
8
  // control that should stop them, and the concrete result across the boundary.
9
9
 
10
10
  const CODE_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.java', '.php', '.rs', '.sh', '.bash', '.zsh']);
11
- const WEB_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.html', '.vue', '.svelte']);
11
+ const WEB_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.html', '.vue', '.svelte', '.erb', '.rb']);
12
12
  const WORKFLOW_RE = /(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/;
13
13
 
14
14
  const CLASSES = [
@@ -23,6 +23,9 @@ const CLASSES = [
23
23
  /\bsubprocess\.(?:run|call|Popen|check_output|check_call)\s*\(/,
24
24
  /\bos\.(?:system|popen)\s*\(/,
25
25
  /\bshell\s*:\s*true\b/,
26
+ // Ruby, Go, Java
27
+ /\b(?:system|spawn|exec)\s*\(?\s*["'][^"'\n]*#\{|%x[{(\[]|\bIO\.popen\b|\bOpen3\.\w+|`[^`\n]*#\{/,
28
+ /\bexec\.Command(?:Context)?\s*\(|\bRuntime\.getRuntime\(\)\.exec\b|\bnew\s+ProcessBuilder\b/,
26
29
  ],
27
30
  guide: [
28
31
  'Invariant: a lower-trust value never becomes shell syntax, a program name, or an option the caller did not intend.',
@@ -44,6 +47,7 @@ const CLASSES = [
44
47
  /\brequire\s*\(\s*(?!['"`])[^)]/,
45
48
  /\bimport\s*\(\s*(?!['"`])[^)]/,
46
49
  /\b(?:exec|compile)\s*\(\s*(?!['"])/,
50
+ /\b(?:instance_eval|class_eval|module_eval|instance_exec)\b|\.(?:constantize|safe_constantize)\b|\bconst_get\s*\(|\.(?:public_)?send\s*\(\s*(?![:'\"])/,
47
51
  ],
48
52
  guide: [
49
53
  'Invariant: text from a lower-trust source is never run as code, and a module path is never chosen by it.',
@@ -61,6 +65,7 @@ const CLASSES = [
61
65
  /\b(?:readFileSync|writeFileSync|readFile|writeFile|appendFileSync|createReadStream|createWriteStream|unlinkSync|rmSync|rmdirSync|renameSync|copyFileSync|symlinkSync|cpSync)\s*\(\s*(?!['"`])/,
62
66
  /\bpath\.(?:join|resolve)\s*\([^)]*\b(?:req|params|query|body|name|slug|id|input|args?|file|target)\b/,
63
67
  /\bextract(?:All)?\s*\(|\btar\b.*\bx\b|\bunzip\b/,
68
+ /\bFile\.(?:read|write|open|binread|binwrite|delete|unlink|rename|symlink)\s*\(\s*(?!['\"])|\bFileUtils\.\w+|\bsend_file\b|\bos\.(?:Open|OpenFile|Create|ReadFile|WriteFile|Remove(?:All)?)\s*\(|\bfilepath\.Join\s*\(/,
64
69
  ],
65
70
  guide: [
66
71
  'Invariant: a path built from a lower-trust value stays inside the directory the code means, after normalization and symlink resolution.',
@@ -81,6 +86,7 @@ const CLASSES = [
81
86
  /\baxios(?:\.\w+)?\s*\(/,
82
87
  /\brequests\.(?:get|post|put|patch|delete|request)\s*\(/,
83
88
  /\burllib\.request\.urlopen\s*\(/,
89
+ /\bNet::HTTP\b|\bHTTParty\b|\bFaraday\b|\bURI\.open\b|\bRestClient\b|\bhttp\.(?:Get|Post|Head|NewRequest(?:WithContext)?)\s*\(/,
84
90
  ],
85
91
  guide: [
86
92
  'Invariant: a lower-trust value cannot pick the host, scheme, or port of a request made with this process\'s network position or credentials.',
@@ -100,6 +106,10 @@ const CLASSES = [
100
106
  /\bcreateServer\s*\(/,
101
107
  /\.listen\s*\(\s*(?:\d|port|PORT)/,
102
108
  /\b(?:req|request)\.(?:headers|query|params|body)\b/,
109
+ /\b(?:before_action|skip_before_action|authorize!?|current_user|policy_scope|load_and_authorize_resource)\b|\bparams\[/,
110
+ /\bdef\s+(?:self\.)?(?:authorized\?|visible\?|scope_items\b|resolve\b)|\bfield\s+:\w+|\bargument\s+:\w+/,
111
+ /^\s*(?:get|post|put|patch|delete|match|resources?)\s+['\":]/,
112
+ /\bhttp\.HandleFunc\s*\(|\b\w+\.(?:GET|POST|PUT|PATCH|DELETE|Handle|HandleFunc)\s*\(\s*\"\//,
103
113
  ],
104
114
  guide: [
105
115
  'Invariant: every action that reads or changes another principal\'s data, or spends shared resources, checks who is asking and whether they may.',
@@ -119,6 +129,7 @@ const CLASSES = [
119
129
  /os\.environ(?:\.get)?\s*[[(]\s*['"][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)/,
120
130
  /\b(?:authorization|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token)\b/i,
121
131
  /\bcredentials?\.json\b|\bkeychain\b|\.netrc\b/i,
132
+ /\bENV(?:\.fetch)?\s*[\[(]\s*['\"][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)|\bos\.Getenv\s*\(\s*\"[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)/,
122
133
  ],
123
134
  guide: [
124
135
  'Invariant: a credential reaches only the party it was issued for, and never lands in logs, errors, receipts, prompts, child process environments, or files others can read.',
@@ -155,6 +166,7 @@ const CLASSES = [
155
166
  /\b(?:SELECT|INSERT|UPDATE|DELETE)\b[^;\n]*(?:\$\{|['"`]\s*\+|%s|\.format\(|\bf["'])/i,
156
167
  /\.(?:query|execute|raw|exec)\s*\(\s*(?:`[^`]*\$\{|f["']|['"][^'"]*['"]\s*\+)/,
157
168
  /\$where\b|\.find\s*\(\s*(?:req|request)\./,
169
+ /\.(?:where|order|group|having|joins|select|pluck|find_by_sql|exec_query|execute)\s*\(\s*\"[^\"\n]*#\{|\.order\s*\(\s*params\b|\.(?:Query|QueryRow|Exec)(?:Context)?\s*\([^)\n]*fmt\.Sprintf/,
158
170
  ],
159
171
  guide: [
160
172
  'Invariant: lower-trust values reach a query only as bound parameters, never as query text, identifiers, or operators.',
@@ -175,6 +187,7 @@ const CLASSES = [
175
187
  /\bv-html\b|\{@html\b/,
176
188
  /\bpostMessage\s*\(|addEventListener\s*\(\s*['"]message['"]/,
177
189
  /\bhref\s*=\s*\{?\s*(?!['"](?:https?:|\/|#))/,
190
+ /\.html_safe\b|\braw\s*\(|<%==/,
178
191
  ],
179
192
  guide: [
180
193
  'Invariant: text from another user or an outside source renders as text, never as markup or script, and cross-window messages are accepted only from expected origins.',
@@ -194,6 +207,7 @@ const CLASSES = [
194
207
  /\bunserialize\s*\(|\bMarshal\.load\b/,
195
208
  /\b__proto__\b|\bconstructor\s*\[\s*['"]prototype/,
196
209
  /\bObject\.assign\s*\(\s*\{\}\s*,\s*(?:req|body|input|data|JSON\.parse)/,
210
+ /\bYAML\.(?:load|unsafe_load)\s*\(|\bPsych\.(?:load|unsafe_load)\b|\bOj\.load\b|\bnew\s+ObjectInputStream\b|\bgob\.NewDecoder\b/,
197
211
  ],
198
212
  guide: [
199
213
  'Invariant: parsing lower-trust input never runs code, never changes shared object prototypes, and never produces an object the next step trusts more than the input.',
@@ -212,6 +226,8 @@ const CLASSES = [
212
226
  /createHash\s*\(\s*['"](?:md5|sha1)['"]/,
213
227
  /\b\w*(?:token|secret|signature|hmac|digest)\w*\s*(?:===|!==|==|!=)\s*\w*(?:token|secret|signature|hmac|digest|expected)/i,
214
228
  /\bjwt\.(?:decode|verify)\s*\(|algorithms?\s*:\s*\[?\s*['"]none['"]/,
229
+ /\bOpenSSL::(?:Cipher|PKey|HMAC|Digest|KDF)\b|\bDigest::(?:MD5|SHA1)\b|\bSecureRandom\b|\bsecure_compare\b|\bJWT\.decode\b/,
230
+ /\bcrypto\.(?:createCipheriv|createDecipheriv|createSign|createVerify|timingSafeEqual)\b|\b(?:decrypt|encrypt|verify_signature|verify_raw|sign_raw)\s*[(]|\bcrypto\/(?:aes|cipher|rsa|ecdsa|ed25519|hmac|subtle)\b/,
215
231
  ],
216
232
  guide: [
217
233
  'Invariant: secrets, session ids, and one-time codes are unguessable, and checks of them do not leak timing or accept forged values.',
@@ -225,7 +241,7 @@ const CLASSES = [
225
241
  boundary: 'install, build, and release',
226
242
  weight: 8,
227
243
  exts: null,
228
- files: (rel) => WORKFLOW_RE.test(rel) || /(?:^|\/)package\.json$/.test(rel) || /(?:^|\/)(?:Dockerfile|Makefile|install\.sh|setup\.py|pyproject\.toml)$/.test(rel),
244
+ files: (rel) => WORKFLOW_RE.test(rel) || /(?:^|\/)package\.json$/.test(rel) || /(?:^|\/)(?:Dockerfile|Makefile|install\.sh|setup\.py|pyproject\.toml|Gemfile|Rakefile|go\.mod)$/.test(rel) || /\.gemspec$/.test(rel),
229
245
  detect: [
230
246
  /"(?:preinstall|install|postinstall|prepare|prepublish)"\s*:/,
231
247
  /\bpull_request_target\b|\bworkflow_run\b/,
@@ -233,6 +249,7 @@ const CLASSES = [
233
249
  /\bcurl\b[^|\n]*\|\s*(?:ba|z)?sh\b|\bwget\b[^|\n]*\|\s*(?:ba|z)?sh\b/,
234
250
  /\$\{\{\s*github\.event\.(?:issue|pull_request|comment|head_commit|review)\b/,
235
251
  /\bsecrets\.[A-Z0-9_]+/,
252
+ /^\s*(?:gem|source)\s+['\"][^'\"]+['\"]\s*,\s*(?:git|github|path):|\.add_(?:runtime_)?dependency\b|\bspec\.(?:files|executables)\b/,
236
253
  ],
237
254
  guide: [
238
255
  'Invariant: code from a lower-trust contributor or dependency cannot run with release credentials, change what ships, or execute on a user\'s machine at install time.',
@@ -277,6 +294,7 @@ const CANDIDATE_GATE = [
277
294
  'Propose "confirmed" only when the trace is complete, the impact crosses a stated boundary, every condition is listed, and no layer in source prevents it.',
278
295
  'Do not strengthen a crash into code execution, ordinary work into an outage, or a same-person action into privilege gain.',
279
296
  'If a decisive fact is outside source (deployment, proxy, provider setting, who can write a file), propose "needs_validation" with the exact missing fact. It gets no severity.',
297
+ 'Before calling a library version or behavior unknown, read what is installed in the target: lockfiles, venv or .venv site-packages, node_modules, vendor folders. A fact on disk is not outside source.',
280
298
  'A missing best practice with no affected principal or resource goes in "hardening", not in candidates. A candidate disproved by source is dropped.',
281
299
  'The fingerprint names the root cause, like "lib/foo.js:runHook/shell-from-task-title". No line numbers, no severity, no verdict in it.',
282
300
  'Return an empty candidates array when nothing survives these rules. Most units should end "covered".',
@@ -761,6 +761,7 @@ function verifierPrompt({ candidate, cls }) {
761
761
  'Ask who controls the input. If it is the same person who runs the tool, it is self-impact, so reject it.',
762
762
  '"confirmed": the full trace holds, the impact crosses the stated boundary, and nothing in source prevents it. Fix the severity if it is overstated; overall can never exceed impact.',
763
763
  '"needs_validation": the trace holds in source, but a decisive fact is outside it. Name that fact exactly and give a safe check the owner can run.',
764
+ 'A library version or behavior is not outside source when the target has it installed. Read the lockfile, the venv or .venv site-packages, or node_modules before sending it to the owner.',
764
765
  '"rejected": the source disproves it. Name the line that does.',
765
766
  'Use "corrected" only for fields you would change. Read-only: do not run the target or touch the network.',
766
767
  ]),
@@ -1163,6 +1164,17 @@ function resolveOutDir(root, { out, repo }) {
1163
1164
  return { dir: path.join(outRoot, `run-${next}`), outRoot };
1164
1165
  }
1165
1166
 
1167
+ // Same function in the fingerprint and sinks within a few lines of each other:
1168
+ // one defect, even when two hunters filed it under different classes.
1169
+ function sameRootCause(a, b) {
1170
+ const scope = (fp) => String(fp || '').split('/').slice(0, -1).join('/');
1171
+ const sink = (c) => (Array.isArray(c.trace) && c.trace.length ? c.trace[c.trace.length - 1] : null);
1172
+ const sa = sink(a);
1173
+ const sb = sink(b);
1174
+ if (!sa || !sb || !scope(a.fingerprint) || scope(a.fingerprint) !== scope(b.fingerprint)) return false;
1175
+ return sa.file === sb.file && Math.abs(Number(sa.line) - Number(sb.line)) <= 3;
1176
+ }
1177
+
1166
1178
  function budgetPlan(profile, total) {
1167
1179
  const critics = profile === 'quick' ? 1 : 2;
1168
1180
  const verifyReserve = Math.max(1, Math.ceil(0.3 * (total - critics)));
@@ -1256,7 +1268,11 @@ async function runAudit(opts = {}) {
1256
1268
 
1257
1269
  const plan = budgetPlan(profile, total);
1258
1270
  const plannedCount = ledger.filter((u) => u.status === 'planned').length;
1259
- meta.plan = { ...plan, planned_units: plannedCount, units_per_hunter: UNITS_PER_HUNTER[profile], hunters_needed: Math.ceil(plannedCount / UNITS_PER_HUNTER[profile]) };
1271
+ // Hunters take one class in one subsystem at a time, so count batches per group.
1272
+ const groupSizes = new Map();
1273
+ for (const u of ledger) if (u.status === 'planned') { const k = `${u.attack_class}\0${u.subsystem}`; groupSizes.set(k, (groupSizes.get(k) || 0) + 1); }
1274
+ const huntersNeeded = [...groupSizes.values()].reduce((n, size) => n + Math.ceil(size / UNITS_PER_HUNTER[profile]), 0);
1275
+ meta.plan = { ...plan, planned_units: plannedCount, units_per_hunter: UNITS_PER_HUNTER[profile], hunters_needed: huntersNeeded };
1260
1276
 
1261
1277
  const pendingRecords = [];
1262
1278
  const finish = (status, reason, records = [], carriedPending = []) => {
@@ -1378,9 +1394,23 @@ async function runAudit(opts = {}) {
1378
1394
  const pending = [];
1379
1395
  const toVerify = [];
1380
1396
  const seenFp = new Set();
1397
+ const kept = [];
1381
1398
  for (const cand of queue) {
1382
1399
  if (seenFp.has(cand.fingerprint)) continue;
1383
1400
  seenFp.add(cand.fingerprint);
1401
+ const twin = !cand._carried && kept.find((k) => !k._carried && sameRootCause(k, cand));
1402
+ if (twin) {
1403
+ // Two hunters named one root cause from different classes. Check it once
1404
+ // and point the twin's areas at the surviving fingerprint.
1405
+ twin.coverage_ids = [...new Set([...(twin.coverage_ids || []), ...(cand.coverage_ids || [])])];
1406
+ for (const u of ledger) {
1407
+ if (Array.isArray(u.result_fingerprints) && u.result_fingerprints.includes(cand.fingerprint)) {
1408
+ u.result_fingerprints = [...new Set(u.result_fingerprints.map((fp) => (fp === cand.fingerprint ? twin.fingerprint : fp)))];
1409
+ }
1410
+ }
1411
+ continue;
1412
+ }
1413
+ kept.push(cand);
1384
1414
  if (!cand.attack_class || !CLASS_BY_ID.has(cand.attack_class)) cand.attack_class = cand._unit_class || 'command-injection';
1385
1415
  const errs = citationErrors(root, cand);
1386
1416
  if (errs.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.80.0",
3
+ "version": "3.80.1",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",
@@ -91,7 +91,14 @@
91
91
  "developer-tools",
92
92
  "cli",
93
93
  "codebase-navigation",
94
- "ai-agents"
94
+ "ai-agents",
95
+ "agent-memory",
96
+ "ai-memory",
97
+ "llm-memory",
98
+ "project-memory",
99
+ "context-graph",
100
+ "knowledge-graph",
101
+ "memory"
95
102
  ],
96
103
  "author": "Keshav Rao (atrislabs)",
97
104
  "license": "MIT",
package/utils/auth.js CHANGED
@@ -40,9 +40,11 @@ let inputIndex = 0;
40
40
  /**
41
41
  * Prompt the user for input, handling both TTY and piped input.
42
42
  * @param {string} question - Prompt text to display
43
+ * @param {{secret?: boolean}} [opts] - secret: never print the answer back
43
44
  * @returns {Promise<string>} User's input
44
45
  */
45
- function promptUser(question) {
46
+ function promptUser(question, opts = {}) {
47
+ const shown = (answer) => (opts.secret ? '' : answer);
46
48
  // If stdin is not a TTY (piped input), read all lines upfront
47
49
  if (!process.stdin.isTTY && inputLines.length === 0 && !sharedRl) {
48
50
  return new Promise((resolve) => {
@@ -57,7 +59,7 @@ function promptUser(question) {
57
59
  inputLines = data.trim().split('\n');
58
60
  process.stdout.write(question);
59
61
  const answer = inputLines[inputIndex++] || '';
60
- console.log(answer);
62
+ console.log(shown(answer));
61
63
  resolve(answer.trim());
62
64
  });
63
65
  process.stdin.resume();
@@ -69,22 +71,34 @@ function promptUser(question) {
69
71
  return new Promise((resolve) => {
70
72
  process.stdout.write(question);
71
73
  const answer = inputLines[inputIndex++] || '';
72
- console.log(answer);
74
+ console.log(shown(answer));
73
75
  resolve(answer.trim());
74
76
  });
75
77
  }
76
78
 
77
79
  // Interactive TTY mode - create readline per prompt
80
+ // A secret typed at a terminal must stay hidden even when stdout is a file:
81
+ // terminal mode turns off the terminal's own echo, and the prompt goes to
82
+ // whichever stream is still the screen.
83
+ const secretOut = opts.secret && !process.stdout.isTTY && process.stderr.isTTY ? process.stderr : process.stdout;
78
84
  const rl = readline.createInterface({
79
85
  input: process.stdin,
80
- output: process.stdout
86
+ output: secretOut,
87
+ ...(opts.secret ? { terminal: Boolean(process.stdin.isTTY) } : {}),
81
88
  });
82
89
 
90
+ // Secret answers: let the prompt through, then swallow the typed echo.
91
+ let muted = false;
92
+ if (opts.secret) {
93
+ rl._writeToOutput = (text) => { if (!muted) rl.output.write(text); };
94
+ }
83
95
  return new Promise((resolve) => {
84
96
  rl.question(question, (answer) => {
97
+ if (muted) rl.output.write('\n');
85
98
  rl.close();
86
99
  resolve(answer.trim());
87
100
  });
101
+ muted = Boolean(opts.secret);
88
102
  });
89
103
  }
90
104