getdoorman 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/bin/doorman.js +444 -0
- package/package.json +74 -0
- package/src/ai-fixer.js +559 -0
- package/src/ast-scanner.js +434 -0
- package/src/auth.js +149 -0
- package/src/baseline.js +48 -0
- package/src/compliance.js +539 -0
- package/src/config.js +466 -0
- package/src/custom-rules.js +32 -0
- package/src/dashboard.js +202 -0
- package/src/detector.js +142 -0
- package/src/fix-engine.js +48 -0
- package/src/fix-registry-extra.js +95 -0
- package/src/fix-registry-go-rust.js +77 -0
- package/src/fix-registry-java-csharp.js +77 -0
- package/src/fix-registry-js.js +99 -0
- package/src/fix-registry-mcp-ai.js +57 -0
- package/src/fix-registry-python.js +87 -0
- package/src/fixer-ruby-php.js +608 -0
- package/src/fixer.js +2113 -0
- package/src/hooks.js +115 -0
- package/src/ignore.js +176 -0
- package/src/index.js +384 -0
- package/src/metrics.js +126 -0
- package/src/monorepo.js +65 -0
- package/src/presets.js +54 -0
- package/src/reporter.js +975 -0
- package/src/rule-worker.js +36 -0
- package/src/rules/ast-rules.js +756 -0
- package/src/rules/bugs/accessibility.js +235 -0
- package/src/rules/bugs/ai-codegen-fixable.js +172 -0
- package/src/rules/bugs/ai-codegen.js +365 -0
- package/src/rules/bugs/code-smell-bugs.js +247 -0
- package/src/rules/bugs/crypto-bugs.js +195 -0
- package/src/rules/bugs/docker-bugs.js +158 -0
- package/src/rules/bugs/general.js +361 -0
- package/src/rules/bugs/go-bugs.js +279 -0
- package/src/rules/bugs/index.js +73 -0
- package/src/rules/bugs/js-api.js +257 -0
- package/src/rules/bugs/js-array-object.js +210 -0
- package/src/rules/bugs/js-async-fixable.js +223 -0
- package/src/rules/bugs/js-async.js +211 -0
- package/src/rules/bugs/js-closure-scope.js +182 -0
- package/src/rules/bugs/js-database.js +203 -0
- package/src/rules/bugs/js-error-handling.js +148 -0
- package/src/rules/bugs/js-logic.js +261 -0
- package/src/rules/bugs/js-memory.js +214 -0
- package/src/rules/bugs/js-node.js +361 -0
- package/src/rules/bugs/js-react.js +373 -0
- package/src/rules/bugs/js-regex.js +200 -0
- package/src/rules/bugs/js-state.js +272 -0
- package/src/rules/bugs/js-type-coercion.js +318 -0
- package/src/rules/bugs/nextjs-bugs.js +242 -0
- package/src/rules/bugs/nextjs-fixable.js +120 -0
- package/src/rules/bugs/node-fixable.js +178 -0
- package/src/rules/bugs/python-advanced.js +245 -0
- package/src/rules/bugs/python-fixable.js +98 -0
- package/src/rules/bugs/python.js +284 -0
- package/src/rules/bugs/react-fixable.js +207 -0
- package/src/rules/bugs/ruby-bugs.js +182 -0
- package/src/rules/bugs/shell-bugs.js +181 -0
- package/src/rules/bugs/silent-failures.js +261 -0
- package/src/rules/bugs/ts-bugs.js +235 -0
- package/src/rules/bugs/unused-vars.js +65 -0
- package/src/rules/compliance/accessibility-ext.js +468 -0
- package/src/rules/compliance/education.js +322 -0
- package/src/rules/compliance/financial.js +421 -0
- package/src/rules/compliance/frameworks.js +507 -0
- package/src/rules/compliance/healthcare.js +520 -0
- package/src/rules/compliance/index.js +2714 -0
- package/src/rules/compliance/regional-eu.js +480 -0
- package/src/rules/compliance/regional-international.js +903 -0
- package/src/rules/cost/index.js +1993 -0
- package/src/rules/data/index.js +2503 -0
- package/src/rules/dependencies/index.js +1684 -0
- package/src/rules/deployment/index.js +2050 -0
- package/src/rules/index.js +71 -0
- package/src/rules/infrastructure/index.js +3048 -0
- package/src/rules/performance/index.js +3455 -0
- package/src/rules/quality/index.js +3175 -0
- package/src/rules/reliability/index.js +3040 -0
- package/src/rules/scope-rules.js +815 -0
- package/src/rules/security/ai-api.js +1177 -0
- package/src/rules/security/auth.js +1328 -0
- package/src/rules/security/cors.js +127 -0
- package/src/rules/security/crypto.js +527 -0
- package/src/rules/security/csharp.js +862 -0
- package/src/rules/security/csrf.js +193 -0
- package/src/rules/security/dart.js +835 -0
- package/src/rules/security/deserialization.js +291 -0
- package/src/rules/security/file-upload.js +187 -0
- package/src/rules/security/go.js +850 -0
- package/src/rules/security/headers.js +235 -0
- package/src/rules/security/index.js +65 -0
- package/src/rules/security/injection.js +1639 -0
- package/src/rules/security/mcp-server.js +71 -0
- package/src/rules/security/misconfiguration.js +660 -0
- package/src/rules/security/oauth-jwt.js +329 -0
- package/src/rules/security/path-traversal.js +295 -0
- package/src/rules/security/php.js +1054 -0
- package/src/rules/security/prototype-pollution.js +283 -0
- package/src/rules/security/rate-limiting.js +208 -0
- package/src/rules/security/ruby.js +1061 -0
- package/src/rules/security/rust.js +693 -0
- package/src/rules/security/secrets.js +747 -0
- package/src/rules/security/shell.js +647 -0
- package/src/rules/security/ssrf.js +298 -0
- package/src/rules/security/supply-chain-advanced.js +393 -0
- package/src/rules/security/supply-chain.js +734 -0
- package/src/rules/security/swift.js +835 -0
- package/src/rules/security/taint.js +27 -0
- package/src/rules/security/xss.js +520 -0
- package/src/scan-cache.js +71 -0
- package/src/scanner.js +710 -0
- package/src/scope-analyzer.js +685 -0
- package/src/share.js +88 -0
- package/src/taint.js +300 -0
- package/src/telemetry.js +183 -0
- package/src/tracer.js +190 -0
- package/src/upload.js +35 -0
- package/src/worker.js +31 -0
|
@@ -0,0 +1,1993 @@
|
|
|
1
|
+
const JS_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'];
|
|
2
|
+
function isSourceFile(f) { return JS_EXTENSIONS.some(ext => f.endsWith(ext)); }
|
|
3
|
+
|
|
4
|
+
const rules = [
|
|
5
|
+
// COST-001: AI API calls without caching
|
|
6
|
+
{
|
|
7
|
+
id: 'COST-001',
|
|
8
|
+
category: 'cost',
|
|
9
|
+
severity: 'high',
|
|
10
|
+
confidence: 'likely',
|
|
11
|
+
title: 'AI API Calls Without Caching',
|
|
12
|
+
check({ files }) {
|
|
13
|
+
const findings = [];
|
|
14
|
+
for (const [filepath, content] of files) {
|
|
15
|
+
if (!isSourceFile(filepath)) continue;
|
|
16
|
+
|
|
17
|
+
const hasAICall = content.match(/(?:openai|anthropic|chat\.completions|messages\.create|generateText)/i);
|
|
18
|
+
if (!hasAICall) continue;
|
|
19
|
+
|
|
20
|
+
// Check if it's in a request handler without caching
|
|
21
|
+
if ((filepath.includes('api/') || filepath.includes('route') || filepath.includes('handler')) &&
|
|
22
|
+
!content.includes('cache') && !content.includes('redis') && !content.includes('memoize')) {
|
|
23
|
+
findings.push({
|
|
24
|
+
ruleId: 'COST-001', category: 'cost', severity: 'high',
|
|
25
|
+
title: 'AI/LLM API call in request handler without caching',
|
|
26
|
+
description: 'Each request triggers a paid API call. Cache identical responses to reduce costs.',
|
|
27
|
+
file: filepath, fix: null,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return findings;
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// COST-002: AI call on every page load
|
|
36
|
+
{
|
|
37
|
+
id: 'COST-002',
|
|
38
|
+
category: 'cost',
|
|
39
|
+
severity: 'high',
|
|
40
|
+
confidence: 'likely',
|
|
41
|
+
title: 'AI API Call on Page Load',
|
|
42
|
+
check({ files }) {
|
|
43
|
+
const findings = [];
|
|
44
|
+
for (const [filepath, content] of files) {
|
|
45
|
+
if (!filepath.match(/\.(jsx|tsx)$/)) continue;
|
|
46
|
+
|
|
47
|
+
// AI call inside a component or useEffect without caching
|
|
48
|
+
if (content.match(/(?:openai|anthropic|chat\.completions|messages\.create|generateText)/i)) {
|
|
49
|
+
if (content.includes('useEffect') || content.includes('getServerSideProps') || content.includes('loader')) {
|
|
50
|
+
findings.push({
|
|
51
|
+
ruleId: 'COST-002', category: 'cost', severity: 'high',
|
|
52
|
+
title: 'AI API call runs on every page load — very expensive at scale',
|
|
53
|
+
description: 'At 1,000 daily users, this could cost $100+/day. Cache results or pre-generate content.',
|
|
54
|
+
file: filepath, fix: null,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return findings;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// COST-003: No token limit on AI calls
|
|
64
|
+
{
|
|
65
|
+
id: 'COST-003',
|
|
66
|
+
category: 'cost',
|
|
67
|
+
severity: 'medium',
|
|
68
|
+
confidence: 'suggestion',
|
|
69
|
+
title: 'No Token Limit on AI API Call',
|
|
70
|
+
check({ files }) {
|
|
71
|
+
const findings = [];
|
|
72
|
+
for (const [filepath, content] of files) {
|
|
73
|
+
if (!isSourceFile(filepath)) continue;
|
|
74
|
+
|
|
75
|
+
if (content.match(/(?:chat\.completions\.create|messages\.create)/)) {
|
|
76
|
+
if (!content.includes('max_tokens') && !content.includes('maxTokens')) {
|
|
77
|
+
findings.push({
|
|
78
|
+
ruleId: 'COST-003', category: 'cost', severity: 'medium',
|
|
79
|
+
title: 'AI API call without max_tokens limit',
|
|
80
|
+
description: 'Without a token limit, a single request could generate a very long (expensive) response.',
|
|
81
|
+
file: filepath, fix: null,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return findings;
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// COST-004: Embedding generated on every request
|
|
91
|
+
{
|
|
92
|
+
id: 'COST-004',
|
|
93
|
+
category: 'cost',
|
|
94
|
+
severity: 'medium',
|
|
95
|
+
confidence: 'suggestion',
|
|
96
|
+
title: 'Embeddings Generated Per-Request',
|
|
97
|
+
check({ files }) {
|
|
98
|
+
const findings = [];
|
|
99
|
+
for (const [filepath, content] of files) {
|
|
100
|
+
if (!isSourceFile(filepath)) continue;
|
|
101
|
+
if (content.match(/(?:embeddings\.create|embed|createEmbedding)/) &&
|
|
102
|
+
(filepath.includes('api/') || filepath.includes('route'))) {
|
|
103
|
+
if (!content.includes('cache') && !content.includes('stored')) {
|
|
104
|
+
findings.push({
|
|
105
|
+
ruleId: 'COST-004', category: 'cost', severity: 'medium',
|
|
106
|
+
title: 'Embeddings generated per-request instead of pre-computed',
|
|
107
|
+
description: 'Pre-compute and store embeddings instead of generating them on every request.',
|
|
108
|
+
file: filepath, fix: null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return findings;
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
// COST-005: CloudWatch logs without retention policy
|
|
118
|
+
{ id: 'COST-005', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'CloudWatch Logs Without Retention Policy',
|
|
119
|
+
check({ files }) {
|
|
120
|
+
const findings = [];
|
|
121
|
+
for (const [fp, c] of files) {
|
|
122
|
+
if (!fp.match(/\.(json|ya?ml|tf)$/)) continue;
|
|
123
|
+
if (c.match(/aws.*logs|cloudwatch.*logs|log.*group/i) && !c.match(/retention|retentionInDays/i)) {
|
|
124
|
+
findings.push({ ruleId: 'COST-005', category: 'cost', severity: 'medium',
|
|
125
|
+
title: 'CloudWatch log group without retention policy — logs accumulate forever',
|
|
126
|
+
description: 'Set retentionInDays (e.g., 30 for dev, 90 for prod) to avoid unbounded log storage costs.', file: fp, fix: null });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return findings;
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// COST-006: No multi-stage Docker build
|
|
134
|
+
{ id: 'COST-006', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'No Multi-Stage Docker Build',
|
|
135
|
+
check({ files }) {
|
|
136
|
+
const findings = [];
|
|
137
|
+
for (const [fp, c] of files) {
|
|
138
|
+
if (!fp.endsWith('Dockerfile') && !fp.match(/Dockerfile\.\w+$/)) continue;
|
|
139
|
+
const fromCount = (c.match(/^\s*FROM\s+/gim) || []).length;
|
|
140
|
+
if (fromCount < 2) {
|
|
141
|
+
findings.push({ ruleId: 'COST-006', category: 'cost', severity: 'medium',
|
|
142
|
+
title: 'Single-stage Dockerfile includes dev dependencies in production image',
|
|
143
|
+
description: 'Multi-stage builds (FROM node AS build ... FROM node:alpine) reduce image size 50-80%, lowering storage and transfer costs.', file: fp, fix: null });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return findings;
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
// COST-007: No dependency cache in CI
|
|
151
|
+
{ id: 'COST-007', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'No Dependency Cache in CI',
|
|
152
|
+
check({ files }) {
|
|
153
|
+
const findings = [];
|
|
154
|
+
for (const [fp, c] of files) {
|
|
155
|
+
if (!fp.includes('.github/workflows') && !fp.includes('.gitlab-ci')) continue;
|
|
156
|
+
if ((c.includes('npm ci') || c.includes('npm install') || c.includes('yarn install')) &&
|
|
157
|
+
!c.match(/cache.*node_modules|actions\/cache|cache-dependency-path/)) {
|
|
158
|
+
findings.push({ ruleId: 'COST-007', category: 'cost', severity: 'medium',
|
|
159
|
+
title: 'CI pipeline installs dependencies without caching',
|
|
160
|
+
description: 'Add actions/cache for node_modules to cut CI time and runner costs by 50%+.', file: fp, fix: null });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return findings;
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
// COST-008: No AI token usage tracking
|
|
168
|
+
{ id: 'COST-008', category: 'cost', severity: 'high', confidence: 'likely', title: 'No AI Token Usage Tracking',
|
|
169
|
+
check({ files }) {
|
|
170
|
+
const findings = [];
|
|
171
|
+
for (const [fp, c] of files) {
|
|
172
|
+
if (!isSourceFile(fp)) continue;
|
|
173
|
+
if (c.match(/openai|anthropic|chat\.completions|messages\.create/i)) {
|
|
174
|
+
if (!c.match(/usage\.|input_tokens|output_tokens|total_tokens|promptTokens/)) {
|
|
175
|
+
findings.push({ ruleId: 'COST-008', category: 'cost', severity: 'high',
|
|
176
|
+
title: 'AI API calls without tracking token usage',
|
|
177
|
+
description: 'Log usage.input_tokens and usage.output_tokens from AI responses to track per-request costs and identify expensive prompts.', file: fp, fix: null });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return findings;
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
// COST-009: Most expensive model hardcoded
|
|
186
|
+
{ id: 'COST-009', category: 'cost', severity: 'high', confidence: 'likely', title: 'Most Expensive AI Model Hardcoded',
|
|
187
|
+
check({ files }) {
|
|
188
|
+
const findings = [];
|
|
189
|
+
for (const [fp, c] of files) {
|
|
190
|
+
if (!isSourceFile(fp)) continue;
|
|
191
|
+
const lines = c.split('\n');
|
|
192
|
+
for (let i = 0; i < lines.length; i++) {
|
|
193
|
+
if (lines[i].match(/model\s*:\s*['"`](?:gpt-4(?!o-mini|-mini)|claude-opus-4(?!-5))/)) {
|
|
194
|
+
findings.push({ ruleId: 'COST-009', category: 'cost', severity: 'high',
|
|
195
|
+
title: 'Most expensive AI model hardcoded — cheaper alternatives available',
|
|
196
|
+
description: 'Route simple tasks to cheaper models (gpt-4o-mini, claude-haiku). Premium models cost 10-20x more.', file: fp, line: i + 1, fix: null });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return findings;
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
// COST-010: No S3 lifecycle policy
|
|
205
|
+
{ id: 'COST-010', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'No S3 Lifecycle Policy',
|
|
206
|
+
check({ files }) {
|
|
207
|
+
const findings = [];
|
|
208
|
+
for (const [fp, c] of files) {
|
|
209
|
+
if (c.match(/s3.*bucket|aws.*s3/i) && !c.match(/lifecycle|intelligent.tiering|StorageClass/i)) {
|
|
210
|
+
findings.push({ ruleId: 'COST-010', category: 'cost', severity: 'medium',
|
|
211
|
+
title: 'S3 bucket without lifecycle policy — old objects stored at full price forever',
|
|
212
|
+
description: 'Add lifecycle rules to transition old objects to S3-IA/Glacier after 30/90 days. Can reduce storage costs by 70%+.', file: fp, fix: null });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return findings;
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// COST-011: Polling instead of webhooks
|
|
220
|
+
{ id: 'COST-011', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Polling Instead of Webhooks/SSE',
|
|
221
|
+
check({ files }) {
|
|
222
|
+
const findings = [];
|
|
223
|
+
for (const [fp, c] of files) {
|
|
224
|
+
if (!isSourceFile(fp)) continue;
|
|
225
|
+
if (c.match(/setInterval\s*\(.*(?:fetch|axios|http\.get)/)) {
|
|
226
|
+
findings.push({ ruleId: 'COST-011', category: 'cost', severity: 'medium',
|
|
227
|
+
title: 'Polling external API with setInterval — use webhooks or SSE',
|
|
228
|
+
description: 'Polling makes API calls even when nothing changes. Webhooks (push) eliminate unnecessary requests and their associated costs.', file: fp, fix: null });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return findings;
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// COST-012: No cost budget alerts
|
|
236
|
+
{ id: 'COST-012', category: 'cost', severity: 'high', confidence: 'likely', title: 'No Cloud Cost Budget Alerts',
|
|
237
|
+
check({ files }) {
|
|
238
|
+
const has = [...files.values()].some(c => c.match(/budget.*alert|billing.*alert|cost.*anomaly/i));
|
|
239
|
+
if (!has && [...files.values()].some(c => c.match(/\baws\b|\bgcp\b|\bazure\b/i))) {
|
|
240
|
+
return [{ ruleId: 'COST-012', category: 'cost', severity: 'high',
|
|
241
|
+
title: 'No cloud cost budget alerts configured',
|
|
242
|
+
description: 'Set AWS Budgets or GCP Budget Alerts to notify you before costs spike. Unexpected bills of $10k+ are common without alerts.', fix: null }];
|
|
243
|
+
}
|
|
244
|
+
return [];
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
// COST-013: No prompt caching for repeated system prompts
|
|
249
|
+
{ id: 'COST-013', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'AI Prompt Caching Not Used',
|
|
250
|
+
check({ files }) {
|
|
251
|
+
const findings = [];
|
|
252
|
+
for (const [fp, c] of files) {
|
|
253
|
+
if (!isSourceFile(fp)) continue;
|
|
254
|
+
if (c.match(/anthropic/) && c.match(/system.*prompt|systemPrompt/i)) {
|
|
255
|
+
if (!c.match(/cache_control|cacheControl/)) {
|
|
256
|
+
findings.push({ ruleId: 'COST-013', category: 'cost', severity: 'medium',
|
|
257
|
+
title: 'Anthropic API without prompt caching on long system prompts',
|
|
258
|
+
description: 'Add cache_control: { type: "ephemeral" } to system prompts. Prompt caching reduces costs 90% for repeated requests.', file: fp, fix: null });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return findings;
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
// COST-014: Images served without optimization
|
|
267
|
+
{ id: 'COST-014', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Images Without Optimization Pipeline',
|
|
268
|
+
check({ files, stack }) {
|
|
269
|
+
const findings = [];
|
|
270
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
271
|
+
const hasOpt = 'sharp' in allDeps || 'imagemin' in allDeps || stack.framework === 'nextjs';
|
|
272
|
+
const hasImages = [...files.keys()].some(f => f.match(/\.(jpg|jpeg|png)$/i));
|
|
273
|
+
if (hasImages && !hasOpt) {
|
|
274
|
+
findings.push({ ruleId: 'COST-014', category: 'cost', severity: 'medium',
|
|
275
|
+
title: 'No image optimization pipeline — serving full-size originals wastes bandwidth',
|
|
276
|
+
description: 'Use sharp or Next.js Image to compress and serve WebP. Unoptimized images can be 5-10x larger, multiplying CDN/bandwidth costs.', fix: null });
|
|
277
|
+
}
|
|
278
|
+
return findings;
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
// COST-015: Verbose debug logging in production
|
|
283
|
+
{ id: 'COST-015', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Verbose Debug Logging in Production Code',
|
|
284
|
+
check({ files }) {
|
|
285
|
+
const findings = [];
|
|
286
|
+
for (const [fp, c] of files) {
|
|
287
|
+
if (!isSourceFile(fp) || fp.includes('test') || fp.includes('spec')) continue;
|
|
288
|
+
const count = (c.match(/console\.debug|logger\.debug|log\.debug/g) || []).length;
|
|
289
|
+
if (count > 5) {
|
|
290
|
+
findings.push({ ruleId: 'COST-015', category: 'cost', severity: 'medium',
|
|
291
|
+
title: `${count} debug log statements — excessive logging inflates log storage costs`,
|
|
292
|
+
description: 'Set LOG_LEVEL=warn in production. Debug-level logging can multiply log volume and cost 5-10x.', file: fp, fix: null });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return findings;
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
// COST-016: Synchronous file reads on every request
|
|
300
|
+
{ id: 'COST-016', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Synchronous File Read on Every Request',
|
|
301
|
+
check({ files }) {
|
|
302
|
+
const findings = [];
|
|
303
|
+
for (const [fp, c] of files) {
|
|
304
|
+
if (!isSourceFile(fp)) continue;
|
|
305
|
+
const lines = c.split('\n');
|
|
306
|
+
for (let i = 0; i < lines.length; i++) {
|
|
307
|
+
if (lines[i].match(/readFileSync|existsSync/i) && !lines[i].match(/startup|init|bootstrap|config/i)) {
|
|
308
|
+
findings.push({ ruleId: 'COST-016', category: 'cost', severity: 'medium', title: 'Synchronous file I/O in request handler — blocks event loop, increases latency and CPU cost', description: 'Cache file contents at startup or use async readFile. Synchronous FS calls block all concurrent requests.', file: fp, line: i + 1, fix: null });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return findings;
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
// COST-017: No Lambda memory optimization
|
|
317
|
+
{ id: 'COST-017', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Lambda Without Memory Optimization',
|
|
318
|
+
check({ files }) {
|
|
319
|
+
const findings = [];
|
|
320
|
+
for (const [fp, c] of files) {
|
|
321
|
+
if (!fp.match(/serverless\.ya?ml|sam\.ya?ml|lambda.*\.tf|\.aws\/template/i)) continue;
|
|
322
|
+
if (c.match(/MemorySize:\s*1024|MemorySize:\s*2048|MemorySize:\s*3008/)) {
|
|
323
|
+
findings.push({ ruleId: 'COST-017', category: 'cost', severity: 'low', title: 'Lambda configured with high memory — consider profiling with AWS Lambda Power Tuning', description: 'Use AWS Lambda Power Tuning to find the optimal memory setting. Higher memory costs more but completes faster — balance for your workload.', file: fp, fix: null });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return findings;
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
// COST-018: DynamoDB scan instead of query
|
|
331
|
+
{ id: 'COST-018', category: 'cost', severity: 'high', confidence: 'likely', title: 'DynamoDB Full Table Scan',
|
|
332
|
+
check({ files }) {
|
|
333
|
+
const findings = [];
|
|
334
|
+
for (const [fp, c] of files) {
|
|
335
|
+
if (!isSourceFile(fp)) continue;
|
|
336
|
+
const lines = c.split('\n');
|
|
337
|
+
for (let i = 0; i < lines.length; i++) {
|
|
338
|
+
if (lines[i].match(/dynamoDB?\.scan\(|documentClient\.scan\(|\.scan\(\{.*TableName/i)) {
|
|
339
|
+
findings.push({ ruleId: 'COST-018', category: 'cost', severity: 'high', title: 'DynamoDB Scan reads entire table — exponential cost at scale', description: 'Use Query with a key condition or add a GSI (Global Secondary Index). Table scans read every item and cost full throughput.', file: fp, line: i + 1, fix: null });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return findings;
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
// COST-019: No CloudFront for static assets
|
|
348
|
+
{ id: 'COST-019', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'No CloudFront for Static Assets',
|
|
349
|
+
check({ files, stack }) {
|
|
350
|
+
const findings = [];
|
|
351
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
352
|
+
const isAWS = [...files.keys()].some(f => f.match(/aws|lambda|s3|dynamodb/i)) || ['aws-sdk', '@aws-sdk/client-s3'].some(d => d in allDeps);
|
|
353
|
+
if (!isAWS) return findings;
|
|
354
|
+
const allCode = [...files.values()].join('\n');
|
|
355
|
+
if (!allCode.match(/CloudFront|cloudfront|cdn\.|CDN|aws_cloudfront/i)) {
|
|
356
|
+
findings.push({ ruleId: 'COST-019', category: 'cost', severity: 'medium', title: 'No CloudFront CDN detected for AWS project — serving assets from origin is expensive', description: 'Add CloudFront in front of S3 and API Gateway. CDN reduces origin bandwidth costs 60-90% and improves latency globally.', fix: null });
|
|
357
|
+
}
|
|
358
|
+
return findings;
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
// COST-020: No connection reuse in Lambda
|
|
363
|
+
{ id: 'COST-020', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Database Connection Not Reused Across Lambda Invocations',
|
|
364
|
+
check({ files, stack }) {
|
|
365
|
+
const findings = [];
|
|
366
|
+
if (!stack.runtime) return findings;
|
|
367
|
+
const allCode = [...files.values()].join('\n');
|
|
368
|
+
const isLambda = allCode.match(/exports\.handler|module\.exports\.handler|lambda|serverless/i);
|
|
369
|
+
if (!isLambda) return findings;
|
|
370
|
+
const hasConnectionOutsideHandler = allCode.match(/mongoose\.connect|new Pool|createConnection/i) && !allCode.match(/if\s*\(.*connected|cached.*connection|global\.__/i);
|
|
371
|
+
if (hasConnectionOutsideHandler) {
|
|
372
|
+
findings.push({ ruleId: 'COST-020', category: 'cost', severity: 'medium', title: 'Lambda opens new DB connection per invocation — use connection caching', description: 'Cache DB connections in module scope outside the handler. Lambda reuses the execution environment — connections persist between warm invocations.', fix: null });
|
|
373
|
+
}
|
|
374
|
+
return findings;
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
// COST-021: No auto-scaling configured
|
|
379
|
+
{ id: 'COST-021', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'No Auto-Scaling Configuration',
|
|
380
|
+
check({ files }) {
|
|
381
|
+
const findings = [];
|
|
382
|
+
for (const [fp, c] of files) {
|
|
383
|
+
if (!fp.match(/\.(tf|ya?ml|json)$/)) continue;
|
|
384
|
+
if (c.match(/aws_instance|EC2|ECS|Fargate/i) && !c.match(/autoscaling|auto_scaling|AutoScaling|HorizontalPodAutoscaler|hpa/i)) {
|
|
385
|
+
findings.push({ ruleId: 'COST-021', category: 'cost', severity: 'medium', title: 'Compute resources without auto-scaling — over-provisioned or under-provisioned', description: 'Configure auto-scaling to match capacity to demand. Fixed-size fleets waste money at low traffic and fail at peak.', file: fp, fix: null });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return findings;
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
// COST-022: No spot/preemptible instances for batch workloads
|
|
393
|
+
{ id: 'COST-022', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Batch Workloads Without Spot Instances',
|
|
394
|
+
check({ files }) {
|
|
395
|
+
const findings = [];
|
|
396
|
+
for (const [fp, c] of files) {
|
|
397
|
+
if (!fp.match(/\.(tf|ya?ml|json)$/)) continue;
|
|
398
|
+
if (c.match(/batch|worker|job|cron/i) && c.match(/aws_instance|instance_type/i) && !c.match(/spot|preemptible|Spot|interruptible/i)) {
|
|
399
|
+
findings.push({ ruleId: 'COST-022', category: 'cost', severity: 'low', title: 'Batch/worker jobs using on-demand instances — spot instances save 60-90%', description: 'Use EC2 Spot or GCP Preemptible instances for fault-tolerant batch jobs. Design for interruption with checkpointing.', file: fp, fix: null });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return findings;
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
// COST-023: SQS polling with WaitTimeSeconds=0
|
|
407
|
+
{ id: 'COST-023', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'SQS Short Polling (WaitTimeSeconds=0)',
|
|
408
|
+
check({ files }) {
|
|
409
|
+
const findings = [];
|
|
410
|
+
for (const [fp, c] of files) {
|
|
411
|
+
if (!isSourceFile(fp)) continue;
|
|
412
|
+
const lines = c.split('\n');
|
|
413
|
+
for (let i = 0; i < lines.length; i++) {
|
|
414
|
+
if (lines[i].match(/receiveMessage|ReceiveMessage/i) && !lines[i].match(/WaitTimeSeconds.*[1-9]|waitTimeSeconds.*[1-9]/)) {
|
|
415
|
+
const nearby = lines.slice(i, i + 10).join('\n');
|
|
416
|
+
if (!nearby.match(/WaitTimeSeconds.*[1-9]|waitTimeSeconds.*[1-9]/)) {
|
|
417
|
+
findings.push({ ruleId: 'COST-023', category: 'cost', severity: 'medium', title: 'SQS receiving without long polling — short polling costs 10x more', description: 'Set WaitTimeSeconds: 20 for long polling. Short polling returns immediately even when queue is empty, multiplying API call costs.', file: fp, line: i + 1, fix: null });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return findings;
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
// COST-024: No gzip/brotli response compression
|
|
427
|
+
{ id: 'COST-024', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'No Response Compression',
|
|
428
|
+
check({ files, stack }) {
|
|
429
|
+
const findings = [];
|
|
430
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
431
|
+
const hasCompression = ['compression', 'shrink-ray-current', 'fastify-compress'].some(d => d in allDeps);
|
|
432
|
+
const allCode = [...files.values()].some(c => c.match(/compress|gzip|deflate|brotli/i));
|
|
433
|
+
if (!hasCompression && !allCode && stack.framework) {
|
|
434
|
+
findings.push({ ruleId: 'COST-024', category: 'cost', severity: 'medium', title: 'No response compression — API responses uncompressed, increasing bandwidth costs', description: 'Add compression middleware (npm install compression). Gzip typically reduces JSON response sizes by 70-90%.', fix: null });
|
|
435
|
+
}
|
|
436
|
+
return findings;
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
// COST-025: Large base64 payloads in responses
|
|
441
|
+
{ id: 'COST-025', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Large Base64 Payloads in API Responses',
|
|
442
|
+
check({ files }) {
|
|
443
|
+
const findings = [];
|
|
444
|
+
for (const [fp, c] of files) {
|
|
445
|
+
if (!isSourceFile(fp)) continue;
|
|
446
|
+
const lines = c.split('\n');
|
|
447
|
+
for (let i = 0; i < lines.length; i++) {
|
|
448
|
+
if (lines[i].match(/\.toString\(['"]base64['"]\)|toBase64\(/) && lines[i].match(/res\.(json|send)|response/i)) {
|
|
449
|
+
findings.push({ ruleId: 'COST-025', category: 'cost', severity: 'medium', title: 'Base64 encoding binary data in API responses — 33% larger than binary transfer', description: 'Serve binary files via S3 presigned URLs. Base64 in JSON responses inflates payload size by 33% and is not cached separately.', file: fp, line: i + 1, fix: null });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return findings;
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
// COST-026: Over-fetching with SELECT *
|
|
458
|
+
{ id: 'COST-026', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'SELECT * Fetches Unnecessary Columns',
|
|
459
|
+
check({ files }) {
|
|
460
|
+
const findings = [];
|
|
461
|
+
for (const [fp, c] of files) {
|
|
462
|
+
if (!isSourceFile(fp)) continue;
|
|
463
|
+
const lines = c.split('\n');
|
|
464
|
+
for (let i = 0; i < lines.length; i++) {
|
|
465
|
+
if (lines[i].match(/SELECT\s+\*/i) || lines[i].match(/findAll\(\)|findMany\(\)/) && !lines[i].match(/select:|attributes:/)) {
|
|
466
|
+
findings.push({ ruleId: 'COST-026', category: 'cost', severity: 'medium', title: 'Fetching all columns when only subset needed — wasted I/O and bandwidth', description: 'Specify the exact columns: SELECT id, name FROM users. Use { select: { id: true, name: true } } in Prisma or { attributes: [] } in Sequelize.', file: fp, line: i + 1, fix: null });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return findings;
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
// COST-027: Sending email on every event instead of batching
|
|
475
|
+
{ id: 'COST-027', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Email Sent on Every Event Instead of Batching',
|
|
476
|
+
check({ files }) {
|
|
477
|
+
const findings = [];
|
|
478
|
+
for (const [fp, c] of files) {
|
|
479
|
+
if (!isSourceFile(fp)) continue;
|
|
480
|
+
const lines = c.split('\n');
|
|
481
|
+
for (let i = 0; i < lines.length; i++) {
|
|
482
|
+
if (lines[i].match(/sendEmail|sendMail|send\(|transporter\./i) && lines[i].match(/forEach|for\s*\(|\.map\(/)) {
|
|
483
|
+
findings.push({ ruleId: 'COST-027', category: 'cost', severity: 'low', title: 'Email API called in a loop — use batch send API to reduce costs', description: 'Use SES SendBulkTemplatedEmail or Postmark batch API. Per-email API calls have per-request overhead; bulk APIs are cheaper.', file: fp, line: i + 1, fix: null });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return findings;
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
// COST-028: No Elastic IP released when not attached
|
|
492
|
+
{ id: 'COST-028', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Elastic IPs May Not Be Released',
|
|
493
|
+
check({ files }) {
|
|
494
|
+
const findings = [];
|
|
495
|
+
for (const [fp, c] of files) {
|
|
496
|
+
if (!fp.match(/\.(tf|ya?ml)$/)) continue;
|
|
497
|
+
const eipCount = (c.match(/aws_eip\b|ElasticIP|EIP/g) || []).length;
|
|
498
|
+
if (eipCount > 2) {
|
|
499
|
+
findings.push({ ruleId: 'COST-028', category: 'cost', severity: 'low', title: `${eipCount} Elastic IPs allocated — unattached EIPs incur hourly charges`, description: 'Release Elastic IPs when not attached to a running instance. AWS charges for unattached EIPs. Use DNS names instead where possible.', file: fp, fix: null });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return findings;
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
// COST-029: No request deduplication / idempotency caching
|
|
507
|
+
{ id: 'COST-029', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'No Request Deduplication for Expensive Operations',
|
|
508
|
+
check({ files }) {
|
|
509
|
+
const findings = [];
|
|
510
|
+
for (const [fp, c] of files) {
|
|
511
|
+
if (!isSourceFile(fp)) continue;
|
|
512
|
+
const lines = c.split('\n');
|
|
513
|
+
for (let i = 0; i < lines.length; i++) {
|
|
514
|
+
if (lines[i].match(/openai|anthropic|cohere|gpt|claude/i) && lines[i].match(/create|complete|generate|invoke/i)) {
|
|
515
|
+
const nearby = lines.slice(Math.max(0, i - 5), i + 5).join('\n');
|
|
516
|
+
if (!nearby.match(/cache|idempotency|dedup|hash/i)) {
|
|
517
|
+
findings.push({ ruleId: 'COST-029', category: 'cost', severity: 'low', title: 'AI API call without request deduplication — identical prompts billed multiple times', description: 'Cache AI responses by prompt hash. Use idempotency keys where supported. Even short TTL caches significantly reduce costs for repeated queries.', file: fp, line: i + 1, fix: null });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return findings;
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
// COST-030: No reserved capacity for predictable workloads
|
|
527
|
+
{ id: 'COST-030', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'No Reserved Instances for Predictable Workloads',
|
|
528
|
+
check({ files }) {
|
|
529
|
+
const findings = [];
|
|
530
|
+
for (const [fp, c] of files) {
|
|
531
|
+
if (!fp.match(/\.(tf|ya?ml|json)$/)) continue;
|
|
532
|
+
const hasRDS = c.match(/aws_db_instance|RDSDBInstance|AWS::RDS/i);
|
|
533
|
+
const hasEC2 = c.match(/aws_instance\b|EC2Instance|AWS::EC2::Instance/i);
|
|
534
|
+
const hasReserved = c.match(/reserved_instance|ReservedDB|instance_class.*reserved|savings_plan/i);
|
|
535
|
+
if ((hasRDS || hasEC2) && !hasReserved) {
|
|
536
|
+
findings.push({ ruleId: 'COST-030', category: 'cost', severity: 'low', title: 'RDS/EC2 using on-demand pricing — Reserved Instances save 30-60%', description: 'Purchase 1-year Reserved Instances for stable production workloads. RI pricing saves 30-60% vs on-demand for predictable usage.', file: fp, fix: null });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return findings;
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
// COST-031: Large webpack bundles without code splitting
|
|
544
|
+
{ id: 'COST-031', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'No Code Splitting in Webpack Config',
|
|
545
|
+
check({ files }) {
|
|
546
|
+
const findings = [];
|
|
547
|
+
for (const [fp, c] of files) {
|
|
548
|
+
if (!fp.match(/webpack\.config\./)) continue;
|
|
549
|
+
if (!c.match(/splitChunks|SplitChunksPlugin|import\(.*\/\*.*chunk/i)) {
|
|
550
|
+
findings.push({ ruleId: 'COST-031', category: 'cost', severity: 'medium', title: 'Webpack config without code splitting — large bundles increase CDN egress costs', description: 'Configure SplitChunksPlugin and dynamic import() for route-based code splitting. Reduces initial bundle size and bandwidth.', file: fp, fix: null });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return findings;
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
// COST-032: No TTL on DynamoDB items
|
|
558
|
+
{ id: 'COST-032', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'DynamoDB Items Without TTL',
|
|
559
|
+
check({ files }) {
|
|
560
|
+
const findings = [];
|
|
561
|
+
for (const [fp, c] of files) {
|
|
562
|
+
if (!isSourceFile(fp)) continue;
|
|
563
|
+
const hasDynamo = c.match(/DynamoDB|dynamoDB|documentClient/i);
|
|
564
|
+
const hasTTL = c.match(/TimeToLive|ttl|expiresAt|expiry/i);
|
|
565
|
+
if (hasDynamo && !hasTTL) {
|
|
566
|
+
findings.push({ ruleId: 'COST-032', category: 'cost', severity: 'low', title: 'DynamoDB tables without TTL — stale items accumulate, increasing storage costs', description: 'Enable TTL on DynamoDB tables for ephemeral data (sessions, temp tokens). DynamoDB storage is billed per GB stored.', file: fp, fix: null });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return findings;
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
// COST-033: Excessive Lambda timeout
|
|
574
|
+
{ id: 'COST-033', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Lambda Timeout Too High',
|
|
575
|
+
check({ files }) {
|
|
576
|
+
const findings = [];
|
|
577
|
+
for (const [fp, c] of files) {
|
|
578
|
+
if (!fp.match(/serverless\.ya?ml|sam\.ya?ml|lambda.*\.tf/i)) continue;
|
|
579
|
+
const match = c.match(/Timeout:\s*(\d+)/);
|
|
580
|
+
if (match && parseInt(match[1]) > 60) {
|
|
581
|
+
findings.push({ ruleId: 'COST-033', category: 'cost', severity: 'low', title: `Lambda timeout set to ${match[1]}s — high timeout means billing continues on hung invocations`, description: 'Set the minimum viable timeout. A Lambda that should take 5s but times out after 900s wastes 895s of compute billing.', file: fp, fix: null });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return findings;
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
// COST-034: CloudWatch Logs with no retention (infinite retention)
|
|
589
|
+
{ id: 'COST-034', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'CloudWatch Logs Group No Retention Policy',
|
|
590
|
+
check({ files }) {
|
|
591
|
+
const findings = [];
|
|
592
|
+
for (const [fp, c] of files) {
|
|
593
|
+
if (!fp.match(/\.(tf|ya?ml|json)$/) && !fp.match(/cloudformation|cdk/i)) continue;
|
|
594
|
+
if (c.match(/aws_cloudwatch_log_group|CloudWatchLogs|LogGroup/i) && !c.match(/retention_in_days|RetentionInDays/i)) {
|
|
595
|
+
findings.push({ ruleId: 'COST-034', category: 'cost', severity: 'medium', title: 'CloudWatch Log Group without retention policy — logs stored forever at $0.03/GB', description: 'Set retentionInDays: 30 (or appropriate value). CloudWatch Logs are expensive at scale without expiry.', file: fp, fix: null });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return findings;
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
// COST-035: Image assets not WebP/AVIF
|
|
603
|
+
{ id: 'COST-035', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'No Next-Gen Image Format (WebP/AVIF)',
|
|
604
|
+
check({ files }) {
|
|
605
|
+
const findings = [];
|
|
606
|
+
for (const [fp, c] of files) {
|
|
607
|
+
if (!fp.match(/\.(html|jsx|tsx|vue)$/)) continue;
|
|
608
|
+
const jpgCount = (c.match(/\.(jpg|jpeg|png)['"]/gi) || []).length;
|
|
609
|
+
if (jpgCount > 3 && !c.match(/\.webp|\.avif|next\/image|<Image|picture.*source/i)) {
|
|
610
|
+
findings.push({ ruleId: 'COST-035', category: 'cost', severity: 'low', title: `${jpgCount} images using JPEG/PNG — WebP/AVIF reduces bandwidth 25-50%`, description: 'Convert images to WebP or AVIF. Use <picture> with format fallbacks or Next.js Image component for automatic optimization.', file: fp, fix: null });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return findings;
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
|
|
617
|
+
// COST-036: No connection keep-alive for HTTP clients
|
|
618
|
+
{ id: 'COST-036', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'HTTP Client Without Keep-Alive',
|
|
619
|
+
check({ files }) {
|
|
620
|
+
const findings = [];
|
|
621
|
+
for (const [fp, c] of files) {
|
|
622
|
+
if (!isSourceFile(fp)) continue;
|
|
623
|
+
if (c.match(/https?\.request|new https?\.Agent|axios\.create/i) && !c.match(/keepAlive.*true|keep-alive|maxSockets/i)) {
|
|
624
|
+
findings.push({ ruleId: 'COST-036', category: 'cost', severity: 'medium', title: 'HTTP client without keep-alive — new TCP connection per request', description: 'Enable keep-alive: new https.Agent({ keepAlive: true }). Each new TCP connection adds latency and CPU overhead. Keep-alive reuses connections.', file: fp, fix: null });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return findings;
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
// COST-037: No pagination on data export
|
|
632
|
+
{ id: 'COST-037', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Data Export Without Streaming/Pagination',
|
|
633
|
+
check({ files }) {
|
|
634
|
+
const findings = [];
|
|
635
|
+
for (const [fp, c] of files) {
|
|
636
|
+
if (!isSourceFile(fp)) continue;
|
|
637
|
+
const lines = c.split('\n');
|
|
638
|
+
for (let i = 0; i < lines.length; i++) {
|
|
639
|
+
if (lines[i].match(/export|download/i) && lines[i].match(/csv|xlsx|json/i)) {
|
|
640
|
+
const handler = lines.slice(i, i + 20).join('\n');
|
|
641
|
+
if (handler.match(/findAll\(\)|find\(\)|findMany\(\)/) && !handler.match(/stream|cursor|chunk|batch|limit|offset/)) {
|
|
642
|
+
findings.push({ ruleId: 'COST-037', category: 'cost', severity: 'medium', title: 'Data export loads all records at once — memory spike and timeout risk', description: 'Stream exports using database cursors: db.query().stream(). Loading millions of rows into memory causes OOM errors and timeouts.', file: fp, line: i + 1, fix: null });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return findings;
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
// COST-038: CloudWatch detailed monitoring always on
|
|
652
|
+
{ id: 'COST-038', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'EC2 Detailed Monitoring Always Enabled',
|
|
653
|
+
check({ files }) {
|
|
654
|
+
const findings = [];
|
|
655
|
+
for (const [fp, c] of files) {
|
|
656
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
657
|
+
if (c.match(/aws_instance/i) && c.match(/monitoring\s*=\s*true/)) {
|
|
658
|
+
findings.push({ ruleId: 'COST-038', category: 'cost', severity: 'low', title: 'EC2 detailed monitoring enabled — $2.10/instance/month additional cost', description: 'Use basic monitoring (default) for non-critical instances. Detailed monitoring (1-min intervals) is only needed for critical instances.', file: fp, fix: null });
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return findings;
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
// COST-039: No S3 Intelligent Tiering
|
|
666
|
+
{ id: 'COST-039', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'S3 Bucket Without Intelligent Tiering',
|
|
667
|
+
check({ files }) {
|
|
668
|
+
const findings = [];
|
|
669
|
+
for (const [fp, c] of files) {
|
|
670
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
671
|
+
if (c.match(/aws_s3_bucket\b/i) && !c.match(/intelligent.*tiering|INTELLIGENT_TIERING|lifecycle|transition/i)) {
|
|
672
|
+
findings.push({ ruleId: 'COST-039', category: 'cost', severity: 'low', title: 'S3 bucket without lifecycle/Intelligent Tiering — paying full price for infrequent objects', description: 'Add S3 Intelligent Tiering lifecycle rule. Objects not accessed for 30 days auto-move to IA tier (40% savings), then Glacier (68% savings).', file: fp, fix: null });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return findings;
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
|
|
679
|
+
// COST-040: Unused Elastic Load Balancer
|
|
680
|
+
{ id: 'COST-040', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Load Balancer With Single Target',
|
|
681
|
+
check({ files }) {
|
|
682
|
+
const findings = [];
|
|
683
|
+
for (const [fp, c] of files) {
|
|
684
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
685
|
+
if (c.match(/aws_lb\b|aws_alb\b/i) && !c.match(/aws_lb_target_group_attachment.*count|for_each|count\s*=\s*[2-9]/i)) {
|
|
686
|
+
findings.push({ ruleId: 'COST-040', category: 'cost', severity: 'medium', title: 'Load balancer with single target — $16+/month with no HA benefit', description: 'Remove the ALB for single-instance setups (use ECS directly or API Gateway). An ALB only provides value with multiple targets.', file: fp, fix: null });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return findings;
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
// COST-041: NAT Gateway in each AZ
|
|
694
|
+
{ id: 'COST-041', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Multiple NAT Gateways (Consider Single for Dev)',
|
|
695
|
+
check({ files }) {
|
|
696
|
+
const findings = [];
|
|
697
|
+
for (const [fp, c] of files) {
|
|
698
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
699
|
+
const natCount = (c.match(/aws_nat_gateway\b/gi) || []).length;
|
|
700
|
+
if (natCount > 1 && c.match(/dev|staging|non.*prod/i)) {
|
|
701
|
+
findings.push({ ruleId: 'COST-041', category: 'cost', severity: 'medium', title: `${natCount} NAT Gateways in non-production — $32/gateway/month × ${natCount}`, description: 'Use a single NAT Gateway for dev/staging environments. Only production needs per-AZ NAT for resilience. Single NAT saves $64+/month in a 3-AZ setup.', file: fp, fix: null });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return findings;
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
// COST-042: No AI response streaming
|
|
709
|
+
{ id: 'COST-042', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'AI API Without Response Streaming',
|
|
710
|
+
check({ files }) {
|
|
711
|
+
const findings = [];
|
|
712
|
+
for (const [fp, c] of files) {
|
|
713
|
+
if (!isSourceFile(fp)) continue;
|
|
714
|
+
if (c.match(/openai|anthropic|claude|gpt/i) && c.match(/create|complete|generate|message/i)) {
|
|
715
|
+
if (!c.match(/stream.*true|streaming.*true|stream:/i)) {
|
|
716
|
+
findings.push({ ruleId: 'COST-042', category: 'cost', severity: 'low', title: 'AI API called without streaming — users wait for full response before seeing output', description: 'Enable streaming for LLM APIs. Streaming improves UX by showing tokens as they generate and prevents request timeouts for long responses.', file: fp, fix: null });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return findings;
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
// COST-043: Storing media files in database BLOBs
|
|
725
|
+
{ id: 'COST-043', category: 'cost', severity: 'high', confidence: 'likely', title: 'Media Files Stored as Database BLOBs',
|
|
726
|
+
check({ files }) {
|
|
727
|
+
const findings = [];
|
|
728
|
+
for (const [fp, c] of files) {
|
|
729
|
+
if (!isSourceFile(fp)) continue;
|
|
730
|
+
const lines = c.split('\n');
|
|
731
|
+
for (let i = 0; i < lines.length; i++) {
|
|
732
|
+
if (lines[i].match(/BLOB|bytea|binary|image.*data|file.*data/i) && lines[i].match(/save|insert|create|store/i)) {
|
|
733
|
+
findings.push({ ruleId: 'COST-043', category: 'cost', severity: 'high', title: 'Media/binary files stored in database — expensive, slow, and unscalable', description: 'Store files in S3 or object storage. Save only the URL in the database. Database storage costs 100x more than S3 for large files.', file: fp, line: i + 1, fix: null });
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return findings;
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
|
|
741
|
+
// COST-044: No request timeout on fetch/axios
|
|
742
|
+
{ id: 'COST-044', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'HTTP Requests Without Timeout',
|
|
743
|
+
check({ files }) {
|
|
744
|
+
const findings = [];
|
|
745
|
+
for (const [fp, c] of files) {
|
|
746
|
+
if (!isSourceFile(fp)) continue;
|
|
747
|
+
const lines = c.split('\n');
|
|
748
|
+
for (let i = 0; i < lines.length; i++) {
|
|
749
|
+
if (lines[i].match(/fetch\(|axios\.(get|post|put|delete|patch)\(/i)) {
|
|
750
|
+
const block = lines.slice(i, i + 5).join('\n');
|
|
751
|
+
if (!block.match(/timeout|signal.*AbortSignal|AbortController/i)) {
|
|
752
|
+
findings.push({ ruleId: 'COST-044', category: 'cost', severity: 'medium', title: 'HTTP request without timeout — hung requests consume connections and memory', description: 'Add timeout: 5000 to axios or use AbortController with setTimeout for fetch. Hung HTTP requests leak connections and inflate cost.', file: fp, line: i + 1, fix: null });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return findings;
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
|
|
761
|
+
// COST-045: Unused Lambda functions
|
|
762
|
+
{ id: 'COST-045', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Lambda Functions Without Invocation Tracking',
|
|
763
|
+
check({ files }) {
|
|
764
|
+
const findings = [];
|
|
765
|
+
for (const [fp, c] of files) {
|
|
766
|
+
if (!fp.match(/serverless\.ya?ml|sam\.ya?ml/i)) continue;
|
|
767
|
+
const funcCount = (c.match(/^\s+\w+:\s*$/mg) || []).length;
|
|
768
|
+
const hasMetrics = c.match(/cloudwatch|metrics|monitoring/i);
|
|
769
|
+
if (funcCount > 10 && !hasMetrics) {
|
|
770
|
+
findings.push({ ruleId: 'COST-045', category: 'cost', severity: 'low', title: `${funcCount} Lambda functions without invocation monitoring — unused functions accumulate`, description: 'Monitor invocation counts with CloudWatch. Review and remove Lambda functions not invoked for 30+ days.', file: fp, fix: null });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return findings;
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
// COST-046: No ECR image lifecycle policy
|
|
778
|
+
{ id: 'COST-046', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'ECR Repository Without Lifecycle Policy',
|
|
779
|
+
check({ files }) {
|
|
780
|
+
const findings = [];
|
|
781
|
+
for (const [fp, c] of files) {
|
|
782
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
783
|
+
if (c.match(/aws_ecr_repository\b/i) && !c.match(/aws_ecr_lifecycle_policy|lifecycle_policy/i)) {
|
|
784
|
+
findings.push({ ruleId: 'COST-046', category: 'cost', severity: 'low', title: 'ECR repository without lifecycle policy — old images accumulate at $0.10/GB/month', description: 'Add lifecycle policy to keep only last 10 images. Each pushed image is stored forever without a policy, accumulating storage costs.', file: fp, fix: null });
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return findings;
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
|
|
791
|
+
// COST-047: GraphQL without query depth limiting
|
|
792
|
+
{ id: 'COST-047', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'GraphQL Without Query Depth/Cost Limiting',
|
|
793
|
+
check({ files, stack }) {
|
|
794
|
+
const findings = [];
|
|
795
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
796
|
+
const hasGraphQL = 'graphql' in allDeps || '@apollo/server' in allDeps;
|
|
797
|
+
const hasDepthLimit = ['graphql-depth-limit', 'graphql-query-complexity', 'graphql-cost-analysis'].some(d => d in allDeps);
|
|
798
|
+
if (hasGraphQL && !hasDepthLimit) {
|
|
799
|
+
findings.push({ ruleId: 'COST-047', category: 'cost', severity: 'medium', title: 'GraphQL without query depth or complexity limiting', description: 'Add graphql-depth-limit and graphql-query-complexity. Deeply nested or complex queries can cause exponential DB load and bill spikes.', fix: null });
|
|
800
|
+
}
|
|
801
|
+
return findings;
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
|
|
805
|
+
// COST-048: No request size limit
|
|
806
|
+
{ id: 'COST-048', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'No Request Body Size Limit',
|
|
807
|
+
check({ files }) {
|
|
808
|
+
const findings = [];
|
|
809
|
+
const hasLimit = [...files.values()].some(c => c.match(/limit.*['"]\d+\w*['"]|bodyLimit|maxBodySize|body-parser.*limit|express\.json.*limit/i));
|
|
810
|
+
const hasExpress = [...files.values()].some(c => c.match(/express\(\)|fastify\(\)/i));
|
|
811
|
+
if (hasExpress && !hasLimit) {
|
|
812
|
+
findings.push({ ruleId: 'COST-048', category: 'cost', severity: 'medium', title: 'No request body size limit — large payloads consume bandwidth and memory', description: 'Add body size limit: express.json({ limit: "1mb" }). Without limits, a single request with a 1GB body can exhaust server memory and incur bandwidth costs.', fix: null });
|
|
813
|
+
}
|
|
814
|
+
return findings;
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
|
|
818
|
+
// COST-049: Audit logging to same DB (expensive queries)
|
|
819
|
+
{ id: 'COST-049', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Audit Logs Written to Main Database',
|
|
820
|
+
check({ files }) {
|
|
821
|
+
const findings = [];
|
|
822
|
+
for (const [fp, c] of files) {
|
|
823
|
+
if (!isSourceFile(fp)) continue;
|
|
824
|
+
if (c.match(/auditLog|audit_log|AuditLog/i) && c.match(/save|insert|create/i)) {
|
|
825
|
+
if (!c.match(/elasticsearch|cloudwatch|s3|bigquery|separate.*db|audit.*db/i)) {
|
|
826
|
+
findings.push({ ruleId: 'COST-049', category: 'cost', severity: 'low', title: 'Audit logs written to main application database', description: 'Send audit logs to CloudWatch Logs, Elasticsearch, or a separate database. Audit logs in main DB bloat storage, slow queries, and inflate backup sizes.', file: fp, fix: null });
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return findings;
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
|
|
834
|
+
// COST-050: No idle resource detection
|
|
835
|
+
{ id: 'COST-050', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'No Cloud Cost Anomaly Detection',
|
|
836
|
+
check({ files }) {
|
|
837
|
+
const findings = [];
|
|
838
|
+
const isCloud = [...files.values()].some(c => c.match(/aws_|AWS::|azure|gcp|google.*cloud/i));
|
|
839
|
+
const hasCostAlert = [...files.values()].some(c => c.match(/aws_budgets|CostBudget|billing.*alert|cost.*anomaly|aws_ce_anomaly/i));
|
|
840
|
+
if (isCloud && !hasCostAlert) {
|
|
841
|
+
findings.push({ ruleId: 'COST-050', category: 'cost', severity: 'low', title: 'No cloud cost budget alerts configured', description: 'Add AWS Budgets or GCP billing alerts. Without alerts, runaway costs from misconfigurations or attacks go unnoticed until the monthly bill.', fix: null });
|
|
842
|
+
}
|
|
843
|
+
return findings;
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
// COST-051: API call in rendering loop
|
|
848
|
+
{ id: 'COST-051', category: 'cost', severity: 'high', confidence: 'likely', title: 'API Call Inside Rendering Loop',
|
|
849
|
+
check({ files }) {
|
|
850
|
+
const findings = [];
|
|
851
|
+
for (const [fp, c] of files) {
|
|
852
|
+
if (!isSourceFile(fp)) continue;
|
|
853
|
+
const lines = c.split('\n');
|
|
854
|
+
for (let i = 0; i < lines.length; i++) {
|
|
855
|
+
if (lines[i].match(/(?:for|forEach|map|while)\s*\(/) && !lines[i].match(/Promise\.all/)) {
|
|
856
|
+
const block = lines.slice(i, i + 8).join('\n');
|
|
857
|
+
if (block.match(/fetch\(|axios\.|got\.|https?\./i)) {
|
|
858
|
+
findings.push({ ruleId: 'COST-051', category: 'cost', severity: 'high', title: 'HTTP API call inside a loop — N requests instead of 1 batch request', description: 'Batch API calls outside the loop. Use bulk endpoints or batch processing. N sequential API calls cost N × (latency + per-request overhead).', file: fp, line: i + 1, fix: null });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return findings;
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
// COST-052: No CloudFront cache behavior for API
|
|
867
|
+
{ id: 'COST-052', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'CloudFront Without Cache Behaviors for GET APIs',
|
|
868
|
+
check({ files }) {
|
|
869
|
+
const findings = [];
|
|
870
|
+
for (const [fp, c] of files) {
|
|
871
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
872
|
+
if (c.match(/aws_cloudfront_distribution/i) && c.match(/origin.*api/i)) {
|
|
873
|
+
if (!c.match(/cache_behavior|ordered_cache_behavior/i)) {
|
|
874
|
+
findings.push({ ruleId: 'COST-052', category: 'cost', severity: 'medium', title: 'CloudFront fronting API without cache behaviors — all requests hit origin', description: 'Add cache_behavior for cacheable GET endpoints. Cache-Control: max-age=60 on GET /products reduces origin requests 90% for popular endpoints.', file: fp, fix: null });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return findings;
|
|
879
|
+
},
|
|
880
|
+
},
|
|
881
|
+
// COST-053: Database query result not paginated
|
|
882
|
+
{ id: 'COST-053', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Database Query Returns Unbounded Results',
|
|
883
|
+
check({ files }) {
|
|
884
|
+
const findings = [];
|
|
885
|
+
for (const [fp, c] of files) {
|
|
886
|
+
if (!isSourceFile(fp)) continue;
|
|
887
|
+
const lines = c.split('\n');
|
|
888
|
+
for (let i = 0; i < lines.length; i++) {
|
|
889
|
+
if (lines[i].match(/SELECT\s+\*\s+FROM|\.find\(\{?\}\)|\.findAll\(\)/i)) {
|
|
890
|
+
if (!lines.slice(i, i + 5).join('\n').match(/LIMIT|limit:|take:|first:|paginate/i)) {
|
|
891
|
+
findings.push({ ruleId: 'COST-053', category: 'cost', severity: 'medium', title: 'Database query without LIMIT — returns all rows', description: 'Add LIMIT to all queries: SELECT * FROM users LIMIT 100. Unbounded queries can return millions of rows, exhausting memory and bandwidth.', file: fp, line: i + 1, fix: null });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return findings;
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
// COST-054: No HTTP conditional requests (ETags)
|
|
900
|
+
{ id: 'COST-054', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'API Does Not Support ETags/Conditional Requests',
|
|
901
|
+
check({ files }) {
|
|
902
|
+
const findings = [];
|
|
903
|
+
const hasETags = [...files.values()].some(c => c.match(/ETag|etag|If-None-Match|Last-Modified|If-Modified-Since/i));
|
|
904
|
+
const hasAPI = [...files.values()].some(c => c.match(/router\.get|app\.get/i));
|
|
905
|
+
if (hasAPI && !hasETags) {
|
|
906
|
+
findings.push({ ruleId: 'COST-054', category: 'cost', severity: 'low', title: 'API does not support ETags — clients cannot use conditional requests', description: 'Add ETag support: res.set("ETag", hash(data)). Conditional requests let clients cache responses and receive 304 Not Modified instead of full responses.', fix: null });
|
|
907
|
+
}
|
|
908
|
+
return findings;
|
|
909
|
+
},
|
|
910
|
+
},
|
|
911
|
+
// COST-055: No RDS proxy for Lambda connections
|
|
912
|
+
{ id: 'COST-055', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Lambda Connecting Directly to RDS (No Proxy)',
|
|
913
|
+
check({ files }) {
|
|
914
|
+
const findings = [];
|
|
915
|
+
for (const [fp, c] of files) {
|
|
916
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
917
|
+
if (c.match(/aws_lambda_function/i) && c.match(/aws_db_instance|aws_rds_cluster/i)) {
|
|
918
|
+
if (!c.match(/aws_db_proxy|rds_proxy|RDSProxy/i)) {
|
|
919
|
+
findings.push({ ruleId: 'COST-055', category: 'cost', severity: 'medium', title: 'Lambda connecting directly to RDS — connection exhaustion at scale', description: 'Add RDS Proxy between Lambda and RDS. Lambda bursts create thousands of connections that RDS cannot handle. RDS Proxy pools and reuses connections.', file: fp, fix: null });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return findings;
|
|
924
|
+
},
|
|
925
|
+
},
|
|
926
|
+
// COST-056: N+1 database queries inflating query costs
|
|
927
|
+
{ id: 'COST-056', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'N+1 Query Pattern Inflating Database Costs',
|
|
928
|
+
check({ files }) {
|
|
929
|
+
const findings = [];
|
|
930
|
+
for (const [fp, c] of files) {
|
|
931
|
+
if (!isSourceFile(fp)) continue;
|
|
932
|
+
const lines = c.split('\n');
|
|
933
|
+
for (let i = 0; i < lines.length; i++) {
|
|
934
|
+
if (lines[i].match(/for\s*\(|forEach\s*\(|\.map\s*\(/) && !lines[i].match(/\/\//)) {
|
|
935
|
+
const loopBody = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
|
|
936
|
+
if (loopBody.match(/await.*\.(findOne|findById|query|execute|select)\s*\(/) ) {
|
|
937
|
+
findings.push({ ruleId: 'COST-056', category: 'cost', severity: 'medium', title: 'Database query inside loop — N+1 queries increasing DB costs', description: 'Use batch queries, JOIN, or DataLoader to fetch related data in bulk. N+1 queries make hundreds of DB calls instead of one, inflating RDS costs and slowing response times.', file: fp, line: i + 1, fix: null });
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return findings;
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
// COST-057: Uncompressed API responses
|
|
946
|
+
{ id: 'COST-057', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'API Responses Not Compressed (gzip/brotli)',
|
|
947
|
+
check({ files, stack }) {
|
|
948
|
+
const findings = [];
|
|
949
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
950
|
+
const hasCompression = ['compression', '@fastify/compress', 'koa-compress'].some(d => d in allDeps) || [...files.values()].some(c => c.match(/compression\s*\(\s*\)|brotli|gzip.*res|Content-Encoding/i));
|
|
951
|
+
const hasExpress = 'express' in allDeps || 'fastify' in allDeps || 'koa' in allDeps;
|
|
952
|
+
if (hasExpress && !hasCompression) {
|
|
953
|
+
findings.push({ ruleId: 'COST-057', category: 'cost', severity: 'medium', title: 'No HTTP response compression — excess bandwidth costs', description: 'Add compression middleware (npm compression). Gzip typically reduces JSON responses by 70-80%, directly reducing data transfer costs and improving performance.', fix: null });
|
|
954
|
+
}
|
|
955
|
+
return findings;
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
// COST-058: Lambda function with overly large deployment package
|
|
959
|
+
{ id: 'COST-058', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Lambda Deployment Package Not Optimized',
|
|
960
|
+
check({ files }) {
|
|
961
|
+
const findings = [];
|
|
962
|
+
for (const [fp, c] of files) {
|
|
963
|
+
if (!fp.endsWith('serverless.yml') && !fp.match(/serverless\.yaml$/)) continue;
|
|
964
|
+
if (!c.match(/exclude:|individually:\s*true|layers:/i)) {
|
|
965
|
+
findings.push({ ruleId: 'COST-058', category: 'cost', severity: 'medium', title: 'Serverless function without package exclusions — cold starts inflated by large packages', description: 'Use package.patterns excludes and Lambda Layers for shared dependencies. Smaller deployment packages reduce Lambda cold start time (proportional to package size) and storage costs.', file: fp, fix: null });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return findings;
|
|
969
|
+
},
|
|
970
|
+
},
|
|
971
|
+
// COST-059: Chatty microservice communication
|
|
972
|
+
{ id: 'COST-059', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Chatty Inter-Service Communication Pattern',
|
|
973
|
+
check({ files }) {
|
|
974
|
+
const findings = [];
|
|
975
|
+
for (const [fp, c] of files) {
|
|
976
|
+
if (!isSourceFile(fp)) continue;
|
|
977
|
+
const lines = c.split('\n');
|
|
978
|
+
let serviceCallCount = 0;
|
|
979
|
+
for (let i = 0; i < lines.length; i++) {
|
|
980
|
+
if (lines[i].match(/await\s+(axios|fetch|got|superagent)\.(get|post)\s*\(/)) serviceCallCount++;
|
|
981
|
+
}
|
|
982
|
+
if (serviceCallCount > 10) {
|
|
983
|
+
findings.push({ ruleId: 'COST-059', category: 'cost', severity: 'medium', title: `${serviceCallCount} outbound HTTP calls in one file — chatty service communication`, description: 'Batch or aggregate service calls. Each HTTP call has network overhead. A GraphQL gateway or API aggregation layer reduces inter-service call volume and associated data transfer costs.', file: fp, fix: null });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return findings;
|
|
987
|
+
},
|
|
988
|
+
},
|
|
989
|
+
// COST-060: Sending large payloads over SQS/SNS
|
|
990
|
+
{ id: 'COST-060', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Large Payload Sent Directly via SQS/SNS',
|
|
991
|
+
check({ files }) {
|
|
992
|
+
const findings = [];
|
|
993
|
+
for (const [fp, c] of files) {
|
|
994
|
+
if (!isSourceFile(fp)) continue;
|
|
995
|
+
const lines = c.split('\n');
|
|
996
|
+
for (let i = 0; i < lines.length; i++) {
|
|
997
|
+
if (lines[i].match(/sendMessage|publish\s*\(.*Message/) && !lines[i].match(/\/\//)) {
|
|
998
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
|
|
999
|
+
if (ctx.match(/JSON\.stringify|payload|data|body/i) && !ctx.match(/s3.*key|S3.*pointer|reference/i)) {
|
|
1000
|
+
findings.push({ ruleId: 'COST-060', category: 'cost', severity: 'low', title: 'Large data sent directly via SQS/SNS — use S3 pointer pattern', description: 'Store large payloads in S3 and pass only the S3 key in the SQS/SNS message. SQS charges per API call and has a 256KB message limit. Large payloads should use the S3 extended library.', file: fp, line: i + 1, fix: null });
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return findings;
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
// COST-061: Polling instead of event-driven architecture
|
|
1009
|
+
{ id: 'COST-061', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Polling Pattern Instead of Event-Driven',
|
|
1010
|
+
check({ files }) {
|
|
1011
|
+
const findings = [];
|
|
1012
|
+
for (const [fp, c] of files) {
|
|
1013
|
+
if (!isSourceFile(fp)) continue;
|
|
1014
|
+
const lines = c.split('\n');
|
|
1015
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1016
|
+
if (lines[i].match(/setInterval\s*\(/) && !lines[i].match(/\/\//)) {
|
|
1017
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
|
|
1018
|
+
if (ctx.match(/fetch|axios|db\.|query|findAll|getItem/i)) {
|
|
1019
|
+
findings.push({ ruleId: 'COST-061', category: 'cost', severity: 'medium', title: 'setInterval polling database/API — continuous unnecessary compute and API costs', description: 'Replace polling with webhooks, WebSockets, or message queue consumers. Polling makes API calls regardless of whether data changed, wasting compute and incurring API costs.', file: fp, line: i + 1, fix: null });
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return findings;
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
1027
|
+
// COST-062: No pagination on list queries
|
|
1028
|
+
{ id: 'COST-062', category: 'cost', severity: 'high', confidence: 'likely', title: 'List API Returns All Records Without Pagination',
|
|
1029
|
+
check({ files }) {
|
|
1030
|
+
const findings = [];
|
|
1031
|
+
for (const [fp, c] of files) {
|
|
1032
|
+
if (!isSourceFile(fp)) continue;
|
|
1033
|
+
const lines = c.split('\n');
|
|
1034
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1035
|
+
if (lines[i].match(/\.findAll\s*\(\s*\)|\.find\s*\(\s*\)|\.scan\s*\(\s*\)/) && !lines[i].match(/\/\//)) {
|
|
1036
|
+
const ctx = lines.slice(Math.max(0, i - 2), i + 5).join('\n');
|
|
1037
|
+
if (!ctx.match(/limit|skip|offset|take|page|cursor|LIMIT/i)) {
|
|
1038
|
+
findings.push({ ruleId: 'COST-062', category: 'cost', severity: 'high', title: 'findAll() without limit — full table scan on every list request', description: 'Add LIMIT/take to all list queries. Fetching all records on every request wastes DB compute, increases memory, and slows response times proportional to table size.', file: fp, line: i + 1, fix: null });
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return findings;
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1046
|
+
// COST-063: Redundant CloudWatch log ingestion
|
|
1047
|
+
{ id: 'COST-063', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Verbose Logging Increasing CloudWatch Ingestion Costs',
|
|
1048
|
+
check({ files }) {
|
|
1049
|
+
const findings = [];
|
|
1050
|
+
for (const [fp, c] of files) {
|
|
1051
|
+
if (!isSourceFile(fp)) continue;
|
|
1052
|
+
const lines = c.split('\n');
|
|
1053
|
+
let verboseLogCount = 0;
|
|
1054
|
+
for (const line of lines) {
|
|
1055
|
+
if (line.match(/console\.log\s*\(|logger\.(debug|verbose|silly)\s*\(/) && !line.match(/\/\//)) verboseLogCount++;
|
|
1056
|
+
}
|
|
1057
|
+
if (verboseLogCount > 20) {
|
|
1058
|
+
findings.push({ ruleId: 'COST-063', category: 'cost', severity: 'low', title: `${verboseLogCount} verbose log statements — high CloudWatch log ingestion cost`, description: 'Remove or reduce verbose logging in production code. CloudWatch charges $0.50/GB ingested. Debug-level logging in production can exceed production traffic data costs.', file: fp, fix: null });
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return findings;
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
// COST-064: Downloading full S3 objects when only metadata needed
|
|
1065
|
+
{ id: 'COST-064', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Downloading Full S3 Objects to Check Metadata',
|
|
1066
|
+
check({ files }) {
|
|
1067
|
+
const findings = [];
|
|
1068
|
+
for (const [fp, c] of files) {
|
|
1069
|
+
if (!isSourceFile(fp)) continue;
|
|
1070
|
+
const lines = c.split('\n');
|
|
1071
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1072
|
+
if (lines[i].match(/getObject\s*\(/) && !lines[i].match(/\/\//)) {
|
|
1073
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
|
|
1074
|
+
if (ctx.match(/\.size|\.lastModified|\.contentType|Content-Length|Last-Modified/i) && !ctx.match(/headObject|getObjectAttributes/i)) {
|
|
1075
|
+
findings.push({ ruleId: 'COST-064', category: 'cost', severity: 'medium', title: 'Downloading S3 object to check metadata — use headObject instead', description: 'Use S3 HeadObject to check size, content-type, and last-modified without downloading the object. GetObject charges per GB downloaded; HeadObject only charges per request.', file: fp, line: i + 1, fix: null });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return findings;
|
|
1081
|
+
},
|
|
1082
|
+
},
|
|
1083
|
+
// COST-065: Generating thumbnails/transforms on every request
|
|
1084
|
+
{ id: 'COST-065', category: 'cost', severity: 'high', confidence: 'likely', title: 'Image/Media Transformed on Every Request',
|
|
1085
|
+
check({ files, stack }) {
|
|
1086
|
+
const findings = [];
|
|
1087
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1088
|
+
const hasTransform = ['sharp', 'jimp', 'imagemagick', 'gm'].some(d => d in allDeps);
|
|
1089
|
+
const cachesTransform = [...files.values()].some(c => c.match(/cache.*resize|resize.*cache|s3.*thumbnail|cloudfront.*image/i));
|
|
1090
|
+
if (hasTransform && !cachesTransform) {
|
|
1091
|
+
findings.push({ ruleId: 'COST-065', category: 'cost', severity: 'high', title: 'Image transformations not cached — reprocessing same image on every request', description: 'Cache transformed images in S3 or use CloudFront with Lambda@Edge for on-demand image resizing. Reprocessing the same image on every request wastes CPU and memory.', fix: null });
|
|
1092
|
+
}
|
|
1093
|
+
return findings;
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
// COST-066: Secrets Manager called on every request
|
|
1097
|
+
{ id: 'COST-066', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'AWS Secrets Manager Called on Every Request',
|
|
1098
|
+
check({ files }) {
|
|
1099
|
+
const findings = [];
|
|
1100
|
+
for (const [fp, c] of files) {
|
|
1101
|
+
if (!isSourceFile(fp)) continue;
|
|
1102
|
+
const lines = c.split('\n');
|
|
1103
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1104
|
+
if (lines[i].match(/getSecretValue|GetSecretValue/) && !lines[i].match(/\/\//)) {
|
|
1105
|
+
const ctx = lines.slice(Math.max(0, i - 10), i + 5).join('\n');
|
|
1106
|
+
if (!ctx.match(/cache|cached|module\.exports|singleton|startup|initialize/i) && ctx.match(/req\.|handler\s*=|router\./)) {
|
|
1107
|
+
findings.push({ ruleId: 'COST-066', category: 'cost', severity: 'medium', title: 'Secrets Manager called per-request — excessive API costs', description: 'Cache Secrets Manager values at startup or in a module-level variable with refresh interval. AWS Secrets Manager charges $0.05 per 10,000 API calls. Per-request calls quickly add up.', file: fp, line: i + 1, fix: null });
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return findings;
|
|
1113
|
+
},
|
|
1114
|
+
},
|
|
1115
|
+
// COST-067: Multi-AZ deployment for non-production environments
|
|
1116
|
+
{ id: 'COST-067', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Multi-AZ Enabled in Non-Production Environment',
|
|
1117
|
+
check({ files }) {
|
|
1118
|
+
const findings = [];
|
|
1119
|
+
for (const [fp, c] of files) {
|
|
1120
|
+
if (!fp.match(/\.tf$|\.yaml$|\.yml$/)) continue;
|
|
1121
|
+
if (fp.match(/dev|staging|test|qa/i) && c.match(/multi_az\s*=\s*true|MultiAZ.*true|multi-az.*true/i)) {
|
|
1122
|
+
findings.push({ ruleId: 'COST-067', category: 'cost', severity: 'low', title: 'Multi-AZ RDS enabled in non-production — double the cost with no benefit', description: 'Disable Multi-AZ for dev/staging environments. Multi-AZ doubles RDS costs and is only needed for production uptime. Use single-AZ for cost savings in non-production.', file: fp, fix: null });
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return findings;
|
|
1126
|
+
},
|
|
1127
|
+
},
|
|
1128
|
+
// COST-068: Sending transactional email synchronously in request handler
|
|
1129
|
+
{ id: 'COST-068', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Transactional Email Sent Synchronously in Request Handler',
|
|
1130
|
+
check({ files, stack }) {
|
|
1131
|
+
const findings = [];
|
|
1132
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1133
|
+
const hasEmail = ['nodemailer', '@sendgrid/mail', 'mailgun-js', 'postmark', 'ses'].some(d => d in allDeps);
|
|
1134
|
+
if (!hasEmail) return findings;
|
|
1135
|
+
for (const [fp, c] of files) {
|
|
1136
|
+
if (!isSourceFile(fp)) continue;
|
|
1137
|
+
const lines = c.split('\n');
|
|
1138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1139
|
+
if (lines[i].match(/await.*sendMail|await.*send\s*\(.*subject|await.*transporter/i) && !lines[i].match(/\/\//)) {
|
|
1140
|
+
const ctx = lines.slice(Math.max(0, i - 10), i).join('\n');
|
|
1141
|
+
if (ctx.match(/router\.(post|get|put|patch)|req\.|res\./)) {
|
|
1142
|
+
findings.push({ ruleId: 'COST-068', category: 'cost', severity: 'medium', title: 'Email sent synchronously in request handler — slows response and blocks retries', description: 'Enqueue email sending to a background job (Bull, SQS). Sending email inline with the request adds 200-500ms latency, blocks the response if email service is slow, and prevents cost-efficient batching.', file: fp, line: i + 1, fix: null });
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return findings;
|
|
1148
|
+
},
|
|
1149
|
+
},
|
|
1150
|
+
// COST-069: Over-provisioned ElastiCache cluster
|
|
1151
|
+
{ id: 'COST-069', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'ElastiCache Cluster Configured Without Auto-Scaling',
|
|
1152
|
+
check({ files }) {
|
|
1153
|
+
const findings = [];
|
|
1154
|
+
for (const [fp, c] of files) {
|
|
1155
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
1156
|
+
if (c.match(/aws_elasticache_cluster|aws_elasticache_replication_group/i)) {
|
|
1157
|
+
if (!c.match(/num_cache_nodes\s*=\s*1|automatic_failover_enabled\s*=\s*false/i)) {
|
|
1158
|
+
const hasScaling = c.match(/aws_appautoscaling|auto_minor_version|serverless.*cache/i);
|
|
1159
|
+
if (!hasScaling) {
|
|
1160
|
+
findings.push({ ruleId: 'COST-069', category: 'cost', severity: 'low', title: 'ElastiCache cluster without scaling configuration', description: 'Consider ElastiCache Serverless for variable workloads or right-size your node type. Over-provisioned cache clusters run at full cost even at low utilization.', file: fp, fix: null });
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return findings;
|
|
1166
|
+
},
|
|
1167
|
+
},
|
|
1168
|
+
// COST-070: Downloading full file before processing
|
|
1169
|
+
{ id: 'COST-070', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'File Fully Downloaded Before Processing (No Streaming)',
|
|
1170
|
+
check({ files }) {
|
|
1171
|
+
const findings = [];
|
|
1172
|
+
for (const [fp, c] of files) {
|
|
1173
|
+
if (!isSourceFile(fp)) continue;
|
|
1174
|
+
const lines = c.split('\n');
|
|
1175
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1176
|
+
if (lines[i].match(/readFileSync|readFile\s*\(/) && !lines[i].match(/\/\//)) {
|
|
1177
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
|
|
1178
|
+
if (ctx.match(/split\s*\(|forEach\s*\(|map\s*\(|filter\s*\(/) && !ctx.match(/stream|pipe\s*\(/)) {
|
|
1179
|
+
findings.push({ ruleId: 'COST-070', category: 'cost', severity: 'medium', title: 'File fully loaded into memory before processing', description: 'Use readable streams for large file processing. Loading large files fully into memory is expensive. Streams process data incrementally with constant memory overhead.', file: fp, line: i + 1, fix: null });
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return findings;
|
|
1185
|
+
},
|
|
1186
|
+
},
|
|
1187
|
+
// COST-071: SQS queue without message visibility timeout
|
|
1188
|
+
{ id: 'COST-071', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'SQS Queue Without Appropriate Visibility Timeout',
|
|
1189
|
+
check({ files }) {
|
|
1190
|
+
const findings = [];
|
|
1191
|
+
for (const [fp, c] of files) {
|
|
1192
|
+
if (!fp.match(/\.tf$|\.yaml$|\.yml$/)) continue;
|
|
1193
|
+
if (c.match(/aws_sqs_queue|Type.*SQS::Queue/i)) {
|
|
1194
|
+
if (!c.match(/visibility_timeout_seconds|VisibilityTimeout/i)) {
|
|
1195
|
+
findings.push({ ruleId: 'COST-071', category: 'cost', severity: 'medium', title: 'SQS queue without explicit visibility timeout — messages reprocessed unnecessarily', description: 'Set visibility_timeout_seconds to at least 6x your Lambda/consumer processing time. Too short causes duplicate processing; too long delays failure detection. Both waste SQS costs.', file: fp, fix: null });
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return findings;
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
// COST-072: No HTTP connection keep-alive
|
|
1203
|
+
{ id: 'COST-072', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Outbound HTTP Requests Without Connection Keep-Alive',
|
|
1204
|
+
check({ files, stack }) {
|
|
1205
|
+
const findings = [];
|
|
1206
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1207
|
+
const hasAxios = 'axios' in allDeps || 'got' in allDeps;
|
|
1208
|
+
const hasKeepAlive = [...files.values()].some(c => c.match(/keepAlive:\s*true|keep-alive|KeepAliveAgent|https\.Agent\s*\(/));
|
|
1209
|
+
if (hasAxios && !hasKeepAlive) {
|
|
1210
|
+
findings.push({ ruleId: 'COST-072', category: 'cost', severity: 'medium', title: 'HTTP client without keep-alive — new TCP connection per request', description: 'Configure axios with a keepAlive HTTP agent. Without keep-alive, every outbound HTTP request opens a new TCP connection, adding 50-200ms overhead and increasing compute costs.', fix: null });
|
|
1211
|
+
}
|
|
1212
|
+
return findings;
|
|
1213
|
+
},
|
|
1214
|
+
},
|
|
1215
|
+
// COST-073: Lambda zip deployment vs container image for large packages
|
|
1216
|
+
{ id: 'COST-073', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Large Lambda Function Could Benefit from Container Image',
|
|
1217
|
+
check({ files, stack }) {
|
|
1218
|
+
const findings = [];
|
|
1219
|
+
const hasBinaryDeps = ['sharp', 'puppeteer', 'canvas', 'aws-sdk', 'ffmpeg'].some(d => d in (stack.dependencies || {}));
|
|
1220
|
+
const isLambda = [...files.keys()].some(f => f.match(/serverless\.yml|lambda.*handler|handler.*lambda/i));
|
|
1221
|
+
const usesContainer = [...files.values()].some(c => c.match(/ecr.*lambda|lambda.*container|image.*uri|package_type.*image/i));
|
|
1222
|
+
if (hasBinaryDeps && isLambda && !usesContainer) {
|
|
1223
|
+
findings.push({ ruleId: 'COST-073', category: 'cost', severity: 'low', title: 'Lambda with large native dependencies — consider container image deployment', description: 'Lambda container images support up to 10GB vs 250MB for zip packages. Large native dependencies (sharp, puppeteer, canvas) fit better as container images, reducing cold start failures.', fix: null });
|
|
1224
|
+
}
|
|
1225
|
+
return findings;
|
|
1226
|
+
},
|
|
1227
|
+
},
|
|
1228
|
+
// COST-074: No cache-busting for static assets
|
|
1229
|
+
{ id: 'COST-074', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Static Assets Without Cache-Busting Hash in Filename',
|
|
1230
|
+
check({ files }) {
|
|
1231
|
+
const findings = [];
|
|
1232
|
+
for (const [fp, c] of files) {
|
|
1233
|
+
if (!fp.match(/webpack\.config|vite\.config|rollup\.config/)) continue;
|
|
1234
|
+
if (!c.match(/contenthash|chunkhash|hash\]|fingerprint/i)) {
|
|
1235
|
+
findings.push({ ruleId: 'COST-074', category: 'cost', severity: 'medium', title: 'Static assets without content hash in filename — prevents effective CDN caching', description: 'Add [contenthash] to output filenames in webpack/vite. Without content hashing, you must use short cache TTLs (or no cache) to avoid serving stale assets, increasing CDN origin requests and costs.', file: fp, fix: null });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return findings;
|
|
1239
|
+
},
|
|
1240
|
+
},
|
|
1241
|
+
// COST-075: Unused CloudWatch metric filters
|
|
1242
|
+
{ id: 'COST-075', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'CloudWatch Metric Filters on High-Volume Log Groups',
|
|
1243
|
+
check({ files }) {
|
|
1244
|
+
const findings = [];
|
|
1245
|
+
for (const [fp, c] of files) {
|
|
1246
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
1247
|
+
const filterCount = (c.match(/aws_cloudwatch_log_metric_filter/g) || []).length;
|
|
1248
|
+
if (filterCount > 10) {
|
|
1249
|
+
findings.push({ ruleId: 'COST-075', category: 'cost', severity: 'low', title: `${filterCount} CloudWatch metric filters — evaluate if all are needed`, description: 'CloudWatch charges $0.02 per metric filter per month. Audit metric filters for active use; remove unused ones. Consider using CloudWatch Logs Insights for ad-hoc analysis instead.', file: fp, fix: null });
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return findings;
|
|
1253
|
+
},
|
|
1254
|
+
},
|
|
1255
|
+
// COST-076: Provisioned Throughput for low-traffic API Gateway
|
|
1256
|
+
{ id: 'COST-076', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'API Gateway with Unnecessary Caching Configuration',
|
|
1257
|
+
check({ files }) {
|
|
1258
|
+
const findings = [];
|
|
1259
|
+
for (const [fp, c] of files) {
|
|
1260
|
+
if (!fp.match(/\.tf$|serverless\.yml|serverless\.yaml$/)) continue;
|
|
1261
|
+
if (c.match(/aws_api_gateway_stage|apiGateway:/i) && c.match(/cache_cluster_enabled\s*=\s*true|caching.*Enabled.*true/i)) {
|
|
1262
|
+
if (!c.match(/cache_cluster_size\s*=\s*0\.|0\.5/i)) {
|
|
1263
|
+
findings.push({ ruleId: 'COST-076', category: 'cost', severity: 'low', title: 'API Gateway cache cluster enabled — costs $15-$70/month per stage', description: 'Evaluate if API Gateway caching is cost-effective vs CloudFront. API Gateway caching charges per hour regardless of hits; CloudFront charges per request. For most APIs, CloudFront is cheaper.', file: fp, fix: null });
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return findings;
|
|
1268
|
+
},
|
|
1269
|
+
},
|
|
1270
|
+
// COST-077: Missing timeout on long-running database queries
|
|
1271
|
+
{ id: 'COST-077', category: 'cost', severity: 'high', confidence: 'likely', title: 'Database Queries Without Statement Timeout',
|
|
1272
|
+
check({ files }) {
|
|
1273
|
+
const findings = [];
|
|
1274
|
+
for (const [fp, c] of files) {
|
|
1275
|
+
if (!isSourceFile(fp)) continue;
|
|
1276
|
+
const hasLongOps = c.match(/GROUP BY|aggregat|COUNT\s*\(|SUM\s*\(|AVG\s*\(|JOIN.*JOIN/i);
|
|
1277
|
+
const hasTimeout = c.match(/statement_timeout|query_timeout|commandTimeout|lock_timeout|statement.*timeout/i);
|
|
1278
|
+
if (hasLongOps && !hasTimeout) {
|
|
1279
|
+
findings.push({ ruleId: 'COST-077', category: 'cost', severity: 'high', title: 'Complex aggregation queries without statement timeout', description: 'Set statement_timeout on complex queries. Unbounded aggregation queries can run for minutes, consuming expensive RDS compute and blocking other queries.', file: fp, fix: null });
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
return findings;
|
|
1283
|
+
},
|
|
1284
|
+
},
|
|
1285
|
+
// COST-078: Generating PDF/reports on every request
|
|
1286
|
+
{ id: 'COST-078', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'PDF/Report Generation Not Cached',
|
|
1287
|
+
check({ files, stack }) {
|
|
1288
|
+
const findings = [];
|
|
1289
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1290
|
+
const hasPDF = ['puppeteer', 'playwright', 'pdfkit', 'jspdf', 'html-pdf', 'wkhtmltopdf'].some(d => d in allDeps);
|
|
1291
|
+
const cachesPDF = [...files.values()].some(c => c.match(/cache.*pdf|pdf.*cache|s3.*report|cloudfront.*pdf/i));
|
|
1292
|
+
if (hasPDF && !cachesPDF) {
|
|
1293
|
+
findings.push({ ruleId: 'COST-078', category: 'cost', severity: 'medium', title: 'PDF/report generation without caching — recomputed on every request', description: 'Cache generated PDFs in S3 and serve via CloudFront. Headless browser PDF generation uses 512MB+ RAM and takes 2-5 seconds. Regenerating the same report repeatedly wastes significant compute cost.', fix: null });
|
|
1294
|
+
}
|
|
1295
|
+
return findings;
|
|
1296
|
+
},
|
|
1297
|
+
},
|
|
1298
|
+
// COST-079: Lambda functions triggered by S3 events on entire bucket
|
|
1299
|
+
{ id: 'COST-079', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Lambda S3 Trigger Without Prefix Filter',
|
|
1300
|
+
check({ files }) {
|
|
1301
|
+
const findings = [];
|
|
1302
|
+
for (const [fp, c] of files) {
|
|
1303
|
+
if (!fp.match(/\.tf$|serverless\.yml|\.yaml$/)) continue;
|
|
1304
|
+
if (c.match(/s3.*events|events.*s3|S3Event|existing_s3/i)) {
|
|
1305
|
+
if (!c.match(/prefix:|filter_prefix|rules:\s*\n\s*-\s*prefix|filterRules/i)) {
|
|
1306
|
+
findings.push({ ruleId: 'COST-079', category: 'cost', severity: 'low', title: 'S3 Lambda trigger without prefix filter — triggered by every object in bucket', description: 'Add a prefix filter to S3 event notifications. Without filtering, the Lambda is triggered by all bucket events including intermediate processing files, multiplying invocation costs.', file: fp, fix: null });
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return findings;
|
|
1311
|
+
},
|
|
1312
|
+
},
|
|
1313
|
+
// COST-080: Large WebSocket messages instead of binary protocol
|
|
1314
|
+
{ id: 'COST-080', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'WebSocket Using JSON for High-Frequency Messages',
|
|
1315
|
+
check({ files, stack }) {
|
|
1316
|
+
const findings = [];
|
|
1317
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1318
|
+
const hasWS = ['ws', 'socket.io', 'uWebSockets.js'].some(d => d in allDeps);
|
|
1319
|
+
const hasBinaryProtocol = [...files.values()].some(c => c.match(/protobuf|msgpack|binary.*socket|Buffer.*send|BINARY/i));
|
|
1320
|
+
const hasHighFreqWS = [...files.values()].some(c => c.match(/emit\s*\(.*position|emit\s*\(.*update|emit\s*\(.*sensor|broadcast.*data/i));
|
|
1321
|
+
if (hasWS && hasHighFreqWS && !hasBinaryProtocol) {
|
|
1322
|
+
findings.push({ ruleId: 'COST-080', category: 'cost', severity: 'low', title: 'High-frequency WebSocket events using JSON — consider binary protocol', description: 'Use MessagePack or Protocol Buffers for high-frequency real-time data. Binary protocols are 50-70% smaller than JSON, reducing data transfer costs for real-time applications.', fix: null });
|
|
1323
|
+
}
|
|
1324
|
+
return findings;
|
|
1325
|
+
},
|
|
1326
|
+
},
|
|
1327
|
+
];
|
|
1328
|
+
|
|
1329
|
+
export default rules;
|
|
1330
|
+
|
|
1331
|
+
// COST-081: Lambda sync invocation in loop
|
|
1332
|
+
rules.push({
|
|
1333
|
+
id: 'COST-081', category: 'cost', severity: 'high', confidence: 'likely', title: 'Lambda invoked synchronously in a loop — high latency and cost',
|
|
1334
|
+
check({ files }) {
|
|
1335
|
+
const findings = [];
|
|
1336
|
+
for (const [fp, c] of files) {
|
|
1337
|
+
if (!isSourceFile(fp)) continue;
|
|
1338
|
+
const lines = c.split('\n');
|
|
1339
|
+
let inLoop = 0;
|
|
1340
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1341
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1342
|
+
if (/\b(?:for|while)\s*\(/.test(lines[i])) inLoop++;
|
|
1343
|
+
if (/^\s*\}/.test(lines[i]) && inLoop > 0) inLoop--;
|
|
1344
|
+
if (inLoop > 0 && /lambda\.invoke|lambda\.invokeAsync|InvocationType.*RequestResponse/i.test(lines[i])) {
|
|
1345
|
+
findings.push({ ruleId: 'COST-081', category: 'cost', severity: 'high', title: 'Lambda invoked inside loop — N sequential Lambda calls, use batch or async', description: 'Invoking Lambda synchronously in a loop multiplies cost and latency linearly. Use Lambda.invokeAsync with Event invocation type or batch with SQS for parallel processing.', file: fp, line: i + 1, fix: null });
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return findings;
|
|
1350
|
+
},
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
// COST-082: Missing Lambda concurrency limit
|
|
1354
|
+
rules.push({
|
|
1355
|
+
id: 'COST-082', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Lambda without reserved concurrency limit — runaway cost risk',
|
|
1356
|
+
check({ files }) {
|
|
1357
|
+
const findings = [];
|
|
1358
|
+
for (const [fp, c] of files) {
|
|
1359
|
+
if (!fp.match(/\.tf$|serverless\.ya?ml$|serverless\.ts$|\.json$/)) continue;
|
|
1360
|
+
if (!c.match(/aws_lambda_function|lambda.*handler|functions:/i)) continue;
|
|
1361
|
+
if (!c.match(/reserved_concurrent_executions|reservedConcurrencyConfig|reservedConcurrency/i)) {
|
|
1362
|
+
findings.push({ ruleId: 'COST-082', category: 'cost', severity: 'medium', title: 'Lambda without reserved concurrency — traffic spike can exhaust account limit and explode costs', description: 'Set reserved_concurrent_executions to cap maximum concurrency. Without a limit, a traffic spike can spawn thousands of concurrent executions, resulting in unexpected costs.', file: fp, fix: null });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
return findings;
|
|
1366
|
+
},
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
// COST-083: Missing CDN for static assets
|
|
1370
|
+
rules.push({
|
|
1371
|
+
id: 'COST-083', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Static assets served from origin without CDN',
|
|
1372
|
+
check({ files }) {
|
|
1373
|
+
const findings = [];
|
|
1374
|
+
const hasStatic = [...files.values()].some(c => /express\.static|serve-static|sendFile.*public|readFile.*static/i.test(c));
|
|
1375
|
+
const hasCDN = [...files.values()].some(c => /cloudfront|cloudflare|cdn\.|fastly|akamai|CDN_URL/i.test(c));
|
|
1376
|
+
if (hasStatic && !hasCDN) {
|
|
1377
|
+
findings.push({ ruleId: 'COST-083', category: 'cost', severity: 'medium', title: 'Static assets served from origin without CDN — high bandwidth cost', description: 'Serving static assets directly from your server wastes bandwidth and compute. Use CloudFront, Cloudflare, or similar CDN to cache assets at edge locations.', fix: null });
|
|
1378
|
+
}
|
|
1379
|
+
return findings;
|
|
1380
|
+
},
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
// COST-084: Uncompressed API responses
|
|
1384
|
+
rules.push({
|
|
1385
|
+
id: 'COST-084', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'API responses not compressed (no gzip/brotli)',
|
|
1386
|
+
check({ files }) {
|
|
1387
|
+
const findings = [];
|
|
1388
|
+
const hasExpress = [...files.values()].some(c => /require.*express|from.*express/i.test(c));
|
|
1389
|
+
const hasCompression = [...files.values()].some(c => /compression\s*\(\)|compress\s*\(\)|gzip|brotli|zlib/i.test(c));
|
|
1390
|
+
if (hasExpress && !hasCompression) {
|
|
1391
|
+
findings.push({ ruleId: 'COST-084', category: 'cost', severity: 'medium', title: 'Express app without compression middleware — full-size responses increase bandwidth cost', description: 'Add compression middleware: app.use(require("compression")()). Gzip/Brotli typically reduces response size by 60-80%, reducing bandwidth costs and improving latency.', fix: null });
|
|
1392
|
+
}
|
|
1393
|
+
return findings;
|
|
1394
|
+
},
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// COST-085: Polling instead of webhooks
|
|
1398
|
+
rules.push({
|
|
1399
|
+
id: 'COST-085', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Polling loop instead of webhooks or Server-Sent Events',
|
|
1400
|
+
check({ files }) {
|
|
1401
|
+
const findings = [];
|
|
1402
|
+
for (const [fp, c] of files) {
|
|
1403
|
+
if (!isSourceFile(fp)) continue;
|
|
1404
|
+
const lines = c.split('\n');
|
|
1405
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1406
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1407
|
+
if (/setInterval\s*\(\s*(?:async\s*)?\(/.test(lines[i])) {
|
|
1408
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
|
|
1409
|
+
if (/fetch\s*\(|axios\.|http\.request/.test(ctx)) {
|
|
1410
|
+
findings.push({ ruleId: 'COST-085', category: 'cost', severity: 'medium', title: 'Polling with setInterval + HTTP request — replace with webhooks or SSE', description: 'Periodic polling makes unnecessary API calls even when there\'s no new data. Use webhooks (push model), Server-Sent Events, or WebSockets for real-time updates.', file: fp, line: i + 1, fix: null });
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return findings;
|
|
1416
|
+
},
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
// COST-086: N+1 queries in GraphQL resolvers
|
|
1420
|
+
rules.push({
|
|
1421
|
+
id: 'COST-086', category: 'cost', severity: 'high', confidence: 'likely', title: 'N+1 query pattern in GraphQL resolver',
|
|
1422
|
+
check({ files }) {
|
|
1423
|
+
const findings = [];
|
|
1424
|
+
for (const [fp, c] of files) {
|
|
1425
|
+
if (!isSourceFile(fp)) continue;
|
|
1426
|
+
const lines = c.split('\n');
|
|
1427
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1428
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1429
|
+
if (/(?:resolve|Query|Mutation)\s*[:=]/.test(lines[i]) || /resolver/.test(fp)) {
|
|
1430
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 15)).join('\n');
|
|
1431
|
+
if (/\.findById\s*\(|\.findOne\s*\(/.test(ctx) && /\.map\s*\(|forEach/.test(ctx) && !/DataLoader|dataloader|batch/i.test(ctx)) {
|
|
1432
|
+
findings.push({ ruleId: 'COST-086', category: 'cost', severity: 'high', title: 'N+1 query in resolver — use DataLoader for batching', description: 'A DB query inside a list resolver fires once per list item (N+1 queries). Use DataLoader to batch and cache: return userLoader.load(parent.userId).', file: fp, line: i + 1, fix: null });
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return findings;
|
|
1438
|
+
},
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
// COST-087: Uncached expensive computation per request
|
|
1442
|
+
rules.push({
|
|
1443
|
+
id: 'COST-087', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Expensive computation repeated per request without caching',
|
|
1444
|
+
check({ files }) {
|
|
1445
|
+
const findings = [];
|
|
1446
|
+
for (const [fp, c] of files) {
|
|
1447
|
+
if (!isSourceFile(fp)) continue;
|
|
1448
|
+
const lines = c.split('\n');
|
|
1449
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1450
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1451
|
+
if (/(?:sort\s*\(|aggregate\s*\(|GROUP BY|SUM\s*\(|COUNT\s*\()/.test(lines[i])) {
|
|
1452
|
+
const ctx = lines.slice(Math.max(0, i - 8), i).join('\n');
|
|
1453
|
+
if (/router\.|app\.(get|post)|handler|req,\s*res/.test(ctx) && !/cache|redis|memcache/i.test(ctx)) {
|
|
1454
|
+
findings.push({ ruleId: 'COST-087', category: 'cost', severity: 'medium', title: 'Aggregate/sort query in request handler without caching', description: 'Running aggregation queries on every request is expensive. Cache results in Redis or memory for frequently requested but infrequently changed data.', file: fp, line: i + 1, fix: null });
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
return findings;
|
|
1460
|
+
},
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
// COST-088: S3 GET/PUT in tight loop
|
|
1464
|
+
rules.push({
|
|
1465
|
+
id: 'COST-088', category: 'cost', severity: 'high', confidence: 'likely', title: 'S3 GET/PUT called in a loop — high request count and cost',
|
|
1466
|
+
check({ files }) {
|
|
1467
|
+
const findings = [];
|
|
1468
|
+
for (const [fp, c] of files) {
|
|
1469
|
+
if (!isSourceFile(fp)) continue;
|
|
1470
|
+
const lines = c.split('\n');
|
|
1471
|
+
let inLoop = 0;
|
|
1472
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1473
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1474
|
+
if (/\b(?:for|while)\s*\(/.test(lines[i])) inLoop++;
|
|
1475
|
+
if (/^\s*\}/.test(lines[i]) && inLoop > 0) inLoop--;
|
|
1476
|
+
if (inLoop > 0 && /s3\.(?:getObject|putObject|upload|getSignedUrl)/i.test(lines[i])) {
|
|
1477
|
+
findings.push({ ruleId: 'COST-088', category: 'cost', severity: 'high', title: 'S3 API called in loop — use bulk operations or presigned URL batch', description: 'S3 API calls inside loops incur per-request costs and throttling. Use S3 batch operations, multipart upload, or generate multiple presigned URLs in one call.', file: fp, line: i + 1, fix: null });
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
return findings;
|
|
1482
|
+
},
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
// COST-089: Over-provisioned Lambda memory
|
|
1486
|
+
rules.push({
|
|
1487
|
+
id: 'COST-089', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Lambda configured with very high memory (>=3008 MB)',
|
|
1488
|
+
check({ files }) {
|
|
1489
|
+
const findings = [];
|
|
1490
|
+
for (const [fp, c] of files) {
|
|
1491
|
+
if (!fp.match(/\.tf$|serverless\.ya?ml$|\.json$/)) continue;
|
|
1492
|
+
const m = c.match(/memory(?:_size)?\s*[:=]\s*(\d{4,})/i);
|
|
1493
|
+
if (m && parseInt(m[1]) >= 3008) {
|
|
1494
|
+
findings.push({ ruleId: 'COST-089', category: 'cost', severity: 'low', title: `Lambda memory set to ${m[1]}MB — verify this is necessary`, description: 'Lambda cost is proportional to memory × duration. Measure actual memory usage with AWS Lambda Power Tuning and right-size to reduce costs.', file: fp, fix: null });
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return findings;
|
|
1498
|
+
},
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
// COST-090: No pagination on expensive queries
|
|
1502
|
+
rules.push({
|
|
1503
|
+
id: 'COST-090', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Expensive query without pagination — unbounded result set',
|
|
1504
|
+
check({ files }) {
|
|
1505
|
+
const findings = [];
|
|
1506
|
+
for (const [fp, c] of files) {
|
|
1507
|
+
if (!isSourceFile(fp)) continue;
|
|
1508
|
+
const lines = c.split('\n');
|
|
1509
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1510
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1511
|
+
if (/\.findMany\s*\(\s*\)|\.findAll\s*\(\s*\)|SELECT \* FROM/i.test(lines[i])) {
|
|
1512
|
+
findings.push({ ruleId: 'COST-090', category: 'cost', severity: 'medium', title: 'Query with no filters or pagination — full table scan', description: 'Queries that return all records increase DB compute cost and data transfer costs linearly with table growth. Always paginate with take/limit.', file: fp, line: i + 1, fix: null });
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
return findings;
|
|
1517
|
+
},
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
// COST-091: Missing CloudWatch log retention
|
|
1521
|
+
rules.push({
|
|
1522
|
+
id: 'COST-091', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'CloudWatch log group without retention policy',
|
|
1523
|
+
check({ files }) {
|
|
1524
|
+
const findings = [];
|
|
1525
|
+
for (const [fp, c] of files) {
|
|
1526
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
1527
|
+
if (c.match(/aws_cloudwatch_log_group/) && !c.match(/retention_in_days/)) {
|
|
1528
|
+
findings.push({ ruleId: 'COST-091', category: 'cost', severity: 'low', title: 'CloudWatch log group without retention — logs stored indefinitely', description: 'CloudWatch logs stored indefinitely accrue significant storage costs. Set retention_in_days to 30, 60, or 90 days depending on compliance requirements.', file: fp, fix: null });
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
return findings;
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
// COST-092: DynamoDB scan instead of query
|
|
1536
|
+
rules.push({
|
|
1537
|
+
id: 'COST-092', category: 'cost', severity: 'high', confidence: 'likely', title: 'DynamoDB Scan operation used — reads entire table',
|
|
1538
|
+
check({ files }) {
|
|
1539
|
+
const findings = [];
|
|
1540
|
+
for (const [fp, c] of files) {
|
|
1541
|
+
if (!isSourceFile(fp)) continue;
|
|
1542
|
+
const lines = c.split('\n');
|
|
1543
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1544
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1545
|
+
if (/dynamodb\.\s*scan\s*\(|DynamoDB.*\.scan\s*\(/i.test(lines[i])) {
|
|
1546
|
+
findings.push({ ruleId: 'COST-092', category: 'cost', severity: 'high', title: 'DynamoDB Scan — reads every item, O(table size) cost', description: 'DynamoDB Scan reads every item in the table, consuming read capacity proportional to table size. Use Query with a partition key, or add a GSI for access patterns.', file: fp, line: i + 1, fix: null });
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return findings;
|
|
1551
|
+
},
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
// COST-093: No request deduplication for idempotent operations
|
|
1555
|
+
rules.push({
|
|
1556
|
+
id: 'COST-093', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'External API calls without deduplication or request coalescing',
|
|
1557
|
+
check({ files }) {
|
|
1558
|
+
const findings = [];
|
|
1559
|
+
const hasFetch = [...files.values()].some(c => /fetch\s*\(|axios\.get\s*\(/.test(c));
|
|
1560
|
+
const hasDedup = [...files.values()].some(c => /dedup|coalesce|dataloader|cache.*request|request.*cache|memoize/i.test(c));
|
|
1561
|
+
if (hasFetch && !hasDedup) {
|
|
1562
|
+
findings.push({ ruleId: 'COST-093', category: 'cost', severity: 'low', title: 'No request deduplication — simultaneous identical requests make multiple API calls', description: 'Use DataLoader or a request cache to deduplicate identical concurrent requests. This reduces external API costs and improves performance under load.', fix: null });
|
|
1563
|
+
}
|
|
1564
|
+
return findings;
|
|
1565
|
+
},
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
// COST-094 through COST-120: Additional cost rules
|
|
1569
|
+
|
|
1570
|
+
// COST-094: SQS long polling not enabled
|
|
1571
|
+
rules.push({
|
|
1572
|
+
id: 'COST-094', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'SQS queue without long polling — excess API calls',
|
|
1573
|
+
check({ files }) {
|
|
1574
|
+
const findings = [];
|
|
1575
|
+
for (const [fp, c] of files) {
|
|
1576
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
1577
|
+
if (c.match(/aws_sqs_queue/) && !c.match(/receive_wait_time_seconds\s*=\s*[1-9]\d*/)) {
|
|
1578
|
+
findings.push({ ruleId: 'COST-094', category: 'cost', severity: 'low', title: 'SQS queue without long polling (receive_wait_time_seconds) — excessive ReceiveMessage API calls', description: 'Set receive_wait_time_seconds = 20 to enable long polling. Short polling checks for messages even when the queue is empty, incurring unnecessary API charges.', file: fp, fix: null });
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return findings;
|
|
1582
|
+
},
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
// COST-095: Unoptimized Docker image size
|
|
1586
|
+
rules.push({
|
|
1587
|
+
id: 'COST-095', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Dockerfile not using slim or alpine base image',
|
|
1588
|
+
check({ files }) {
|
|
1589
|
+
const findings = [];
|
|
1590
|
+
for (const [fp, c] of files) {
|
|
1591
|
+
if (!fp.match(/Dockerfile/i)) continue;
|
|
1592
|
+
const fromLine = c.match(/^FROM\s+node:(\d+)\s*$/m);
|
|
1593
|
+
if (fromLine) {
|
|
1594
|
+
findings.push({ ruleId: 'COST-095', category: 'cost', severity: 'low', title: 'Using full node image — use node:XX-alpine or node:XX-slim to reduce image size', description: 'Full node images are 350MB+. Alpine-based images are ~50MB. Smaller images reduce ECR storage costs, data transfer costs, and deployment time.', file: fp, fix: null });
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
return findings;
|
|
1598
|
+
},
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
// COST-096: S3 lifecycle policy missing
|
|
1602
|
+
rules.push({
|
|
1603
|
+
id: 'COST-096', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'S3 bucket without lifecycle policy — old objects accumulate indefinitely',
|
|
1604
|
+
check({ files }) {
|
|
1605
|
+
const findings = [];
|
|
1606
|
+
for (const [fp, c] of files) {
|
|
1607
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
1608
|
+
if (c.match(/resource\s+["']aws_s3_bucket["']/) && !c.match(/aws_s3_bucket_lifecycle_configuration|lifecycle_rule/)) {
|
|
1609
|
+
findings.push({ ruleId: 'COST-096', category: 'cost', severity: 'medium', title: 'S3 bucket without lifecycle policy — storage costs grow unbounded', description: 'Add S3 lifecycle rules to transition old objects to cheaper storage classes (Glacier) and expire objects that are no longer needed.', file: fp, fix: null });
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return findings;
|
|
1613
|
+
},
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
// COST-097: EC2 instance type not cost-optimized
|
|
1617
|
+
rules.push({
|
|
1618
|
+
id: 'COST-097', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'EC2 instance type may be over-provisioned',
|
|
1619
|
+
check({ files }) {
|
|
1620
|
+
const findings = [];
|
|
1621
|
+
for (const [fp, c] of files) {
|
|
1622
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
1623
|
+
if (c.match(/instance_type\s*=\s*["'](?:m5\.4xlarge|m5\.8xlarge|c5\.4xlarge|r5\.4xlarge|m4\.4xlarge|c4\.4xlarge)/)) {
|
|
1624
|
+
findings.push({ ruleId: 'COST-097', category: 'cost', severity: 'low', title: 'Large EC2 instance type — verify utilization and consider downsizing', description: 'Large instance types significantly increase compute costs. Use AWS Compute Optimizer to analyze actual CPU/memory usage and right-size your instances.', file: fp, fix: null });
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
return findings;
|
|
1628
|
+
},
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
// COST-098: No reserved capacity for steady-state workloads
|
|
1632
|
+
rules.push({
|
|
1633
|
+
id: 'COST-098', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'No Reserved Instances or Savings Plans — paying on-demand rates',
|
|
1634
|
+
check({ files }) {
|
|
1635
|
+
const findings = [];
|
|
1636
|
+
const hasTF = [...files.keys()].some(f => f.match(/\.tf$/));
|
|
1637
|
+
const hasReserved = [...files.values()].some(c => /aws_reserved_instance|savings_plan|aws_ec2_capacity_reservation/i.test(c));
|
|
1638
|
+
const hasSignificantInfra = [...files.values()].some(c => /aws_instance|aws_ecs_service|aws_eks_node_group/i.test(c));
|
|
1639
|
+
if (hasTF && hasSignificantInfra && !hasReserved) {
|
|
1640
|
+
findings.push({ ruleId: 'COST-098', category: 'cost', severity: 'low', title: 'EC2/ECS/EKS without Reserved Instances or Savings Plans — paying on-demand premium', description: 'For steady-state workloads running 24/7, Compute Savings Plans or Reserved Instances save 40-60% compared to on-demand pricing.', fix: null });
|
|
1641
|
+
}
|
|
1642
|
+
return findings;
|
|
1643
|
+
},
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
// COST-099: Multiple small Lambda functions that could be consolidated
|
|
1647
|
+
rules.push({
|
|
1648
|
+
id: 'COST-099', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Many small Lambda functions — consider consolidation',
|
|
1649
|
+
check({ files }) {
|
|
1650
|
+
const findings = [];
|
|
1651
|
+
const hasTF = [...files.keys()].some(f => f.match(/\.tf$/));
|
|
1652
|
+
const lambdaCount = [...files.values()].reduce((n, c) => n + (c.match(/resource\s+["']aws_lambda_function["']/g) || []).length, 0);
|
|
1653
|
+
if (hasTF && lambdaCount > 20) {
|
|
1654
|
+
findings.push({ ruleId: 'COST-099', category: 'cost', severity: 'low', title: `${lambdaCount} Lambda functions — consider consolidating infrequently-called functions`, description: 'Many small Lambda functions each have their own cold start overhead and management cost. Consider consolidating with routing logic or moving to container-based deployment.', fix: null });
|
|
1655
|
+
}
|
|
1656
|
+
return findings;
|
|
1657
|
+
},
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
// COST-100: Using synchronous external calls in high-traffic endpoints
|
|
1661
|
+
rules.push({
|
|
1662
|
+
id: 'COST-100', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Synchronous external API calls in high-traffic endpoint — latency multiplies cost',
|
|
1663
|
+
check({ files }) {
|
|
1664
|
+
const findings = [];
|
|
1665
|
+
for (const [fp, c] of files) {
|
|
1666
|
+
if (!isSourceFile(fp)) continue;
|
|
1667
|
+
const lines = c.split('\n');
|
|
1668
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1669
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1670
|
+
if (/await\s+(?:fetch|axios)\s*\(/.test(lines[i])) {
|
|
1671
|
+
const ctx = lines.slice(Math.max(0, i - 8), i).join('\n');
|
|
1672
|
+
if (/router\.(get|post|put)|app\.(get|post)|handler/i.test(ctx)) {
|
|
1673
|
+
const endpointCtx = lines.slice(Math.max(0, i - 8), i + 2).join('\n');
|
|
1674
|
+
if (!/cache|redis|cached/.test(endpointCtx)) {
|
|
1675
|
+
findings.push({ ruleId: 'COST-100', category: 'cost', severity: 'medium', title: 'Synchronous external HTTP call in request handler without caching', description: 'External API calls in request handlers add latency cost to every request. Cache responses where possible to reduce both latency and upstream API costs.', file: fp, line: i + 1, fix: null });
|
|
1676
|
+
break;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return findings;
|
|
1683
|
+
},
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
// COST-101 through COST-120
|
|
1687
|
+
|
|
1688
|
+
// COST-101: Missing auto-scaling configuration
|
|
1689
|
+
rules.push({
|
|
1690
|
+
id: 'COST-101', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'No auto-scaling configured — over-provisioned for off-peak',
|
|
1691
|
+
check({ files }) {
|
|
1692
|
+
const findings = [];
|
|
1693
|
+
const hasTF = [...files.keys()].some(f => f.match(/\.tf$/));
|
|
1694
|
+
const hasEC2orECS = [...files.values()].some(c => /aws_instance|aws_ecs_service/i.test(c));
|
|
1695
|
+
const hasScaling = [...files.values()].some(c => /aws_autoscaling|aws_appautoscaling|minCapacity|maxCapacity|desired_capacity/i.test(c));
|
|
1696
|
+
if (hasTF && hasEC2orECS && !hasScaling) {
|
|
1697
|
+
findings.push({ ruleId: 'COST-101', category: 'cost', severity: 'medium', title: 'EC2/ECS without auto-scaling — manually fixed capacity wastes money during off-peak', description: 'Configure auto-scaling to scale in during low-traffic periods. ECS Service Auto Scaling can reduce costs significantly for variable workloads.', fix: null });
|
|
1698
|
+
}
|
|
1699
|
+
return findings;
|
|
1700
|
+
},
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
// COST-102: Unnecessary console.log in production bundle
|
|
1704
|
+
rules.push({
|
|
1705
|
+
id: 'COST-102', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'console.log in production code increases log volume costs',
|
|
1706
|
+
check({ files }) {
|
|
1707
|
+
const findings = [];
|
|
1708
|
+
for (const [fp, c] of files) {
|
|
1709
|
+
if (!isSourceFile(fp) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1710
|
+
const logCount = (c.match(/console\.(?:log|debug|info)\s*\(/g) || []).length;
|
|
1711
|
+
if (logCount > 10) {
|
|
1712
|
+
findings.push({ ruleId: 'COST-102', category: 'cost', severity: 'low', title: `${logCount} console.log calls — verbose logging increases CloudWatch/log storage costs`, description: 'Excessive logging increases log storage and ingestion costs. Use structured logging with log levels and disable DEBUG logging in production.', file: fp, fix: null });
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
return findings;
|
|
1716
|
+
},
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
// COST-103: Lambda with too many layers
|
|
1720
|
+
rules.push({
|
|
1721
|
+
id: 'COST-103', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Lambda function with many layers — larger cold starts',
|
|
1722
|
+
check({ files }) {
|
|
1723
|
+
const findings = [];
|
|
1724
|
+
for (const [fp, c] of files) {
|
|
1725
|
+
if (!fp.match(/\.tf$|serverless\.ya?ml$/)) continue;
|
|
1726
|
+
const layerCount = (c.match(/aws_lambda_layer_version|layers:/g) || []).length;
|
|
1727
|
+
if (layerCount > 5) {
|
|
1728
|
+
findings.push({ ruleId: 'COST-103', category: 'cost', severity: 'low', title: 'Lambda with many layers — increases package size and cold start duration', description: 'Lambda supports up to 5 layers but each adds to the total package size. Consolidate layers to reduce cold start time and storage costs.', file: fp, fix: null });
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
return findings;
|
|
1732
|
+
},
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
// COST-104: HTTP keep-alive not configured
|
|
1736
|
+
rules.push({
|
|
1737
|
+
id: 'COST-104', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'HTTP connections not using keep-alive — overhead per request',
|
|
1738
|
+
check({ files }) {
|
|
1739
|
+
const findings = [];
|
|
1740
|
+
for (const [fp, c] of files) {
|
|
1741
|
+
if (!isSourceFile(fp)) continue;
|
|
1742
|
+
if (/new\s+https?\.Agent\s*\(\s*\)/.test(c) && !/keepAlive\s*:\s*true/.test(c)) {
|
|
1743
|
+
findings.push({ ruleId: 'COST-104', category: 'cost', severity: 'low', title: 'HTTP Agent without keepAlive: true — TCP connection overhead per request', description: 'Configure HTTP agents with { keepAlive: true } to reuse TCP connections and reduce latency and server resource usage for multiple requests.', file: fp, fix: null });
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return findings;
|
|
1747
|
+
},
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
// COST-105: Missing spot instances for batch workloads
|
|
1751
|
+
rules.push({
|
|
1752
|
+
id: 'COST-105', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Batch/background jobs on on-demand instances — use Spot',
|
|
1753
|
+
check({ files }) {
|
|
1754
|
+
const findings = [];
|
|
1755
|
+
for (const [fp, c] of files) {
|
|
1756
|
+
if (!fp.match(/\.tf$/)) continue;
|
|
1757
|
+
if (c.match(/aws_ecs_task_definition/) && c.match(/worker|batch|job|cron/i) && !c.match(/spot|FARGATE_SPOT|capacity_provider.*SPOT/i)) {
|
|
1758
|
+
findings.push({ ruleId: 'COST-105', category: 'cost', severity: 'low', title: 'Batch/worker tasks without Spot instances — 70% cost savings available', description: 'Use FARGATE_SPOT or EC2 Spot instances for fault-tolerant batch jobs and background workers. Spot pricing is 70-90% cheaper than on-demand.', file: fp, fix: null });
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
return findings;
|
|
1762
|
+
},
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
// COST-106: No connection reuse for database
|
|
1766
|
+
rules.push({
|
|
1767
|
+
id: 'COST-106', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Database connection not reused between Lambda invocations',
|
|
1768
|
+
check({ files }) {
|
|
1769
|
+
const findings = [];
|
|
1770
|
+
for (const [fp, c] of files) {
|
|
1771
|
+
if (!isSourceFile(fp)) continue;
|
|
1772
|
+
// Lambda handler that creates DB connection inside handler
|
|
1773
|
+
if (/exports\.handler|module\.exports.*handler|async.*event.*context/.test(c)) {
|
|
1774
|
+
if (/new\s+(?:Pool|Client|Sequelize|MongoClient)\s*\(/.test(c)) {
|
|
1775
|
+
const handlerIdx = c.match(/exports\.handler|module\.exports.*handler/)?.index || 0;
|
|
1776
|
+
const connectIdx = c.search(/new\s+(?:Pool|Client|Sequelize|MongoClient)\s*\(/);
|
|
1777
|
+
if (connectIdx > handlerIdx) {
|
|
1778
|
+
findings.push({ ruleId: 'COST-106', category: 'cost', severity: 'medium', title: 'DB connection created inside Lambda handler — new connection per invocation', description: 'Create DB connections outside the handler function to reuse them across warm invocations. Connection overhead adds latency and connection count to your database.', file: fp, fix: null });
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
return findings;
|
|
1784
|
+
},
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
// COST-107: Unused SQS messages not deleted
|
|
1788
|
+
rules.push({
|
|
1789
|
+
id: 'COST-107', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'SQS messages received but not deleted — incurs repeated polling costs',
|
|
1790
|
+
check({ files }) {
|
|
1791
|
+
const findings = [];
|
|
1792
|
+
for (const [fp, c] of files) {
|
|
1793
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
1794
|
+
if (/receiveMessage|sqs\.receive/i.test(c) && !/deleteMessage|sqs\.delete/i.test(c)) {
|
|
1795
|
+
findings.push({ ruleId: 'COST-107', category: 'cost', severity: 'medium', title: 'SQS messages received without deleteMessage call — messages reprocessed and cost multiplied', description: 'Always call sqs.deleteMessage() after successfully processing a message to avoid re-delivery charges.', file: fp, fix: null });
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
return findings;
|
|
1799
|
+
},
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
// COST-108: CloudFront without price class optimization
|
|
1803
|
+
rules.push({
|
|
1804
|
+
id: 'COST-108', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'CloudFront distribution using all edge locations (PriceClass_All)',
|
|
1805
|
+
check({ files }) {
|
|
1806
|
+
const findings = [];
|
|
1807
|
+
for (const [fp, c] of files) {
|
|
1808
|
+
if (!fp.endsWith('.tf')) continue;
|
|
1809
|
+
if (/resource\s+"aws_cloudfront_distribution"/.test(c) && /PriceClass_All/.test(c)) findings.push({ ruleId: 'COST-108', category: 'cost', severity: 'low', title: 'CloudFront using PriceClass_All — most expensive option', description: 'Consider PriceClass_100 or PriceClass_200 if you only serve specific geographic regions.', file: fp, fix: null });
|
|
1810
|
+
}
|
|
1811
|
+
return findings;
|
|
1812
|
+
},
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
// COST-109: RDS instance without reserved instance tags
|
|
1816
|
+
rules.push({
|
|
1817
|
+
id: 'COST-109', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Long-running RDS instance without Reserved Instance consideration',
|
|
1818
|
+
check({ files }) {
|
|
1819
|
+
const findings = [];
|
|
1820
|
+
for (const [fp, c] of files) {
|
|
1821
|
+
if (!fp.endsWith('.tf')) continue;
|
|
1822
|
+
if (/resource\s+"aws_db_instance"/.test(c) && !/reserved|lifecycle/.test(c) && /instance_class\s*=\s*"db\./.test(c)) findings.push({ ruleId: 'COST-109', category: 'cost', severity: 'medium', title: 'RDS instance without Reserved Instance planning — up to 60% savings available', description: 'For stable workloads, purchase Reserved Instances for RDS to save up to 60% over on-demand pricing.', file: fp, fix: null });
|
|
1823
|
+
}
|
|
1824
|
+
return findings;
|
|
1825
|
+
},
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
// COST-110: Lambda without ARM64 architecture
|
|
1829
|
+
rules.push({
|
|
1830
|
+
id: 'COST-110', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Lambda function not using ARM64 (Graviton2) — 20% cost savings available',
|
|
1831
|
+
check({ files }) {
|
|
1832
|
+
const findings = [];
|
|
1833
|
+
for (const [fp, c] of files) {
|
|
1834
|
+
if (!fp.endsWith('.tf')) continue;
|
|
1835
|
+
if (/resource\s+"aws_lambda_function"/.test(c) && !/architectures\s*=\s*\["arm64"\]/.test(c)) findings.push({ ruleId: 'COST-110', category: 'cost', severity: 'low', title: 'Lambda function using x86_64 instead of ARM64 — Graviton2 is ~20% cheaper', description: 'Set architectures = ["arm64"] in Lambda configuration for ~20% cost reduction with similar or better performance.', file: fp, fix: null });
|
|
1836
|
+
}
|
|
1837
|
+
return findings;
|
|
1838
|
+
},
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
// COST-111: Missing S3 intelligent-tiering
|
|
1842
|
+
rules.push({
|
|
1843
|
+
id: 'COST-111', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'S3 bucket without Intelligent-Tiering lifecycle rule',
|
|
1844
|
+
check({ files }) {
|
|
1845
|
+
const findings = [];
|
|
1846
|
+
for (const [fp, c] of files) {
|
|
1847
|
+
if (!fp.endsWith('.tf')) continue;
|
|
1848
|
+
if (/resource\s+"aws_s3_bucket"/.test(c) && !/INTELLIGENT_TIERING|intelligent_tiering/.test(c) && !/lifecycle/.test(c)) findings.push({ ruleId: 'COST-111', category: 'cost', severity: 'low', title: 'S3 bucket without lifecycle policy — data stored in Standard tier indefinitely', description: 'Add S3 lifecycle rules to transition infrequently accessed objects to cheaper storage tiers.', file: fp, fix: null });
|
|
1849
|
+
}
|
|
1850
|
+
return findings;
|
|
1851
|
+
},
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
// COST-112: ECS service with no auto-scaling
|
|
1855
|
+
rules.push({
|
|
1856
|
+
id: 'COST-112', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'ECS service without auto-scaling — always running maximum capacity',
|
|
1857
|
+
check({ files }) {
|
|
1858
|
+
const findings = [];
|
|
1859
|
+
for (const [fp, c] of files) {
|
|
1860
|
+
if (!fp.endsWith('.tf')) continue;
|
|
1861
|
+
if (/resource\s+"aws_ecs_service"/.test(c) && !/aws_appautoscaling_target|autoscaling/.test(c)) findings.push({ ruleId: 'COST-112', category: 'cost', severity: 'medium', title: 'ECS service without auto-scaling — wastes resources during low-traffic periods', description: 'Configure Application Auto Scaling for ECS services to scale based on CPU/memory metrics.', file: fp, fix: null });
|
|
1862
|
+
}
|
|
1863
|
+
return findings;
|
|
1864
|
+
},
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
// COST-113: Multiple await calls instead of Promise.all
|
|
1868
|
+
rules.push({
|
|
1869
|
+
id: 'COST-113', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Sequential await calls for independent operations — parallelize to reduce latency and cost',
|
|
1870
|
+
check({ files }) {
|
|
1871
|
+
const findings = [];
|
|
1872
|
+
for (const [fp, c] of files) {
|
|
1873
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts') && !fp.match(/\.[jt]sx?$/)) continue;
|
|
1874
|
+
const lines = c.split('\n');
|
|
1875
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
1876
|
+
const awaitMatch = lines[i].match(/^\s*(?:const|let|var)\s+\w+\s*=\s*await\s+\w+\s*\(/);
|
|
1877
|
+
const nextAwait = lines[i+1].match(/^\s*(?:const|let|var)\s+\w+\s*=\s*await\s+\w+\s*\(/);
|
|
1878
|
+
if (awaitMatch && nextAwait) {
|
|
1879
|
+
findings.push({ ruleId: 'COST-113', category: 'cost', severity: 'medium', title: 'Sequential awaits for independent operations — use Promise.all() for parallel execution', description: 'Replace sequential await calls with Promise.all([op1, op2]) to run operations in parallel.', file: fp, line: i + 1, fix: null });
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
return findings;
|
|
1884
|
+
},
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
// COST-114: Over-fetching GraphQL fields
|
|
1888
|
+
rules.push({
|
|
1889
|
+
id: 'COST-114', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'GraphQL query requesting all fields (may over-fetch data)',
|
|
1890
|
+
check({ files }) {
|
|
1891
|
+
const findings = [];
|
|
1892
|
+
for (const [fp, c] of files) {
|
|
1893
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts') && !fp.match(/\.[jt]sx?$/) && !fp.endsWith('.graphql') && !fp.endsWith('.gql')) continue;
|
|
1894
|
+
if (/\.\.\.\s*on\s+\w+\s*\{[\s\S]{0,50}__typename[\s\S]{0,50}\}/.test(c)) continue;
|
|
1895
|
+
if (/query\s*\w*\s*\{[\s\S]{0,50}\w+\s*\{[\s\S]{0,20}__typename/.test(c)) {
|
|
1896
|
+
findings.push({ ruleId: 'COST-114', category: 'cost', severity: 'medium', title: 'GraphQL query may be over-fetching data — request only needed fields', description: 'Only request fields you actually use in GraphQL queries to reduce data transfer and processing costs.', file: fp, fix: null });
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
return findings;
|
|
1900
|
+
},
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
// COST-115: No API response compression
|
|
1904
|
+
rules.push({
|
|
1905
|
+
id: 'COST-115', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Express API without response compression — higher data transfer costs',
|
|
1906
|
+
check({ files }) {
|
|
1907
|
+
const findings = [];
|
|
1908
|
+
for (const [fp, c] of files) {
|
|
1909
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
1910
|
+
if (/app\.listen|createServer/.test(c) && !/compression\s*\(\)|require.*compression|from.*compression|brotli|gzip/i.test(c)) {
|
|
1911
|
+
findings.push({ ruleId: 'COST-115', category: 'cost', severity: 'medium', title: 'Express server without compression middleware — uncompressed responses increase bandwidth costs', description: 'Add compression middleware: app.use(require("compression")()) to reduce response sizes by 60-80%.', file: fp, fix: null });
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return findings;
|
|
1915
|
+
},
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
// COST-116: Excessive Lambda memory allocation
|
|
1919
|
+
rules.push({
|
|
1920
|
+
id: 'COST-116', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Lambda function with very high memory allocation',
|
|
1921
|
+
check({ files }) {
|
|
1922
|
+
const findings = [];
|
|
1923
|
+
for (const [fp, c] of files) {
|
|
1924
|
+
if (!fp.endsWith('.tf') && !fp.endsWith('.json') && !fp.endsWith('.yml') && !fp.endsWith('.yaml')) continue;
|
|
1925
|
+
const m = c.match(/memory_size\s*=\s*(\d+)|MemorySize['":\s]+(\d+)/);
|
|
1926
|
+
if (m) {
|
|
1927
|
+
const mem = parseInt(m[1] || m[2]);
|
|
1928
|
+
if (mem >= 3008) findings.push({ ruleId: 'COST-116', category: 'cost', severity: 'medium', title: `Lambda with ${mem}MB memory — use AWS Lambda Power Tuning to find optimal allocation`, description: 'Run AWS Lambda Power Tuning to find the most cost-effective memory configuration.', file: fp, fix: null });
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
return findings;
|
|
1932
|
+
},
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
// COST-117: NAT Gateway for low-traffic use case
|
|
1936
|
+
rules.push({
|
|
1937
|
+
id: 'COST-117', category: 'cost', severity: 'medium', confidence: 'suggestion', title: 'Terraform NAT Gateway without considering VPC Endpoints',
|
|
1938
|
+
check({ files }) {
|
|
1939
|
+
const findings = [];
|
|
1940
|
+
for (const [fp, c] of files) {
|
|
1941
|
+
if (!fp.endsWith('.tf')) continue;
|
|
1942
|
+
if (/resource\s+"aws_nat_gateway"/.test(c) && !/aws_vpc_endpoint/.test(c)) findings.push({ ruleId: 'COST-117', category: 'cost', severity: 'medium', title: 'NAT Gateway used without VPC Endpoints — S3/DynamoDB traffic should use free VPC endpoints', description: 'Create VPC Endpoints for S3 and DynamoDB to avoid NAT Gateway charges for AWS service traffic.', file: fp, fix: null });
|
|
1943
|
+
}
|
|
1944
|
+
return findings;
|
|
1945
|
+
},
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
// COST-118: Unused Elastic IP addresses
|
|
1949
|
+
rules.push({
|
|
1950
|
+
id: 'COST-118', category: 'cost', severity: 'low', confidence: 'likely', title: 'Terraform Elastic IP without association — idle EIP incurs charges',
|
|
1951
|
+
check({ files }) {
|
|
1952
|
+
const findings = [];
|
|
1953
|
+
for (const [fp, c] of files) {
|
|
1954
|
+
if (!fp.endsWith('.tf')) continue;
|
|
1955
|
+
if (/resource\s+"aws_eip"/.test(c) && !/association|instance\s*=/.test(c)) findings.push({ ruleId: 'COST-118', category: 'cost', severity: 'low', title: 'Unattached Elastic IP address — idle EIPs incur $0.005/hour charge', description: 'Release unneeded Elastic IP addresses or associate them with running instances.', file: fp, fix: null });
|
|
1956
|
+
}
|
|
1957
|
+
return findings;
|
|
1958
|
+
},
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
// COST-119: No connection pooling for serverless DB
|
|
1962
|
+
rules.push({
|
|
1963
|
+
id: 'COST-119', category: 'cost', severity: 'high', confidence: 'likely', title: 'Serverless function without database connection pooling proxy',
|
|
1964
|
+
check({ files, stack }) {
|
|
1965
|
+
const findings = [];
|
|
1966
|
+
const hasLambda = [...files.keys()].some(f => /lambda|serverless|handler\.js/.test(f));
|
|
1967
|
+
if (!hasLambda) return findings;
|
|
1968
|
+
if ((stack.dependencies?.['mongoose'] || stack.dependencies?.['pg'] || stack.dependencies?.['mysql2']) && !/rds.proxy|pgBouncer|planetscale|aurora.serverless/i.test([...files.values()].join('\n'))) {
|
|
1969
|
+
const dbFiles = [...files.keys()].filter(f => (f.endsWith('.js') || f.endsWith('.ts')) && /db|database|model/.test(f));
|
|
1970
|
+
if (dbFiles.length > 0) findings.push({ ruleId: 'COST-119', category: 'cost', severity: 'high', title: 'Serverless function connecting directly to RDS — use RDS Proxy to reduce connection costs', description: 'Use RDS Proxy or PgBouncer to pool database connections from serverless functions.', file: dbFiles[0], fix: null });
|
|
1971
|
+
}
|
|
1972
|
+
return findings;
|
|
1973
|
+
},
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
// COST-120: Terraform resources without lifecycle prevent_destroy
|
|
1977
|
+
rules.push({
|
|
1978
|
+
id: 'COST-120', category: 'cost', severity: 'low', confidence: 'suggestion', title: 'Critical resources without Terraform lifecycle prevent_destroy',
|
|
1979
|
+
check({ files }) {
|
|
1980
|
+
const findings = [];
|
|
1981
|
+
const criticalResources = ['aws_db_instance', 'aws_elasticache_cluster', 'aws_s3_bucket', 'aws_dynamodb_table'];
|
|
1982
|
+
for (const [fp, c] of files) {
|
|
1983
|
+
if (!fp.endsWith('.tf')) continue;
|
|
1984
|
+
for (const res of criticalResources) {
|
|
1985
|
+
if (new RegExp(`resource\\s+"${res}"`).test(c) && !/prevent_destroy/.test(c)) {
|
|
1986
|
+
findings.push({ ruleId: 'COST-120', category: 'cost', severity: 'low', title: `Critical resource ${res} without prevent_destroy — accidental deletion risk`, description: 'Add lifecycle { prevent_destroy = true } to critical resources to prevent accidental deletion.', file: fp, fix: null });
|
|
1987
|
+
break;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
return findings;
|
|
1992
|
+
},
|
|
1993
|
+
});
|