@preflight-agent/cli 0.1.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.
@@ -0,0 +1,87 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { glob } from 'glob';
4
+ import type { CheckResult } from '../scan';
5
+
6
+ export async function runGraphqlChecks(dir: string): Promise<CheckResult[]> {
7
+ const results: CheckResult[] = [];
8
+
9
+ const gqlFiles = await glob(`${dir}/**/*.{graphql,gql}`, { ignore: ['**/node_modules/**'] });
10
+ const gqlTsFiles = await glob(`${dir}/src/**/*.ts`, { ignore: ['**/node_modules/**'] });
11
+
12
+ const allGqlFiles = [...gqlFiles, ...gqlTsFiles.filter(f => {
13
+ const content = fs.readFileSync(f, 'utf-8');
14
+ return content.includes('graphql') || content.includes('GraphQL') || content.includes('gql`') || content.includes('apollo') || content.includes('urql');
15
+ })];
16
+
17
+ if (allGqlFiles.length === 0) {
18
+ results.push({ status: 'warn', message: 'No GraphQL files detected \u2014 skipping GraphQL checks' });
19
+ return results;
20
+ }
21
+
22
+ let hasDepthLimit = false;
23
+ let depthFile: string | undefined;
24
+
25
+ for (const file of allGqlFiles) {
26
+ const content = fs.readFileSync(file, 'utf-8');
27
+ if (
28
+ content.includes('depthLimit') ||
29
+ content.includes('queryDepth') ||
30
+ content.includes('maxDepth') ||
31
+ content.includes('validationRules') ||
32
+ content.includes('complexity')
33
+ ) {
34
+ hasDepthLimit = true;
35
+ depthFile = path.relative(dir, file);
36
+ break;
37
+ }
38
+ }
39
+
40
+ if (hasDepthLimit) {
41
+ results.push({ status: 'pass', message: 'GraphQL query depth limiting found', file: depthFile });
42
+ } else {
43
+ results.push({ status: 'warn', message: 'No GraphQL depth limiting found \u2014 malicious queries can exhaust your server' });
44
+ }
45
+
46
+ let hasAuth = false;
47
+ let authFile: string | undefined;
48
+
49
+ for (const file of allGqlFiles) {
50
+ const content = fs.readFileSync(file, 'utf-8');
51
+ if (
52
+ content.includes('context') && (
53
+ content.includes('token') ||
54
+ content.includes('auth') ||
55
+ content.includes('session') ||
56
+ content.includes('bearer')
57
+ )
58
+ ) {
59
+ hasAuth = true;
60
+ authFile = path.relative(dir, file);
61
+ break;
62
+ }
63
+ }
64
+
65
+ if (hasAuth) {
66
+ results.push({ status: 'pass', message: 'GraphQL authentication context found', file: authFile });
67
+ } else {
68
+ results.push({ status: 'warn', message: 'No GraphQL auth context detected \u2014 your GraphQL endpoint may be public' });
69
+ }
70
+
71
+ let introspectionDisabled = false;
72
+ for (const file of allGqlFiles) {
73
+ const content = fs.readFileSync(file, 'utf-8');
74
+ if (content.includes('introspection') && (content.includes('false') || content.includes('disabled') || content.includes('0'))) {
75
+ introspectionDisabled = true;
76
+ break;
77
+ }
78
+ }
79
+
80
+ results.push(
81
+ introspectionDisabled
82
+ ? { status: 'pass', message: 'GraphQL introspection disabled in production' }
83
+ : { status: 'warn', message: 'GraphQL introspection may be enabled \u2014 attackers can discover your entire schema' }
84
+ );
85
+
86
+ return results;
87
+ }
@@ -0,0 +1,9 @@
1
+ export { runSecurityChecks } from './security';
2
+ export { runAuthChecks } from './auth';
3
+ export { runPaymentChecks } from './payments';
4
+ export { runDatabaseChecks } from './database';
5
+ export { runApiChecks } from './api';
6
+ export { runWebChecks } from './web';
7
+ export { runGraphqlChecks } from './graphql';
8
+ export { runRealtimeChecks } from './realtime';
9
+ export { runVulnerabilityChecks } from './vulnerabilities';
@@ -0,0 +1,92 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { glob } from 'glob';
4
+ import type { CheckResult } from '../scan';
5
+
6
+ export async function runPaymentChecks(dir: string): Promise<CheckResult[]> {
7
+ const results: CheckResult[] = [];
8
+
9
+ const webhookFiles = await glob(
10
+ `${dir}/**/{webhook,webhooks,mpesa,stripe,payment}/**/*.{ts,tsx,js}`,
11
+ { ignore: ['**/node_modules/**', '**/.next/**'] }
12
+ );
13
+
14
+ const apiFiles = await glob(`${dir}/src/app/api/**/*.ts`, { ignore: ['**/node_modules/**'] });
15
+ const allFiles = [...new Set([...webhookFiles, ...apiFiles])];
16
+ const paymentRelatedFiles = allFiles.filter(f =>
17
+ /webhook|stripe|mpesa|payment|daraja|stk/i.test(f)
18
+ );
19
+
20
+ if (paymentRelatedFiles.length === 0) {
21
+ results.push({ status: 'warn', message: 'No payment-related files detected \u2014 skipping payment checks' });
22
+ return results;
23
+ }
24
+
25
+ let hasSignatureValidation = false;
26
+ let signatureFile: string | undefined;
27
+
28
+ for (const file of paymentRelatedFiles) {
29
+ const content = fs.readFileSync(file, 'utf-8');
30
+ if (
31
+ content.includes('constructEvent') ||
32
+ content.includes('stripe.webhooks') ||
33
+ content.includes('X-Mpesa-Signature') ||
34
+ content.includes('validateWebhook') ||
35
+ content.includes('verifySignature') ||
36
+ content.includes('crypto.timingSafeEqual') ||
37
+ content.includes('hmac')
38
+ ) {
39
+ hasSignatureValidation = true;
40
+ signatureFile = path.relative(dir, file);
41
+ break;
42
+ }
43
+ }
44
+
45
+ if (hasSignatureValidation) {
46
+ results.push({ status: 'pass', message: 'Webhook signature validation found', file: signatureFile });
47
+ } else {
48
+ const firstFile = path.relative(dir, paymentRelatedFiles[0]);
49
+ results.push({
50
+ status: 'fail',
51
+ message: 'No webhook signature validation found \u2014 anyone can fake payment events',
52
+ file: firstFile,
53
+ });
54
+ }
55
+
56
+ let hasErrorHandling = false;
57
+ for (const file of paymentRelatedFiles) {
58
+ const content = fs.readFileSync(file, 'utf-8');
59
+ if (content.includes('try') && content.includes('catch')) {
60
+ hasErrorHandling = true;
61
+ break;
62
+ }
63
+ }
64
+
65
+ if (hasErrorHandling) {
66
+ results.push({ status: 'pass', message: 'Error handling (try/catch) found in payment routes' });
67
+ } else {
68
+ results.push({ status: 'warn', message: 'No try/catch found in payment files \u2014 unhandled failures will crash' });
69
+ }
70
+
71
+ let hasIdempotency = false;
72
+ for (const file of paymentRelatedFiles) {
73
+ const content = fs.readFileSync(file, 'utf-8');
74
+ if (
75
+ content.includes('idempotencyKey') ||
76
+ content.includes('idempotency_key') ||
77
+ content.includes('TransactionID') ||
78
+ content.includes('CheckoutRequestID')
79
+ ) {
80
+ hasIdempotency = true;
81
+ break;
82
+ }
83
+ }
84
+
85
+ results.push(
86
+ hasIdempotency
87
+ ? { status: 'pass', message: 'Idempotency keys found in payment flow' }
88
+ : { status: 'warn', message: 'No idempotency keys detected \u2014 duplicate payments may occur' }
89
+ );
90
+
91
+ return results;
92
+ }
@@ -0,0 +1,96 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { glob } from 'glob';
4
+ import type { CheckResult } from '../scan';
5
+
6
+ export async function runRealtimeChecks(dir: string): Promise<CheckResult[]> {
7
+ const results: CheckResult[] = [];
8
+
9
+ const tsFiles = await glob(`${dir}/src/**/*.ts`, { ignore: ['**/node_modules/**', '**/.next/**'] });
10
+ const realtimeFiles = tsFiles.filter(f => {
11
+ const content = fs.readFileSync(f, 'utf-8');
12
+ return (
13
+ content.includes('websocket') ||
14
+ content.includes('WebSocket') ||
15
+ content.includes('ws://') ||
16
+ content.includes('wss://') ||
17
+ content.includes('socket') ||
18
+ content.includes('subscription') ||
19
+ content.includes('realtime') ||
20
+ content.includes('supabase') && content.includes('channel') ||
21
+ content.includes('broadcast') ||
22
+ content.includes('presence')
23
+ );
24
+ });
25
+
26
+ if (realtimeFiles.length === 0) {
27
+ results.push({ status: 'warn', message: 'No real-time features detected \u2014 skipping real-time checks' });
28
+ return results;
29
+ }
30
+
31
+ let hasCleanup = false;
32
+ let cleanupFile: string | undefined;
33
+
34
+ for (const file of realtimeFiles) {
35
+ const content = fs.readFileSync(file, 'utf-8');
36
+ if (
37
+ content.includes('unsubscribe') ||
38
+ content.includes('unmount') ||
39
+ content.includes('cleanup') ||
40
+ content.includes('disconnect') ||
41
+ content.includes('removeChannel') ||
42
+ content.includes('destroy') ||
43
+ content.includes('close()')
44
+ ) {
45
+ hasCleanup = true;
46
+ cleanupFile = path.relative(dir, file);
47
+ break;
48
+ }
49
+ }
50
+
51
+ if (hasCleanup) {
52
+ results.push({ status: 'pass', message: 'Real-time connection cleanup found', file: cleanupFile });
53
+ } else {
54
+ results.push({ status: 'warn', message: 'No real-time cleanup detected \u2014 connections may leak and cause memory issues' });
55
+ }
56
+
57
+ let hasReconnect = false;
58
+ for (const file of realtimeFiles) {
59
+ const content = fs.readFileSync(file, 'utf-8');
60
+ if (
61
+ content.includes('reconnect') ||
62
+ content.includes('retry') ||
63
+ content.includes('backoff')
64
+ ) {
65
+ hasReconnect = true;
66
+ break;
67
+ }
68
+ }
69
+
70
+ results.push(
71
+ hasReconnect
72
+ ? { status: 'pass', message: 'Reconnection logic found for real-time connections' }
73
+ : { status: 'warn', message: 'No reconnection logic detected \u2014 temporary network issues will drop connections permanently' }
74
+ );
75
+
76
+ let hasAuth = false;
77
+ for (const file of realtimeFiles) {
78
+ const content = fs.readFileSync(file, 'utf-8');
79
+ if (
80
+ content.includes('token') ||
81
+ content.includes('auth') && (content.includes('channel') || content.includes('socket')) ||
82
+ content.includes('accessToken')
83
+ ) {
84
+ hasAuth = true;
85
+ break;
86
+ }
87
+ }
88
+
89
+ results.push(
90
+ hasAuth
91
+ ? { status: 'pass', message: 'Authentication found on real-time connections' }
92
+ : { status: 'warn', message: 'No authentication detected on real-time channels \u2014 anyone can connect' }
93
+ );
94
+
95
+ return results;
96
+ }
@@ -0,0 +1,70 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { glob } from 'glob';
4
+ import type { CheckResult } from '../scan';
5
+
6
+ const DANGEROUS_PATTERNS = [
7
+ { pattern: /supabase.*service_role/i, label: 'Supabase service role key' },
8
+ { pattern: /sk-[a-zA-Z0-9]{20,}/, label: 'OpenAI API key' },
9
+ { pattern: /STRIPE_SECRET_KEY/, label: 'Stripe secret key' },
10
+ { pattern: /ANTHROPIC_API_KEY/, label: 'Anthropic API key' },
11
+ { pattern: /Consumer_Secret/i, label: 'M-Pesa consumer secret' },
12
+ ];
13
+
14
+ const CLIENT_SIDE_DIRS = ['components', 'app', 'pages', 'src/app', 'src/pages', 'src/components', 'src/lib'];
15
+
16
+ export async function runSecurityChecks(dir: string): Promise<CheckResult[]> {
17
+ const results: CheckResult[] = [];
18
+
19
+ const gitignorePath = path.join(dir, '.gitignore');
20
+ if (fs.existsSync(gitignorePath)) {
21
+ const gitignore = fs.readFileSync(gitignorePath, 'utf-8');
22
+ if (gitignore.includes('.env')) {
23
+ results.push({ status: 'pass', message: '.env is gitignored' });
24
+ } else {
25
+ results.push({ status: 'fail', message: '.env is NOT gitignored \u2014 your secrets will be committed' });
26
+ }
27
+ } else {
28
+ results.push({ status: 'warn', message: 'No .gitignore found' });
29
+ }
30
+
31
+ const envExamplePath = path.join(dir, '.env.example');
32
+ if (fs.existsSync(envExamplePath)) {
33
+ results.push({ status: 'pass', message: '.env.example exists' });
34
+ } else {
35
+ results.push({ status: 'warn', message: 'No .env.example \u2014 collaborators won\'t know what vars to set' });
36
+ }
37
+
38
+ const clientFiles: string[] = [];
39
+ for (const clientDir of CLIENT_SIDE_DIRS) {
40
+ const fullDir = path.join(dir, clientDir);
41
+ if (fs.existsSync(fullDir)) {
42
+ const files = await glob(`${fullDir}/**/*.{ts,tsx,js,jsx}`, { ignore: ['**/node_modules/**'] });
43
+ clientFiles.push(...files);
44
+ }
45
+ }
46
+
47
+ for (const file of clientFiles) {
48
+ const content = fs.readFileSync(file, 'utf-8');
49
+ const lines = content.split('\n');
50
+ for (const { pattern, label } of DANGEROUS_PATTERNS) {
51
+ for (let i = 0; i < lines.length; i++) {
52
+ if (pattern.test(lines[i]) && !lines[i].trim().startsWith('//') && !lines[i].trim().startsWith('*')) {
53
+ const relativePath = path.relative(dir, file);
54
+ results.push({
55
+ status: 'fail',
56
+ message: `${label} found in client-side code`,
57
+ file: relativePath,
58
+ line: i + 1,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ if (clientFiles.length > 0 && !results.some(r => r.status === 'fail' && r.message.includes('found in client-side'))) {
66
+ results.push({ status: 'pass', message: 'No dangerous secrets found in client-side code' });
67
+ }
68
+
69
+ return results;
70
+ }
@@ -0,0 +1,196 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { glob } from 'glob';
4
+ import type { CheckResult } from '../scan';
5
+
6
+ export async function runVulnerabilityChecks(dir: string): Promise<CheckResult[]> {
7
+ const results: CheckResult[] = [];
8
+
9
+ const allTsFiles = await glob(`${dir}/src/**/*.{ts,tsx,js,jsx}`, { ignore: ['**/node_modules/**', '**/.next/**'] });
10
+ const allFiles = await glob(`${dir}/**/*.{ts,tsx,js,jsx,json,env,yml,yaml,config.*}`, { ignore: ['**/node_modules/**', '**/.next/**', '**/dist/**'] });
11
+
12
+ // --- 1. XSS: dangerouslySetInnerHTML / innerHTML ---
13
+ let hasUnsafeHTML = false;
14
+ let unsafeHTMLFile: string | undefined;
15
+
16
+ for (const file of allTsFiles) {
17
+ const content = fs.readFileSync(file, 'utf-8');
18
+ if (content.includes('dangerouslySetInnerHTML') || content.includes('.innerHTML =')) {
19
+ hasUnsafeHTML = true;
20
+ unsafeHTMLFile = path.relative(dir, file);
21
+ break;
22
+ }
23
+ }
24
+
25
+ results.push(
26
+ hasUnsafeHTML
27
+ ? { status: 'warn', message: 'dangerouslySetInnerHTML or innerHTML assignment found \u2014 potential XSS risk', file: unsafeHTMLFile }
28
+ : { status: 'pass', message: 'No dangerouslySetInnerHTML or innerHTML assignments found' }
29
+ );
30
+
31
+ // --- 2. eval() usage ---
32
+ let hasEval = false;
33
+ let evalFile: string | undefined;
34
+
35
+ for (const file of allTsFiles) {
36
+ const content = fs.readFileSync(file, 'utf-8');
37
+ const lines = content.split('\n');
38
+ for (let i = 0; i < lines.length; i++) {
39
+ const trimmed = lines[i].trim();
40
+ if (/eval\s*\(/.test(trimmed) && !trimmed.startsWith('//') && !trimmed.startsWith('*')) {
41
+ hasEval = true;
42
+ evalFile = path.relative(dir, file);
43
+ break;
44
+ }
45
+ }
46
+ if (hasEval) break;
47
+ }
48
+
49
+ results.push(
50
+ hasEval
51
+ ? { status: 'fail', message: 'eval() usage detected \u2014 arbitrary code execution risk', file: evalFile }
52
+ : { status: 'pass', message: 'No eval() usage detected' }
53
+ );
54
+
55
+ // --- 3. Sniffing protection: X-Content-Type-Options ---
56
+ const configFiles = [
57
+ 'next.config.ts', 'next.config.js', 'next.config.mjs',
58
+ '.htaccess', 'nginx.conf', 'web.config',
59
+ ];
60
+
61
+ let hasNosniff = false;
62
+ let nosniffFile: string | undefined;
63
+
64
+ for (const cfg of configFiles) {
65
+ const cfgPath = path.join(dir, cfg);
66
+ if (fs.existsSync(cfgPath)) {
67
+ const content = fs.readFileSync(cfgPath, 'utf-8');
68
+ if (content.includes('nosniff') || content.includes('X-Content-Type-Options')) {
69
+ hasNosniff = true;
70
+ nosniffFile = cfg;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+
76
+ results.push(
77
+ hasNosniff
78
+ ? { status: 'pass', message: 'X-Content-Type-Options: nosniff found \u2014 MIME sniffing protection enabled', file: nosniffFile }
79
+ : { status: 'warn', message: 'No X-Content-Type-Options: nosniff found \u2014 browser may MIME-sniff responses' }
80
+ );
81
+
82
+ // --- 4. HSTS ---
83
+ let hasHSTS = false;
84
+ let hstsFile: string | undefined;
85
+
86
+ for (const cfg of configFiles) {
87
+ const cfgPath = path.join(dir, cfg);
88
+ if (fs.existsSync(cfgPath)) {
89
+ const content = fs.readFileSync(cfgPath, 'utf-8');
90
+ if (content.includes('Strict-Transport-Security') || content.includes('HSTS') || content.includes('hsts')) {
91
+ hasHSTS = true;
92
+ hstsFile = cfg;
93
+ break;
94
+ }
95
+ }
96
+ }
97
+
98
+ results.push(
99
+ hasHSTS
100
+ ? { status: 'pass', message: 'HSTS (Strict-Transport-Security) configured', file: hstsFile }
101
+ : { status: 'warn', message: 'No HSTS header found \u2014 users may connect over HTTP instead of HTTPS' }
102
+ );
103
+
104
+ // --- 5. Mixed content: http:// URLs in source ---
105
+ let hasMixedContent = false;
106
+ const mixedContentFiles: string[] = [];
107
+
108
+ for (const file of allTsFiles) {
109
+ const content = fs.readFileSync(file, 'utf-8');
110
+ const httpMatches = content.match(/https?:\/\/(?!localhost)(?!127\.0\.0\.1)(?![\w.-]*\.\w{2,}\/)[^\s"'`)*]+/g);
111
+ if (httpMatches) {
112
+ const insecureUrls = httpMatches.filter(u => u.startsWith('http://'));
113
+ if (insecureUrls.length > 0) {
114
+ hasMixedContent = true;
115
+ mixedContentFiles.push(path.relative(dir, file));
116
+ }
117
+ }
118
+ }
119
+
120
+ results.push(
121
+ hasMixedContent
122
+ ? { status: 'warn', message: `http:// URLs found in ${mixedContentFiles.length} file(s) \u2014 mixed content vulnerability`, file: mixedContentFiles[0] }
123
+ : { status: 'pass', message: 'No mixed content (http:// URLs) detected in source' }
124
+ );
125
+
126
+ // --- 6. CSRF protection ---
127
+ const csrfPatterns = [
128
+ 'csrf', 'CSRF', 'csrfToken', 'xsrf', 'XSRF',
129
+ 'SameSite', 'sameSite', 'same-site',
130
+ 'doubleSubmit', 'csrfProtection',
131
+ ];
132
+
133
+ let hasCSRF = false;
134
+ let csrfFile: string | undefined;
135
+
136
+ for (const file of allTsFiles) {
137
+ const content = fs.readFileSync(file, 'utf-8');
138
+ for (const pattern of csrfPatterns) {
139
+ if (content.includes(pattern)) {
140
+ hasCSRF = true;
141
+ csrfFile = path.relative(dir, file);
142
+ break;
143
+ }
144
+ }
145
+ if (hasCSRF) break;
146
+ }
147
+
148
+ results.push(
149
+ hasCSRF
150
+ ? { status: 'pass', message: 'CSRF protection detected (token or SameSite cookie)', file: csrfFile }
151
+ : { status: 'warn', message: 'No CSRF protection detected \u2014 forms and API mutations may be vulnerable to cross-site requests' }
152
+ );
153
+
154
+ // --- 7. Debug mode / information disclosure ---
155
+ let hasDebugLeak = false;
156
+ let debugFile: string | undefined;
157
+
158
+ for (const file of allTsFiles) {
159
+ const content = fs.readFileSync(file, 'utf-8');
160
+ const lines = content.split('\n');
161
+ for (let i = 0; i < lines.length; i++) {
162
+ const line = lines[i];
163
+ if (
164
+ (line.includes('debug: true') || line.includes('debug = true') || line.includes('isDebug = true')) &&
165
+ !line.trim().startsWith('//')
166
+ ) {
167
+ hasDebugLeak = true;
168
+ debugFile = path.relative(dir, file);
169
+ break;
170
+ }
171
+ }
172
+ if (hasDebugLeak) break;
173
+ }
174
+
175
+ results.push(
176
+ hasDebugLeak
177
+ ? { status: 'warn', message: 'Debug mode enabled in source \u2014 may expose sensitive info in production', file: debugFile }
178
+ : { status: 'pass', message: 'No debug mode flags detected in source' }
179
+ );
180
+
181
+ // --- 8. Check .gitignore for common build artifacts ---
182
+ const gitignorePath = path.join(dir, '.gitignore');
183
+ if (fs.existsSync(gitignorePath)) {
184
+ const gitignore = fs.readFileSync(gitignorePath, 'utf-8');
185
+ const expectedEntries = ['.next', 'dist', 'build', '.env.local', 'coverage', '.turbo'];
186
+ const missing = expectedEntries.filter(e => !gitignore.includes(e));
187
+
188
+ if (missing.length === 0) {
189
+ results.push({ status: 'pass', message: 'All common build artifacts are gitignored (.next, dist, build, .env.local)' });
190
+ } else {
191
+ results.push({ status: 'warn', message: `Build artifacts not gitignored: ${missing.join(', ')} \u2014 they may leak in the repo` });
192
+ }
193
+ }
194
+
195
+ return results;
196
+ }
@@ -0,0 +1,65 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { glob } from 'glob';
4
+ import type { CheckResult } from '../scan';
5
+
6
+ export async function runWebChecks(dir: string): Promise<CheckResult[]> {
7
+ const results: CheckResult[] = [];
8
+
9
+ const nextConfigPaths = [
10
+ path.join(dir, 'next.config.ts'),
11
+ path.join(dir, 'next.config.js'),
12
+ path.join(dir, 'next.config.mjs'),
13
+ ];
14
+
15
+ const existingConfigs = nextConfigPaths.filter(fs.existsSync);
16
+
17
+ if (existingConfigs.length > 0) {
18
+ const content = fs.readFileSync(existingConfigs[0], 'utf-8');
19
+ const hasCSP = content.includes('Content-Security-Policy') || content.includes('csp');
20
+ const hasFrameOptions = content.includes('X-Frame-Options') || content.includes('frame-ancestors');
21
+ const hasCORS = content.includes('cors') || content.includes('CORS') || content.includes('Access-Control');
22
+
23
+ if (hasCSP) {
24
+ results.push({ status: 'pass', message: 'Content Security Policy (CSP) configured', file: path.relative(dir, existingConfigs[0]) });
25
+ } else {
26
+ results.push({ status: 'warn', message: 'No Content Security Policy (CSP) found \u2014 your app is vulnerable to XSS' });
27
+ }
28
+
29
+ if (hasFrameOptions) {
30
+ results.push({ status: 'pass', message: 'Clickjacking protection found (X-Frame-Options / frame-ancestors)' });
31
+ } else {
32
+ results.push({ status: 'warn', message: 'No clickjacking protection \u2014 your app can be embedded in iframes' });
33
+ }
34
+
35
+ if (hasCORS) {
36
+ results.push({ status: 'pass', message: 'CORS configuration detected' });
37
+ } else {
38
+ results.push({ status: 'warn', message: 'No explicit CORS configuration found' });
39
+ }
40
+ } else {
41
+ results.push({ status: 'warn', message: 'No Next.js config found \u2014 skipping security header checks' });
42
+ }
43
+
44
+ const cookieFiles = await glob(`${dir}/**/*.ts`, { ignore: ['**/node_modules/**', '**/.next/**'] });
45
+ let hasSecureCookies = false;
46
+ let cookieFile: string | undefined;
47
+
48
+ for (const file of cookieFiles) {
49
+ const content = fs.readFileSync(file, 'utf-8');
50
+ const hasCookie = content.includes('cookie') || content.includes('Cookie') || content.includes('cookies');
51
+ if (hasCookie && (content.includes('httpOnly') || content.includes('secure') || content.includes('sameSite'))) {
52
+ hasSecureCookies = true;
53
+ cookieFile = path.relative(dir, file);
54
+ break;
55
+ }
56
+ }
57
+
58
+ results.push(
59
+ hasSecureCookies
60
+ ? { status: 'pass', message: 'Cookie security flags (httpOnly/secure/sameSite) detected', file: cookieFile }
61
+ : { status: 'warn', message: 'No secure cookie flags detected \u2014 cookies may be accessible to JavaScript' }
62
+ );
63
+
64
+ return results;
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { runScan } from './scan';
5
+
6
+ const program = new Command();
7
+
8
+ program
9
+ .name('preflight')
10
+ .description('Pre-deploy checklist CLI for vibe coders')
11
+ .version('0.1.0');
12
+
13
+ program
14
+ .command('scan [dir]')
15
+ .description('Scan a project for common pre-deploy issues')
16
+ .option('--json', 'Output results as JSON')
17
+ .option('--strict', 'Exit with code 1 if any checks fail')
18
+ .option('--only <category>', 'Run only one category: security|auth|payments|database|api|web|graphql|realtime|vulnerabilities')
19
+ .action(async (dir: string = '.', options) => {
20
+ await runScan(dir, options);
21
+ });
22
+
23
+ program.parse();
@@ -0,0 +1,47 @@
1
+ import chalk from 'chalk';
2
+ import type { CategoryResult, CheckResult } from './scan';
3
+
4
+ function icon(status: CheckResult['status']) {
5
+ if (status === 'pass') return chalk.green('\u2705');
6
+ if (status === 'fail') return chalk.red('\u274C');
7
+ return chalk.yellow('\u26A0\uFE0F');
8
+ }
9
+
10
+ function score(categories: CategoryResult[]) {
11
+ const all = categories.flatMap((c) => c.checks);
12
+ const passed = all.filter((c) => c.status === 'pass').length;
13
+ return { passed, total: all.length };
14
+ }
15
+
16
+ export function renderReport(dir: string, categories: CategoryResult[]) {
17
+ console.log('');
18
+ console.log(chalk.bold('\u{1F6EB} Agent Preflight \u2014 Pre-Deploy Scan'));
19
+ console.log(chalk.dim(`Scanning: ${dir}`));
20
+ console.log('');
21
+
22
+ for (const category of categories) {
23
+ console.log(chalk.bold(` ${category.name}`));
24
+ for (const check of category.checks) {
25
+ const location =
26
+ check.file
27
+ ? chalk.dim(` (${check.file}${check.line ? `:${check.line}` : ''})`)
28
+ : '';
29
+ console.log(` ${icon(check.status)} ${check.message}${location}`);
30
+ }
31
+ console.log('');
32
+ }
33
+
34
+ const { passed, total } = score(categories);
35
+ const ratio = `${passed}/${total}`;
36
+ const fails = categories.flatMap((c) => c.checks).filter((c) => c.status === 'fail');
37
+
38
+ if (fails.length === 0) {
39
+ console.log(chalk.green(`Score: ${ratio} \u2014 All clear. Ready to deploy. \u{1F680}`));
40
+ } else {
41
+ console.log(
42
+ chalk.red(`Score: ${ratio} \u2014 Fix ${fails.length} critical issue${fails.length > 1 ? 's' : ''} before deploying.`)
43
+ );
44
+ }
45
+
46
+ console.log('');
47
+ }