@safetnsr/vet 1.6.1 → 1.8.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/dist/categories.d.ts +2 -0
- package/dist/categories.js +10 -1
- package/dist/checks/integrity.js +44 -4
- package/dist/checks/models.js +115 -12
- package/dist/checks/ready.js +33 -3
- package/dist/checks/scan.js +27 -8
- package/dist/checks/verify.js +68 -4
- package/package.json +1 -1
package/dist/categories.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { CheckResult, CategoryResult, VetResult } from './types.js';
|
|
2
2
|
export declare function toGrade(score: number): string;
|
|
3
|
+
/** Apply a floor of 20 to non-security checks that have no security-related errors */
|
|
4
|
+
export declare function applyScoreFloor(check: CheckResult): number;
|
|
3
5
|
export declare function buildCategories(checkMap: {
|
|
4
6
|
security: CheckResult[];
|
|
5
7
|
integrity: CheckResult[];
|
package/dist/categories.js
CHANGED
|
@@ -21,11 +21,20 @@ const WEIGHTS = {
|
|
|
21
21
|
debt: 0.25,
|
|
22
22
|
deps: 0.15,
|
|
23
23
|
};
|
|
24
|
+
// ── Scoring floor for non-security checks ────────────────────────────────────
|
|
25
|
+
const SECURITY_CHECKS = new Set(['scan', 'secrets', 'permissions', 'owasp']);
|
|
26
|
+
/** Apply a floor of 20 to non-security checks that have no security-related errors */
|
|
27
|
+
export function applyScoreFloor(check) {
|
|
28
|
+
if (SECURITY_CHECKS.has(check.name))
|
|
29
|
+
return check.score;
|
|
30
|
+
// Non-security check: minimum score is 20
|
|
31
|
+
return Math.max(20, check.score);
|
|
32
|
+
}
|
|
24
33
|
// ── Average scores within a category ────────────────────────────────────────
|
|
25
34
|
function averageScore(checks) {
|
|
26
35
|
if (checks.length === 0)
|
|
27
36
|
return 100;
|
|
28
|
-
const total = checks.reduce((sum, c) => sum + c
|
|
37
|
+
const total = checks.reduce((sum, c) => sum + applyScoreFloor(c), 0);
|
|
29
38
|
return Math.round(total / checks.length);
|
|
30
39
|
}
|
|
31
40
|
// ── Group checks into categories ─────────────────────────────────────────────
|
package/dist/checks/integrity.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join, resolve, dirname, extname } from 'node:path';
|
|
1
|
+
import { join, resolve, dirname, extname, basename } from 'node:path';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { walkFiles, readFile } from '../util.js';
|
|
4
4
|
// ── Hallucinated imports ─────────────────────────────────────────────────────
|
|
@@ -277,6 +277,38 @@ function checkStubbedTests(cwd, files) {
|
|
|
277
277
|
return issues;
|
|
278
278
|
}
|
|
279
279
|
// ── Unhandled async (removed error handling) ─────────────────────────────────
|
|
280
|
+
/** Files that ARE error boundaries — they handle errors by design */
|
|
281
|
+
function isErrorBoundaryFile(file) {
|
|
282
|
+
const normalized = file.replace(/\\/g, '/');
|
|
283
|
+
const base = basename(normalized);
|
|
284
|
+
// Next.js error boundaries
|
|
285
|
+
if (/^error\.[jt]sx?$/.test(base))
|
|
286
|
+
return true;
|
|
287
|
+
if (/^global-error\.[jt]sx?$/.test(base))
|
|
288
|
+
return true;
|
|
289
|
+
// Middleware files
|
|
290
|
+
if (/^middleware\.[jt]sx?$/.test(base))
|
|
291
|
+
return true;
|
|
292
|
+
// Error handler files
|
|
293
|
+
if (/error[-_]?handler/i.test(base))
|
|
294
|
+
return true;
|
|
295
|
+
if (/error[-_]?boundary/i.test(base))
|
|
296
|
+
return true;
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
/** Check if a file has a top-level error handler (global catch-all) */
|
|
300
|
+
function hasGlobalErrorHandling(content) {
|
|
301
|
+
// process.on('unhandledRejection'/'uncaughtException')
|
|
302
|
+
if (/process\.on\s*\(\s*['"](?:unhandledRejection|uncaughtException)['"]/i.test(content))
|
|
303
|
+
return true;
|
|
304
|
+
// Express/Koa-style error middleware: (err, req, res, next) or app.use with 4 params
|
|
305
|
+
if (/(?:app|router)\.use\s*\(\s*(?:async\s*)?\(\s*\w+\s*,\s*\w+\s*,\s*\w+\s*,\s*\w+\s*\)/.test(content))
|
|
306
|
+
return true;
|
|
307
|
+
// window.addEventListener('error'/'unhandledrejection')
|
|
308
|
+
if (/addEventListener\s*\(\s*['"](?:error|unhandledrejection)['"]/i.test(content))
|
|
309
|
+
return true;
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
280
312
|
function checkUnhandledAsync(cwd, files) {
|
|
281
313
|
const issues = [];
|
|
282
314
|
const sourceExts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs']);
|
|
@@ -286,9 +318,15 @@ function checkUnhandledAsync(cwd, files) {
|
|
|
286
318
|
// Skip test files — test runners handle errors at the framework level
|
|
287
319
|
if (isTestFile(file))
|
|
288
320
|
continue;
|
|
321
|
+
// Skip error boundary files — they ARE the error handlers
|
|
322
|
+
if (isErrorBoundaryFile(file))
|
|
323
|
+
continue;
|
|
289
324
|
const content = readFile(join(cwd, file));
|
|
290
325
|
if (!content)
|
|
291
326
|
continue;
|
|
327
|
+
// Skip files with global error handling
|
|
328
|
+
if (hasGlobalErrorHandling(content))
|
|
329
|
+
continue;
|
|
292
330
|
const lines = content.split('\n');
|
|
293
331
|
let unhandledCount = 0;
|
|
294
332
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -299,7 +337,7 @@ function checkUnhandledAsync(cwd, files) {
|
|
|
299
337
|
if (!hasAwait)
|
|
300
338
|
continue;
|
|
301
339
|
// Check context window — look for try { in surrounding lines
|
|
302
|
-
const contextStart = Math.max(0, i -
|
|
340
|
+
const contextStart = Math.max(0, i - 20);
|
|
303
341
|
const contextEnd = Math.min(lines.length - 1, i + 5);
|
|
304
342
|
const contextLines = lines.slice(contextStart, contextEnd + 1);
|
|
305
343
|
const contextText = contextLines.join('\n');
|
|
@@ -307,9 +345,11 @@ function checkUnhandledAsync(cwd, files) {
|
|
|
307
345
|
const tryCount = (contextText.match(/\btry\s*\{/g) || []).length;
|
|
308
346
|
const catchCount = (contextText.match(/\bcatch\s*(?:\([^)]*\))?\s*\{/g) || []).length;
|
|
309
347
|
if (tryCount === 0 || catchCount === 0) {
|
|
310
|
-
//
|
|
348
|
+
// Check for .catch() chained on this or next line
|
|
311
349
|
const hasCatch = /\.catch\s*\(/.test(line) || (i + 1 < lines.length && /\.catch\s*\(/.test(lines[i + 1]));
|
|
312
|
-
|
|
350
|
+
// Check for .then(..., errorHandler) pattern
|
|
351
|
+
const hasThenError = /\.then\s*\([^,]+,\s*\w+/.test(line) || (i + 1 < lines.length && /\.then\s*\([^,]+,\s*\w+/.test(lines[i + 1]));
|
|
352
|
+
if (!hasCatch && !hasThenError) {
|
|
313
353
|
unhandledCount++;
|
|
314
354
|
if (unhandledCount <= 10) {
|
|
315
355
|
issues.push({
|
package/dist/checks/models.js
CHANGED
|
@@ -1,5 +1,79 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
1
|
+
import { join, basename } from 'node:path';
|
|
2
|
+
import { readdirSync, existsSync } from 'node:fs';
|
|
2
3
|
import { readFile, walkFiles } from '../util.js';
|
|
4
|
+
// ── AI framework detection ───────────────────────────────────────────────────
|
|
5
|
+
const AI_NAME_KEYWORDS = ['ai', 'llm', 'openai', 'anthropic', 'langchain', 'provider'];
|
|
6
|
+
const AI_PKG_KEYWORDS = new Set(['ai', 'llm', 'language-model', 'openai', 'anthropic']);
|
|
7
|
+
function isAiFramework(cwd) {
|
|
8
|
+
const aiDeps = ['openai', 'anthropic', 'langchain', 'transformers', 'torch', 'tensorflow', 'llama', 'huggingface'];
|
|
9
|
+
// Check package.json
|
|
10
|
+
const pkgRaw = readFile(join(cwd, 'package.json'));
|
|
11
|
+
if (pkgRaw) {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(pkgRaw);
|
|
14
|
+
const name = (pkg.name || '').toLowerCase();
|
|
15
|
+
if (AI_NAME_KEYWORDS.some(k => name.includes(k)))
|
|
16
|
+
return true;
|
|
17
|
+
if (Array.isArray(pkg.keywords) && pkg.keywords.some((k) => AI_PKG_KEYWORDS.has(k.toLowerCase())))
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch { /* skip */ }
|
|
21
|
+
}
|
|
22
|
+
// Check pyproject.toml / setup.py in root AND subdirectories
|
|
23
|
+
const pyprojectPaths = [join(cwd, 'pyproject.toml')];
|
|
24
|
+
try {
|
|
25
|
+
const entries = readdirSync(cwd);
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (entry.startsWith('.') || entry === 'node_modules')
|
|
28
|
+
continue;
|
|
29
|
+
const subPyproject = join(cwd, entry, 'pyproject.toml');
|
|
30
|
+
if (existsSync(subPyproject))
|
|
31
|
+
pyprojectPaths.push(subPyproject);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { /* skip */ }
|
|
35
|
+
for (const pyprojectPath of pyprojectPaths) {
|
|
36
|
+
const pyproject = readFile(pyprojectPath);
|
|
37
|
+
if (pyproject && aiDeps.some(d => pyproject.includes(d)))
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const setupPy = readFile(join(cwd, 'setup.py'));
|
|
41
|
+
if (setupPy && aiDeps.some(d => setupPy.includes(d)))
|
|
42
|
+
return true;
|
|
43
|
+
// Check directory name for AI keywords (use word-boundary-like matching with separators)
|
|
44
|
+
const dirName = basename(cwd).toLowerCase();
|
|
45
|
+
const dirParts = dirName.split(/[-_./\\]/);
|
|
46
|
+
const DIR_AI_KEYWORDS = ['ai', 'llm', 'openai', 'anthropic', 'langchain', 'pydantic-ai', 'autogen', 'crewai'];
|
|
47
|
+
if (DIR_AI_KEYWORDS.some(k => dirParts.includes(k) || dirName.includes(k)))
|
|
48
|
+
return true;
|
|
49
|
+
// Check CLAUDE.md or .claude/settings.json for AI/LLM terms
|
|
50
|
+
const claudeMd = readFile(join(cwd, 'CLAUDE.md'));
|
|
51
|
+
if (claudeMd) {
|
|
52
|
+
const aiTerms = /\b(llm|language model|ai agent|openai|anthropic|embedding|vector|rag|prompt|fine.?tun)/i;
|
|
53
|
+
if (aiTerms.test(claudeMd))
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
const claudeSettings = readFile(join(cwd, '.claude', 'settings.json'));
|
|
57
|
+
if (claudeSettings) {
|
|
58
|
+
const aiTerms = /\b(llm|language model|ai|openai|anthropic|model)/i;
|
|
59
|
+
if (aiTerms.test(claudeSettings))
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
// ── Test/example/docs path detection ─────────────────────────────────────────
|
|
65
|
+
const TEST_DOCS_PATTERNS = ['test/', 'tests/', '__tests__/', 'examples/', 'docs/'];
|
|
66
|
+
function isTestOrDocsFile(filePath) {
|
|
67
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
68
|
+
if (TEST_DOCS_PATTERNS.some(p => normalized.includes(p) || normalized.startsWith(p)))
|
|
69
|
+
return true;
|
|
70
|
+
const base = basename(filePath);
|
|
71
|
+
if (/\.(test|spec)\./i.test(base))
|
|
72
|
+
return true;
|
|
73
|
+
if (base.endsWith('.md'))
|
|
74
|
+
return true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
3
77
|
// Try to use @safetnsr/model-graveyard if installed (248 models, alias matching, YAML registry)
|
|
4
78
|
async function tryModelGraveyard(cwd) {
|
|
5
79
|
try {
|
|
@@ -8,6 +82,7 @@ async function tryModelGraveyard(cwd) {
|
|
|
8
82
|
return null;
|
|
9
83
|
const report = await mod.scan(cwd);
|
|
10
84
|
const issues = [];
|
|
85
|
+
const aiFramework = isAiFramework(cwd);
|
|
11
86
|
// Files that define deprecated model registries should not be flagged
|
|
12
87
|
const SELF_FILES = ['models.ts', 'models.js', 'model-graveyard', 'model-registry', 'sunset', 'fix/models'];
|
|
13
88
|
for (const match of report.matches) {
|
|
@@ -17,8 +92,10 @@ async function tryModelGraveyard(cwd) {
|
|
|
17
92
|
if (match.file && SELF_FILES.some(s => match.file.toLowerCase().includes(s)))
|
|
18
93
|
continue;
|
|
19
94
|
if (match.model.status === 'deprecated' || match.model.status === 'eol') {
|
|
95
|
+
const inTestDocs = match.file && isTestOrDocsFile(match.file);
|
|
96
|
+
const severity = (aiFramework || inTestDocs) ? 'info' : 'error';
|
|
20
97
|
issues.push({
|
|
21
|
-
severity
|
|
98
|
+
severity,
|
|
22
99
|
message: `${match.model.status} model "${match.raw}" in ${match.file}:${match.line}${match.model.successor ? ` — use "${match.model.successor}"` : ''}`,
|
|
23
100
|
file: match.file,
|
|
24
101
|
line: match.line,
|
|
@@ -27,15 +104,22 @@ async function tryModelGraveyard(cwd) {
|
|
|
27
104
|
});
|
|
28
105
|
}
|
|
29
106
|
}
|
|
30
|
-
const
|
|
107
|
+
const errorCount = issues.filter(i => i.severity === 'error').length;
|
|
108
|
+
const score = aiFramework
|
|
109
|
+
? Math.max(70, 100 - errorCount * 20)
|
|
110
|
+
: Math.max(0, 100 - errorCount * 20);
|
|
111
|
+
let summary = issues.length === 0
|
|
112
|
+
? `${report.filesScanned} files scanned (via model-graveyard) — all current`
|
|
113
|
+
: `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} (via model-graveyard)`;
|
|
114
|
+
if (aiFramework) {
|
|
115
|
+
summary += ' — AI framework detected — model references are expected';
|
|
116
|
+
}
|
|
31
117
|
return {
|
|
32
118
|
name: 'models',
|
|
33
119
|
score: Math.min(100, score),
|
|
34
120
|
maxScore: 100,
|
|
35
121
|
issues,
|
|
36
|
-
summary
|
|
37
|
-
? `${report.filesScanned} files scanned (via model-graveyard) — all current`
|
|
38
|
-
: `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} (via model-graveyard)`,
|
|
122
|
+
summary,
|
|
39
123
|
};
|
|
40
124
|
}
|
|
41
125
|
catch {
|
|
@@ -87,6 +171,7 @@ function builtinModels(cwd, ignore) {
|
|
|
87
171
|
const issues = [];
|
|
88
172
|
const files = walkFiles(cwd, ignore);
|
|
89
173
|
const found = new Map();
|
|
174
|
+
const aiFramework = isAiFramework(cwd);
|
|
90
175
|
for (const f of files) {
|
|
91
176
|
if (!SCAN_EXTS.some(ext => f.endsWith(ext)))
|
|
92
177
|
continue;
|
|
@@ -105,24 +190,42 @@ function builtinModels(cwd, ignore) {
|
|
|
105
190
|
found.set(model, existing);
|
|
106
191
|
}
|
|
107
192
|
}
|
|
108
|
-
for (const [model,
|
|
193
|
+
for (const [model, modelFiles] of found) {
|
|
109
194
|
const info = SUNSET_MODELS[model];
|
|
110
|
-
const fileList =
|
|
195
|
+
const fileList = modelFiles.length <= 2 ? modelFiles.join(', ') : `${modelFiles[0]} +${modelFiles.length - 1} more`;
|
|
196
|
+
// Determine severity: downgrade for AI frameworks or test/docs files
|
|
197
|
+
const allInTestDocs = modelFiles.every(f => isTestOrDocsFile(f));
|
|
198
|
+
const severity = (aiFramework || allInTestDocs) ? 'info' : 'error';
|
|
111
199
|
issues.push({
|
|
112
|
-
severity
|
|
200
|
+
severity,
|
|
113
201
|
message: `deprecated model "${model}" in ${fileList} — use "${info.replacement}"${info.sunset ? ` (sunset ${info.sunset})` : ''}`,
|
|
114
|
-
file:
|
|
202
|
+
file: modelFiles[0],
|
|
115
203
|
fixable: true,
|
|
116
204
|
fixHint: `replace "${model}" with "${info.replacement}"`,
|
|
117
205
|
});
|
|
118
206
|
}
|
|
119
|
-
|
|
207
|
+
let score;
|
|
208
|
+
if (aiFramework) {
|
|
209
|
+
// AI framework: models exist for compatibility, score 70+ base
|
|
210
|
+
const errorCount = issues.filter(i => i.severity === 'error').length;
|
|
211
|
+
score = Math.max(70, 100 - errorCount * 20);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
const errorCount = issues.filter(i => i.severity === 'error').length;
|
|
215
|
+
score = Math.max(0, 100 - errorCount * 20);
|
|
216
|
+
}
|
|
217
|
+
let summary = issues.length === 0
|
|
218
|
+
? 'all model references current'
|
|
219
|
+
: `${issues.length} deprecated model${issues.length > 1 ? 's' : ''} found`;
|
|
220
|
+
if (aiFramework) {
|
|
221
|
+
summary += ' — AI framework detected — model references are expected';
|
|
222
|
+
}
|
|
120
223
|
return {
|
|
121
224
|
name: 'models',
|
|
122
225
|
score: Math.min(100, score),
|
|
123
226
|
maxScore: 100,
|
|
124
227
|
issues,
|
|
125
|
-
summary
|
|
228
|
+
summary,
|
|
126
229
|
};
|
|
127
230
|
}
|
|
128
231
|
export async function checkModels(cwd, ignore) {
|
package/dist/checks/ready.js
CHANGED
|
@@ -48,13 +48,40 @@ function builtinReady(cwd, ignore) {
|
|
|
48
48
|
if (!hasReadme) {
|
|
49
49
|
issues.push({ severity: 'error', message: 'no README — AI agents have no project context', fixable: true, fixHint: 'create a README.md' });
|
|
50
50
|
}
|
|
51
|
+
// Detect Python project (root or subdirs)
|
|
52
|
+
const pythonMarkers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'];
|
|
53
|
+
const hasPythonRoot = pythonMarkers.some(m => files.includes(m));
|
|
54
|
+
const hasPythonSubdir = files.some(f => pythonMarkers.some(m => f.endsWith('/' + m) || f.endsWith('\\' + m)));
|
|
55
|
+
const isPython = hasPythonRoot || hasPythonSubdir;
|
|
56
|
+
// Detect monorepo (multiple manifests in subdirs)
|
|
57
|
+
const subPyprojects = files.filter(f => f !== 'pyproject.toml' && f.endsWith('pyproject.toml'));
|
|
58
|
+
const subPackageJsons = files.filter(f => f !== 'package.json' && f.endsWith('package.json'));
|
|
59
|
+
const isMonorepo = subPyprojects.length > 0 || subPackageJsons.length > 1;
|
|
51
60
|
const manifests = ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'pom.xml', 'build.gradle', 'Gemfile', 'composer.json'];
|
|
52
61
|
const hasManifest = manifests.some(m => files.includes(m));
|
|
53
|
-
|
|
62
|
+
// For Python projects, any pyproject.toml in subdirs counts as a manifest
|
|
63
|
+
const hasManifestAnywhere = hasManifest || isPython;
|
|
64
|
+
if (!hasManifestAnywhere) {
|
|
54
65
|
issues.push({ severity: 'error', message: 'no package manifest — agents can\'t resolve dependencies', fixable: false });
|
|
55
66
|
}
|
|
56
67
|
const codeExts = ['.ts', '.js', '.tsx', '.jsx', '.py', '.rs', '.go', '.java', '.rb', '.php', '.cs', '.swift', '.kt'];
|
|
57
|
-
|
|
68
|
+
// Broader test detection: includes Python test patterns and nested test directories
|
|
69
|
+
const testFiles = files.filter(f => {
|
|
70
|
+
if (/\.(test|spec)\.(ts|js|tsx|jsx|py)$/.test(f))
|
|
71
|
+
return true;
|
|
72
|
+
if (f.includes('__tests__/'))
|
|
73
|
+
return true;
|
|
74
|
+
if (f.startsWith('tests/') || f.startsWith('test/'))
|
|
75
|
+
return true;
|
|
76
|
+
if (f.includes('/tests/') || f.includes('/test/'))
|
|
77
|
+
return true;
|
|
78
|
+
// Python test file patterns: test_*.py, *_test.py
|
|
79
|
+
if (/(?:^|[/\\])test_[^/\\]+\.py$/.test(f))
|
|
80
|
+
return true;
|
|
81
|
+
if (/(?:^|[/\\])[^/\\]+_test\.py$/.test(f))
|
|
82
|
+
return true;
|
|
83
|
+
return false;
|
|
84
|
+
});
|
|
58
85
|
const codeFiles = files.filter(f => codeExts.some(ext => f.endsWith(ext)));
|
|
59
86
|
if (codeFiles.length > 5 && testFiles.length === 0) {
|
|
60
87
|
issues.push({ severity: 'error', message: 'no tests — AI agents produce better code when tests exist to validate against', fixable: false });
|
|
@@ -88,12 +115,15 @@ function builtinReady(cwd, ignore) {
|
|
|
88
115
|
const warnings = issues.filter(i => i.severity === 'warning').length;
|
|
89
116
|
const infos = issues.filter(i => i.severity === 'info').length;
|
|
90
117
|
const score = Math.max(0, Math.min(100, 100 - errors * 30 - warnings * 15 - infos * 3));
|
|
118
|
+
let summary = issues.length === 0 ? 'codebase is well-structured for AI' : `${issues.length} readiness issues`;
|
|
119
|
+
if (isMonorepo)
|
|
120
|
+
summary += ' (monorepo detected)';
|
|
91
121
|
return {
|
|
92
122
|
name: 'ready',
|
|
93
123
|
score: Math.round(score),
|
|
94
124
|
maxScore: 100,
|
|
95
125
|
issues,
|
|
96
|
-
summary
|
|
126
|
+
summary,
|
|
97
127
|
};
|
|
98
128
|
}
|
|
99
129
|
export async function checkReady(cwd, ignore) {
|
package/dist/checks/scan.js
CHANGED
|
@@ -9,16 +9,34 @@ const CRITICAL_PATTERNS = [
|
|
|
9
9
|
regex: /(?:aHR0c|data:text\/html;base64)/i,
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
|
-
id: 'curl-
|
|
12
|
+
id: 'curl-pipe-shell',
|
|
13
13
|
severity: 'critical',
|
|
14
|
-
description: '
|
|
15
|
-
regex: /(?:curl|wget
|
|
14
|
+
description: 'Download-and-execute pattern — remote code execution',
|
|
15
|
+
regex: /(?:curl|wget)\s+[^\n|]*\|\s*(?:ba)?sh\b/i,
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
|
-
id: 'shell
|
|
18
|
+
id: 'pipe-to-shell',
|
|
19
19
|
severity: 'critical',
|
|
20
|
-
description: '
|
|
21
|
-
regex:
|
|
20
|
+
description: 'Pipe to shell interpreter — potential code execution',
|
|
21
|
+
regex: /\|\s*(?:ba)?sh\b|\|\s*\beval\b/,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'command-substitution',
|
|
25
|
+
severity: 'critical',
|
|
26
|
+
description: 'Command substitution in config — potential injection vector',
|
|
27
|
+
regex: /\$\([^)]+\)/,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'data-exfiltration',
|
|
31
|
+
severity: 'critical',
|
|
32
|
+
description: 'Data exfiltration pattern — sending local data to external URL',
|
|
33
|
+
regex: /curl\s+[^\n]*(?:-[dFT]\s*@|--data-binary\s*@|--upload-file)|curl\s+[^\n]*\$(?:API_KEY|SECRET|TOKEN|PASSWORD)|nc\s+-[^\n]*\d+\.\d+/i,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'base64-exec',
|
|
37
|
+
severity: 'critical',
|
|
38
|
+
description: 'Base64-decoded payload piped to execution',
|
|
39
|
+
regex: /base64\s+(?:-d|--decode)[^\n]*\|\s*(?:ba)?sh\b/i,
|
|
22
40
|
},
|
|
23
41
|
{
|
|
24
42
|
id: 'powershell-download',
|
|
@@ -85,9 +103,10 @@ const CONFIG_TARGETS = [
|
|
|
85
103
|
'.claude', 'CLAUDE.md', 'AGENTS.md',
|
|
86
104
|
'.cursorrules', '.cursor',
|
|
87
105
|
'.github',
|
|
88
|
-
'.
|
|
106
|
+
'copilot-instructions.md',
|
|
107
|
+
'.aider.conf.yml', '.aider',
|
|
89
108
|
'.continue',
|
|
90
|
-
'.mcp',
|
|
109
|
+
'.mcp', 'mcp.json', '.mcp.json',
|
|
91
110
|
'.roomodes', '.roo',
|
|
92
111
|
];
|
|
93
112
|
// ── File helpers (delegated to util.ts) ──────────────────────────────────────
|
package/dist/checks/verify.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join, basename } from 'node:path';
|
|
1
|
+
import { join, basename, extname } from 'node:path';
|
|
2
2
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
4
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -10,9 +10,66 @@ function safeExec(cmd, cwd) {
|
|
|
10
10
|
return '';
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
// ── Config/meta file exclusion for thin-file checks ──────────────────────────
|
|
14
|
+
const CONFIG_DOTFILES = new Set([
|
|
15
|
+
'.gitignore', '.gitattributes', '.nvmrc', '.node-version', '.editorconfig',
|
|
16
|
+
'.prettierrc', '.eslintignore', '.npmrc', '.npmignore',
|
|
17
|
+
]);
|
|
18
|
+
const CONFIG_EXTENSIONS = new Set([
|
|
19
|
+
'.yml', '.yaml', '.json', '.toml', '.cfg', '.ini', '.lock', '.svg', '.xml',
|
|
20
|
+
]);
|
|
21
|
+
const CONFIG_DIRS = ['.github/', '.husky/', '.vscode/', '.idea/'];
|
|
22
|
+
const META_FILES = new Set([
|
|
23
|
+
'FUNDING.yaml', 'CODEOWNERS', 'LICENSE',
|
|
24
|
+
'py.typed', 'MANIFEST.in', 'CITATION.cff',
|
|
25
|
+
]);
|
|
26
|
+
const META_EXTENSIONS = new Set([
|
|
27
|
+
'.cff', '.mdc', '.txt', '.html', '.md', '.rst', '.csv',
|
|
28
|
+
'.css', '.scss', '.less', '.map', '.wasm',
|
|
29
|
+
'.sh', '.bash', '.zsh', '.fish', '.bat', '.cmd', '.ps1',
|
|
30
|
+
'.sql', '.graphql', '.gql', '.proto',
|
|
31
|
+
]);
|
|
32
|
+
/** Source code extensions that should be checked for thin files */
|
|
33
|
+
const SOURCE_CODE_EXTS = new Set([
|
|
34
|
+
'.ts', '.js', '.tsx', '.jsx', '.mts', '.mjs', '.cts', '.cjs',
|
|
35
|
+
'.py', '.go', '.rs', '.java', '.rb', '.php', '.cs', '.swift', '.kt',
|
|
36
|
+
]);
|
|
37
|
+
function isConfigOrMetaFile(filePath) {
|
|
38
|
+
const base = basename(filePath);
|
|
39
|
+
const ext = extname(filePath).toLowerCase();
|
|
40
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
41
|
+
// Dotfiles
|
|
42
|
+
if (CONFIG_DOTFILES.has(base))
|
|
43
|
+
return true;
|
|
44
|
+
// Config extensions
|
|
45
|
+
if (CONFIG_EXTENSIONS.has(ext))
|
|
46
|
+
return true;
|
|
47
|
+
// Meta/non-source extensions
|
|
48
|
+
if (META_EXTENSIONS.has(ext))
|
|
49
|
+
return true;
|
|
50
|
+
// Config directories
|
|
51
|
+
if (CONFIG_DIRS.some(d => normalized.includes(d) || normalized.startsWith(d)))
|
|
52
|
+
return true;
|
|
53
|
+
// Meta files
|
|
54
|
+
if (META_FILES.has(base))
|
|
55
|
+
return true;
|
|
56
|
+
if (base.startsWith('CHANGELOG'))
|
|
57
|
+
return true;
|
|
58
|
+
// Any file that is NOT source code should not be flagged for thin content
|
|
59
|
+
if (!SOURCE_CODE_EXTS.has(ext))
|
|
60
|
+
return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
// ── Code file extensions for test detection ──────────────────────────────────
|
|
64
|
+
const CODE_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs']);
|
|
65
|
+
const TEST_FILE_PATTERN = /\.(test|spec)\.(ts|js|tsx|jsx)$/i;
|
|
13
66
|
function isTestFile(filePath) {
|
|
14
67
|
const base = basename(filePath);
|
|
15
|
-
|
|
68
|
+
const ext = extname(filePath).toLowerCase();
|
|
69
|
+
// Only code files can be test files
|
|
70
|
+
if (!CODE_EXTENSIONS.has(ext))
|
|
71
|
+
return false;
|
|
72
|
+
if (TEST_FILE_PATTERN.test(base))
|
|
16
73
|
return true;
|
|
17
74
|
const normalized = filePath.replace(/\\/g, '/');
|
|
18
75
|
// Match __tests__/ anywhere in path (including at root)
|
|
@@ -92,6 +149,8 @@ function isPythonBoilerplate(filePath) {
|
|
|
92
149
|
const base = basename(filePath);
|
|
93
150
|
if (base === '__init__.py')
|
|
94
151
|
return true;
|
|
152
|
+
if (base === 'py.typed')
|
|
153
|
+
return true;
|
|
95
154
|
if (filePath.endsWith('.pyi'))
|
|
96
155
|
return true;
|
|
97
156
|
if (filePath.replace(/\\/g, '/').includes('__pycache__/'))
|
|
@@ -181,8 +240,13 @@ export function checkVerify(cwd, since) {
|
|
|
181
240
|
}
|
|
182
241
|
const lineCount = countLines(content);
|
|
183
242
|
// 2. File must have meaningful content (>10 non-empty lines)
|
|
184
|
-
// Skip thin file check for Python boilerplate files
|
|
185
|
-
if (
|
|
243
|
+
// Skip thin file check for Python boilerplate files (always, regardless of project type)
|
|
244
|
+
if (isPythonBoilerplate(relPath)) {
|
|
245
|
+
verified++;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// Skip thin file check for config/meta files (they're supposed to be small)
|
|
249
|
+
if (isConfigOrMetaFile(relPath)) {
|
|
186
250
|
verified++;
|
|
187
251
|
continue;
|
|
188
252
|
}
|