@stacksjs/ts-cloud 0.1.7 → 0.1.9
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/dist/aws/s3.d.ts +1 -1
- package/dist/bin/cli.js +223 -222
- package/dist/index.js +132 -132
- package/package.json +18 -16
- package/src/aws/acm.ts +768 -0
- package/src/aws/application-autoscaling.ts +845 -0
- package/src/aws/bedrock.ts +4074 -0
- package/src/aws/client.ts +891 -0
- package/src/aws/cloudformation.ts +896 -0
- package/src/aws/cloudfront.ts +1531 -0
- package/src/aws/cloudwatch-logs.ts +154 -0
- package/src/aws/comprehend.ts +839 -0
- package/src/aws/connect.ts +1056 -0
- package/src/aws/deploy-imap.ts +384 -0
- package/src/aws/dynamodb.ts +340 -0
- package/src/aws/ec2.ts +1385 -0
- package/src/aws/ecr.ts +621 -0
- package/src/aws/ecs.ts +615 -0
- package/src/aws/elasticache.ts +301 -0
- package/src/aws/elbv2.ts +942 -0
- package/src/aws/email.ts +928 -0
- package/src/aws/eventbridge.ts +248 -0
- package/src/aws/iam.ts +1689 -0
- package/src/aws/imap-server.ts +2100 -0
- package/src/aws/index.ts +213 -0
- package/src/aws/kendra.ts +1097 -0
- package/src/aws/lambda.ts +786 -0
- package/src/aws/opensearch.ts +158 -0
- package/src/aws/personalize.ts +977 -0
- package/src/aws/polly.ts +559 -0
- package/src/aws/rds.ts +888 -0
- package/src/aws/rekognition.ts +846 -0
- package/src/aws/route53-domains.ts +359 -0
- package/src/aws/route53.ts +1046 -0
- package/src/aws/s3.ts +2334 -0
- package/src/aws/scheduler.ts +571 -0
- package/src/aws/secrets-manager.ts +769 -0
- package/src/aws/ses.ts +1081 -0
- package/src/aws/setup-phone.ts +104 -0
- package/src/aws/setup-sms.ts +580 -0
- package/src/aws/sms.ts +1735 -0
- package/src/aws/smtp-server.ts +531 -0
- package/src/aws/sns.ts +758 -0
- package/src/aws/sqs.ts +382 -0
- package/src/aws/ssm.ts +807 -0
- package/src/aws/sts.ts +92 -0
- package/src/aws/support.ts +391 -0
- package/src/aws/test-imap.ts +86 -0
- package/src/aws/textract.ts +780 -0
- package/src/aws/transcribe.ts +108 -0
- package/src/aws/translate.ts +641 -0
- package/src/aws/voice.ts +1379 -0
- package/src/config.ts +35 -0
- package/src/deploy/index.ts +7 -0
- package/src/deploy/static-site-external-dns.ts +945 -0
- package/src/deploy/static-site.ts +1175 -0
- package/src/dns/cloudflare.ts +548 -0
- package/src/dns/godaddy.ts +412 -0
- package/src/dns/index.ts +205 -0
- package/src/dns/porkbun.ts +362 -0
- package/src/dns/route53-adapter.ts +414 -0
- package/src/dns/types.ts +119 -0
- package/src/dns/validator.ts +369 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/infrastructure.ts +1660 -0
- package/src/index.ts +163 -0
- package/src/push/apns.ts +452 -0
- package/src/push/fcm.ts +506 -0
- package/src/push/index.ts +58 -0
- package/src/security/pre-deploy-scanner.ts +655 -0
- package/src/ssl/acme-client.ts +478 -0
- package/src/ssl/index.ts +7 -0
- package/src/ssl/letsencrypt.ts +747 -0
- package/src/types.ts +2 -0
- package/src/utils/cli.ts +398 -0
- package/src/validation/index.ts +5 -0
- package/src/validation/template.ts +405 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-Deployment Security Scanner
|
|
3
|
+
* Scans source code for leaked secrets, credentials, and sensitive data before deployment
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
7
|
+
import { join, relative, extname } from 'node:path'
|
|
8
|
+
|
|
9
|
+
export interface SecretPattern {
|
|
10
|
+
name: string
|
|
11
|
+
pattern: RegExp
|
|
12
|
+
severity: 'critical' | 'high' | 'medium' | 'low'
|
|
13
|
+
description: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SecurityFinding {
|
|
17
|
+
file: string
|
|
18
|
+
line: number
|
|
19
|
+
column: number
|
|
20
|
+
match: string
|
|
21
|
+
pattern: SecretPattern
|
|
22
|
+
context: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ScanResult {
|
|
26
|
+
passed: boolean
|
|
27
|
+
findings: SecurityFinding[]
|
|
28
|
+
scannedFiles: number
|
|
29
|
+
duration: number
|
|
30
|
+
summary: {
|
|
31
|
+
critical: number
|
|
32
|
+
high: number
|
|
33
|
+
medium: number
|
|
34
|
+
low: number
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ScanOptions {
|
|
39
|
+
directory: string
|
|
40
|
+
exclude?: string[]
|
|
41
|
+
include?: string[]
|
|
42
|
+
skipPatterns?: string[]
|
|
43
|
+
maxFileSize?: number
|
|
44
|
+
failOnSeverity?: 'critical' | 'high' | 'medium' | 'low'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Common secret patterns to detect
|
|
49
|
+
*/
|
|
50
|
+
export const SECRET_PATTERNS: SecretPattern[] = [
|
|
51
|
+
// AWS Credentials
|
|
52
|
+
{
|
|
53
|
+
name: 'AWS Access Key ID',
|
|
54
|
+
pattern: /(?:^|[^A-Z0-9])((AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16})(?:[^A-Z0-9]|$)/g,
|
|
55
|
+
severity: 'critical',
|
|
56
|
+
description: 'AWS Access Key ID detected',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'AWS Secret Access Key',
|
|
60
|
+
pattern: /(?:aws_secret_access_key|aws_secret_key|secret_access_key|secretAccessKey)\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi,
|
|
61
|
+
severity: 'critical',
|
|
62
|
+
description: 'AWS Secret Access Key detected',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'AWS Secret Key (Generic)',
|
|
66
|
+
pattern: /(?:^|['"`:=\s])([A-Za-z0-9/+=]{40})(?:['"`\s]|$)/g,
|
|
67
|
+
severity: 'high',
|
|
68
|
+
description: 'Potential AWS Secret Key (40-char base64)',
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// API Keys (Generic)
|
|
72
|
+
{
|
|
73
|
+
name: 'Generic API Key',
|
|
74
|
+
pattern: /(?:api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*['"]?([A-Za-z0-9_\-]{20,})['"]?/gi,
|
|
75
|
+
severity: 'high',
|
|
76
|
+
description: 'Generic API key detected',
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// Private Keys
|
|
80
|
+
{
|
|
81
|
+
name: 'RSA Private Key',
|
|
82
|
+
pattern: /-----BEGIN RSA PRIVATE KEY-----/g,
|
|
83
|
+
severity: 'critical',
|
|
84
|
+
description: 'RSA private key detected',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'OpenSSH Private Key',
|
|
88
|
+
pattern: /-----BEGIN OPENSSH PRIVATE KEY-----/g,
|
|
89
|
+
severity: 'critical',
|
|
90
|
+
description: 'OpenSSH private key detected',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'EC Private Key',
|
|
94
|
+
pattern: /-----BEGIN EC PRIVATE KEY-----/g,
|
|
95
|
+
severity: 'critical',
|
|
96
|
+
description: 'EC private key detected',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'PGP Private Key',
|
|
100
|
+
pattern: /-----BEGIN PGP PRIVATE KEY BLOCK-----/g,
|
|
101
|
+
severity: 'critical',
|
|
102
|
+
description: 'PGP private key detected',
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// Tokens
|
|
106
|
+
{
|
|
107
|
+
name: 'GitHub Token',
|
|
108
|
+
pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/g,
|
|
109
|
+
severity: 'critical',
|
|
110
|
+
description: 'GitHub personal access token detected',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'GitHub OAuth',
|
|
114
|
+
pattern: /github[_-]?oauth[_-]?token\s*[=:]\s*['"]?([A-Za-z0-9_]{40})['"]?/gi,
|
|
115
|
+
severity: 'critical',
|
|
116
|
+
description: 'GitHub OAuth token detected',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'Slack Token',
|
|
120
|
+
pattern: /xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/g,
|
|
121
|
+
severity: 'critical',
|
|
122
|
+
description: 'Slack token detected',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'Slack Webhook',
|
|
126
|
+
pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g,
|
|
127
|
+
severity: 'high',
|
|
128
|
+
description: 'Slack webhook URL detected',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'Discord Webhook',
|
|
132
|
+
pattern: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+/g,
|
|
133
|
+
severity: 'high',
|
|
134
|
+
description: 'Discord webhook URL detected',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'JWT Token',
|
|
138
|
+
pattern: /eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*/g,
|
|
139
|
+
severity: 'high',
|
|
140
|
+
description: 'JWT token detected',
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// Cloud Provider Keys
|
|
144
|
+
{
|
|
145
|
+
name: 'Google API Key',
|
|
146
|
+
pattern: /AIza[0-9A-Za-z_-]{35}/g,
|
|
147
|
+
severity: 'critical',
|
|
148
|
+
description: 'Google API key detected',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'Google OAuth ID',
|
|
152
|
+
pattern: /[0-9]+-[A-Za-z0-9_]{32}\.apps\.googleusercontent\.com/g,
|
|
153
|
+
severity: 'high',
|
|
154
|
+
description: 'Google OAuth client ID detected',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'Firebase API Key',
|
|
158
|
+
pattern: /(?:firebase[_-]?api[_-]?key)\s*[=:]\s*['"]?([A-Za-z0-9_-]{39})['"]?/gi,
|
|
159
|
+
severity: 'critical',
|
|
160
|
+
description: 'Firebase API key detected',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'Cloudflare API Token',
|
|
164
|
+
pattern: /(?:cloudflare[_-]?api[_-]?token|cf[_-]?api[_-]?token)\s*[=:]\s*['"]?([A-Za-z0-9_-]{40})['"]?/gi,
|
|
165
|
+
severity: 'critical',
|
|
166
|
+
description: 'Cloudflare API token detected',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'Azure Client Secret',
|
|
170
|
+
pattern: /(?:azure[_-]?client[_-]?secret|client[_-]?secret)\s*[=:]\s*['"]?([A-Za-z0-9~._-]{34,})['"]?/gi,
|
|
171
|
+
severity: 'critical',
|
|
172
|
+
description: 'Azure client secret detected',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'Heroku API Key',
|
|
176
|
+
pattern: /(?:heroku[_-]?api[_-]?key)\s*[=:]\s*['"]?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})['"]?/gi,
|
|
177
|
+
severity: 'critical',
|
|
178
|
+
description: 'Heroku API key detected',
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// Database Credentials
|
|
182
|
+
{
|
|
183
|
+
name: 'Database Connection String',
|
|
184
|
+
pattern: /(?:mysql|postgres|postgresql|mongodb|redis|mongodb\+srv):\/\/[^:]+:[^@]+@[^/\s]+/gi,
|
|
185
|
+
severity: 'critical',
|
|
186
|
+
description: 'Database connection string with credentials detected',
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'Database Password',
|
|
190
|
+
pattern: /(?:db[_-]?password|database[_-]?password|mysql[_-]?password|postgres[_-]?password)\s*[=:]\s*['"]?([^'"\s]{8,})['"]?/gi,
|
|
191
|
+
severity: 'critical',
|
|
192
|
+
description: 'Database password detected',
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
// Payment/Financial
|
|
196
|
+
{
|
|
197
|
+
name: 'Stripe API Key',
|
|
198
|
+
pattern: /(?:sk|pk)_(?:test|live)_[0-9a-zA-Z]{24,}/g,
|
|
199
|
+
severity: 'critical',
|
|
200
|
+
description: 'Stripe API key detected',
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: 'PayPal Client ID',
|
|
204
|
+
pattern: /(?:paypal[_-]?client[_-]?id)\s*[=:]\s*['"]?([A-Za-z0-9_-]{80})['"]?/gi,
|
|
205
|
+
severity: 'high',
|
|
206
|
+
description: 'PayPal client ID detected',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: 'Square Access Token',
|
|
210
|
+
pattern: /sq0[a-z]{3}-[0-9A-Za-z_-]{22,}/g,
|
|
211
|
+
severity: 'critical',
|
|
212
|
+
description: 'Square access token detected',
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
// Communication Services
|
|
216
|
+
{
|
|
217
|
+
name: 'Twilio API Key',
|
|
218
|
+
pattern: /SK[a-f0-9]{32}/g,
|
|
219
|
+
severity: 'critical',
|
|
220
|
+
description: 'Twilio API key detected',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: 'SendGrid API Key',
|
|
224
|
+
pattern: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g,
|
|
225
|
+
severity: 'critical',
|
|
226
|
+
description: 'SendGrid API key detected',
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'Mailgun API Key',
|
|
230
|
+
pattern: /key-[0-9a-zA-Z]{32}/g,
|
|
231
|
+
severity: 'critical',
|
|
232
|
+
description: 'Mailgun API key detected',
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// Authentication Secrets
|
|
236
|
+
{
|
|
237
|
+
name: 'Password in Code',
|
|
238
|
+
pattern: /(?:password|passwd|pwd)\s*[=:]\s*['"]([^'"]{8,})['"](?!\s*[,\]])/gi,
|
|
239
|
+
severity: 'high',
|
|
240
|
+
description: 'Hardcoded password detected',
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: 'Secret/Token Assignment',
|
|
244
|
+
pattern: /(?:secret|token|auth[_-]?token|access[_-]?token)\s*[=:]\s*['"]([A-Za-z0-9_\-/+=]{16,})['"](?!\s*[,\]])/gi,
|
|
245
|
+
severity: 'high',
|
|
246
|
+
description: 'Hardcoded secret or token detected',
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
// NPM/Package Registry
|
|
250
|
+
{
|
|
251
|
+
name: 'NPM Token',
|
|
252
|
+
pattern: /(?:npm[_-]?token)\s*[=:]\s*['"]?([A-Za-z0-9_-]{36})['"]?/gi,
|
|
253
|
+
severity: 'critical',
|
|
254
|
+
description: 'NPM token detected',
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
// SSH/Git
|
|
258
|
+
{
|
|
259
|
+
name: 'SSH Private Key Path Exposed',
|
|
260
|
+
pattern: /~\/\.ssh\/id_[a-z]+|\/home\/[^/]+\/\.ssh\/id_[a-z]+/g,
|
|
261
|
+
severity: 'medium',
|
|
262
|
+
description: 'SSH private key path exposed',
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
// Environment Variable Leaks
|
|
266
|
+
{
|
|
267
|
+
name: 'Env Variable with Secret',
|
|
268
|
+
pattern: /(?:process\.env\.)([A-Z_]*(?:SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL|AUTH)[A-Z_]*)\s*(?:===?\s*['"]([^'"]+)['"])?/g,
|
|
269
|
+
severity: 'medium',
|
|
270
|
+
description: 'Environment variable containing secret may be exposed',
|
|
271
|
+
},
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* File extensions to scan by default
|
|
276
|
+
*/
|
|
277
|
+
const DEFAULT_SCAN_EXTENSIONS = [
|
|
278
|
+
'.js',
|
|
279
|
+
'.jsx',
|
|
280
|
+
'.ts',
|
|
281
|
+
'.tsx',
|
|
282
|
+
'.mjs',
|
|
283
|
+
'.cjs',
|
|
284
|
+
'.vue',
|
|
285
|
+
'.svelte',
|
|
286
|
+
'.html',
|
|
287
|
+
'.htm',
|
|
288
|
+
'.css',
|
|
289
|
+
'.scss',
|
|
290
|
+
'.less',
|
|
291
|
+
'.json',
|
|
292
|
+
'.yaml',
|
|
293
|
+
'.yml',
|
|
294
|
+
'.toml',
|
|
295
|
+
'.xml',
|
|
296
|
+
'.env',
|
|
297
|
+
'.config',
|
|
298
|
+
'.conf',
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Directories to exclude by default
|
|
303
|
+
*/
|
|
304
|
+
const DEFAULT_EXCLUDE_DIRS = [
|
|
305
|
+
'node_modules',
|
|
306
|
+
'.git',
|
|
307
|
+
'.svn',
|
|
308
|
+
'.hg',
|
|
309
|
+
'dist',
|
|
310
|
+
'build',
|
|
311
|
+
'coverage',
|
|
312
|
+
'.nyc_output',
|
|
313
|
+
'__pycache__',
|
|
314
|
+
'.pytest_cache',
|
|
315
|
+
'vendor',
|
|
316
|
+
'.idea',
|
|
317
|
+
'.vscode',
|
|
318
|
+
'.turbo',
|
|
319
|
+
'.next',
|
|
320
|
+
'.nuxt',
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Files to exclude by default
|
|
325
|
+
*/
|
|
326
|
+
const DEFAULT_EXCLUDE_FILES = [
|
|
327
|
+
'package-lock.json',
|
|
328
|
+
'yarn.lock',
|
|
329
|
+
'pnpm-lock.yaml',
|
|
330
|
+
'bun.lockb',
|
|
331
|
+
'*.min.js',
|
|
332
|
+
'*.min.css',
|
|
333
|
+
'*.map',
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Pre-deployment security scanner
|
|
338
|
+
*/
|
|
339
|
+
export class PreDeployScanner {
|
|
340
|
+
private patterns: SecretPattern[]
|
|
341
|
+
private excludeDirs: string[]
|
|
342
|
+
private excludeFiles: string[]
|
|
343
|
+
private maxFileSize: number
|
|
344
|
+
|
|
345
|
+
constructor(options?: {
|
|
346
|
+
customPatterns?: SecretPattern[]
|
|
347
|
+
excludeDirs?: string[]
|
|
348
|
+
excludeFiles?: string[]
|
|
349
|
+
maxFileSize?: number
|
|
350
|
+
}) {
|
|
351
|
+
this.patterns = [...SECRET_PATTERNS, ...(options?.customPatterns || [])]
|
|
352
|
+
this.excludeDirs = [...DEFAULT_EXCLUDE_DIRS, ...(options?.excludeDirs || [])]
|
|
353
|
+
this.excludeFiles = [...DEFAULT_EXCLUDE_FILES, ...(options?.excludeFiles || [])]
|
|
354
|
+
this.maxFileSize = options?.maxFileSize || 1024 * 1024 // 1MB default
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Scan a directory for secrets
|
|
359
|
+
*/
|
|
360
|
+
async scan(options: ScanOptions): Promise<ScanResult> {
|
|
361
|
+
const startTime = Date.now()
|
|
362
|
+
const findings: SecurityFinding[] = []
|
|
363
|
+
let scannedFiles = 0
|
|
364
|
+
|
|
365
|
+
const { directory, exclude = [], include, skipPatterns = [] } = options
|
|
366
|
+
const failSeverity = options.failOnSeverity || 'critical'
|
|
367
|
+
|
|
368
|
+
if (!existsSync(directory)) {
|
|
369
|
+
throw new Error(`Directory not found: ${directory}`)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Get all files to scan
|
|
373
|
+
const files = this.getFilesToScan(directory, [...this.excludeDirs, ...exclude], include)
|
|
374
|
+
|
|
375
|
+
// Scan each file
|
|
376
|
+
for (const file of files) {
|
|
377
|
+
const relativePath = relative(directory, file)
|
|
378
|
+
|
|
379
|
+
// Skip excluded files
|
|
380
|
+
if (this.shouldExcludeFile(relativePath)) {
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const stat = statSync(file)
|
|
386
|
+
|
|
387
|
+
// Skip files that are too large
|
|
388
|
+
if (stat.size > this.maxFileSize) {
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const content = readFileSync(file, 'utf-8')
|
|
393
|
+
const fileFindings = this.scanContent(content, relativePath, skipPatterns)
|
|
394
|
+
findings.push(...fileFindings)
|
|
395
|
+
scannedFiles++
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// Skip files that can't be read (binary, etc.)
|
|
399
|
+
continue
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Calculate summary
|
|
404
|
+
const summary = {
|
|
405
|
+
critical: findings.filter(f => f.pattern.severity === 'critical').length,
|
|
406
|
+
high: findings.filter(f => f.pattern.severity === 'high').length,
|
|
407
|
+
medium: findings.filter(f => f.pattern.severity === 'medium').length,
|
|
408
|
+
low: findings.filter(f => f.pattern.severity === 'low').length,
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Determine if scan passed based on severity threshold
|
|
412
|
+
const severityOrder = ['low', 'medium', 'high', 'critical']
|
|
413
|
+
const failIndex = severityOrder.indexOf(failSeverity)
|
|
414
|
+
let passed = true
|
|
415
|
+
|
|
416
|
+
for (let i = failIndex; i < severityOrder.length; i++) {
|
|
417
|
+
if (summary[severityOrder[i] as keyof typeof summary] > 0) {
|
|
418
|
+
passed = false
|
|
419
|
+
break
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
passed,
|
|
425
|
+
findings,
|
|
426
|
+
scannedFiles,
|
|
427
|
+
duration: Date.now() - startTime,
|
|
428
|
+
summary,
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Scan content for secrets
|
|
434
|
+
*/
|
|
435
|
+
private scanContent(content: string, filePath: string, skipPatterns: string[]): SecurityFinding[] {
|
|
436
|
+
const findings: SecurityFinding[] = []
|
|
437
|
+
const lines = content.split('\n')
|
|
438
|
+
|
|
439
|
+
for (const pattern of this.patterns) {
|
|
440
|
+
// Skip patterns if specified
|
|
441
|
+
if (skipPatterns.includes(pattern.name)) {
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Reset regex lastIndex
|
|
446
|
+
pattern.pattern.lastIndex = 0
|
|
447
|
+
|
|
448
|
+
let match: RegExpExecArray | null
|
|
449
|
+
while ((match = pattern.pattern.exec(content)) !== null) {
|
|
450
|
+
// Find line number and column
|
|
451
|
+
const beforeMatch = content.substring(0, match.index)
|
|
452
|
+
const lineNumber = beforeMatch.split('\n').length
|
|
453
|
+
const lastNewline = beforeMatch.lastIndexOf('\n')
|
|
454
|
+
const column = match.index - lastNewline
|
|
455
|
+
|
|
456
|
+
// Get context (the line containing the match)
|
|
457
|
+
const contextLine = lines[lineNumber - 1] || ''
|
|
458
|
+
|
|
459
|
+
// Skip if it looks like a test/example/placeholder
|
|
460
|
+
if (this.isLikelyPlaceholder(match[0], contextLine)) {
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
findings.push({
|
|
465
|
+
file: filePath,
|
|
466
|
+
line: lineNumber,
|
|
467
|
+
column,
|
|
468
|
+
match: this.maskSecret(match[0]),
|
|
469
|
+
pattern,
|
|
470
|
+
context: this.maskSecret(contextLine.trim()),
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return findings
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Check if a match is likely a placeholder/example
|
|
480
|
+
*/
|
|
481
|
+
private isLikelyPlaceholder(match: string, context: string): boolean {
|
|
482
|
+
const placeholderIndicators = [
|
|
483
|
+
'example',
|
|
484
|
+
'placeholder',
|
|
485
|
+
'your_',
|
|
486
|
+
'YOUR_',
|
|
487
|
+
'xxx',
|
|
488
|
+
'XXX',
|
|
489
|
+
'***',
|
|
490
|
+
'test',
|
|
491
|
+
'TEST',
|
|
492
|
+
'dummy',
|
|
493
|
+
'DUMMY',
|
|
494
|
+
'fake',
|
|
495
|
+
'FAKE',
|
|
496
|
+
'sample',
|
|
497
|
+
'SAMPLE',
|
|
498
|
+
'<your',
|
|
499
|
+
'${',
|
|
500
|
+
'{{',
|
|
501
|
+
'process.env',
|
|
502
|
+
'import.meta.env',
|
|
503
|
+
'CHANGEME',
|
|
504
|
+
'TODO',
|
|
505
|
+
'FIXME',
|
|
506
|
+
]
|
|
507
|
+
|
|
508
|
+
const lowerMatch = match.toLowerCase()
|
|
509
|
+
const lowerContext = context.toLowerCase()
|
|
510
|
+
|
|
511
|
+
for (const indicator of placeholderIndicators) {
|
|
512
|
+
if (lowerMatch.includes(indicator.toLowerCase()) || lowerContext.includes(indicator.toLowerCase())) {
|
|
513
|
+
return true
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Check if it's in a comment
|
|
518
|
+
const trimmedContext = context.trim()
|
|
519
|
+
if (trimmedContext.startsWith('//') || trimmedContext.startsWith('#') || trimmedContext.startsWith('*') || trimmedContext.startsWith('/*')) {
|
|
520
|
+
// Only skip if it's clearly documentation
|
|
521
|
+
if (lowerContext.includes('example') || lowerContext.includes('format:') || lowerContext.includes('e.g.')) {
|
|
522
|
+
return true
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return false
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Mask a secret for display
|
|
531
|
+
*/
|
|
532
|
+
private maskSecret(value: string): string {
|
|
533
|
+
if (value.length <= 8) {
|
|
534
|
+
return '*'.repeat(value.length)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const visibleChars = Math.min(4, Math.floor(value.length * 0.2))
|
|
538
|
+
return value.substring(0, visibleChars) + '*'.repeat(value.length - visibleChars * 2) + value.substring(value.length - visibleChars)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Get all files to scan in a directory
|
|
543
|
+
*/
|
|
544
|
+
private getFilesToScan(dir: string, excludeDirs: string[], includeExtensions?: string[]): string[] {
|
|
545
|
+
const files: string[] = []
|
|
546
|
+
const extensions = includeExtensions || DEFAULT_SCAN_EXTENSIONS
|
|
547
|
+
|
|
548
|
+
const scan = (currentDir: string) => {
|
|
549
|
+
const entries = readdirSync(currentDir, { withFileTypes: true })
|
|
550
|
+
|
|
551
|
+
for (const entry of entries) {
|
|
552
|
+
const fullPath = join(currentDir, entry.name)
|
|
553
|
+
|
|
554
|
+
if (entry.isDirectory()) {
|
|
555
|
+
// Skip excluded directories
|
|
556
|
+
if (!excludeDirs.includes(entry.name)) {
|
|
557
|
+
scan(fullPath)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
else if (entry.isFile()) {
|
|
561
|
+
const ext = extname(entry.name).toLowerCase()
|
|
562
|
+
// Include files with matching extensions or no extension (like .env files)
|
|
563
|
+
if (extensions.includes(ext) || entry.name.startsWith('.env') || entry.name.endsWith('.config')) {
|
|
564
|
+
files.push(fullPath)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
scan(dir)
|
|
571
|
+
return files
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Check if a file should be excluded
|
|
576
|
+
*/
|
|
577
|
+
private shouldExcludeFile(filePath: string): boolean {
|
|
578
|
+
const fileName = filePath.split('/').pop() || ''
|
|
579
|
+
|
|
580
|
+
for (const pattern of this.excludeFiles) {
|
|
581
|
+
if (pattern.startsWith('*')) {
|
|
582
|
+
// Wildcard pattern
|
|
583
|
+
const suffix = pattern.substring(1)
|
|
584
|
+
if (fileName.endsWith(suffix)) {
|
|
585
|
+
return true
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
else if (fileName === pattern) {
|
|
589
|
+
return true
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return false
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Add custom patterns
|
|
598
|
+
*/
|
|
599
|
+
addPattern(pattern: SecretPattern): void {
|
|
600
|
+
this.patterns.push(pattern)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Get all registered patterns
|
|
605
|
+
*/
|
|
606
|
+
getPatterns(): SecretPattern[] {
|
|
607
|
+
return [...this.patterns]
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Convenience function to scan a directory
|
|
613
|
+
*/
|
|
614
|
+
export async function scanForSecrets(options: ScanOptions): Promise<ScanResult> {
|
|
615
|
+
const scanner = new PreDeployScanner()
|
|
616
|
+
return scanner.scan(options)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Format scan results for CLI output
|
|
621
|
+
*/
|
|
622
|
+
export function formatScanResults(result: ScanResult): string {
|
|
623
|
+
const lines: string[] = []
|
|
624
|
+
|
|
625
|
+
lines.push(`\nSecurity Scan Results`)
|
|
626
|
+
lines.push('='.repeat(50))
|
|
627
|
+
lines.push(`Files scanned: ${result.scannedFiles}`)
|
|
628
|
+
lines.push(`Duration: ${result.duration}ms`)
|
|
629
|
+
lines.push('')
|
|
630
|
+
|
|
631
|
+
lines.push('Summary:')
|
|
632
|
+
lines.push(` Critical: ${result.summary.critical}`)
|
|
633
|
+
lines.push(` High: ${result.summary.high}`)
|
|
634
|
+
lines.push(` Medium: ${result.summary.medium}`)
|
|
635
|
+
lines.push(` Low: ${result.summary.low}`)
|
|
636
|
+
lines.push('')
|
|
637
|
+
|
|
638
|
+
if (result.findings.length > 0) {
|
|
639
|
+
lines.push('Findings:')
|
|
640
|
+
lines.push('-'.repeat(50))
|
|
641
|
+
|
|
642
|
+
for (const finding of result.findings) {
|
|
643
|
+
lines.push(`\n[${finding.pattern.severity.toUpperCase()}] ${finding.pattern.name}`)
|
|
644
|
+
lines.push(` File: ${finding.file}:${finding.line}:${finding.column}`)
|
|
645
|
+
lines.push(` Match: ${finding.match}`)
|
|
646
|
+
lines.push(` Context: ${finding.context}`)
|
|
647
|
+
lines.push(` Description: ${finding.pattern.description}`)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
lines.push('')
|
|
652
|
+
lines.push(result.passed ? '✓ Security scan passed' : '✗ Security scan failed')
|
|
653
|
+
|
|
654
|
+
return lines.join('\n')
|
|
655
|
+
}
|