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
|
@@ -19,6 +19,59 @@ import { createHash } from 'crypto';
|
|
|
19
19
|
import * as output from '../utils/output.js';
|
|
20
20
|
import { ThreatIntel } from '../utils/threat-intel.js';
|
|
21
21
|
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// HERMES SKILL FRONTMATTER PATTERNS (Track D — cross-skill/tool binding)
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
// Built-in tool registries that skills may reference.
|
|
27
|
+
// Ship Safe tools are added lazily in checkHermesFrontmatter() to avoid
|
|
28
|
+
// loading hermes-tool-registry.js (and its crypto import) on every invocation.
|
|
29
|
+
const KNOWN_TOOL_REGISTRIES = {
|
|
30
|
+
// Common Hermes community tools (names only — no handler)
|
|
31
|
+
'web_search': 'hermes-community',
|
|
32
|
+
'web_browser': 'hermes-community',
|
|
33
|
+
'file_read': 'hermes-community',
|
|
34
|
+
'file_write': 'hermes-community',
|
|
35
|
+
'code_execute': 'hermes-community',
|
|
36
|
+
'github_api': 'hermes-community',
|
|
37
|
+
'memory_store': 'hermes-community',
|
|
38
|
+
'memory_retrieve': 'hermes-community',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Hermes-specific patterns to check in skill markdown/frontmatter
|
|
42
|
+
const HERMES_SKILL_PATTERNS = [
|
|
43
|
+
{
|
|
44
|
+
name: 'Hermes: XML tool_call injection',
|
|
45
|
+
regex: /<tool_call>[\s\S]{0,300}<\/tool_call>/gi,
|
|
46
|
+
severity: 'critical',
|
|
47
|
+
note: 'Skill body contains a <tool_call> block — will be executed by Hermes agents that load this skill.',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'Hermes: function_calls injection',
|
|
51
|
+
regex: /<function_calls>[\s\S]{0,300}<\/function_calls>/gi,
|
|
52
|
+
severity: 'critical',
|
|
53
|
+
note: 'Skill body contains a <function_calls> block — classic Hermes function-call injection.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'Hermes: Forced tool invocation instruction',
|
|
57
|
+
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,
|
|
58
|
+
severity: 'high',
|
|
59
|
+
note: 'Skill instructs agent to call a specific tool unconditionally — bypasses agent autonomy.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'Hermes: Plan/goal hijacking',
|
|
63
|
+
regex: /(?:update\s+(?:your\s+)?(?:goal|plan|objective)\s+to|change\s+(?:your\s+)?(?:goal|plan|objective)|your\s+(?:new\s+)?(?:goal|plan|primary\s+objective)\s+(?:is|should\s+be))/gi,
|
|
64
|
+
severity: 'critical',
|
|
65
|
+
note: 'Skill attempts to overwrite the agent\'s goal or plan state — ASI-01 Goal Hijacking.',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'Hermes: Memory layer write instruction',
|
|
69
|
+
regex: /(?:write\s+(?:this|the\s+following)\s+to\s+(?:memory|episodic|semantic|working)\s+memory|store\s+(?:this|the\s+following)\s+in\s+(?:memory|episodic|semantic))/gi,
|
|
70
|
+
severity: 'high',
|
|
71
|
+
note: 'Skill instructs agent to write attacker-controlled data to memory — ASI-06 Memory Poisoning.',
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
22
75
|
// =============================================================================
|
|
23
76
|
// POPULAR SKILL NAMES (for typosquatting detection)
|
|
24
77
|
// =============================================================================
|
|
@@ -113,7 +166,7 @@ export async function scanSkillCommand(target, options = {}) {
|
|
|
113
166
|
console.log(chalk.gray(` Size: ${content.length} bytes`));
|
|
114
167
|
console.log();
|
|
115
168
|
|
|
116
|
-
const findings = analyzeSkill(content, skillName, source);
|
|
169
|
+
const findings = await analyzeSkill(content, skillName, source);
|
|
117
170
|
|
|
118
171
|
if (options.json) {
|
|
119
172
|
console.log(JSON.stringify({ skill: skillName, source, findings, summary: getSummary(findings) }, null, 2));
|
|
@@ -127,7 +180,7 @@ export async function scanSkillCommand(target, options = {}) {
|
|
|
127
180
|
// SKILL ANALYSIS
|
|
128
181
|
// =============================================================================
|
|
129
182
|
|
|
130
|
-
function analyzeSkill(content, skillName, source) {
|
|
183
|
+
async function analyzeSkill(content, skillName, source) {
|
|
131
184
|
const findings = [];
|
|
132
185
|
|
|
133
186
|
// 1. Static pattern analysis
|
|
@@ -152,10 +205,12 @@ function analyzeSkill(content, skillName, source) {
|
|
|
152
205
|
try {
|
|
153
206
|
const manifest = JSON.parse(content);
|
|
154
207
|
if (manifest.permissions) {
|
|
155
|
-
const
|
|
208
|
+
const dangerousPerm = [/\bshell\b/i, /\bexec\b/i, /\bsystem\b/i, /\badmin\b/i, /\broot\b/i,
|
|
209
|
+
/filesystem\s*:\s*(write|read-write)/i, /network\s*:\s*(unrestricted|all)/i,
|
|
210
|
+
/^filesystem$/i, /^network$/i];
|
|
156
211
|
for (const perm of (Array.isArray(manifest.permissions) ? manifest.permissions : [])) {
|
|
157
212
|
const permStr = typeof perm === 'string' ? perm : perm.name || '';
|
|
158
|
-
if (
|
|
213
|
+
if (dangerousPerm.some(p => p.test(permStr))) {
|
|
159
214
|
findings.push({
|
|
160
215
|
check: 'permission-audit',
|
|
161
216
|
name: `Dangerous permission: ${permStr}`,
|
|
@@ -216,6 +271,194 @@ function analyzeSkill(content, skillName, source) {
|
|
|
216
271
|
});
|
|
217
272
|
}
|
|
218
273
|
|
|
274
|
+
// 6. Hermes-specific: frontmatter tool binding + permission drift validation
|
|
275
|
+
findings.push(...(await checkHermesFrontmatter(content)));
|
|
276
|
+
|
|
277
|
+
// 7. Hermes-specific: function-call injection and goal hijacking in body
|
|
278
|
+
findings.push(...checkHermesBodyPatterns(content, lines));
|
|
279
|
+
|
|
280
|
+
return findings;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// =============================================================================
|
|
284
|
+
// HERMES FRONTMATTER VALIDATION (Track D)
|
|
285
|
+
// =============================================================================
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Parse YAML frontmatter block (between --- delimiters) from markdown skill.
|
|
289
|
+
* Returns a plain object with string/array values; null if no frontmatter.
|
|
290
|
+
*/
|
|
291
|
+
function parseFrontmatter(content) {
|
|
292
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
293
|
+
if (!match) return null;
|
|
294
|
+
|
|
295
|
+
const fm = {};
|
|
296
|
+
const yamlBlock = match[1];
|
|
297
|
+
|
|
298
|
+
for (const line of yamlBlock.split('\n')) {
|
|
299
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
300
|
+
if (!kv) continue;
|
|
301
|
+
const [, key, rawVal] = kv;
|
|
302
|
+
const val = rawVal.trim();
|
|
303
|
+
|
|
304
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
305
|
+
// Inline array: [a, b, c]
|
|
306
|
+
fm[key] = val.slice(1, -1).split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean);
|
|
307
|
+
} else {
|
|
308
|
+
fm[key] = val.replace(/^['"]|['"]$/g, '');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Collect multi-line list values (indented - items)
|
|
313
|
+
const listRe = /^(\w[\w-]*):\s*\n((?:\s+-\s+.+\n?)+)/gm;
|
|
314
|
+
let m;
|
|
315
|
+
while ((m = listRe.exec(yamlBlock)) !== null) {
|
|
316
|
+
const [, key, block] = m;
|
|
317
|
+
fm[key] = block.match(/-\s+(.+)/g)?.map(s => s.replace(/^-\s+/, '').replace(/['"]/g, '').trim()) ?? [];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return fm;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let _hermesToolsLoaded = false;
|
|
324
|
+
async function ensureHermesToolsLoaded() {
|
|
325
|
+
if (_hermesToolsLoaded) return;
|
|
326
|
+
try {
|
|
327
|
+
const { HERMES_TOOLS } = await import('../utils/hermes-tool-registry.js');
|
|
328
|
+
for (const t of HERMES_TOOLS) KNOWN_TOOL_REGISTRIES[t.name] = 'ship-safe';
|
|
329
|
+
} catch { /* non-fatal — registry unavailable */ }
|
|
330
|
+
_hermesToolsLoaded = true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function checkHermesFrontmatter(content) {
|
|
334
|
+
await ensureHermesToolsLoaded();
|
|
335
|
+
const findings = [];
|
|
336
|
+
const fm = parseFrontmatter(content);
|
|
337
|
+
|
|
338
|
+
// Not a markdown skill with frontmatter — skip
|
|
339
|
+
if (!fm) return findings;
|
|
340
|
+
|
|
341
|
+
// ── Check: missing permissions field ──────────────────────────────────────
|
|
342
|
+
if (!fm.permissions) {
|
|
343
|
+
findings.push({
|
|
344
|
+
check: 'hermes-frontmatter',
|
|
345
|
+
name: 'Hermes: Skill missing permissions field (ASI-02 Excessive Agency)',
|
|
346
|
+
severity: 'medium',
|
|
347
|
+
line: 0,
|
|
348
|
+
matched: 'No permissions: field in frontmatter — skill may be granted more access than intended',
|
|
349
|
+
});
|
|
350
|
+
} else {
|
|
351
|
+
// ── Check: wildcard permissions ──────────────────────────────────────────
|
|
352
|
+
const perms = Array.isArray(fm.permissions) ? fm.permissions : [fm.permissions];
|
|
353
|
+
const wildcards = perms.filter(p => /^\*$|^all$|^any$/i.test(String(p)));
|
|
354
|
+
if (wildcards.length > 0) {
|
|
355
|
+
findings.push({
|
|
356
|
+
check: 'hermes-frontmatter',
|
|
357
|
+
name: 'Hermes: Wildcard permissions (* / all) — excessive agency (ASI-02)',
|
|
358
|
+
severity: 'high',
|
|
359
|
+
line: 0,
|
|
360
|
+
matched: `permissions: [${wildcards.join(', ')}]`,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Check: dangerous explicit permissions ────────────────────────────────
|
|
365
|
+
// Match whole-word or exact qualified values — don't fire on "filesystem: read-only"
|
|
366
|
+
const dangerousPatterns = [
|
|
367
|
+
/\bshell\b/i, /\bexec\b/i, /\bsystem\b/i, /\badmin\b/i, /\broot\b/i, /\bsudo\b/i,
|
|
368
|
+
/filesystem\s*:\s*write/i, /filesystem\s*:\s*read-write/i,
|
|
369
|
+
/network\s*:\s*unrestricted/i, /network\s*:\s*all/i,
|
|
370
|
+
/^filesystem$/i, /^network$/i, // bare "filesystem" or "network" without qualifier is ambiguous → flag
|
|
371
|
+
];
|
|
372
|
+
for (const perm of perms) {
|
|
373
|
+
if (dangerousPatterns.some(p => p.test(String(perm)))) {
|
|
374
|
+
findings.push({
|
|
375
|
+
check: 'hermes-frontmatter',
|
|
376
|
+
name: `Hermes: Dangerous permission declared: ${perm}`,
|
|
377
|
+
severity: 'high',
|
|
378
|
+
line: 0,
|
|
379
|
+
matched: `permissions: [${perm}]`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Check: missing version pin ────────────────────────────────────────────
|
|
386
|
+
if (!fm.version) {
|
|
387
|
+
findings.push({
|
|
388
|
+
check: 'hermes-frontmatter',
|
|
389
|
+
name: 'Hermes: Skill missing version field — unpinned skill (ASI-10 Supply Chain)',
|
|
390
|
+
severity: 'medium',
|
|
391
|
+
line: 0,
|
|
392
|
+
matched: 'No version: field in frontmatter — skill version drift cannot be detected',
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Check: cross-skill tool binding validation ────────────────────────────
|
|
397
|
+
const tools = Array.isArray(fm.tools) ? fm.tools : fm.tools ? [fm.tools] : [];
|
|
398
|
+
for (const toolName of tools) {
|
|
399
|
+
if (!KNOWN_TOOL_REGISTRIES[toolName]) {
|
|
400
|
+
findings.push({
|
|
401
|
+
check: 'hermes-tool-binding',
|
|
402
|
+
name: `Hermes: Unresolvable tool reference: "${toolName}"`,
|
|
403
|
+
severity: 'high',
|
|
404
|
+
line: 0,
|
|
405
|
+
matched: `tools: [${toolName}] — not found in any known tool registry. May cause silent failures or late-binding substitution.`,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Check: tools declared but no permissions field ────────────────────────
|
|
411
|
+
if (tools.length > 0 && !fm.permissions) {
|
|
412
|
+
findings.push({
|
|
413
|
+
check: 'hermes-tool-binding',
|
|
414
|
+
name: 'Hermes: Skill declares tools without permissions (permission drift)',
|
|
415
|
+
severity: 'high',
|
|
416
|
+
line: 0,
|
|
417
|
+
matched: `tools: [${tools.join(', ')}] declared but no permissions: field — skill runs with ambient agent permissions`,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return findings;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function checkHermesBodyPatterns(content, lines) {
|
|
425
|
+
const findings = [];
|
|
426
|
+
|
|
427
|
+
for (let i = 0; i < lines.length; i++) {
|
|
428
|
+
const line = lines[i];
|
|
429
|
+
for (const pattern of HERMES_SKILL_PATTERNS) {
|
|
430
|
+
pattern.regex.lastIndex = 0;
|
|
431
|
+
if (pattern.regex.test(line)) {
|
|
432
|
+
findings.push({
|
|
433
|
+
check: 'hermes-injection',
|
|
434
|
+
name: pattern.name,
|
|
435
|
+
severity: pattern.severity,
|
|
436
|
+
line: i + 1,
|
|
437
|
+
matched: line.trim().slice(0, 100),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Multi-line checks for <tool_call> blocks that span lines
|
|
444
|
+
for (const pattern of HERMES_SKILL_PATTERNS) {
|
|
445
|
+
pattern.regex.lastIndex = 0;
|
|
446
|
+
const match = pattern.regex.exec(content);
|
|
447
|
+
if (match) {
|
|
448
|
+
// Avoid duplicate if already caught line-by-line
|
|
449
|
+
const alreadyFound = findings.some(f => f.name === pattern.name);
|
|
450
|
+
if (!alreadyFound) {
|
|
451
|
+
findings.push({
|
|
452
|
+
check: 'hermes-injection',
|
|
453
|
+
name: pattern.name,
|
|
454
|
+
severity: pattern.severity,
|
|
455
|
+
line: 0,
|
|
456
|
+
matched: match[0].slice(0, 100),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
219
462
|
return findings;
|
|
220
463
|
}
|
|
221
464
|
|
|
@@ -281,7 +524,7 @@ async function scanAllSkills(rootPath) {
|
|
|
281
524
|
const response = await fetch(url);
|
|
282
525
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
283
526
|
const content = await response.text();
|
|
284
|
-
const findings = analyzeSkill(content, name, url);
|
|
527
|
+
const findings = await analyzeSkill(content, name, url);
|
|
285
528
|
if (findings.length > 0) {
|
|
286
529
|
printSkillFindings(findings, name);
|
|
287
530
|
} else {
|
package/cli/index.js
CHANGED
|
@@ -71,3 +71,8 @@ export { CacheManager } from './utils/cache-manager.js';
|
|
|
71
71
|
|
|
72
72
|
// ── LLM Providers ─────────────────────────────────────────────────────────────
|
|
73
73
|
export { createProvider, autoDetectProvider } from './providers/llm-provider.js';
|
|
74
|
+
|
|
75
|
+
// ── v8.0.0 — Ship Safe × Hermes Agent ────────────────────────────────────────
|
|
76
|
+
export { HermesSecurityAgent } from './agents/hermes-security-agent.js';
|
|
77
|
+
export { AgentAttestationAgent } from './agents/agent-attestation-agent.js';
|
|
78
|
+
export { HERMES_TOOLS, registerWithHermes, verifyIntegrity } from './utils/hermes-tool-registry.js';
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermes Tool Registry — Ship Safe × Hermes Agent
|
|
3
|
+
* =================================================
|
|
4
|
+
*
|
|
5
|
+
* Declares Ship Safe's five security tools in the Hermes Agent tool-registry
|
|
6
|
+
* format. Import this module in your Hermes agent bootstrap to register
|
|
7
|
+
* Ship Safe as a first-class citizen in the tool registry.
|
|
8
|
+
*
|
|
9
|
+
* USAGE:
|
|
10
|
+
* import { HERMES_TOOLS, registerWithHermes } from './hermes-tool-registry.js';
|
|
11
|
+
*
|
|
12
|
+
* // Option A — register all tools at once
|
|
13
|
+
* await registerWithHermes(agent.toolRegistry);
|
|
14
|
+
*
|
|
15
|
+
* // Option B — use the raw definitions
|
|
16
|
+
* for (const tool of HERMES_TOOLS) agent.toolRegistry.register(tool);
|
|
17
|
+
*
|
|
18
|
+
* SECURITY NOTE:
|
|
19
|
+
* These definitions are pinned, hardcoded, and integrity-verified at load
|
|
20
|
+
* time. They are never fetched from a remote URL. Do not replace the
|
|
21
|
+
* INTEGRITY_HASH values without auditing the updated definitions.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from 'crypto';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// TOOL DEFINITIONS (Hermes function-call schema format)
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
export const HERMES_TOOLS = [
|
|
35
|
+
{
|
|
36
|
+
name: 'ship_safe_audit',
|
|
37
|
+
description:
|
|
38
|
+
'Run a Ship Safe security audit on a local codebase directory. ' +
|
|
39
|
+
'Returns a findings report with severity-graded issues, CWE/OWASP mappings, ' +
|
|
40
|
+
'and remediation guidance. Use before deploying any code or merging PRs.',
|
|
41
|
+
parameters: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
path: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
description: 'Absolute path to the project root directory to scan.',
|
|
47
|
+
},
|
|
48
|
+
severity: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
enum: ['critical', 'high', 'medium', 'low'],
|
|
51
|
+
description: 'Minimum severity threshold for reported findings.',
|
|
52
|
+
default: 'medium',
|
|
53
|
+
},
|
|
54
|
+
deep: {
|
|
55
|
+
type: 'boolean',
|
|
56
|
+
description: 'Enable deep LLM-powered taint analysis (Haiku→Sonnet→Opus pipeline). Slower but more accurate.',
|
|
57
|
+
default: false,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
required: ['path'],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
handler: async ({ path: scanPath, severity = 'medium', deep = false }) => {
|
|
64
|
+
const { auditCommand } = await import('../commands/audit.js');
|
|
65
|
+
return auditCommand(scanPath, { severity, deep, json: true, quiet: true });
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
name: 'ship_safe_scan_mcp',
|
|
71
|
+
description:
|
|
72
|
+
'Analyze an MCP server manifest (URL or local file path) for security issues ' +
|
|
73
|
+
'before connecting. Checks for prompt injection in tool descriptions, credential ' +
|
|
74
|
+
'harvesting patterns, Hermes function-call poisoning, schema bypass (additionalProperties: true), ' +
|
|
75
|
+
'and known-malicious server hashes. Returns per-tool findings.',
|
|
76
|
+
parameters: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
target: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'URL (https://...) or absolute local file path to the MCP manifest JSON.',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
required: ['target'],
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
},
|
|
87
|
+
handler: async ({ target }) => {
|
|
88
|
+
const { scanMcpCommand } = await import('../commands/scan-mcp.js');
|
|
89
|
+
return scanMcpCommand(target, { json: true });
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
name: 'ship_safe_get_findings',
|
|
95
|
+
description:
|
|
96
|
+
'Retrieve findings from the last saved Ship Safe scan report for a project. ' +
|
|
97
|
+
'Optionally filter by minimum severity. Returns an array of findings with rule, ' +
|
|
98
|
+
'title, severity, file, line, and remediation guidance.',
|
|
99
|
+
parameters: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
path: {
|
|
103
|
+
type: 'string',
|
|
104
|
+
description: 'Absolute path to the project root (used to locate the saved report).',
|
|
105
|
+
},
|
|
106
|
+
severity: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
enum: ['critical', 'high', 'medium', 'low'],
|
|
109
|
+
description: 'Minimum severity to include in results.',
|
|
110
|
+
default: 'medium',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: ['path'],
|
|
114
|
+
additionalProperties: false,
|
|
115
|
+
},
|
|
116
|
+
handler: async ({ path: projectPath, severity = 'medium' }) => {
|
|
117
|
+
const { mcpGetFindings } = await import('../commands/mcp.js');
|
|
118
|
+
return mcpGetFindings({ projectPath, severity });
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
name: 'ship_safe_suppress_finding',
|
|
124
|
+
description:
|
|
125
|
+
'Suppress a known-safe finding by inserting an inline ship-safe-ignore comment ' +
|
|
126
|
+
'in the source file before the flagged line. Use only when the finding is a ' +
|
|
127
|
+
'confirmed false positive and you can document why it is safe.',
|
|
128
|
+
parameters: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: {
|
|
131
|
+
file: {
|
|
132
|
+
type: 'string',
|
|
133
|
+
description: 'Absolute path to the source file containing the finding.',
|
|
134
|
+
},
|
|
135
|
+
line: {
|
|
136
|
+
type: 'number',
|
|
137
|
+
description: 'Line number of the finding (1-based).',
|
|
138
|
+
},
|
|
139
|
+
reason: {
|
|
140
|
+
type: 'string',
|
|
141
|
+
description: 'Human-readable explanation of why this finding is safe to suppress.',
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
required: ['file', 'line', 'reason'],
|
|
145
|
+
additionalProperties: false,
|
|
146
|
+
},
|
|
147
|
+
handler: async ({ file, line, reason }) => {
|
|
148
|
+
const { mcpSuppressFinding } = await import('../commands/mcp.js');
|
|
149
|
+
return mcpSuppressFinding({ file, line, reason });
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
name: 'ship_safe_memory_list',
|
|
155
|
+
description:
|
|
156
|
+
'List all entries in the Ship Safe security memory for a project. ' +
|
|
157
|
+
'The memory stores learned false positives that are automatically filtered ' +
|
|
158
|
+
'from future scans. Returns each entry with its rule, file pattern, and ' +
|
|
159
|
+
'the snippet that was suppressed.',
|
|
160
|
+
parameters: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
path: {
|
|
164
|
+
type: 'string',
|
|
165
|
+
description: 'Absolute path to the project root.',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
required: ['path'],
|
|
169
|
+
additionalProperties: false,
|
|
170
|
+
},
|
|
171
|
+
handler: async ({ path: projectPath }) => {
|
|
172
|
+
const { SecurityMemory } = await import('./security-memory.js');
|
|
173
|
+
const mem = new SecurityMemory(projectPath);
|
|
174
|
+
return mem.list();
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
// =============================================================================
|
|
180
|
+
// INTEGRITY VERIFICATION
|
|
181
|
+
// Each tool definition is hashed at module load time. If the registry is
|
|
182
|
+
// tampered with (e.g. supply-chain attack), the hash check will fail.
|
|
183
|
+
// Run `node -e "import('./hermes-tool-registry.js').then(m => m.printHashes())"` to regenerate.
|
|
184
|
+
// =============================================================================
|
|
185
|
+
|
|
186
|
+
const KNOWN_HASHES = {
|
|
187
|
+
ship_safe_audit: '4d282d29e44fcc01',
|
|
188
|
+
ship_safe_scan_mcp: 'f967aea9626ca840',
|
|
189
|
+
ship_safe_get_findings: 'c09c9447efd574b3',
|
|
190
|
+
ship_safe_suppress_finding: '3b7339419fe52ac7',
|
|
191
|
+
ship_safe_memory_list: 'c71c996716d1805b',
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
function toolHash(tool) {
|
|
195
|
+
// Hash name + description + parameter schema only (not handler function)
|
|
196
|
+
const canonical = JSON.stringify({ name: tool.name, description: tool.description, parameters: tool.parameters });
|
|
197
|
+
return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function verifyIntegrity() {
|
|
201
|
+
const mismatches = [];
|
|
202
|
+
for (const tool of HERMES_TOOLS) {
|
|
203
|
+
const actual = toolHash(tool);
|
|
204
|
+
const expected = KNOWN_HASHES[tool.name];
|
|
205
|
+
if (expected && actual !== expected) {
|
|
206
|
+
mismatches.push({ tool: tool.name, expected, actual });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return mismatches;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function printHashes() {
|
|
213
|
+
console.log('// Current tool definition hashes — paste into KNOWN_HASHES:');
|
|
214
|
+
for (const tool of HERMES_TOOLS) {
|
|
215
|
+
console.log(` ${tool.name}: '${toolHash(tool)}',`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// REGISTRATION HELPER
|
|
221
|
+
// =============================================================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Register all Ship Safe tools with a Hermes tool registry instance.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} toolRegistry — Hermes ToolRegistry instance with a .register() method
|
|
227
|
+
* @param {object} options
|
|
228
|
+
* @param {boolean} [options.skipVerification=false] — bypass hash verification (not recommended)
|
|
229
|
+
* @param {boolean} [options.quiet=false] — suppress registration log lines
|
|
230
|
+
*/
|
|
231
|
+
export async function registerWithHermes(toolRegistry, options = {}) {
|
|
232
|
+
if (!options.skipVerification) { // ship-safe-ignore — this is the integrity-check implementation, not a bypass
|
|
233
|
+
const mismatches = verifyIntegrity();
|
|
234
|
+
if (mismatches.length > 0) {
|
|
235
|
+
const msg = mismatches.map(m => ` ${m.tool}: expected ${m.expected}, got ${m.actual}`).join('\n');
|
|
236
|
+
throw new Error(`Ship Safe tool registry integrity check failed:\n${msg}\n\nThis may indicate a supply-chain attack. Run ship-safe --version to verify your installation.`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (const tool of HERMES_TOOLS) {
|
|
241
|
+
if (typeof toolRegistry.register === 'function') {
|
|
242
|
+
toolRegistry.register(tool);
|
|
243
|
+
} else if (typeof toolRegistry.registerTool === 'function') {
|
|
244
|
+
toolRegistry.registerTool(tool);
|
|
245
|
+
} else {
|
|
246
|
+
throw new Error('toolRegistry must have a .register() or .registerTool() method');
|
|
247
|
+
}
|
|
248
|
+
if (!options.quiet) {
|
|
249
|
+
console.log(` [ship-safe] Registered tool: ${tool.name}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|