ship-safe 6.4.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Security Memory
3
+ * ================
4
+ *
5
+ * Hermes-inspired persistent memory for false-positive learning.
6
+ *
7
+ * After deep analysis confirms a finding is a false-positive, the verdict is
8
+ * written to `.ship-safe/memory.json`. On the next scan, the SecurityMemory
9
+ * filter runs before scoring and suppresses any finding whose (rule + file +
10
+ * matched-snippet hash) appears in memory — avoiding the same LLM call twice.
11
+ *
12
+ * Memory entries are per-repo (stored in `.ship-safe/` inside the project
13
+ * root), so suppressions don't bleed across unrelated projects.
14
+ *
15
+ * USAGE:
16
+ * import { SecurityMemory } from '../utils/security-memory.js';
17
+ *
18
+ * const memory = new SecurityMemory(rootPath);
19
+ *
20
+ * // After deep analysis — auto-learn false positives
21
+ * memory.learnFromAnalysis(findings);
22
+ *
23
+ * // Filter findings against memory before scoring
24
+ * const { kept, suppressed } = memory.filter(findings);
25
+ *
26
+ * // Manual suppression (from `ship-safe memory add`)
27
+ * memory.suppress(finding, 'Not reachable from user input');
28
+ *
29
+ * // CLI commands
30
+ * memory.list() → all suppressed entries
31
+ * memory.forget(id) → remove one entry
32
+ * memory.clear() → wipe all entries
33
+ */
34
+
35
+ import fs from 'fs';
36
+ import path from 'path';
37
+ import { createHash } from 'crypto';
38
+
39
+ const MEMORY_DIR = '.ship-safe';
40
+ const MEMORY_FILE = 'memory.json';
41
+
42
+ /** How many chars of the matched snippet to hash (stable across reformatting) */
43
+ const SNIPPET_HASH_LEN = 120;
44
+
45
+ export class SecurityMemory {
46
+ constructor(rootPath) {
47
+ this.rootPath = rootPath;
48
+ this.memoryDir = path.join(rootPath, MEMORY_DIR);
49
+ this.memoryPath = path.join(this.memoryDir, MEMORY_FILE);
50
+ this._data = null; // lazy-loaded
51
+ }
52
+
53
+ // ===========================================================================
54
+ // PERSISTENCE
55
+ // ===========================================================================
56
+
57
+ _load() {
58
+ if (this._data) return this._data;
59
+
60
+ try {
61
+ if (fs.existsSync(this.memoryPath)) {
62
+ this._data = JSON.parse(fs.readFileSync(this.memoryPath, 'utf-8'));
63
+ }
64
+ } catch { /* corrupt file — start fresh */ }
65
+
66
+ if (!this._data || typeof this._data !== 'object') {
67
+ this._data = { version: 1, entries: [] };
68
+ }
69
+ if (!Array.isArray(this._data.entries)) {
70
+ this._data.entries = [];
71
+ }
72
+
73
+ return this._data;
74
+ }
75
+
76
+ _save() {
77
+ try {
78
+ if (!fs.existsSync(this.memoryDir)) {
79
+ fs.mkdirSync(this.memoryDir, { recursive: true });
80
+ }
81
+ fs.writeFileSync(this.memoryPath, JSON.stringify(this._data, null, 2), 'utf-8');
82
+ } catch { /* non-fatal */ }
83
+ }
84
+
85
+ // ===========================================================================
86
+ // KEY GENERATION
87
+ // ===========================================================================
88
+
89
+ /**
90
+ * Generate a stable key for a finding.
91
+ * Key = SHA-256(rule + file-basename + first N chars of matched).
92
+ * Deliberately excludes line number so the suppression survives minor refactors.
93
+ */
94
+ static keyOf(finding) {
95
+ const rule = finding.rule || '';
96
+ const file = finding.file ? path.basename(finding.file) : '';
97
+ const snippet = (finding.matched || finding.title || '').slice(0, SNIPPET_HASH_LEN);
98
+ return createHash('sha256').update(`${rule}::${file}::${snippet}`).digest('hex').slice(0, 16);
99
+ }
100
+
101
+ // ===========================================================================
102
+ // CORE API
103
+ // ===========================================================================
104
+
105
+ /**
106
+ * Learn from deep analysis results.
107
+ * Any finding with exploitability === 'false_positive' is auto-added to memory.
108
+ *
109
+ * @param {object[]} findings — findings array (with deepAnalysis attached)
110
+ * @returns {number} — count of new entries added
111
+ */
112
+ learnFromAnalysis(findings) {
113
+ const data = this._load();
114
+ const existingKeys = new Set(data.entries.map(e => e.key));
115
+ let added = 0;
116
+
117
+ for (const f of findings) {
118
+ if (f.deepAnalysis?.exploitability !== 'false_positive') continue;
119
+
120
+ const key = SecurityMemory.keyOf(f);
121
+ if (existingKeys.has(key)) continue;
122
+
123
+ data.entries.push({
124
+ key,
125
+ rule: f.rule,
126
+ file: f.file ? path.basename(f.file) : '',
127
+ title: f.title,
128
+ severity: f.severity,
129
+ reason: f.deepAnalysis.reasoning || 'Auto-detected false positive by deep analysis',
130
+ source: 'deep-analysis',
131
+ suppressedAt: new Date().toISOString(),
132
+ });
133
+
134
+ existingKeys.add(key);
135
+ added++;
136
+ }
137
+
138
+ if (added > 0) this._save();
139
+ return added;
140
+ }
141
+
142
+ /**
143
+ * Manually suppress a finding with a reason.
144
+ *
145
+ * @param {object} finding
146
+ * @param {string} reason
147
+ * @returns {string} — the key added
148
+ */
149
+ suppress(finding, reason = '') {
150
+ const data = this._load();
151
+ const key = SecurityMemory.keyOf(finding);
152
+
153
+ if (data.entries.some(e => e.key === key)) {
154
+ return key; // already suppressed
155
+ }
156
+
157
+ data.entries.push({
158
+ key,
159
+ rule: finding.rule,
160
+ file: finding.file ? path.basename(finding.file) : '',
161
+ title: finding.title,
162
+ severity: finding.severity,
163
+ reason: reason || 'Manually suppressed',
164
+ source: 'manual',
165
+ suppressedAt: new Date().toISOString(),
166
+ });
167
+
168
+ this._save();
169
+ return key;
170
+ }
171
+
172
+ /**
173
+ * Filter findings against memory.
174
+ * Returns { kept, suppressed } — suppressed findings get a .memorySuppressed flag.
175
+ *
176
+ * @param {object[]} findings
177
+ * @returns {{ kept: object[], suppressed: object[], suppressedCount: number }}
178
+ */
179
+ filter(findings) {
180
+ const data = this._load();
181
+ if (data.entries.length === 0) {
182
+ return { kept: findings, suppressed: [], suppressedCount: 0 };
183
+ }
184
+
185
+ const keySet = new Set(data.entries.map(e => e.key));
186
+ const kept = [];
187
+ const suppressed = [];
188
+
189
+ for (const f of findings) {
190
+ const key = SecurityMemory.keyOf(f);
191
+ const entry = data.entries.find(e => e.key === key);
192
+ if (entry) {
193
+ suppressed.push({ ...f, memorySuppressed: true, suppressionReason: entry.reason });
194
+ } else {
195
+ kept.push(f);
196
+ }
197
+ }
198
+
199
+ return { kept, suppressed, suppressedCount: suppressed.length };
200
+ }
201
+
202
+ /**
203
+ * Remove a single entry by key.
204
+ */
205
+ forget(key) {
206
+ const data = this._load();
207
+ const before = data.entries.length;
208
+ data.entries = data.entries.filter(e => e.key !== key);
209
+ const removed = before - data.entries.length;
210
+ if (removed > 0) this._save();
211
+ return removed;
212
+ }
213
+
214
+ /**
215
+ * Wipe all memory entries.
216
+ */
217
+ clear() {
218
+ this._data = { version: 1, entries: [] };
219
+ this._save();
220
+ }
221
+
222
+ /**
223
+ * Return all entries.
224
+ */
225
+ list() {
226
+ return this._load().entries;
227
+ }
228
+
229
+ /**
230
+ * Count of suppressed entries.
231
+ */
232
+ get size() {
233
+ return this._load().entries.length;
234
+ }
235
+ }
236
+
237
+ // =============================================================================
238
+ // CLI COMMAND (ship-safe memory)
239
+ // =============================================================================
240
+
241
+ import chalk from 'chalk';
242
+ import * as output from './output.js';
243
+
244
+ export async function memoryCommand(subcommand, args = [], options = {}) {
245
+ const rootPath = path.resolve(options.path || '.');
246
+ const memory = new SecurityMemory(rootPath);
247
+
248
+ switch (subcommand) {
249
+ case 'list':
250
+ case undefined: {
251
+ const entries = memory.list();
252
+ if (entries.length === 0) {
253
+ console.log('\n No suppressed findings in memory.\n');
254
+ return;
255
+ }
256
+ console.log(`\n ${chalk.cyan.bold('Security Memory')} — ${entries.length} suppressed finding(s)\n`);
257
+ for (const e of entries) {
258
+ const sev = e.severity === 'critical' ? chalk.red.bold(e.severity)
259
+ : e.severity === 'high' ? chalk.yellow(e.severity)
260
+ : chalk.gray(e.severity);
261
+ console.log(` ${chalk.gray(e.key)} ${sev} ${chalk.white(e.rule)}`);
262
+ console.log(` ${chalk.gray('File:')} ${e.file} ${chalk.gray('Source:')} ${e.source} ${chalk.gray('Date:')} ${e.suppressedAt.slice(0, 10)}`);
263
+ console.log(` ${chalk.gray('Reason:')} ${e.reason}`);
264
+ console.log();
265
+ }
266
+ break;
267
+ }
268
+
269
+ case 'forget': {
270
+ const key = args[0];
271
+ if (!key) {
272
+ output.error('Usage: ship-safe memory forget <key>');
273
+ process.exit(1);
274
+ }
275
+ const removed = memory.forget(key);
276
+ if (removed) {
277
+ console.log(chalk.green(` Removed memory entry: ${key}`));
278
+ } else {
279
+ console.log(chalk.yellow(` Key not found in memory: ${key}`));
280
+ }
281
+ break;
282
+ }
283
+
284
+ case 'clear': {
285
+ const before = memory.size;
286
+ memory.clear();
287
+ console.log(chalk.green(` Cleared ${before} memory entry/entries.`));
288
+ break;
289
+ }
290
+
291
+ default:
292
+ output.error(`Unknown memory subcommand: ${subcommand}`);
293
+ console.log(' Usage: ship-safe memory [list|forget <key>|clear]');
294
+ process.exit(1);
295
+ }
296
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ship-safe",
3
- "version": "6.4.0",
4
- "description": "AI-powered multi-agent security platform. 18 agents scan 80+ attack classes with LLM-powered deep analysis. Red team your code before attackers do.",
3
+ "version": "8.0.0",
4
+ "description": "AI-powered multi-agent security platform. 22 agents scan 80+ attack classes including Hermes Agent deployments (ASI-01–ASI-10), tool registry poisoning, function-call injection, skill permission drift, and agent attestation. Ship Safe × Hermes Agent.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
7
7
  "ship-safe": "cli/bin/ship-safe.js"