ship-safe 6.1.1 → 6.2.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.
Files changed (47) hide show
  1. package/README.md +735 -641
  2. package/cli/agents/api-fuzzer.js +345 -345
  3. package/cli/agents/auth-bypass-agent.js +348 -348
  4. package/cli/agents/base-agent.js +272 -272
  5. package/cli/agents/cicd-scanner.js +236 -201
  6. package/cli/agents/config-auditor.js +521 -521
  7. package/cli/agents/deep-analyzer.js +6 -2
  8. package/cli/agents/git-history-scanner.js +170 -170
  9. package/cli/agents/html-reporter.js +568 -568
  10. package/cli/agents/index.js +84 -84
  11. package/cli/agents/injection-tester.js +500 -500
  12. package/cli/agents/llm-redteam.js +251 -251
  13. package/cli/agents/mobile-scanner.js +231 -231
  14. package/cli/agents/orchestrator.js +322 -322
  15. package/cli/agents/pii-compliance-agent.js +301 -301
  16. package/cli/agents/scoring-engine.js +248 -248
  17. package/cli/agents/supabase-rls-agent.js +154 -154
  18. package/cli/agents/supply-chain-agent.js +650 -507
  19. package/cli/bin/ship-safe.js +452 -426
  20. package/cli/commands/agent.js +608 -608
  21. package/cli/commands/audit.js +986 -980
  22. package/cli/commands/baseline.js +193 -193
  23. package/cli/commands/ci.js +342 -342
  24. package/cli/commands/deps.js +516 -516
  25. package/cli/commands/doctor.js +159 -159
  26. package/cli/commands/fix.js +218 -218
  27. package/cli/commands/hooks.js +268 -0
  28. package/cli/commands/init.js +407 -407
  29. package/cli/commands/mcp.js +304 -304
  30. package/cli/commands/red-team.js +7 -1
  31. package/cli/commands/remediate.js +798 -798
  32. package/cli/commands/rotate.js +571 -571
  33. package/cli/commands/scan.js +569 -569
  34. package/cli/commands/score.js +449 -449
  35. package/cli/commands/watch.js +281 -281
  36. package/cli/hooks/patterns.js +313 -0
  37. package/cli/hooks/post-tool-use.js +140 -0
  38. package/cli/hooks/pre-tool-use.js +186 -0
  39. package/cli/index.js +73 -69
  40. package/cli/providers/llm-provider.js +397 -287
  41. package/cli/utils/autofix-rules.js +74 -74
  42. package/cli/utils/cache-manager.js +311 -311
  43. package/cli/utils/output.js +230 -230
  44. package/cli/utils/patterns.js +1121 -1121
  45. package/cli/utils/pdf-generator.js +94 -94
  46. package/package.json +69 -69
  47. package/configs/supabase/rls-templates.sql +0 -242
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Shared hook patterns
3
+ * ====================
4
+ *
5
+ * Single source of truth for all patterns used by pre-tool-use.js
6
+ * and post-tool-use.js. Keeps both hooks in sync automatically.
7
+ *
8
+ * Design rules:
9
+ * - CRITICAL_PATTERNS: block on pre-tool-use. Must have SPECIFIC PREFIXES
10
+ * to keep false-positive rate near zero. No generic patterns here.
11
+ * - HIGH_PATTERNS: advisory only (post-tool-use). Broader, needs entropy gate.
12
+ * - DANGEROUS_BASH_PATTERNS: block on Bash tool calls.
13
+ * - ENV_FILE_RE: recognise .env files that SHOULD contain secrets.
14
+ * - SKIP_PATHS: files where reporting is never useful.
15
+ */
16
+
17
+ import path from 'path';
18
+
19
+ // =============================================================================
20
+ // SHANNON ENTROPY — used to filter generic token false positives
21
+ // =============================================================================
22
+
23
+ export function shannonEntropy(str) {
24
+ if (!str || str.length === 0) return 0;
25
+ const freq = {};
26
+ for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
27
+ return Object.values(freq).reduce((sum, count) => {
28
+ const p = count / str.length;
29
+ return sum - p * Math.log2(p);
30
+ }, 0);
31
+ }
32
+
33
+ // =============================================================================
34
+ // CRITICAL PATTERNS — block on write (precision over recall)
35
+ //
36
+ // Each entry:
37
+ // name — human-readable label shown in block message
38
+ // re — regex (stateless; reset lastIndex between uses)
39
+ // envVar — suggested environment variable name for the fix message
40
+ //
41
+ // Removed / demoted compared to earlier version:
42
+ // Supabase JWT → was ANY HS256 JWT; now requires service_role in payload
43
+ // Twilio Auth Token (SK…) → no prefix, too many false positives; removed
44
+ // Twilio Account SID → tightened to hex-only [a-f0-9]
45
+ // Cloudflare API Token → broken lookahead, no reliable prefix; removed
46
+ // =============================================================================
47
+
48
+ export const CRITICAL_PATTERNS = [
49
+ {
50
+ name: 'AWS Access Key ID',
51
+ re: /AKIA[0-9A-Z]{16}/,
52
+ envVar: 'AWS_ACCESS_KEY_ID',
53
+ },
54
+ {
55
+ name: 'AWS Secret Access Key',
56
+ re: /(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[=:]\s*["']?([A-Za-z0-9/+=]{40})["']?/,
57
+ envVar: 'AWS_SECRET_ACCESS_KEY',
58
+ },
59
+ {
60
+ name: 'GitHub PAT (classic)',
61
+ re: /ghp_[a-zA-Z0-9]{36}/,
62
+ envVar: 'GITHUB_TOKEN',
63
+ },
64
+ {
65
+ name: 'GitHub OAuth Token',
66
+ re: /gho_[a-zA-Z0-9]{36}/,
67
+ envVar: 'GITHUB_TOKEN',
68
+ },
69
+ {
70
+ name: 'GitHub App Token',
71
+ re: /(?:ghu_|ghs_)[a-zA-Z0-9]{36}/,
72
+ envVar: 'GITHUB_TOKEN',
73
+ },
74
+ {
75
+ name: 'GitHub Fine-Grained PAT',
76
+ re: /github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}/,
77
+ envVar: 'GITHUB_TOKEN',
78
+ },
79
+ {
80
+ name: 'Anthropic API Key',
81
+ re: /sk-ant-api03-[a-zA-Z0-9\-_]{93}/,
82
+ envVar: 'ANTHROPIC_API_KEY',
83
+ },
84
+ {
85
+ name: 'OpenAI API Key',
86
+ re: /sk-(?:proj-|None-)?[a-zA-Z0-9]{48}/,
87
+ envVar: 'OPENAI_API_KEY',
88
+ },
89
+ {
90
+ name: 'Stripe Live Secret Key',
91
+ re: /sk_live_[0-9a-zA-Z]{24,}/,
92
+ envVar: 'STRIPE_SECRET_KEY',
93
+ },
94
+ {
95
+ name: 'Stripe Restricted Key',
96
+ re: /rk_live_[0-9a-zA-Z]{24,}/,
97
+ envVar: 'STRIPE_RESTRICTED_KEY',
98
+ },
99
+ {
100
+ name: 'Slack Bot Token',
101
+ re: /xoxb-[0-9]{11}-[0-9]{11}-[a-zA-Z0-9]{24}/,
102
+ envVar: 'SLACK_BOT_TOKEN',
103
+ },
104
+ {
105
+ name: 'Slack User Token',
106
+ re: /xoxp-[0-9]{11}-[0-9]{11}-[0-9]{12}-[a-zA-Z0-9]{32}/,
107
+ envVar: 'SLACK_USER_TOKEN',
108
+ },
109
+ {
110
+ name: 'Twilio Account SID',
111
+ // Tightened: must be lowercase hex, not any alphanumeric
112
+ re: /AC[a-f0-9]{32}/,
113
+ envVar: 'TWILIO_ACCOUNT_SID',
114
+ },
115
+ {
116
+ name: 'Google API Key',
117
+ re: /AIza[0-9A-Za-z\-_]{35}/,
118
+ envVar: 'GOOGLE_API_KEY',
119
+ },
120
+ {
121
+ name: 'npm Auth Token',
122
+ re: /npm_[A-Za-z0-9]{36}/,
123
+ envVar: 'NPM_TOKEN',
124
+ },
125
+ {
126
+ name: 'PyPI API Token',
127
+ re: /pypi-AgEIcHlwaS5vcmc[A-Za-z0-9\-_]{50,}/,
128
+ envVar: 'PYPI_API_TOKEN',
129
+ },
130
+ {
131
+ name: 'Supabase Service Role Key',
132
+ // Requires standard HS256 JWT header + base64("service_role") in payload
133
+ // Far more precise than matching any JWT.
134
+ re: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9+/_-]*c2VydmljZV9yb2xl/,
135
+ envVar: 'SUPABASE_SERVICE_ROLE_KEY',
136
+ },
137
+ {
138
+ name: 'Private Key (PEM)',
139
+ re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/,
140
+ envVar: 'PRIVATE_KEY_PATH',
141
+ },
142
+ ];
143
+
144
+ // =============================================================================
145
+ // HIGH PATTERNS — advisory post-write scan only
146
+ // Broader patterns; generic ones gated by entropy check.
147
+ // =============================================================================
148
+
149
+ export const HIGH_PATTERNS = [
150
+ {
151
+ name: 'Hardcoded password assignment',
152
+ severity: 'high',
153
+ re: /(?:password|passwd|pwd)\s*[:=]\s*["'][^"']{8,}["']/i,
154
+ checkEntropy: true, // run entropy on the captured value
155
+ },
156
+ {
157
+ name: 'Database URL with credentials',
158
+ severity: 'high',
159
+ re: /(?:postgres|mysql|mongodb|redis):\/\/[^:]+:[^@]{4,}@/,
160
+ checkEntropy: false,
161
+ },
162
+ {
163
+ name: 'Generic high-entropy secret assignment',
164
+ severity: 'high',
165
+ re: /(?:token|secret|api_key|apikey)\s*[:=]\s*["']([A-Za-z0-9+/=_\-]{32,})["']/i,
166
+ checkEntropy: true, // only report if entropy > threshold
167
+ },
168
+ ];
169
+
170
+ const ENTROPY_THRESHOLD = 3.5;
171
+
172
+ // =============================================================================
173
+ // DANGEROUS BASH PATTERNS — block on Bash tool calls
174
+ // =============================================================================
175
+
176
+ export const DANGEROUS_BASH_PATTERNS = [
177
+ {
178
+ name: 'Remote script execution (curl/wget piped to shell)',
179
+ re: /(?:curl|wget)\s+[^|]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh|dash|ksh)/,
180
+ reason: 'Executing remote scripts without verification is the #1 CI/CD supply chain attack vector (Trivy/CanisterWorm 2026). Download first, verify checksum, then execute.',
181
+ },
182
+ {
183
+ name: 'Remote script execution (PowerShell iex/Invoke-Expression)',
184
+ re: /(?:iex|Invoke-Expression)\s*\(?.*(?:Invoke-WebRequest|iwr|curl|wget)/i,
185
+ reason: 'PowerShell equivalent of curl|bash. Download the script first, inspect it, then execute.',
186
+ },
187
+ {
188
+ name: 'Recursive force delete of system paths',
189
+ re: /rm\s+(?:-[a-z]*f[a-z]*\s+|--force\s+)?(?:-[a-z]*r[a-z]*\s+|--recursive\s+)?\/(?:\s|$|(?!tmp|var\/tmp|home)[a-z])/,
190
+ reason: 'Destructive operation targeting system paths. Double-check the path before proceeding.',
191
+ },
192
+ {
193
+ name: 'Elevated npm install permissions',
194
+ re: /npm\s+(?:i|install)\s+[^\n]*--unsafe-perm/,
195
+ reason: '--unsafe-perm elevates install script privileges. Use sandboxed installs instead.',
196
+ },
197
+ {
198
+ name: 'Credential file read (potential exfiltration)',
199
+ re: /(?:cat|type|Get-Content)\s+[^\n]*(?:~\/\.(?:aws|ssh|npmrc|pypirc|netrc|gitconfig|gnupg)|\/etc\/(?:passwd|shadow))/,
200
+ reason: 'Reading sensitive credential files.',
201
+ },
202
+ {
203
+ name: 'Env-var exfiltration via network call',
204
+ re: /(?:curl|wget|Invoke-WebRequest)\s+[^\n]*\$(?:AWS_|GITHUB_TOKEN|NPM_TOKEN|ANTHROPIC_|OPENAI_|GROQ_|SECRET|PASSWORD|TOKEN)/,
205
+ reason: 'Sending an environment variable that likely contains credentials over the network.',
206
+ },
207
+ {
208
+ name: 'Secret committed in git message',
209
+ re: /git\s+commit\s+[^\n]*-m\s+["'][^\n]*(?:sk-|ghp_|npm_|AKIA|xoxb-|sk_live_)[^\n]*/,
210
+ reason: 'Possible secret hardcoded in a git commit message. Secrets in commit history are permanent.',
211
+ },
212
+ ];
213
+
214
+ // =============================================================================
215
+ // ENV FILE PATTERNS
216
+ // =============================================================================
217
+
218
+ /** Files that SHOULD contain secrets — write is allowed but gitignore is checked */
219
+ export const ENV_FILE_RE = /(?:^|[/\\])\.env(?:\.[a-zA-Z0-9]+)?$/;
220
+
221
+ /** Files that are purely documentation/examples — silently skip all checks */
222
+ export const ENV_EXAMPLE_RE = /(?:^|[/\\])\.env\.(?:example|sample|template|test)$/i;
223
+
224
+ // =============================================================================
225
+ // SKIP PATHS (post-tool-use advisory scan — never report on these)
226
+ // =============================================================================
227
+
228
+ export const SKIP_PATHS = [
229
+ /\.test\.[jt]sx?$/,
230
+ /\.spec\.[jt]sx?$/,
231
+ /__tests__[/\\]/,
232
+ /[/\\]tests?[/\\]/,
233
+ /[/\\]fixtures?[/\\]/,
234
+ /[/\\]mocks?[/\\]/,
235
+ ENV_EXAMPLE_RE,
236
+ /\.sample$/,
237
+ /CHANGELOG/i,
238
+ ];
239
+
240
+ // Note: .md files are NOT skipped — secrets in docs are real issues.
241
+
242
+ // =============================================================================
243
+ // SCAN HELPERS
244
+ // =============================================================================
245
+
246
+ /**
247
+ * Scan content for critical secrets.
248
+ * Returns array of { name, line, envVar } — line is 1-based.
249
+ */
250
+ export function scanCritical(content) {
251
+ const lines = content.split('\n');
252
+ const hits = [];
253
+ for (const { name, re, envVar } of CRITICAL_PATTERNS) {
254
+ for (let i = 0; i < lines.length; i++) {
255
+ // Reset regex state (stateless patterns, but be safe)
256
+ re.lastIndex = 0;
257
+ if (re.test(lines[i])) {
258
+ hits.push({ name, line: i + 1, envVar });
259
+ break; // one hit per pattern type is enough for the block message
260
+ }
261
+ }
262
+ }
263
+ return hits;
264
+ }
265
+
266
+ /**
267
+ * Scan content for high-severity issues (advisory).
268
+ * Applies entropy gate for patterns that request it.
269
+ */
270
+ export function scanHigh(content) {
271
+ const hits = [];
272
+ for (const { name, severity, re, checkEntropy } of HIGH_PATTERNS) {
273
+ re.lastIndex = 0;
274
+ const m = re.exec(content);
275
+ if (!m) continue;
276
+ if (checkEntropy) {
277
+ // Use captured group if present, otherwise the full match
278
+ const value = m[1] || m[0];
279
+ if (shannonEntropy(value) < ENTROPY_THRESHOLD) continue;
280
+ }
281
+ hits.push({ name, severity });
282
+ }
283
+ return hits;
284
+ }
285
+
286
+ /**
287
+ * Build a specific fix suggestion for a detected secret.
288
+ *
289
+ * @param {string} envVar — e.g. 'STRIPE_SECRET_KEY'
290
+ * @param {string} filePath — e.g. 'src/config.ts'
291
+ */
292
+ export function buildFixSuggestion(envVar, filePath) {
293
+ const ext = filePath ? path.extname(filePath).toLowerCase() : '';
294
+ if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) {
295
+ return `process.env.${envVar}`;
296
+ }
297
+ if (ext === '.py') {
298
+ return `os.environ.get('${envVar}')`;
299
+ }
300
+ if (['.rb'].includes(ext)) {
301
+ return `ENV['${envVar}']`;
302
+ }
303
+ if (['.go'].includes(ext)) {
304
+ return `os.Getenv("${envVar}")`;
305
+ }
306
+ if (['.java', '.kt'].includes(ext)) {
307
+ return `System.getenv("${envVar}")`;
308
+ }
309
+ if (['.cs'].includes(ext)) {
310
+ return `Environment.GetEnvironmentVariable("${envVar}")`;
311
+ }
312
+ return `$ENV:${envVar}`;
313
+ }
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ship-safe PostToolUse Hook
4
+ * ===========================
5
+ *
6
+ * Runs after Write / Edit / MultiEdit / NotebookEdit completes successfully.
7
+ * Scans the written content for secrets and security issues, then returns
8
+ * findings as a message that Claude Code injects back into the conversation.
9
+ *
10
+ * For Write and Edit, content is read from tool_input (no disk read needed).
11
+ * For MultiEdit and NotebookEdit, the file is read from disk after the write.
12
+ *
13
+ * PostToolUse NEVER blocks — exit 0 always.
14
+ * Empty stdout = silent (no findings or file skipped).
15
+ *
16
+ * Install via: npx ship-safe hooks install
17
+ */
18
+
19
+ import path from 'path';
20
+ import { existsSync, readFileSync } from 'fs';
21
+ import {
22
+ scanCritical,
23
+ scanHigh,
24
+ SKIP_PATHS,
25
+ ENV_FILE_RE,
26
+ ENV_EXAMPLE_RE,
27
+ } from './patterns.js';
28
+
29
+ // =============================================================================
30
+ // Main
31
+ // =============================================================================
32
+
33
+ async function main() {
34
+ let payload;
35
+ try {
36
+ const raw = await readStdin();
37
+ payload = JSON.parse(raw);
38
+ } catch {
39
+ process.exit(0);
40
+ }
41
+
42
+ const { tool_name, tool_input, tool_result_is_error } = payload;
43
+
44
+ if (tool_result_is_error) process.exit(0);
45
+ if (!['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(tool_name)) process.exit(0);
46
+
47
+ const filePath = tool_input && (tool_input.file_path || tool_input.notebook_path || tool_input.path);
48
+ if (!filePath) process.exit(0);
49
+
50
+ // Skip example/sample env files entirely
51
+ if (ENV_EXAMPLE_RE.test(filePath)) process.exit(0);
52
+
53
+ // Skip test fixtures, mocks, etc.
54
+ if (SKIP_PATHS.some(p => p.test(filePath))) process.exit(0);
55
+
56
+ // .env files: secrets are expected — no secret scan, but gitignore already
57
+ // warned in PreToolUse. Silent here.
58
+ if (ENV_FILE_RE.test(filePath)) process.exit(0);
59
+
60
+ // Get content to scan
61
+ const content = getContent(tool_name, tool_input, filePath);
62
+ if (!content) process.exit(0);
63
+
64
+ // Run scans
65
+ const critical = scanCritical(content);
66
+ const high = scanHigh(content);
67
+
68
+ if (critical.length === 0 && high.length === 0) process.exit(0);
69
+
70
+ // Format advisory message for Claude's context
71
+ const lines = [
72
+ `[ship-safe] Security findings in ${path.basename(filePath)}:`,
73
+ '',
74
+ ];
75
+
76
+ if (critical.length > 0) {
77
+ lines.push('CRITICAL — rotate these credentials immediately:');
78
+ for (const { name, line } of critical) {
79
+ lines.push(` • ${name}${line ? ` (line ${line})` : ''}`);
80
+ }
81
+ lines.push('');
82
+ }
83
+
84
+ if (high.length > 0) {
85
+ lines.push('HIGH — review these:');
86
+ for (const { name } of high) {
87
+ lines.push(` • ${name}`);
88
+ }
89
+ lines.push('');
90
+ }
91
+
92
+ lines.push('Run `npx ship-safe scan .` for full details and auto-fix options.');
93
+
94
+ process.stdout.write(lines.join('\n'));
95
+ process.exit(0);
96
+ }
97
+
98
+ // =============================================================================
99
+ // Helpers
100
+ // =============================================================================
101
+
102
+ /**
103
+ * Get the content to scan.
104
+ * Prefer tool_input (avoids a disk read) for Write and Edit.
105
+ * Fall back to disk read for MultiEdit and NotebookEdit.
106
+ */
107
+ function getContent(toolName, input, filePath) {
108
+ if (toolName === 'Write' && input?.content) {
109
+ return input.content;
110
+ }
111
+ if (toolName === 'Edit' && input?.new_string) {
112
+ // For Edit, scan the full file so we catch pre-existing issues too
113
+ return readFromDisk(filePath);
114
+ }
115
+ // MultiEdit and NotebookEdit — read the final state from disk
116
+ return readFromDisk(filePath);
117
+ }
118
+
119
+ function readFromDisk(filePath) {
120
+ try {
121
+ if (!existsSync(filePath)) return null;
122
+ return readFileSync(filePath, 'utf8');
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ function readStdin() {
129
+ return new Promise((resolve, reject) => {
130
+ if (process.stdin.isTTY) return resolve('');
131
+ const chunks = [];
132
+ process.stdin.setEncoding('utf8');
133
+ process.stdin.on('data', chunk => chunks.push(chunk));
134
+ process.stdin.on('end', () => resolve(chunks.join('')));
135
+ process.stdin.on('error', reject);
136
+ setTimeout(() => resolve(''), 3000);
137
+ });
138
+ }
139
+
140
+ main().catch(() => process.exit(0));
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ship-safe PreToolUse Hook
4
+ * ==========================
5
+ *
6
+ * Runs before every Claude Code tool call. Blocks:
7
+ * - Write / Edit / MultiEdit / NotebookEdit: content containing critical secrets
8
+ * (unless the target is a .env file — secrets belong there)
9
+ * - Bash: known-dangerous command patterns
10
+ *
11
+ * Protocol (claw-code / Claude Code hooks spec):
12
+ * - Input: JSON payload on stdin
13
+ * - Exit 0: allow the tool to run (stdout = optional advisory message)
14
+ * - Exit 2: BLOCK the tool (stdout = reason shown to Claude and user)
15
+ * - Exit 1: warn but allow (stdout = warning message)
16
+ *
17
+ * Install via: npx ship-safe hooks install
18
+ */
19
+
20
+ import path from 'path';
21
+ import fs from 'fs';
22
+ import {
23
+ scanCritical,
24
+ buildFixSuggestion,
25
+ DANGEROUS_BASH_PATTERNS,
26
+ ENV_FILE_RE,
27
+ ENV_EXAMPLE_RE,
28
+ } from './patterns.js';
29
+
30
+ // =============================================================================
31
+ // Main
32
+ // =============================================================================
33
+
34
+ async function main() {
35
+ let payload;
36
+ try {
37
+ const raw = await readStdin();
38
+ payload = JSON.parse(raw);
39
+ } catch {
40
+ process.exit(0); // can't parse → allow
41
+ }
42
+
43
+ const { tool_name, tool_input } = payload;
44
+
45
+ // ── File write hooks (Write / Edit / MultiEdit / NotebookEdit) ───────────
46
+ if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(tool_name)) {
47
+ const filePath = tool_input && (tool_input.file_path || tool_input.notebook_path || tool_input.path);
48
+
49
+ // .env.example / .env.sample — purely documentation, skip all checks
50
+ if (filePath && ENV_EXAMPLE_RE.test(filePath)) {
51
+ process.exit(0);
52
+ }
53
+
54
+ // .env / .env.local / .env.production — secrets SHOULD be here
55
+ // Allow write, but warn if .gitignore doesn't cover the file
56
+ if (filePath && ENV_FILE_RE.test(filePath)) {
57
+ const warning = checkEnvGitignore(filePath);
58
+ if (warning) {
59
+ process.stdout.write(warning);
60
+ process.exit(1); // warn but allow
61
+ }
62
+ process.exit(0);
63
+ }
64
+
65
+ const content = extractContent(tool_name, tool_input);
66
+ if (content) {
67
+ const hits = scanCritical(content);
68
+ if (hits.length > 0) {
69
+ process.stdout.write(buildBlockMessage(hits, filePath));
70
+ process.exit(2);
71
+ }
72
+ }
73
+ }
74
+
75
+ // ── Bash hooks ─────────────────────────────────────────────────────────────
76
+ if (tool_name === 'Bash') {
77
+ const command = tool_input?.command ? String(tool_input.command) : '';
78
+ if (command) {
79
+ const hit = DANGEROUS_BASH_PATTERNS.find(p => p.re.test(command));
80
+ if (hit) {
81
+ process.stdout.write(
82
+ `ship-safe blocked this command — ${hit.name}\n\n${hit.reason}`
83
+ );
84
+ process.exit(2);
85
+ }
86
+ }
87
+ }
88
+
89
+ process.exit(0);
90
+ }
91
+
92
+ // =============================================================================
93
+ // Helpers
94
+ // =============================================================================
95
+
96
+ function extractContent(toolName, input) {
97
+ if (!input) return null;
98
+ switch (toolName) {
99
+ case 'Write':
100
+ return input.content || null;
101
+ case 'Edit':
102
+ return input.new_string || null;
103
+ case 'MultiEdit':
104
+ return Array.isArray(input.edits)
105
+ ? input.edits.map(e => e.new_string || '').join('\n')
106
+ : null;
107
+ case 'NotebookEdit':
108
+ // NotebookEdit passes new cell source as new_source or source
109
+ return input.new_source || input.source || input.cell_source || null;
110
+ default:
111
+ return null;
112
+ }
113
+ }
114
+
115
+ function buildBlockMessage(hits, filePath) {
116
+ const ext = filePath ? path.extname(filePath).toLowerCase() : '.js';
117
+ const lines = [
118
+ `ship-safe blocked this write — critical secret(s) detected:`,
119
+ '',
120
+ ];
121
+
122
+ for (const { name, line, envVar } of hits) {
123
+ const fix = buildFixSuggestion(envVar, filePath || '');
124
+ lines.push(` • ${name} on line ${line}`);
125
+ lines.push(` Fix: replace with ${fix}`);
126
+ lines.push(` Add to .env: ${envVar}=<your_value>`);
127
+ lines.push('');
128
+ }
129
+
130
+ lines.push('Run `npx ship-safe scan .` for a full report.');
131
+ return lines.join('\n');
132
+ }
133
+
134
+ /**
135
+ * Check if a .env file is covered by .gitignore.
136
+ * Returns a warning string if not covered, null if OK.
137
+ */
138
+ function checkEnvGitignore(envFilePath) {
139
+ const dir = path.dirname(path.resolve(envFilePath));
140
+ const gitignorePath = path.join(dir, '.gitignore');
141
+
142
+ // Walk up to repo root looking for .gitignore
143
+ const roots = [dir, path.dirname(dir), path.dirname(path.dirname(dir))];
144
+ for (const root of roots) {
145
+ const gi = path.join(root, '.gitignore');
146
+ if (!fs.existsSync(gi)) continue;
147
+ try {
148
+ const content = fs.readFileSync(gi, 'utf8');
149
+ const lines = content.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
150
+ const basename = path.basename(envFilePath);
151
+ const covered = lines.some(l =>
152
+ l === '.env' ||
153
+ l === basename ||
154
+ l === '.env*' ||
155
+ l === '*.env' ||
156
+ l === '.env.*' ||
157
+ (l.startsWith('.env') && basename.startsWith(l.replace('*', '')))
158
+ );
159
+ if (covered) return null;
160
+ return (
161
+ `ship-safe: ${basename} is not in .gitignore — secrets could be committed.\n` +
162
+ `Add this line to ${gi}:\n .env*\n`
163
+ );
164
+ } catch { /* ignore read errors */ }
165
+ }
166
+
167
+ // No .gitignore found at all
168
+ return (
169
+ `ship-safe: no .gitignore found. Create one and add ".env*" to prevent ` +
170
+ `secrets from being committed.\n`
171
+ );
172
+ }
173
+
174
+ function readStdin() {
175
+ return new Promise((resolve, reject) => {
176
+ if (process.stdin.isTTY) return resolve('');
177
+ const chunks = [];
178
+ process.stdin.setEncoding('utf8');
179
+ process.stdin.on('data', chunk => chunks.push(chunk));
180
+ process.stdin.on('end', () => resolve(chunks.join('')));
181
+ process.stdin.on('error', reject);
182
+ setTimeout(() => resolve(''), 3000); // never hang Claude Code
183
+ });
184
+ }
185
+
186
+ main().catch(() => process.exit(0)); // never crash — silently allow on error