@safetnsr/vet 1.7.0 → 1.8.2
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/checks/debt.js +17 -4
- package/dist/checks/deps.js +146 -135
- package/dist/checks/diff.js +23 -4
- package/dist/checks/integrity.js +94 -17
- package/dist/checks/memory.js +6 -10
- package/dist/checks/models.js +60 -13
- package/dist/checks/owasp/asi01-goal-hijack.d.ts +5 -0
- package/dist/checks/owasp/asi01-goal-hijack.js +49 -0
- package/dist/checks/owasp/asi02-tool-misuse.d.ts +5 -0
- package/dist/checks/owasp/asi02-tool-misuse.js +98 -0
- package/dist/checks/owasp/asi03-identity-abuse.d.ts +5 -0
- package/dist/checks/owasp/asi03-identity-abuse.js +80 -0
- package/dist/checks/owasp/asi04-supply-chain.d.ts +5 -0
- package/dist/checks/owasp/asi04-supply-chain.js +79 -0
- package/dist/checks/owasp/asi05-code-execution.d.ts +5 -0
- package/dist/checks/owasp/asi05-code-execution.js +67 -0
- package/dist/checks/owasp/asi06-memory-poisoning.d.ts +5 -0
- package/dist/checks/owasp/asi06-memory-poisoning.js +61 -0
- package/dist/checks/owasp/asi07-inter-agent.d.ts +5 -0
- package/dist/checks/owasp/asi07-inter-agent.js +62 -0
- package/dist/checks/owasp/asi08-cascading.d.ts +5 -0
- package/dist/checks/owasp/asi08-cascading.js +36 -0
- package/dist/checks/owasp/asi09-trust-exploitation.d.ts +5 -0
- package/dist/checks/owasp/asi09-trust-exploitation.js +65 -0
- package/dist/checks/owasp/asi10-rogue-agents.d.ts +5 -0
- package/dist/checks/owasp/asi10-rogue-agents.js +31 -0
- package/dist/checks/owasp/index.d.ts +11 -0
- package/dist/checks/owasp/index.js +11 -0
- package/dist/checks/owasp/shared.d.ts +11 -0
- package/dist/checks/owasp/shared.js +61 -0
- package/dist/checks/owasp.js +1 -1
- package/dist/checks/ready.js +42 -7
- package/dist/checks/receipt.js +2 -16
- package/dist/checks/scan.js +54 -6
- package/dist/checks/secrets.js +2 -20
- package/dist/checks/tests.d.ts +3 -0
- package/dist/checks/tests.js +10 -0
- package/dist/checks/verify.js +35 -2
- package/dist/cli.js +111 -69
- package/dist/util.d.ts +0 -1
- package/dist/util.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readTextFile } from './shared.js';
|
|
2
|
+
// ── ASI08 — Cascading Failures ────────────────────────────────────────────────
|
|
3
|
+
export function checkASI08(cwd, configFiles) {
|
|
4
|
+
const findings = [];
|
|
5
|
+
if (configFiles.length === 0)
|
|
6
|
+
return { findings, deduction: 0 };
|
|
7
|
+
const workflowKeywords = ['step', 'workflow', 'pipeline', 'sequence', 'chain', 'loop', 'iterate'];
|
|
8
|
+
const errorHandlingKeywords = [
|
|
9
|
+
'rollback', 'undo', 'revert', 'recover', 'retry', 'error handling', 'handle error',
|
|
10
|
+
'circuit breaker', 'rate limit', 'max retries', 'timeout', 'fail safe', 'fallback',
|
|
11
|
+
'on error', 'if it fails', 'if something goes wrong',
|
|
12
|
+
];
|
|
13
|
+
let hasWorkflowContent = false;
|
|
14
|
+
let hasErrorHandling = false;
|
|
15
|
+
for (const filePath of configFiles) {
|
|
16
|
+
const content = readTextFile(filePath);
|
|
17
|
+
if (!content)
|
|
18
|
+
continue;
|
|
19
|
+
if (workflowKeywords.some(kw => new RegExp(kw, 'i').test(content))) {
|
|
20
|
+
hasWorkflowContent = true;
|
|
21
|
+
}
|
|
22
|
+
if (errorHandlingKeywords.some(kw => new RegExp(kw, 'i').test(content))) {
|
|
23
|
+
hasErrorHandling = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (hasWorkflowContent && !hasErrorHandling) {
|
|
27
|
+
findings.push({
|
|
28
|
+
asiId: 'ASI08',
|
|
29
|
+
severity: 'warning',
|
|
30
|
+
message: 'ASI08: multi-step workflow config lacks error handling, rollback, or recovery instructions',
|
|
31
|
+
fixHint: 'add error handling instructions: rollback procedures, retry limits, and circuit breakers',
|
|
32
|
+
});
|
|
33
|
+
return { findings, deduction: 5 };
|
|
34
|
+
}
|
|
35
|
+
return { findings, deduction: 0 };
|
|
36
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readTextFile } from './shared.js';
|
|
4
|
+
// ── ASI09 — Human-Agent Trust Exploitation ────────────────────────────────────
|
|
5
|
+
export function checkASI09(cwd, configFiles) {
|
|
6
|
+
const findings = [];
|
|
7
|
+
if (configFiles.length === 0)
|
|
8
|
+
return { findings, deduction: 0 };
|
|
9
|
+
const destructiveKeywords = ['delete', 'drop', 'remove', 'deploy', 'publish', 'push', 'rm ', 'truncate'];
|
|
10
|
+
const approvalKeywords = [
|
|
11
|
+
'confirm', 'approval', 'approve', 'ask.*before', 'human.*review', 'manual.*review',
|
|
12
|
+
'permission', 'consent', 'verify.*before', 'check.*before', 'gate',
|
|
13
|
+
];
|
|
14
|
+
let hasDestructiveOps = false;
|
|
15
|
+
let hasApprovalGate = false;
|
|
16
|
+
for (const filePath of configFiles) {
|
|
17
|
+
const content = readTextFile(filePath);
|
|
18
|
+
if (!content)
|
|
19
|
+
continue;
|
|
20
|
+
if (destructiveKeywords.some(kw => new RegExp(`\\b${kw}\\b`, 'i').test(content))) {
|
|
21
|
+
hasDestructiveOps = true;
|
|
22
|
+
}
|
|
23
|
+
if (approvalKeywords.some(kw => new RegExp(kw, 'i').test(content))) {
|
|
24
|
+
hasApprovalGate = true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const claudeSettings = join(cwd, '.claude', 'settings.json');
|
|
28
|
+
if (existsSync(claudeSettings)) {
|
|
29
|
+
const content = readTextFile(claudeSettings);
|
|
30
|
+
if (content) {
|
|
31
|
+
try {
|
|
32
|
+
const settings = JSON.parse(content);
|
|
33
|
+
if (settings.autoApprove === true || (Array.isArray(settings.autoApprove) && settings.autoApprove.length > 0)) {
|
|
34
|
+
findings.push({
|
|
35
|
+
asiId: 'ASI09',
|
|
36
|
+
severity: 'warning',
|
|
37
|
+
message: 'ASI09: autoApprove enabled in .claude/settings.json — destructive ops may run unattended',
|
|
38
|
+
file: '.claude/settings.json',
|
|
39
|
+
fixHint: 'disable autoApprove or restrict it to non-destructive operations only',
|
|
40
|
+
});
|
|
41
|
+
return { findings, deduction: 10 };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch { /* intentional: skip unparseable settings */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (hasDestructiveOps && !hasApprovalGate) {
|
|
48
|
+
findings.push({
|
|
49
|
+
asiId: 'ASI09',
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
message: 'ASI09: agent config references destructive operations (delete/deploy/publish) without approval gates',
|
|
52
|
+
fixHint: 'require explicit human confirmation before destructive operations (delete, deploy, publish)',
|
|
53
|
+
});
|
|
54
|
+
return { findings, deduction: 10 };
|
|
55
|
+
}
|
|
56
|
+
if (!hasApprovalGate) {
|
|
57
|
+
findings.push({
|
|
58
|
+
asiId: 'ASI09',
|
|
59
|
+
severity: 'info',
|
|
60
|
+
message: 'ASI09: no human approval gates mentioned in agent configs',
|
|
61
|
+
fixHint: 'document which operations require human approval in your agent config',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { findings, deduction: 0 };
|
|
65
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readTextFile } from './shared.js';
|
|
2
|
+
// ── ASI10 — Rogue Agents ─────────────────────────────────────────────────────
|
|
3
|
+
export function checkASI10(cwd, configFiles) {
|
|
4
|
+
const findings = [];
|
|
5
|
+
if (configFiles.length === 0)
|
|
6
|
+
return { findings, deduction: 0 };
|
|
7
|
+
const monitoringKeywords = [
|
|
8
|
+
'log', 'audit', 'monitor', 'alert', 'trace', 'observ', 'kill switch',
|
|
9
|
+
'stop', 'timeout', 'session limit', 'max token', 'budget', 'governance',
|
|
10
|
+
];
|
|
11
|
+
let hasGovernance = false;
|
|
12
|
+
for (const filePath of configFiles) {
|
|
13
|
+
const content = readTextFile(filePath);
|
|
14
|
+
if (!content)
|
|
15
|
+
continue;
|
|
16
|
+
if (monitoringKeywords.some(kw => new RegExp(kw, 'i').test(content))) {
|
|
17
|
+
hasGovernance = true;
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (!hasGovernance) {
|
|
22
|
+
findings.push({
|
|
23
|
+
asiId: 'ASI10',
|
|
24
|
+
severity: 'info',
|
|
25
|
+
message: 'ASI10: no monitoring, logging, or kill switch mechanisms mentioned in agent configs',
|
|
26
|
+
fixHint: 'add monitoring/audit trail requirements and session limits to your agent config',
|
|
27
|
+
});
|
|
28
|
+
return { findings, deduction: 5 };
|
|
29
|
+
}
|
|
30
|
+
return { findings, deduction: 0 };
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { type OwaspFinding, collectAgentConfigFiles, collectMcpConfigFiles, readTextFile } from './shared.js';
|
|
2
|
+
export { checkASI01 } from './asi01-goal-hijack.js';
|
|
3
|
+
export { checkASI02 } from './asi02-tool-misuse.js';
|
|
4
|
+
export { checkASI03 } from './asi03-identity-abuse.js';
|
|
5
|
+
export { checkASI04 } from './asi04-supply-chain.js';
|
|
6
|
+
export { checkASI05 } from './asi05-code-execution.js';
|
|
7
|
+
export { checkASI06 } from './asi06-memory-poisoning.js';
|
|
8
|
+
export { checkASI07 } from './asi07-inter-agent.js';
|
|
9
|
+
export { checkASI08 } from './asi08-cascading.js';
|
|
10
|
+
export { checkASI09 } from './asi09-trust-exploitation.js';
|
|
11
|
+
export { checkASI10 } from './asi10-rogue-agents.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { collectAgentConfigFiles, collectMcpConfigFiles, readTextFile } from './shared.js';
|
|
2
|
+
export { checkASI01 } from './asi01-goal-hijack.js';
|
|
3
|
+
export { checkASI02 } from './asi02-tool-misuse.js';
|
|
4
|
+
export { checkASI03 } from './asi03-identity-abuse.js';
|
|
5
|
+
export { checkASI04 } from './asi04-supply-chain.js';
|
|
6
|
+
export { checkASI05 } from './asi05-code-execution.js';
|
|
7
|
+
export { checkASI06 } from './asi06-memory-poisoning.js';
|
|
8
|
+
export { checkASI07 } from './asi07-inter-agent.js';
|
|
9
|
+
export { checkASI08 } from './asi08-cascading.js';
|
|
10
|
+
export { checkASI09 } from './asi09-trust-exploitation.js';
|
|
11
|
+
export { checkASI10 } from './asi10-rogue-agents.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function collectAgentConfigFiles(cwd: string): string[];
|
|
2
|
+
export declare function collectMcpConfigFiles(cwd: string): string[];
|
|
3
|
+
export declare function readTextFile(filePath: string): string | null;
|
|
4
|
+
export interface OwaspFinding {
|
|
5
|
+
asiId: string;
|
|
6
|
+
severity: 'error' | 'warning' | 'info';
|
|
7
|
+
message: string;
|
|
8
|
+
file?: string;
|
|
9
|
+
line?: number;
|
|
10
|
+
fixHint?: string;
|
|
11
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
3
|
+
import { isTextFile, collectDirFiles } from '../../util.js';
|
|
4
|
+
// ── Agent config file targets ─────────────────────────────────────────────────
|
|
5
|
+
const AGENT_CONFIG_TARGETS = [
|
|
6
|
+
'.claude',
|
|
7
|
+
'CLAUDE.md',
|
|
8
|
+
'AGENTS.md',
|
|
9
|
+
'.cursorrules',
|
|
10
|
+
'.cursor',
|
|
11
|
+
'.github/copilot-instructions.md',
|
|
12
|
+
'.mcp',
|
|
13
|
+
'mcp.json',
|
|
14
|
+
'.aider.conf.yml',
|
|
15
|
+
'.continue',
|
|
16
|
+
'.roomodes',
|
|
17
|
+
'.roo',
|
|
18
|
+
'codex.md',
|
|
19
|
+
];
|
|
20
|
+
const MCP_CONFIG_PATHS = [
|
|
21
|
+
'mcp.json',
|
|
22
|
+
'.mcp',
|
|
23
|
+
'.cursor/mcp.json',
|
|
24
|
+
'.claude/mcp.json',
|
|
25
|
+
];
|
|
26
|
+
// ── File helpers ──────────────────────────────────────────────────────────────
|
|
27
|
+
/** Collect files for a given list of target paths (files or directories). */
|
|
28
|
+
function collectConfigFiles(cwd, targets) {
|
|
29
|
+
const files = [];
|
|
30
|
+
for (const target of targets) {
|
|
31
|
+
const full = join(cwd, target);
|
|
32
|
+
if (!existsSync(full))
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
const s = statSync(full);
|
|
36
|
+
if (s.isFile()) {
|
|
37
|
+
files.push(full);
|
|
38
|
+
}
|
|
39
|
+
else if (s.isDirectory()) {
|
|
40
|
+
files.push(...collectDirFiles(full));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch { /* intentional: resolver may fail on unreadable files */ }
|
|
44
|
+
}
|
|
45
|
+
return [...new Set(files)];
|
|
46
|
+
}
|
|
47
|
+
export function collectAgentConfigFiles(cwd) {
|
|
48
|
+
return collectConfigFiles(cwd, AGENT_CONFIG_TARGETS);
|
|
49
|
+
}
|
|
50
|
+
export function collectMcpConfigFiles(cwd) {
|
|
51
|
+
return collectConfigFiles(cwd, MCP_CONFIG_PATHS);
|
|
52
|
+
}
|
|
53
|
+
export function readTextFile(filePath) {
|
|
54
|
+
if (!isTextFile(filePath))
|
|
55
|
+
return null;
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(filePath, 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
catch { /* intentional: resolver may fail on unreadable files */ }
|
|
60
|
+
return null;
|
|
61
|
+
}
|
package/dist/checks/owasp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { collectAgentConfigFiles, collectMcpConfigFiles, checkASI01, checkASI02, checkASI03, checkASI04, checkASI05, checkASI06, checkASI07, checkASI08, checkASI09, checkASI10, } from './owasp
|
|
1
|
+
import { collectAgentConfigFiles, collectMcpConfigFiles, checkASI01, checkASI02, checkASI03, checkASI04, checkASI05, checkASI06, checkASI07, checkASI08, checkASI09, checkASI10, } from './owasp/index.js';
|
|
2
2
|
// ── Main export ───────────────────────────────────────────────────────────────
|
|
3
3
|
export function checkOwasp(cwd) {
|
|
4
4
|
const allConfigFiles = collectAgentConfigFiles(cwd);
|
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,17 +115,25 @@ 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) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
130
|
+
try {
|
|
131
|
+
const rich = await tryAiReady(cwd);
|
|
132
|
+
if (rich)
|
|
133
|
+
return rich;
|
|
134
|
+
return builtinReady(cwd, ignore);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return { name: 'ready', score: 100, maxScore: 100, issues: [], summary: 'ready check failed' };
|
|
138
|
+
}
|
|
104
139
|
}
|
package/dist/checks/receipt.js
CHANGED
|
@@ -1,28 +1,14 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import * as crypto from 'node:crypto';
|
|
4
|
+
import { collectDirFiles } from '../util.js';
|
|
4
5
|
import { createInterface } from 'node:readline';
|
|
5
6
|
// ── Session file discovery ───────────────────────────────────────────────────
|
|
6
|
-
function walkDir(dir) {
|
|
7
|
-
const results = [];
|
|
8
|
-
try {
|
|
9
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
10
|
-
for (const entry of entries) {
|
|
11
|
-
const full = path.join(dir, entry.name);
|
|
12
|
-
if (entry.isDirectory())
|
|
13
|
-
results.push(...walkDir(full));
|
|
14
|
-
else
|
|
15
|
-
results.push(full);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
catch { /* skip */ }
|
|
19
|
-
return results;
|
|
20
|
-
}
|
|
21
7
|
export function findSessionFiles(baseDir) {
|
|
22
8
|
const dir = baseDir || path.join(process.env['HOME'] || '~', '.claude', 'projects');
|
|
23
9
|
if (!fs.existsSync(dir))
|
|
24
10
|
return [];
|
|
25
|
-
return
|
|
11
|
+
return collectDirFiles(dir).filter(f => f.endsWith('.jsonl')).sort();
|
|
26
12
|
}
|
|
27
13
|
export function findLatestSession(baseDir) {
|
|
28
14
|
const files = findSessionFiles(baseDir);
|
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',
|
|
@@ -118,12 +136,42 @@ function collectConfigFiles(cwd) {
|
|
|
118
136
|
}
|
|
119
137
|
return [...new Set(files)];
|
|
120
138
|
}
|
|
139
|
+
/** Check if a line is inside a markdown code fence or inline code */
|
|
140
|
+
function isInCodeContext(lines, lineIndex) {
|
|
141
|
+
const line = lines[lineIndex];
|
|
142
|
+
// Check if the match is inside inline backticks on this line
|
|
143
|
+
// Simple heuristic: line contains backtick-wrapped content with the pattern
|
|
144
|
+
if (/`[^`]*\$\([^)]+\)[^`]*`/.test(line))
|
|
145
|
+
return true;
|
|
146
|
+
// Check if we're inside a fenced code block (``` or ~~~)
|
|
147
|
+
let inFence = false;
|
|
148
|
+
for (let i = 0; i < lineIndex; i++) {
|
|
149
|
+
if (/^```|^~~~/.test(lines[i].trim()))
|
|
150
|
+
inFence = !inFence;
|
|
151
|
+
}
|
|
152
|
+
return inFence;
|
|
153
|
+
}
|
|
154
|
+
/** Check if a file is a CI/workflow file where shell commands are expected */
|
|
155
|
+
function isWorkflowFile(relPath) {
|
|
156
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
157
|
+
return normalized.includes('.github/workflows/') ||
|
|
158
|
+
normalized.includes('.circleci/') ||
|
|
159
|
+
normalized.includes('.gitlab-ci') ||
|
|
160
|
+
/Makefile|Dockerfile|Jenkinsfile/i.test(normalized);
|
|
161
|
+
}
|
|
121
162
|
function scanContent(content, relPath) {
|
|
122
163
|
const findings = [];
|
|
123
164
|
const lines = content.split('\n');
|
|
165
|
+
const isWorkflow = isWorkflowFile(relPath);
|
|
124
166
|
for (let i = 0; i < lines.length; i++) {
|
|
125
167
|
const line = lines[i];
|
|
126
168
|
for (const pattern of ALL_SCAN_PATTERNS) {
|
|
169
|
+
// Skip command-substitution checks in workflow files (shell commands are expected)
|
|
170
|
+
if (pattern.id === 'command-substitution' && isWorkflow)
|
|
171
|
+
continue;
|
|
172
|
+
// Skip command-substitution in markdown code contexts
|
|
173
|
+
if (pattern.id === 'command-substitution' && relPath.endsWith('.md') && isInCodeContext(lines, i))
|
|
174
|
+
continue;
|
|
127
175
|
if (pattern.regex.test(line)) {
|
|
128
176
|
pattern.regex.lastIndex = 0;
|
|
129
177
|
findings.push({
|
package/dist/checks/secrets.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join, relative, extname, dirname } from 'node:path';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { execSync } from 'node:child_process';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
|
+
import { collectDirFiles } from '../util.js';
|
|
6
7
|
// ── Shannon entropy ──────────────────────────────────────────────────────────
|
|
7
8
|
function calculateEntropy(str) {
|
|
8
9
|
if (str.length === 0)
|
|
@@ -117,25 +118,6 @@ function detectBuildDir(cwd) {
|
|
|
117
118
|
}
|
|
118
119
|
return null;
|
|
119
120
|
}
|
|
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
121
|
function shouldScan(filePath) {
|
|
140
122
|
const ext = extname(filePath).toLowerCase();
|
|
141
123
|
if (SKIP_EXTS.has(ext))
|
|
@@ -213,7 +195,7 @@ export async function checkSecrets(cwd) {
|
|
|
213
195
|
// 1. Build output scan
|
|
214
196
|
const buildDir = detectBuildDir(cwd);
|
|
215
197
|
if (buildDir) {
|
|
216
|
-
const buildFiles =
|
|
198
|
+
const buildFiles = collectDirFiles(buildDir).filter(f => shouldScan(f));
|
|
217
199
|
for (const file of buildFiles) {
|
|
218
200
|
try {
|
|
219
201
|
const findings = await scanBuildFile(file);
|
package/dist/checks/tests.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import type { CheckResult } from '../types.js';
|
|
2
|
+
/** Check if a file has a vet-ignore directive for a specific check in its first 5 lines.
|
|
3
|
+
* Format: // vet-ignore: check-name OR /* vet-ignore: check-name */
|
|
4
|
+
export declare function hasVetIgnore(content: string, checkName: string): boolean;
|
|
2
5
|
export declare function checkTests(cwd: string, ignore: string[]): CheckResult;
|
package/dist/checks/tests.js
CHANGED
|
@@ -191,6 +191,13 @@ function findDuplicateDescribes(lines, file) {
|
|
|
191
191
|
}
|
|
192
192
|
return issues;
|
|
193
193
|
}
|
|
194
|
+
/** Check if a file has a vet-ignore directive for a specific check in its first 5 lines.
|
|
195
|
+
* Format: // vet-ignore: check-name OR /* vet-ignore: check-name */
|
|
196
|
+
export function hasVetIgnore(content, checkName) {
|
|
197
|
+
const firstLines = content.split('\n').slice(0, 5);
|
|
198
|
+
const re = new RegExp(`(?://|/\\*|#)\\s*vet-ignore:\\s*${checkName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
199
|
+
return firstLines.some(line => re.test(line));
|
|
200
|
+
}
|
|
194
201
|
export function checkTests(cwd, ignore) {
|
|
195
202
|
const allFiles = walkFiles(cwd, ignore);
|
|
196
203
|
const testFiles = allFiles.filter(f => isTestFile(f));
|
|
@@ -203,6 +210,9 @@ export function checkTests(cwd, ignore) {
|
|
|
203
210
|
catch {
|
|
204
211
|
continue;
|
|
205
212
|
}
|
|
213
|
+
// Skip files with vet-ignore: tests directive
|
|
214
|
+
if (hasVetIgnore(content, 'tests'))
|
|
215
|
+
continue;
|
|
206
216
|
const lines = content.split('\n');
|
|
207
217
|
issues.push(...findTautological(lines, rel));
|
|
208
218
|
issues.push(...findEmptyBodies(content, rel));
|
package/dist/checks/verify.js
CHANGED
|
@@ -21,6 +21,18 @@ const CONFIG_EXTENSIONS = new Set([
|
|
|
21
21
|
const CONFIG_DIRS = ['.github/', '.husky/', '.vscode/', '.idea/'];
|
|
22
22
|
const META_FILES = new Set([
|
|
23
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',
|
|
24
36
|
]);
|
|
25
37
|
function isConfigOrMetaFile(filePath) {
|
|
26
38
|
const base = basename(filePath);
|
|
@@ -32,6 +44,9 @@ function isConfigOrMetaFile(filePath) {
|
|
|
32
44
|
// Config extensions
|
|
33
45
|
if (CONFIG_EXTENSIONS.has(ext))
|
|
34
46
|
return true;
|
|
47
|
+
// Meta/non-source extensions
|
|
48
|
+
if (META_EXTENSIONS.has(ext))
|
|
49
|
+
return true;
|
|
35
50
|
// Config directories
|
|
36
51
|
if (CONFIG_DIRS.some(d => normalized.includes(d) || normalized.startsWith(d)))
|
|
37
52
|
return true;
|
|
@@ -40,6 +55,9 @@ function isConfigOrMetaFile(filePath) {
|
|
|
40
55
|
return true;
|
|
41
56
|
if (base.startsWith('CHANGELOG'))
|
|
42
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;
|
|
43
61
|
return false;
|
|
44
62
|
}
|
|
45
63
|
// ── Code file extensions for test detection ──────────────────────────────────
|
|
@@ -127,16 +145,26 @@ function isPythonProject(cwd) {
|
|
|
127
145
|
const markers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'];
|
|
128
146
|
return markers.some(m => existsSync(join(cwd, m)));
|
|
129
147
|
}
|
|
148
|
+
/** Directories where small files are expected (examples, demos, docs) */
|
|
149
|
+
const SMALL_FILE_DIRS = ['examples/', 'example/', 'demos/', 'demo/', 'docs/'];
|
|
130
150
|
function isPythonBoilerplate(filePath) {
|
|
131
151
|
const base = basename(filePath);
|
|
132
152
|
if (base === '__init__.py')
|
|
133
153
|
return true;
|
|
154
|
+
if (base === '__main__.py')
|
|
155
|
+
return true;
|
|
156
|
+
if (base === 'py.typed')
|
|
157
|
+
return true;
|
|
134
158
|
if (filePath.endsWith('.pyi'))
|
|
135
159
|
return true;
|
|
136
160
|
if (filePath.replace(/\\/g, '/').includes('__pycache__/'))
|
|
137
161
|
return true;
|
|
138
162
|
return false;
|
|
139
163
|
}
|
|
164
|
+
function isInSmallFileDir(filePath) {
|
|
165
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
166
|
+
return SMALL_FILE_DIRS.some(d => normalized.includes(d) || normalized.startsWith(d));
|
|
167
|
+
}
|
|
140
168
|
// ── Main check ───────────────────────────────────────────────────────────────
|
|
141
169
|
export function checkVerify(cwd, since) {
|
|
142
170
|
const issues = [];
|
|
@@ -220,8 +248,13 @@ export function checkVerify(cwd, since) {
|
|
|
220
248
|
}
|
|
221
249
|
const lineCount = countLines(content);
|
|
222
250
|
// 2. File must have meaningful content (>10 non-empty lines)
|
|
223
|
-
// Skip thin file check for Python boilerplate files
|
|
224
|
-
if (
|
|
251
|
+
// Skip thin file check for Python boilerplate files (always, regardless of project type)
|
|
252
|
+
if (isPythonBoilerplate(relPath)) {
|
|
253
|
+
verified++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
// Skip thin file check for files in examples/docs directories
|
|
257
|
+
if (isInSmallFileDir(relPath)) {
|
|
225
258
|
verified++;
|
|
226
259
|
continue;
|
|
227
260
|
}
|