ship-safe 1.0.1 → 3.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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Fix Command
3
+ * ===========
4
+ *
5
+ * Scans for secrets and generates a .env.example file with placeholder values.
6
+ * Also shows a summary of what to move to environment variables.
7
+ *
8
+ * USAGE:
9
+ * ship-safe fix Scan and generate .env.example
10
+ * ship-safe fix --dry-run Preview what would be generated (don't write file)
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import ora from 'ora';
16
+ import chalk from 'chalk';
17
+ import {
18
+ SECRET_PATTERNS,
19
+ SKIP_DIRS,
20
+ SKIP_EXTENSIONS,
21
+ TEST_FILE_PATTERNS,
22
+ MAX_FILE_SIZE
23
+ } from '../utils/patterns.js';
24
+ import { isHighEntropyMatch } from '../utils/entropy.js';
25
+ import { glob } from 'glob';
26
+ import * as output from '../utils/output.js';
27
+
28
+ // =============================================================================
29
+ // MAIN COMMAND
30
+ // =============================================================================
31
+
32
+ export async function fixCommand(options = {}) {
33
+ const cwd = process.cwd();
34
+
35
+ const spinner = ora({ text: 'Scanning for secrets...', color: 'cyan' }).start();
36
+
37
+ try {
38
+ const files = await findFiles(cwd);
39
+ const results = [];
40
+
41
+ for (const file of files) {
42
+ const findings = await scanFile(file);
43
+ if (findings.length > 0) {
44
+ results.push({ file, findings });
45
+ }
46
+ }
47
+
48
+ spinner.stop();
49
+
50
+ if (results.length === 0) {
51
+ output.success('No secrets found — nothing to fix!');
52
+ console.log(chalk.gray('\nYour codebase looks clean. Keep it that way with:'));
53
+ console.log(chalk.gray(' npx ship-safe guard # Block pushes if secrets are found'));
54
+ return;
55
+ }
56
+
57
+ // Build env var suggestions from findings
58
+ const envVars = buildEnvVarSuggestions(results);
59
+
60
+ output.header('Fix Report');
61
+ printFindings(results, cwd);
62
+ printEnvExample(envVars, options.dryRun);
63
+
64
+ } catch (err) {
65
+ spinner.fail('Fix scan failed');
66
+ output.error(err.message);
67
+ process.exit(1);
68
+ }
69
+ }
70
+
71
+ // =============================================================================
72
+ // SCAN (same logic as scan command, reused here)
73
+ // =============================================================================
74
+
75
+ async function findFiles(rootPath) {
76
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
77
+ const files = await glob('**/*', {
78
+ cwd: rootPath, absolute: true, nodir: true, ignore: globIgnore, dot: true
79
+ });
80
+
81
+ const filtered = [];
82
+ for (const file of files) {
83
+ const ext = path.extname(file).toLowerCase();
84
+ if (SKIP_EXTENSIONS.has(ext)) continue;
85
+ const basename = path.basename(file);
86
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
87
+ if (TEST_FILE_PATTERNS.some(p => p.test(file))) continue;
88
+ if (basename === '.env.example') continue; // Don't scan example files
89
+ try {
90
+ const stats = fs.statSync(file);
91
+ if (stats.size > MAX_FILE_SIZE) continue;
92
+ } catch { continue; }
93
+ filtered.push(file);
94
+ }
95
+ return filtered;
96
+ }
97
+
98
+ async function scanFile(filePath) {
99
+ const findings = [];
100
+ try {
101
+ const content = fs.readFileSync(filePath, 'utf-8');
102
+ const lines = content.split('\n');
103
+
104
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
105
+ const line = lines[lineNum];
106
+ if (/ship-safe-ignore/i.test(line)) continue;
107
+
108
+ for (const pattern of SECRET_PATTERNS) {
109
+ pattern.pattern.lastIndex = 0;
110
+ let match;
111
+ while ((match = pattern.pattern.exec(line)) !== null) {
112
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
113
+ findings.push({
114
+ line: lineNum + 1,
115
+ matched: match[0],
116
+ patternName: pattern.name,
117
+ severity: pattern.severity,
118
+ });
119
+ }
120
+ }
121
+ }
122
+ } catch {}
123
+ return findings;
124
+ }
125
+
126
+ // =============================================================================
127
+ // ENV VAR GENERATION
128
+ // =============================================================================
129
+
130
+ function buildEnvVarSuggestions(results) {
131
+ const seen = new Set();
132
+ const vars = [];
133
+
134
+ for (const { findings } of results) {
135
+ for (const f of findings) {
136
+ const varName = patternToEnvVar(f.patternName);
137
+ if (!seen.has(varName)) {
138
+ seen.add(varName);
139
+ vars.push({ name: varName, comment: f.patternName });
140
+ }
141
+ }
142
+ }
143
+
144
+ return vars;
145
+ }
146
+
147
+ /**
148
+ * Convert a pattern name to a sensible env var name.
149
+ * e.g. "OpenAI API Key" → "OPENAI_API_KEY"
150
+ */
151
+ function patternToEnvVar(patternName) {
152
+ return patternName
153
+ .toUpperCase()
154
+ .replace(/[^A-Z0-9\s]/g, '')
155
+ .trim()
156
+ .replace(/\s+/g, '_');
157
+ }
158
+
159
+ // =============================================================================
160
+ // OUTPUT
161
+ // =============================================================================
162
+
163
+ function printFindings(results, rootPath) {
164
+ const total = results.reduce((sum, r) => sum + r.findings.length, 0);
165
+ console.log(chalk.red.bold(`\n Found ${total} secret(s) across ${results.length} file(s)\n`));
166
+
167
+ for (const { file, findings } of results) {
168
+ const relPath = path.relative(rootPath, file);
169
+ console.log(chalk.white.bold(` ${relPath}`));
170
+ for (const f of findings) {
171
+ console.log(chalk.gray(` Line ${f.line}: `) + chalk.yellow(f.patternName));
172
+ }
173
+ }
174
+ }
175
+
176
+ function printEnvExample(envVars, dryRun) {
177
+ const lines = [
178
+ '# .env.example',
179
+ '# Generated by ship-safe — replace placeholder values with your actual secrets.',
180
+ '# Copy this file to .env and fill in the values.',
181
+ '# NEVER commit .env — only commit .env.example',
182
+ '',
183
+ ];
184
+
185
+ for (const { name, comment } of envVars) {
186
+ lines.push(`# ${comment}`);
187
+ lines.push(`${name}=your_${name.toLowerCase()}_here`);
188
+ lines.push('');
189
+ }
190
+
191
+ const content = lines.join('\n');
192
+
193
+ output.header(dryRun ? '.env.example Preview (dry run)' : 'Generated .env.example');
194
+ console.log();
195
+ console.log(chalk.gray(content));
196
+
197
+ if (!dryRun) {
198
+ const envExamplePath = path.join(process.cwd(), '.env.example');
199
+
200
+ if (fs.existsSync(envExamplePath)) {
201
+ output.warning('.env.example already exists — skipping. Use --force to overwrite.');
202
+ } else {
203
+ fs.writeFileSync(envExamplePath, content);
204
+ output.success('Created .env.example');
205
+ }
206
+
207
+ console.log();
208
+ console.log(chalk.cyan.bold('Next steps:'));
209
+ console.log(chalk.white('1.') + chalk.gray(' Copy .env.example to .env'));
210
+ console.log(chalk.white('2.') + chalk.gray(' Replace placeholder values with your real secrets'));
211
+ console.log(chalk.white('3.') + chalk.gray(' Remove the hardcoded values from your source code'));
212
+ console.log(chalk.white('4.') + chalk.gray(' Verify .env is in your .gitignore'));
213
+ console.log(chalk.white('5.') + chalk.gray(' Run npx ship-safe scan . to confirm clean'));
214
+ console.log();
215
+ }
216
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Guard Command
3
+ * =============
4
+ *
5
+ * Installs a git pre-push hook that runs ship-safe scan before every push.
6
+ * If secrets are found, the push is blocked.
7
+ *
8
+ * USAGE:
9
+ * ship-safe guard Install pre-push hook
10
+ * ship-safe guard --pre-commit Install pre-commit hook instead
11
+ * ship-safe guard remove Remove installed hooks
12
+ *
13
+ * HUSKY SUPPORT:
14
+ * If a .husky/ directory is detected, the hook is added there instead.
15
+ * Otherwise it goes directly into .git/hooks/.
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import chalk from 'chalk';
21
+ import * as output from '../utils/output.js';
22
+
23
+ // =============================================================================
24
+ // HOOK SCRIPTS
25
+ // =============================================================================
26
+
27
+ const PRE_PUSH_HOOK = `#!/bin/sh
28
+ # ship-safe pre-push hook
29
+ # Scans for leaked secrets before every git push.
30
+ # Remove this hook with: npx ship-safe guard remove
31
+
32
+ echo ""
33
+ echo "🔍 ship-safe: Scanning for secrets before push..."
34
+
35
+ npx --yes ship-safe scan . --json > /tmp/ship-safe-scan.json 2>/dev/null
36
+
37
+ if [ $? -ne 0 ]; then
38
+ echo ""
39
+ echo "❌ ship-safe: Secrets detected! Push blocked."
40
+ echo ""
41
+ echo "Run 'npx ship-safe scan .' to see details."
42
+ echo "Fix the issues, then push again."
43
+ echo ""
44
+ echo "To skip this check (not recommended):"
45
+ echo " git push --no-verify"
46
+ echo ""
47
+ rm -f /tmp/ship-safe-scan.json
48
+ exit 1
49
+ fi
50
+
51
+ echo "✅ ship-safe: No secrets detected. Pushing..."
52
+ rm -f /tmp/ship-safe-scan.json
53
+ exit 0
54
+ `;
55
+
56
+ const PRE_COMMIT_HOOK = `#!/bin/sh
57
+ # ship-safe pre-commit hook
58
+ # Scans staged files for leaked secrets before every commit.
59
+ # Remove this hook with: npx ship-safe guard remove
60
+
61
+ echo ""
62
+ echo "🔍 ship-safe: Scanning for secrets before commit..."
63
+
64
+ npx --yes ship-safe scan . --json > /tmp/ship-safe-scan.json 2>/dev/null
65
+
66
+ if [ $? -ne 0 ]; then
67
+ echo ""
68
+ echo "❌ ship-safe: Secrets detected! Commit blocked."
69
+ echo ""
70
+ echo "Run 'npx ship-safe scan .' to see details."
71
+ echo "Fix the issues, then commit again."
72
+ echo ""
73
+ echo "To skip this check (not recommended):"
74
+ echo " git commit --no-verify"
75
+ echo ""
76
+ rm -f /tmp/ship-safe-scan.json
77
+ exit 1
78
+ fi
79
+
80
+ echo "✅ ship-safe: No secrets detected. Committing..."
81
+ rm -f /tmp/ship-safe-scan.json
82
+ exit 0
83
+ `;
84
+
85
+ const HUSKY_PRE_PUSH = `#!/usr/bin/env sh
86
+ . "$(dirname -- "$0")/_/husky.sh"
87
+
88
+ echo ""
89
+ echo "🔍 ship-safe: Scanning for secrets before push..."
90
+
91
+ npx ship-safe scan . --json > /tmp/ship-safe-scan.json 2>/dev/null
92
+
93
+ if [ $? -ne 0 ]; then
94
+ echo ""
95
+ echo "❌ ship-safe: Secrets detected! Push blocked."
96
+ echo "Run 'npx ship-safe scan .' to see details."
97
+ rm -f /tmp/ship-safe-scan.json
98
+ exit 1
99
+ fi
100
+
101
+ echo "✅ ship-safe: No secrets detected."
102
+ rm -f /tmp/ship-safe-scan.json
103
+ `;
104
+
105
+ const HUSKY_PRE_COMMIT = `#!/usr/bin/env sh
106
+ . "$(dirname -- "$0")/_/husky.sh"
107
+
108
+ echo ""
109
+ echo "🔍 ship-safe: Scanning for secrets before commit..."
110
+
111
+ npx ship-safe scan . --json > /tmp/ship-safe-scan.json 2>/dev/null
112
+
113
+ if [ $? -ne 0 ]; then
114
+ echo ""
115
+ echo "❌ ship-safe: Secrets detected! Commit blocked."
116
+ echo "Run 'npx ship-safe scan .' to see details."
117
+ rm -f /tmp/ship-safe-scan.json
118
+ exit 1
119
+ fi
120
+
121
+ echo "✅ ship-safe: No secrets detected."
122
+ rm -f /tmp/ship-safe-scan.json
123
+ `;
124
+
125
+ // =============================================================================
126
+ // MAIN COMMAND
127
+ // =============================================================================
128
+
129
+ export async function guardCommand(action, options = {}) {
130
+ const cwd = process.cwd();
131
+
132
+ // Verify this is a git repo
133
+ const gitDir = findGitDir(cwd);
134
+ if (!gitDir) {
135
+ output.error('Not a git repository. Run this from your project root.');
136
+ process.exit(1);
137
+ }
138
+
139
+ if (action === 'remove') {
140
+ return removeHooks(gitDir, cwd);
141
+ }
142
+
143
+ return installHook(gitDir, cwd, options);
144
+ }
145
+
146
+ // =============================================================================
147
+ // INSTALL
148
+ // =============================================================================
149
+
150
+ function installHook(gitDir, cwd, options) {
151
+ const hookType = options.preCommit ? 'pre-commit' : 'pre-push';
152
+ const hookScript = options.preCommit ? PRE_COMMIT_HOOK : PRE_PUSH_HOOK;
153
+ const huskyScript = options.preCommit ? HUSKY_PRE_COMMIT : HUSKY_PRE_PUSH;
154
+
155
+ output.header('Installing ship-safe Guard');
156
+
157
+ // Check for Husky
158
+ const huskyDir = path.join(cwd, '.husky');
159
+ const useHusky = fs.existsSync(huskyDir);
160
+
161
+ if (useHusky) {
162
+ installHuskyHook(huskyDir, hookType, huskyScript);
163
+ } else {
164
+ installGitHook(gitDir, hookType, hookScript);
165
+ }
166
+
167
+ console.log();
168
+ console.log(chalk.gray('What happens now:'));
169
+ console.log(chalk.gray(` Every git ${hookType === 'pre-push' ? 'push' : 'commit'} will run ship-safe scan`));
170
+ console.log(chalk.gray(' If secrets are found, the operation is blocked'));
171
+ console.log(chalk.gray(' Use --no-verify to skip (not recommended)'));
172
+ console.log();
173
+ console.log(chalk.gray('To remove: npx ship-safe guard remove'));
174
+ }
175
+
176
+ function installGitHook(gitDir, hookType, script) {
177
+ const hooksDir = path.join(gitDir, 'hooks');
178
+ const hookPath = path.join(hooksDir, hookType);
179
+
180
+ // Ensure hooks directory exists
181
+ if (!fs.existsSync(hooksDir)) {
182
+ fs.mkdirSync(hooksDir, { recursive: true });
183
+ }
184
+
185
+ // Check if hook already exists (not from ship-safe)
186
+ if (fs.existsSync(hookPath)) {
187
+ const existing = fs.readFileSync(hookPath, 'utf-8');
188
+ if (!existing.includes('ship-safe')) {
189
+ output.warning(`Existing ${hookType} hook found. Appending ship-safe check.`);
190
+ fs.appendFileSync(hookPath, '\n' + script);
191
+ output.success(`Appended to .git/hooks/${hookType}`);
192
+ return;
193
+ }
194
+ output.warning(`ship-safe guard already installed in .git/hooks/${hookType}`);
195
+ return;
196
+ }
197
+
198
+ fs.writeFileSync(hookPath, script);
199
+ // Make executable (chmod +x)
200
+ try {
201
+ fs.chmodSync(hookPath, '755');
202
+ } catch {
203
+ // Windows doesn't support chmod, but hooks still run via git
204
+ }
205
+
206
+ output.success(`Hook installed at .git/hooks/${hookType}`);
207
+ }
208
+
209
+ function installHuskyHook(huskyDir, hookType, script) {
210
+ const hookPath = path.join(huskyDir, hookType);
211
+
212
+ if (fs.existsSync(hookPath)) {
213
+ const existing = fs.readFileSync(hookPath, 'utf-8');
214
+ if (!existing.includes('ship-safe')) {
215
+ output.warning(`Existing Husky ${hookType} found. Appending ship-safe check.`);
216
+ fs.appendFileSync(hookPath, '\n# ship-safe\n' + script.split('\n').slice(3).join('\n'));
217
+ output.success(`Appended to .husky/${hookType}`);
218
+ return;
219
+ }
220
+ output.warning(`ship-safe guard already installed in .husky/${hookType}`);
221
+ return;
222
+ }
223
+
224
+ fs.writeFileSync(hookPath, script);
225
+ try {
226
+ fs.chmodSync(hookPath, '755');
227
+ } catch {}
228
+
229
+ output.success(`Hook installed at .husky/${hookType} (Husky detected)`);
230
+ }
231
+
232
+ // =============================================================================
233
+ // REMOVE
234
+ // =============================================================================
235
+
236
+ function removeHooks(gitDir, cwd) {
237
+ output.header('Removing ship-safe Guard');
238
+
239
+ let removed = 0;
240
+
241
+ // Check .git/hooks
242
+ const hookTypes = ['pre-push', 'pre-commit'];
243
+ for (const hookType of hookTypes) {
244
+ const hookPath = path.join(gitDir, 'hooks', hookType);
245
+ if (fs.existsSync(hookPath)) {
246
+ const content = fs.readFileSync(hookPath, 'utf-8');
247
+ if (content.includes('ship-safe')) {
248
+ if (content.trim() === PRE_PUSH_HOOK.trim() || content.trim() === PRE_COMMIT_HOOK.trim()) {
249
+ // Ship-safe is the only hook — delete the file
250
+ fs.unlinkSync(hookPath);
251
+ output.success(`Removed .git/hooks/${hookType}`);
252
+ } else {
253
+ // Other hooks exist — only remove ship-safe lines
254
+ const cleaned = content
255
+ .replace(/# ship-safe[\s\S]*?exit 0\n/g, '')
256
+ .trimEnd() + '\n';
257
+ fs.writeFileSync(hookPath, cleaned);
258
+ output.success(`Removed ship-safe from .git/hooks/${hookType}`);
259
+ }
260
+ removed++;
261
+ }
262
+ }
263
+
264
+ // Check .husky
265
+ const huskyHookPath = path.join(cwd, '.husky', hookType);
266
+ if (fs.existsSync(huskyHookPath)) {
267
+ const content = fs.readFileSync(huskyHookPath, 'utf-8');
268
+ if (content.includes('ship-safe')) {
269
+ fs.unlinkSync(huskyHookPath);
270
+ output.success(`Removed .husky/${hookType}`);
271
+ removed++;
272
+ }
273
+ }
274
+ }
275
+
276
+ if (removed === 0) {
277
+ output.warning('No ship-safe hooks found.');
278
+ }
279
+ }
280
+
281
+ // =============================================================================
282
+ // UTILITIES
283
+ // =============================================================================
284
+
285
+ function findGitDir(startPath) {
286
+ let current = startPath;
287
+
288
+ while (true) {
289
+ const gitPath = path.join(current, '.git');
290
+ if (fs.existsSync(gitPath)) {
291
+ return gitPath;
292
+ }
293
+ const parent = path.dirname(current);
294
+ if (parent === current) return null; // Reached filesystem root
295
+ current = parent;
296
+ }
297
+ }