ship-safe 7.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -21
- package/cli/agents/agent-attestation-agent.js +318 -0
- package/cli/agents/agentic-security-agent.js +35 -0
- package/cli/agents/cicd-scanner.js +22 -0
- package/cli/agents/config-auditor.js +235 -0
- package/cli/agents/hermes-security-agent.js +536 -0
- package/cli/agents/index.js +63 -22
- package/cli/agents/managed-agent-scanner.js +333 -0
- package/cli/agents/supply-chain-agent.js +1 -1
- package/cli/bin/ship-safe.js +125 -5
- package/cli/commands/audit.js +116 -2
- package/cli/commands/autofix.js +383 -0
- package/cli/commands/env-audit.js +349 -0
- package/cli/commands/red-team.js +2 -2
- package/cli/commands/scan-mcp.js +78 -0
- package/cli/commands/scan-skill.js +248 -5
- package/cli/index.js +5 -0
- package/cli/utils/hermes-tool-registry.js +252 -0
- package/cli/utils/patterns.js +1 -0
- package/cli/utils/plugin-loader.js +276 -0
- package/cli/utils/scan-playbook.js +312 -0
- package/cli/utils/security-memory.js +296 -0
- package/package.json +2 -2
|
@@ -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": "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"
|