ship-safe 5.0.0 → 6.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.
@@ -1,260 +1,342 @@
1
- /**
2
- * CI Command — Optimized for CI/CD Pipelines
3
- * =============================================
4
- *
5
- * Single command for CI pipelines with:
6
- * - Exit code 1 if score < threshold (default 75)
7
- * - SARIF output for GitHub Code Scanning upload
8
- * - JSON output for custom integrations
9
- * - Compact summary for CI logs
10
- * - --fail-on flag for severity-based gating
11
- *
12
- * USAGE:
13
- * npx ship-safe ci . Default: fail if score < 75
14
- * npx ship-safe ci . --threshold 60 Custom score threshold
15
- * npx ship-safe ci . --fail-on critical Only fail on critical findings
16
- * npx ship-safe ci . --sarif results.sarif SARIF for GitHub Code Scanning
17
- * npx ship-safe ci . --baseline Only check new findings
18
- */
19
-
20
- import fs from 'fs';
21
- import path from 'path';
22
- import { buildOrchestrator } from '../agents/index.js';
23
- import { ScoringEngine } from '../agents/scoring-engine.js';
24
- import { PolicyEngine } from '../agents/policy-engine.js';
25
- import { runDepsAudit } from './deps.js';
26
- import { filterBaseline } from './baseline.js';
27
- import {
28
- SECRET_PATTERNS,
29
- SKIP_DIRS,
30
- SKIP_EXTENSIONS,
31
- MAX_FILE_SIZE,
32
- loadGitignorePatterns
33
- } from '../utils/patterns.js';
34
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
35
- import fg from 'fast-glob';
36
-
37
- // =============================================================================
38
- // MAIN COMMAND
39
- // =============================================================================
40
-
41
- export async function ciCommand(targetPath = '.', options = {}) {
42
- const absolutePath = path.resolve(targetPath);
43
- const threshold = options.threshold || 75;
44
- const failOn = options.failOn || null;
45
- const sarifPath = options.sarif || null;
46
-
47
- if (!fs.existsSync(absolutePath)) {
48
- console.error(`[ship-safe] Path does not exist: ${absolutePath}`);
49
- process.exit(1);
50
- }
51
-
52
- const startTime = Date.now();
53
-
54
- // ── Secret Scan ──────────────────────────────────────────────────────────
55
- const allFiles = await findFiles(absolutePath);
56
- const secretFindings = [];
57
-
58
- for (const file of allFiles) {
59
- try {
60
- const content = fs.readFileSync(file, 'utf-8');
61
- const lines = content.split('\n');
62
- for (let lineNum = 0; lineNum < lines.length; lineNum++) {
63
- const line = lines[lineNum];
64
- if (/ship-safe-ignore/i.test(line)) continue;
65
- for (const pattern of SECRET_PATTERNS) {
66
- pattern.pattern.lastIndex = 0;
67
- let match;
68
- while ((match = pattern.pattern.exec(line)) !== null) {
69
- if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
70
- secretFindings.push({
71
- file, line: lineNum + 1, column: match.index + 1,
72
- matched: match[0], severity: pattern.severity,
73
- category: pattern.category || 'secrets',
74
- rule: pattern.name, title: pattern.name.replace(/_/g, ' '),
75
- description: pattern.description,
76
- confidence: getConfidence(pattern, match[0]),
77
- fix: 'Move to environment variable or secrets manager',
78
- });
79
- }
80
- }
81
- }
82
- } catch { /* skip */ }
83
- }
84
-
85
- // ── Agent Scan ───────────────────────────────────────────────────────────
86
- const orchestrator = buildOrchestrator();
87
- const results = await orchestrator.runAll(absolutePath, { quiet: true });
88
- const agentFindings = results.findings;
89
-
90
- // ── Dependency Audit ─────────────────────────────────────────────────────
91
- let depVulns = [];
92
- if (options.deps !== false) {
93
- try {
94
- const depResult = await runDepsAudit(absolutePath);
95
- depVulns = depResult.vulns || [];
96
- } catch { /* skip */ }
97
- }
98
-
99
- // ── Merge & Deduplicate ──────────────────────────────────────────────────
100
- const seen = new Set();
101
- let allFindings = [...secretFindings, ...agentFindings].filter(f => {
102
- const key = `${f.file}:${f.line}:${f.rule}`;
103
- if (seen.has(key)) return false;
104
- seen.add(key);
105
- return true;
106
- });
107
-
108
- // Apply policy
109
- const policy = PolicyEngine.load(absolutePath);
110
- allFindings = policy.applyPolicy(allFindings);
111
-
112
- // Apply baseline filter
113
- if (options.baseline) {
114
- allFindings = filterBaseline(allFindings, absolutePath);
115
- }
116
-
117
- // ── Score ────────────────────────────────────────────────────────────────
118
- const scoringEngine = new ScoringEngine();
119
- const scoreResult = scoringEngine.compute(allFindings, depVulns);
120
- scoringEngine.saveToHistory(absolutePath, scoreResult);
121
-
122
- const duration = ((Date.now() - startTime) / 1000).toFixed(1);
123
-
124
- // ── SARIF Output ─────────────────────────────────────────────────────────
125
- if (sarifPath) {
126
- const sarif = buildSARIF(allFindings, absolutePath);
127
- fs.writeFileSync(sarifPath, JSON.stringify(sarif, null, 2));
128
- }
129
-
130
- // ── JSON Output ──────────────────────────────────────────────────────────
131
- if (options.json) {
132
- console.log(JSON.stringify({
133
- score: scoreResult.score,
134
- grade: scoreResult.grade.letter,
135
- totalFindings: allFindings.length,
136
- totalDepVulns: depVulns.length,
137
- critical: allFindings.filter(f => f.severity === 'critical').length,
138
- high: allFindings.filter(f => f.severity === 'high').length,
139
- medium: allFindings.filter(f => f.severity === 'medium').length,
140
- low: allFindings.filter(f => f.severity === 'low').length,
141
- threshold,
142
- pass: determinePass(scoreResult, allFindings, threshold, failOn),
143
- duration: `${duration}s`,
144
- }, null, 2));
145
- } else {
146
- // ── Compact CI Summary ───────────────────────────────────────────────
147
- const critical = allFindings.filter(f => f.severity === 'critical').length;
148
- const high = allFindings.filter(f => f.severity === 'high').length;
149
- const medium = allFindings.filter(f => f.severity === 'medium').length;
150
-
151
- console.log(`[ship-safe] Score: ${scoreResult.score}/100 (${scoreResult.grade.letter}) | Findings: ${allFindings.length} (${critical}C ${high}H ${medium}M) | CVEs: ${depVulns.length} | ${duration}s`);
152
-
153
- if (critical > 0) {
154
- console.log(`[ship-safe] Critical findings:`);
155
- for (const f of allFindings.filter(f => f.severity === 'critical').slice(0, 5)) {
156
- const rel = path.relative(absolutePath, f.file).replace(/\\/g, '/');
157
- console.log(` - ${f.rule} at ${rel}:${f.line}`);
158
- }
159
- }
160
-
161
- if (sarifPath) {
162
- console.log(`[ship-safe] SARIF: ${sarifPath}`);
163
- }
164
- }
165
-
166
- // ── Exit Code ────────────────────────────────────────────────────────────
167
- const pass = determinePass(scoreResult, allFindings, threshold, failOn);
168
- if (!pass) {
169
- if (!options.json) {
170
- if (failOn) {
171
- console.log(`[ship-safe] FAIL: Found ${failOn}-severity findings`);
172
- } else {
173
- console.log(`[ship-safe] FAIL: Score ${scoreResult.score} < threshold ${threshold}`);
174
- }
175
- }
176
- process.exit(1);
177
- } else {
178
- if (!options.json) {
179
- console.log(`[ship-safe] PASS`);
180
- }
181
- process.exit(0);
182
- }
183
- }
184
-
185
- // =============================================================================
186
- // HELPERS
187
- // =============================================================================
188
-
189
- function determinePass(scoreResult, findings, threshold, failOn) {
190
- if (failOn) {
191
- const sevOrder = ['critical', 'high', 'medium', 'low'];
192
- const failIndex = sevOrder.indexOf(failOn);
193
- if (failIndex === -1) return scoreResult.score >= threshold;
194
- const blockingSevs = sevOrder.slice(0, failIndex + 1);
195
- return !findings.some(f => blockingSevs.includes(f.severity));
196
- }
197
- return scoreResult.score >= threshold;
198
- }
199
-
200
- function buildSARIF(findings, rootPath) {
201
- const rules = {};
202
- for (const f of findings) {
203
- if (!rules[f.rule]) {
204
- rules[f.rule] = {
205
- id: f.rule, name: f.title || f.rule,
206
- shortDescription: { text: f.title || f.rule },
207
- fullDescription: { text: f.description || '' },
208
- defaultConfiguration: {
209
- level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
210
- },
211
- };
212
- }
213
- }
214
-
215
- return {
216
- version: '2.1.0',
217
- $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
218
- runs: [{
219
- tool: {
220
- driver: {
221
- name: 'ship-safe', version: '5.0.0',
222
- informationUri: 'https://github.com/asamassekou10/ship-safe',
223
- rules: Object.values(rules),
224
- },
225
- },
226
- results: findings.map(f => ({
227
- ruleId: f.rule,
228
- level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
229
- message: { text: `${f.title}: ${f.description}` },
230
- locations: [{
231
- physicalLocation: {
232
- artifactLocation: {
233
- uri: path.relative(rootPath, f.file).replace(/\\/g, '/'),
234
- uriBaseId: '%SRCROOT%',
235
- },
236
- region: { startLine: f.line, startColumn: f.column || 1 },
237
- },
238
- }],
239
- })),
240
- }],
241
- };
242
- }
243
-
244
- async function findFiles(rootPath) {
245
- const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
246
- const gitignoreGlobs = loadGitignorePatterns(rootPath);
247
- globIgnore.push(...gitignoreGlobs);
248
-
249
- const files = await fg('**/*', {
250
- cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true,
251
- });
252
-
253
- return files.filter(file => {
254
- const ext = path.extname(file).toLowerCase();
255
- if (SKIP_EXTENSIONS.has(ext)) return false;
256
- if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
257
- try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
258
- return true;
259
- });
260
- }
1
+ /**
2
+ * CI Command — Optimized for CI/CD Pipelines
3
+ * =============================================
4
+ *
5
+ * Single command for CI pipelines with:
6
+ * - Exit code 1 if score < threshold (default 75)
7
+ * - SARIF output for GitHub Code Scanning upload
8
+ * - JSON output for custom integrations
9
+ * - Compact summary for CI logs
10
+ * - --fail-on flag for severity-based gating
11
+ *
12
+ * USAGE:
13
+ * npx ship-safe ci . Default: fail if score < 75
14
+ * npx ship-safe ci . --threshold 60 Custom score threshold
15
+ * npx ship-safe ci . --fail-on critical Only fail on critical findings
16
+ * npx ship-safe ci . --sarif results.sarif SARIF for GitHub Code Scanning
17
+ * npx ship-safe ci . --baseline Only check new findings
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import { execFileSync } from 'child_process';
23
+ import { buildOrchestrator } from '../agents/index.js';
24
+ import { ScoringEngine } from '../agents/scoring-engine.js';
25
+ import { PolicyEngine } from '../agents/policy-engine.js';
26
+ import { runDepsAudit } from './deps.js';
27
+ import { filterBaseline } from './baseline.js';
28
+ import {
29
+ SECRET_PATTERNS,
30
+ SKIP_DIRS,
31
+ SKIP_EXTENSIONS,
32
+ SKIP_FILENAMES,
33
+ MAX_FILE_SIZE,
34
+ loadGitignorePatterns
35
+ } from '../utils/patterns.js';
36
+ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
37
+ import fg from 'fast-glob';
38
+
39
+ // =============================================================================
40
+ // MAIN COMMAND
41
+ // =============================================================================
42
+
43
+ export async function ciCommand(targetPath = '.', options = {}) {
44
+ const absolutePath = path.resolve(targetPath);
45
+ const threshold = options.threshold || 75;
46
+ const failOn = options.failOn || null;
47
+ const sarifPath = options.sarif || null;
48
+
49
+ if (!fs.existsSync(absolutePath)) {
50
+ console.error(`[ship-safe] Path does not exist: ${absolutePath}`);
51
+ process.exit(1);
52
+ }
53
+
54
+ const startTime = Date.now();
55
+
56
+ // ── Secret Scan ──────────────────────────────────────────────────────────
57
+ const allFiles = await findFiles(absolutePath);
58
+ const secretFindings = [];
59
+
60
+ for (const file of allFiles) {
61
+ try {
62
+ const content = fs.readFileSync(file, 'utf-8');
63
+ const lines = content.split('\n');
64
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
65
+ const line = lines[lineNum];
66
+ if (/ship-safe-ignore/i.test(line)) continue;
67
+ for (const pattern of SECRET_PATTERNS) {
68
+ pattern.pattern.lastIndex = 0;
69
+ let match;
70
+ while ((match = pattern.pattern.exec(line)) !== null) {
71
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
72
+ secretFindings.push({
73
+ file, line: lineNum + 1, column: match.index + 1,
74
+ matched: match[0], severity: pattern.severity,
75
+ category: pattern.category || 'secrets',
76
+ rule: pattern.name, title: pattern.name.replace(/_/g, ' '),
77
+ description: pattern.description,
78
+ confidence: getConfidence(pattern, match[0]),
79
+ fix: 'Move to environment variable or secrets manager',
80
+ });
81
+ }
82
+ }
83
+ }
84
+ } catch { /* skip */ }
85
+ }
86
+
87
+ // ── Agent Scan ───────────────────────────────────────────────────────────
88
+ const orchestrator = buildOrchestrator();
89
+ const results = await orchestrator.runAll(absolutePath, { quiet: true }); // ship-safe-ignore — orchestrator result, not LLM output triggering actions
90
+ const agentFindings = results.findings;
91
+
92
+ // ── Dependency Audit ─────────────────────────────────────────────────────
93
+ let depVulns = [];
94
+ if (options.deps !== false) {
95
+ try {
96
+ const depResult = await runDepsAudit(absolutePath);
97
+ depVulns = depResult.vulns || [];
98
+ } catch { /* skip */ }
99
+ }
100
+
101
+ // ── Merge & Deduplicate ──────────────────────────────────────────────────
102
+ const seen = new Set();
103
+ let allFindings = [...secretFindings, ...agentFindings].filter(f => {
104
+ const key = `${f.file}:${f.line}:${f.rule}`;
105
+ if (seen.has(key)) return false;
106
+ seen.add(key);
107
+ return true;
108
+ });
109
+
110
+ // Apply policy
111
+ const policy = PolicyEngine.load(absolutePath);
112
+ allFindings = policy.applyPolicy(allFindings);
113
+
114
+ // Apply baseline filter
115
+ if (options.baseline) {
116
+ allFindings = filterBaseline(allFindings, absolutePath);
117
+ }
118
+
119
+ // ── Score ────────────────────────────────────────────────────────────────
120
+ const scoringEngine = new ScoringEngine();
121
+ const scoreResult = scoringEngine.compute(allFindings, depVulns);
122
+ scoringEngine.saveToHistory(absolutePath, scoreResult);
123
+
124
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
125
+
126
+ // ── SARIF Output ─────────────────────────────────────────────────────────
127
+ if (sarifPath) {
128
+ const sarif = buildSARIF(allFindings, absolutePath);
129
+ fs.writeFileSync(sarifPath, JSON.stringify(sarif, null, 2));
130
+ }
131
+
132
+ // ── JSON Output ──────────────────────────────────────────────────────────
133
+ if (options.json) {
134
+ console.log(JSON.stringify({
135
+ score: scoreResult.score,
136
+ grade: scoreResult.grade.letter,
137
+ totalFindings: allFindings.length,
138
+ totalDepVulns: depVulns.length,
139
+ critical: allFindings.filter(f => f.severity === 'critical').length,
140
+ high: allFindings.filter(f => f.severity === 'high').length,
141
+ medium: allFindings.filter(f => f.severity === 'medium').length,
142
+ low: allFindings.filter(f => f.severity === 'low').length,
143
+ threshold,
144
+ pass: determinePass(scoreResult, allFindings, threshold, failOn),
145
+ duration: `${duration}s`,
146
+ }, null, 2));
147
+ } else {
148
+ // ── Compact CI Summary ───────────────────────────────────────────────
149
+ const critical = allFindings.filter(f => f.severity === 'critical').length;
150
+ const high = allFindings.filter(f => f.severity === 'high').length;
151
+ const medium = allFindings.filter(f => f.severity === 'medium').length;
152
+
153
+ console.log(`[ship-safe] Score: ${scoreResult.score}/100 (${scoreResult.grade.letter}) | Findings: ${allFindings.length} (${critical}C ${high}H ${medium}M) | CVEs: ${depVulns.length} | ${duration}s`);
154
+
155
+ if (critical > 0) {
156
+ console.log(`[ship-safe] Critical findings:`);
157
+ for (const f of allFindings.filter(f => f.severity === 'critical').slice(0, 5)) {
158
+ const rel = path.relative(absolutePath, f.file).replace(/\\/g, '/');
159
+ console.log(` - ${f.rule} at ${rel}:${f.line}`);
160
+ }
161
+ }
162
+
163
+ if (sarifPath) {
164
+ console.log(`[ship-safe] SARIF: ${sarifPath}`);
165
+ }
166
+ }
167
+
168
+ // ── GitHub PR Comment ──────────────────────────────────────────────────
169
+ if (options.githubPr) {
170
+ try {
171
+ postPRComment(scoreResult, allFindings, depVulns, absolutePath, duration);
172
+ } catch (err) {
173
+ console.log(`[ship-safe] Warning: Could not post PR comment: ${err.message}`);
174
+ }
175
+ }
176
+
177
+ // ── Exit Code ────────────────────────────────────────────────────────────
178
+ const pass = determinePass(scoreResult, allFindings, threshold, failOn);
179
+ if (!pass) {
180
+ if (!options.json) {
181
+ if (failOn) {
182
+ console.log(`[ship-safe] FAIL: Found ${failOn}-severity findings`);
183
+ } else {
184
+ console.log(`[ship-safe] FAIL: Score ${scoreResult.score} < threshold ${threshold}`);
185
+ }
186
+ }
187
+ process.exit(1);
188
+ } else {
189
+ if (!options.json) {
190
+ console.log(`[ship-safe] PASS`);
191
+ }
192
+ process.exit(0);
193
+ }
194
+ }
195
+
196
+ // =============================================================================
197
+ // HELPERS
198
+ // =============================================================================
199
+
200
+ function determinePass(scoreResult, findings, threshold, failOn) {
201
+ if (failOn) {
202
+ const sevOrder = ['critical', 'high', 'medium', 'low'];
203
+ const failIndex = sevOrder.indexOf(failOn);
204
+ if (failIndex === -1) return scoreResult.score >= threshold;
205
+ const blockingSevs = sevOrder.slice(0, failIndex + 1);
206
+ return !findings.some(f => blockingSevs.includes(f.severity));
207
+ }
208
+ return scoreResult.score >= threshold;
209
+ }
210
+
211
+ function buildSARIF(findings, rootPath) {
212
+ const rules = {};
213
+ for (const f of findings) {
214
+ if (!rules[f.rule]) {
215
+ rules[f.rule] = {
216
+ id: f.rule, name: f.title || f.rule,
217
+ shortDescription: { text: f.title || f.rule },
218
+ fullDescription: { text: f.description || '' },
219
+ defaultConfiguration: {
220
+ level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
221
+ },
222
+ };
223
+ }
224
+ }
225
+
226
+ return {
227
+ version: '2.1.0',
228
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
229
+ runs: [{
230
+ tool: {
231
+ driver: {
232
+ name: 'ship-safe', version: '5.0.0',
233
+ informationUri: 'https://github.com/asamassekou10/ship-safe',
234
+ rules: Object.values(rules),
235
+ },
236
+ },
237
+ results: findings.map(f => ({
238
+ ruleId: f.rule,
239
+ level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
240
+ message: { text: `${f.title}: ${f.description}` },
241
+ locations: [{
242
+ physicalLocation: {
243
+ artifactLocation: {
244
+ uri: path.relative(rootPath, f.file).replace(/\\/g, '/'),
245
+ uriBaseId: '%SRCROOT%',
246
+ },
247
+ region: { startLine: f.line, startColumn: f.column || 1 },
248
+ },
249
+ }],
250
+ })),
251
+ }],
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Post a summary comment on the current GitHub PR using the `gh` CLI.
257
+ * Requires: `gh` installed and authenticated, running in a PR context.
258
+ */
259
+ function postPRComment(scoreResult, findings, depVulns, rootPath, duration) {
260
+ // Detect PR number from environment (GitHub Actions sets GITHUB_REF)
261
+ let prNumber = process.env.GITHUB_PR_NUMBER || '';
262
+
263
+ if (!prNumber) {
264
+ // Try to detect from GITHUB_REF (refs/pull/123/merge)
265
+ const ref = process.env.GITHUB_REF || '';
266
+ const match = ref.match(/refs\/pull\/(\d+)\//);
267
+ if (match) prNumber = match[1];
268
+ }
269
+
270
+ if (!prNumber) {
271
+ // Try gh pr view to get current PR
272
+ try {
273
+ const prJson = execFileSync('gh', ['pr', 'view', '--json', 'number'], { // ship-safe-ignore — execFileSync, not MCP
274
+ cwd: rootPath, stdio: ['pipe', 'pipe', 'pipe'], // ship-safe-ignore
275
+ }).toString();
276
+ const parsed = JSON.parse(prJson);
277
+ prNumber = String(parsed.number);
278
+ } catch {
279
+ console.log('[ship-safe] No PR detected — skipping PR comment');
280
+ return;
281
+ }
282
+ }
283
+
284
+ const critical = findings.filter(f => f.severity === 'critical').length;
285
+ const high = findings.filter(f => f.severity === 'high').length;
286
+ const medium = findings.filter(f => f.severity === 'medium').length;
287
+ const low = findings.filter(f => f.severity === 'low').length;
288
+
289
+ const gradeEmoji = { A: '🟢', B: '🔵', C: '🟡', D: '🟠', F: '🔴' };
290
+ const emoji = gradeEmoji[scoreResult.grade.letter] || '⚪';
291
+
292
+ // Build markdown body
293
+ let body = `## ${emoji} Ship Safe Security Report\n\n`;
294
+ body += `| Metric | Value |\n|--------|-------|\n`;
295
+ body += `| **Score** | ${scoreResult.score}/100 (${scoreResult.grade.letter}) |\n`;
296
+ body += `| **Findings** | ${findings.length} total (${critical}C ${high}H ${medium}M ${low}L) |\n`;
297
+ body += `| **Dep CVEs** | ${depVulns.length} |\n`;
298
+ body += `| **Duration** | ${duration}s |\n\n`;
299
+
300
+ if (critical > 0 || high > 0) {
301
+ body += `### Critical & High Findings\n\n`;
302
+ body += `| Severity | File | Issue |\n|----------|------|-------|\n`;
303
+ for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high').slice(0, 20)) {
304
+ const rel = path.relative(rootPath, f.file).replace(/\\/g, '/');
305
+ body += `| ${f.severity.toUpperCase()} | \`${rel}:${f.line}\` | ${(f.title || f.rule).slice(0, 60)} |\n`;
306
+ }
307
+ body += '\n';
308
+ }
309
+
310
+ if (findings.length === 0 && depVulns.length === 0) {
311
+ body += '> No security issues found — looking good! 🎉\n\n';
312
+ }
313
+
314
+ body += `<sub>Generated by <a href="https://shipsafecli.com">Ship Safe</a></sub>`;
315
+
316
+ // Post comment via gh CLI
317
+ execFileSync('gh', ['pr', 'comment', prNumber, '--body', body], { // ship-safe-ignore — execFileSync, not MCP
318
+ cwd: rootPath,
319
+ stdio: ['pipe', 'pipe', 'pipe'], // ship-safe-ignore
320
+ });
321
+
322
+ console.log(`[ship-safe] PR comment posted on #${prNumber}`);
323
+ }
324
+
325
+ async function findFiles(rootPath) {
326
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
327
+ const gitignoreGlobs = loadGitignorePatterns(rootPath);
328
+ globIgnore.push(...gitignoreGlobs);
329
+
330
+ const files = await fg('**/*', {
331
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true,
332
+ });
333
+
334
+ return files.filter(file => {
335
+ const ext = path.extname(file).toLowerCase();
336
+ if (SKIP_EXTENSIONS.has(ext)) return false;
337
+ if (SKIP_FILENAMES.has(path.basename(file))) return false;
338
+ if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
339
+ try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
340
+ return true;
341
+ });
342
+ }