ship-safe 7.0.0 → 8.0.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/README.md +80 -21
- package/cli/agents/agent-attestation-agent.js +318 -0
- package/cli/agents/agentic-security-agent.js +35 -0
- package/cli/agents/cicd-scanner.js +22 -0
- package/cli/agents/config-auditor.js +235 -0
- package/cli/agents/hermes-security-agent.js +536 -0
- package/cli/agents/index.js +63 -22
- package/cli/agents/managed-agent-scanner.js +333 -0
- package/cli/agents/supply-chain-agent.js +1 -1
- package/cli/bin/ship-safe.js +125 -5
- package/cli/commands/audit.js +116 -2
- package/cli/commands/autofix.js +383 -0
- package/cli/commands/env-audit.js +349 -0
- package/cli/commands/red-team.js +2 -2
- package/cli/commands/scan-mcp.js +78 -0
- package/cli/commands/scan-skill.js +248 -5
- package/cli/index.js +5 -0
- package/cli/utils/hermes-tool-registry.js +252 -0
- package/cli/utils/patterns.js +1 -0
- package/cli/utils/plugin-loader.js +276 -0
- package/cli/utils/scan-playbook.js +312 -0
- package/cli/utils/security-memory.js +296 -0
- package/package.json +2 -2
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env Audit Command
|
|
3
|
+
* ==================
|
|
4
|
+
*
|
|
5
|
+
* Post-sync credential health check. Designed to run immediately after
|
|
6
|
+
* `stripe projects env --pull` or any credential provisioning workflow.
|
|
7
|
+
*
|
|
8
|
+
* Checks:
|
|
9
|
+
* 1. Every .env* file is covered by .gitignore
|
|
10
|
+
* 2. No .env values appear hardcoded in source files
|
|
11
|
+
* 3. Git history has no previously committed .env files
|
|
12
|
+
* 4. Agent configs can't read credential files without restriction
|
|
13
|
+
*
|
|
14
|
+
* USAGE:
|
|
15
|
+
* ship-safe env-audit [path]
|
|
16
|
+
* ship-safe env-audit . --json
|
|
17
|
+
*
|
|
18
|
+
* EXIT CODES:
|
|
19
|
+
* 0 — clean
|
|
20
|
+
* 1 — issues found
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import fg from 'fast-glob';
|
|
26
|
+
import chalk from 'chalk';
|
|
27
|
+
import ora from 'ora';
|
|
28
|
+
import { execFileSync } from 'child_process';
|
|
29
|
+
import { SECRET_PATTERNS, SKIP_DIRS } from '../utils/patterns.js';
|
|
30
|
+
|
|
31
|
+
// Minimum value length to cross-reference (skip short values like "true", "3000")
|
|
32
|
+
const MIN_VALUE_LENGTH = 8;
|
|
33
|
+
|
|
34
|
+
// Keys that are safe to appear in source (not secrets)
|
|
35
|
+
const SAFE_KEY_PATTERNS = /^(?:NEXT_PUBLIC_|REACT_APP_|VITE_|NUXT_PUBLIC_|NODE_ENV|PORT|HOST|HOSTNAME|LOG_LEVEL|DEBUG|TZ|LANG|APP_NAME|APP_URL|BASE_URL)/i;
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// ENV AUDIT COMMAND
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
export async function envAuditCommand(targetPath = '.', options) {
|
|
42
|
+
const absolutePath = path.resolve(targetPath);
|
|
43
|
+
const spinner = ora('Auditing credential environment...').start();
|
|
44
|
+
|
|
45
|
+
const findings = [];
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// ── Step 1: Find all .env files ──────────────────────────────────────────
|
|
49
|
+
const envFiles = await findEnvFiles(absolutePath);
|
|
50
|
+
spinner.text = `Found ${envFiles.length} .env file(s)`;
|
|
51
|
+
|
|
52
|
+
if (envFiles.length === 0) {
|
|
53
|
+
spinner.succeed('No .env files found — nothing to audit.');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Step 2: Check .gitignore coverage ────────────────────────────────────
|
|
58
|
+
spinner.text = 'Checking .gitignore coverage...';
|
|
59
|
+
for (const envFile of envFiles) {
|
|
60
|
+
const relPath = path.relative(absolutePath, envFile).replace(/\\/g, '/');
|
|
61
|
+
const basename = path.basename(envFile);
|
|
62
|
+
|
|
63
|
+
// Skip .env.example and .env.sample — these are meant to be committed
|
|
64
|
+
if (/\.env\.(?:example|sample|template)$/i.test(basename)) continue;
|
|
65
|
+
|
|
66
|
+
const isIgnored = checkGitignored(absolutePath, relPath);
|
|
67
|
+
if (!isIgnored) {
|
|
68
|
+
findings.push({
|
|
69
|
+
type: 'gitignore',
|
|
70
|
+
severity: 'critical',
|
|
71
|
+
file: relPath,
|
|
72
|
+
message: `${relPath} is NOT covered by .gitignore — credentials will be committed`,
|
|
73
|
+
fix: `Add "${basename}" to your .gitignore file`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Step 3: Parse .env values and cross-reference against source ─────────
|
|
79
|
+
spinner.text = 'Cross-referencing credentials against source files...';
|
|
80
|
+
// Only cross-reference real .env files, not .env.example/.env.sample
|
|
81
|
+
const realEnvFiles = envFiles.filter(f => !/\.env\.(?:example|sample|template)$/i.test(path.basename(f)));
|
|
82
|
+
const envValues = parseEnvFiles(realEnvFiles);
|
|
83
|
+
const sensitiveValues = envValues.filter(v =>
|
|
84
|
+
v.value.length >= MIN_VALUE_LENGTH && !SAFE_KEY_PATTERNS.test(v.key)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (sensitiveValues.length > 0) {
|
|
88
|
+
const sourceFiles = await findSourceFiles(absolutePath);
|
|
89
|
+
for (const { key, value, file: envFile } of sensitiveValues) {
|
|
90
|
+
for (const srcFile of sourceFiles) {
|
|
91
|
+
const relSrc = path.relative(absolutePath, srcFile).replace(/\\/g, '/');
|
|
92
|
+
// Don't flag the .env file itself
|
|
93
|
+
if (srcFile === envFile) continue;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const content = fs.readFileSync(srcFile, 'utf-8');
|
|
97
|
+
if (content.includes(value)) {
|
|
98
|
+
findings.push({
|
|
99
|
+
type: 'hardcoded',
|
|
100
|
+
severity: 'critical',
|
|
101
|
+
file: relSrc,
|
|
102
|
+
message: `${key} value from ${path.basename(envFile)} is hardcoded in ${relSrc}`,
|
|
103
|
+
fix: `Replace the hardcoded value with process.env.${key} or equivalent`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// skip unreadable files
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Step 4: Check git history for committed .env files ───────────────────
|
|
114
|
+
spinner.text = 'Checking git history for committed credentials...';
|
|
115
|
+
const historyLeaks = checkGitHistory(absolutePath);
|
|
116
|
+
for (const leak of historyLeaks) {
|
|
117
|
+
findings.push({
|
|
118
|
+
type: 'history',
|
|
119
|
+
severity: 'high',
|
|
120
|
+
file: leak,
|
|
121
|
+
message: `${leak} was previously committed to git history — credentials may be in old commits`,
|
|
122
|
+
fix: 'Rotate all credentials that were in this file. Use git filter-repo to remove from history if needed.',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Step 5: Check .projects manifest for credential leaks ────────────────
|
|
127
|
+
spinner.text = 'Checking .projects manifest...';
|
|
128
|
+
const projectsDir = path.join(absolutePath, '.projects');
|
|
129
|
+
if (fs.existsSync(projectsDir)) {
|
|
130
|
+
const projectsFiles = await fg(['**/*'], {
|
|
131
|
+
cwd: projectsDir,
|
|
132
|
+
absolute: true,
|
|
133
|
+
onlyFiles: true,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
for (const pFile of projectsFiles) {
|
|
137
|
+
try {
|
|
138
|
+
const content = fs.readFileSync(pFile, 'utf-8');
|
|
139
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
140
|
+
pattern.pattern.lastIndex = 0;
|
|
141
|
+
const match = pattern.pattern.exec(content);
|
|
142
|
+
if (match) {
|
|
143
|
+
const relPath = path.relative(absolutePath, pFile).replace(/\\/g, '/');
|
|
144
|
+
findings.push({
|
|
145
|
+
type: 'projects-manifest',
|
|
146
|
+
severity: 'critical',
|
|
147
|
+
file: relPath,
|
|
148
|
+
message: `${pattern.name} found in .projects manifest — credentials should not be in the manifest`,
|
|
149
|
+
fix: 'Remove credential values from .projects/ config. Stripe Projects stores credentials server-side.',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// skip unreadable
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Step 6: Check agent config access to .env files ──────────────────────
|
|
160
|
+
spinner.text = 'Checking agent config access to credential files...';
|
|
161
|
+
const agentConfigs = await fg([
|
|
162
|
+
'.claude/settings.json',
|
|
163
|
+
'.cursorrules',
|
|
164
|
+
'.cursor/rules/*.mdc',
|
|
165
|
+
'.windsurfrules',
|
|
166
|
+
'CLAUDE.md',
|
|
167
|
+
'.claw.json',
|
|
168
|
+
'.claw/settings.json',
|
|
169
|
+
'openclaw.json',
|
|
170
|
+
], {
|
|
171
|
+
cwd: absolutePath,
|
|
172
|
+
absolute: true,
|
|
173
|
+
onlyFiles: true,
|
|
174
|
+
dot: true,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
for (const configFile of agentConfigs) {
|
|
178
|
+
try {
|
|
179
|
+
const content = fs.readFileSync(configFile, 'utf-8');
|
|
180
|
+
const relPath = path.relative(absolutePath, configFile).replace(/\\/g, '/');
|
|
181
|
+
|
|
182
|
+
// Check for danger modes that give agents full access
|
|
183
|
+
if (/dangerouslySkipPermissions\s*["']?\s*[:=]\s*["']?true/i.test(content) ||
|
|
184
|
+
/permissionMode\s*["']?\s*[:=]\s*["']?danger-full-access/i.test(content)) {
|
|
185
|
+
findings.push({
|
|
186
|
+
type: 'agent-access',
|
|
187
|
+
severity: 'critical',
|
|
188
|
+
file: relPath,
|
|
189
|
+
message: `${relPath} grants unrestricted file access — agent can read all .env credentials`,
|
|
190
|
+
fix: 'Remove dangerouslySkipPermissions / danger-full-access. Scope agent file access explicitly.',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// skip
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Output ───────────────────────────────────────────────────────────────
|
|
199
|
+
spinner.stop();
|
|
200
|
+
|
|
201
|
+
if (options.json) {
|
|
202
|
+
console.log(JSON.stringify({ findings, envFiles: envFiles.length, clean: findings.length === 0 }, null, 2));
|
|
203
|
+
process.exit(findings.length > 0 ? 1 : 0);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
console.log();
|
|
208
|
+
console.log(chalk.cyan.bold(' Ship Safe — Env Audit'));
|
|
209
|
+
console.log(chalk.gray(` Scanned ${envFiles.length} .env file(s), ${sensitiveValues.length} credential(s)`));
|
|
210
|
+
console.log();
|
|
211
|
+
|
|
212
|
+
if (findings.length === 0) {
|
|
213
|
+
console.log(chalk.green(' ✔ Environment is clean. No credential leaks detected.\n'));
|
|
214
|
+
console.log(chalk.gray(' Tip: run this after every `stripe projects env --pull` or credential sync.\n'));
|
|
215
|
+
process.exit(0);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Group by type
|
|
220
|
+
const groups = {
|
|
221
|
+
gitignore: { label: 'Missing .gitignore Coverage', icon: '🔓' },
|
|
222
|
+
hardcoded: { label: 'Hardcoded Credentials in Source', icon: '🔑' },
|
|
223
|
+
history: { label: 'Credentials in Git History', icon: '📜' },
|
|
224
|
+
'projects-manifest': { label: 'Credentials in .projects Manifest', icon: '📁' },
|
|
225
|
+
'agent-access': { label: 'Agent Config: Unrestricted Credential Access', icon: '🤖' },
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
for (const [type, meta] of Object.entries(groups)) {
|
|
229
|
+
const typeFindings = findings.filter(f => f.type === type);
|
|
230
|
+
if (typeFindings.length === 0) continue;
|
|
231
|
+
|
|
232
|
+
console.log(chalk.yellow(` ${meta.icon} ${meta.label} (${typeFindings.length})`));
|
|
233
|
+
for (const f of typeFindings) {
|
|
234
|
+
const sevColor = f.severity === 'critical' ? chalk.red : chalk.yellow;
|
|
235
|
+
console.log(` ${sevColor(f.severity.toUpperCase())} ${f.file}`);
|
|
236
|
+
console.log(chalk.gray(` ${f.message}`));
|
|
237
|
+
console.log(chalk.gray(` Fix: ${f.fix}`));
|
|
238
|
+
}
|
|
239
|
+
console.log();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const criticals = findings.filter(f => f.severity === 'critical').length;
|
|
243
|
+
if (criticals > 0) {
|
|
244
|
+
console.log(chalk.red.bold(` ✘ ${criticals} critical issue(s). Fix before committing.\n`));
|
|
245
|
+
} else {
|
|
246
|
+
console.log(chalk.yellow(` ⚠ ${findings.length} issue(s) found. Review before committing.\n`));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
process.exit(1);
|
|
250
|
+
|
|
251
|
+
} catch (err) {
|
|
252
|
+
spinner.fail(`Env audit error: ${err.message}`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// =============================================================================
|
|
258
|
+
// HELPERS
|
|
259
|
+
// =============================================================================
|
|
260
|
+
|
|
261
|
+
async function findEnvFiles(rootPath) {
|
|
262
|
+
return fg(['.env', '.env.*', '**/.env', '**/.env.*'], {
|
|
263
|
+
cwd: rootPath,
|
|
264
|
+
absolute: true,
|
|
265
|
+
onlyFiles: true,
|
|
266
|
+
dot: true,
|
|
267
|
+
ignore: Array.from(SKIP_DIRS).map(d => `**/${d}/**`),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseEnvFiles(envFiles) {
|
|
272
|
+
const values = [];
|
|
273
|
+
for (const file of envFiles) {
|
|
274
|
+
try {
|
|
275
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
276
|
+
for (const line of content.split('\n')) {
|
|
277
|
+
const trimmed = line.trim();
|
|
278
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
279
|
+
const eqIdx = trimmed.indexOf('=');
|
|
280
|
+
if (eqIdx === -1) continue;
|
|
281
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
282
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
283
|
+
// Strip surrounding quotes
|
|
284
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
285
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
286
|
+
value = value.slice(1, -1);
|
|
287
|
+
}
|
|
288
|
+
if (value && value !== '' && !value.startsWith('${')) {
|
|
289
|
+
values.push({ key, value, file });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
// skip unreadable
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return values;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function findSourceFiles(rootPath) {
|
|
300
|
+
return fg(['**/*.{js,jsx,ts,tsx,mjs,cjs,py,rb,go,rs,php,java,kt,swift,yaml,yml,json,toml,tf}'], {
|
|
301
|
+
cwd: rootPath,
|
|
302
|
+
absolute: true,
|
|
303
|
+
onlyFiles: true,
|
|
304
|
+
ignore: [
|
|
305
|
+
...Array.from(SKIP_DIRS).map(d => `**/${d}/**`),
|
|
306
|
+
'**/.env*',
|
|
307
|
+
'**/*.min.js',
|
|
308
|
+
'**/__tests__/**',
|
|
309
|
+
'**/*.test.*',
|
|
310
|
+
'**/*.spec.*',
|
|
311
|
+
'**/test/**',
|
|
312
|
+
'**/tests/**',
|
|
313
|
+
'**/fixtures/**',
|
|
314
|
+
'**/snippets/**',
|
|
315
|
+
],
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function checkGitignored(rootPath, relPath) {
|
|
320
|
+
try {
|
|
321
|
+
execFileSync('git', ['check-ignore', '-q', relPath], { cwd: rootPath, stdio: 'pipe' });
|
|
322
|
+
return true; // exit 0 means it IS ignored
|
|
323
|
+
} catch {
|
|
324
|
+
return false; // exit 1 means it is NOT ignored
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function checkGitHistory(rootPath) {
|
|
329
|
+
try {
|
|
330
|
+
const result = execFileSync('git', ['log', '--all', '--diff-filter=A', '--name-only', '--pretty=format:'], {
|
|
331
|
+
cwd: rootPath,
|
|
332
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
333
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
334
|
+
}).toString();
|
|
335
|
+
|
|
336
|
+
const envFilePattern = /^\.env(?:\.\w+)?$/;
|
|
337
|
+
const safeEnvPattern = /\.env\.(?:example|sample|template)$/;
|
|
338
|
+
const committed = new Set();
|
|
339
|
+
for (const line of result.split('\n')) {
|
|
340
|
+
const trimmed = line.trim();
|
|
341
|
+
if (trimmed && envFilePattern.test(path.basename(trimmed)) && !safeEnvPattern.test(trimmed)) {
|
|
342
|
+
committed.add(trimmed);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return Array.from(committed);
|
|
346
|
+
} catch {
|
|
347
|
+
return [];
|
|
348
|
+
}
|
|
349
|
+
}
|
package/cli/commands/red-team.js
CHANGED
|
@@ -17,7 +17,7 @@ import fs from 'fs';
|
|
|
17
17
|
import path from 'path';
|
|
18
18
|
import chalk from 'chalk';
|
|
19
19
|
import ora from 'ora';
|
|
20
|
-
import {
|
|
20
|
+
import { buildOrchestratorAsync } from '../agents/index.js';
|
|
21
21
|
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
22
22
|
import { PolicyEngine } from '../agents/policy-engine.js';
|
|
23
23
|
import { HTMLReporter } from '../agents/html-reporter.js';
|
|
@@ -39,7 +39,7 @@ export async function redTeamCommand(targetPath = '.', options = {}) {
|
|
|
39
39
|
console.log();
|
|
40
40
|
|
|
41
41
|
// ── 1. Run orchestrator ─────────────────────────────────────────────────────
|
|
42
|
-
const orchestrator =
|
|
42
|
+
const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
|
|
43
43
|
|
|
44
44
|
const agentFilter = options.agents
|
|
45
45
|
? options.agents.split(',').map(a => a.trim())
|
package/cli/commands/scan-mcp.js
CHANGED
|
@@ -109,6 +109,72 @@ const MCP_TOOL_PATTERNS = [
|
|
|
109
109
|
severity: 'medium',
|
|
110
110
|
target: 'any',
|
|
111
111
|
},
|
|
112
|
+
|
|
113
|
+
// ── Hermes Agent: Function-Call Poisoning (ASI-03, ASI-05) ───────────────
|
|
114
|
+
{
|
|
115
|
+
name: 'Hermes: XML tool_call injection in description',
|
|
116
|
+
regex: /<tool_call>[\s\S]{0,300}<\/tool_call>/gi,
|
|
117
|
+
severity: 'critical',
|
|
118
|
+
target: 'description',
|
|
119
|
+
owasp: 'ASI-03',
|
|
120
|
+
note: 'Description embeds a Hermes-format <tool_call> block — will be parsed and executed by agents consuming this manifest.',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'Hermes: Function-call format injection',
|
|
124
|
+
regex: /<function_calls>[\s\S]{0,300}<\/function_calls>/gi,
|
|
125
|
+
severity: 'critical',
|
|
126
|
+
target: 'description',
|
|
127
|
+
owasp: 'ASI-03',
|
|
128
|
+
note: 'Description embeds a <function_calls> block matching Hermes/Claude XML call format.',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'Hermes: tool_choice manipulation',
|
|
132
|
+
regex: /tool_choice\s*[=:]\s*["']?(?:auto|any|none|required)["']?\s*(?:,|\}|$)/gi,
|
|
133
|
+
severity: 'high',
|
|
134
|
+
target: 'description',
|
|
135
|
+
owasp: 'ASI-03',
|
|
136
|
+
note: 'Description attempts to override tool_choice routing, steering agent to call attacker-controlled tools.',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'Hermes: Forced tool invocation via description',
|
|
140
|
+
regex: /(?:you\s+must\s+(?:call|invoke|use)\s+(?:the\s+)?tool|always\s+(?:call|invoke|run)\s+(?:the\s+)?(?:tool|function)|tool\s+MUST\s+be\s+(?:called|invoked|used))/gi,
|
|
141
|
+
severity: 'high',
|
|
142
|
+
target: 'description',
|
|
143
|
+
owasp: 'ASI-03',
|
|
144
|
+
note: 'Instruction in tool description coerces the LLM agent into calling a specific tool, bypassing agent autonomy.',
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'Hermes: Schema bypass via additionalProperties',
|
|
148
|
+
regex: /"additionalProperties"\s*:\s*true/gi,
|
|
149
|
+
severity: 'high',
|
|
150
|
+
target: 'schema',
|
|
151
|
+
owasp: 'ASI-03',
|
|
152
|
+
note: 'Tool input schema allows arbitrary extra properties — attackers can inject undeclared parameters that bypass input validation.',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'Hermes: Late binding via env-var registry URL',
|
|
156
|
+
regex: /(?:HERMES_REGISTRY_URL|AGENT_REGISTRY|TOOL_REGISTRY_URL|REGISTRY_ENDPOINT)\s*[=:]/gi,
|
|
157
|
+
severity: 'critical',
|
|
158
|
+
target: 'any',
|
|
159
|
+
owasp: 'ASI-05',
|
|
160
|
+
note: 'Tool definition references a runtime-resolved registry URL — attacker who controls the env var can swap the entire tool registry at execution time.',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'Hermes: Namespace collision / tool shadowing',
|
|
164
|
+
regex: /(?:override\s+(?:existing\s+)?tool|shadow\s+tool|replace\s+(?:the\s+)?(?:existing\s+)?tool|re-register\s+tool)/gi,
|
|
165
|
+
severity: 'critical',
|
|
166
|
+
target: 'description',
|
|
167
|
+
owasp: 'ASI-05',
|
|
168
|
+
note: 'Description explicitly documents shadowing a previously registered tool — classic namespace collision attack.',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: 'Hermes: Recursive sub-agent invocation in description',
|
|
172
|
+
regex: /(?:spawn\s+(?:a\s+)?(?:new\s+)?(?:sub[-\s]?agent|child[-\s]?agent|nested[-\s]?agent)|create\s+(?:a\s+)?(?:sub[-\s]?agent|child[-\s]?agent)|recursively\s+call\s+(?:agent|tool))/gi,
|
|
173
|
+
severity: 'high',
|
|
174
|
+
target: 'description',
|
|
175
|
+
owasp: 'ASI-02',
|
|
176
|
+
note: 'Description instructs the agent to spawn sub-agents — could lead to unbounded recursion or privilege escalation through child agents.',
|
|
177
|
+
},
|
|
112
178
|
];
|
|
113
179
|
|
|
114
180
|
// Dangerous tool name keywords — flag tools whose names suggest shell/exec access
|
|
@@ -348,6 +414,18 @@ function analyzeToolDefinition(tool) {
|
|
|
348
414
|
}
|
|
349
415
|
}
|
|
350
416
|
|
|
417
|
+
// Check for additionalProperties: true at the top-level schema (schema bypass)
|
|
418
|
+
const topSchema = tool.inputSchema || tool.input_schema || {};
|
|
419
|
+
if (topSchema.additionalProperties === true) {
|
|
420
|
+
findings.push({
|
|
421
|
+
check: 'schema-analysis',
|
|
422
|
+
name: 'Hermes: Schema bypass — additionalProperties: true',
|
|
423
|
+
severity: 'high',
|
|
424
|
+
tool: name,
|
|
425
|
+
matched: 'Top-level inputSchema has additionalProperties: true — arbitrary params accepted',
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
351
429
|
// Check for excessive required parameters (information harvesting)
|
|
352
430
|
const required = tool.inputSchema?.required || tool.input_schema?.required || [];
|
|
353
431
|
const properties = tool.inputSchema?.properties || tool.input_schema?.properties || {};
|