@safetnsr/vet 0.2.1 → 0.4.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
@@ -5,9 +5,11 @@ import { isGitRepo, readFile, c } from './util.js';
5
5
  import { checkReady } from './checks/ready.js';
6
6
  import { checkDiff } from './checks/diff.js';
7
7
  import { checkModels } from './checks/models.js';
8
- import { checkLinks } from './checks/links.js';
9
8
  import { checkConfig } from './checks/config.js';
10
9
  import { checkHistory } from './checks/history.js';
10
+ import { checkScan } from './checks/scan.js';
11
+ import { checkSecrets } from './checks/secrets.js';
12
+ import { checkReceipt, runReceiptCommand } from './checks/receipt.js';
11
13
  import { score } from './scorer.js';
12
14
  import { reportPretty, reportJSON } from './reporter.js';
13
15
  const args = process.argv.slice(2);
@@ -35,10 +37,21 @@ if (flags.has('--help') || flags.has('-h')) {
35
37
  npx @safetnsr/vet --since HEAD~5 check specific commit range
36
38
  npx @safetnsr/vet --watch live monitoring during AI sessions
37
39
  npx @safetnsr/vet init generate configs + hooks
40
+ npx @safetnsr/vet receipt show last agent session receipt
41
+
42
+ ${c.dim}checks:${c.reset}
43
+ ready codebase readiness for AI agents
44
+ diff AI-specific anti-patterns in recent changes
45
+ models deprecated/risky model usage
46
+ config agent config hygiene
47
+ history git history quality
48
+ scan malicious patterns in agent config files
49
+ secrets leaked secrets in build output and .env files
50
+ receipt last agent session audit (informational)
38
51
 
39
52
  ${c.dim}options:${c.reset}
40
53
  --ci CI mode (exit 1 if score < threshold)
41
- --fix auto-fix configs, models, links
54
+ --fix auto-fix configs, models
42
55
  --since REF diff against specific commit/range
43
56
  --watch re-run on file changes
44
57
  --json JSON output
@@ -54,11 +67,11 @@ if (flags.has('--version') || flags.has('-v')) {
54
67
  console.log(pkg.version);
55
68
  }
56
69
  catch {
57
- console.log('0.2.0');
70
+ console.log('0.3.0');
58
71
  }
59
72
  process.exit(0);
60
73
  }
61
- const COMMANDS = ['init'];
74
+ const COMMANDS = ['init', 'receipt'];
62
75
  const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
63
76
  const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
64
77
  const isCI = flags.has('--ci');
@@ -81,6 +94,11 @@ if (command === 'init') {
81
94
  await init(cwd);
82
95
  process.exit(0);
83
96
  }
97
+ if (command === 'receipt') {
98
+ const format = isJSON ? 'json' : 'ascii';
99
+ await runReceiptCommand(format);
100
+ process.exit(0);
101
+ }
84
102
  if (!isGitRepo(cwd)) {
85
103
  console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
86
104
  process.exit(1);
@@ -90,12 +108,10 @@ if (isFix) {
90
108
  console.log(`\n ${c.bold}vet --fix${c.reset}\n`);
91
109
  const { fixConfig } = await import('./fix/config.js');
92
110
  const { fixModels } = await import('./fix/models.js');
93
- const { fixLinks } = await import('./fix/links.js');
94
111
  const configResult = fixConfig(cwd);
95
112
  const modelsResult = fixModels(cwd, ignore);
96
- const linksResult = fixLinks(cwd, ignore);
97
- const allMessages = [...configResult.messages, ...modelsResult.messages, ...linksResult.messages];
98
- const totalFixed = configResult.fixed + modelsResult.fixed + linksResult.fixed;
113
+ const allMessages = [...configResult.messages, ...modelsResult.messages];
114
+ const totalFixed = configResult.fixed + modelsResult.fixed;
99
115
  if (allMessages.length > 0) {
100
116
  for (const msg of allMessages)
101
117
  console.log(msg);
@@ -104,7 +120,7 @@ if (isFix) {
104
120
  process.exit(0);
105
121
  }
106
122
  async function runChecks() {
107
- const allChecks = ['ready', 'diff', 'models', 'links', 'config', 'history'];
123
+ const allChecks = ['ready', 'diff', 'models', 'config', 'history', 'scan', 'secrets', 'receipt'];
108
124
  const enabledChecks = config.checks || allChecks;
109
125
  const results = [];
110
126
  // ready and models are async (try rich subpackages first, fallback to built-in)
@@ -114,12 +130,16 @@ async function runChecks() {
114
130
  results.push(checkDiff(cwd, { since }));
115
131
  if (enabledChecks.includes('models'))
116
132
  results.push(await checkModels(cwd, ignore));
117
- if (enabledChecks.includes('links'))
118
- results.push(checkLinks(cwd, ignore));
119
133
  if (enabledChecks.includes('config'))
120
134
  results.push(checkConfig(cwd, ignore));
121
135
  if (enabledChecks.includes('history'))
122
136
  results.push(checkHistory(cwd));
137
+ if (enabledChecks.includes('scan'))
138
+ results.push(checkScan(cwd));
139
+ if (enabledChecks.includes('secrets'))
140
+ results.push(await checkSecrets(cwd));
141
+ if (enabledChecks.includes('receipt'))
142
+ results.push(await checkReceipt(cwd));
123
143
  return score(cwd, results);
124
144
  }
125
145
  // --watch mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",