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.
- package/README.md +735 -641
- package/cli/agents/api-fuzzer.js +345 -345
- package/cli/agents/auth-bypass-agent.js +348 -348
- package/cli/agents/base-agent.js +272 -272
- package/cli/agents/cicd-scanner.js +236 -201
- package/cli/agents/config-auditor.js +521 -521
- package/cli/agents/deep-analyzer.js +6 -2
- package/cli/agents/git-history-scanner.js +170 -170
- package/cli/agents/html-reporter.js +568 -568
- package/cli/agents/index.js +84 -84
- package/cli/agents/injection-tester.js +500 -500
- package/cli/agents/llm-redteam.js +251 -251
- package/cli/agents/mobile-scanner.js +231 -231
- package/cli/agents/orchestrator.js +322 -322
- package/cli/agents/pii-compliance-agent.js +301 -301
- package/cli/agents/scoring-engine.js +248 -248
- package/cli/agents/supabase-rls-agent.js +154 -154
- package/cli/agents/supply-chain-agent.js +650 -507
- package/cli/bin/ship-safe.js +452 -426
- package/cli/commands/agent.js +608 -608
- package/cli/commands/audit.js +986 -980
- package/cli/commands/baseline.js +193 -193
- package/cli/commands/ci.js +342 -342
- package/cli/commands/deps.js +516 -516
- package/cli/commands/doctor.js +159 -159
- package/cli/commands/fix.js +218 -218
- package/cli/commands/hooks.js +268 -0
- package/cli/commands/init.js +407 -407
- package/cli/commands/mcp.js +304 -304
- package/cli/commands/red-team.js +7 -1
- package/cli/commands/remediate.js +798 -798
- package/cli/commands/rotate.js +571 -571
- package/cli/commands/scan.js +569 -569
- package/cli/commands/score.js +449 -449
- package/cli/commands/watch.js +281 -281
- package/cli/hooks/patterns.js +313 -0
- package/cli/hooks/post-tool-use.js +140 -0
- package/cli/hooks/pre-tool-use.js +186 -0
- package/cli/index.js +73 -69
- package/cli/providers/llm-provider.js +397 -287
- package/cli/utils/autofix-rules.js +74 -74
- package/cli/utils/cache-manager.js +311 -311
- package/cli/utils/output.js +230 -230
- package/cli/utils/patterns.js +1121 -1121
- package/cli/utils/pdf-generator.js +94 -94
- package/package.json +69 -69
- 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
|