ship-safe 4.2.0 → 5.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 +134 -25
- package/cli/__tests__/agents.test.js +805 -0
- package/cli/agents/agentic-security-agent.js +261 -0
- package/cli/agents/api-fuzzer.js +111 -0
- package/cli/agents/base-agent.js +271 -253
- package/cli/agents/config-auditor.js +71 -0
- package/cli/agents/deep-analyzer.js +333 -0
- package/cli/agents/html-reporter.js +370 -363
- package/cli/agents/index.js +74 -56
- package/cli/agents/injection-tester.js +45 -0
- package/cli/agents/mcp-security-agent.js +358 -0
- package/cli/agents/mobile-scanner.js +6 -0
- package/cli/agents/orchestrator.js +109 -7
- package/cli/agents/pii-compliance-agent.js +301 -0
- package/cli/agents/rag-security-agent.js +204 -0
- package/cli/agents/sbom-generator.js +100 -11
- package/cli/agents/scoring-engine.js +4 -0
- package/cli/agents/supabase-rls-agent.js +154 -0
- package/cli/agents/supply-chain-agent.js +507 -274
- package/cli/agents/verifier-agent.js +292 -0
- package/cli/bin/ship-safe.js +46 -6
- package/cli/commands/audit.js +59 -1
- package/cli/commands/baseline.js +192 -0
- package/cli/commands/ci.js +260 -0
- package/cli/commands/red-team.js +8 -2
- package/cli/index.js +4 -0
- package/cli/utils/autofix-rules.js +74 -0
- package/cli/utils/pdf-generator.js +94 -0
- package/cli/utils/secrets-verifier.js +247 -0
- package/package.json +2 -2
|
@@ -0,0 +1,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 { 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
|
+
}
|
package/cli/commands/red-team.js
CHANGED
|
@@ -45,10 +45,16 @@ export async function redTeamCommand(targetPath = '.', options = {}) {
|
|
|
45
45
|
? options.agents.split(',').map(a => a.trim())
|
|
46
46
|
: null;
|
|
47
47
|
|
|
48
|
-
const
|
|
48
|
+
const orchestratorOpts = {
|
|
49
49
|
verbose: options.verbose,
|
|
50
50
|
agents: agentFilter,
|
|
51
|
-
}
|
|
51
|
+
};
|
|
52
|
+
if (options.deep) orchestratorOpts.deep = true;
|
|
53
|
+
if (options.local) orchestratorOpts.local = true;
|
|
54
|
+
if (options.model) orchestratorOpts.model = options.model;
|
|
55
|
+
if (options.budget) orchestratorOpts.budget = options.budget;
|
|
56
|
+
|
|
57
|
+
const results = await orchestrator.runAll(absolutePath, orchestratorOpts);
|
|
52
58
|
|
|
53
59
|
const { recon, findings, agentResults } = results;
|
|
54
60
|
|
package/cli/index.js
CHANGED
|
@@ -22,6 +22,9 @@ export { watchCommand } from './commands/watch.js';
|
|
|
22
22
|
// ── v4.2 Commands ─────────────────────────────────────────────────────────────
|
|
23
23
|
export { doctorCommand } from './commands/doctor.js';
|
|
24
24
|
|
|
25
|
+
// ── v4.3 Commands ─────────────────────────────────────────────────────────────
|
|
26
|
+
export { baselineCommand } from './commands/baseline.js';
|
|
27
|
+
|
|
25
28
|
// ── Patterns ──────────────────────────────────────────────────────────────────
|
|
26
29
|
export { SECRET_PATTERNS, SECURITY_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS } from './utils/patterns.js';
|
|
27
30
|
|
|
@@ -42,6 +45,7 @@ export { MobileScanner } from './agents/mobile-scanner.js';
|
|
|
42
45
|
export { GitHistoryScanner } from './agents/git-history-scanner.js';
|
|
43
46
|
export { CICDScanner } from './agents/cicd-scanner.js';
|
|
44
47
|
export { APIFuzzer } from './agents/api-fuzzer.js';
|
|
48
|
+
export { SupabaseRLSAgent } from './agents/supabase-rls-agent.js';
|
|
45
49
|
|
|
46
50
|
// ── Supporting Modules ────────────────────────────────────────────────────────
|
|
47
51
|
export { ScoringEngine, GRADES, CATEGORIES } from './agents/scoring-engine.js';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-Fix Rules
|
|
3
|
+
* ================
|
|
4
|
+
*
|
|
5
|
+
* Pure functions that transform a source line to fix a security issue.
|
|
6
|
+
* Each rule maps to a finding rule name from the agents.
|
|
7
|
+
*
|
|
8
|
+
* Used by `ship-safe remediate --all` to auto-fix agent-detected issues
|
|
9
|
+
* beyond just secrets.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const AUTOFIX_RULES = [
|
|
13
|
+
{
|
|
14
|
+
rule: 'TLS_REJECT_UNAUTHORIZED',
|
|
15
|
+
match: /rejectUnauthorized\s*:\s*false/g,
|
|
16
|
+
replace: (line) => line.replace(/rejectUnauthorized\s*:\s*false/, 'rejectUnauthorized: true // TODO: configure proper CA bundle'),
|
|
17
|
+
description: 'Enable TLS certificate verification',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
rule: 'DOCKER_LATEST_TAG',
|
|
21
|
+
match: /FROM\s+(\S+):latest/gi,
|
|
22
|
+
replace: (line) => {
|
|
23
|
+
return line.replace(/FROM\s+(\S+):latest/i, (_, image) => {
|
|
24
|
+
const pinned = { node: 'node:20-alpine', python: 'python:3.12-slim', nginx: 'nginx:1.25-alpine', ruby: 'ruby:3.3-slim' };
|
|
25
|
+
return `FROM ${pinned[image] || image + ':latest'} # TODO: pin to specific version`;
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
description: 'Pin Docker base image to a specific version',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
rule: 'DEBUG_MODE_PRODUCTION',
|
|
32
|
+
match: /(?:DEBUG|debug)\s*[:=]\s*(?:true|True|1|['"]true['"])/g,
|
|
33
|
+
replace: (line) => line
|
|
34
|
+
.replace(/DEBUG\s*=\s*True/, 'DEBUG = False')
|
|
35
|
+
.replace(/DEBUG\s*=\s*true/, 'DEBUG = false')
|
|
36
|
+
.replace(/debug\s*:\s*true/, 'debug: false')
|
|
37
|
+
.replace(/debug\s*=\s*['"]true['"]/, "debug = 'false'"),
|
|
38
|
+
description: 'Disable debug mode for production',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
rule: 'XSS_DANGEROUS_INNER_HTML',
|
|
42
|
+
match: /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*([^}]+)\}\s*\}/g, // ship-safe-ignore: autofix pattern
|
|
43
|
+
replace: (line) => {
|
|
44
|
+
return line.replace(
|
|
45
|
+
/dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*([^}]+)\}\s*\}/,
|
|
46
|
+
(_, value) => `dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(${value.trim()}) }}` // ship-safe-ignore: replacement template
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
description: 'Wrap dangerouslySetInnerHTML value in DOMPurify.sanitize()',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
rule: 'CMD_INJECTION_SHELL_TRUE',
|
|
53
|
+
match: /shell\s*:\s*true/g,
|
|
54
|
+
replace: (line) => line.replace(/shell\s*:\s*true/, 'shell: false // TODO: ensure command works without shell'),
|
|
55
|
+
description: 'Disable shell execution in spawn/exec',
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if a finding's rule has an autofix available.
|
|
61
|
+
*/
|
|
62
|
+
export function hasAutofix(rule) {
|
|
63
|
+
return AUTOFIX_RULES.some(r => r.rule === rule);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Apply an autofix rule to a line.
|
|
68
|
+
* Returns the fixed line, or the original if no rule matches.
|
|
69
|
+
*/
|
|
70
|
+
export function applyAutofix(rule, line) {
|
|
71
|
+
const fixRule = AUTOFIX_RULES.find(r => r.rule === rule);
|
|
72
|
+
if (!fixRule) return line;
|
|
73
|
+
return fixRule.replace(line);
|
|
74
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF Generator
|
|
3
|
+
* ==============
|
|
4
|
+
*
|
|
5
|
+
* Zero-dependency PDF generation via Chrome/Chromium headless mode.
|
|
6
|
+
* Falls back to generating a print-optimized HTML file if Chrome is not found.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Well-known Chrome/Chromium paths by platform.
|
|
15
|
+
*/
|
|
16
|
+
function findChrome() {
|
|
17
|
+
const candidates = process.platform === 'win32'
|
|
18
|
+
? [
|
|
19
|
+
process.env.CHROME_PATH,
|
|
20
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
21
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
22
|
+
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
|
|
23
|
+
]
|
|
24
|
+
: process.platform === 'darwin'
|
|
25
|
+
? [
|
|
26
|
+
process.env.CHROME_PATH,
|
|
27
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
28
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
29
|
+
]
|
|
30
|
+
: [
|
|
31
|
+
process.env.CHROME_PATH,
|
|
32
|
+
'/usr/bin/google-chrome',
|
|
33
|
+
'/usr/bin/google-chrome-stable',
|
|
34
|
+
'/usr/bin/chromium',
|
|
35
|
+
'/usr/bin/chromium-browser',
|
|
36
|
+
'/snap/bin/chromium',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const c of candidates) {
|
|
40
|
+
if (c && fs.existsSync(c)) return c;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if Chrome is available.
|
|
47
|
+
*/
|
|
48
|
+
export function isChromeAvailable() {
|
|
49
|
+
return findChrome() !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate PDF from an HTML file using Chrome headless.
|
|
54
|
+
* Returns the output path, or null if Chrome is not available.
|
|
55
|
+
*/
|
|
56
|
+
export function generatePDF(htmlPath, outputPath) {
|
|
57
|
+
const chrome = findChrome();
|
|
58
|
+
if (!chrome) return null;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const args = [
|
|
62
|
+
'--headless',
|
|
63
|
+
'--disable-gpu',
|
|
64
|
+
'--no-sandbox',
|
|
65
|
+
`--print-to-pdf=${outputPath}`,
|
|
66
|
+
'--print-to-pdf-no-header',
|
|
67
|
+
htmlPath,
|
|
68
|
+
];
|
|
69
|
+
execFileSync(chrome, args, { timeout: 30000, stdio: 'pipe' });
|
|
70
|
+
return outputPath;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Generate a print-optimized HTML file as PDF fallback.
|
|
78
|
+
*/
|
|
79
|
+
export function generatePrintHTML(htmlPath, outputPath) {
|
|
80
|
+
let html = fs.readFileSync(htmlPath, 'utf-8');
|
|
81
|
+
// Add print-optimized styles
|
|
82
|
+
const printCSS = `
|
|
83
|
+
<style media="print">
|
|
84
|
+
body { background: #fff !important; color: #1e293b !important; }
|
|
85
|
+
.score-card, .stat, .summary-card, .toc { background: #f8fafc !important; border: 1px solid #e2e8f0 !important; }
|
|
86
|
+
table, th, td { border: 1px solid #e2e8f0 !important; }
|
|
87
|
+
code { background: #f1f5f9 !important; color: #0f172a !important; }
|
|
88
|
+
pre { background: #f1f5f9 !important; }
|
|
89
|
+
a { color: #0369a1 !important; }
|
|
90
|
+
</style>`;
|
|
91
|
+
html = html.replace('</head>', printCSS + '\n</head>');
|
|
92
|
+
fs.writeFileSync(outputPath, html);
|
|
93
|
+
return outputPath;
|
|
94
|
+
}
|