@safetnsr/vet 0.2.0 → 0.3.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.
@@ -0,0 +1,280 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, createReadStream } from 'node:fs';
2
+ import { join, relative, extname, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { execSync } from 'node:child_process';
5
+ import { createInterface } from 'node:readline';
6
+ // ── Shannon entropy ──────────────────────────────────────────────────────────
7
+ function calculateEntropy(str) {
8
+ if (str.length === 0)
9
+ return 0;
10
+ const freq = {};
11
+ for (const ch of str)
12
+ freq[ch] = (freq[ch] ?? 0) + 1;
13
+ let entropy = 0;
14
+ const len = str.length;
15
+ for (const count of Object.values(freq)) {
16
+ const p = count / len;
17
+ entropy -= p * Math.log2(p);
18
+ }
19
+ return entropy;
20
+ }
21
+ function isHighEntropy(str) {
22
+ if (str.length < 20)
23
+ return false;
24
+ if (!/^[a-zA-Z0-9+/=_-]+$/.test(str))
25
+ return false;
26
+ return calculateEntropy(str) > 4.5;
27
+ }
28
+ const LEAK_PATTERNS = [
29
+ { name: 'Google API Key', regex: /AIza[0-9A-Za-z_-]{35}/g, severity: 'high' },
30
+ { name: 'Stripe Secret Key', regex: /sk_live_[0-9a-zA-Z]{24,}/g, severity: 'critical' },
31
+ { name: 'Stripe Publishable Key', regex: /pk_live_[0-9a-zA-Z]{24,}/g, severity: 'info' },
32
+ { name: 'Anthropic API Key', regex: /sk-ant-[a-zA-Z0-9_-]{40,}/g, severity: 'critical' },
33
+ { name: 'OpenAI API Key', regex: /sk-(?!live_)[a-zA-Z0-9]{20,}/g, severity: 'critical' },
34
+ { name: 'GitHub Token (ghp)', regex: /ghp_[a-zA-Z0-9]{36}/g, severity: 'critical' },
35
+ { name: 'GitHub Token (gho)', regex: /gho_[a-zA-Z0-9]{36}/g, severity: 'critical' },
36
+ { name: 'GitHub Token (ghu)', regex: /ghu_[a-zA-Z0-9]{36}/g, severity: 'critical' },
37
+ { name: 'GitHub Token (ghs)', regex: /ghs_[a-zA-Z0-9]{36}/g, severity: 'critical' },
38
+ { name: 'GitHub Token (ghr)', regex: /ghr_[a-zA-Z0-9]{36}/g, severity: 'critical' },
39
+ { name: 'AWS Access Key', regex: /AKIA[0-9A-Z]{16}/g, severity: 'critical' },
40
+ { name: 'JWT Token', regex: /eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+/g, severity: 'medium' },
41
+ { name: 'Private Key', regex: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g, severity: 'critical' },
42
+ { name: 'MongoDB Connection String', regex: /mongodb:\/\/[^\s'"]+/g, severity: 'high' },
43
+ { name: 'Postgres Connection String', regex: /postgres:\/\/[^\s'"]+/g, severity: 'high' },
44
+ { name: 'MySQL Connection String', regex: /mysql:\/\/[^\s'"]+/g, severity: 'high' },
45
+ { name: 'Redis Connection String', regex: /redis:\/\/[^\s'"]+/g, severity: 'high' },
46
+ { name: 'Slack Token', regex: /xox[bpsa]-[a-zA-Z0-9-]+/g, severity: 'critical' },
47
+ { name: 'Twilio API Key', regex: /SK[a-f0-9]{32}/g, severity: 'high' },
48
+ { name: 'SendGrid API Key', regex: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/g, severity: 'high' },
49
+ ];
50
+ const ENV_PATTERNS = [
51
+ { provider: 'Anthropic', pattern: /sk-ant-[a-zA-Z0-9_-]{20,}/, envVars: ['ANTHROPIC_API_KEY'], costEstimate: '$20-500/mo' },
52
+ { provider: 'OpenAI', pattern: /sk-proj-[a-zA-Z0-9_-]{20,}/, envVars: ['OPENAI_API_KEY'], costEstimate: '$20-1000/mo' },
53
+ { provider: 'Google AI', pattern: /AIza[a-zA-Z0-9_-]{35}/, envVars: ['GOOGLE_AI_API_KEY', 'GEMINI_API_KEY'], costEstimate: '$10-500/mo' },
54
+ { provider: 'Replicate', pattern: /r8_[a-zA-Z0-9]{37}/, envVars: ['REPLICATE_API_TOKEN'], costEstimate: '$5-200/mo' },
55
+ { provider: 'HuggingFace', pattern: /hf_[a-zA-Z0-9]{34}/, envVars: ['HF_TOKEN', 'HUGGINGFACE_API_KEY'], costEstimate: '$0-100/mo' },
56
+ { provider: 'Groq', pattern: /gsk_[a-zA-Z0-9]{48,}/, envVars: ['GROQ_API_KEY'], costEstimate: '$0-50/mo' },
57
+ { provider: 'Fireworks', pattern: /fw_[a-zA-Z0-9]{30,}/, envVars: ['FIREWORKS_API_KEY'], costEstimate: '$5-200/mo' },
58
+ { provider: 'DeepSeek', pattern: /sk-[a-f0-9]{32,}/, envVars: ['DEEPSEEK_API_KEY'], costEstimate: '$5-100/mo' },
59
+ ];
60
+ const ENV_ONLY_PROVIDERS = new Set(['Cohere', 'Mistral', 'Together']);
61
+ function detectEnvKeys(line) {
62
+ const matches = [];
63
+ const seen = new Set();
64
+ for (const p of ENV_PATTERNS) {
65
+ if (p.envVars) {
66
+ for (const envVar of p.envVars) {
67
+ const envRe = new RegExp(`${envVar}\\s*=\\s*["']?([^"'\\s#]+)["']?`);
68
+ const m = line.match(envRe);
69
+ if (m && m[1] && m[1].length >= 8 && !seen.has(m[1])) {
70
+ seen.add(m[1]);
71
+ matches.push({ provider: p.provider, key: m[1], costEstimate: p.costEstimate });
72
+ }
73
+ }
74
+ }
75
+ if (ENV_ONLY_PROVIDERS.has(p.provider))
76
+ continue;
77
+ const m = line.match(p.pattern);
78
+ if (m && m[0] && !seen.has(m[0])) {
79
+ seen.add(m[0]);
80
+ matches.push({ provider: p.provider, key: m[0], costEstimate: p.costEstimate });
81
+ }
82
+ }
83
+ return matches;
84
+ }
85
+ function isGitTracked(filePath) {
86
+ try {
87
+ const result = execSync(`git ls-files --error-unmatch "${filePath}"`, {
88
+ cwd: dirname(filePath),
89
+ stdio: ['pipe', 'pipe', 'pipe'],
90
+ encoding: 'utf-8',
91
+ });
92
+ return result.trim().length > 0;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ function maskKey(key) {
99
+ if (key.length <= 8)
100
+ return '****';
101
+ return key.slice(0, 4) + '…' + key.slice(-4);
102
+ }
103
+ // ── Build output scanning ────────────────────────────────────────────────────
104
+ const SCANNABLE_EXTS = new Set(['.js', '.mjs', '.cjs', '.css', '.html', '.json', '.map']);
105
+ const SKIP_EXTS = new Set([
106
+ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp',
107
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
108
+ '.wasm', '.zip', '.gz', '.br',
109
+ '.mp4', '.mp3', '.wav', '.webm', '.pdf',
110
+ ]);
111
+ const BUILD_DIRS = ['dist', 'build', '.next', 'out', 'public'];
112
+ function detectBuildDir(cwd) {
113
+ for (const candidate of BUILD_DIRS) {
114
+ const full = join(cwd, candidate);
115
+ if (existsSync(full) && statSync(full).isDirectory())
116
+ return full;
117
+ }
118
+ return null;
119
+ }
120
+ function walkBuild(dir) {
121
+ const results = [];
122
+ try {
123
+ const entries = readdirSync(dir, { withFileTypes: true });
124
+ for (const entry of entries) {
125
+ const full = join(dir, entry.name);
126
+ if (entry.isDirectory()) {
127
+ if (entry.name === 'node_modules')
128
+ continue;
129
+ results.push(...walkBuild(full));
130
+ }
131
+ else {
132
+ results.push(full);
133
+ }
134
+ }
135
+ }
136
+ catch { /* skip */ }
137
+ return results;
138
+ }
139
+ function shouldScan(filePath) {
140
+ const ext = extname(filePath).toLowerCase();
141
+ if (SKIP_EXTS.has(ext))
142
+ return false;
143
+ return SCANNABLE_EXTS.has(ext);
144
+ }
145
+ async function scanBuildFile(filePath) {
146
+ const findings = [];
147
+ const ext = extname(filePath).toLowerCase();
148
+ if (ext === '.map') {
149
+ findings.push({ name: 'Source Map', severity: 'medium', preview: 'Source map exposes original source code', line: 0 });
150
+ return findings;
151
+ }
152
+ const rl = createInterface({ input: createReadStream(filePath, { encoding: 'utf8' }), crlfDelay: Infinity });
153
+ let lineNumber = 0;
154
+ for await (const lineText of rl) {
155
+ lineNumber++;
156
+ for (const pattern of LEAK_PATTERNS) {
157
+ pattern.regex.lastIndex = 0;
158
+ let m;
159
+ while ((m = pattern.regex.exec(lineText)) !== null) {
160
+ const masked = lineText.replace(m[0], m[0].slice(0, 4) + '****' + m[0].slice(-4));
161
+ findings.push({ name: pattern.name, severity: pattern.severity, preview: masked.slice(0, 120), line: lineNumber });
162
+ if (m[0].length === 0) {
163
+ pattern.regex.lastIndex++;
164
+ break;
165
+ }
166
+ }
167
+ pattern.regex.lastIndex = 0;
168
+ }
169
+ }
170
+ return findings;
171
+ }
172
+ // ── Dev environment scanning ─────────────────────────────────────────────────
173
+ function findEnvFiles(dir, maxDepth = 3, depth = 0) {
174
+ if (depth > maxDepth)
175
+ return [];
176
+ const files = [];
177
+ try {
178
+ const entries = readdirSync(dir, { withFileTypes: true });
179
+ for (const entry of entries) {
180
+ const full = join(dir, entry.name);
181
+ if (entry.isFile()) {
182
+ if (entry.name === '.env' || entry.name.startsWith('.env.') || entry.name.endsWith('.env')) {
183
+ files.push(full);
184
+ }
185
+ }
186
+ else if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
187
+ files.push(...findEnvFiles(full, maxDepth, depth + 1));
188
+ }
189
+ }
190
+ }
191
+ catch { /* skip */ }
192
+ return files;
193
+ }
194
+ function scanEnvFile(filePath) {
195
+ const findings = [];
196
+ try {
197
+ const lines = readFileSync(filePath, 'utf-8').split('\n');
198
+ const gitTracked = isGitTracked(filePath);
199
+ for (const line of lines) {
200
+ if (line.trimStart().startsWith('#'))
201
+ continue;
202
+ for (const m of detectEnvKeys(line)) {
203
+ findings.push({ ...m, gitTracked, file: filePath });
204
+ }
205
+ }
206
+ }
207
+ catch { /* skip */ }
208
+ return findings;
209
+ }
210
+ // ── Main check ───────────────────────────────────────────────────────────────
211
+ export async function checkSecrets(cwd) {
212
+ const issues = [];
213
+ // 1. Build output scan
214
+ const buildDir = detectBuildDir(cwd);
215
+ if (buildDir) {
216
+ const buildFiles = walkBuild(buildDir).filter(f => shouldScan(f));
217
+ for (const file of buildFiles) {
218
+ try {
219
+ const findings = await scanBuildFile(file);
220
+ for (const f of findings) {
221
+ issues.push({
222
+ severity: f.severity === 'critical' ? 'error' : f.severity === 'high' ? 'warning' : 'info',
223
+ message: `[build] ${f.name}: ${f.preview}`,
224
+ file: relative(cwd, file),
225
+ line: f.line || undefined,
226
+ fixable: false,
227
+ });
228
+ }
229
+ }
230
+ catch { /* skip */ }
231
+ }
232
+ }
233
+ // 2. .env files in project
234
+ const envFiles = findEnvFiles(cwd);
235
+ for (const envFile of envFiles) {
236
+ const findings = scanEnvFile(envFile);
237
+ for (const f of findings) {
238
+ const relPath = relative(cwd, f.file);
239
+ const severity = f.gitTracked ? 'error' : 'warning';
240
+ issues.push({
241
+ severity,
242
+ message: `[env] ${f.provider} key in ${relPath}${f.gitTracked ? ' (git-tracked!)' : ''} — ${maskKey(f.key)} (${f.costEstimate})`,
243
+ file: relPath,
244
+ fixable: false,
245
+ fixHint: f.gitTracked ? 'remove from git: git rm --cached ' + relPath : undefined,
246
+ });
247
+ }
248
+ }
249
+ // 3. Home dotfiles (shell history, rc files)
250
+ const home = homedir();
251
+ const dotfiles = ['.bashrc', '.zshrc', '.bash_profile', '.profile', '.zprofile'];
252
+ for (const name of dotfiles) {
253
+ const fp = join(home, name);
254
+ if (!existsSync(fp))
255
+ continue;
256
+ const findings = scanEnvFile(fp);
257
+ for (const f of findings) {
258
+ issues.push({
259
+ severity: 'warning',
260
+ message: `[home] ${f.provider} key in ~/${name} — ${maskKey(f.key)} (${f.costEstimate})`,
261
+ file: `~/${name}`,
262
+ fixable: false,
263
+ });
264
+ }
265
+ }
266
+ // Score: each critical issue = -3, warning = -1
267
+ const errors = issues.filter(i => i.severity === 'error').length;
268
+ const warnings = issues.filter(i => i.severity === 'warning').length;
269
+ const score = Math.max(0, Math.min(10, 10 - errors * 3 - warnings * 1));
270
+ const buildNote = buildDir ? '' : ' (no build dir found)';
271
+ return {
272
+ name: 'secrets',
273
+ score: Math.round(score * 10) / 10,
274
+ maxScore: 10,
275
+ issues,
276
+ summary: issues.length === 0
277
+ ? `no leaked secrets detected${buildNote}`
278
+ : `${issues.length} secret${issues.length !== 1 ? 's' : ''} found${buildNote}`,
279
+ };
280
+ }
package/dist/cli.js CHANGED
@@ -8,6 +8,10 @@ import { checkModels } from './checks/models.js';
8
8
  import { checkLinks } from './checks/links.js';
9
9
  import { checkConfig } from './checks/config.js';
10
10
  import { checkHistory } from './checks/history.js';
11
+ import { checkScan } from './checks/scan.js';
12
+ import { checkSecrets } from './checks/secrets.js';
13
+ import { checkReceipt, runReceiptCommand } from './checks/receipt.js';
14
+ import { checkEdge, runEdgeCommand } from './checks/edge.js';
11
15
  import { score } from './scorer.js';
12
16
  import { reportPretty, reportJSON } from './reporter.js';
13
17
  const args = process.argv.slice(2);
@@ -35,6 +39,20 @@ if (flags.has('--help') || flags.has('-h')) {
35
39
  npx @safetnsr/vet --since HEAD~5 check specific commit range
36
40
  npx @safetnsr/vet --watch live monitoring during AI sessions
37
41
  npx @safetnsr/vet init generate configs + hooks
42
+ npx @safetnsr/vet receipt show last agent session receipt
43
+ npx @safetnsr/vet edge show human-edge score for git history
44
+
45
+ ${c.dim}checks:${c.reset}
46
+ ready codebase readiness for AI agents
47
+ diff AI-specific anti-patterns in recent changes
48
+ models deprecated/risky model usage
49
+ links dead markdown links
50
+ config agent config hygiene
51
+ history git history quality
52
+ scan malicious patterns in agent config files
53
+ secrets leaked secrets in build output and .env files
54
+ receipt last agent session audit (informational)
55
+ edge human replaceability score from git history
38
56
 
39
57
  ${c.dim}options:${c.reset}
40
58
  --ci CI mode (exit 1 if score < threshold)
@@ -43,6 +61,7 @@ if (flags.has('--help') || flags.has('-h')) {
43
61
  --watch re-run on file changes
44
62
  --json JSON output
45
63
  --pretty force pretty output (even in pipes)
64
+ --explain show detailed reasoning (edge subcommand)
46
65
  -h, --help show this help
47
66
  -v, --version show version
48
67
  `);
@@ -54,11 +73,11 @@ if (flags.has('--version') || flags.has('-v')) {
54
73
  console.log(pkg.version);
55
74
  }
56
75
  catch {
57
- console.log('0.2.0');
76
+ console.log('0.3.0');
58
77
  }
59
78
  process.exit(0);
60
79
  }
61
- const COMMANDS = ['init'];
80
+ const COMMANDS = ['init', 'receipt', 'edge'];
62
81
  const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
63
82
  const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
64
83
  const isCI = flags.has('--ci');
@@ -81,6 +100,16 @@ if (command === 'init') {
81
100
  await init(cwd);
82
101
  process.exit(0);
83
102
  }
103
+ if (command === 'receipt') {
104
+ const format = isJSON ? 'json' : 'ascii';
105
+ await runReceiptCommand(format);
106
+ process.exit(0);
107
+ }
108
+ if (command === 'edge') {
109
+ const explain = flags.has('--explain');
110
+ runEdgeCommand(cwd, explain);
111
+ process.exit(0);
112
+ }
84
113
  if (!isGitRepo(cwd)) {
85
114
  console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
86
115
  process.exit(1);
@@ -103,28 +132,37 @@ if (isFix) {
103
132
  console.log(`\n ${totalFixed > 0 ? c.green : c.dim}fixed ${totalFixed} issue${totalFixed !== 1 ? 's' : ''}${c.reset}\n`);
104
133
  process.exit(0);
105
134
  }
106
- function runChecks() {
107
- const allChecks = ['ready', 'diff', 'models', 'links', 'config', 'history'];
135
+ async function runChecks() {
136
+ const allChecks = ['ready', 'diff', 'models', 'links', 'config', 'history', 'scan', 'secrets', 'receipt', 'edge'];
108
137
  const enabledChecks = config.checks || allChecks;
109
138
  const results = [];
139
+ // ready and models are async (try rich subpackages first, fallback to built-in)
110
140
  if (enabledChecks.includes('ready'))
111
- results.push(checkReady(cwd, ignore));
141
+ results.push(await checkReady(cwd, ignore));
112
142
  if (enabledChecks.includes('diff'))
113
143
  results.push(checkDiff(cwd, { since }));
114
144
  if (enabledChecks.includes('models'))
115
- results.push(checkModels(cwd, ignore));
145
+ results.push(await checkModels(cwd, ignore));
116
146
  if (enabledChecks.includes('links'))
117
147
  results.push(checkLinks(cwd, ignore));
118
148
  if (enabledChecks.includes('config'))
119
149
  results.push(checkConfig(cwd, ignore));
120
150
  if (enabledChecks.includes('history'))
121
151
  results.push(checkHistory(cwd));
152
+ if (enabledChecks.includes('scan'))
153
+ results.push(checkScan(cwd));
154
+ if (enabledChecks.includes('secrets'))
155
+ results.push(await checkSecrets(cwd));
156
+ if (enabledChecks.includes('receipt'))
157
+ results.push(await checkReceipt(cwd));
158
+ if (enabledChecks.includes('edge'))
159
+ results.push(checkEdge(cwd));
122
160
  return score(cwd, results);
123
161
  }
124
162
  // --watch mode
125
163
  if (isWatch) {
126
164
  console.clear();
127
- let result = runChecks();
165
+ let result = await runChecks();
128
166
  console.log(reportPretty(result));
129
167
  console.log(` ${c.dim}watching for changes... (ctrl+c to stop)${c.reset}\n`);
130
168
  let debounce = null;
@@ -137,9 +175,9 @@ if (isWatch) {
137
175
  return;
138
176
  if (debounce)
139
177
  clearTimeout(debounce);
140
- debounce = setTimeout(() => {
178
+ debounce = setTimeout(async () => {
141
179
  console.clear();
142
- result = runChecks();
180
+ result = await runChecks();
143
181
  console.log(reportPretty(result));
144
182
  console.log(` ${c.dim}watching for changes... (ctrl+c to stop)${c.reset}\n`);
145
183
  }, 500);
@@ -156,7 +194,7 @@ if (isWatch) {
156
194
  }
157
195
  else {
158
196
  // Normal run
159
- const result = runChecks();
197
+ const result = await runChecks();
160
198
  if (isJSON) {
161
199
  console.log(reportJSON(result));
162
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "vet your AI-generated code — one command, six checks, zero config",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "build": "tsc",
15
- "dev": "tsx src/cli.ts"
15
+ "dev": "tsx src/cli.ts",
16
+ "test": "node --import tsx/esm --test 'test/*.test.mjs'"
16
17
  },
17
18
  "keywords": [
18
19
  "ai",
@@ -38,5 +39,8 @@
38
39
  },
39
40
  "engines": {
40
41
  "node": ">=18"
42
+ },
43
+ "optionalDependencies": {
44
+ "@safetnsr/model-graveyard": "^0.2.0"
41
45
  }
42
46
  }