ship-safe 1.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 +201 -0
- package/ai-defense/system-prompt-armor.md +327 -0
- package/checklists/launch-day.md +168 -0
- package/cli/bin/ship-safe.js +97 -0
- package/cli/commands/checklist.js +223 -0
- package/cli/commands/init.js +265 -0
- package/cli/commands/scan.js +261 -0
- package/cli/index.js +12 -0
- package/cli/utils/output.js +177 -0
- package/cli/utils/patterns.js +265 -0
- package/configs/nextjs-security-headers.js +220 -0
- package/package.json +54 -0
- package/snippets/README.md +58 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan Command
|
|
3
|
+
* ============
|
|
4
|
+
*
|
|
5
|
+
* Scans a directory for leaked secrets using pattern matching.
|
|
6
|
+
*
|
|
7
|
+
* USAGE:
|
|
8
|
+
* ship-safe scan [path] Scan specified path (default: current directory)
|
|
9
|
+
* ship-safe scan . -v Verbose mode (show files being scanned)
|
|
10
|
+
* ship-safe scan . --json Output as JSON (for CI integration)
|
|
11
|
+
*
|
|
12
|
+
* EXIT CODES:
|
|
13
|
+
* 0 - No secrets found
|
|
14
|
+
* 1 - Secrets found (or error)
|
|
15
|
+
*
|
|
16
|
+
* This allows CI pipelines to fail builds when secrets are detected.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { glob } from 'glob';
|
|
22
|
+
import ora from 'ora';
|
|
23
|
+
import chalk from 'chalk';
|
|
24
|
+
import {
|
|
25
|
+
SECRET_PATTERNS,
|
|
26
|
+
SKIP_DIRS,
|
|
27
|
+
SKIP_EXTENSIONS,
|
|
28
|
+
MAX_FILE_SIZE
|
|
29
|
+
} from '../utils/patterns.js';
|
|
30
|
+
import * as output from '../utils/output.js';
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// MAIN SCAN FUNCTION
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
export async function scanCommand(targetPath = '.', options = {}) {
|
|
37
|
+
const absolutePath = path.resolve(targetPath);
|
|
38
|
+
|
|
39
|
+
// Validate path exists
|
|
40
|
+
if (!fs.existsSync(absolutePath)) {
|
|
41
|
+
output.error(`Path does not exist: ${absolutePath}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Start spinner
|
|
46
|
+
const spinner = ora({
|
|
47
|
+
text: 'Scanning for secrets...',
|
|
48
|
+
color: 'cyan'
|
|
49
|
+
}).start();
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Find all files
|
|
53
|
+
const files = await findFiles(absolutePath, options.verbose);
|
|
54
|
+
spinner.text = `Scanning ${files.length} files...`;
|
|
55
|
+
|
|
56
|
+
// Scan each file
|
|
57
|
+
const results = [];
|
|
58
|
+
let scannedCount = 0;
|
|
59
|
+
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
const findings = await scanFile(file);
|
|
62
|
+
if (findings.length > 0) {
|
|
63
|
+
results.push({ file, findings });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
scannedCount++;
|
|
67
|
+
if (options.verbose) {
|
|
68
|
+
spinner.text = `Scanned ${scannedCount}/${files.length}: ${path.relative(absolutePath, file)}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
spinner.stop();
|
|
73
|
+
|
|
74
|
+
// Output results
|
|
75
|
+
if (options.json) {
|
|
76
|
+
outputJSON(results, files.length);
|
|
77
|
+
} else {
|
|
78
|
+
outputPretty(results, files.length, absolutePath);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Exit with appropriate code
|
|
82
|
+
const hasFindings = results.length > 0;
|
|
83
|
+
process.exit(hasFindings ? 1 : 0);
|
|
84
|
+
|
|
85
|
+
} catch (err) {
|
|
86
|
+
spinner.fail('Scan failed');
|
|
87
|
+
output.error(err.message);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// =============================================================================
|
|
93
|
+
// FILE DISCOVERY
|
|
94
|
+
// =============================================================================
|
|
95
|
+
|
|
96
|
+
async function findFiles(rootPath, verbose = false) {
|
|
97
|
+
// Build ignore patterns from SKIP_DIRS
|
|
98
|
+
const ignorePatterns = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
99
|
+
|
|
100
|
+
// Find all files
|
|
101
|
+
const files = await glob('**/*', {
|
|
102
|
+
cwd: rootPath,
|
|
103
|
+
absolute: true,
|
|
104
|
+
nodir: true,
|
|
105
|
+
ignore: ignorePatterns,
|
|
106
|
+
dot: true // Include dotfiles (but not .git which is ignored)
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Filter by extension and size
|
|
110
|
+
const filtered = [];
|
|
111
|
+
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
// Skip by extension
|
|
114
|
+
const ext = path.extname(file).toLowerCase();
|
|
115
|
+
if (SKIP_EXTENSIONS.has(ext)) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Handle compound extensions like .min.js
|
|
120
|
+
const basename = path.basename(file);
|
|
121
|
+
if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Skip by size
|
|
126
|
+
try {
|
|
127
|
+
const stats = fs.statSync(file);
|
|
128
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
129
|
+
if (verbose) {
|
|
130
|
+
console.log(chalk.gray(` Skipping (too large): ${file}`));
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
filtered.push(file);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return filtered;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// =============================================================================
|
|
145
|
+
// FILE SCANNING
|
|
146
|
+
// =============================================================================
|
|
147
|
+
|
|
148
|
+
async function scanFile(filePath) {
|
|
149
|
+
const findings = [];
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
153
|
+
const lines = content.split('\n');
|
|
154
|
+
|
|
155
|
+
// Check each pattern against each line
|
|
156
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
157
|
+
const line = lines[lineNum];
|
|
158
|
+
|
|
159
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
160
|
+
// Reset regex state (important for global regexes)
|
|
161
|
+
pattern.pattern.lastIndex = 0;
|
|
162
|
+
|
|
163
|
+
let match;
|
|
164
|
+
while ((match = pattern.pattern.exec(line)) !== null) {
|
|
165
|
+
findings.push({
|
|
166
|
+
line: lineNum + 1,
|
|
167
|
+
column: match.index + 1,
|
|
168
|
+
matched: match[0],
|
|
169
|
+
patternName: pattern.name,
|
|
170
|
+
severity: pattern.severity,
|
|
171
|
+
description: pattern.description
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// Skip files that can't be read (binary, permissions, etc.)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return findings;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// =============================================================================
|
|
184
|
+
// OUTPUT FORMATTING
|
|
185
|
+
// =============================================================================
|
|
186
|
+
|
|
187
|
+
function outputPretty(results, filesScanned, rootPath) {
|
|
188
|
+
// Calculate stats
|
|
189
|
+
const stats = {
|
|
190
|
+
total: 0,
|
|
191
|
+
critical: 0,
|
|
192
|
+
high: 0,
|
|
193
|
+
medium: 0,
|
|
194
|
+
filesScanned
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
for (const { findings } of results) {
|
|
198
|
+
for (const f of findings) {
|
|
199
|
+
stats.total++;
|
|
200
|
+
stats[f.severity]++;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Print header
|
|
205
|
+
output.header('Scan Results');
|
|
206
|
+
|
|
207
|
+
if (results.length === 0) {
|
|
208
|
+
output.success('No secrets detected in your codebase!');
|
|
209
|
+
console.log();
|
|
210
|
+
console.log(chalk.gray('Note: This scanner uses pattern matching and may miss some secrets.'));
|
|
211
|
+
console.log(chalk.gray('Consider also using: gitleaks, trufflehog, or detect-secrets'));
|
|
212
|
+
} else {
|
|
213
|
+
// Print findings grouped by file
|
|
214
|
+
for (const { file, findings } of results) {
|
|
215
|
+
const relPath = path.relative(rootPath, file);
|
|
216
|
+
|
|
217
|
+
for (const f of findings) {
|
|
218
|
+
output.finding(
|
|
219
|
+
relPath,
|
|
220
|
+
f.line,
|
|
221
|
+
f.patternName,
|
|
222
|
+
f.severity,
|
|
223
|
+
f.matched,
|
|
224
|
+
f.description
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Print recommendations
|
|
230
|
+
output.recommendations();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Print summary
|
|
234
|
+
output.summary(stats);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function outputJSON(results, filesScanned) {
|
|
238
|
+
const jsonOutput = {
|
|
239
|
+
success: results.length === 0,
|
|
240
|
+
filesScanned,
|
|
241
|
+
totalFindings: 0,
|
|
242
|
+
findings: []
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
for (const { file, findings } of results) {
|
|
246
|
+
for (const f of findings) {
|
|
247
|
+
jsonOutput.totalFindings++;
|
|
248
|
+
jsonOutput.findings.push({
|
|
249
|
+
file,
|
|
250
|
+
line: f.line,
|
|
251
|
+
column: f.column,
|
|
252
|
+
severity: f.severity,
|
|
253
|
+
type: f.patternName,
|
|
254
|
+
matched: output.maskSecret(f.matched),
|
|
255
|
+
description: f.description
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
261
|
+
}
|
package/cli/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ship Safe CLI - Module Entry Point
|
|
3
|
+
* ===================================
|
|
4
|
+
*
|
|
5
|
+
* This file exports the CLI commands for programmatic use.
|
|
6
|
+
* For normal CLI usage, run: npx ship-safe
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { scanCommand } from './commands/scan.js';
|
|
10
|
+
export { checklistCommand } from './commands/checklist.js';
|
|
11
|
+
export { initCommand } from './commands/init.js';
|
|
12
|
+
export { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS } from './utils/patterns.js';
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output Utilities
|
|
3
|
+
* ================
|
|
4
|
+
*
|
|
5
|
+
* Consistent, pretty terminal output for the CLI.
|
|
6
|
+
* Uses chalk for colors and provides helper functions
|
|
7
|
+
* for common output patterns.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// SEVERITY COLORS
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
export const severityColors = {
|
|
17
|
+
critical: chalk.bgRed.white.bold,
|
|
18
|
+
high: chalk.red.bold,
|
|
19
|
+
medium: chalk.yellow,
|
|
20
|
+
low: chalk.blue
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const severityIcons = {
|
|
24
|
+
critical: '\u2620\ufe0f ', // skull
|
|
25
|
+
high: '\u26a0\ufe0f ', // warning
|
|
26
|
+
medium: '\u26a1', // lightning
|
|
27
|
+
low: '\u2139\ufe0f ' // info
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// OUTPUT HELPERS
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Print a section header
|
|
36
|
+
*/
|
|
37
|
+
export function header(text) {
|
|
38
|
+
console.log();
|
|
39
|
+
console.log(chalk.cyan.bold('='.repeat(60)));
|
|
40
|
+
console.log(chalk.cyan.bold(` ${text}`));
|
|
41
|
+
console.log(chalk.cyan.bold('='.repeat(60)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Print a subheader
|
|
46
|
+
*/
|
|
47
|
+
export function subheader(text) {
|
|
48
|
+
console.log();
|
|
49
|
+
console.log(chalk.white.bold(text));
|
|
50
|
+
console.log(chalk.gray('-'.repeat(text.length)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Print a success message
|
|
55
|
+
*/
|
|
56
|
+
export function success(text) {
|
|
57
|
+
console.log(chalk.green('\u2714 ') + text);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Print a warning message
|
|
62
|
+
*/
|
|
63
|
+
export function warning(text) {
|
|
64
|
+
console.log(chalk.yellow('\u26a0 ') + text);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Print an error message
|
|
69
|
+
*/
|
|
70
|
+
export function error(text) {
|
|
71
|
+
console.log(chalk.red('\u2718 ') + text);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Print an info message
|
|
76
|
+
*/
|
|
77
|
+
export function info(text) {
|
|
78
|
+
console.log(chalk.blue('\u2139 ') + text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Print a finding (secret detected)
|
|
83
|
+
*/
|
|
84
|
+
export function finding(file, line, patternName, severity, matched, description) {
|
|
85
|
+
const color = severityColors[severity] || chalk.white;
|
|
86
|
+
const icon = severityIcons[severity] || '';
|
|
87
|
+
|
|
88
|
+
console.log();
|
|
89
|
+
console.log(chalk.white.bold(`${file}:${line}`));
|
|
90
|
+
console.log(` ${icon}${color(`[${severity.toUpperCase()}]`)} ${chalk.white(patternName)}`);
|
|
91
|
+
console.log(` ${chalk.gray('Found:')} ${chalk.yellow(maskSecret(matched))}`);
|
|
92
|
+
console.log(` ${chalk.gray('Why:')} ${description}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Mask the middle of a secret for safe display
|
|
97
|
+
*/
|
|
98
|
+
export function maskSecret(secret) {
|
|
99
|
+
if (secret.length <= 10) {
|
|
100
|
+
return secret.substring(0, 3) + '***';
|
|
101
|
+
}
|
|
102
|
+
return secret.substring(0, 6) + '***' + secret.substring(secret.length - 4);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Print a summary box
|
|
107
|
+
*/
|
|
108
|
+
export function summary(stats) {
|
|
109
|
+
console.log();
|
|
110
|
+
console.log(chalk.cyan('='.repeat(60)));
|
|
111
|
+
|
|
112
|
+
if (stats.total === 0) {
|
|
113
|
+
console.log(chalk.green.bold(' \u2714 No secrets detected!'));
|
|
114
|
+
} else {
|
|
115
|
+
console.log(chalk.red.bold(` \u26a0 Found ${stats.total} potential secret(s)`));
|
|
116
|
+
|
|
117
|
+
if (stats.critical > 0) {
|
|
118
|
+
console.log(chalk.red(` \u2022 Critical: ${stats.critical}`));
|
|
119
|
+
}
|
|
120
|
+
if (stats.high > 0) {
|
|
121
|
+
console.log(chalk.red(` \u2022 High: ${stats.high}`));
|
|
122
|
+
}
|
|
123
|
+
if (stats.medium > 0) {
|
|
124
|
+
console.log(chalk.yellow(` \u2022 Medium: ${stats.medium}`));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(chalk.gray(` Files scanned: ${stats.filesScanned}`));
|
|
129
|
+
console.log(chalk.cyan('='.repeat(60)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Print recommended actions after finding secrets
|
|
134
|
+
*/
|
|
135
|
+
export function recommendations() {
|
|
136
|
+
console.log();
|
|
137
|
+
console.log(chalk.cyan.bold('Recommended Actions:'));
|
|
138
|
+
console.log();
|
|
139
|
+
console.log(chalk.white('1.') + ' Move secrets to environment variables (.env file)');
|
|
140
|
+
console.log(chalk.white('2.') + ' Add .env to your .gitignore');
|
|
141
|
+
console.log(chalk.white('3.') + chalk.yellow(' If already committed:'));
|
|
142
|
+
console.log(chalk.gray(' \u2022 Rotate the compromised credentials immediately'));
|
|
143
|
+
console.log(chalk.gray(' \u2022 Use git-filter-repo or BFG Repo-Cleaner to remove from history'));
|
|
144
|
+
console.log(chalk.gray(' \u2022 Remember: deleting doesn\'t remove git history!'));
|
|
145
|
+
console.log();
|
|
146
|
+
console.log(chalk.white('4.') + ' Set up pre-commit hooks to catch this automatically:');
|
|
147
|
+
console.log(chalk.gray(' npm install --save-dev husky'));
|
|
148
|
+
console.log(chalk.gray(' npx husky add .husky/pre-commit "npx ship-safe scan ."'));
|
|
149
|
+
console.log();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Print a checklist item
|
|
154
|
+
*/
|
|
155
|
+
export function checklistItem(number, title, checked = null) {
|
|
156
|
+
const checkbox = checked === null
|
|
157
|
+
? chalk.gray('[ ]')
|
|
158
|
+
: checked
|
|
159
|
+
? chalk.green('[\u2714]')
|
|
160
|
+
: chalk.red('[\u2718]');
|
|
161
|
+
|
|
162
|
+
console.log(`${checkbox} ${chalk.white.bold(`${number}.`)} ${title}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Print progress (for verbose mode)
|
|
167
|
+
*/
|
|
168
|
+
export function progress(text) {
|
|
169
|
+
process.stdout.write(chalk.gray(`\r${text}`));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Clear the current line
|
|
174
|
+
*/
|
|
175
|
+
export function clearLine() {
|
|
176
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
177
|
+
}
|