ship-safe 7.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/deep-analyzer.js +473 -133
- 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/orchestrator.js +13 -3
- package/cli/agents/supply-chain-agent.js +1 -1
- package/cli/bin/ship-safe.js +129 -5
- package/cli/commands/audit.js +149 -3
- package/cli/commands/autofix.js +383 -0
- package/cli/commands/env-audit.js +349 -0
- package/cli/commands/init.js +104 -0
- package/cli/commands/mcp.js +270 -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 +142 -5
- package/cli/index.js +5 -0
- package/cli/providers/llm-provider.js +50 -2
- 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
|
@@ -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": "
|
|
4
|
-
"description": "AI-powered multi-agent security platform.
|
|
3
|
+
"version": "9.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"
|