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,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
|
+
}
|