ship-safe 7.0.0 → 9.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.
@@ -24,6 +24,9 @@
24
24
  * scan_secrets - Scan a directory for leaked secrets
25
25
  * get_checklist - Return the launch-day security checklist
26
26
  * analyze_file - Analyze a single file for security issues
27
+ * scan_repo - Run a full multi-agent security scan on a repo
28
+ * get_findings - Read findings from a saved ship-safe report file
29
+ * suppress_finding - Add a ship-safe-ignore comment to suppress a finding
27
30
  *
28
31
  * PROTOCOL:
29
32
  * JSON-RPC 2.0 over stdio (MCP spec: https://modelcontextprotocol.io)
@@ -34,6 +37,10 @@ import path from 'path';
34
37
  import fg from 'fast-glob';
35
38
  import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, TEST_FILE_PATTERNS, MAX_FILE_SIZE } from '../utils/patterns.js';
36
39
  import { isHighEntropyMatch } from '../utils/entropy.js';
40
+ import { buildOrchestrator } from '../agents/index.js';
41
+ import { ScoringEngine } from '../agents/scoring-engine.js';
42
+ import { autoDetectProvider } from '../providers/llm-provider.js';
43
+ import { DeepAnalyzer } from '../agents/deep-analyzer.js';
37
44
 
38
45
  // =============================================================================
39
46
  // MCP TOOL DEFINITIONS
@@ -80,6 +87,74 @@ const TOOLS = [
80
87
  required: ['path'],
81
88
  },
82
89
  },
90
+ {
91
+ name: 'scan_repo',
92
+ description: 'Run a full multi-agent security scan on a repository or directory. Runs all 20+ ship-safe security agents (injection, auth bypass, secrets, supply chain, LLM security, etc.) and returns a structured findings report with severity ratings and remediation advice. Use this when the user asks to audit, scan, or check the security of their project.',
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: {
96
+ path: {
97
+ type: 'string',
98
+ description: 'The directory path to scan. Use "." for current directory.',
99
+ },
100
+ agents: {
101
+ type: 'array',
102
+ items: { type: 'string' },
103
+ description: 'Specific agent names to run (optional). Omit to run all agents.',
104
+ },
105
+ llm: {
106
+ type: 'boolean',
107
+ description: 'Enable LLM-powered deep analysis for critical/high findings (default: false). Requires ANTHROPIC_API_KEY or similar env var.',
108
+ },
109
+ outputFile: {
110
+ type: 'string',
111
+ description: 'Optional path to save the JSON report for later retrieval with get_findings.',
112
+ },
113
+ },
114
+ required: ['path'],
115
+ },
116
+ },
117
+ {
118
+ name: 'get_findings',
119
+ description: 'Read and return findings from a ship-safe JSON report file previously saved by scan_repo or the ship-safe CLI (npx ship-safe audit --json). Useful for reviewing or referencing a prior scan without re-running it.',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ reportPath: {
124
+ type: 'string',
125
+ description: 'Path to the ship-safe JSON report file (e.g. ship-safe-report.json).',
126
+ },
127
+ severity: {
128
+ type: 'string',
129
+ enum: ['critical', 'high', 'medium', 'low'],
130
+ description: 'Filter findings by minimum severity (optional).',
131
+ },
132
+ },
133
+ required: ['reportPath'],
134
+ },
135
+ },
136
+ {
137
+ name: 'suppress_finding',
138
+ description: 'Add a ship-safe-ignore comment to a specific line in a file to suppress a false-positive security finding. The comment tells ship-safe\'s scanner to skip that line in future scans. Always explain why the suppression is safe.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ file: {
143
+ type: 'string',
144
+ description: 'Path to the file containing the false-positive finding.',
145
+ },
146
+ line: {
147
+ type: 'number',
148
+ description: 'Line number of the finding to suppress (1-indexed).',
149
+ },
150
+ reason: {
151
+ type: 'string',
152
+ description: 'Brief explanation of why this finding is a false positive (appended to the ignore comment).',
153
+ },
154
+ },
155
+ required: ['file', 'line', 'reason'],
156
+ },
157
+ },
83
158
  ];
84
159
 
85
160
  // =============================================================================
@@ -163,6 +238,192 @@ async function analyzeFile({ path: filePath }) {
163
238
  };
164
239
  }
165
240
 
241
+ async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, outputFile }) {
242
+ const rootPath = path.resolve(targetPath);
243
+
244
+ if (!fs.existsSync(rootPath)) {
245
+ return { error: `Path does not exist: ${rootPath}` };
246
+ }
247
+
248
+ // MCP communicates over stdout as JSON-RPC. Suppress all console output during
249
+ // the scan so spinner text and log lines don't pollute the transport stream.
250
+ const noop = () => {};
251
+ const savedLog = console.log;
252
+ const savedWarn = console.warn;
253
+ const savedError = console.error;
254
+ const savedInfo = console.info;
255
+ console.log = console.warn = console.error = console.info = noop;
256
+
257
+ try {
258
+ const orchestrator = buildOrchestrator();
259
+ const context = { rootPath };
260
+
261
+ // Run all agents (quiet:true suppresses ora spinners; console is already nulled)
262
+ const { findings, recon } = await orchestrator.runAll(rootPath, {
263
+ agents: agentFilter,
264
+ timeout: 30000,
265
+ concurrency: 6,
266
+ quiet: true,
267
+ });
268
+
269
+ // Optional: LLM deep analysis
270
+ let deepStats = null;
271
+ if (llm) {
272
+ const provider = autoDetectProvider(rootPath, {});
273
+ if (provider) {
274
+ const analyzer = new DeepAnalyzer({ provider, budgetCents: 50, verbose: false });
275
+ await analyzer.analyze(findings, { rootPath, recon });
276
+ deepStats = analyzer.getStats();
277
+ }
278
+ }
279
+
280
+ // Score
281
+ const scorer = new ScoringEngine();
282
+ const { score, grade } = scorer.score(findings);
283
+
284
+ const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
285
+ const bySeverity = {};
286
+ for (const sev of SEV_ORDER) {
287
+ bySeverity[sev] = findings.filter(f => f.severity === sev).length;
288
+ }
289
+
290
+ const report = {
291
+ scannedAt: new Date().toISOString(),
292
+ rootPath,
293
+ score,
294
+ grade,
295
+ totalFindings: findings.length,
296
+ bySeverity,
297
+ findings: findings.map(f => ({
298
+ title: f.title,
299
+ severity: f.severity,
300
+ category: f.category,
301
+ rule: f.rule,
302
+ file: f.file ? path.relative(rootPath, f.file) : null,
303
+ line: f.line,
304
+ description: f.description,
305
+ remediation: f.remediation,
306
+ confidence: f.confidence,
307
+ ...(f.deepAnalysis ? { deepAnalysis: f.deepAnalysis } : {}),
308
+ })),
309
+ ...(deepStats ? { deepAnalysis: deepStats } : {}),
310
+ summary: `Score: ${score}/100 (${grade}) — ${findings.length} finding(s): ${bySeverity.critical} critical, ${bySeverity.high} high, ${bySeverity.medium} medium, ${bySeverity.low} low.`,
311
+ };
312
+
313
+ if (outputFile) {
314
+ const outPath = path.resolve(outputFile);
315
+ fs.writeFileSync(outPath, JSON.stringify(report, null, 2), 'utf-8');
316
+ report.savedTo = outPath;
317
+ }
318
+
319
+ return report;
320
+ } catch (err) {
321
+ return { error: `Scan failed: ${err.message}` };
322
+ } finally {
323
+ // Always restore console so other tool calls are not affected
324
+ console.log = savedLog;
325
+ console.warn = savedWarn;
326
+ console.error = savedError;
327
+ console.info = savedInfo;
328
+ }
329
+ }
330
+
331
+ function getFindings({ reportPath, severity }) {
332
+ const absPath = path.resolve(reportPath);
333
+
334
+ if (!fs.existsSync(absPath)) {
335
+ return { error: `Report file not found: ${absPath}` };
336
+ }
337
+
338
+ let report;
339
+ try {
340
+ report = JSON.parse(fs.readFileSync(absPath, 'utf-8'));
341
+ } catch (err) {
342
+ return { error: `Failed to parse report: ${err.message}` };
343
+ }
344
+
345
+ const findings = report.findings ?? [];
346
+ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
347
+ const filtered = severity
348
+ ? findings.filter(f => (SEV_RANK[f.severity] ?? 0) >= (SEV_RANK[severity] ?? 0))
349
+ : findings;
350
+
351
+ return {
352
+ reportPath: absPath,
353
+ scannedAt: report.scannedAt,
354
+ score: report.score,
355
+ grade: report.grade,
356
+ totalFindings: filtered.length,
357
+ bySeverity: report.bySeverity,
358
+ findings: filtered,
359
+ summary: report.summary,
360
+ ...(severity ? { filter: `severity >= ${severity}` } : {}),
361
+ };
362
+ }
363
+
364
+ function suppressFinding({ file, line, reason }) {
365
+ const absPath = path.resolve(file);
366
+
367
+ if (!fs.existsSync(absPath)) {
368
+ return { error: `File not found: ${absPath}` };
369
+ }
370
+
371
+ let content;
372
+ try {
373
+ content = fs.readFileSync(absPath, 'utf-8');
374
+ } catch (err) {
375
+ return { error: `Cannot read file: ${err.message}` };
376
+ }
377
+
378
+ const lines = content.split('\n');
379
+ const lineIdx = line - 1; // Convert to 0-indexed
380
+
381
+ if (lineIdx < 0 || lineIdx >= lines.length) {
382
+ return { error: `Line ${line} is out of range (file has ${lines.length} lines)` };
383
+ }
384
+
385
+ const targetLine = lines[lineIdx];
386
+
387
+ // Already suppressed?
388
+ if (/ship-safe-ignore/i.test(targetLine)) {
389
+ return { alreadySuppressed: true, file: absPath, line, message: 'Line already has a ship-safe-ignore comment.' };
390
+ }
391
+
392
+ // Detect indentation and comment style
393
+ const indent = targetLine.match(/^(\s*)/)?.[1] ?? '';
394
+ const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(file);
395
+ const isPy = /\.py$/.test(file);
396
+ const isRb = /\.rb$/.test(file);
397
+ const isHtml = /\.(html?|vue|svelte)$/.test(file);
398
+
399
+ let ignoreComment;
400
+ if (isHtml) {
401
+ ignoreComment = `${indent}<!-- ship-safe-ignore — ${reason} -->`;
402
+ } else if (isPy || isRb) {
403
+ ignoreComment = `${indent}# ship-safe-ignore — ${reason}`;
404
+ } else {
405
+ ignoreComment = `${indent}// ship-safe-ignore — ${reason}`;
406
+ }
407
+
408
+ // Insert ignore comment on the line BEFORE the finding
409
+ lines.splice(lineIdx, 0, ignoreComment);
410
+
411
+ try {
412
+ fs.writeFileSync(absPath, lines.join('\n'), 'utf-8');
413
+ } catch (err) {
414
+ return { error: `Cannot write file: ${err.message}` };
415
+ }
416
+
417
+ return {
418
+ suppressed: true,
419
+ file: absPath,
420
+ originalLine: line,
421
+ insertedLine: line, // The ignore comment is now on this line, original moved to line+1
422
+ comment: ignoreComment,
423
+ message: `Added ship-safe-ignore comment before line ${line} in ${path.basename(file)}.`,
424
+ };
425
+ }
426
+
166
427
  // =============================================================================
167
428
  // SCAN UTILITIES (shared with scan command)
168
429
  // =============================================================================
@@ -283,6 +544,15 @@ async function handleRequest(request) {
283
544
  case 'analyze_file':
284
545
  result = await analyzeFile(args);
285
546
  break;
547
+ case 'scan_repo':
548
+ result = await scanRepo(args);
549
+ break;
550
+ case 'get_findings':
551
+ result = getFindings(args);
552
+ break;
553
+ case 'suppress_finding':
554
+ result = suppressFinding(args);
555
+ break;
286
556
  default:
287
557
  return respondError(-32601, `Unknown tool: ${name}`);
288
558
  }
@@ -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 || {};