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.
- package/README.md +80 -23
- package/cli/agents/agent-attestation-agent.js +318 -0
- package/cli/agents/agent-config-scanner.js +15 -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/deep-analyzer.js +39 -19
- package/cli/agents/hermes-security-agent.js +536 -0
- package/cli/agents/index.js +65 -21
- package/cli/agents/managed-agent-scanner.js +333 -0
- package/cli/agents/memory-poisoning-agent.js +304 -0
- package/cli/agents/scoring-engine.js +16 -1
- package/cli/agents/supply-chain-agent.js +129 -3
- package/cli/bin/ship-safe.js +178 -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/live-advisories.js +241 -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/commands/watch.js +205 -0
- package/cli/index.js +5 -0
- package/cli/providers/llm-provider.js +89 -1
- package/cli/utils/compliance-map.js +66 -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
|
@@ -50,6 +50,72 @@ const AGENTIC_MAP = {
|
|
|
50
50
|
'ASI10': { soc2: ['CC7.2', 'CC7.4'], iso27001: ['A.8.9', 'A.5.30'], nistAiRmf: ['MANAGE 2.2', 'MANAGE 4.1'] },
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// OWASP AGENTIC AI TOP 10 (December 2025)
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
const OWASP_AGENTIC_TOP_10 = {
|
|
58
|
+
ASI01: { id: 'ASI01', title: 'Agent Goal Hijacking', description: 'Manipulation of agent objectives through prompt injection, memory poisoning, or instruction override.' },
|
|
59
|
+
ASI02: { id: 'ASI02', title: 'Tool Misuse', description: 'Agent uses tools in unintended or dangerous ways — shell execution, file deletion, network access beyond scope.' },
|
|
60
|
+
ASI03: { id: 'ASI03', title: 'Privilege Abuse', description: 'Agent operates with excessive permissions — writes outside project, accesses secrets, escalates access.' },
|
|
61
|
+
ASI04: { id: 'ASI04', title: 'Agentic Supply Chain', description: 'Compromised skills, MCP servers, or tool packages that the agent depends on.' },
|
|
62
|
+
ASI05: { id: 'ASI05', title: 'Memory & Context Poisoning', description: 'Malicious data persisted in agent memory, rules files, or context that survives sessions.' },
|
|
63
|
+
ASI06: { id: 'ASI06', title: 'Uncontrolled Data Exposure', description: 'Agent leaks code, secrets, or PII through tool outputs, logs, or external API calls.' },
|
|
64
|
+
ASI07: { id: 'ASI07', title: 'Insecure Communication', description: 'Unencrypted MCP transport, HTTP model endpoints, or plaintext inter-agent messaging.' },
|
|
65
|
+
ASI08: { id: 'ASI08', title: 'Missing Human Oversight', description: 'Agent takes destructive or irreversible actions without user confirmation — proactive mode risks.' },
|
|
66
|
+
ASI09: { id: 'ASI09', title: 'Weak Identity & Auth', description: 'Agent sessions without authentication, shared API keys, or no audit trail of actions.' },
|
|
67
|
+
ASI10: { id: 'ASI10', title: 'Rogue Agent Behavior', description: 'Agent deviates from intended behavior — self-modification, stealth mode, output suppression.' },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Enrich a finding with OWASP Agentic Top 10 metadata.
|
|
72
|
+
* Attaches `agenticRisk` object if the finding maps to ASI01–ASI10.
|
|
73
|
+
* @param {object} finding
|
|
74
|
+
* @returns {object} — finding with agenticRisk attached (or unchanged)
|
|
75
|
+
*/
|
|
76
|
+
export function enrichAgenticRisk(finding) {
|
|
77
|
+
const owasp = finding.owasp;
|
|
78
|
+
if (!owasp || !OWASP_AGENTIC_TOP_10[owasp]) return finding;
|
|
79
|
+
|
|
80
|
+
const risk = OWASP_AGENTIC_TOP_10[owasp];
|
|
81
|
+
finding.agenticRisk = {
|
|
82
|
+
id: risk.id,
|
|
83
|
+
title: risk.title,
|
|
84
|
+
description: risk.description,
|
|
85
|
+
};
|
|
86
|
+
return finding;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get OWASP Agentic Top 10 summary across all findings.
|
|
91
|
+
* @param {object[]} findings
|
|
92
|
+
* @returns {{ risks: object[], coverage: number }}
|
|
93
|
+
*/
|
|
94
|
+
export function getAgenticSummary(findings) {
|
|
95
|
+
const counts = {};
|
|
96
|
+
for (const f of findings) {
|
|
97
|
+
const owasp = f.owasp;
|
|
98
|
+
if (owasp && OWASP_AGENTIC_TOP_10[owasp]) {
|
|
99
|
+
counts[owasp] = (counts[owasp] || 0) + 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const risks = Object.entries(OWASP_AGENTIC_TOP_10).map(([id, info]) => ({
|
|
104
|
+
...info,
|
|
105
|
+
findingCount: counts[id] || 0,
|
|
106
|
+
status: counts[id] ? 'flagged' : 'clear',
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const flagged = risks.filter(r => r.findingCount > 0).length;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
risks,
|
|
113
|
+
flagged,
|
|
114
|
+
total: 10,
|
|
115
|
+
coverage: `${flagged}/10`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
53
119
|
// =============================================================================
|
|
54
120
|
// PUBLIC API
|
|
55
121
|
// =============================================================================
|
|
@@ -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
|
+
}
|
package/cli/utils/patterns.js
CHANGED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Loader — Custom Agent Plugin System
|
|
3
|
+
* ============================================
|
|
4
|
+
*
|
|
5
|
+
* Allows users to drop custom security agents into `.ship-safe/agents/` and
|
|
6
|
+
* have them automatically loaded and run alongside the built-in agents.
|
|
7
|
+
*
|
|
8
|
+
* HOW IT WORKS:
|
|
9
|
+
* 1. On startup, loadPlugins(rootPath) scans `.ship-safe/agents/*.js`
|
|
10
|
+
* 2. Each file must export a default class that extends BaseAgent
|
|
11
|
+
* 3. Validated plugins are instantiated and returned for registration
|
|
12
|
+
* 4. buildOrchestrator() calls loadPlugins() and registers the results
|
|
13
|
+
*
|
|
14
|
+
* PLUGIN CONTRACT:
|
|
15
|
+
* A valid plugin must:
|
|
16
|
+
* - Export a default class (ES module)
|
|
17
|
+
* - Extend BaseAgent (from ship-safe's agent framework)
|
|
18
|
+
* - Implement `async analyze(context)` returning an array of findings
|
|
19
|
+
* - Set `this.name` and `this.category` in the constructor
|
|
20
|
+
*
|
|
21
|
+
* EXAMPLE PLUGIN:
|
|
22
|
+
*
|
|
23
|
+
* // .ship-safe/agents/my-rule.js
|
|
24
|
+
* import { BaseAgent, createFinding } from 'ship-safe';
|
|
25
|
+
*
|
|
26
|
+
* export default class MyCustomRule extends BaseAgent {
|
|
27
|
+
* constructor() {
|
|
28
|
+
* super();
|
|
29
|
+
* this.name = 'MyCustomRule';
|
|
30
|
+
* this.category = 'custom';
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* async analyze({ rootPath, files }) {
|
|
34
|
+
* const findings = [];
|
|
35
|
+
* for (const file of files) {
|
|
36
|
+
* const content = fs.readFileSync(file, 'utf-8');
|
|
37
|
+
* if (content.includes('eval(')) { // ship-safe-ignore — JSDoc example, not real eval
|
|
38
|
+
* findings.push(createFinding({
|
|
39
|
+
* rule: 'CUSTOM_EVAL',
|
|
40
|
+
* severity: 'high',
|
|
41
|
+
* title: 'Dangerous eval() usage', // ship-safe-ignore — JSDoc string literal
|
|
42
|
+
* description: 'eval() can execute arbitrary code', // ship-safe-ignore — JSDoc string literal
|
|
43
|
+
* file,
|
|
44
|
+
* remediation: 'Replace eval() with safer alternatives', // ship-safe-ignore — JSDoc string literal
|
|
45
|
+
* }));
|
|
46
|
+
* }
|
|
47
|
+
* }
|
|
48
|
+
* return findings;
|
|
49
|
+
* }
|
|
50
|
+
* }
|
|
51
|
+
*
|
|
52
|
+
* PLUGIN ISOLATION:
|
|
53
|
+
* Plugins run in the same process but each agent gets its own timeout (30s).
|
|
54
|
+
* A crashing or hanging plugin does not affect other agents.
|
|
55
|
+
*
|
|
56
|
+
* SECURITY NOTE:
|
|
57
|
+
* Plugins are arbitrary code executed from the local filesystem. Never install
|
|
58
|
+
* plugins from untrusted sources. ship-safe will warn if plugins are detected.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import fs from 'fs';
|
|
62
|
+
import path from 'path';
|
|
63
|
+
import { pathToFileURL } from 'url';
|
|
64
|
+
|
|
65
|
+
const PLUGIN_DIR = '.ship-safe/agents';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load custom agent plugins from .ship-safe/agents/*.js
|
|
69
|
+
*
|
|
70
|
+
* @param {string} rootPath — project root directory
|
|
71
|
+
* @param {object} options — { verbose, quiet }
|
|
72
|
+
* @returns {Promise<object[]>} — array of instantiated agent objects
|
|
73
|
+
*/
|
|
74
|
+
export async function loadPlugins(rootPath, options = {}) {
|
|
75
|
+
const pluginDir = path.join(rootPath, PLUGIN_DIR);
|
|
76
|
+
|
|
77
|
+
if (!fs.existsSync(pluginDir)) return [];
|
|
78
|
+
|
|
79
|
+
let files;
|
|
80
|
+
try {
|
|
81
|
+
files = fs.readdirSync(pluginDir)
|
|
82
|
+
.filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
|
|
83
|
+
.map(f => path.join(pluginDir, f));
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (files.length === 0) return [];
|
|
89
|
+
|
|
90
|
+
if (!options.quiet) {
|
|
91
|
+
console.log(` Loading ${files.length} plugin(s) from ${PLUGIN_DIR}...`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const plugins = [];
|
|
95
|
+
|
|
96
|
+
for (const filePath of files) {
|
|
97
|
+
try {
|
|
98
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
99
|
+
const mod = await import(fileUrl);
|
|
100
|
+
const PluginClass = mod.default;
|
|
101
|
+
|
|
102
|
+
if (typeof PluginClass !== 'function') {
|
|
103
|
+
if (options.verbose) console.warn(` [plugin] ${path.basename(filePath)}: no default export class`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Validate the plugin before instantiation
|
|
108
|
+
const validation = validatePlugin(PluginClass, filePath);
|
|
109
|
+
if (!validation.valid) {
|
|
110
|
+
console.warn(` [plugin] ${path.basename(filePath)} skipped: ${validation.reason}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const instance = new PluginClass();
|
|
115
|
+
|
|
116
|
+
// Ensure required fields are set after construction
|
|
117
|
+
if (!instance.name) {
|
|
118
|
+
instance.name = path.basename(filePath, '.js');
|
|
119
|
+
}
|
|
120
|
+
if (!instance.category) {
|
|
121
|
+
instance.category = 'custom';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
plugins.push(instance);
|
|
125
|
+
|
|
126
|
+
if (!options.quiet) {
|
|
127
|
+
console.log(` [plugin] Loaded: ${instance.name} (${instance.category})`);
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.warn(` [plugin] Failed to load ${path.basename(filePath)}: ${err.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return plugins;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Validate a plugin class before instantiation.
|
|
139
|
+
* Does static checks only — does not instantiate.
|
|
140
|
+
*/
|
|
141
|
+
function validatePlugin(PluginClass, filePath) {
|
|
142
|
+
const name = path.basename(filePath);
|
|
143
|
+
|
|
144
|
+
if (typeof PluginClass !== 'function') {
|
|
145
|
+
return { valid: false, reason: 'default export is not a class/function' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check prototype has analyze() — the required method
|
|
149
|
+
const proto = PluginClass.prototype;
|
|
150
|
+
if (typeof proto?.analyze !== 'function') {
|
|
151
|
+
return { valid: false, reason: 'class does not implement analyze()' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { valid: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* List available plugins without loading them.
|
|
159
|
+
* Used by `ship-safe doctor` and `ship-safe plugins list`.
|
|
160
|
+
*/
|
|
161
|
+
export function listPluginFiles(rootPath) {
|
|
162
|
+
const pluginDir = path.join(rootPath, PLUGIN_DIR);
|
|
163
|
+
if (!fs.existsSync(pluginDir)) return [];
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
return fs.readdirSync(pluginDir)
|
|
167
|
+
.filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
|
|
168
|
+
.map(f => ({
|
|
169
|
+
name: path.basename(f, '.js'),
|
|
170
|
+
path: path.join(pluginDir, f),
|
|
171
|
+
size: fs.statSync(path.join(pluginDir, f)).size,
|
|
172
|
+
}));
|
|
173
|
+
} catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Scaffold a new plugin file in .ship-safe/agents/
|
|
180
|
+
*/
|
|
181
|
+
export function scaffoldPlugin(rootPath, pluginName) {
|
|
182
|
+
const pluginDir = path.join(rootPath, PLUGIN_DIR);
|
|
183
|
+
if (!fs.existsSync(pluginDir)) {
|
|
184
|
+
fs.mkdirSync(pluginDir, { recursive: true });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const safeName = pluginName.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
188
|
+
const className = safeName.replace(/-([a-z])/g, (_, c) => c.toUpperCase()).replace(/^[a-z]/, c => c.toUpperCase());
|
|
189
|
+
const filePath = path.join(pluginDir, `${safeName}.js`);
|
|
190
|
+
|
|
191
|
+
if (fs.existsSync(filePath)) {
|
|
192
|
+
throw new Error(`Plugin already exists: ${filePath}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const template = `/**
|
|
196
|
+
* Custom Ship Safe Agent: ${className}
|
|
197
|
+
*
|
|
198
|
+
* Drop this file in .ship-safe/agents/ to have it run automatically
|
|
199
|
+
* as part of every \`ship-safe audit\` or \`ship-safe watch --deep\`.
|
|
200
|
+
*
|
|
201
|
+
* The \`analyze(context)\` method receives:
|
|
202
|
+
* context.rootPath — absolute path to the project root
|
|
203
|
+
* context.files — array of absolute file paths to scan
|
|
204
|
+
* context.recon — recon data (frameworks, databases, auth patterns)
|
|
205
|
+
* context.options — CLI options passed to the scan
|
|
206
|
+
*
|
|
207
|
+
* Return an array of findings using \`createFinding()\`.
|
|
208
|
+
*/
|
|
209
|
+
|
|
210
|
+
import fs from 'fs';
|
|
211
|
+
|
|
212
|
+
// BaseAgent and createFinding are available from ship-safe internals.
|
|
213
|
+
// If ship-safe is installed globally, use:
|
|
214
|
+
// import { BaseAgent, createFinding } from 'ship-safe';
|
|
215
|
+
// If running from source:
|
|
216
|
+
// import { BaseAgent, createFinding } from '../agents/base-agent.js';
|
|
217
|
+
let BaseAgent, createFinding;
|
|
218
|
+
try {
|
|
219
|
+
({ BaseAgent, createFinding } = await import('ship-safe'));
|
|
220
|
+
} catch {
|
|
221
|
+
// Running from source — adjust path if needed
|
|
222
|
+
({ BaseAgent, createFinding } = await import('../agents/base-agent.js'));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export default class ${className} extends BaseAgent {
|
|
226
|
+
constructor() {
|
|
227
|
+
super();
|
|
228
|
+
this.name = '${className}';
|
|
229
|
+
this.category = 'custom'; // or: secrets | injection | auth | config | api | llm
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async analyze({ rootPath, files = [], recon, options }) {
|
|
233
|
+
const findings = [];
|
|
234
|
+
|
|
235
|
+
for (const file of files) {
|
|
236
|
+
// Skip files you don't care about
|
|
237
|
+
if (!/\\.(js|ts|jsx|tsx|py|rb|go|java)$/.test(file)) continue;
|
|
238
|
+
|
|
239
|
+
let content;
|
|
240
|
+
try {
|
|
241
|
+
content = fs.readFileSync(file, 'utf-8');
|
|
242
|
+
} catch {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const lines = content.split('\\n');
|
|
247
|
+
for (let i = 0; i < lines.length; i++) {
|
|
248
|
+
const line = lines[i];
|
|
249
|
+
if (/ship-safe-ignore/i.test(line)) continue; // respect suppression comments
|
|
250
|
+
|
|
251
|
+
// Example: flag dangerous eval calls // ship-safe-ignore
|
|
252
|
+
if (/\\beval\\s*\\(/.test(line)) { // ship-safe-ignore — template example in plugin scaffold, not real eval
|
|
253
|
+
findings.push(createFinding({
|
|
254
|
+
rule: '${safeName.toUpperCase().replace(/-/g, '_')}',
|
|
255
|
+
severity: 'high', // critical | high | medium | low
|
|
256
|
+
title: 'Example finding from ${className}',
|
|
257
|
+
description: 'Describe the security risk here.',
|
|
258
|
+
file,
|
|
259
|
+
line: i + 1,
|
|
260
|
+
matched: line.trim().slice(0, 100),
|
|
261
|
+
category: this.category,
|
|
262
|
+
remediation: 'Describe the fix here.',
|
|
263
|
+
confidence: 'medium', // high | medium | low
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return findings;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
`;
|
|
273
|
+
|
|
274
|
+
fs.writeFileSync(filePath, template, 'utf-8');
|
|
275
|
+
return filePath;
|
|
276
|
+
}
|