ship-safe 6.4.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.
@@ -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
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Live Advisory Feed
3
+ * ===================
4
+ *
5
+ * Queries the GitHub Advisory Database and OSV.dev API for real-time
6
+ * advisories on your exact dependency versions. Unlike static CVE checks,
7
+ * this catches actively-compromised packages (Axios 1.8.2, LiteLLM 1.82.7)
8
+ * within hours of publication.
9
+ *
10
+ * USAGE:
11
+ * ship-safe advisories . # Check npm + PyPI deps
12
+ * ship-safe advisories . --ecosystem npm
13
+ * ship-safe advisories . --json
14
+ *
15
+ * APIs used:
16
+ * - OSV.dev (https://api.osv.dev) — aggregates GitHub Advisories, PyPI, npm
17
+ * - No API key needed — fully open, rate-limited to 1000 req/min
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+
23
+ // =============================================================================
24
+ // DEPENDENCY EXTRACTION
25
+ // =============================================================================
26
+
27
+ /**
28
+ * Extract package names + versions from project manifests.
29
+ * Returns: [{ name, version, ecosystem, file }]
30
+ */
31
+ export function extractDependencies(rootPath) {
32
+ const deps = [];
33
+
34
+ // npm / Node.js
35
+ const pkgPath = path.join(rootPath, 'package.json');
36
+ if (fs.existsSync(pkgPath)) {
37
+ try {
38
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
39
+ const allDeps = {
40
+ ...(pkg.dependencies || {}),
41
+ ...(pkg.devDependencies || {}),
42
+ };
43
+ for (const [name, versionRange] of Object.entries(allDeps)) {
44
+ // Strip semver prefix (^, ~, >=)
45
+ const version = String(versionRange).replace(/^[\^~>=<]+/, '').trim();
46
+ if (/^\d/.test(version)) {
47
+ deps.push({ name, version, ecosystem: 'npm', file: pkgPath });
48
+ }
49
+ }
50
+ } catch { /* skip */ }
51
+ }
52
+
53
+ // Also check package-lock.json for pinned versions (more accurate)
54
+ const lockPath = path.join(rootPath, 'package-lock.json');
55
+ if (fs.existsSync(lockPath)) {
56
+ try {
57
+ const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
58
+ const packages = lock.packages || {};
59
+ for (const [pkgKey, info] of Object.entries(packages)) {
60
+ if (!pkgKey || pkgKey === '') continue; // root entry
61
+ const name = pkgKey.replace(/^node_modules\//, '');
62
+ if (info.version && /^\d/.test(info.version)) {
63
+ // Only add if not already present from package.json
64
+ if (!deps.find(d => d.name === name && d.ecosystem === 'npm')) {
65
+ deps.push({ name, version: info.version, ecosystem: 'npm', file: lockPath });
66
+ }
67
+ }
68
+ }
69
+ } catch { /* skip */ }
70
+ }
71
+
72
+ // Python
73
+ const reqPath = path.join(rootPath, 'requirements.txt');
74
+ if (fs.existsSync(reqPath)) {
75
+ try {
76
+ const lines = fs.readFileSync(reqPath, 'utf-8').split('\n');
77
+ for (const line of lines) {
78
+ const m = line.trim().match(/^([\w-]+)==([\d.]+)/);
79
+ if (m) {
80
+ deps.push({ name: m[1], version: m[2], ecosystem: 'PyPI', file: reqPath });
81
+ }
82
+ }
83
+ } catch { /* skip */ }
84
+ }
85
+
86
+ // Poetry (pyproject.toml)
87
+ const pyprojectPath = path.join(rootPath, 'pyproject.toml');
88
+ if (fs.existsSync(pyprojectPath)) {
89
+ try {
90
+ const content = fs.readFileSync(pyprojectPath, 'utf-8');
91
+ const depSection = content.match(/\[tool\.poetry\.dependencies\]([\s\S]*?)(?:\n\[|$)/);
92
+ if (depSection) {
93
+ const lines = depSection[1].split('\n');
94
+ for (const line of lines) {
95
+ const m = line.match(/^([\w-]+)\s*=\s*"([\d.]+)"/);
96
+ if (m) {
97
+ deps.push({ name: m[1], version: m[2], ecosystem: 'PyPI', file: pyprojectPath });
98
+ }
99
+ }
100
+ }
101
+ } catch { /* skip */ }
102
+ }
103
+
104
+ return deps;
105
+ }
106
+
107
+ // =============================================================================
108
+ // OSV.DEV API
109
+ // =============================================================================
110
+
111
+ /**
112
+ * Query OSV.dev for known vulnerabilities affecting a specific package version.
113
+ * Uses the batch query endpoint for efficiency.
114
+ *
115
+ * @param {{ name: string, version: string, ecosystem: string }[]} deps
116
+ * @returns {Promise<object[]>} — Array of advisory objects
117
+ */
118
+ export async function queryOSV(deps) {
119
+ if (deps.length === 0) return [];
120
+
121
+ // OSV batch query supports up to 1000 packages per request
122
+ const batchSize = 1000;
123
+ const allResults = [];
124
+
125
+ for (let i = 0; i < deps.length; i += batchSize) {
126
+ const batch = deps.slice(i, i + batchSize);
127
+ const queries = batch.map(d => ({
128
+ package: { name: d.name, ecosystem: d.ecosystem },
129
+ version: d.version,
130
+ }));
131
+
132
+ try {
133
+ const response = await fetch('https://api.osv.dev/v1/querybatch', {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({ queries }),
137
+ });
138
+
139
+ if (!response.ok) {
140
+ throw new Error(`OSV API error: HTTP ${response.status}`);
141
+ }
142
+
143
+ const data = await response.json();
144
+ const results = data.results || [];
145
+
146
+ for (let j = 0; j < results.length; j++) {
147
+ const vulns = results[j].vulns || [];
148
+ for (const vuln of vulns) {
149
+ allResults.push({
150
+ id: vuln.id,
151
+ summary: vuln.summary || '',
152
+ severity: extractSeverity(vuln),
153
+ package: batch[j].name,
154
+ version: batch[j].version,
155
+ ecosystem: batch[j].ecosystem,
156
+ file: deps[i + j].file,
157
+ aliases: vuln.aliases || [],
158
+ published: vuln.published || null,
159
+ modified: vuln.modified || null,
160
+ isMalware: (vuln.id || '').startsWith('MAL-') ||
161
+ (vuln.summary || '').toLowerCase().includes('malicious') ||
162
+ (vuln.summary || '').toLowerCase().includes('malware'),
163
+ });
164
+ }
165
+ }
166
+ } catch (err) {
167
+ // Network error — return what we have so far
168
+ if (allResults.length === 0) {
169
+ throw new Error(`Failed to reach OSV.dev: ${err.message}. Run with --offline to skip live checks.`);
170
+ }
171
+ }
172
+ }
173
+
174
+ return allResults;
175
+ }
176
+
177
+ /**
178
+ * Extract the highest severity from an OSV vulnerability object.
179
+ */
180
+ function extractSeverity(vuln) {
181
+ // Check database_specific severity first
182
+ if (vuln.database_specific?.severity) {
183
+ return vuln.database_specific.severity.toLowerCase();
184
+ }
185
+
186
+ // Check CVSS in severity array
187
+ const sevEntries = vuln.severity || [];
188
+ for (const entry of sevEntries) {
189
+ if (entry.type === 'CVSS_V3') {
190
+ const score = parseFloat(entry.score) || 0;
191
+ if (score >= 9.0) return 'critical';
192
+ if (score >= 7.0) return 'high';
193
+ if (score >= 4.0) return 'medium';
194
+ return 'low';
195
+ }
196
+ }
197
+
198
+ // Malware is always critical
199
+ if ((vuln.id || '').startsWith('MAL-')) return 'critical';
200
+
201
+ return 'medium';
202
+ }
203
+
204
+ // =============================================================================
205
+ // MAIN COMMAND
206
+ // =============================================================================
207
+
208
+ /**
209
+ * Run the live advisory check.
210
+ * Returns findings in ship-safe standard format.
211
+ */
212
+ export async function runLiveAdvisories(rootPath, options = {}) {
213
+ const deps = extractDependencies(rootPath);
214
+
215
+ if (deps.length === 0) {
216
+ return { advisories: [], deps: 0, checked: 0 };
217
+ }
218
+
219
+ // Filter by ecosystem if requested
220
+ const filtered = options.ecosystem
221
+ ? deps.filter(d => d.ecosystem.toLowerCase() === options.ecosystem.toLowerCase())
222
+ : deps;
223
+
224
+ const advisories = await queryOSV(filtered);
225
+
226
+ // Sort: malware first, then by severity
227
+ const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
228
+ advisories.sort((a, b) => {
229
+ if (a.isMalware && !b.isMalware) return -1;
230
+ if (!a.isMalware && b.isMalware) return 1;
231
+ return (sevOrder[a.severity] || 2) - (sevOrder[b.severity] || 2);
232
+ });
233
+
234
+ return {
235
+ advisories,
236
+ deps: filtered.length,
237
+ checked: filtered.length,
238
+ };
239
+ }
240
+
241
+ export default { extractDependencies, queryOSV, runLiveAdvisories };
@@ -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 { buildOrchestrator } from '../agents/index.js';
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 = buildOrchestrator();
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())
@@ -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 || {};