ship-safe 2.0.0 → 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.
- package/README.md +109 -7
- package/cli/bin/ship-safe.js +36 -1
- package/cli/commands/fix.js +216 -0
- package/cli/commands/guard.js +297 -0
- package/cli/commands/mcp.js +303 -0
- package/cli/commands/scan.js +231 -39
- package/cli/utils/entropy.js +126 -0
- package/cli/utils/output.js +10 -1
- package/cli/utils/patterns.js +32 -1
- package/configs/ship-safeignore-template +50 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server
|
|
3
|
+
* ==========
|
|
4
|
+
*
|
|
5
|
+
* Exposes ship-safe as a Model Context Protocol (MCP) server.
|
|
6
|
+
* Allows AI editors (Claude Desktop, Cursor, Windsurf, Zed) to call
|
|
7
|
+
* ship-safe's security tools directly during conversations.
|
|
8
|
+
*
|
|
9
|
+
* USAGE:
|
|
10
|
+
* npx ship-safe mcp Start the MCP server (stdio transport)
|
|
11
|
+
*
|
|
12
|
+
* SETUP (Claude Desktop):
|
|
13
|
+
* Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
|
|
14
|
+
* {
|
|
15
|
+
* "mcpServers": {
|
|
16
|
+
* "ship-safe": {
|
|
17
|
+
* "command": "npx",
|
|
18
|
+
* "args": ["ship-safe", "mcp"]
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* AVAILABLE TOOLS:
|
|
24
|
+
* scan_secrets - Scan a directory for leaked secrets
|
|
25
|
+
* get_checklist - Return the launch-day security checklist
|
|
26
|
+
* analyze_file - Analyze a single file for security issues
|
|
27
|
+
*
|
|
28
|
+
* PROTOCOL:
|
|
29
|
+
* JSON-RPC 2.0 over stdio (MCP spec: https://modelcontextprotocol.io)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from 'fs';
|
|
33
|
+
import path from 'path';
|
|
34
|
+
import { glob } from 'glob';
|
|
35
|
+
import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, TEST_FILE_PATTERNS, MAX_FILE_SIZE } from '../utils/patterns.js';
|
|
36
|
+
import { isHighEntropyMatch } from '../utils/entropy.js';
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// MCP TOOL DEFINITIONS
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
const TOOLS = [
|
|
43
|
+
{
|
|
44
|
+
name: 'scan_secrets',
|
|
45
|
+
description: 'Scan a directory or file for leaked secrets, API keys, and credentials. Returns structured findings with severity, file location, and remediation advice.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
path: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'The directory or file path to scan. Use "." for the current directory.',
|
|
52
|
+
},
|
|
53
|
+
includeTests: {
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
description: 'Whether to include test files in the scan (default: false)',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
required: ['path'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'get_checklist',
|
|
63
|
+
description: 'Return the ship-safe launch-day security checklist as structured data. Use this to guide users through pre-launch security checks.',
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'analyze_file',
|
|
71
|
+
description: 'Analyze a single file for security issues including secrets, hardcoded credentials, and dangerous patterns.',
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
path: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
description: 'The absolute or relative path to the file to analyze.',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
required: ['path'],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// TOOL IMPLEMENTATIONS
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
async function scanSecrets({ path: targetPath, includeTests = false }) {
|
|
90
|
+
const absolutePath = path.resolve(targetPath);
|
|
91
|
+
|
|
92
|
+
if (!fs.existsSync(absolutePath)) {
|
|
93
|
+
return { error: `Path does not exist: ${absolutePath}` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stat = fs.statSync(absolutePath);
|
|
97
|
+
const files = stat.isFile()
|
|
98
|
+
? [absolutePath]
|
|
99
|
+
: await findFiles(absolutePath, includeTests);
|
|
100
|
+
|
|
101
|
+
const results = [];
|
|
102
|
+
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const findings = scanFile(file);
|
|
105
|
+
if (findings.length > 0) {
|
|
106
|
+
results.push({ file: path.relative(process.cwd(), file), findings });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
filesScanned: files.length,
|
|
112
|
+
totalFindings: results.reduce((sum, r) => sum + r.findings.length, 0),
|
|
113
|
+
clean: results.length === 0,
|
|
114
|
+
findings: results,
|
|
115
|
+
summary: results.length === 0
|
|
116
|
+
? 'No secrets detected.'
|
|
117
|
+
: `Found ${results.reduce((s, r) => s + r.findings.length, 0)} secret(s) across ${results.length} file(s).`,
|
|
118
|
+
remediation: results.length > 0
|
|
119
|
+
? 'Move secrets to environment variables. Add .env to .gitignore. Rotate any already-committed credentials.'
|
|
120
|
+
: null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getChecklist() {
|
|
125
|
+
return {
|
|
126
|
+
title: 'Ship Safe Launch-Day Security Checklist',
|
|
127
|
+
items: [
|
|
128
|
+
{ id: 1, category: 'Secrets', check: 'No API keys hardcoded in source code', command: 'npx ship-safe scan .' },
|
|
129
|
+
{ id: 2, category: 'Secrets', check: '.env file is in .gitignore', command: null },
|
|
130
|
+
{ id: 3, category: 'Secrets', check: '.env.example exists with placeholder values', command: 'npx ship-safe fix' },
|
|
131
|
+
{ id: 4, category: 'Database', check: 'Row Level Security (RLS) enabled on all Supabase tables', command: null },
|
|
132
|
+
{ id: 5, category: 'Database', check: 'Service role key is server-side only (never in frontend)', command: null },
|
|
133
|
+
{ id: 6, category: 'Auth', check: 'Authentication required on all sensitive API routes', command: null },
|
|
134
|
+
{ id: 7, category: 'Auth', check: 'JWT tokens expire within 24 hours', command: null },
|
|
135
|
+
{ id: 8, category: 'Headers', check: 'Security headers configured (CSP, X-Frame-Options, HSTS)', command: 'npx ship-safe init --headers' },
|
|
136
|
+
{ id: 9, category: 'API', check: 'Rate limiting implemented on auth and AI endpoints', command: null },
|
|
137
|
+
{ id: 10, category: 'API', check: 'Input validation on all API endpoints', command: null },
|
|
138
|
+
{ id: 11, category: 'AI', check: 'Token limits set on all LLM API calls', command: null },
|
|
139
|
+
{ id: 12, category: 'AI', check: 'Budget caps configured in AI provider dashboard', command: null },
|
|
140
|
+
{ id: 13, category: 'CI/CD', check: 'ship-safe scan runs in CI pipeline', command: null },
|
|
141
|
+
{ id: 14, category: 'CI/CD', check: 'Pre-push hook installed', command: 'npx ship-safe guard' },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function analyzeFile({ path: filePath }) {
|
|
147
|
+
const absolutePath = path.resolve(filePath);
|
|
148
|
+
|
|
149
|
+
if (!fs.existsSync(absolutePath)) {
|
|
150
|
+
return { error: `File does not exist: ${absolutePath}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const findings = scanFile(absolutePath);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
file: filePath,
|
|
157
|
+
totalFindings: findings.length,
|
|
158
|
+
clean: findings.length === 0,
|
|
159
|
+
findings,
|
|
160
|
+
summary: findings.length === 0
|
|
161
|
+
? `No secrets detected in ${path.basename(filePath)}.`
|
|
162
|
+
: `Found ${findings.length} potential secret(s) in ${path.basename(filePath)}.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// =============================================================================
|
|
167
|
+
// SCAN UTILITIES (shared with scan command)
|
|
168
|
+
// =============================================================================
|
|
169
|
+
|
|
170
|
+
async function findFiles(rootPath, includeTests) {
|
|
171
|
+
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
172
|
+
const files = await glob('**/*', { cwd: rootPath, absolute: true, nodir: true, ignore: globIgnore, dot: true });
|
|
173
|
+
|
|
174
|
+
return files.filter(file => {
|
|
175
|
+
const ext = path.extname(file).toLowerCase();
|
|
176
|
+
if (SKIP_EXTENSIONS.has(ext)) return false;
|
|
177
|
+
const basename = path.basename(file);
|
|
178
|
+
if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) return false;
|
|
179
|
+
if (!includeTests && TEST_FILE_PATTERNS.some(p => p.test(file))) return false;
|
|
180
|
+
try {
|
|
181
|
+
return fs.statSync(file).size <= MAX_FILE_SIZE;
|
|
182
|
+
} catch { return false; }
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function scanFile(filePath) {
|
|
187
|
+
const findings = [];
|
|
188
|
+
try {
|
|
189
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
190
|
+
const lines = content.split('\n');
|
|
191
|
+
|
|
192
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
193
|
+
const line = lines[lineNum];
|
|
194
|
+
if (/ship-safe-ignore/i.test(line)) continue;
|
|
195
|
+
|
|
196
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
197
|
+
pattern.pattern.lastIndex = 0;
|
|
198
|
+
let match;
|
|
199
|
+
while ((match = pattern.pattern.exec(line)) !== null) {
|
|
200
|
+
if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
|
|
201
|
+
findings.push({
|
|
202
|
+
line: lineNum + 1,
|
|
203
|
+
type: pattern.name,
|
|
204
|
+
severity: pattern.severity,
|
|
205
|
+
description: pattern.description,
|
|
206
|
+
fix: 'Move to environment variable. Never commit to source control.',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch {}
|
|
212
|
+
return findings;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// =============================================================================
|
|
216
|
+
// MCP STDIO SERVER
|
|
217
|
+
// =============================================================================
|
|
218
|
+
|
|
219
|
+
export async function mcpCommand() {
|
|
220
|
+
// MCP uses JSON-RPC 2.0 over stdio
|
|
221
|
+
process.stdin.setEncoding('utf-8');
|
|
222
|
+
|
|
223
|
+
let buffer = '';
|
|
224
|
+
|
|
225
|
+
process.stdin.on('data', async (chunk) => {
|
|
226
|
+
buffer += chunk;
|
|
227
|
+
|
|
228
|
+
// MCP messages are newline-delimited JSON
|
|
229
|
+
const lines = buffer.split('\n');
|
|
230
|
+
buffer = lines.pop(); // Keep incomplete line in buffer
|
|
231
|
+
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
if (!line.trim()) continue;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const request = JSON.parse(line);
|
|
237
|
+
const response = await handleRequest(request);
|
|
238
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
239
|
+
} catch (err) {
|
|
240
|
+
const errorResponse = {
|
|
241
|
+
jsonrpc: '2.0',
|
|
242
|
+
id: null,
|
|
243
|
+
error: { code: -32700, message: 'Parse error', data: err.message },
|
|
244
|
+
};
|
|
245
|
+
process.stdout.write(JSON.stringify(errorResponse) + '\n');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
process.stdin.on('end', () => process.exit(0));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function handleRequest(request) {
|
|
254
|
+
const { jsonrpc, id, method, params } = request;
|
|
255
|
+
|
|
256
|
+
const respond = (result) => ({ jsonrpc: '2.0', id, result });
|
|
257
|
+
const respondError = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
258
|
+
|
|
259
|
+
switch (method) {
|
|
260
|
+
case 'initialize':
|
|
261
|
+
return respond({
|
|
262
|
+
protocolVersion: '2024-11-05',
|
|
263
|
+
capabilities: { tools: {} },
|
|
264
|
+
serverInfo: { name: 'ship-safe', version: '3.0.0' },
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
case 'tools/list':
|
|
268
|
+
return respond({ tools: TOOLS });
|
|
269
|
+
|
|
270
|
+
case 'tools/call': {
|
|
271
|
+
const { name, arguments: args } = params;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
let result;
|
|
275
|
+
switch (name) {
|
|
276
|
+
case 'scan_secrets':
|
|
277
|
+
result = await scanSecrets(args);
|
|
278
|
+
break;
|
|
279
|
+
case 'get_checklist':
|
|
280
|
+
result = getChecklist();
|
|
281
|
+
break;
|
|
282
|
+
case 'analyze_file':
|
|
283
|
+
result = await analyzeFile(args);
|
|
284
|
+
break;
|
|
285
|
+
default:
|
|
286
|
+
return respondError(-32601, `Unknown tool: ${name}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return respond({
|
|
290
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
291
|
+
});
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return respondError(-32603, err.message);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
case 'notifications/initialized':
|
|
298
|
+
return null; // No response needed for notifications
|
|
299
|
+
|
|
300
|
+
default:
|
|
301
|
+
return respondError(-32601, `Method not found: ${method}`);
|
|
302
|
+
}
|
|
303
|
+
}
|