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.
@@ -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
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Scan Playbook
3
+ * =============
4
+ *
5
+ * Hermes-inspired repo-specific intelligence that accumulates across scans
6
+ * and gets injected into the DeepAnalyzer system prompt as project context.
7
+ *
8
+ * After each scan, the playbook is updated with:
9
+ * - Tech stack (frameworks, databases, runtimes)
10
+ * - Auth patterns detected
11
+ * - Known suppressed rules (from memory)
12
+ * - Scan statistics (score trend, most common finding categories)
13
+ * - Custom notes added by the user
14
+ *
15
+ * DeepAnalyzer reads the playbook and prepends it to every LLM call so the
16
+ * model has richer context than the generic system prompt alone — reducing
17
+ * both false positives and missed findings.
18
+ *
19
+ * PLAYBOOK FILE: .ship-safe/playbook.md
20
+ *
21
+ * USAGE:
22
+ * import { ScanPlaybook } from '../utils/scan-playbook.js';
23
+ *
24
+ * const playbook = new ScanPlaybook(rootPath);
25
+ *
26
+ * // After each scan — update with latest recon + score
27
+ * playbook.update(recon, scoreResult, suppressedRules);
28
+ *
29
+ * // Get the context string to inject into LLM prompts
30
+ * const context = playbook.getPromptContext();
31
+ *
32
+ * // CLI
33
+ * playbook.show() — print current playbook
34
+ * playbook.addNote(text) — add a custom note
35
+ */
36
+
37
+ import fs from 'fs';
38
+ import path from 'path';
39
+
40
+ const PLAYBOOK_DIR = '.ship-safe';
41
+ const PLAYBOOK_FILE = 'playbook.md';
42
+ const HISTORY_FILE = 'scan-history.json';
43
+
44
+ /** Minimum number of scans before the playbook is considered reliable */
45
+ const MIN_SCANS_FOR_PLAYBOOK = 2;
46
+
47
+ export class ScanPlaybook {
48
+ constructor(rootPath) {
49
+ this.rootPath = rootPath;
50
+ this.playbookDir = path.join(rootPath, PLAYBOOK_DIR);
51
+ this.playbookPath = path.join(this.playbookDir, PLAYBOOK_FILE);
52
+ this.historyPath = path.join(this.playbookDir, HISTORY_FILE);
53
+ }
54
+
55
+ // ===========================================================================
56
+ // UPDATE — called after every scan
57
+ // ===========================================================================
58
+
59
+ /**
60
+ * Update the playbook with the latest scan data.
61
+ *
62
+ * @param {object} recon — from ReconAgent
63
+ * @param {object} scoreResult — { score, grade, totalFindings, ... }
64
+ * @param {object[]} findings — all findings (used for category frequency)
65
+ * @param {string[]} suppressedRules — rules currently in memory
66
+ */
67
+ update(recon, scoreResult, findings = [], suppressedRules = []) {
68
+ // Ensure dir exists
69
+ if (!fs.existsSync(this.playbookDir)) {
70
+ fs.mkdirSync(this.playbookDir, { recursive: true });
71
+ }
72
+
73
+ // Update scan history
74
+ const history = this._loadHistory();
75
+ history.push({
76
+ date: new Date().toISOString(),
77
+ score: scoreResult?.score ?? null,
78
+ grade: scoreResult?.grade?.letter ?? scoreResult?.grade ?? null,
79
+ totalFindings: scoreResult?.totalFindings ?? findings.length,
80
+ });
81
+ // Keep last 50 entries
82
+ while (history.length > 50) history.shift();
83
+ this._saveHistory(history);
84
+
85
+ // Only update the playbook markdown after MIN_SCANS
86
+ if (history.length < MIN_SCANS_FOR_PLAYBOOK) return;
87
+
88
+ // Preserve user-written custom notes from existing playbook
89
+ const existingNotes = this._extractCustomNotes();
90
+
91
+ const content = this._buildPlaybook(recon, history, findings, suppressedRules, existingNotes);
92
+ fs.writeFileSync(this.playbookPath, content, 'utf-8');
93
+ }
94
+
95
+ // ===========================================================================
96
+ // READ — injected into LLM prompts
97
+ // ===========================================================================
98
+
99
+ /**
100
+ * Returns a compact context string to prepend to LLM system prompts.
101
+ * Empty string if the playbook doesn't exist yet.
102
+ */
103
+ getPromptContext() {
104
+ if (!fs.existsSync(this.playbookPath)) return '';
105
+
106
+ try {
107
+ const content = fs.readFileSync(this.playbookPath, 'utf-8');
108
+ // Extract the machine-readable section between <!-- context-start --> and <!-- context-end -->
109
+ const match = content.match(/<!-- context-start -->([\s\S]*?)<!-- context-end -->/);
110
+ if (match) return match[1].trim();
111
+
112
+ // Fallback: return first 1500 chars of playbook
113
+ return content.slice(0, 1500);
114
+ } catch {
115
+ return '';
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Whether the playbook has enough data to be useful.
121
+ */
122
+ get isReady() {
123
+ return fs.existsSync(this.playbookPath);
124
+ }
125
+
126
+ // ===========================================================================
127
+ // CLI
128
+ // ===========================================================================
129
+
130
+ show() {
131
+ if (!fs.existsSync(this.playbookPath)) {
132
+ console.log('\n No playbook yet. Run `ship-safe audit` at least twice to generate one.\n');
133
+ return;
134
+ }
135
+ const content = fs.readFileSync(this.playbookPath, 'utf-8');
136
+ console.log('\n' + content);
137
+ }
138
+
139
+ /**
140
+ * Append a custom user note to the playbook.
141
+ */
142
+ addNote(text) {
143
+ if (!fs.existsSync(this.playbookPath)) {
144
+ console.error(' No playbook yet — run ship-safe audit first.');
145
+ return;
146
+ }
147
+ const existing = fs.readFileSync(this.playbookPath, 'utf-8');
148
+ const timestamp = new Date().toISOString().slice(0, 10);
149
+ const noteSection = existing.includes('## Custom Notes')
150
+ ? existing.replace('## Custom Notes', `## Custom Notes\n\n- [${timestamp}] ${text}`)
151
+ : existing + `\n\n## Custom Notes\n\n- [${timestamp}] ${text}\n`;
152
+ fs.writeFileSync(this.playbookPath, noteSection, 'utf-8');
153
+ console.log(' Note added to playbook.');
154
+ }
155
+
156
+ // ===========================================================================
157
+ // INTERNALS
158
+ // ===========================================================================
159
+
160
+ _loadHistory() {
161
+ try {
162
+ if (fs.existsSync(this.historyPath)) {
163
+ return JSON.parse(fs.readFileSync(this.historyPath, 'utf-8'));
164
+ }
165
+ } catch { /* start fresh */ }
166
+ return [];
167
+ }
168
+
169
+ _saveHistory(history) {
170
+ try {
171
+ fs.writeFileSync(this.historyPath, JSON.stringify(history, null, 2), 'utf-8');
172
+ } catch { /* non-fatal */ }
173
+ }
174
+
175
+ _extractCustomNotes() {
176
+ if (!fs.existsSync(this.playbookPath)) return '';
177
+ try {
178
+ const content = fs.readFileSync(this.playbookPath, 'utf-8');
179
+ const match = content.match(/## Custom Notes([\s\S]*)$/);
180
+ return match ? match[0] : '';
181
+ } catch { return ''; }
182
+ }
183
+
184
+ _buildPlaybook(recon, history, findings, suppressedRules, customNotes) {
185
+ const repoName = path.basename(this.rootPath);
186
+ const lastScore = history.at(-1)?.score ?? '?';
187
+ const lastGrade = history.at(-1)?.grade ?? '?';
188
+ const scanCount = history.length;
189
+ const avgScore = history.length > 0
190
+ ? Math.round(history.reduce((s, h) => s + (h.score ?? 0), 0) / history.length)
191
+ : 0;
192
+
193
+ // Score trend
194
+ const trend = history.length >= 2
195
+ ? (history.at(-1).score ?? 0) - (history.at(-2).score ?? 0)
196
+ : 0;
197
+ const trendStr = trend > 0 ? `↑ +${trend}` : trend < 0 ? `↓ ${trend}` : '→ stable';
198
+
199
+ // Category frequency
200
+ const catCounts = {};
201
+ for (const f of findings) {
202
+ catCounts[f.category || 'other'] = (catCounts[f.category || 'other'] || 0) + 1;
203
+ }
204
+ const topCategories = Object.entries(catCounts)
205
+ .sort((a, b) => b[1] - a[1])
206
+ .slice(0, 5)
207
+ .map(([cat, count]) => `${cat} (${count})`)
208
+ .join(', ') || 'none';
209
+
210
+ // Tech stack
211
+ const frameworks = recon?.frameworks?.length ? recon.frameworks.join(', ') : 'unknown';
212
+ const databases = recon?.databases?.length ? recon.databases.join(', ') : 'none detected';
213
+ const authPat = recon?.authPatterns?.length ? recon.authPatterns.join(', ') : 'none detected';
214
+ const runtime = recon?.runtime || 'unknown';
215
+ const packageManager = recon?.packageManager || 'unknown';
216
+ const language = recon?.language || recon?.primaryLanguage || 'unknown';
217
+
218
+ const suppressedSection = suppressedRules.length > 0
219
+ ? suppressedRules.map(r => `- ${r}`).join('\n')
220
+ : '- none';
221
+
222
+ const historyTable = history.slice(-10).map(h =>
223
+ `| ${h.date.slice(0, 10)} | ${h.score ?? '?'}/100 | ${h.grade ?? '?'} | ${h.totalFindings ?? '?'} |`
224
+ ).join('\n');
225
+
226
+ return `# Ship Safe Playbook — ${repoName}
227
+
228
+ > Auto-generated after ${scanCount} scan(s). Do not edit the section between the
229
+ > \`<!-- context-start -->\` and \`<!-- context-end -->\` markers — it is overwritten on each scan.
230
+ > Add custom notes at the bottom under **Custom Notes**.
231
+
232
+ <!-- context-start -->
233
+ REPO: ${repoName}
234
+ LANGUAGE: ${language}
235
+ RUNTIME: ${runtime}
236
+ PACKAGE_MANAGER: ${packageManager}
237
+ FRAMEWORKS: ${frameworks}
238
+ DATABASES: ${databases}
239
+ AUTH_PATTERNS: ${authPat}
240
+ LAST_SCORE: ${lastScore}/100 (${lastGrade})
241
+ TREND: ${trendStr}
242
+ AVG_SCORE: ${avgScore}/100 over ${scanCount} scans
243
+ TOP_FINDING_CATEGORIES: ${topCategories}
244
+ SUPPRESSED_RULES: ${suppressedRules.join(', ') || 'none'}
245
+ SCANS_COMPLETED: ${scanCount}
246
+ <!-- context-end -->
247
+
248
+ ## Security Profile
249
+
250
+ | Property | Value |
251
+ |----------|-------|
252
+ | Language | ${language} |
253
+ | Runtime | ${runtime} |
254
+ | Frameworks | ${frameworks} |
255
+ | Databases | ${databases} |
256
+ | Auth patterns | ${authPat} |
257
+
258
+ ## Score History (last 10 scans)
259
+
260
+ | Date | Score | Grade | Findings |
261
+ |------|-------|-------|----------|
262
+ ${historyTable}
263
+
264
+ **Current:** ${lastScore}/100 ${lastGrade} (${trendStr})
265
+
266
+ ## Known Suppressions
267
+
268
+ The following rules are currently suppressed in \`.ship-safe/memory.json\`:
269
+
270
+ ${suppressedSection}
271
+
272
+ ## Top Finding Categories
273
+
274
+ ${topCategories}
275
+
276
+ ${customNotes || '## Custom Notes\n\n_Add project-specific context here with: `ship-safe playbook add-note "..."`_\n'}
277
+ `;
278
+ }
279
+ }
280
+
281
+ // =============================================================================
282
+ // CLI COMMAND (ship-safe playbook)
283
+ // =============================================================================
284
+
285
+ import chalk from 'chalk';
286
+
287
+ export async function playbookCommand(subcommand, args = [], options = {}) {
288
+ const rootPath = path.resolve(options.path || '.');
289
+ const playbook = new ScanPlaybook(rootPath);
290
+
291
+ switch (subcommand) {
292
+ case 'show':
293
+ case undefined:
294
+ playbook.show();
295
+ break;
296
+
297
+ case 'add-note': {
298
+ const text = args.join(' ').trim();
299
+ if (!text) {
300
+ console.error(' Usage: ship-safe playbook add-note "your note here"');
301
+ process.exit(1);
302
+ }
303
+ playbook.addNote(text);
304
+ break;
305
+ }
306
+
307
+ default:
308
+ console.error(` Unknown playbook subcommand: ${subcommand}`);
309
+ console.log(' Usage: ship-safe playbook [show|add-note "..."]');
310
+ process.exit(1);
311
+ }
312
+ }