ship-safe 8.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/cli/agents/deep-analyzer.js +473 -133
- package/cli/agents/orchestrator.js +13 -3
- package/cli/bin/ship-safe.js +4 -0
- package/cli/commands/audit.js +33 -1
- package/cli/commands/init.js +104 -0
- package/cli/commands/mcp.js +270 -0
- package/cli/commands/watch.js +142 -5
- package/cli/providers/llm-provider.js +50 -2
- package/package.json +1 -1
|
@@ -234,9 +234,19 @@ export class Orchestrator {
|
|
|
234
234
|
allFindings = await analyzer.analyze(allFindings, { rootPath: absolutePath, recon });
|
|
235
235
|
const stats = analyzer.getStats();
|
|
236
236
|
if (deepSpinner) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
237
|
+
if (stats.multiTier) {
|
|
238
|
+
const tierNote = stats.tier3Count > 0
|
|
239
|
+
? `, ${stats.tier3Count} escalated to Opus`
|
|
240
|
+
: stats.tier2Count > 0 ? `, ${stats.tier2Count} via Sonnet` : '';
|
|
241
|
+
const skipNote = stats.skippedCount > 0 ? `, ${stats.skippedCount} triaged away` : '';
|
|
242
|
+
deepSpinner.succeed(chalk.green(
|
|
243
|
+
`Deep analysis (Haiku→Sonnet→Opus): ${stats.analyzedCount} analyzed${tierNote}${skipNote} (${stats.spentCents}¢)`
|
|
244
|
+
));
|
|
245
|
+
} else {
|
|
246
|
+
deepSpinner.succeed(chalk.green(
|
|
247
|
+
`Deep analysis: ${stats.analyzedCount} findings analyzed (${stats.spentCents}¢)`
|
|
248
|
+
));
|
|
249
|
+
}
|
|
240
250
|
}
|
|
241
251
|
} catch (err) {
|
|
242
252
|
if (deepSpinner) deepSpinner.fail(chalk.yellow(`Deep analysis failed: ${err.message}`));
|
package/cli/bin/ship-safe.js
CHANGED
|
@@ -125,6 +125,8 @@ program
|
|
|
125
125
|
.option('--headers', 'Only copy security headers config')
|
|
126
126
|
.option('--agents', 'Only add security rules to AI agent instruction files (CLAUDE.md, .cursor/rules/, .windsurfrules, copilot-instructions.md)')
|
|
127
127
|
.option('--openclaw', 'Generate a hardened openclaw.json template')
|
|
128
|
+
.option('--hermes', 'Bootstrap Hermes Agent security config (allowlist, integrity hashes, CI)')
|
|
129
|
+
.option('--from <url>', 'Fetch a pre-built Hermes config bundle from a setup URL (used with --hermes)')
|
|
128
130
|
.action(initCommand);
|
|
129
131
|
|
|
130
132
|
// -----------------------------------------------------------------------------
|
|
@@ -231,6 +233,8 @@ program
|
|
|
231
233
|
.option('--include-legal', 'Also run the legal risk scan (DMCA, leaked source, IP disputes)')
|
|
232
234
|
.option('--agentic [iterations]', 'Agentic scan→fix→verify loop (default: 3 iterations, target score: 75)', (v) => v ? parseInt(v) : true)
|
|
233
235
|
.option('--agentic-target <score>', 'Target security score for agentic loop (default: 75)', parseInt)
|
|
236
|
+
.option('--hermes-only', 'Run only Hermes-relevant agents (llm + supply-chain categories) for fast CI')
|
|
237
|
+
.option('--fail-below <threshold>', 'Exit 1 if score is below threshold (number or "baseline")')
|
|
234
238
|
.option('-v, --verbose', 'Verbose output')
|
|
235
239
|
.action(auditCommand);
|
|
236
240
|
|
package/cli/commands/audit.js
CHANGED
|
@@ -190,6 +190,15 @@ export async function auditCommand(targetPath = '.', options = {}) {
|
|
|
190
190
|
|
|
191
191
|
// ── Phase 2: Agent Scan ───────────────────────────────────────────────────
|
|
192
192
|
const orchestrator = await buildOrchestratorAsync(absolutePath, { quiet: true });
|
|
193
|
+
|
|
194
|
+
// --hermes-only: filter to llm + supply-chain category agents only
|
|
195
|
+
if (options.hermesOnly && orchestrator.agents) {
|
|
196
|
+
const hermesCategories = new Set(['llm', 'supply-chain']);
|
|
197
|
+
orchestrator.agents = orchestrator.agents.filter(a =>
|
|
198
|
+
hermesCategories.has(a.category) || hermesCategories.has(a.constructor?.category)
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
193
202
|
const registeredAgentCount = orchestrator.agents?.length || 15;
|
|
194
203
|
const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
|
|
195
204
|
let agentFindings = [];
|
|
@@ -567,7 +576,30 @@ export async function auditCommand(targetPath = '.', options = {}) {
|
|
|
567
576
|
}
|
|
568
577
|
}
|
|
569
578
|
|
|
570
|
-
|
|
579
|
+
// ── Exit code logic ─────────────────────────────────────────────────────
|
|
580
|
+
let threshold = 75;
|
|
581
|
+
if (options.failBelow !== undefined) {
|
|
582
|
+
if (options.failBelow === 'baseline') {
|
|
583
|
+
// Read baseline score from .ship-safe/hermes-baseline.json
|
|
584
|
+
const baselinePath = path.join(absolutePath, '.ship-safe', 'hermes-baseline.json');
|
|
585
|
+
try {
|
|
586
|
+
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
|
|
587
|
+
threshold = baseline.score || 0;
|
|
588
|
+
if (!machineOutput) {
|
|
589
|
+
console.log(chalk.gray(` Baseline threshold: ${threshold}/100 (from ${baselinePath})`));
|
|
590
|
+
}
|
|
591
|
+
} catch {
|
|
592
|
+
if (!machineOutput) {
|
|
593
|
+
console.log(chalk.yellow(` Warning: could not read baseline — using score 0 as threshold`));
|
|
594
|
+
}
|
|
595
|
+
threshold = 0;
|
|
596
|
+
}
|
|
597
|
+
} else {
|
|
598
|
+
threshold = parseInt(options.failBelow, 10) || 75;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
process.exit(scoreResult.score >= threshold ? 0 : 1);
|
|
571
603
|
}
|
|
572
604
|
|
|
573
605
|
/**
|
package/cli/commands/init.js
CHANGED
|
@@ -55,6 +55,11 @@ export async function initCommand(options = {}) {
|
|
|
55
55
|
return handleOpenClawInit(targetDir, options.force, results);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Handle --hermes --from <url>
|
|
59
|
+
if (options.hermes) {
|
|
60
|
+
return handleHermesInit(targetDir, options);
|
|
61
|
+
}
|
|
62
|
+
|
|
58
63
|
const hasSpecificFlag = options.gitignore || options.headers || options.agents;
|
|
59
64
|
const copyGitignore = hasSpecificFlag ? !!options.gitignore : true;
|
|
60
65
|
const copyHeaders = hasSpecificFlag ? !!options.headers : true;
|
|
@@ -301,6 +306,105 @@ async function handleAgentFiles(targetDir, force, results) {
|
|
|
301
306
|
}
|
|
302
307
|
}
|
|
303
308
|
|
|
309
|
+
// =============================================================================
|
|
310
|
+
// HERMES AGENT INIT
|
|
311
|
+
// =============================================================================
|
|
312
|
+
|
|
313
|
+
async function handleHermesInit(targetDir, options) {
|
|
314
|
+
const fromUrl = options.from;
|
|
315
|
+
|
|
316
|
+
if (!fromUrl) {
|
|
317
|
+
console.error(chalk.red('\nError: --hermes requires --from <setup-url>'));
|
|
318
|
+
console.error(chalk.gray(' Generate a setup URL at: https://shipsafecli.com/app/deploy'));
|
|
319
|
+
console.error(chalk.gray(' Then run: npx ship-safe init --hermes --from <url>\n'));
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Validate the URL is from a trusted origin
|
|
324
|
+
let parsed;
|
|
325
|
+
try {
|
|
326
|
+
parsed = new URL(fromUrl);
|
|
327
|
+
} catch {
|
|
328
|
+
console.error(chalk.red('\nError: Invalid URL: ' + fromUrl + '\n'));
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const TRUSTED_HOSTS = ['shipsafecli.com', 'www.shipsafecli.com', 'localhost', '127.0.0.1'];
|
|
333
|
+
if (!TRUSTED_HOSTS.includes(parsed.hostname)) {
|
|
334
|
+
console.error(chalk.red('\nError: Setup URL must be from shipsafecli.com (got: ' + parsed.hostname + ')'));
|
|
335
|
+
console.error(chalk.gray(' Only URLs generated by the Ship Safe webapp are trusted.\n'));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
console.log();
|
|
340
|
+
output.header('Hermes Agent Security Setup');
|
|
341
|
+
console.log();
|
|
342
|
+
console.log(chalk.gray('Fetching config from:'), chalk.cyan(fromUrl));
|
|
343
|
+
console.log();
|
|
344
|
+
|
|
345
|
+
// Fetch the config bundle
|
|
346
|
+
let data;
|
|
347
|
+
try {
|
|
348
|
+
const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
|
|
349
|
+
const res = await fetch(fromUrl, { headers: { 'Accept': 'application/json' } });
|
|
350
|
+
if (!res.ok) {
|
|
351
|
+
const body = await res.json().catch(() => ({}));
|
|
352
|
+
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
353
|
+
}
|
|
354
|
+
data = await res.json();
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.error(chalk.red('\nFailed to fetch setup config: ' + err.message + '\n'));
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
|
|
361
|
+
console.error(chalk.red('\nInvalid config bundle — no files returned.\n'));
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Write each file
|
|
366
|
+
const written = [];
|
|
367
|
+
const skipped = [];
|
|
368
|
+
|
|
369
|
+
for (const { path: filePath, content } of data.files) {
|
|
370
|
+
// Sanitize path — no traversal
|
|
371
|
+
const normalized = path.normalize(filePath).replace(/^(\.\.(\/|\\|$))+/, '');
|
|
372
|
+
const absPath = path.join(targetDir, normalized);
|
|
373
|
+
|
|
374
|
+
if (fs.existsSync(absPath) && !options.force) {
|
|
375
|
+
skipped.push(normalized);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const dir = path.dirname(absPath);
|
|
380
|
+
if (!fs.existsSync(dir)) {
|
|
381
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
382
|
+
}
|
|
383
|
+
fs.writeFileSync(absPath, content, 'utf-8');
|
|
384
|
+
written.push(normalized);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Print results
|
|
388
|
+
console.log(chalk.green.bold('Files written:'));
|
|
389
|
+
for (const f of written) {
|
|
390
|
+
console.log(chalk.green(` ✔ ${f}`));
|
|
391
|
+
}
|
|
392
|
+
if (skipped.length > 0) {
|
|
393
|
+
console.log();
|
|
394
|
+
console.log(chalk.yellow.bold('Skipped (already exist — use -f to overwrite):'));
|
|
395
|
+
for (const f of skipped) {
|
|
396
|
+
console.log(chalk.yellow(` → ${f}`));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
console.log();
|
|
401
|
+
console.log(chalk.cyan.bold('Next steps:'));
|
|
402
|
+
console.log(chalk.white(' 1.') + ' Populate your baseline: ' + chalk.cyan('npx ship-safe audit .'));
|
|
403
|
+
console.log(chalk.white(' 2.') + ' Auto-fix findings: ' + chalk.cyan('npx ship-safe audit . --agentic 3 --agentic-target 80'));
|
|
404
|
+
console.log(chalk.white(' 3.') + ' Commit everything and push — CI runs on every PR.');
|
|
405
|
+
console.log();
|
|
406
|
+
}
|
|
407
|
+
|
|
304
408
|
// =============================================================================
|
|
305
409
|
// OPENCLAW HARDENED CONFIG
|
|
306
410
|
// =============================================================================
|
package/cli/commands/mcp.js
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* scan_secrets - Scan a directory for leaked secrets
|
|
25
25
|
* get_checklist - Return the launch-day security checklist
|
|
26
26
|
* analyze_file - Analyze a single file for security issues
|
|
27
|
+
* scan_repo - Run a full multi-agent security scan on a repo
|
|
28
|
+
* get_findings - Read findings from a saved ship-safe report file
|
|
29
|
+
* suppress_finding - Add a ship-safe-ignore comment to suppress a finding
|
|
27
30
|
*
|
|
28
31
|
* PROTOCOL:
|
|
29
32
|
* JSON-RPC 2.0 over stdio (MCP spec: https://modelcontextprotocol.io)
|
|
@@ -34,6 +37,10 @@ import path from 'path';
|
|
|
34
37
|
import fg from 'fast-glob';
|
|
35
38
|
import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, TEST_FILE_PATTERNS, MAX_FILE_SIZE } from '../utils/patterns.js';
|
|
36
39
|
import { isHighEntropyMatch } from '../utils/entropy.js';
|
|
40
|
+
import { buildOrchestrator } from '../agents/index.js';
|
|
41
|
+
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
42
|
+
import { autoDetectProvider } from '../providers/llm-provider.js';
|
|
43
|
+
import { DeepAnalyzer } from '../agents/deep-analyzer.js';
|
|
37
44
|
|
|
38
45
|
// =============================================================================
|
|
39
46
|
// MCP TOOL DEFINITIONS
|
|
@@ -80,6 +87,74 @@ const TOOLS = [
|
|
|
80
87
|
required: ['path'],
|
|
81
88
|
},
|
|
82
89
|
},
|
|
90
|
+
{
|
|
91
|
+
name: 'scan_repo',
|
|
92
|
+
description: 'Run a full multi-agent security scan on a repository or directory. Runs all 20+ ship-safe security agents (injection, auth bypass, secrets, supply chain, LLM security, etc.) and returns a structured findings report with severity ratings and remediation advice. Use this when the user asks to audit, scan, or check the security of their project.',
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
path: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
description: 'The directory path to scan. Use "." for current directory.',
|
|
99
|
+
},
|
|
100
|
+
agents: {
|
|
101
|
+
type: 'array',
|
|
102
|
+
items: { type: 'string' },
|
|
103
|
+
description: 'Specific agent names to run (optional). Omit to run all agents.',
|
|
104
|
+
},
|
|
105
|
+
llm: {
|
|
106
|
+
type: 'boolean',
|
|
107
|
+
description: 'Enable LLM-powered deep analysis for critical/high findings (default: false). Requires ANTHROPIC_API_KEY or similar env var.',
|
|
108
|
+
},
|
|
109
|
+
outputFile: {
|
|
110
|
+
type: 'string',
|
|
111
|
+
description: 'Optional path to save the JSON report for later retrieval with get_findings.',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
required: ['path'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'get_findings',
|
|
119
|
+
description: 'Read and return findings from a ship-safe JSON report file previously saved by scan_repo or the ship-safe CLI (npx ship-safe audit --json). Useful for reviewing or referencing a prior scan without re-running it.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
reportPath: {
|
|
124
|
+
type: 'string',
|
|
125
|
+
description: 'Path to the ship-safe JSON report file (e.g. ship-safe-report.json).',
|
|
126
|
+
},
|
|
127
|
+
severity: {
|
|
128
|
+
type: 'string',
|
|
129
|
+
enum: ['critical', 'high', 'medium', 'low'],
|
|
130
|
+
description: 'Filter findings by minimum severity (optional).',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
required: ['reportPath'],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'suppress_finding',
|
|
138
|
+
description: 'Add a ship-safe-ignore comment to a specific line in a file to suppress a false-positive security finding. The comment tells ship-safe\'s scanner to skip that line in future scans. Always explain why the suppression is safe.',
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
file: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
description: 'Path to the file containing the false-positive finding.',
|
|
145
|
+
},
|
|
146
|
+
line: {
|
|
147
|
+
type: 'number',
|
|
148
|
+
description: 'Line number of the finding to suppress (1-indexed).',
|
|
149
|
+
},
|
|
150
|
+
reason: {
|
|
151
|
+
type: 'string',
|
|
152
|
+
description: 'Brief explanation of why this finding is a false positive (appended to the ignore comment).',
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
required: ['file', 'line', 'reason'],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
83
158
|
];
|
|
84
159
|
|
|
85
160
|
// =============================================================================
|
|
@@ -163,6 +238,192 @@ async function analyzeFile({ path: filePath }) {
|
|
|
163
238
|
};
|
|
164
239
|
}
|
|
165
240
|
|
|
241
|
+
async function scanRepo({ path: targetPath, agents: agentFilter, llm = false, outputFile }) {
|
|
242
|
+
const rootPath = path.resolve(targetPath);
|
|
243
|
+
|
|
244
|
+
if (!fs.existsSync(rootPath)) {
|
|
245
|
+
return { error: `Path does not exist: ${rootPath}` };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// MCP communicates over stdout as JSON-RPC. Suppress all console output during
|
|
249
|
+
// the scan so spinner text and log lines don't pollute the transport stream.
|
|
250
|
+
const noop = () => {};
|
|
251
|
+
const savedLog = console.log;
|
|
252
|
+
const savedWarn = console.warn;
|
|
253
|
+
const savedError = console.error;
|
|
254
|
+
const savedInfo = console.info;
|
|
255
|
+
console.log = console.warn = console.error = console.info = noop;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const orchestrator = buildOrchestrator();
|
|
259
|
+
const context = { rootPath };
|
|
260
|
+
|
|
261
|
+
// Run all agents (quiet:true suppresses ora spinners; console is already nulled)
|
|
262
|
+
const { findings, recon } = await orchestrator.runAll(rootPath, {
|
|
263
|
+
agents: agentFilter,
|
|
264
|
+
timeout: 30000,
|
|
265
|
+
concurrency: 6,
|
|
266
|
+
quiet: true,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Optional: LLM deep analysis
|
|
270
|
+
let deepStats = null;
|
|
271
|
+
if (llm) {
|
|
272
|
+
const provider = autoDetectProvider(rootPath, {});
|
|
273
|
+
if (provider) {
|
|
274
|
+
const analyzer = new DeepAnalyzer({ provider, budgetCents: 50, verbose: false });
|
|
275
|
+
await analyzer.analyze(findings, { rootPath, recon });
|
|
276
|
+
deepStats = analyzer.getStats();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Score
|
|
281
|
+
const scorer = new ScoringEngine();
|
|
282
|
+
const { score, grade } = scorer.score(findings);
|
|
283
|
+
|
|
284
|
+
const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
|
|
285
|
+
const bySeverity = {};
|
|
286
|
+
for (const sev of SEV_ORDER) {
|
|
287
|
+
bySeverity[sev] = findings.filter(f => f.severity === sev).length;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const report = {
|
|
291
|
+
scannedAt: new Date().toISOString(),
|
|
292
|
+
rootPath,
|
|
293
|
+
score,
|
|
294
|
+
grade,
|
|
295
|
+
totalFindings: findings.length,
|
|
296
|
+
bySeverity,
|
|
297
|
+
findings: findings.map(f => ({
|
|
298
|
+
title: f.title,
|
|
299
|
+
severity: f.severity,
|
|
300
|
+
category: f.category,
|
|
301
|
+
rule: f.rule,
|
|
302
|
+
file: f.file ? path.relative(rootPath, f.file) : null,
|
|
303
|
+
line: f.line,
|
|
304
|
+
description: f.description,
|
|
305
|
+
remediation: f.remediation,
|
|
306
|
+
confidence: f.confidence,
|
|
307
|
+
...(f.deepAnalysis ? { deepAnalysis: f.deepAnalysis } : {}),
|
|
308
|
+
})),
|
|
309
|
+
...(deepStats ? { deepAnalysis: deepStats } : {}),
|
|
310
|
+
summary: `Score: ${score}/100 (${grade}) — ${findings.length} finding(s): ${bySeverity.critical} critical, ${bySeverity.high} high, ${bySeverity.medium} medium, ${bySeverity.low} low.`,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
if (outputFile) {
|
|
314
|
+
const outPath = path.resolve(outputFile);
|
|
315
|
+
fs.writeFileSync(outPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
316
|
+
report.savedTo = outPath;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return report;
|
|
320
|
+
} catch (err) {
|
|
321
|
+
return { error: `Scan failed: ${err.message}` };
|
|
322
|
+
} finally {
|
|
323
|
+
// Always restore console so other tool calls are not affected
|
|
324
|
+
console.log = savedLog;
|
|
325
|
+
console.warn = savedWarn;
|
|
326
|
+
console.error = savedError;
|
|
327
|
+
console.info = savedInfo;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function getFindings({ reportPath, severity }) {
|
|
332
|
+
const absPath = path.resolve(reportPath);
|
|
333
|
+
|
|
334
|
+
if (!fs.existsSync(absPath)) {
|
|
335
|
+
return { error: `Report file not found: ${absPath}` };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let report;
|
|
339
|
+
try {
|
|
340
|
+
report = JSON.parse(fs.readFileSync(absPath, 'utf-8'));
|
|
341
|
+
} catch (err) {
|
|
342
|
+
return { error: `Failed to parse report: ${err.message}` };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const findings = report.findings ?? [];
|
|
346
|
+
const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
347
|
+
const filtered = severity
|
|
348
|
+
? findings.filter(f => (SEV_RANK[f.severity] ?? 0) >= (SEV_RANK[severity] ?? 0))
|
|
349
|
+
: findings;
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
reportPath: absPath,
|
|
353
|
+
scannedAt: report.scannedAt,
|
|
354
|
+
score: report.score,
|
|
355
|
+
grade: report.grade,
|
|
356
|
+
totalFindings: filtered.length,
|
|
357
|
+
bySeverity: report.bySeverity,
|
|
358
|
+
findings: filtered,
|
|
359
|
+
summary: report.summary,
|
|
360
|
+
...(severity ? { filter: `severity >= ${severity}` } : {}),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function suppressFinding({ file, line, reason }) {
|
|
365
|
+
const absPath = path.resolve(file);
|
|
366
|
+
|
|
367
|
+
if (!fs.existsSync(absPath)) {
|
|
368
|
+
return { error: `File not found: ${absPath}` };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let content;
|
|
372
|
+
try {
|
|
373
|
+
content = fs.readFileSync(absPath, 'utf-8');
|
|
374
|
+
} catch (err) {
|
|
375
|
+
return { error: `Cannot read file: ${err.message}` };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const lines = content.split('\n');
|
|
379
|
+
const lineIdx = line - 1; // Convert to 0-indexed
|
|
380
|
+
|
|
381
|
+
if (lineIdx < 0 || lineIdx >= lines.length) {
|
|
382
|
+
return { error: `Line ${line} is out of range (file has ${lines.length} lines)` };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const targetLine = lines[lineIdx];
|
|
386
|
+
|
|
387
|
+
// Already suppressed?
|
|
388
|
+
if (/ship-safe-ignore/i.test(targetLine)) {
|
|
389
|
+
return { alreadySuppressed: true, file: absPath, line, message: 'Line already has a ship-safe-ignore comment.' };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Detect indentation and comment style
|
|
393
|
+
const indent = targetLine.match(/^(\s*)/)?.[1] ?? '';
|
|
394
|
+
const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(file);
|
|
395
|
+
const isPy = /\.py$/.test(file);
|
|
396
|
+
const isRb = /\.rb$/.test(file);
|
|
397
|
+
const isHtml = /\.(html?|vue|svelte)$/.test(file);
|
|
398
|
+
|
|
399
|
+
let ignoreComment;
|
|
400
|
+
if (isHtml) {
|
|
401
|
+
ignoreComment = `${indent}<!-- ship-safe-ignore — ${reason} -->`;
|
|
402
|
+
} else if (isPy || isRb) {
|
|
403
|
+
ignoreComment = `${indent}# ship-safe-ignore — ${reason}`;
|
|
404
|
+
} else {
|
|
405
|
+
ignoreComment = `${indent}// ship-safe-ignore — ${reason}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Insert ignore comment on the line BEFORE the finding
|
|
409
|
+
lines.splice(lineIdx, 0, ignoreComment);
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
fs.writeFileSync(absPath, lines.join('\n'), 'utf-8');
|
|
413
|
+
} catch (err) {
|
|
414
|
+
return { error: `Cannot write file: ${err.message}` };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
suppressed: true,
|
|
419
|
+
file: absPath,
|
|
420
|
+
originalLine: line,
|
|
421
|
+
insertedLine: line, // The ignore comment is now on this line, original moved to line+1
|
|
422
|
+
comment: ignoreComment,
|
|
423
|
+
message: `Added ship-safe-ignore comment before line ${line} in ${path.basename(file)}.`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
166
427
|
// =============================================================================
|
|
167
428
|
// SCAN UTILITIES (shared with scan command)
|
|
168
429
|
// =============================================================================
|
|
@@ -283,6 +544,15 @@ async function handleRequest(request) {
|
|
|
283
544
|
case 'analyze_file':
|
|
284
545
|
result = await analyzeFile(args);
|
|
285
546
|
break;
|
|
547
|
+
case 'scan_repo':
|
|
548
|
+
result = await scanRepo(args);
|
|
549
|
+
break;
|
|
550
|
+
case 'get_findings':
|
|
551
|
+
result = getFindings(args);
|
|
552
|
+
break;
|
|
553
|
+
case 'suppress_finding':
|
|
554
|
+
result = suppressFinding(args);
|
|
555
|
+
break;
|
|
286
556
|
default:
|
|
287
557
|
return respondError(-32601, `Unknown tool: ${name}`);
|
|
288
558
|
}
|