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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/bin/doorman.js +444 -0
  4. package/package.json +74 -0
  5. package/src/ai-fixer.js +559 -0
  6. package/src/ast-scanner.js +434 -0
  7. package/src/auth.js +149 -0
  8. package/src/baseline.js +48 -0
  9. package/src/compliance.js +539 -0
  10. package/src/config.js +466 -0
  11. package/src/custom-rules.js +32 -0
  12. package/src/dashboard.js +202 -0
  13. package/src/detector.js +142 -0
  14. package/src/fix-engine.js +48 -0
  15. package/src/fix-registry-extra.js +95 -0
  16. package/src/fix-registry-go-rust.js +77 -0
  17. package/src/fix-registry-java-csharp.js +77 -0
  18. package/src/fix-registry-js.js +99 -0
  19. package/src/fix-registry-mcp-ai.js +57 -0
  20. package/src/fix-registry-python.js +87 -0
  21. package/src/fixer-ruby-php.js +608 -0
  22. package/src/fixer.js +2113 -0
  23. package/src/hooks.js +115 -0
  24. package/src/ignore.js +176 -0
  25. package/src/index.js +384 -0
  26. package/src/metrics.js +126 -0
  27. package/src/monorepo.js +65 -0
  28. package/src/presets.js +54 -0
  29. package/src/reporter.js +975 -0
  30. package/src/rule-worker.js +36 -0
  31. package/src/rules/ast-rules.js +756 -0
  32. package/src/rules/bugs/accessibility.js +235 -0
  33. package/src/rules/bugs/ai-codegen-fixable.js +172 -0
  34. package/src/rules/bugs/ai-codegen.js +365 -0
  35. package/src/rules/bugs/code-smell-bugs.js +247 -0
  36. package/src/rules/bugs/crypto-bugs.js +195 -0
  37. package/src/rules/bugs/docker-bugs.js +158 -0
  38. package/src/rules/bugs/general.js +361 -0
  39. package/src/rules/bugs/go-bugs.js +279 -0
  40. package/src/rules/bugs/index.js +73 -0
  41. package/src/rules/bugs/js-api.js +257 -0
  42. package/src/rules/bugs/js-array-object.js +210 -0
  43. package/src/rules/bugs/js-async-fixable.js +223 -0
  44. package/src/rules/bugs/js-async.js +211 -0
  45. package/src/rules/bugs/js-closure-scope.js +182 -0
  46. package/src/rules/bugs/js-database.js +203 -0
  47. package/src/rules/bugs/js-error-handling.js +148 -0
  48. package/src/rules/bugs/js-logic.js +261 -0
  49. package/src/rules/bugs/js-memory.js +214 -0
  50. package/src/rules/bugs/js-node.js +361 -0
  51. package/src/rules/bugs/js-react.js +373 -0
  52. package/src/rules/bugs/js-regex.js +200 -0
  53. package/src/rules/bugs/js-state.js +272 -0
  54. package/src/rules/bugs/js-type-coercion.js +318 -0
  55. package/src/rules/bugs/nextjs-bugs.js +242 -0
  56. package/src/rules/bugs/nextjs-fixable.js +120 -0
  57. package/src/rules/bugs/node-fixable.js +178 -0
  58. package/src/rules/bugs/python-advanced.js +245 -0
  59. package/src/rules/bugs/python-fixable.js +98 -0
  60. package/src/rules/bugs/python.js +284 -0
  61. package/src/rules/bugs/react-fixable.js +207 -0
  62. package/src/rules/bugs/ruby-bugs.js +182 -0
  63. package/src/rules/bugs/shell-bugs.js +181 -0
  64. package/src/rules/bugs/silent-failures.js +261 -0
  65. package/src/rules/bugs/ts-bugs.js +235 -0
  66. package/src/rules/bugs/unused-vars.js +65 -0
  67. package/src/rules/compliance/accessibility-ext.js +468 -0
  68. package/src/rules/compliance/education.js +322 -0
  69. package/src/rules/compliance/financial.js +421 -0
  70. package/src/rules/compliance/frameworks.js +507 -0
  71. package/src/rules/compliance/healthcare.js +520 -0
  72. package/src/rules/compliance/index.js +2714 -0
  73. package/src/rules/compliance/regional-eu.js +480 -0
  74. package/src/rules/compliance/regional-international.js +903 -0
  75. package/src/rules/cost/index.js +1993 -0
  76. package/src/rules/data/index.js +2503 -0
  77. package/src/rules/dependencies/index.js +1684 -0
  78. package/src/rules/deployment/index.js +2050 -0
  79. package/src/rules/index.js +71 -0
  80. package/src/rules/infrastructure/index.js +3048 -0
  81. package/src/rules/performance/index.js +3455 -0
  82. package/src/rules/quality/index.js +3175 -0
  83. package/src/rules/reliability/index.js +3040 -0
  84. package/src/rules/scope-rules.js +815 -0
  85. package/src/rules/security/ai-api.js +1177 -0
  86. package/src/rules/security/auth.js +1328 -0
  87. package/src/rules/security/cors.js +127 -0
  88. package/src/rules/security/crypto.js +527 -0
  89. package/src/rules/security/csharp.js +862 -0
  90. package/src/rules/security/csrf.js +193 -0
  91. package/src/rules/security/dart.js +835 -0
  92. package/src/rules/security/deserialization.js +291 -0
  93. package/src/rules/security/file-upload.js +187 -0
  94. package/src/rules/security/go.js +850 -0
  95. package/src/rules/security/headers.js +235 -0
  96. package/src/rules/security/index.js +65 -0
  97. package/src/rules/security/injection.js +1639 -0
  98. package/src/rules/security/mcp-server.js +71 -0
  99. package/src/rules/security/misconfiguration.js +660 -0
  100. package/src/rules/security/oauth-jwt.js +329 -0
  101. package/src/rules/security/path-traversal.js +295 -0
  102. package/src/rules/security/php.js +1054 -0
  103. package/src/rules/security/prototype-pollution.js +283 -0
  104. package/src/rules/security/rate-limiting.js +208 -0
  105. package/src/rules/security/ruby.js +1061 -0
  106. package/src/rules/security/rust.js +693 -0
  107. package/src/rules/security/secrets.js +747 -0
  108. package/src/rules/security/shell.js +647 -0
  109. package/src/rules/security/ssrf.js +298 -0
  110. package/src/rules/security/supply-chain-advanced.js +393 -0
  111. package/src/rules/security/supply-chain.js +734 -0
  112. package/src/rules/security/swift.js +835 -0
  113. package/src/rules/security/taint.js +27 -0
  114. package/src/rules/security/xss.js +520 -0
  115. package/src/scan-cache.js +71 -0
  116. package/src/scanner.js +710 -0
  117. package/src/scope-analyzer.js +685 -0
  118. package/src/share.js +88 -0
  119. package/src/taint.js +300 -0
  120. package/src/telemetry.js +183 -0
  121. package/src/tracer.js +190 -0
  122. package/src/upload.js +35 -0
  123. package/src/worker.js +31 -0
@@ -0,0 +1,3048 @@
1
+ function isDockerfile(f) { return f.endsWith('Dockerfile') || f.match(/Dockerfile\.\w+$/); }
2
+ function isComposeFile(f) { return f.match(/docker-compose\.ya?ml$/) || f.match(/compose\.ya?ml$/); }
3
+ function isTestFile(f) { return f.match(/\.(test|spec)\.\w+$/) || f.includes('__tests__') || f.includes('/test/'); }
4
+
5
+ const SENSITIVE_PORTS = ['22', '3306', '5432', '27017'];
6
+ const SENSITIVE_MOUNT_PATHS = ['/', '/etc', '/var/run/docker.sock', '/root', '/proc', '/sys', '/dev'];
7
+
8
+ const rules = [
9
+ // INFRA-001: Dockerfile running as root (no USER instruction)
10
+ {
11
+ id: 'INFRA-001',
12
+ category: 'infrastructure',
13
+ severity: 'high',
14
+ confidence: 'likely',
15
+ title: 'Docker Container Runs as Root',
16
+ check({ files }) {
17
+ const findings = [];
18
+ for (const [filepath, content] of files) {
19
+ if (isTestFile(filepath)) continue;
20
+ if (!isDockerfile(filepath)) continue;
21
+ const lines = content.split('\n');
22
+ const hasUser = lines.some(line => line.match(/^\s*USER\s+/i));
23
+ if (!hasUser) {
24
+ findings.push({
25
+ ruleId: 'INFRA-001', category: 'infrastructure', severity: 'high',
26
+ title: 'Dockerfile has no USER instruction — container runs as root',
27
+ description: 'Running containers as root is a security risk. Add a USER instruction to run as a non-root user.',
28
+ file: filepath, line: 1, fix: null,
29
+ });
30
+ }
31
+ }
32
+ return findings;
33
+ },
34
+ },
35
+
36
+ // INFRA-002: Docker image using latest tag (not pinned)
37
+ {
38
+ id: 'INFRA-002',
39
+ category: 'infrastructure',
40
+ severity: 'medium',
41
+ confidence: 'likely',
42
+ title: 'Unpinned Docker Image Tag',
43
+ check({ files }) {
44
+ const findings = [];
45
+ for (const [filepath, content] of files) {
46
+ if (isTestFile(filepath)) continue;
47
+ if (!isDockerfile(filepath)) continue;
48
+ const lines = content.split('\n');
49
+ for (let i = 0; i < lines.length; i++) {
50
+ const match = lines[i].match(/^\s*FROM\s+(\S+)/i);
51
+ if (match) {
52
+ const image = match[1];
53
+ // Flag if using :latest explicitly or no tag at all (implicit latest)
54
+ if (image.endsWith(':latest') || (!image.includes(':') && !image.includes('@'))) {
55
+ findings.push({
56
+ ruleId: 'INFRA-002', category: 'infrastructure', severity: 'medium',
57
+ title: `Docker image "${image}" uses unpinned tag — builds are not reproducible`,
58
+ description: 'Pin images to a specific version or SHA digest (e.g. node:20-alpine) for reproducible builds.',
59
+ file: filepath, line: i + 1, fix: null,
60
+ });
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return findings;
66
+ },
67
+ },
68
+
69
+ // INFRA-003: Docker COPY . . (copies everything including secrets)
70
+ {
71
+ id: 'INFRA-003',
72
+ category: 'infrastructure',
73
+ severity: 'high',
74
+ confidence: 'likely',
75
+ title: 'Docker COPY Copies Entire Context',
76
+ check({ files }) {
77
+ const findings = [];
78
+ for (const [filepath, content] of files) {
79
+ if (isTestFile(filepath)) continue;
80
+ if (!isDockerfile(filepath)) continue;
81
+ const lines = content.split('\n');
82
+ for (let i = 0; i < lines.length; i++) {
83
+ if (lines[i].match(/^\s*COPY\s+\.\s+\./i) || lines[i].match(/^\s*ADD\s+\.\s+\./i)) {
84
+ findings.push({
85
+ ruleId: 'INFRA-003', category: 'infrastructure', severity: 'high',
86
+ title: 'COPY . . copies the entire build context including potential secrets (.env, keys)',
87
+ description: 'Use a .dockerignore file and copy only needed files, or use multi-stage builds to avoid leaking secrets.',
88
+ file: filepath, line: i + 1, fix: null,
89
+ });
90
+ }
91
+ }
92
+ }
93
+ return findings;
94
+ },
95
+ },
96
+
97
+ // INFRA-004: No .dockerignore file
98
+ {
99
+ id: 'INFRA-004',
100
+ category: 'infrastructure',
101
+ severity: 'medium',
102
+ confidence: 'likely',
103
+ title: 'Missing .dockerignore File',
104
+ check({ files }) {
105
+ const findings = [];
106
+ const hasDockerfile = [...files.keys()].some(f => isDockerfile(f));
107
+ if (!hasDockerfile) return findings;
108
+
109
+ const hasDockerignore = [...files.keys()].some(f => f.endsWith('.dockerignore'));
110
+ if (!hasDockerignore) {
111
+ findings.push({
112
+ ruleId: 'INFRA-004', category: 'infrastructure', severity: 'medium',
113
+ title: 'No .dockerignore file found — build context may include unnecessary or sensitive files',
114
+ description: 'Add a .dockerignore to exclude node_modules, .env, .git, and other files from the Docker build context.',
115
+ file: null, line: null, fix: null,
116
+ });
117
+ }
118
+ return findings;
119
+ },
120
+ },
121
+
122
+ // INFRA-005: Docker EXPOSE on sensitive ports
123
+ {
124
+ id: 'INFRA-005',
125
+ category: 'infrastructure',
126
+ severity: 'high',
127
+ confidence: 'likely',
128
+ title: 'Sensitive Port Exposed in Dockerfile',
129
+ check({ files }) {
130
+ const findings = [];
131
+ for (const [filepath, content] of files) {
132
+ if (isTestFile(filepath)) continue;
133
+ if (!isDockerfile(filepath)) continue;
134
+ const lines = content.split('\n');
135
+ for (let i = 0; i < lines.length; i++) {
136
+ const match = lines[i].match(/^\s*EXPOSE\s+(.+)/i);
137
+ if (match) {
138
+ const ports = match[1].split(/\s+/);
139
+ for (const port of ports) {
140
+ const portNum = port.replace(/\/\w+$/, ''); // strip /tcp, /udp
141
+ if (SENSITIVE_PORTS.includes(portNum)) {
142
+ findings.push({
143
+ ruleId: 'INFRA-005', category: 'infrastructure', severity: 'high',
144
+ title: `Dockerfile exposes sensitive port ${portNum} (SSH, database, or admin service)`,
145
+ description: 'Avoid exposing SSH (22), MySQL (3306), PostgreSQL (5432), or MongoDB (27017) ports directly. Use internal networks instead.',
146
+ file: filepath, line: i + 1, fix: null,
147
+ });
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ return findings;
154
+ },
155
+ },
156
+
157
+ // INFRA-006: No health check in Docker Compose
158
+ {
159
+ id: 'INFRA-006',
160
+ category: 'infrastructure',
161
+ severity: 'medium',
162
+ confidence: 'likely',
163
+ title: 'No Health Check in Docker Compose',
164
+ check({ files }) {
165
+ const findings = [];
166
+ for (const [filepath, content] of files) {
167
+ if (isTestFile(filepath)) continue;
168
+ if (!isComposeFile(filepath)) continue;
169
+ if (!content.includes('healthcheck')) {
170
+ findings.push({
171
+ ruleId: 'INFRA-006', category: 'infrastructure', severity: 'medium',
172
+ title: 'Docker Compose service has no healthcheck configured',
173
+ description: 'Add healthcheck blocks to services so Docker can detect and restart unhealthy containers.',
174
+ file: filepath, line: 1, fix: null,
175
+ });
176
+ }
177
+ }
178
+ return findings;
179
+ },
180
+ },
181
+
182
+ // INFRA-007: Docker Compose volumes mounting host root or sensitive paths
183
+ {
184
+ id: 'INFRA-007',
185
+ category: 'infrastructure',
186
+ severity: 'critical',
187
+ confidence: 'definite',
188
+ title: 'Sensitive Host Path Mounted in Docker Compose',
189
+ check({ files }) {
190
+ const findings = [];
191
+ for (const [filepath, content] of files) {
192
+ if (isTestFile(filepath)) continue;
193
+ if (!isComposeFile(filepath)) continue;
194
+ const lines = content.split('\n');
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const line = lines[i].trim();
197
+ for (const sensitive of SENSITIVE_MOUNT_PATHS) {
198
+ // Match volume mounts like "- /etc:/something" or "- /var/run/docker.sock:/var/run/docker.sock"
199
+ const pattern = new RegExp(`^-\\s+${sensitive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(:|$)`);
200
+ if (line.match(pattern)) {
201
+ findings.push({
202
+ ruleId: 'INFRA-007', category: 'infrastructure', severity: 'critical',
203
+ title: `Docker Compose mounts sensitive host path "${sensitive}" — potential host compromise`,
204
+ description: 'Mounting host root, /etc, /proc, or docker.sock into containers can allow container escape. Use named volumes or restrict paths.',
205
+ file: filepath, line: i + 1, fix: null,
206
+ });
207
+ }
208
+ }
209
+ }
210
+ }
211
+ return findings;
212
+ },
213
+ },
214
+
215
+ // INFRA-008: No resource limits in Docker Compose
216
+ {
217
+ id: 'INFRA-008',
218
+ category: 'infrastructure',
219
+ severity: 'medium',
220
+ confidence: 'likely',
221
+ title: 'No Resource Limits in Docker Compose',
222
+ check({ files }) {
223
+ const findings = [];
224
+ for (const [filepath, content] of files) {
225
+ if (isTestFile(filepath)) continue;
226
+ if (!isComposeFile(filepath)) continue;
227
+ const hasLimits = content.includes('mem_limit') ||
228
+ content.includes('cpus') ||
229
+ content.includes('memory:') ||
230
+ content.includes('deploy:') && content.includes('resources:');
231
+ if (!hasLimits) {
232
+ findings.push({
233
+ ruleId: 'INFRA-008', category: 'infrastructure', severity: 'medium',
234
+ title: 'Docker Compose services have no resource limits (mem_limit, cpus)',
235
+ description: 'Set memory and CPU limits to prevent a single container from consuming all host resources.',
236
+ file: filepath, line: 1, fix: null,
237
+ });
238
+ }
239
+ }
240
+ return findings;
241
+ },
242
+ },
243
+
244
+ // INFRA-009: Privileged mode in Docker Compose
245
+ {
246
+ id: 'INFRA-009',
247
+ category: 'infrastructure',
248
+ severity: 'critical',
249
+ confidence: 'definite',
250
+ title: 'Privileged Mode in Docker Compose',
251
+ check({ files }) {
252
+ const findings = [];
253
+ for (const [filepath, content] of files) {
254
+ if (isTestFile(filepath)) continue;
255
+ if (!isComposeFile(filepath)) continue;
256
+ const lines = content.split('\n');
257
+ for (let i = 0; i < lines.length; i++) {
258
+ if (lines[i].match(/^\s*privileged\s*:\s*true/i)) {
259
+ findings.push({
260
+ ruleId: 'INFRA-009', category: 'infrastructure', severity: 'critical',
261
+ title: 'Docker Compose service runs in privileged mode — full host access',
262
+ description: 'Privileged mode gives the container full access to the host. Use specific capabilities (cap_add) instead.',
263
+ file: filepath, line: i + 1, fix: null,
264
+ });
265
+ }
266
+ }
267
+ }
268
+ return findings;
269
+ },
270
+ },
271
+
272
+ // INFRA-010: No logging driver configured
273
+ {
274
+ id: 'INFRA-010',
275
+ category: 'infrastructure',
276
+ severity: 'low',
277
+ confidence: 'suggestion',
278
+ title: 'No Logging Driver Configured',
279
+ check({ files }) {
280
+ const findings = [];
281
+ for (const [filepath, content] of files) {
282
+ if (isTestFile(filepath)) continue;
283
+ if (!isComposeFile(filepath)) continue;
284
+ const hasLogging = content.includes('logging:') || content.includes('log_driver') || content.includes('log_opt');
285
+ if (!hasLogging) {
286
+ findings.push({
287
+ ruleId: 'INFRA-010', category: 'infrastructure', severity: 'low',
288
+ title: 'Docker Compose has no logging driver configured — logs may be unbounded',
289
+ description: 'Configure a logging driver (json-file with max-size, or a centralized driver like fluentd/awslogs) to prevent disk exhaustion.',
290
+ file: filepath, line: 1, fix: null,
291
+ });
292
+ }
293
+ }
294
+ return findings;
295
+ },
296
+ },
297
+
298
+ // INFRA-DOCKER-001: Secrets in ENV instruction
299
+ { id: 'INFRA-DOCKER-001', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Secrets in Dockerfile ENV Instruction',
300
+ check({ files }) {
301
+ const findings = [];
302
+ for (const [fp, c] of files) {
303
+ if (!isDockerfile(fp)) continue;
304
+ const lines = c.split('\n');
305
+ for (let i = 0; i < lines.length; i++) {
306
+ if (lines[i].match(/^\s*ENV\s+/) && lines[i].match(/(?:password|secret|key|token|apikey|credential)/i)) {
307
+ findings.push({ ruleId: 'INFRA-DOCKER-001', category: 'infrastructure', severity: 'critical',
308
+ title: 'Secret in Dockerfile ENV — visible in image layers and docker inspect',
309
+ description: 'Pass secrets at runtime via --env-file or Docker secrets. Never bake secrets into images.', file: fp, line: i + 1, fix: null });
310
+ }
311
+ }
312
+ }
313
+ return findings;
314
+ },
315
+ },
316
+
317
+ // INFRA-DOCKER-002: curl|bash pattern
318
+ { id: 'INFRA-DOCKER-002', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Remote Script Execution in Dockerfile',
319
+ check({ files }) {
320
+ const findings = [];
321
+ for (const [fp, c] of files) {
322
+ if (!isDockerfile(fp)) continue;
323
+ const lines = c.split('\n');
324
+ for (let i = 0; i < lines.length; i++) {
325
+ if (lines[i].match(/curl.*\|\s*(?:bash|sh)|wget.*\|\s*(?:bash|sh)/)) {
326
+ findings.push({ ruleId: 'INFRA-DOCKER-002', category: 'infrastructure', severity: 'critical',
327
+ title: 'curl|bash in Dockerfile — remote script execution without integrity check',
328
+ description: 'Download scripts to a file, verify the checksum, then execute. curl|bash is a supply chain attack vector.', file: fp, line: i + 1, fix: null });
329
+ }
330
+ }
331
+ }
332
+ return findings;
333
+ },
334
+ },
335
+
336
+ // INFRA-DOCKER-003: No multi-stage build
337
+ { id: 'INFRA-DOCKER-003', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Multi-Stage Docker Build',
338
+ check({ files }) {
339
+ const findings = [];
340
+ for (const [fp, c] of files) {
341
+ if (!isDockerfile(fp)) continue;
342
+ if ((c.match(/^\s*FROM\s+/gim) || []).length < 2) {
343
+ findings.push({ ruleId: 'INFRA-DOCKER-003', category: 'infrastructure', severity: 'medium',
344
+ title: 'Single-stage Dockerfile — dev dependencies included in production image',
345
+ description: 'Use multi-stage builds (FROM node AS build ... FROM node:alpine) to keep production images small and secure.', file: fp, fix: null });
346
+ }
347
+ }
348
+ return findings;
349
+ },
350
+ },
351
+
352
+ // INFRA-DOCKER-004: Package cache not cleared
353
+ { id: 'INFRA-DOCKER-004', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Package Cache Not Cleared in Dockerfile',
354
+ check({ files }) {
355
+ const findings = [];
356
+ for (const [fp, c] of files) {
357
+ if (!isDockerfile(fp)) continue;
358
+ if (c.match(/apt-get install/) && !c.match(/rm -rf \/var\/cache\/apt|apt-get clean/)) {
359
+ findings.push({ ruleId: 'INFRA-DOCKER-004', category: 'infrastructure', severity: 'low',
360
+ title: 'apt-get cache not cleared — unnecessary bloat in image layers',
361
+ description: 'Add && rm -rf /var/cache/apt/lists/* after apt-get install to reduce image size.', file: fp, fix: null });
362
+ }
363
+ }
364
+ return findings;
365
+ },
366
+ },
367
+
368
+ // INFRA-K8S-001: No resource limits
369
+ { id: 'INFRA-K8S-001', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes Pod Without Resource Limits',
370
+ check({ files }) {
371
+ const findings = [];
372
+ for (const [fp, c] of files) {
373
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
374
+ if (c.match(/kind:\s*Deployment|kind:\s*StatefulSet/) && !c.match(/\blimits\s*:/)) {
375
+ findings.push({ ruleId: 'INFRA-K8S-001', category: 'infrastructure', severity: 'high',
376
+ title: 'Kubernetes deployment without resource limits — pod can starve neighbors',
377
+ description: 'Set resources.requests and resources.limits for CPU and memory on all pods.', file: fp, fix: null });
378
+ }
379
+ }
380
+ return findings;
381
+ },
382
+ },
383
+
384
+ // INFRA-K8S-002: No liveness/readiness probes
385
+ { id: 'INFRA-K8S-002', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'K8s Deployment Without Health Probes',
386
+ check({ files }) {
387
+ const findings = [];
388
+ for (const [fp, c] of files) {
389
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
390
+ if (c.match(/kind:\s*Deployment/) && !c.match(/livenessProbe|readinessProbe/)) {
391
+ findings.push({ ruleId: 'INFRA-K8S-002', category: 'infrastructure', severity: 'high',
392
+ title: 'Kubernetes deployment without liveness/readiness probes',
393
+ description: 'Without probes K8s cannot detect unhealthy pods. Add livenessProbe and readinessProbe with /health endpoint.', file: fp, fix: null });
394
+ }
395
+ }
396
+ return findings;
397
+ },
398
+ },
399
+
400
+ // INFRA-K8S-003: Secrets in ConfigMap
401
+ { id: 'INFRA-K8S-003', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Secrets in Kubernetes ConfigMap',
402
+ check({ files }) {
403
+ const findings = [];
404
+ for (const [fp, c] of files) {
405
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
406
+ if (c.match(/kind:\s*ConfigMap/) && c.match(/(?:password|secret|token|api.key):/i)) {
407
+ findings.push({ ruleId: 'INFRA-K8S-003', category: 'infrastructure', severity: 'critical',
408
+ title: 'Secret-like values in ConfigMap — use kind: Secret or external secrets manager',
409
+ description: 'ConfigMaps are stored unencrypted. Use Kubernetes Secrets, or better, an external secrets manager (Vault, AWS Secrets Manager).', file: fp, fix: null });
410
+ }
411
+ }
412
+ return findings;
413
+ },
414
+ },
415
+
416
+ // INFRA-K8S-004: No pod autoscaler
417
+ { id: 'INFRA-K8S-004', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Horizontal Pod Autoscaler',
418
+ check({ files }) {
419
+ const hasDeployment = [...files.values()].some(c => c.match(/kind:\s*Deployment/));
420
+ const hasHPA = [...files.values()].some(c => c.includes('HorizontalPodAutoscaler'));
421
+ if (hasDeployment && !hasHPA) {
422
+ return [{ ruleId: 'INFRA-K8S-004', category: 'infrastructure', severity: 'medium',
423
+ title: 'No HorizontalPodAutoscaler configured — fixed replica count under load',
424
+ description: 'Add an HPA to automatically scale pods based on CPU/memory. Fixed replica counts waste resources or fail under traffic spikes.', fix: null }];
425
+ }
426
+ return [];
427
+ },
428
+ },
429
+
430
+ // INFRA-CLOUD-001: SSH open to all IPs
431
+ { id: 'INFRA-CLOUD-001', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'SSH Port Open to Internet',
432
+ check({ files }) {
433
+ const findings = [];
434
+ for (const [fp, c] of files) {
435
+ if (!fp.match(/\.(tf|json|ya?ml)$/)) continue;
436
+ if (c.match(/0\.0\.0\.0\/0|:\/0/) && c.match(/port.*22\b|from_port.*22|toPort.*22/)) {
437
+ findings.push({ ruleId: 'INFRA-CLOUD-001', category: 'infrastructure', severity: 'critical',
438
+ title: 'Security group allows SSH (port 22) from 0.0.0.0/0',
439
+ description: 'Restrict SSH access to specific IPs or use a bastion host. Open SSH is the leading cause of cloud server compromise.', file: fp, fix: null });
440
+ }
441
+ }
442
+ return findings;
443
+ },
444
+ },
445
+
446
+ // INFRA-CLOUD-002: Database publicly accessible
447
+ { id: 'INFRA-CLOUD-002', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Database Publicly Accessible',
448
+ check({ files }) {
449
+ const findings = [];
450
+ for (const [fp, c] of files) {
451
+ if (!fp.match(/\.(tf|json|ya?ml)$/)) continue;
452
+ if (c.match(/publicly_accessible\s*=\s*true|PubliclyAccessible.*true/)) {
453
+ findings.push({ ruleId: 'INFRA-CLOUD-002', category: 'infrastructure', severity: 'critical',
454
+ title: 'RDS instance configured as publicly accessible',
455
+ description: 'Databases must never be publicly accessible. Place in a private subnet and connect through the application layer.', file: fp, fix: null });
456
+ }
457
+ }
458
+ return findings;
459
+ },
460
+ },
461
+
462
+ // INFRA-CLOUD-003: Hardcoded creds in Terraform
463
+ { id: 'INFRA-CLOUD-003', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Hardcoded Credentials in Terraform',
464
+ check({ files }) {
465
+ const findings = [];
466
+ for (const [fp, c] of files) {
467
+ if (!fp.endsWith('.tf')) continue;
468
+ const lines = c.split('\n');
469
+ for (let i = 0; i < lines.length; i++) {
470
+ if (lines[i].match(/(?:access_key|secret_key|password)\s*=\s*["'][^"'${}]+["']/)) {
471
+ findings.push({ ruleId: 'INFRA-CLOUD-003', category: 'infrastructure', severity: 'critical',
472
+ title: 'Hardcoded credential in Terraform configuration',
473
+ description: 'Use TF_VAR_ environment variables or a secrets manager. Never commit credentials to version control.', file: fp, line: i + 1, fix: null });
474
+ }
475
+ }
476
+ }
477
+ return findings;
478
+ },
479
+ },
480
+
481
+ // INFRA-CLOUD-004: No remote Terraform state
482
+ { id: 'INFRA-CLOUD-004', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No Remote Terraform State Backend',
483
+ check({ files }) {
484
+ const hasTf = [...files.keys()].some(f => f.endsWith('.tf'));
485
+ const hasBackend = [...files.values()].some(c => c.match(/backend\s+"(?:s3|gcs|azurerm|remote)"/));
486
+ if (hasTf && !hasBackend) {
487
+ return [{ ruleId: 'INFRA-CLOUD-004', category: 'infrastructure', severity: 'high',
488
+ title: 'No remote Terraform state backend — state stored locally',
489
+ description: 'Use S3+DynamoDB or Terraform Cloud for state. Local state cannot be shared and is lost if the machine is destroyed.', fix: null }];
490
+ }
491
+ return [];
492
+ },
493
+ },
494
+
495
+ // INFRA-CLOUD-005: IAM admin policy
496
+ { id: 'INFRA-CLOUD-005', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'IAM Policy Grants Full Admin Access',
497
+ check({ files }) {
498
+ const findings = [];
499
+ for (const [fp, c] of files) {
500
+ if (!fp.match(/\.(tf|json)$/)) continue;
501
+ if (c.match(/AdministratorAccess/) || c.match(/"Action"\s*:\s*"\*".*"Resource"\s*:\s*"\*"/s)) {
502
+ findings.push({ ruleId: 'INFRA-CLOUD-005', category: 'infrastructure', severity: 'critical',
503
+ title: 'IAM policy grants unrestricted admin access (Action: *, Resource: *)',
504
+ description: 'Apply least-privilege. Grant only the specific actions and resources this role needs.', file: fp, fix: null });
505
+ }
506
+ }
507
+ return findings;
508
+ },
509
+ },
510
+
511
+ // INFRA-CLOUD-006: RDS without encryption
512
+ { id: 'INFRA-CLOUD-006', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'RDS Without Encryption at Rest',
513
+ check({ files }) {
514
+ const findings = [];
515
+ for (const [fp, c] of files) {
516
+ if (!fp.endsWith('.tf')) continue;
517
+ if (c.match(/resource\s+"aws_db_instance"/) && !c.match(/storage_encrypted\s*=\s*true/)) {
518
+ findings.push({ ruleId: 'INFRA-CLOUD-006', category: 'infrastructure', severity: 'high',
519
+ title: 'RDS instance without storage_encrypted = true',
520
+ description: 'Enable encryption at rest. Required for HIPAA, PCI DSS, and SOC 2 compliance.', file: fp, fix: null });
521
+ }
522
+ }
523
+ return findings;
524
+ },
525
+ },
526
+
527
+ // INFRA-CLOUD-007: No tfsec/checkov in CI
528
+ { id: 'INFRA-CLOUD-007', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No IaC Security Scanner in CI',
529
+ check({ files }) {
530
+ const hasTf = [...files.keys()].some(f => f.endsWith('.tf'));
531
+ if (!hasTf) return [];
532
+ const hasScan = [...files.values()].some(c => c.match(/tfsec|checkov|terrascan|trivy.*config/));
533
+ if (!hasScan) {
534
+ return [{ ruleId: 'INFRA-CLOUD-007', category: 'infrastructure', severity: 'medium',
535
+ title: 'No IaC security scanner (tfsec, checkov) in CI pipeline',
536
+ description: 'Add tfsec or checkov to catch Terraform misconfigurations before they reach production.', fix: null }];
537
+ }
538
+ return [];
539
+ },
540
+ },
541
+
542
+ // INFRA-CLOUD-008: K8s ingress without TLS
543
+ { id: 'INFRA-CLOUD-008', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes Ingress Without TLS',
544
+ check({ files }) {
545
+ const findings = [];
546
+ for (const [fp, c] of files) {
547
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
548
+ if (c.match(/kind:\s*Ingress/) && !c.match(/\btls\b:/)) {
549
+ findings.push({ ruleId: 'INFRA-CLOUD-008', category: 'infrastructure', severity: 'high',
550
+ title: 'Kubernetes Ingress without TLS — serving HTTP in production',
551
+ description: "Add tls: section and use cert-manager with Let's Encrypt for automatic HTTPS.", file: fp, fix: null });
552
+ }
553
+ }
554
+ return findings;
555
+ },
556
+ },
557
+
558
+ // INFRA-K8S-009: Kubernetes Pod running as root
559
+ { id: 'INFRA-K8S-009', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes Pod Running as Root',
560
+ check({ files }) {
561
+ const findings = [];
562
+ for (const [fp, c] of files) {
563
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
564
+ if (c.match(/kind:\s*(?:Pod|Deployment|StatefulSet|DaemonSet)/)) {
565
+ if (!c.match(/runAsNonRoot:\s*true|runAsUser:\s*[1-9]/) ) {
566
+ findings.push({ ruleId: 'INFRA-K8S-009', category: 'infrastructure', severity: 'high', title: 'Kubernetes workload may run as root — privilege escalation risk', description: 'Set securityContext.runAsNonRoot: true and runAsUser: 1000. Running as root in containers enables container escape exploits.', file: fp, fix: null });
567
+ }
568
+ }
569
+ }
570
+ return findings;
571
+ },
572
+ },
573
+
574
+ // INFRA-K8S-010: Privileged container
575
+ { id: 'INFRA-K8S-010', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Kubernetes Privileged Container',
576
+ check({ files }) {
577
+ const findings = [];
578
+ for (const [fp, c] of files) {
579
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
580
+ if (c.match(/privileged:\s*true/)) {
581
+ findings.push({ ruleId: 'INFRA-K8S-010', category: 'infrastructure', severity: 'critical', title: 'Privileged container — has full access to host system', description: 'Remove privileged: true. Privileged containers can access all host devices and escape the container namespace. Use specific capabilities instead.', file: fp, fix: null });
582
+ }
583
+ }
584
+ return findings;
585
+ },
586
+ },
587
+
588
+ // INFRA-K8S-011: hostNetwork: true
589
+ { id: 'INFRA-K8S-011', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes Pod Using Host Network',
590
+ check({ files }) {
591
+ const findings = [];
592
+ for (const [fp, c] of files) {
593
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
594
+ if (c.match(/hostNetwork:\s*true|hostPID:\s*true|hostIPC:\s*true/)) {
595
+ findings.push({ ruleId: 'INFRA-K8S-011', category: 'infrastructure', severity: 'high', title: 'Pod using hostNetwork/hostPID/hostIPC — breaks container isolation', description: 'Remove hostNetwork/hostPID/hostIPC. These settings allow the container to see host processes and network interfaces, defeating isolation.', file: fp, fix: null });
596
+ }
597
+ }
598
+ return findings;
599
+ },
600
+ },
601
+
602
+ // INFRA-K8S-012: No network policy
603
+ { id: 'INFRA-K8S-012', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Kubernetes NetworkPolicy',
604
+ check({ files }) {
605
+ const findings = [];
606
+ const hasDeployment = [...files.entries()].some(([fp, c]) => fp.match(/\.(yaml|yml)$/) && c.match(/kind:\s*Deployment/));
607
+ const hasNetPolicy = [...files.values()].some(c => c.match(/kind:\s*NetworkPolicy/));
608
+ if (hasDeployment && !hasNetPolicy) {
609
+ findings.push({ ruleId: 'INFRA-K8S-012', category: 'infrastructure', severity: 'medium', title: 'No Kubernetes NetworkPolicy — all pods can communicate with each other', description: 'Define NetworkPolicy to restrict pod-to-pod traffic. Default deny-all ingress then allowlist required connections implements least-privilege networking.', fix: null });
610
+ }
611
+ return findings;
612
+ },
613
+ },
614
+
615
+ // INFRA-K8S-013: No PodDisruptionBudget
616
+ { id: 'INFRA-K8S-013', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No PodDisruptionBudget for Production Deployments',
617
+ check({ files }) {
618
+ const findings = [];
619
+ const hasDeployment = [...files.entries()].some(([fp, c]) => fp.match(/\.(yaml|yml)$/) && c.match(/kind:\s*Deployment/) && c.match(/replicas:\s*[2-9]/));
620
+ const hasPDB = [...files.values()].some(c => c.match(/kind:\s*PodDisruptionBudget/));
621
+ if (hasDeployment && !hasPDB) {
622
+ findings.push({ ruleId: 'INFRA-K8S-013', category: 'infrastructure', severity: 'medium', title: 'Multi-replica Deployment without PodDisruptionBudget', description: 'Add PodDisruptionBudget with minAvailable: 1. Without PDB, node maintenance can take down all replicas simultaneously.', fix: null });
623
+ }
624
+ return findings;
625
+ },
626
+ },
627
+
628
+ // INFRA-K8S-014: Image pull policy Always missing for mutable tags
629
+ { id: 'INFRA-K8S-014', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Mutable Docker Tag Without imagePullPolicy: Always',
630
+ check({ files }) {
631
+ const findings = [];
632
+ for (const [fp, c] of files) {
633
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
634
+ const lines = c.split('\n');
635
+ for (let i = 0; i < lines.length; i++) {
636
+ if (lines[i].match(/image:\s*\w.*:latest/) && !lines.slice(i, i + 5).join('\n').match(/imagePullPolicy:\s*Always/)) {
637
+ findings.push({ ruleId: 'INFRA-K8S-014', category: 'infrastructure', severity: 'medium', title: 'Using :latest tag without imagePullPolicy: Always — may run stale image', description: 'Pin to a specific tag or digest and set imagePullPolicy: Always. The :latest tag is mutable and cached nodes may run old versions.', file: fp, line: i + 1, fix: null });
638
+ }
639
+ }
640
+ }
641
+ return findings;
642
+ },
643
+ },
644
+
645
+ // INFRA-TF-001: Terraform state not encrypted
646
+ { id: 'INFRA-TF-001', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform Remote State Not Encrypted',
647
+ check({ files }) {
648
+ const findings = [];
649
+ for (const [fp, c] of files) {
650
+ if (!fp.match(/\.tf$/) && !fp.match(/backend\.tf/)) continue;
651
+ if (c.match(/backend\s+"s3"/i) && !c.match(/encrypt\s*=\s*true/)) {
652
+ findings.push({ ruleId: 'INFRA-TF-001', category: 'infrastructure', severity: 'high', title: 'Terraform S3 backend without encryption', description: 'Add encrypt = true to S3 backend config. Terraform state contains sensitive values including secrets and resource IDs.', file: fp, fix: null });
653
+ }
654
+ }
655
+ return findings;
656
+ },
657
+ },
658
+
659
+ // INFRA-TF-002: Terraform state locking disabled
660
+ { id: 'INFRA-TF-002', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform State Without Locking',
661
+ check({ files }) {
662
+ const findings = [];
663
+ for (const [fp, c] of files) {
664
+ if (!fp.match(/\.tf$/)) continue;
665
+ if (c.match(/backend\s+"s3"/i) && !c.match(/dynamodb_table/)) {
666
+ findings.push({ ruleId: 'INFRA-TF-002', category: 'infrastructure', severity: 'high', title: 'S3 backend without DynamoDB lock table — concurrent applies will corrupt state', description: 'Add dynamodb_table = "terraform-lock" to backend config. Without locking, concurrent terraform apply commands corrupt state.', file: fp, fix: null });
667
+ }
668
+ }
669
+ return findings;
670
+ },
671
+ },
672
+
673
+ // INFRA-TF-003: Wildcard resource in IAM policy
674
+ { id: 'INFRA-TF-003', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'IAM Policy with Wildcard Resource',
675
+ check({ files }) {
676
+ const findings = [];
677
+ for (const [fp, c] of files) {
678
+ if (!fp.match(/\.(tf|json)$/)) continue;
679
+ const lines = c.split('\n');
680
+ for (let i = 0; i < lines.length; i++) {
681
+ if (lines[i].match(/"Resource"\s*:\s*['"]\*['"]/i) || lines[i].match(/resource\s*=\s*['"]\*['"]/i)) {
682
+ findings.push({ ruleId: 'INFRA-TF-003', category: 'infrastructure', severity: 'high', title: 'IAM policy with Resource: "*" — violates least privilege', description: 'Specify exact ARNs: arn:aws:s3:::my-bucket/*. Wildcard resources grant permissions to all resources of that type.', file: fp, line: i + 1, fix: null });
683
+ }
684
+ }
685
+ }
686
+ return findings;
687
+ },
688
+ },
689
+
690
+ // INFRA-TF-004: No provider version pinning
691
+ { id: 'INFRA-TF-004', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Terraform Provider Without Version Constraint',
692
+ check({ files }) {
693
+ const findings = [];
694
+ for (const [fp, c] of files) {
695
+ if (!fp.match(/\.tf$/)) continue;
696
+ if (c.match(/required_providers/i) && !c.match(/version\s*=\s*["']~>|version\s*=\s*["']>=/)) {
697
+ findings.push({ ruleId: 'INFRA-TF-004', category: 'infrastructure', severity: 'medium', title: 'Terraform provider without version constraint — may break on provider update', description: 'Pin providers: version = "~> 5.0". Unpinned providers upgrade automatically and may introduce breaking changes.', file: fp, fix: null });
698
+ }
699
+ }
700
+ return findings;
701
+ },
702
+ },
703
+
704
+ // INFRA-TF-005: Public S3 bucket in Terraform
705
+ { id: 'INFRA-TF-005', category: 'infrastructure', severity: 'critical', confidence: 'likely', title: 'Terraform S3 Bucket Publicly Accessible',
706
+ check({ files }) {
707
+ const findings = [];
708
+ for (const [fp, c] of files) {
709
+ if (!fp.match(/\.tf$/)) continue;
710
+ if (c.match(/acl\s*=\s*["']public-read(?:-write)?["']/i)) {
711
+ findings.push({ ruleId: 'INFRA-TF-005', category: 'infrastructure', severity: 'critical', title: 'S3 bucket with public ACL — data exposed to internet', description: 'Remove public ACL and enable aws_s3_bucket_public_access_block with all options set to true.', file: fp, fix: null });
712
+ }
713
+ if (c.match(/block_public_acls\s*=\s*false|block_public_policy\s*=\s*false/i)) {
714
+ findings.push({ ruleId: 'INFRA-TF-005', category: 'infrastructure', severity: 'critical', title: 'S3 public access block disabled — bucket accessible from internet', description: 'Set block_public_acls, block_public_policy, ignore_public_acls, and restrict_public_buckets all to true.', file: fp, fix: null });
715
+ }
716
+ }
717
+ return findings;
718
+ },
719
+ },
720
+
721
+ // INFRA-TF-006: No VPC for database instances
722
+ { id: 'INFRA-TF-006', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Database Instance Without VPC',
723
+ check({ files }) {
724
+ const findings = [];
725
+ for (const [fp, c] of files) {
726
+ if (!fp.match(/\.tf$/)) continue;
727
+ if (c.match(/aws_db_instance|aws_rds_cluster/i) && !c.match(/vpc_security_group_ids|db_subnet_group_name/i)) {
728
+ findings.push({ ruleId: 'INFRA-TF-006', category: 'infrastructure', severity: 'high', title: 'RDS instance without VPC configuration — may be publicly accessible', description: 'Configure vpc_security_group_ids and db_subnet_group_name. Databases must be in a private VPC subnet with restricted security groups.', file: fp, fix: null });
729
+ }
730
+ }
731
+ return findings;
732
+ },
733
+ },
734
+
735
+ // INFRA-DOCKER-005: Docker container without read-only filesystem
736
+ { id: 'INFRA-DOCKER-005', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Container Without Read-Only Root Filesystem',
737
+ check({ files }) {
738
+ const findings = [];
739
+ for (const [fp, c] of files) {
740
+ if (!fp.match(/\.(yaml|yml)$/) && !fp.endsWith('Dockerfile')) continue;
741
+ if (c.match(/kind:\s*(?:Pod|Deployment)/i) && !c.match(/readOnlyRootFilesystem:\s*true/)) {
742
+ findings.push({ ruleId: 'INFRA-DOCKER-005', category: 'infrastructure', severity: 'medium', title: 'Container without readOnlyRootFilesystem: true', description: 'Set securityContext.readOnlyRootFilesystem: true. Prevents attackers from writing malicious files after gaining code execution.', file: fp, fix: null });
743
+ }
744
+ }
745
+ return findings;
746
+ },
747
+ },
748
+
749
+ // INFRA-DOCKER-006: Exposed secrets in Docker build args
750
+ { id: 'INFRA-DOCKER-006', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Secrets in Docker Build Args',
751
+ check({ files }) {
752
+ const findings = [];
753
+ for (const [fp, c] of files) {
754
+ if (!fp.endsWith('Dockerfile') && !fp.match(/docker-compose\.ya?ml/i)) continue;
755
+ const lines = c.split('\n');
756
+ for (let i = 0; i < lines.length; i++) {
757
+ if (lines[i].match(/ARG\s+(?:PASSWORD|SECRET|API_KEY|TOKEN|PRIVATE)/i) || lines[i].match(/--build-arg\s+(?:PASSWORD|SECRET|API_KEY|TOKEN)/i)) {
758
+ findings.push({ ruleId: 'INFRA-DOCKER-006', category: 'infrastructure', severity: 'high', title: 'Secret passed as Docker build argument — embedded in image layer history', description: 'Use Docker BuildKit secrets (--mount=type=secret) or multi-stage builds. Build ARG values are visible in docker history.', file: fp, line: i + 1, fix: null });
759
+ }
760
+ }
761
+ }
762
+ return findings;
763
+ },
764
+ },
765
+
766
+ // INFRA-DOCKER-007: No HEALTHCHECK in Dockerfile
767
+ { id: 'INFRA-DOCKER-007', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Dockerfile Without HEALTHCHECK',
768
+ check({ files }) {
769
+ const findings = [];
770
+ for (const [fp, c] of files) {
771
+ if (!fp.endsWith('Dockerfile') && !fp.match(/Dockerfile\./)) continue;
772
+ if (!c.match(/^HEALTHCHECK\s/m)) {
773
+ findings.push({ ruleId: 'INFRA-DOCKER-007', category: 'infrastructure', severity: 'low', title: 'Dockerfile without HEALTHCHECK instruction', description: 'Add HEALTHCHECK CMD curl -f http://localhost:8080/health || exit 1. Docker orchestrators use healthchecks to restart unhealthy containers.', file: fp, fix: null });
774
+ }
775
+ }
776
+ return findings;
777
+ },
778
+ },
779
+
780
+ // INFRA-DOCKER-008: Running as root in Dockerfile
781
+ { id: 'INFRA-DOCKER-008', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Dockerfile Runs as Root User',
782
+ check({ files }) {
783
+ const findings = [];
784
+ for (const [fp, c] of files) {
785
+ if (!fp.endsWith('Dockerfile') && !fp.match(/Dockerfile\./)) continue;
786
+ if (!c.match(/^USER\s+(?!root)/m)) {
787
+ findings.push({ ruleId: 'INFRA-DOCKER-008', category: 'infrastructure', severity: 'high', title: 'Dockerfile has no USER instruction — container runs as root', description: 'Add USER node or USER 1001 before CMD. Running containers as root enables privilege escalation if the app is compromised.', file: fp, fix: null });
788
+ }
789
+ }
790
+ return findings;
791
+ },
792
+ },
793
+
794
+ // INFRA-DOCKER-009: Using ADD instead of COPY in Dockerfile
795
+ { id: 'INFRA-DOCKER-009', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Dockerfile Uses ADD Instead of COPY',
796
+ check({ files }) {
797
+ const findings = [];
798
+ for (const [fp, c] of files) {
799
+ if (!fp.endsWith('Dockerfile') && !fp.match(/Dockerfile\./)) continue;
800
+ const lines = c.split('\n');
801
+ for (let i = 0; i < lines.length; i++) {
802
+ if (lines[i].match(/^ADD\s+/) && !lines[i].match(/https?:\/\//)) {
803
+ findings.push({ ruleId: 'INFRA-DOCKER-009', category: 'infrastructure', severity: 'low', title: 'Dockerfile uses ADD for local file — prefer COPY', description: 'Use COPY for local files. ADD auto-extracts tarballs and can fetch URLs, which is surprising and potentially dangerous.', file: fp, line: i + 1, fix: null });
804
+ }
805
+ }
806
+ }
807
+ return findings;
808
+ },
809
+ },
810
+
811
+ // INFRA-CLOUD-009: Security group allows all outbound
812
+ { id: 'INFRA-CLOUD-009', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Security Group Allows All Outbound Traffic',
813
+ check({ files }) {
814
+ const findings = [];
815
+ for (const [fp, c] of files) {
816
+ if (!fp.match(/\.(tf|ya?ml|json)$/)) continue;
817
+ if (c.match(/egress|outbound/i) && c.match(/0\.0\.0\.0\/0|:\/0/) && c.match(/-1|all.*protocol|protocol.*all/i)) {
818
+ findings.push({ ruleId: 'INFRA-CLOUD-009', category: 'infrastructure', severity: 'medium', title: 'Security group allows unrestricted outbound traffic', description: 'Restrict egress to known destinations (database, external APIs). Unlimited outbound enables data exfiltration if the instance is compromised.', file: fp, fix: null });
819
+ }
820
+ }
821
+ return findings;
822
+ },
823
+ },
824
+
825
+ // INFRA-CLOUD-010: No CloudTrail logging
826
+ { id: 'INFRA-CLOUD-010', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No AWS CloudTrail Configured',
827
+ check({ files }) {
828
+ const findings = [];
829
+ const isAWS = [...files.keys()].some(f => f.match(/\.tf$|CloudFormation|cdk/i)) && [...files.values()].some(c => c.match(/aws_|AWS::/));
830
+ if (!isAWS) return findings;
831
+ const hasTrail = [...files.values()].some(c => c.match(/aws_cloudtrail|CloudTrail|cloudtrail/i));
832
+ if (!hasTrail) {
833
+ findings.push({ ruleId: 'INFRA-CLOUD-010', category: 'infrastructure', severity: 'high', title: 'No AWS CloudTrail detected — API calls not being logged', description: 'Enable CloudTrail in all regions. CloudTrail is required for security investigations, compliance audits, and detecting unauthorized API calls.', fix: null });
834
+ }
835
+ return findings;
836
+ },
837
+ },
838
+
839
+ // INFRA-CLOUD-011: RDS instance publicly accessible
840
+ { id: 'INFRA-CLOUD-011', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'RDS Instance Publicly Accessible',
841
+ check({ files }) {
842
+ const findings = [];
843
+ for (const [fp, c] of files) {
844
+ if (!fp.match(/\.tf$/)) continue;
845
+ if (c.match(/aws_db_instance/i) && c.match(/publicly_accessible\s*=\s*true/)) {
846
+ findings.push({ ruleId: 'INFRA-CLOUD-011', category: 'infrastructure', severity: 'critical', title: 'RDS database publicly accessible from internet', description: 'Set publicly_accessible = false. Databases must only be accessible from within the VPC. Use bastion hosts or VPN for admin access.', file: fp, fix: null });
847
+ }
848
+ }
849
+ return findings;
850
+ },
851
+ },
852
+
853
+ // INFRA-CLOUD-012: EC2 without IMDSv2
854
+ { id: 'INFRA-CLOUD-012', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'EC2 Instance Without IMDSv2',
855
+ check({ files }) {
856
+ const findings = [];
857
+ for (const [fp, c] of files) {
858
+ if (!fp.match(/\.tf$/)) continue;
859
+ if (c.match(/aws_instance\b/i) && !c.match(/http_tokens\s*=\s*["']required['"]/i)) {
860
+ findings.push({ ruleId: 'INFRA-CLOUD-012', category: 'infrastructure', severity: 'high', title: 'EC2 instance without IMDSv2 enforcement — SSRF can steal instance credentials', description: 'Set metadata_options { http_tokens = "required" }. IMDSv2 prevents SSRF attacks from stealing IAM instance role credentials.', file: fp, fix: null });
861
+ }
862
+ }
863
+ return findings;
864
+ },
865
+ },
866
+
867
+ // INFRA-CLOUD-013: No VPC Flow Logs
868
+ { id: 'INFRA-CLOUD-013', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'VPC Without Flow Logs',
869
+ check({ files }) {
870
+ const findings = [];
871
+ const hasVPC = [...files.values()].some(c => c.match(/aws_vpc\b|resource.*vpc/i));
872
+ const hasFlowLogs = [...files.values()].some(c => c.match(/aws_flow_log|flow_logs|FlowLog/i));
873
+ if (hasVPC && !hasFlowLogs) {
874
+ findings.push({ ruleId: 'INFRA-CLOUD-013', category: 'infrastructure', severity: 'medium', title: 'VPC without Flow Logs — network traffic not monitored', description: 'Enable VPC Flow Logs to CloudWatch or S3. Flow logs are essential for detecting network-based attacks and investigating incidents.', fix: null });
875
+ }
876
+ return findings;
877
+ },
878
+ },
879
+
880
+ // INFRA-CLOUD-014: No WAF on public-facing API
881
+ { id: 'INFRA-CLOUD-014', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Public API Without WAF',
882
+ check({ files }) {
883
+ const findings = [];
884
+ const hasAPI = [...files.values()].some(c => c.match(/aws_api_gateway|aws_lb|ApplicationLoadBalancer|ApiGateway/i));
885
+ const hasWAF = [...files.values()].some(c => c.match(/aws_wafv2|waf_web_acl|WafWebAcl|AWS::WAFv2/i));
886
+ if (hasAPI && !hasWAF) {
887
+ findings.push({ ruleId: 'INFRA-CLOUD-014', category: 'infrastructure', severity: 'medium', title: 'Public API Gateway/ALB without WAF — no automated threat protection', description: 'Associate WAFv2 Web ACL with API Gateway and ALB. WAF blocks SQL injection, XSS, and bot traffic before reaching your application.', fix: null });
888
+ }
889
+ return findings;
890
+ },
891
+ },
892
+
893
+ // INFRA-CLOUD-015: Lambda without VPC for DB access
894
+ { id: 'INFRA-CLOUD-015', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Lambda Function Accessing Database Without VPC',
895
+ check({ files }) {
896
+ const findings = [];
897
+ for (const [fp, c] of files) {
898
+ if (!fp.match(/\.tf$/)) continue;
899
+ if (c.match(/aws_lambda_function/i) && !c.match(/vpc_config/i)) {
900
+ const allCode = [...files.values()].join('\n');
901
+ if (allCode.match(/aws_db_instance|aws_rds_cluster|aws_elasticache/i)) {
902
+ findings.push({ ruleId: 'INFRA-CLOUD-015', category: 'infrastructure', severity: 'medium', title: 'Lambda function without VPC config — cannot securely reach private database', description: 'Configure vpc_config with private subnet IDs to place Lambda in VPC. Required for secure RDS/ElastiCache access.', file: fp, fix: null });
903
+ }
904
+ }
905
+ }
906
+ return findings;
907
+ },
908
+ },
909
+
910
+ // INFRA-MON-001: No alerting configured
911
+ { id: 'INFRA-MON-001', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Infrastructure Alerting',
912
+ check({ files }) {
913
+ const findings = [];
914
+ const hasAlerts = [...files.values()].some(c => c.match(/aws_cloudwatch_metric_alarm|PagerDuty|OpsGenie|Slack.*alert|AlertManager|prometheus.*alert/i));
915
+ const hasInfra = [...files.keys()].some(f => f.match(/\.tf$|\.(yaml|yml)$/));
916
+ if (hasInfra && !hasAlerts) {
917
+ findings.push({ ruleId: 'INFRA-MON-001', category: 'infrastructure', severity: 'medium', title: 'No infrastructure alerting configured — incidents not automatically detected', description: 'Configure CloudWatch alarms, PagerDuty, or AlertManager. Alert on: error rate, CPU, memory, disk, and latency p99.', fix: null });
918
+ }
919
+ return findings;
920
+ },
921
+ },
922
+
923
+ // INFRA-MON-002: No log aggregation
924
+ { id: 'INFRA-MON-002', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Centralized Log Aggregation',
925
+ check({ files }) {
926
+ const findings = [];
927
+ const hasLogs = [...files.values()].some(c => c.match(/elasticsearch|opensearch|splunk|datadog|loggly|papertrail|CloudWatch.*Logs|loki|fluentd|logstash/i));
928
+ const hasApp = [...files.keys()].some(f => f.endsWith('package.json'));
929
+ if (hasApp && !hasLogs) {
930
+ findings.push({ ruleId: 'INFRA-MON-002', category: 'infrastructure', severity: 'medium', title: 'No log aggregation service detected', description: 'Configure centralized logging (CloudWatch Logs, Datadog, or ELK stack). Without aggregation, debugging incidents requires SSH access to each server.', fix: null });
931
+ }
932
+ return findings;
933
+ },
934
+ },
935
+
936
+ // INFRA-MON-003: No infrastructure-as-code for monitoring
937
+ { id: 'INFRA-MON-003', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Monitoring Not Defined as Code',
938
+ check({ files }) {
939
+ const findings = [];
940
+ const hasTerraform = [...files.keys()].some(f => f.match(/\.tf$/));
941
+ const hasMonitoring = [...files.values()].some(c => c.match(/aws_cloudwatch|datadog_monitor|grafana.*dashboard|prometheus/i));
942
+ if (hasTerraform && !hasMonitoring) {
943
+ findings.push({ ruleId: 'INFRA-MON-003', category: 'infrastructure', severity: 'low', title: 'No monitoring resources in Terraform — monitoring not managed as code', description: 'Define CloudWatch dashboards and alarms in Terraform. Infrastructure-as-code for monitoring ensures monitoring survives environment rebuilds.', fix: null });
944
+ }
945
+ return findings;
946
+ },
947
+ },
948
+
949
+ // INFRA-NET-001: Unrestricted ICMP
950
+ { id: 'INFRA-NET-001', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Security Group Allows All ICMP',
951
+ check({ files }) {
952
+ const findings = [];
953
+ for (const [fp, c] of files) {
954
+ if (!fp.match(/\.(tf|ya?ml|json)$/)) continue;
955
+ if (c.match(/protocol.*icmp|icmp.*protocol/i) && c.match(/0\.0\.0\.0\/0/) && c.match(/ingress|inbound/i)) {
956
+ findings.push({ ruleId: 'INFRA-NET-001', category: 'infrastructure', severity: 'low', title: 'Security group allows all ICMP from internet — enables ping sweeps', description: 'Restrict ICMP to VPC CIDR only. Unrestricted ICMP enables network mapping and can be used in amplification attacks.', file: fp, fix: null });
957
+ }
958
+ }
959
+ return findings;
960
+ },
961
+ },
962
+
963
+ // INFRA-NET-002: Open all ports between services
964
+ { id: 'INFRA-NET-002', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Unrestricted Intra-Service Networking',
965
+ check({ files }) {
966
+ const findings = [];
967
+ for (const [fp, c] of files) {
968
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
969
+ if (c.match(/kind:\s*NetworkPolicy/i) && c.match(/podSelector:\s*\{\}/i) && c.match(/namespaceSelector:\s*\{\}/i)) {
970
+ findings.push({ ruleId: 'INFRA-NET-002', category: 'infrastructure', severity: 'high', title: 'NetworkPolicy allows traffic from any namespace — no namespace isolation', description: 'Restrict namespaceSelector to specific namespaces. Wildcard namespace selectors allow any pod in the cluster to communicate.', file: fp, fix: null });
971
+ }
972
+ }
973
+ return findings;
974
+ },
975
+ },
976
+
977
+ // INFRA-TF-007: Sensitive Terraform output not marked sensitive
978
+ { id: 'INFRA-TF-007', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform Output With Sensitive Data Not Marked',
979
+ check({ files }) {
980
+ const findings = [];
981
+ for (const [fp, c] of files) {
982
+ if (!fp.match(/\.tf$/)) continue;
983
+ const lines = c.split('\n');
984
+ for (let i = 0; i < lines.length; i++) {
985
+ if (lines[i].match(/^output\s+["']\w*(password|secret|key|token)\w*["']/i)) {
986
+ const block = lines.slice(i, i + 10).join('\n');
987
+ if (!block.match(/sensitive\s*=\s*true/)) {
988
+ findings.push({ ruleId: 'INFRA-TF-007', category: 'infrastructure', severity: 'high', title: 'Terraform output containing secret not marked sensitive = true', description: 'Add sensitive = true to outputs containing passwords/keys. Without it, the value is shown in plaintext in terraform plan/apply output.', file: fp, line: i + 1, fix: null });
989
+ }
990
+ }
991
+ }
992
+ }
993
+ return findings;
994
+ },
995
+ },
996
+
997
+ // INFRA-K8S-015: No PodSecurityPolicy/PodSecurityAdmission
998
+ { id: 'INFRA-K8S-015', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No Pod Security Standards Enforced',
999
+ check({ files }) {
1000
+ const findings = [];
1001
+ const hasDeployment = [...files.entries()].some(([fp, c]) => fp.match(/\.(yaml|yml)$/) && c.match(/kind:\s*Deployment/));
1002
+ const hasPSA = [...files.values()].some(c => c.match(/pod-security\.kubernetes\.io|PodSecurityPolicy|securityContext:/i));
1003
+ if (hasDeployment && !hasPSA) {
1004
+ findings.push({ ruleId: 'INFRA-K8S-015', category: 'infrastructure', severity: 'high', title: 'No Pod Security Standards/Admission controller configured', description: 'Add pod-security.kubernetes.io/enforce: restricted label to namespaces. Without PSA, pods can request any capabilities.', fix: null });
1005
+ }
1006
+ return findings;
1007
+ },
1008
+ },
1009
+
1010
+ // INFRA-K8S-016: No resource quotas on namespace
1011
+ { id: 'INFRA-K8S-016', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Kubernetes Resource Quotas',
1012
+ check({ files }) {
1013
+ const findings = [];
1014
+ const hasNamespace = [...files.values()].some(c => c.match(/kind:\s*Namespace/i));
1015
+ const hasQuota = [...files.values()].some(c => c.match(/kind:\s*ResourceQuota/i));
1016
+ if (hasNamespace && !hasQuota) {
1017
+ findings.push({ ruleId: 'INFRA-K8S-016', category: 'infrastructure', severity: 'medium', title: 'Kubernetes namespace without ResourceQuota', description: 'Add ResourceQuota to limit CPU, memory, and pod count per namespace. Without quotas, one misbehaving app can starve all others.', fix: null });
1018
+ }
1019
+ return findings;
1020
+ },
1021
+ },
1022
+
1023
+ // INFRA-CLOUD-016: No GuardDuty / Threat Detection
1024
+ { id: 'INFRA-CLOUD-016', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No Cloud Threat Detection Service',
1025
+ check({ files }) {
1026
+ const findings = [];
1027
+ const isAWS = [...files.values()].some(c => c.match(/aws_|AWS::/));
1028
+ const hasGuardDuty = [...files.values()].some(c => c.match(/aws_guardduty|GuardDuty|security_hub|aws_securityhub/i));
1029
+ if (isAWS && !hasGuardDuty) {
1030
+ findings.push({ ruleId: 'INFRA-CLOUD-016', category: 'infrastructure', severity: 'high', title: 'No AWS GuardDuty — threat detection not enabled', description: 'Enable GuardDuty in all regions. GuardDuty detects: compromised instances, unusual API calls, Bitcoin mining, data exfiltration, and more.', fix: null });
1031
+ }
1032
+ return findings;
1033
+ },
1034
+ },
1035
+
1036
+ // INFRA-CLOUD-017: No AWS Config rules
1037
+ { id: 'INFRA-CLOUD-017', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No AWS Config for Compliance Monitoring',
1038
+ check({ files }) {
1039
+ const findings = [];
1040
+ const isAWS = [...files.values()].some(c => c.match(/aws_|AWS::/));
1041
+ const hasConfig = [...files.values()].some(c => c.match(/aws_config|AWS::Config|AWSConfig/i));
1042
+ if (isAWS && !hasConfig) {
1043
+ findings.push({ ruleId: 'INFRA-CLOUD-017', category: 'infrastructure', severity: 'medium', title: 'No AWS Config — resource compliance not continuously monitored', description: 'Enable AWS Config to track resource changes and evaluate against compliance rules. Required for SOC 2, PCI DSS, and HIPAA on AWS.', fix: null });
1044
+ }
1045
+ return findings;
1046
+ },
1047
+ },
1048
+
1049
+ // INFRA-DOCKER-010: Docker layer cache not optimized
1050
+ { id: 'INFRA-DOCKER-010', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Dockerfile Layer Order Not Optimized for Cache',
1051
+ check({ files }) {
1052
+ const findings = [];
1053
+ for (const [fp, c] of files) {
1054
+ if (!fp.endsWith('Dockerfile') && !fp.match(/Dockerfile\./)) continue;
1055
+ const lines = c.split('\n');
1056
+ let copyIdx = -1, npmIdx = -1;
1057
+ for (let i = 0; i < lines.length; i++) {
1058
+ if (lines[i].match(/^COPY\s+\.\s+\.|^ADD\s+\.\s+\./)) copyIdx = i;
1059
+ if (lines[i].match(/npm\s+(?:ci|install)|yarn\s+install|pnpm\s+install/)) npmIdx = i;
1060
+ }
1061
+ if (copyIdx >= 0 && npmIdx >= 0 && copyIdx < npmIdx) {
1062
+ findings.push({ ruleId: 'INFRA-DOCKER-010', category: 'infrastructure', severity: 'low', title: 'COPY . before npm install — code changes invalidate dependency cache', description: 'Copy package*.json first, run npm install, then COPY . . This caches node_modules when only source code changes, speeding builds 5-10x.', file: fp, line: copyIdx + 1, fix: null });
1063
+ }
1064
+ }
1065
+ return findings;
1066
+ },
1067
+ },
1068
+
1069
+ // INFRA-CLOUD-018: No KMS encryption for secrets
1070
+ { id: 'INFRA-CLOUD-018', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Secrets Manager Without KMS Customer-Managed Key',
1071
+ check({ files }) {
1072
+ const findings = [];
1073
+ for (const [fp, c] of files) {
1074
+ if (!fp.match(/\.tf$/)) continue;
1075
+ if (c.match(/aws_secretsmanager_secret\b/i) && !c.match(/kms_key_id/i)) {
1076
+ findings.push({ ruleId: 'INFRA-CLOUD-018', category: 'infrastructure', severity: 'high', title: 'Secrets Manager secret without KMS customer-managed key', description: 'Add kms_key_id to use a customer-managed KMS key. Without CMK, AWS uses shared keys — CMK enables access control and audit via CloudTrail.', file: fp, fix: null });
1077
+ }
1078
+ }
1079
+ return findings;
1080
+ },
1081
+ },
1082
+
1083
+ // INFRA-CLOUD-019: IAM role with inline policy
1084
+ { id: 'INFRA-CLOUD-019', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'IAM Role With Inline Policy',
1085
+ check({ files }) {
1086
+ const findings = [];
1087
+ for (const [fp, c] of files) {
1088
+ if (!fp.match(/\.tf$/)) continue;
1089
+ if (c.match(/aws_iam_role_policy\b/i) && !c.match(/aws_iam_role_policy_attachment/i)) {
1090
+ findings.push({ ruleId: 'INFRA-CLOUD-019', category: 'infrastructure', severity: 'medium', title: 'IAM role with inline policy — use managed policies for reusability', description: 'Use aws_iam_policy + aws_iam_role_policy_attachment. Inline policies cannot be reused across roles and are harder to audit.', file: fp, fix: null });
1091
+ }
1092
+ }
1093
+ return findings;
1094
+ },
1095
+ },
1096
+
1097
+ // INFRA-CLOUD-020: No S3 object versioning for Terraform state bucket
1098
+ { id: 'INFRA-CLOUD-020', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform State S3 Bucket Without Versioning',
1099
+ check({ files }) {
1100
+ const findings = [];
1101
+ for (const [fp, c] of files) {
1102
+ if (!fp.match(/\.tf$/)) continue;
1103
+ if (c.match(/backend\s+"s3"/i) && !c.match(/versioning.*enabled|versioning_configuration.*ENABLED/i)) {
1104
+ findings.push({ ruleId: 'INFRA-CLOUD-020', category: 'infrastructure', severity: 'high', title: 'Terraform state bucket without versioning — state corruption is unrecoverable', description: 'Enable versioning on the S3 bucket storing Terraform state. State file corruption without versioning permanently destroys infrastructure tracking.', file: fp, fix: null });
1105
+ }
1106
+ }
1107
+ return findings;
1108
+ },
1109
+ },
1110
+
1111
+ // INFRA-K8S-017: ConfigMap with sensitive data
1112
+ { id: 'INFRA-K8S-017', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes ConfigMap Storing Secrets',
1113
+ check({ files }) {
1114
+ const findings = [];
1115
+ for (const [fp, c] of files) {
1116
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
1117
+ if (c.match(/kind:\s*ConfigMap/i)) {
1118
+ if (c.match(/password|secret|api_key|token|credential/i)) {
1119
+ findings.push({ ruleId: 'INFRA-K8S-017', category: 'infrastructure', severity: 'high', title: 'Kubernetes ConfigMap contains what appears to be secret data', description: 'Move secrets to kind: Secret (with encryption at rest) or use external secrets operator (Vault, AWS Secrets Manager). ConfigMaps are not encrypted.', file: fp, fix: null });
1120
+ }
1121
+ }
1122
+ }
1123
+ return findings;
1124
+ },
1125
+ },
1126
+
1127
+ // INFRA-K8S-018: No resource requests (only limits)
1128
+ { id: 'INFRA-K8S-018', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes Container Without Resource Requests',
1129
+ check({ files }) {
1130
+ const findings = [];
1131
+ for (const [fp, c] of files) {
1132
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
1133
+ if (c.match(/kind:\s*(?:Pod|Deployment|StatefulSet)/i) && c.match(/limits:/)) {
1134
+ if (!c.match(/requests:/)) {
1135
+ findings.push({ ruleId: 'INFRA-K8S-018', category: 'infrastructure', severity: 'medium', title: 'Container has limits but no resource requests — scheduler cannot make optimal placement', description: 'Add resources.requests matching your typical usage. Without requests, Kubernetes cannot schedule pods efficiently and may cause node oversubscription.', file: fp, fix: null });
1136
+ }
1137
+ }
1138
+ }
1139
+ return findings;
1140
+ },
1141
+ },
1142
+
1143
+ // INFRA-TF-008: No module versioning
1144
+ { id: 'INFRA-TF-008', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Terraform Module Without Version Pin',
1145
+ check({ files }) {
1146
+ const findings = [];
1147
+ for (const [fp, c] of files) {
1148
+ if (!fp.match(/\.tf$/)) continue;
1149
+ const lines = c.split('\n');
1150
+ for (let i = 0; i < lines.length; i++) {
1151
+ if (lines[i].match(/source\s*=\s*["']registry\.terraform\.io|source\s*=\s*["']\w+\//i)) {
1152
+ if (!lines.slice(i, i + 5).join('\n').match(/version\s*=/)) {
1153
+ findings.push({ ruleId: 'INFRA-TF-008', category: 'infrastructure', severity: 'medium', title: 'Terraform module without version constraint — automatically uses latest', description: 'Pin module versions: version = "~> 3.0". Unpinned modules break infrastructure on their next release.', file: fp, line: i + 1, fix: null });
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ return findings;
1159
+ },
1160
+ },
1161
+
1162
+ // INFRA-MON-004: No log retention for compliance
1163
+ { id: 'INFRA-MON-004', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Logs Without Compliance Retention Period',
1164
+ check({ files }) {
1165
+ const findings = [];
1166
+ for (const [fp, c] of files) {
1167
+ if (!fp.match(/\.(tf|ya?ml|json)$/)) continue;
1168
+ if (c.match(/log_group|LogGroup|cloudwatch.*log/i) && !c.match(/retention.*days.*(?:30|60|90|180|365|730|1827|3653)|RetentionInDays/i)) {
1169
+ findings.push({ ruleId: 'INFRA-MON-004', category: 'infrastructure', severity: 'medium', title: 'Log group without retention policy for compliance', description: 'Set retention: PCI DSS requires 1 year, HIPAA 6 years, SOC 2 typically 90 days. Infinite retention is expensive; too short fails compliance audits.', file: fp, fix: null });
1170
+ }
1171
+ }
1172
+ return findings;
1173
+ },
1174
+ },
1175
+
1176
+ // INFRA-CLOUD-021: Lambda without dead letter queue
1177
+ { id: 'INFRA-CLOUD-021', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Lambda Without Dead Letter Queue',
1178
+ check({ files }) {
1179
+ const findings = [];
1180
+ for (const [fp, c] of files) {
1181
+ if (!fp.match(/\.tf$|serverless\.ya?ml|sam\.ya?ml/i)) continue;
1182
+ if (c.match(/aws_lambda_function|Lambda\s*:/i)) {
1183
+ if (!c.match(/dead_letter_config|DeadLetterConfig|dlq|dead.*letter/i)) {
1184
+ findings.push({ ruleId: 'INFRA-CLOUD-021', category: 'infrastructure', severity: 'medium', title: 'Lambda function without Dead Letter Queue — failed invocations lost silently', description: 'Configure dead_letter_config with an SQS queue or SNS topic. Without DLQ, failed async Lambda invocations are lost without alerting.', file: fp, fix: null });
1185
+ }
1186
+ }
1187
+ }
1188
+ return findings;
1189
+ },
1190
+ },
1191
+
1192
+ // INFRA-NET-003: No DNS TTL optimization
1193
+ { id: 'INFRA-NET-003', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'DNS Records With High TTL (Slow Failover)',
1194
+ check({ files }) {
1195
+ const findings = [];
1196
+ for (const [fp, c] of files) {
1197
+ if (!fp.match(/\.(tf|ya?ml|json)$/)) continue;
1198
+ const match = c.match(/ttl\s*=\s*(\d+)/i);
1199
+ if (match && parseInt(match[1]) > 300 && c.match(/A\s*record|CNAME|failover|health.*check/i)) {
1200
+ findings.push({ ruleId: 'INFRA-NET-003', category: 'infrastructure', severity: 'low', title: `DNS TTL of ${match[1]}s — slow failover during outages`, description: 'Set TTL to 60s for DNS records used with health checks or failover. High TTL means users cache the old IP for minutes during incidents.', file: fp, fix: null });
1201
+ }
1202
+ }
1203
+ return findings;
1204
+ },
1205
+ },
1206
+
1207
+ // INFRA-K8S-019: No startup probe for slow-starting containers
1208
+ { id: 'INFRA-K8S-019', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Startup Probe for Slow-Starting Container',
1209
+ check({ files }) {
1210
+ const findings = [];
1211
+ for (const [fp, c] of files) {
1212
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
1213
+ if (c.match(/initialDelaySeconds:\s*([6-9]\d|[1-9]\d\d)/) && !c.match(/startupProbe:/)) {
1214
+ findings.push({ ruleId: 'INFRA-K8S-019', category: 'infrastructure', severity: 'medium', title: 'Long initialDelaySeconds without startup probe', description: 'Use startupProbe instead of initialDelaySeconds for slow-starting apps. startupProbe delays other probes until app is ready without fixed delay.', file: fp, fix: null });
1215
+ }
1216
+ }
1217
+ return findings;
1218
+ },
1219
+ },
1220
+ // INFRA-CLOUD-022: CloudFront without WAF
1221
+ { id: 'INFRA-CLOUD-022', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'CloudFront Distribution Without WAF',
1222
+ check({ files }) {
1223
+ const findings = [];
1224
+ for (const [fp, c] of files) {
1225
+ if (!fp.match(/\.tf$/)) continue;
1226
+ if (c.match(/aws_cloudfront_distribution/i) && !c.match(/web_acl_id|waf_web_acl/i)) {
1227
+ findings.push({ ruleId: 'INFRA-CLOUD-022', category: 'infrastructure', severity: 'medium', title: 'CloudFront distribution without WAF Web ACL', description: 'Associate WAFv2 with CloudFront. WAF at the edge blocks attacks before they reach your origin, reducing origin load and improving protection.', file: fp, fix: null });
1228
+ }
1229
+ }
1230
+ return findings;
1231
+ },
1232
+ },
1233
+ // INFRA-CLOUD-023: RDS backup retention too short
1234
+ { id: 'INFRA-CLOUD-023', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'RDS Backup Retention Too Short',
1235
+ check({ files }) {
1236
+ const findings = [];
1237
+ for (const [fp, c] of files) {
1238
+ if (!fp.match(/\.tf$/)) continue;
1239
+ const match = c.match(/backup_retention_period\s*=\s*(\d+)/i);
1240
+ if (match && parseInt(match[1]) < 7) {
1241
+ findings.push({ ruleId: 'INFRA-CLOUD-023', category: 'infrastructure', severity: 'medium', title: `RDS backup retention ${match[1]} days — too short for compliance`, description: 'Set backup_retention_period = 7 (minimum). PCI DSS requires 90 days, HIPAA requires 6 years for audit logs. Increase retention for compliance.', file: fp, fix: null });
1242
+ }
1243
+ }
1244
+ return findings;
1245
+ },
1246
+ },
1247
+ // INFRA-CLOUD-024: No deletion protection on production database
1248
+ { id: 'INFRA-CLOUD-024', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Production Database Without Deletion Protection',
1249
+ check({ files }) {
1250
+ const findings = [];
1251
+ for (const [fp, c] of files) {
1252
+ if (!fp.match(/\.tf$/)) continue;
1253
+ if (c.match(/aws_db_instance\b/i) && !c.match(/deletion_protection\s*=\s*true/)) {
1254
+ findings.push({ ruleId: 'INFRA-CLOUD-024', category: 'infrastructure', severity: 'high', title: 'RDS instance without deletion_protection = true', description: 'Set deletion_protection = true for production databases. This prevents accidental terraform destroy from permanently deleting your production database.', file: fp, fix: null });
1255
+ }
1256
+ }
1257
+ return findings;
1258
+ },
1259
+ },
1260
+ // INFRA-DOCKER-011: Dockerfile using latest base image
1261
+ { id: 'INFRA-DOCKER-011', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Dockerfile Uses :latest Base Image Tag',
1262
+ check({ files }) {
1263
+ const findings = [];
1264
+ for (const [fp, c] of files) {
1265
+ if (!fp.endsWith('Dockerfile') && !fp.match(/Dockerfile\./)) continue;
1266
+ const lines = c.split('\n');
1267
+ for (let i = 0; i < lines.length; i++) {
1268
+ if (lines[i].match(/^FROM\s+\w[^@\n]*:latest/i)) {
1269
+ findings.push({ ruleId: 'INFRA-DOCKER-011', category: 'infrastructure', severity: 'medium', title: 'Dockerfile FROM uses :latest tag — non-reproducible builds', description: 'Pin to a specific version: FROM node:20.11.0-alpine3.19. :latest changes silently and can break builds or introduce vulnerabilities.', file: fp, line: i + 1, fix: null });
1270
+ }
1271
+ }
1272
+ }
1273
+ return findings;
1274
+ },
1275
+ },
1276
+ // INFRA-TF-009: terraform.tfvars with secrets committed
1277
+ { id: 'INFRA-TF-009', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Terraform Variables File With Secrets',
1278
+ check({ files }) {
1279
+ const findings = [];
1280
+ for (const [fp, c] of files) {
1281
+ if (!fp.endsWith('.tfvars') && !fp.endsWith('.tfvars.json')) continue;
1282
+ const lines = c.split('\n');
1283
+ for (let i = 0; i < lines.length; i++) {
1284
+ if (lines[i].match(/password\s*=\s*["'].{4,}|secret\s*=\s*["'].{4,}|api_key\s*=\s*["'].{4,}/i)) {
1285
+ findings.push({ ruleId: 'INFRA-TF-009', category: 'infrastructure', severity: 'critical', title: 'Terraform .tfvars file contains secret values — should not be committed to git', description: 'Add *.tfvars to .gitignore. Use Vault, SSM Parameter Store, or AWS Secrets Manager for secret values. Reference with data.aws_secretsmanager_secret.', file: fp, line: i + 1, fix: null });
1286
+ }
1287
+ }
1288
+ }
1289
+ return findings;
1290
+ },
1291
+ },
1292
+ // INFRA-NET-004: Security group ingress from 0.0.0.0 on database port
1293
+ { id: 'INFRA-NET-004', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Database Port Open to Internet',
1294
+ check({ files }) {
1295
+ const findings = [];
1296
+ for (const [fp, c] of files) {
1297
+ if (!fp.match(/\.tf$/)) continue;
1298
+ const lines = c.split('\n');
1299
+ for (let i = 0; i < lines.length; i++) {
1300
+ if (lines[i].match(/from_port\s*=\s*(?:5432|3306|27017|1433|6379|9200)/) && lines.slice(i, i + 10).join('\n').match(/0\.0\.0\.0\/0/)) {
1301
+ findings.push({ ruleId: 'INFRA-NET-004', category: 'infrastructure', severity: 'critical', title: 'Database port open to 0.0.0.0/0 — database accessible from internet', description: 'Restrict database security group ingress to application security group only. Databases must never be publicly accessible.', file: fp, line: i + 1, fix: null });
1302
+ }
1303
+ }
1304
+ }
1305
+ return findings;
1306
+ },
1307
+ },
1308
+ // INFRA-CLOUD-025: No encryption at rest for EBS volumes
1309
+ { id: 'INFRA-CLOUD-025', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'EBS Volume Without Encryption at Rest',
1310
+ check({ files }) {
1311
+ const findings = [];
1312
+ for (const [fp, c] of files) {
1313
+ if (!fp.match(/\.tf$/)) continue;
1314
+ if (c.match(/aws_ebs_volume\b|aws_instance\b/i) && !c.match(/encrypted\s*=\s*true/)) {
1315
+ findings.push({ ruleId: 'INFRA-CLOUD-025', category: 'infrastructure', severity: 'high', title: 'EBS volume without encryption at rest', description: 'Set encrypted = true for all EBS volumes. Required for HIPAA, PCI DSS. Enable account-level EBS encryption default in AWS settings.', file: fp, fix: null });
1316
+ }
1317
+ }
1318
+ return findings;
1319
+ },
1320
+ },
1321
+
1322
+ // INFRA-TF-010: No tagging strategy
1323
+ { id: 'INFRA-TF-010', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'No Resource Tagging Strategy',
1324
+ check({ files }) {
1325
+ const findings = [];
1326
+ for (const [fp, c] of files) {
1327
+ if (!fp.match(/\.tf$/)) continue;
1328
+ if (c.match(/aws_instance\b|aws_db_instance\b|aws_s3_bucket\b/i)) {
1329
+ if (!c.match(/tags\s*=\s*\{|default_tags|common_tags/i)) {
1330
+ findings.push({ ruleId: 'INFRA-TF-010', category: 'infrastructure', severity: 'low', title: 'AWS resources without consistent tags — cost allocation impossible', description: 'Add tags: Environment, Team, Service, CostCenter. Tags enable cost allocation by team/service and are required for billing analysis.', file: fp, fix: null });
1331
+ }
1332
+ }
1333
+ }
1334
+ return findings;
1335
+ },
1336
+ },
1337
+ // INFRA-K8S-020: No anti-affinity rules for HA
1338
+ { id: 'INFRA-K8S-020', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Pod Anti-Affinity for High Availability',
1339
+ check({ files }) {
1340
+ const findings = [];
1341
+ for (const [fp, c] of files) {
1342
+ if (!fp.match(/\.(yaml|yml)$/)) continue;
1343
+ if (c.match(/kind:\s*Deployment/i) && c.match(/replicas:\s*[2-9]/)) {
1344
+ if (!c.match(/podAntiAffinity|pod.*anti.*affinity/i)) {
1345
+ findings.push({ ruleId: 'INFRA-K8S-020', category: 'infrastructure', severity: 'medium', title: 'Multi-replica Deployment without pod anti-affinity — all pods may land on same node', description: 'Add podAntiAffinity to spread replicas across nodes. Without it, Kubernetes may place all pods on one node, negating HA.', file: fp, fix: null });
1346
+ }
1347
+ }
1348
+ }
1349
+ return findings;
1350
+ },
1351
+ },
1352
+ // INFRA-CLOUD-026: Route53 without health check failover
1353
+ { id: 'INFRA-CLOUD-026', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Route53 Without Health Check Based Failover',
1354
+ check({ files }) {
1355
+ const findings = [];
1356
+ for (const [fp, c] of files) {
1357
+ if (!fp.match(/\.tf$/)) continue;
1358
+ if (c.match(/aws_route53_record/i) && !c.match(/health_check_id|failover|FAILOVER/i)) {
1359
+ findings.push({ ruleId: 'INFRA-CLOUD-026', category: 'infrastructure', severity: 'medium', title: 'Route53 record without health check failover', description: 'Add Route53 health checks with failover routing. Without health checks, DNS continues directing traffic to failed endpoints.', file: fp, fix: null });
1360
+ }
1361
+ }
1362
+ return findings;
1363
+ },
1364
+ },
1365
+ // INFRA-CLOUD-027: No SES sending limit monitoring
1366
+ { id: 'INFRA-CLOUD-027', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'SES Without Bounce/Complaint Rate Monitoring',
1367
+ check({ files }) {
1368
+ const findings = [];
1369
+ const allCode = [...files.values()].join('\n');
1370
+ const hasSES = allCode.match(/aws.*ses|ses.*send|sendEmail|SESClient/i);
1371
+ const hasMonitor = allCode.match(/bounce.*rate|complaint.*rate|ses.*reputation|ses.*notification/i);
1372
+ if (hasSES && !hasMonitor) {
1373
+ findings.push({ ruleId: 'INFRA-CLOUD-027', category: 'infrastructure', severity: 'medium', title: 'AWS SES without bounce/complaint rate monitoring', description: 'Monitor SES bounce rate (<5%) and complaint rate (<0.1%). AWS suspends sending if rates exceed thresholds, potentially killing all email functionality.', fix: null });
1374
+ }
1375
+ return findings;
1376
+ },
1377
+ },
1378
+ // INFRA-MON-005: No capacity planning
1379
+ { id: 'INFRA-MON-005', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Disk Space Monitoring',
1380
+ check({ files }) {
1381
+ const findings = [];
1382
+ const allCode = [...files.values()].join('\n');
1383
+ const hasDiskMonitor = allCode.match(/disk.*alert|disk.*alarm|diskSpace|node_disk|disk.*usage.*alarm/i);
1384
+ const hasInfra = [...files.keys()].some(f => f.match(/\.tf$/));
1385
+ if (hasInfra && !hasDiskMonitor) {
1386
+ findings.push({ ruleId: 'INFRA-MON-005', category: 'infrastructure', severity: 'medium', title: 'No disk space alerting — disk full causes silent failures', description: 'Alert when disk usage > 80%. Full disks cause databases, log systems, and applications to fail silently or with cryptic errors.', fix: null });
1387
+ }
1388
+ return findings;
1389
+ },
1390
+ },
1391
+ // INFRA-NET-005: No DDoS protection
1392
+ { id: 'INFRA-NET-005', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No DDoS Protection Service',
1393
+ check({ files }) {
1394
+ const findings = [];
1395
+ const isAWS = [...files.values()].some(c => c.match(/aws_|AWS::/));
1396
+ const hasShield = [...files.values()].some(c => c.match(/aws_shield|Shield.*Advanced|cloudflare|akamai.*ddos/i));
1397
+ if (isAWS && !hasShield) {
1398
+ findings.push({ ruleId: 'INFRA-NET-005', category: 'infrastructure', severity: 'high', title: 'No AWS Shield or DDoS protection configured', description: 'Enable AWS Shield Standard (free) for basic DDoS protection. For critical infrastructure, AWS Shield Advanced provides enhanced protection and DDoS cost protection.', fix: null });
1399
+ }
1400
+ return findings;
1401
+ },
1402
+ },
1403
+ // INFRA-K8S-021: No Pod Security Standards enforcement
1404
+ { id: 'INFRA-K8S-021', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No Pod Security Standards Labels on Namespaces',
1405
+ check({ files }) {
1406
+ const findings = [];
1407
+ for (const [fp, c] of files) {
1408
+ if (!fp.match(/\.yaml$|\.yml$/)) continue;
1409
+ if (c.match(/kind:\s*Namespace/)) {
1410
+ if (!c.match(/pod-security\.kubernetes\.io\/(enforce|audit|warn)/)) {
1411
+ findings.push({ ruleId: 'INFRA-K8S-021', category: 'infrastructure', severity: 'high', title: 'Kubernetes Namespace without Pod Security Standards labels', description: 'Add pod-security.kubernetes.io/enforce: restricted label to namespaces. Without PSS enforcement, pods can run as root or with privileged access, violating least-privilege.', file: fp, fix: null });
1412
+ }
1413
+ }
1414
+ }
1415
+ return findings;
1416
+ },
1417
+ },
1418
+ // INFRA-K8S-022: Service account with auto-mounted token
1419
+ { id: 'INFRA-K8S-022', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes ServiceAccount Token Auto-Mounted',
1420
+ check({ files }) {
1421
+ const findings = [];
1422
+ for (const [fp, c] of files) {
1423
+ if (!fp.match(/\.yaml$|\.yml$/)) continue;
1424
+ if (c.match(/kind:\s*Pod|kind:\s*Deployment/)) {
1425
+ if (!c.match(/automountServiceAccountToken:\s*false/)) {
1426
+ findings.push({ ruleId: 'INFRA-K8S-022', category: 'infrastructure', severity: 'medium', title: 'Pod does not disable automatic ServiceAccount token mounting', description: 'Set automountServiceAccountToken: false if the pod does not need Kubernetes API access. Auto-mounted tokens can be stolen if the pod is compromised and used to access the cluster.', file: fp, fix: null });
1427
+ }
1428
+ }
1429
+ }
1430
+ return findings;
1431
+ },
1432
+ },
1433
+ // INFRA-TF-011: No remote state locking
1434
+ { id: 'INFRA-TF-011', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform Remote State Without Locking',
1435
+ check({ files }) {
1436
+ const findings = [];
1437
+ for (const [fp, c] of files) {
1438
+ if (!fp.match(/\.tf$/)) continue;
1439
+ if (c.match(/backend\s+"s3"/) && !c.match(/dynamodb_table|lock_table/i)) {
1440
+ findings.push({ ruleId: 'INFRA-TF-011', category: 'infrastructure', severity: 'high', title: 'Terraform S3 backend without DynamoDB state locking', description: 'Add dynamodb_table to S3 backend config. Without state locking, concurrent terraform applies corrupt the state file, causing phantom resource creation and deletion.', file: fp, fix: null });
1441
+ }
1442
+ }
1443
+ return findings;
1444
+ },
1445
+ },
1446
+ // INFRA-CLOUD-028: Lambda without concurrency limits
1447
+ { id: 'INFRA-CLOUD-028', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Lambda Without Reserved or Provisioned Concurrency Limits',
1448
+ check({ files }) {
1449
+ const findings = [];
1450
+ for (const [fp, c] of files) {
1451
+ if (!fp.match(/\.tf$|serverless\.yml|\.yaml$/) ) continue;
1452
+ if (c.match(/aws_lambda_function|handler:.*\.handler/i)) {
1453
+ if (!c.match(/reserved_concurrent_executions|provisioned_concurrent_executions|reservedConcurrency/i)) {
1454
+ findings.push({ ruleId: 'INFRA-CLOUD-028', category: 'infrastructure', severity: 'medium', title: 'Lambda without concurrency limit — single function can exhaust account concurrency', description: 'Set reserved_concurrent_executions on Lambda functions. Without limits, a traffic spike or runaway loop on one function can consume all 1,000 account-level concurrent executions.', file: fp, fix: null });
1455
+ }
1456
+ }
1457
+ }
1458
+ return findings;
1459
+ },
1460
+ },
1461
+ // INFRA-CLOUD-029: No S3 bucket public access block
1462
+ { id: 'INFRA-CLOUD-029', category: 'infrastructure', severity: 'critical', confidence: 'likely', title: 'S3 Bucket Without Public Access Block',
1463
+ check({ files }) {
1464
+ const findings = [];
1465
+ for (const [fp, c] of files) {
1466
+ if (!fp.match(/\.tf$/)) continue;
1467
+ const buckets = c.match(/resource\s+"aws_s3_bucket"\s+"\w+"/g) || [];
1468
+ if (buckets.length > 0) {
1469
+ const hasBlock = c.match(/aws_s3_bucket_public_access_block|block_public_acls\s*=\s*true/i);
1470
+ if (!hasBlock) {
1471
+ findings.push({ ruleId: 'INFRA-CLOUD-029', category: 'infrastructure', severity: 'critical', title: 'S3 bucket defined without public access block configuration', description: 'Add aws_s3_bucket_public_access_block with all options set to true. Without this, S3 ACLs or policies can accidentally make buckets public, exposing data.', file: fp, fix: null });
1472
+ }
1473
+ }
1474
+ }
1475
+ return findings;
1476
+ },
1477
+ },
1478
+ // INFRA-CLOUD-030: RDS without automated backups
1479
+ { id: 'INFRA-CLOUD-030', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'RDS Instance With Automated Backup Disabled',
1480
+ check({ files }) {
1481
+ const findings = [];
1482
+ for (const [fp, c] of files) {
1483
+ if (!fp.match(/\.tf$/)) continue;
1484
+ if (c.match(/aws_db_instance|aws_rds_cluster/i)) {
1485
+ if (c.match(/backup_retention_period\s*=\s*0/)) {
1486
+ findings.push({ ruleId: 'INFRA-CLOUD-030', category: 'infrastructure', severity: 'high', title: 'RDS automated backups disabled (backup_retention_period = 0)', description: 'Set backup_retention_period to at least 7 days. Disabling automated backups means data loss in case of corruption or accidental deletion with no recovery path.', file: fp, fix: null });
1487
+ }
1488
+ }
1489
+ }
1490
+ return findings;
1491
+ },
1492
+ },
1493
+ // INFRA-MON-006: No alerting on Lambda error rate
1494
+ { id: 'INFRA-MON-006', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Lambda Functions Without Error Rate Alarms',
1495
+ check({ files }) {
1496
+ const findings = [];
1497
+ const hasLambda = [...files.values()].some(c => c.match(/aws_lambda_function/i));
1498
+ const hasAlarm = [...files.values()].some(c => c.match(/aws_cloudwatch_metric_alarm.*Lambda.*Errors|metric_name.*Errors.*aws.*lambda/i));
1499
+ if (hasLambda && !hasAlarm) {
1500
+ findings.push({ ruleId: 'INFRA-MON-006', category: 'infrastructure', severity: 'medium', title: 'Lambda functions without CloudWatch error alarms', description: 'Create CloudWatch alarms for Lambda Errors metric. Without alarms, Lambda failures are invisible until users complain. Alarm on error rate > 1% for 5 minutes.', fix: null });
1501
+ }
1502
+ return findings;
1503
+ },
1504
+ },
1505
+ // INFRA-CLOUD-031: No VPC endpoint for S3/DynamoDB
1506
+ { id: 'INFRA-CLOUD-031', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No VPC Endpoint for AWS Services (S3/DynamoDB)',
1507
+ check({ files }) {
1508
+ const findings = [];
1509
+ const hasVPC = [...files.values()].some(c => c.match(/aws_vpc\s+|resource.*aws_vpc/i));
1510
+ const hasS3OrDynamo = [...files.values()].some(c => c.match(/aws_s3_bucket|aws_dynamodb_table/i));
1511
+ const hasEndpoint = [...files.values()].some(c => c.match(/aws_vpc_endpoint/i));
1512
+ if (hasVPC && hasS3OrDynamo && !hasEndpoint) {
1513
+ findings.push({ ruleId: 'INFRA-CLOUD-031', category: 'infrastructure', severity: 'medium', title: 'VPC resources accessing S3/DynamoDB without VPC endpoints', description: 'Add VPC endpoints for S3 and DynamoDB. Without endpoints, traffic routes through the public internet, incurring NAT Gateway data processing costs and increasing attack surface.', fix: null });
1514
+ }
1515
+ return findings;
1516
+ },
1517
+ },
1518
+ // INFRA-NET-006: Security group allows unrestricted database port
1519
+ { id: 'INFRA-NET-006', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Security Group Exposes Database Port Publicly',
1520
+ check({ files }) {
1521
+ const findings = [];
1522
+ for (const [fp, c] of files) {
1523
+ if (!fp.match(/\.tf$/)) continue;
1524
+ const lines = c.split('\n');
1525
+ for (let i = 0; i < lines.length; i++) {
1526
+ if (lines[i].match(/from_port\s*=\s*(5432|3306|1433|27017|6379|5984)\b/)) {
1527
+ const ctx = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
1528
+ if (ctx.match(/cidr_blocks\s*=\s*\[["']0\.0\.0\.0\/0["']\]/)) {
1529
+ findings.push({ ruleId: 'INFRA-NET-006', category: 'infrastructure', severity: 'critical', title: 'Database port (Postgres/MySQL/Redis/MongoDB) exposed to 0.0.0.0/0', description: 'Restrict database security group ingress to application security group only. Publicly exposed database ports are scanned and attacked within minutes of exposure.', file: fp, line: i + 1, fix: null });
1530
+ }
1531
+ }
1532
+ }
1533
+ }
1534
+ return findings;
1535
+ },
1536
+ },
1537
+ // INFRA-K8S-023: Container with writable root filesystem
1538
+ { id: 'INFRA-K8S-023', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Container Without readOnlyRootFilesystem',
1539
+ check({ files }) {
1540
+ const findings = [];
1541
+ for (const [fp, c] of files) {
1542
+ if (!fp.match(/\.yaml$|\.yml$/)) continue;
1543
+ if (c.match(/kind:\s*(Pod|Deployment|StatefulSet|DaemonSet)/) && c.match(/containers:/) && !c.match(/readOnlyRootFilesystem:\s*true/)) {
1544
+ findings.push({ ruleId: 'INFRA-K8S-023', category: 'infrastructure', severity: 'medium', title: 'Kubernetes container without readOnlyRootFilesystem: true', description: 'Set securityContext.readOnlyRootFilesystem: true. A writable filesystem allows attackers to drop malware or modify application binaries after gaining initial access.', file: fp, fix: null });
1545
+ }
1546
+ }
1547
+ return findings;
1548
+ },
1549
+ },
1550
+ // INFRA-CLOUD-032: EC2 instance store used for persistent data
1551
+ { id: 'INFRA-CLOUD-032', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'EC2 Instance Store Used for Persistent Data',
1552
+ check({ files }) {
1553
+ const findings = [];
1554
+ for (const [fp, c] of files) {
1555
+ if (!fp.match(/\.tf$/)) continue;
1556
+ if (c.match(/aws_instance.*instance_type\s*=.*\w+\.(i\d|d\d|h\d)/i)) {
1557
+ const hasEBS = c.match(/ebs_block_device|root_block_device/i);
1558
+ if (!hasEBS) {
1559
+ findings.push({ ruleId: 'INFRA-CLOUD-032', category: 'infrastructure', severity: 'high', title: 'Storage-optimized EC2 instance without explicit EBS volume — data lost on stop/terminate', description: 'Use EBS volumes for persistent data, not instance store. Instance store volumes are ephemeral and all data is permanently lost when the instance stops or fails.', file: fp, fix: null });
1560
+ }
1561
+ }
1562
+ }
1563
+ return findings;
1564
+ },
1565
+ },
1566
+ // INFRA-TF-012: Terraform output with sensitive = false for secrets
1567
+ { id: 'INFRA-TF-012', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Sensitive Value in Terraform Output Without sensitive = true',
1568
+ check({ files }) {
1569
+ const findings = [];
1570
+ for (const [fp, c] of files) {
1571
+ if (!fp.match(/\.tf$/)) continue;
1572
+ const lines = c.split('\n');
1573
+ for (let i = 0; i < lines.length; i++) {
1574
+ if (lines[i].match(/output\s+"\w*(password|secret|key|token|credential)\w*"/i)) {
1575
+ const ctx = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
1576
+ if (!ctx.match(/sensitive\s*=\s*true/)) {
1577
+ findings.push({ ruleId: 'INFRA-TF-012', category: 'infrastructure', severity: 'high', title: 'Terraform output for secret/password without sensitive = true', description: 'Mark outputs containing secrets with sensitive = true. Without this, the value is printed in plain text in terraform apply output and stored unredacted in state.', file: fp, line: i + 1, fix: null });
1578
+ }
1579
+ }
1580
+ }
1581
+ }
1582
+ return findings;
1583
+ },
1584
+ },
1585
+ // INFRA-MON-007: No CloudWatch dashboard
1586
+ { id: 'INFRA-MON-007', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'No CloudWatch Dashboard for Production Resources',
1587
+ check({ files }) {
1588
+ const findings = [];
1589
+ const hasAWSResources = [...files.values()].some(c => c.match(/aws_lambda_function|aws_rds_instance|aws_ecs_service/i));
1590
+ const hasDashboard = [...files.values()].some(c => c.match(/aws_cloudwatch_dashboard|CloudWatch.*Dashboard|grafana/i));
1591
+ if (hasAWSResources && !hasDashboard) {
1592
+ findings.push({ ruleId: 'INFRA-MON-007', category: 'infrastructure', severity: 'low', title: 'No CloudWatch dashboard configured for production resources', description: 'Create CloudWatch dashboards for key metrics (request rate, error rate, latency, CPU, memory). Dashboards reduce MTTR by providing instant visibility into system health.', fix: null });
1593
+ }
1594
+ return findings;
1595
+ },
1596
+ },
1597
+ // INFRA-CLOUD-033: IAM role with AdministratorAccess
1598
+ { id: 'INFRA-CLOUD-033', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'IAM Role with AdministratorAccess Policy',
1599
+ check({ files }) {
1600
+ const findings = [];
1601
+ for (const [fp, c] of files) {
1602
+ if (!fp.match(/\.tf$|\.json$|\.yaml$|\.yml$/)) continue;
1603
+ if (c.match(/AdministratorAccess|arn:aws:iam::aws:policy\/AdministratorAccess/)) {
1604
+ findings.push({ ruleId: 'INFRA-CLOUD-033', category: 'infrastructure', severity: 'critical', title: 'IAM role attached to AdministratorAccess managed policy', description: 'Replace AdministratorAccess with minimum required permissions. AdministratorAccess grants full AWS account control — a compromised role can delete all resources, exfiltrate all data.', file: fp, fix: null });
1605
+ }
1606
+ }
1607
+ return findings;
1608
+ },
1609
+ },
1610
+ // INFRA-NET-007: No private subnet for databases
1611
+ { id: 'INFRA-NET-007', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Database Resources Not in Private Subnet',
1612
+ check({ files }) {
1613
+ const findings = [];
1614
+ for (const [fp, c] of files) {
1615
+ if (!fp.match(/\.tf$/)) continue;
1616
+ if (c.match(/aws_db_instance|aws_rds_cluster|aws_elasticache_cluster/i)) {
1617
+ if (!c.match(/db_subnet_group|subnet.*private|private.*subnet/i)) {
1618
+ findings.push({ ruleId: 'INFRA-NET-007', category: 'infrastructure', severity: 'high', title: 'Database resource without explicit private subnet group', description: 'Create a dedicated DB subnet group using private subnets. Databases should never be placed in public subnets. Use private subnets that route through NAT for outbound, not inbound.', file: fp, fix: null });
1619
+ }
1620
+ }
1621
+ }
1622
+ return findings;
1623
+ },
1624
+ },
1625
+ // INFRA-K8S-024: Kubernetes RBAC with wildcard verbs
1626
+ { id: 'INFRA-K8S-024', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes RBAC Role With Wildcard Verbs',
1627
+ check({ files }) {
1628
+ const findings = [];
1629
+ for (const [fp, c] of files) {
1630
+ if (!fp.match(/\.yaml$|\.yml$/)) continue;
1631
+ if (c.match(/kind:\s*(Role|ClusterRole)/) && c.match(/verbs:\s*\n\s*-\s*['"*]["']/)) {
1632
+ findings.push({ ruleId: 'INFRA-K8S-024', category: 'infrastructure', severity: 'high', title: 'Kubernetes Role with wildcard (*) verbs — overly permissive', description: 'Specify minimum required verbs (get, list, watch, create, update, patch, delete). Wildcard verbs grant all operations including delete, enabling privilege escalation or data destruction.', file: fp, fix: null });
1633
+ }
1634
+ }
1635
+ return findings;
1636
+ },
1637
+ },
1638
+ // INFRA-CLOUD-034: SQS queue without encryption at rest
1639
+ { id: 'INFRA-CLOUD-034', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'SQS Queue Without Encryption at Rest',
1640
+ check({ files }) {
1641
+ const findings = [];
1642
+ for (const [fp, c] of files) {
1643
+ if (!fp.match(/\.tf$/)) continue;
1644
+ if (c.match(/aws_sqs_queue/i) && !c.match(/kms_master_key_id|sqs_managed_sse_enabled\s*=\s*true/i)) {
1645
+ findings.push({ ruleId: 'INFRA-CLOUD-034', category: 'infrastructure', severity: 'medium', title: 'SQS queue without SSE encryption', description: 'Enable SQS Server-Side Encryption with SQS-managed keys (sqs_managed_sse_enabled = true) or a KMS key. Encrypted queues protect sensitive message data at rest.', file: fp, fix: null });
1646
+ }
1647
+ }
1648
+ return findings;
1649
+ },
1650
+ },
1651
+ // INFRA-TF-013: No Terraform module source versioning
1652
+ { id: 'INFRA-TF-013', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Terraform Module Source Without Version Constraint',
1653
+ check({ files }) {
1654
+ const findings = [];
1655
+ for (const [fp, c] of files) {
1656
+ if (!fp.match(/\.tf$/)) continue;
1657
+ const lines = c.split('\n');
1658
+ for (let i = 0; i < lines.length; i++) {
1659
+ if (lines[i].match(/source\s*=\s*["'](?!\.\/|\.\.\/|\bterraform\b)/)) {
1660
+ const ctx = lines.slice(i, Math.min(lines.length, i + 8)).join('\n');
1661
+ if (!ctx.match(/version\s*=/)) {
1662
+ findings.push({ ruleId: 'INFRA-TF-013', category: 'infrastructure', severity: 'medium', title: 'Terraform module without version pin — breaking changes on next apply', description: 'Add version = "~> X.Y" to all module blocks. Unpinned modules pull the latest version on each terraform init, which may introduce breaking changes silently.', file: fp, line: i + 1, fix: null });
1663
+ }
1664
+ }
1665
+ }
1666
+ }
1667
+ return findings;
1668
+ },
1669
+ },
1670
+ // INFRA-CLOUD-035: AWS account without budget alerts
1671
+ { id: 'INFRA-CLOUD-035', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No AWS Budget Alert Configured',
1672
+ check({ files }) {
1673
+ const findings = [];
1674
+ const hasAWSResources = [...files.values()].some(c => c.match(/aws_lambda|aws_rds|aws_ecs|aws_ec2/i));
1675
+ const hasBudget = [...files.values()].some(c => c.match(/aws_budgets_budget|cost.*alert|billing.*alarm|CostAnomaly/i));
1676
+ if (hasAWSResources && !hasBudget) {
1677
+ findings.push({ ruleId: 'INFRA-CLOUD-035', category: 'infrastructure', severity: 'medium', title: 'AWS resources provisioned without budget alerts', description: 'Create AWS Budgets with email/SNS alerts. Without cost alerts, runaway Lambda loops, DDoS attacks, or misconfigured resources can generate unbounded costs before discovery.', fix: null });
1678
+ }
1679
+ return findings;
1680
+ },
1681
+ },
1682
+ // INFRA-NET-008: No TLS termination at load balancer
1683
+ { id: 'INFRA-NET-008', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Load Balancer Without HTTPS Listener',
1684
+ check({ files }) {
1685
+ const findings = [];
1686
+ for (const [fp, c] of files) {
1687
+ if (!fp.match(/\.tf$/)) continue;
1688
+ if (c.match(/aws_lb_listener|aws_alb_listener/i)) {
1689
+ const hasHTTPS = c.match(/protocol\s*=\s*["']HTTPS["']|port\s*=\s*["']443["']/i);
1690
+ const hasHTTP = c.match(/protocol\s*=\s*["']HTTP["']|port\s*=\s*["']80["']/i);
1691
+ if (hasHTTP && !hasHTTPS) {
1692
+ findings.push({ ruleId: 'INFRA-NET-008', category: 'infrastructure', severity: 'high', title: 'Load balancer with only HTTP listener — no HTTPS termination', description: 'Add an HTTPS listener with an ACM certificate. HTTP traffic is unencrypted and can be intercepted. Redirect all HTTP traffic to HTTPS (status 301).', file: fp, fix: null });
1693
+ }
1694
+ }
1695
+ }
1696
+ return findings;
1697
+ },
1698
+ },
1699
+ // INFRA-CLOUD-036: Lambda with excessive timeout
1700
+ { id: 'INFRA-CLOUD-036', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Lambda Function With Maximum Timeout Setting',
1701
+ check({ files }) {
1702
+ const findings = [];
1703
+ for (const [fp, c] of files) {
1704
+ if (!fp.match(/\.tf$|serverless\.yml|serverless\.yaml$/)) continue;
1705
+ if (c.match(/timeout\s*=\s*900|timeout\s*:\s*900/i)) {
1706
+ findings.push({ ruleId: 'INFRA-CLOUD-036', category: 'infrastructure', severity: 'low', title: 'Lambda timeout set to maximum (15 minutes) — runaway functions waste money', description: 'Set Lambda timeout to 2x the expected maximum execution time. Maximum timeouts mask bugs, increase blast radius of infinite loops, and delay failure detection by 15 minutes.', file: fp, fix: null });
1707
+ }
1708
+ }
1709
+ return findings;
1710
+ },
1711
+ },
1712
+ // INFRA-K8S-025: Kubernetes Ingress without TLS
1713
+ { id: 'INFRA-K8S-025', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes Ingress Without TLS Configuration',
1714
+ check({ files }) {
1715
+ const findings = [];
1716
+ for (const [fp, c] of files) {
1717
+ if (!fp.match(/\.yaml$|\.yml$/)) continue;
1718
+ if (c.match(/kind:\s*Ingress/) && c.match(/host:|rules:/) && !c.match(/tls:|cert-manager/i)) {
1719
+ findings.push({ ruleId: 'INFRA-K8S-025', category: 'infrastructure', severity: 'high', title: 'Kubernetes Ingress without TLS — traffic served over HTTP', description: 'Add tls: section with cert-manager annotation. HTTP-only Ingress exposes all traffic including authentication tokens in plaintext. Use cert-manager for automatic TLS certificate management.', file: fp, fix: null });
1720
+ }
1721
+ }
1722
+ return findings;
1723
+ },
1724
+ },
1725
+ ];
1726
+
1727
+ export default rules;
1728
+
1729
+ // INFRA-K8S-026: No resource limits in K8s deployment
1730
+ rules.push({
1731
+ id: 'INFRA-K8S-026', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes container without CPU/memory resource limits',
1732
+ check({ files }) {
1733
+ const findings = [];
1734
+ for (const [fp, c] of files) {
1735
+ if (!fp.match(/\.ya?ml$/)) continue;
1736
+ if (c.match(/kind:\s*(?:Deployment|StatefulSet|DaemonSet)/) && c.match(/containers:/) && !c.match(/resources:\s*\n\s+limits:/)) {
1737
+ findings.push({ ruleId: 'INFRA-K8S-026', category: 'infrastructure', severity: 'high', title: 'K8s container without resource limits — noisy neighbor risk', description: 'Without resource limits, a container can consume all node CPU/memory, starving other pods. Add resources.limits.cpu and resources.limits.memory.', file: fp, fix: null });
1738
+ }
1739
+ }
1740
+ return findings;
1741
+ },
1742
+ });
1743
+
1744
+ // INFRA-K8S-027: No liveness probe
1745
+ rules.push({
1746
+ id: 'INFRA-K8S-027', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes Deployment without liveness probe',
1747
+ check({ files }) {
1748
+ const findings = [];
1749
+ for (const [fp, c] of files) {
1750
+ if (!fp.match(/\.ya?ml$/)) continue;
1751
+ if (c.match(/kind:\s*(?:Deployment|StatefulSet)/) && c.match(/containers:/) && !c.match(/livenessProbe:/)) {
1752
+ findings.push({ ruleId: 'INFRA-K8S-027', category: 'infrastructure', severity: 'medium', title: 'K8s Deployment without livenessProbe — stuck pods not restarted', description: 'Without a liveness probe, Kubernetes cannot detect when a container is deadlocked. Add a livenessProbe to trigger automatic restart.', file: fp, fix: null });
1753
+ }
1754
+ }
1755
+ return findings;
1756
+ },
1757
+ });
1758
+
1759
+ // INFRA-K8S-028: Privileged container
1760
+ rules.push({
1761
+ id: 'INFRA-K8S-028', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Kubernetes container running as privileged',
1762
+ check({ files }) {
1763
+ const findings = [];
1764
+ for (const [fp, c] of files) {
1765
+ if (!fp.match(/\.ya?ml$/)) continue;
1766
+ if (c.match(/privileged\s*:\s*true/)) {
1767
+ findings.push({ ruleId: 'INFRA-K8S-028', category: 'infrastructure', severity: 'critical', title: 'Privileged K8s container — full host access, container escape possible', description: 'Privileged containers have root access to the host node. A compromised privileged container can escape to the host. Remove privileged: true and use specific capabilities instead.', file: fp, fix: null });
1768
+ }
1769
+ }
1770
+ return findings;
1771
+ },
1772
+ });
1773
+
1774
+ // INFRA-K8S-029: hostNetwork: true
1775
+ rules.push({
1776
+ id: 'INFRA-K8S-029', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes pod using hostNetwork: true',
1777
+ check({ files }) {
1778
+ const findings = [];
1779
+ for (const [fp, c] of files) {
1780
+ if (!fp.match(/\.ya?ml$/)) continue;
1781
+ if (c.match(/hostNetwork\s*:\s*true/)) {
1782
+ findings.push({ ruleId: 'INFRA-K8S-029', category: 'infrastructure', severity: 'high', title: 'K8s pod with hostNetwork:true — bypasses network isolation', description: 'hostNetwork: true gives the pod access to the host network namespace, bypassing Kubernetes network policies and exposing host services. Use Service resources instead.', file: fp, fix: null });
1783
+ }
1784
+ }
1785
+ return findings;
1786
+ },
1787
+ });
1788
+
1789
+ // INFRA-K8S-030: Root filesystem not read-only
1790
+ rules.push({
1791
+ id: 'INFRA-K8S-030', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes container without readOnlyRootFilesystem',
1792
+ check({ files }) {
1793
+ const findings = [];
1794
+ for (const [fp, c] of files) {
1795
+ if (!fp.match(/\.ya?ml$/)) continue;
1796
+ if (c.match(/kind:\s*(?:Deployment|StatefulSet|DaemonSet)/) && c.match(/securityContext:/) && !c.match(/readOnlyRootFilesystem\s*:\s*true/)) {
1797
+ findings.push({ ruleId: 'INFRA-K8S-030', category: 'infrastructure', severity: 'medium', title: 'Container without readOnlyRootFilesystem — attacker can modify container filesystem', description: 'Set securityContext.readOnlyRootFilesystem: true to prevent attackers from writing malicious files to the container. Mount specific writable volumes only where needed.', file: fp, fix: null });
1798
+ }
1799
+ }
1800
+ return findings;
1801
+ },
1802
+ });
1803
+
1804
+ // INFRA-K8S-031: Missing network policy
1805
+ rules.push({
1806
+ id: 'INFRA-K8S-031', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Kubernetes NetworkPolicy defined — all pod-to-pod traffic allowed',
1807
+ check({ files }) {
1808
+ const findings = [];
1809
+ const hasDeployment = [...files.keys()].some(f => f.match(/\.ya?ml$/) && [...files.get(f)].join && /kind:\s*Deployment/.test(files.get(f)));
1810
+ const hasNetPol = [...files.values()].some(c => /kind:\s*NetworkPolicy/.test(c));
1811
+ if (hasDeployment && !hasNetPol) {
1812
+ findings.push({ ruleId: 'INFRA-K8S-031', category: 'infrastructure', severity: 'medium', title: 'No NetworkPolicy — all pods can communicate without restriction', description: 'Without NetworkPolicies, any compromised pod can reach any other pod or service. Define ingress/egress NetworkPolicies to enforce least-privilege network access.', fix: null });
1813
+ }
1814
+ return findings;
1815
+ },
1816
+ });
1817
+
1818
+ // INFRA-DC-026: Docker Compose no restart policy
1819
+ rules.push({
1820
+ id: 'INFRA-DC-026', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Docker Compose service without restart policy',
1821
+ check({ files }) {
1822
+ const findings = [];
1823
+ for (const [fp, c] of files) {
1824
+ if (!fp.match(/docker-compose.*\.ya?ml$/i)) continue;
1825
+ if (c.match(/services:/) && !c.match(/restart\s*:/)) {
1826
+ findings.push({ ruleId: 'INFRA-DC-026', category: 'infrastructure', severity: 'medium', title: 'Docker Compose service without restart policy — won\'t recover from crashes', description: 'Without restart: unless-stopped or always, crashed services stay down. Add restart: unless-stopped to all production services.', file: fp, fix: null });
1827
+ }
1828
+ }
1829
+ return findings;
1830
+ },
1831
+ });
1832
+
1833
+ // INFRA-DC-027: Secrets in Docker Compose environment
1834
+ rules.push({
1835
+ id: 'INFRA-DC-027', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Hardcoded secret in Docker Compose environment section',
1836
+ check({ files }) {
1837
+ const findings = [];
1838
+ for (const [fp, c] of files) {
1839
+ if (!fp.match(/docker-compose.*\.ya?ml$/i) && !fp.match(/compose\.ya?ml$/i)) continue;
1840
+ const lines = c.split('\n');
1841
+ for (let i = 0; i < lines.length; i++) {
1842
+ if (/(?:PASSWORD|SECRET|KEY|TOKEN|API_KEY)\s*:\s*[^${\s][^\s]{3,}/i.test(lines[i]) && !/\$\{/.test(lines[i])) {
1843
+ findings.push({ ruleId: 'INFRA-DC-027', category: 'infrastructure', severity: 'critical', title: 'Hardcoded secret in docker-compose.yml environment section', description: 'Secrets committed to docker-compose files are exposed to anyone with repo access. Use ${VARIABLE} references with a .env file, or Docker secrets.', file: fp, line: i + 1, fix: null });
1844
+ }
1845
+ }
1846
+ }
1847
+ return findings;
1848
+ },
1849
+ });
1850
+
1851
+ // INFRA-DC-028: Docker Compose port bound to 0.0.0.0
1852
+ rules.push({
1853
+ id: 'INFRA-DC-028', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Docker Compose port bound to all interfaces (0.0.0.0)',
1854
+ check({ files }) {
1855
+ const findings = [];
1856
+ for (const [fp, c] of files) {
1857
+ if (!fp.match(/docker-compose.*\.ya?ml$/i) && !fp.match(/compose\.ya?ml$/i)) continue;
1858
+ const lines = c.split('\n');
1859
+ for (let i = 0; i < lines.length; i++) {
1860
+ if (/["']?0\.0\.0\.0:\d+:\d+["']?/.test(lines[i])) {
1861
+ findings.push({ ruleId: 'INFRA-DC-028', category: 'infrastructure', severity: 'medium', title: 'Port bound to 0.0.0.0 — accessible from any network interface', description: 'Binding ports to 0.0.0.0 exposes services to all network interfaces including public ones. Bind to 127.0.0.1 for local-only services: "127.0.0.1:3000:3000".', file: fp, line: i + 1, fix: null });
1862
+ }
1863
+ }
1864
+ }
1865
+ return findings;
1866
+ },
1867
+ });
1868
+
1869
+ // INFRA-TF-026: No S3 versioning
1870
+ rules.push({
1871
+ id: 'INFRA-TF-026', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Terraform S3 bucket without versioning enabled',
1872
+ check({ files }) {
1873
+ const findings = [];
1874
+ for (const [fp, c] of files) {
1875
+ if (!fp.match(/\.tf$/)) continue;
1876
+ if (c.match(/resource\s+["']aws_s3_bucket["']/) && !c.match(/versioning\s*\{[^}]*enabled\s*=\s*true/)) {
1877
+ findings.push({ ruleId: 'INFRA-TF-026', category: 'infrastructure', severity: 'medium', title: 'S3 bucket without versioning — no protection against accidental deletion', description: 'Enable S3 versioning to protect against accidental overwrites and deletions. Add versioning { enabled = true } to your aws_s3_bucket resource.', file: fp, fix: null });
1878
+ }
1879
+ }
1880
+ return findings;
1881
+ },
1882
+ });
1883
+
1884
+ // INFRA-TF-027: No CloudTrail
1885
+ rules.push({
1886
+ id: 'INFRA-TF-027', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No AWS CloudTrail configured in Terraform',
1887
+ check({ files }) {
1888
+ const findings = [];
1889
+ const hasTF = [...files.keys()].some(f => f.match(/\.tf$/));
1890
+ const hasCloudTrail = [...files.values()].some(c => /aws_cloudtrail|cloudtrail/i.test(c));
1891
+ if (hasTF && !hasCloudTrail) {
1892
+ findings.push({ ruleId: 'INFRA-TF-027', category: 'infrastructure', severity: 'high', title: 'No CloudTrail — API calls not audited, incident response blind', description: 'CloudTrail provides an audit log of all AWS API calls. Without it, security incidents cannot be investigated. Enable CloudTrail with log file validation.', fix: null });
1893
+ }
1894
+ return findings;
1895
+ },
1896
+ });
1897
+
1898
+ // INFRA-TF-028: Open security group (0.0.0.0/0 ingress)
1899
+ rules.push({
1900
+ id: 'INFRA-TF-028', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Terraform security group with unrestricted ingress (0.0.0.0/0)',
1901
+ check({ files }) {
1902
+ const findings = [];
1903
+ for (const [fp, c] of files) {
1904
+ if (!fp.match(/\.tf$/)) continue;
1905
+ const lines = c.split('\n');
1906
+ for (let i = 0; i < lines.length; i++) {
1907
+ if (/cidr_blocks\s*=\s*\[\s*["']0\.0\.0\.0\/0["']/.test(lines[i])) {
1908
+ const ctx = lines.slice(Math.max(0, i - 5), i).join('\n');
1909
+ if (/ingress/.test(ctx)) {
1910
+ findings.push({ ruleId: 'INFRA-TF-028', category: 'infrastructure', severity: 'critical', title: 'Security group allows ingress from any IP (0.0.0.0/0)', description: 'Opening a security group to the entire internet exposes services to port scanning and exploitation. Restrict ingress to specific IP ranges or VPC CIDRs.', file: fp, line: i + 1, fix: null });
1911
+ }
1912
+ }
1913
+ }
1914
+ }
1915
+ return findings;
1916
+ },
1917
+ });
1918
+
1919
+ // INFRA-TF-029: No GuardDuty
1920
+ rules.push({
1921
+ id: 'INFRA-TF-029', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No AWS GuardDuty configured',
1922
+ check({ files }) {
1923
+ const findings = [];
1924
+ const hasTF = [...files.keys()].some(f => f.match(/\.tf$/));
1925
+ const hasGuardDuty = [...files.values()].some(c => /aws_guardduty|guardduty/i.test(c));
1926
+ if (hasTF && !hasGuardDuty) {
1927
+ findings.push({ ruleId: 'INFRA-TF-029', category: 'infrastructure', severity: 'medium', title: 'No GuardDuty — no threat detection for malicious activity', description: 'AWS GuardDuty continuously monitors for malicious activity and anomalous behavior. Enable it to detect compromised credentials, crypto mining, and data exfiltration.', fix: null });
1928
+ }
1929
+ return findings;
1930
+ },
1931
+ });
1932
+
1933
+ // INFRA-TF-030: RDS not encrypted at rest
1934
+ rules.push({
1935
+ id: 'INFRA-TF-030', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform RDS instance without storage encryption',
1936
+ check({ files }) {
1937
+ const findings = [];
1938
+ for (const [fp, c] of files) {
1939
+ if (!fp.match(/\.tf$/)) continue;
1940
+ if (c.match(/resource\s+["']aws_db_instance["']/) && !c.match(/storage_encrypted\s*=\s*true/)) {
1941
+ findings.push({ ruleId: 'INFRA-TF-030', category: 'infrastructure', severity: 'high', title: 'RDS instance without storage_encrypted=true — data at rest unprotected', description: 'Enable RDS encryption at rest: storage_encrypted = true. AWS KMS encrypts the underlying storage, snapshots, and replicas.', file: fp, fix: null });
1942
+ }
1943
+ }
1944
+ return findings;
1945
+ },
1946
+ });
1947
+
1948
+ // INFRA-K8S-032: Container running as root
1949
+ rules.push({
1950
+ id: 'INFRA-K8S-032', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes container may run as root (no runAsNonRoot)',
1951
+ check({ files }) {
1952
+ const findings = [];
1953
+ for (const [fp, c] of files) {
1954
+ if (!fp.match(/\.ya?ml$/)) continue;
1955
+ if (c.match(/kind:\s*(?:Deployment|StatefulSet|DaemonSet)/) && c.match(/containers:/) && !c.match(/runAsNonRoot\s*:\s*true|runAsUser\s*:\s*[1-9]/)) {
1956
+ findings.push({ ruleId: 'INFRA-K8S-032', category: 'infrastructure', severity: 'high', title: 'K8s container without runAsNonRoot — may execute as UID 0', description: 'Set securityContext.runAsNonRoot: true to prevent containers from running as root. A root container has elevated privileges if it escapes to the host.', file: fp, fix: null });
1957
+ }
1958
+ }
1959
+ return findings;
1960
+ },
1961
+ });
1962
+
1963
+ // INFRA-K8S-033: Sensitive hostPath mount
1964
+ rules.push({
1965
+ id: 'INFRA-K8S-033', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Kubernetes pod mounts sensitive host path',
1966
+ check({ files }) {
1967
+ const findings = [];
1968
+ for (const [fp, c] of files) {
1969
+ if (!fp.match(/\.ya?ml$/)) continue;
1970
+ if (c.match(/hostPath:/) && c.match(/path:\s*(?:\/etc|\/var\/run\/docker\.sock|\/proc|\/sys|\/root)/)) {
1971
+ findings.push({ ruleId: 'INFRA-K8S-033', category: 'infrastructure', severity: 'critical', title: 'Pod mounts sensitive host path (/etc, /proc, docker.sock) — container escape risk', description: 'Mounting /etc, docker.sock, /proc or /sys gives container access to host credentials and container runtime. Remove sensitive hostPath mounts.', file: fp, fix: null });
1972
+ }
1973
+ }
1974
+ return findings;
1975
+ },
1976
+ });
1977
+
1978
+ // INFRA-TF-031: EKS cluster public endpoint access
1979
+ rules.push({
1980
+ id: 'INFRA-TF-031', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'EKS cluster Kubernetes API publicly accessible',
1981
+ check({ files }) {
1982
+ const findings = [];
1983
+ for (const [fp, c] of files) {
1984
+ if (!fp.match(/\.tf$/)) continue;
1985
+ if (c.match(/resource\s+["']aws_eks_cluster["']/) && c.match(/endpoint_public_access\s*=\s*true/) && !c.match(/public_access_cidrs/)) {
1986
+ findings.push({ ruleId: 'INFRA-TF-031', category: 'infrastructure', severity: 'high', title: 'EKS cluster API endpoint publicly accessible without IP restriction', description: 'Restrict public EKS endpoint access with public_access_cidrs to known IPs, or disable public access and use a VPN/bastion.', file: fp, fix: null });
1987
+ }
1988
+ }
1989
+ return findings;
1990
+ },
1991
+ });
1992
+
1993
+ // INFRA-DC-029: Docker Compose missing health check
1994
+ rules.push({
1995
+ id: 'INFRA-DC-029', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Docker Compose service without healthcheck',
1996
+ check({ files }) {
1997
+ const findings = [];
1998
+ for (const [fp, c] of files) {
1999
+ if (!fp.match(/docker-compose.*\.ya?ml$/i) && !fp.match(/compose\.ya?ml$/i)) continue;
2000
+ if (c.match(/services:/) && !c.match(/healthcheck:/)) {
2001
+ findings.push({ ruleId: 'INFRA-DC-029', category: 'infrastructure', severity: 'medium', title: 'Docker Compose without healthcheck — container marked healthy before ready', description: 'Without healthcheck, Docker marks containers healthy immediately. Add healthcheck with test, interval, timeout, and retries to ensure services are truly ready.', file: fp, fix: null });
2002
+ }
2003
+ }
2004
+ return findings;
2005
+ },
2006
+ });
2007
+
2008
+ // INFRA additional rules
2009
+
2010
+ // INFRA-TF-032: No WAF configured
2011
+ rules.push({
2012
+ id: 'INFRA-TF-032', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No WAF (Web Application Firewall) configured',
2013
+ check({ files }) {
2014
+ const findings = [];
2015
+ const hasTF = [...files.keys()].some(f => f.match(/\.tf$/));
2016
+ const hasALB = [...files.values()].some(c => /aws_alb|aws_lb\b|aws_cloudfront/i.test(c));
2017
+ const hasWAF = [...files.values()].some(c => /aws_waf|aws_wafv2|waf_regional/i.test(c));
2018
+ if (hasTF && hasALB && !hasWAF) {
2019
+ findings.push({ ruleId: 'INFRA-TF-032', category: 'infrastructure', severity: 'high', title: 'Load balancer without WAF — no protection against OWASP Top 10', description: 'Attach AWS WAFv2 to your ALB or CloudFront distribution to block OWASP Top 10 attacks and malicious bots.', fix: null });
2020
+ }
2021
+ return findings;
2022
+ },
2023
+ });
2024
+
2025
+ // INFRA-K8S-034: No pod security policy/admission controller
2026
+ rules.push({
2027
+ id: 'INFRA-K8S-034', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No Pod Security Standards or Admission Controller configured',
2028
+ check({ files }) {
2029
+ const findings = [];
2030
+ const hasK8s = [...files.keys()].some(f => f.match(/\.ya?ml$/) && /kind:\s*Deployment/.test(files.get(f)));
2031
+ const hasPSP = [...files.values()].some(c => /PodSecurityPolicy|PodSecurity|OPA|Kyverno|admission.*webhook/i.test(c));
2032
+ if (hasK8s && !hasPSP) {
2033
+ findings.push({ ruleId: 'INFRA-K8S-034', category: 'infrastructure', severity: 'high', title: 'No Pod Security Standards admission controller — privileged pods can be deployed', description: 'Configure Kubernetes Pod Security Standards (Restricted profile) or an OPA/Kyverno policy to prevent privileged containers from being scheduled.', fix: null });
2034
+ }
2035
+ return findings;
2036
+ },
2037
+ });
2038
+
2039
+ // INFRA-TF-033: ELB access logs disabled
2040
+ rules.push({
2041
+ id: 'INFRA-TF-033', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Terraform ELB/ALB without access logging',
2042
+ check({ files }) {
2043
+ const findings = [];
2044
+ for (const [fp, c] of files) {
2045
+ if (!fp.match(/\.tf$/)) continue;
2046
+ if (c.match(/resource\s+["']aws_(?:alb|lb)\b/) && !c.match(/access_logs\s*\{[^}]*enabled\s*=\s*true/)) {
2047
+ findings.push({ ruleId: 'INFRA-TF-033', category: 'infrastructure', severity: 'medium', title: 'ALB without access logs — no HTTP request audit trail', description: 'Enable ALB access logs to S3 for security auditing and incident investigation. Set access_logs { bucket = ... enabled = true }.', file: fp, fix: null });
2048
+ }
2049
+ }
2050
+ return findings;
2051
+ },
2052
+ });
2053
+
2054
+ // INFRA-TF-034: ECR image scanning disabled
2055
+ rules.push({
2056
+ id: 'INFRA-TF-034', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Terraform ECR repository without image scanning',
2057
+ check({ files }) {
2058
+ const findings = [];
2059
+ for (const [fp, c] of files) {
2060
+ if (!fp.match(/\.tf$/)) continue;
2061
+ if (c.match(/aws_ecr_repository/) && !c.match(/scan_on_push\s*=\s*true/)) {
2062
+ findings.push({ ruleId: 'INFRA-TF-034', category: 'infrastructure', severity: 'medium', title: 'ECR repository without scan_on_push — container vulnerabilities not detected', description: 'Enable scan_on_push = true in aws_ecr_repository to automatically scan images for CVEs on each push.', file: fp, fix: null });
2063
+ }
2064
+ }
2065
+ return findings;
2066
+ },
2067
+ });
2068
+
2069
+ // INFRA-K8S-035: Missing pod anti-affinity for HA
2070
+ rules.push({
2071
+ id: 'INFRA-K8S-035', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes Deployment without pod anti-affinity — all pods may schedule on same node',
2072
+ check({ files }) {
2073
+ const findings = [];
2074
+ for (const [fp, c] of files) {
2075
+ if (!fp.match(/\.ya?ml$/)) continue;
2076
+ if (c.match(/kind:\s*Deployment/) && !c.match(/affinity:|podAntiAffinity:/)) {
2077
+ const replicas = c.match(/replicas:\s*(\d+)/);
2078
+ if (replicas && parseInt(replicas[1]) > 1) {
2079
+ findings.push({ ruleId: 'INFRA-K8S-035', category: 'infrastructure', severity: 'medium', title: `Deployment with ${replicas[1]} replicas but no anti-affinity — single node failure takes all pods`, description: 'Add podAntiAffinity to spread replicas across nodes. Without it, all pods may schedule on the same node, making a node failure a full service outage.', file: fp, fix: null });
2080
+ }
2081
+ }
2082
+ }
2083
+ return findings;
2084
+ },
2085
+ });
2086
+
2087
+ // INFRA-TF-035: VPC without VPC Flow Logs
2088
+ rules.push({
2089
+ id: 'INFRA-TF-035', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'AWS VPC without flow logs enabled',
2090
+ check({ files }) {
2091
+ const findings = [];
2092
+ for (const [fp, c] of files) {
2093
+ if (!fp.match(/\.tf$/)) continue;
2094
+ if (c.match(/resource\s+["']aws_vpc["']/) && !c.match(/aws_flow_log|vpc_flow_log/)) {
2095
+ findings.push({ ruleId: 'INFRA-TF-035', category: 'infrastructure', severity: 'medium', title: 'VPC without flow logs — network traffic not audited', description: 'Enable VPC Flow Logs to capture network traffic for security analysis and incident response. Store in S3 or CloudWatch.', file: fp, fix: null });
2096
+ }
2097
+ }
2098
+ return findings;
2099
+ },
2100
+ });
2101
+
2102
+ // INFRA-TF-036: RDS not in private subnet
2103
+ rules.push({
2104
+ id: 'INFRA-TF-036', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'RDS instance publicly accessible',
2105
+ check({ files }) {
2106
+ const findings = [];
2107
+ for (const [fp, c] of files) {
2108
+ if (!fp.match(/\.tf$/)) continue;
2109
+ if (c.match(/resource\s+["']aws_db_instance["']/) && c.match(/publicly_accessible\s*=\s*true/)) {
2110
+ findings.push({ ruleId: 'INFRA-TF-036', category: 'infrastructure', severity: 'critical', title: 'RDS instance publicly accessible — database exposed to internet', description: 'Set publicly_accessible = false and place the RDS instance in a private subnet. Never expose database endpoints directly to the internet.', file: fp, fix: null });
2111
+ }
2112
+ }
2113
+ return findings;
2114
+ },
2115
+ });
2116
+
2117
+ // INFRA-DC-030: docker-compose with root user
2118
+ rules.push({
2119
+ id: 'INFRA-DC-030', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Docker Compose service without user directive — runs as root',
2120
+ check({ files }) {
2121
+ const findings = [];
2122
+ for (const [fp, c] of files) {
2123
+ if (!fp.match(/docker-compose.*\.ya?ml$/i) && !fp.match(/compose\.ya?ml$/i)) continue;
2124
+ if (c.match(/services:/) && !c.match(/user\s*:/)) {
2125
+ findings.push({ ruleId: 'INFRA-DC-030', category: 'infrastructure', severity: 'medium', title: 'Docker Compose services without user directive — containers run as root', description: 'Add user: "1000:1000" (or appropriate non-root UID) to Docker Compose service definitions to prevent root container execution.', file: fp, fix: null });
2126
+ }
2127
+ }
2128
+ return findings;
2129
+ },
2130
+ });
2131
+
2132
+ // INFRA-K8S-036: Missing image pull policy for immutable tags
2133
+ rules.push({
2134
+ id: 'INFRA-K8S-036', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'K8s container with mutable image tag and no imagePullPolicy: Always',
2135
+ check({ files }) {
2136
+ const findings = [];
2137
+ for (const [fp, c] of files) {
2138
+ if (!fp.match(/\.ya?ml$/)) continue;
2139
+ if (c.match(/kind:\s*(?:Deployment|StatefulSet)/) && !c.match(/imagePullPolicy:\s*Always/)) {
2140
+ findings.push({ ruleId: 'INFRA-K8S-036', category: 'infrastructure', severity: 'low', title: 'No imagePullPolicy: Always — stale image may be used on pod restart', description: 'If using mutable image tags, set imagePullPolicy: Always to ensure the latest image is pulled on each pod creation. For immutable tags (SHA), IfNotPresent is fine.', file: fp, fix: null });
2141
+ }
2142
+ }
2143
+ return findings;
2144
+ },
2145
+ });
2146
+
2147
+ // INFRA-TF-037: No MFA delete on S3 bucket
2148
+ rules.push({
2149
+ id: 'INFRA-TF-037', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'S3 bucket without MFA delete protection',
2150
+ check({ files }) {
2151
+ const findings = [];
2152
+ for (const [fp, c] of files) {
2153
+ if (!fp.match(/\.tf$/)) continue;
2154
+ if (c.match(/resource\s+["']aws_s3_bucket["']/) && !c.match(/mfa_delete\s*=\s*["']Enabled["']/)) {
2155
+ findings.push({ ruleId: 'INFRA-TF-037', category: 'infrastructure', severity: 'medium', title: 'S3 bucket without MFA delete — versioned objects can be deleted without MFA', description: 'Enable MFA delete on S3 buckets with critical data to require multi-factor authentication for permanent deletions. Protects against credential compromise.', file: fp, fix: null });
2156
+ }
2157
+ }
2158
+ return findings;
2159
+ },
2160
+ });
2161
+
2162
+ // INFRA additional rules
2163
+
2164
+ // INFRA-TF-038: CloudFront without HTTPS only
2165
+ rules.push({
2166
+ id: 'INFRA-TF-038', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'CloudFront distribution allowing HTTP (not HTTPS-only)',
2167
+ check({ files }) {
2168
+ const findings = [];
2169
+ for (const [fp, c] of files) {
2170
+ if (!fp.match(/\.tf$/)) continue;
2171
+ if (c.match(/aws_cloudfront_distribution/) && !c.match(/viewer_protocol_policy\s*=\s*["']https-only["']/)) {
2172
+ findings.push({ ruleId: 'INFRA-TF-038', category: 'infrastructure', severity: 'high', title: 'CloudFront viewer protocol policy not set to https-only', description: 'Set viewer_protocol_policy = "https-only" or "redirect-to-https" to force TLS for all CloudFront traffic.', file: fp, fix: null });
2173
+ }
2174
+ }
2175
+ return findings;
2176
+ },
2177
+ });
2178
+
2179
+ // INFRA-K8S-037: ConfigMap used for secrets
2180
+ rules.push({
2181
+ id: 'INFRA-K8S-037', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Kubernetes ConfigMap storing secret values',
2182
+ check({ files }) {
2183
+ const findings = [];
2184
+ for (const [fp, c] of files) {
2185
+ if (!fp.match(/\.ya?ml$/)) continue;
2186
+ if (c.match(/kind:\s*ConfigMap/) && c.match(/(?:password|secret|token|key|api_key)\s*:/i)) {
2187
+ findings.push({ ruleId: 'INFRA-K8S-037', category: 'infrastructure', severity: 'critical', title: 'Secrets stored in ConfigMap — use Kubernetes Secret or external secrets manager', description: 'ConfigMaps are not encrypted at rest. Use Kubernetes Secrets with encryption at rest, or an external secrets manager like AWS Secrets Manager.', file: fp, fix: null });
2188
+ }
2189
+ }
2190
+ return findings;
2191
+ },
2192
+ });
2193
+
2194
+ // INFRA-TF-039: IAM role with wildcard permissions
2195
+ rules.push({
2196
+ id: 'INFRA-TF-039', category: 'infrastructure', severity: 'critical', confidence: 'likely', title: 'IAM role with wildcard action (*) permissions',
2197
+ check({ files }) {
2198
+ const findings = [];
2199
+ for (const [fp, c] of files) {
2200
+ if (!fp.match(/\.tf$|\.json$/)) continue;
2201
+ const lines = c.split('\n');
2202
+ for (let i = 0; i < lines.length; i++) {
2203
+ if (/^\s*[#;]/.test(lines[i])) continue;
2204
+ if (/["']Action["']\s*:\s*["']\*["']|actions\s*=\s*\[\s*["']\*["']/.test(lines[i])) {
2205
+ findings.push({ ruleId: 'INFRA-TF-039', category: 'infrastructure', severity: 'critical', title: 'IAM policy with Action: "*" — wildcard grants all permissions', description: 'Wildcard IAM actions violate least-privilege principles. Grant only the specific actions required: ["s3:GetObject", "s3:PutObject"].', file: fp, line: i + 1, fix: null });
2206
+ }
2207
+ }
2208
+ }
2209
+ return findings;
2210
+ },
2211
+ });
2212
+
2213
+ // INFRA-TF-040: AWS account root user access key
2214
+ rules.push({
2215
+ id: 'INFRA-TF-040', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'AWS root account access key configured',
2216
+ check({ files }) {
2217
+ const findings = [];
2218
+ for (const [fp, c] of files) {
2219
+ if (!fp.match(/\.tf$/)) continue;
2220
+ if (c.match(/aws_iam_access_key/) && !c.match(/user\s*=/)) {
2221
+ findings.push({ ruleId: 'INFRA-TF-040', category: 'infrastructure', severity: 'critical', title: 'IAM access key without user — may be root account key', description: 'Delete root account access keys. Create IAM users or roles with minimum necessary permissions. Root account keys cannot be restricted by IAM policies.', file: fp, fix: null });
2222
+ }
2223
+ }
2224
+ return findings;
2225
+ },
2226
+ });
2227
+
2228
+ // INFRA-K8S-038: Service type NodePort in production
2229
+ rules.push({
2230
+ id: 'INFRA-K8S-038', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes Service with type: NodePort — direct node exposure',
2231
+ check({ files }) {
2232
+ const findings = [];
2233
+ for (const [fp, c] of files) {
2234
+ if (!fp.match(/\.ya?ml$/)) continue;
2235
+ if (c.match(/kind:\s*Service/) && c.match(/type:\s*NodePort/)) {
2236
+ findings.push({ ruleId: 'INFRA-K8S-038', category: 'infrastructure', severity: 'medium', title: 'NodePort service exposes ports directly on all nodes', description: 'NodePort exposes the service on a port on every cluster node. Use LoadBalancer or Ingress instead for production traffic to control access properly.', file: fp, fix: null });
2237
+ }
2238
+ }
2239
+ return findings;
2240
+ },
2241
+ });
2242
+
2243
+ // INFRA-TF-041: Unencrypted EKS secrets
2244
+ rules.push({
2245
+ id: 'INFRA-TF-041', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'EKS cluster without envelope encryption for Kubernetes secrets',
2246
+ check({ files }) {
2247
+ const findings = [];
2248
+ for (const [fp, c] of files) {
2249
+ if (!fp.match(/\.tf$/)) continue;
2250
+ if (c.match(/resource\s+["']aws_eks_cluster["']/) && !c.match(/encryption_config|resources.*secrets/i)) {
2251
+ findings.push({ ruleId: 'INFRA-TF-041', category: 'infrastructure', severity: 'high', title: 'EKS cluster without secrets encryption — Kubernetes secrets stored unencrypted in etcd', description: 'Enable envelope encryption for EKS Kubernetes secrets using AWS KMS. Without it, secrets are stored unencrypted in etcd.', file: fp, fix: null });
2252
+ }
2253
+ }
2254
+ return findings;
2255
+ },
2256
+ });
2257
+
2258
+ // INFRA-TF-042: RDS deletion protection disabled
2259
+ rules.push({
2260
+ id: 'INFRA-TF-042', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'RDS without deletion protection enabled',
2261
+ check({ files }) {
2262
+ const findings = [];
2263
+ for (const [fp, c] of files) {
2264
+ if (!fp.match(/\.tf$/)) continue;
2265
+ if (c.match(/resource\s+["']aws_db_instance["']/) && !c.match(/deletion_protection\s*=\s*true/)) {
2266
+ findings.push({ ruleId: 'INFRA-TF-042', category: 'infrastructure', severity: 'high', title: 'RDS without deletion_protection — database can be deleted accidentally', description: 'Set deletion_protection = true on production RDS instances to prevent accidental deletion. This requires it to be explicitly disabled before the instance can be destroyed.', file: fp, fix: null });
2267
+ }
2268
+ }
2269
+ return findings;
2270
+ },
2271
+ });
2272
+
2273
+ // INFRA-DC-031: Docker Compose using host network mode
2274
+ rules.push({
2275
+ id: 'INFRA-DC-031', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Docker Compose service using host network mode',
2276
+ check({ files }) {
2277
+ const findings = [];
2278
+ for (const [fp, c] of files) {
2279
+ if (!fp.match(/docker-compose.*\.ya?ml$/i) && !fp.match(/compose\.ya?ml$/i)) continue;
2280
+ if (c.match(/network_mode:\s*["']?host["']?/)) {
2281
+ findings.push({ ruleId: 'INFRA-DC-031', category: 'infrastructure', severity: 'high', title: 'Docker service with network_mode: host — bypasses container network isolation', description: 'host network mode removes all network isolation between the container and host. Use bridge networking with explicit port mappings instead.', file: fp, fix: null });
2282
+ }
2283
+ }
2284
+ return findings;
2285
+ },
2286
+ });
2287
+
2288
+ // INFRA-K8S-039: Missing PodDisruptionBudget
2289
+ rules.push({
2290
+ id: 'INFRA-K8S-039', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes Deployment without PodDisruptionBudget',
2291
+ check({ files }) {
2292
+ const findings = [];
2293
+ const deployFiles = [];
2294
+ const pdbFiles = new Set();
2295
+ for (const [fp, c] of files) {
2296
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2297
+ if (/kind:\s*Deployment/.test(c)) deployFiles.push(fp);
2298
+ if (/kind:\s*PodDisruptionBudget/.test(c)) pdbFiles.add(fp.replace(/\/[^/]+$/, ''));
2299
+ }
2300
+ for (const fp of deployFiles) {
2301
+ const dir = fp.replace(/\/[^/]+$/, '');
2302
+ if (!pdbFiles.has(dir)) findings.push({ ruleId: 'INFRA-K8S-039', category: 'infrastructure', severity: 'medium', title: 'No PodDisruptionBudget for Deployment — pods may all be evicted during node drain', description: 'Create a PodDisruptionBudget to ensure minimum pod availability during voluntary disruptions.', file: fp, fix: null });
2303
+ }
2304
+ return findings;
2305
+ },
2306
+ });
2307
+
2308
+ // INFRA-K8S-040: Container with all capabilities
2309
+ rules.push({
2310
+ id: 'INFRA-K8S-040', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Kubernetes container with all capabilities added',
2311
+ check({ files }) {
2312
+ const findings = [];
2313
+ for (const [fp, c] of files) {
2314
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2315
+ if (!/kind:\s*(?:Deployment|Pod|DaemonSet|StatefulSet)/.test(c)) continue;
2316
+ if (/capabilities:[\s\S]{0,100}add:\s*[\s\S]{0,50}ALL/.test(c)) findings.push({ ruleId: 'INFRA-K8S-040', category: 'infrastructure', severity: 'critical', title: 'Container with ALL capabilities added — grants root-like privileges', description: 'Drop all capabilities and only add the specific ones required by the container.', file: fp, fix: null });
2317
+ }
2318
+ return findings;
2319
+ },
2320
+ });
2321
+
2322
+ // INFRA-K8S-041: Kubernetes secret in plaintext ConfigMap
2323
+ rules.push({
2324
+ id: 'INFRA-K8S-041', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Database credentials stored in Kubernetes ConfigMap',
2325
+ check({ files }) {
2326
+ const findings = [];
2327
+ for (const [fp, c] of files) {
2328
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2329
+ if (!/kind:\s*ConfigMap/.test(c)) continue;
2330
+ if (/(?:password|secret|key|token)\s*:\s*\S+/i.test(c) && !/secretKeyRef|valueFrom/.test(c)) findings.push({ ruleId: 'INFRA-K8S-041', category: 'infrastructure', severity: 'high', title: 'Secret-looking data in ConfigMap — use Kubernetes Secret instead', description: 'Move sensitive values (passwords, keys, tokens) to Kubernetes Secrets or an external secrets manager.', file: fp, fix: null });
2331
+ }
2332
+ return findings;
2333
+ },
2334
+ });
2335
+
2336
+ // INFRA-TF-043: AWS Lambda function without VPC configuration
2337
+ rules.push({
2338
+ id: 'INFRA-TF-043', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Terraform Lambda function without VPC configuration',
2339
+ check({ files }) {
2340
+ const findings = [];
2341
+ for (const [fp, c] of files) {
2342
+ if (!fp.endsWith('.tf')) continue;
2343
+ if (/resource\s+"aws_lambda_function"/.test(c) && !/vpc_config/.test(c)) findings.push({ ruleId: 'INFRA-TF-043', category: 'infrastructure', severity: 'medium', title: 'Lambda function without VPC configuration — publicly accessible', description: 'Place Lambda functions in a VPC with private subnets if they access internal resources.', file: fp, fix: null });
2344
+ }
2345
+ return findings;
2346
+ },
2347
+ });
2348
+
2349
+ // INFRA-TF-044: S3 bucket with public access allowed
2350
+ rules.push({
2351
+ id: 'INFRA-TF-044', category: 'infrastructure', severity: 'critical', confidence: 'likely', title: 'Terraform S3 bucket without block public access settings',
2352
+ check({ files }) {
2353
+ const findings = [];
2354
+ for (const [fp, c] of files) {
2355
+ if (!fp.endsWith('.tf')) continue;
2356
+ if (/resource\s+"aws_s3_bucket"\s+"[^"]+"/.test(c) && !/aws_s3_bucket_public_access_block/.test(c)) findings.push({ ruleId: 'INFRA-TF-044', category: 'infrastructure', severity: 'critical', title: 'S3 bucket without public access block — may be publicly readable', description: 'Add aws_s3_bucket_public_access_block with all four block settings enabled.', file: fp, fix: null });
2357
+ }
2358
+ return findings;
2359
+ },
2360
+ });
2361
+
2362
+ // INFRA-TF-045: Terraform backend without encryption
2363
+ rules.push({
2364
+ id: 'INFRA-TF-045', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform S3 backend without encryption',
2365
+ check({ files }) {
2366
+ const findings = [];
2367
+ for (const [fp, c] of files) {
2368
+ if (!fp.endsWith('.tf')) continue;
2369
+ if (/backend\s+"s3"/.test(c) && !/encrypt\s*=\s*true/.test(c)) findings.push({ ruleId: 'INFRA-TF-045', category: 'infrastructure', severity: 'high', title: 'Terraform S3 backend without encrypt = true', description: 'Enable encryption for S3 backend state files: encrypt = true.', file: fp, fix: null });
2370
+ }
2371
+ return findings;
2372
+ },
2373
+ });
2374
+
2375
+ // INFRA-TF-046: EC2 instance with public IP
2376
+ rules.push({
2377
+ id: 'INFRA-TF-046', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform EC2 instance with associate_public_ip_address = true',
2378
+ check({ files }) {
2379
+ const findings = [];
2380
+ for (const [fp, c] of files) {
2381
+ if (!fp.endsWith('.tf')) continue;
2382
+ if (/resource\s+"aws_instance"/.test(c) && /associate_public_ip_address\s*=\s*true/.test(c)) findings.push({ ruleId: 'INFRA-TF-046', category: 'infrastructure', severity: 'high', title: 'EC2 instance with public IP — expose attack surface', description: 'Use a private subnet and NAT gateway. Only place EC2 instances in public subnets if they serve as load balancers.', file: fp, fix: null });
2383
+ }
2384
+ return findings;
2385
+ },
2386
+ });
2387
+
2388
+ // INFRA-DC-032: Docker Compose missing resource limits
2389
+ rules.push({
2390
+ id: 'INFRA-DC-032', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Docker Compose service without memory/CPU limits',
2391
+ check({ files }) {
2392
+ const findings = [];
2393
+ for (const [fp, c] of files) {
2394
+ if (!fp.includes('docker-compose') && !fp.includes('compose.')) continue;
2395
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2396
+ if (/^\s+\w+:$/m.test(c) && !/memory:|cpus:|resources:/.test(c)) findings.push({ ruleId: 'INFRA-DC-032', category: 'infrastructure', severity: 'medium', title: 'Docker Compose service without resource limits — container may consume all host resources', description: 'Add memory and CPU limits under deploy.resources.limits in Docker Compose files.', file: fp, fix: null });
2397
+ }
2398
+ return findings;
2399
+ },
2400
+ });
2401
+
2402
+ // INFRA-K8S-042: Kubernetes cluster with deprecated API versions
2403
+ rules.push({
2404
+ id: 'INFRA-K8S-042', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes manifest using deprecated API version',
2405
+ check({ files }) {
2406
+ const findings = [];
2407
+ const deprecated = [
2408
+ { pattern: /apiVersion:\s*extensions\/v1beta1/, msg: 'extensions/v1beta1 is removed in K8s 1.16+' },
2409
+ { pattern: /apiVersion:\s*apps\/v1beta[12]/, msg: 'apps/v1beta1/2 is removed in K8s 1.16+' },
2410
+ { pattern: /apiVersion:\s*networking\.k8s\.io\/v1beta1/, msg: 'networking.k8s.io/v1beta1 is removed in K8s 1.22+' },
2411
+ ];
2412
+ for (const [fp, c] of files) {
2413
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2414
+ for (const { pattern, msg } of deprecated) {
2415
+ if (pattern.test(c)) findings.push({ ruleId: 'INFRA-K8S-042', category: 'infrastructure', severity: 'medium', title: `Deprecated Kubernetes API: ${msg}`, description: 'Update manifests to use current API versions.', file: fp, fix: null });
2416
+ }
2417
+ }
2418
+ return findings;
2419
+ },
2420
+ });
2421
+
2422
+ // INFRA-TF-047: Missing AWS WAF association with ALB
2423
+ rules.push({
2424
+ id: 'INFRA-TF-047', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform ALB without WAF web ACL association',
2425
+ check({ files }) {
2426
+ const findings = [];
2427
+ for (const [fp, c] of files) {
2428
+ if (!fp.endsWith('.tf')) continue;
2429
+ if (/resource\s+"aws_alb"\s+"[^"]+"/.test(c) && !/aws_wafv2_web_acl_association|aws_waf_web_acl/.test(c)) findings.push({ ruleId: 'INFRA-TF-047', category: 'infrastructure', severity: 'high', title: 'Application Load Balancer without WAF — vulnerable to common web attacks', description: 'Associate an AWS WAF web ACL with your ALB to protect against common attack patterns.', file: fp, fix: null });
2430
+ }
2431
+ return findings;
2432
+ },
2433
+ });
2434
+
2435
+ // INFRA-K8S-043: Missing namespace for Kubernetes resources
2436
+ rules.push({
2437
+ id: 'INFRA-K8S-043', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes resource deployed to default namespace',
2438
+ check({ files }) {
2439
+ const findings = [];
2440
+ for (const [fp, c] of files) {
2441
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2442
+ if (!/kind:\s*(?:Deployment|Service|Pod|StatefulSet)/.test(c)) continue;
2443
+ if (/namespace:\s*default/.test(c) || (!/namespace:/.test(c) && /kind:\s*Deployment/.test(c))) findings.push({ ruleId: 'INFRA-K8S-043', category: 'infrastructure', severity: 'medium', title: 'Kubernetes resource in default namespace — no isolation', description: 'Deploy workloads to dedicated namespaces for better isolation and access control.', file: fp, fix: null });
2444
+ }
2445
+ return findings;
2446
+ },
2447
+ });
2448
+
2449
+ // INFRA-TF-048: Missing SNS topic encryption
2450
+ rules.push({
2451
+ id: 'INFRA-TF-048', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Terraform SNS topic without KMS encryption',
2452
+ check({ files }) {
2453
+ const findings = [];
2454
+ for (const [fp, c] of files) {
2455
+ if (!fp.endsWith('.tf')) continue;
2456
+ if (/resource\s+"aws_sns_topic"/.test(c) && !/kms_master_key_id/.test(c)) findings.push({ ruleId: 'INFRA-TF-048', category: 'infrastructure', severity: 'medium', title: 'SNS topic without KMS encryption — messages stored unencrypted', description: 'Configure kms_master_key_id on aws_sns_topic to encrypt messages at rest.', file: fp, fix: null });
2457
+ }
2458
+ return findings;
2459
+ },
2460
+ });
2461
+
2462
+ // INFRA-TF-049: DynamoDB without point-in-time recovery
2463
+ rules.push({
2464
+ id: 'INFRA-TF-049', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform DynamoDB table without point-in-time recovery',
2465
+ check({ files }) {
2466
+ const findings = [];
2467
+ for (const [fp, c] of files) {
2468
+ if (!fp.endsWith('.tf')) continue;
2469
+ if (/resource\s+"aws_dynamodb_table"/.test(c) && !/point_in_time_recovery/.test(c)) findings.push({ ruleId: 'INFRA-TF-049', category: 'infrastructure', severity: 'high', title: 'DynamoDB table without point-in-time recovery — cannot recover from accidental deletes', description: 'Enable point_in_time_recovery { enabled = true } on DynamoDB tables.', file: fp, fix: null });
2470
+ }
2471
+ return findings;
2472
+ },
2473
+ });
2474
+
2475
+ // INFRA-K8S-044: Service account with cluster-admin role
2476
+ rules.push({
2477
+ id: 'INFRA-K8S-044', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Kubernetes service account bound to cluster-admin role',
2478
+ check({ files }) {
2479
+ const findings = [];
2480
+ for (const [fp, c] of files) {
2481
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2482
+ if (/kind:\s*ClusterRoleBinding/.test(c) && /cluster-admin/.test(c) && !/system:masters/.test(c)) findings.push({ ruleId: 'INFRA-K8S-044', category: 'infrastructure', severity: 'critical', title: 'Service account with cluster-admin role — full cluster access', description: 'Follow least privilege — create a Role with only the permissions required.', file: fp, fix: null });
2483
+ }
2484
+ return findings;
2485
+ },
2486
+ });
2487
+
2488
+ // INFRA-TF-050: RDS without automated backups
2489
+ rules.push({
2490
+ id: 'INFRA-TF-050', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform RDS instance with backup_retention_period = 0',
2491
+ check({ files }) {
2492
+ const findings = [];
2493
+ for (const [fp, c] of files) {
2494
+ if (!fp.endsWith('.tf')) continue;
2495
+ if (/resource\s+"aws_db_instance"/.test(c) && /backup_retention_period\s*=\s*0/.test(c)) findings.push({ ruleId: 'INFRA-TF-050', category: 'infrastructure', severity: 'high', title: 'RDS backup retention set to 0 — no automated backups', description: 'Set backup_retention_period to at least 7 days to enable automated RDS backups.', file: fp, fix: null });
2496
+ }
2497
+ return findings;
2498
+ },
2499
+ });
2500
+
2501
+ // INFRA-TF-051: Security group with unrestricted egress
2502
+ rules.push({
2503
+ id: 'INFRA-TF-051', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Terraform security group with unrestricted outbound (0.0.0.0/0)',
2504
+ check({ files }) {
2505
+ const findings = [];
2506
+ for (const [fp, c] of files) {
2507
+ if (!fp.endsWith('.tf')) continue;
2508
+ if (/resource\s+"aws_security_group"/.test(c) && /egress[\s\S]{0,200}cidr_blocks\s*=\s*\["0\.0\.0\.0\/0"\]/.test(c)) findings.push({ ruleId: 'INFRA-TF-051', category: 'infrastructure', severity: 'medium', title: 'Security group with unrestricted outbound access — enables data exfiltration', description: 'Restrict egress to specific CIDR ranges and ports required by the service.', file: fp, fix: null });
2509
+ }
2510
+ return findings;
2511
+ },
2512
+ });
2513
+
2514
+ // INFRA-TF-052: Missing Terraform provider version constraint
2515
+ rules.push({
2516
+ id: 'INFRA-TF-052', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Terraform provider without version constraint',
2517
+ check({ files }) {
2518
+ const findings = [];
2519
+ for (const [fp, c] of files) {
2520
+ if (!fp.endsWith('.tf')) continue;
2521
+ if (/required_providers\s*\{/.test(c) && !/version\s*=\s*"/.test(c)) findings.push({ ruleId: 'INFRA-TF-052', category: 'infrastructure', severity: 'medium', title: 'Terraform provider without version pin — unexpected breaking changes', description: 'Specify version constraints for all required providers: version = "~> 4.0".', file: fp, fix: null });
2522
+ }
2523
+ return findings;
2524
+ },
2525
+ });
2526
+
2527
+ // INFRA-TF-053: ElastiCache Redis without auth token
2528
+ rules.push({
2529
+ id: 'INFRA-TF-053', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform ElastiCache Redis without auth token',
2530
+ check({ files }) {
2531
+ const findings = [];
2532
+ for (const [fp, c] of files) {
2533
+ if (!fp.endsWith('.tf')) continue;
2534
+ if (/resource\s+"aws_elasticache_replication_group"/.test(c) && !/auth_token/.test(c)) findings.push({ ruleId: 'INFRA-TF-053', category: 'infrastructure', severity: 'high', title: 'ElastiCache Redis without auth token — unauthenticated access possible', description: 'Set auth_token on aws_elasticache_replication_group to require password authentication.', file: fp, fix: null });
2535
+ }
2536
+ return findings;
2537
+ },
2538
+ });
2539
+
2540
+ // INFRA-054: Kubernetes pod without network policy
2541
+ rules.push({
2542
+ id: 'INFRA-054', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes namespace without NetworkPolicy',
2543
+ check({ files }) {
2544
+ const findings = [];
2545
+ const hasK8s = [...files.keys()].some(fp => (fp.endsWith('.yaml') || fp.endsWith('.yml')) && files.get(fp).includes('kind: Deployment'));
2546
+ const hasNetPol = [...files.values()].some(c => /kind:\s*NetworkPolicy/.test(c));
2547
+ if (hasK8s && !hasNetPol) findings.push({ ruleId: 'INFRA-054', category: 'infrastructure', severity: 'high', title: 'Kubernetes cluster without NetworkPolicy — unrestricted pod-to-pod traffic', description: 'Define NetworkPolicy resources to restrict pod communication. By default, all pods can communicate with each other.', fix: null });
2548
+ return findings;
2549
+ },
2550
+ });
2551
+
2552
+ // INFRA-055: Kubernetes secret not encrypted at rest
2553
+ rules.push({
2554
+ id: 'INFRA-055', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes Secret in plain YAML',
2555
+ check({ files }) {
2556
+ const findings = [];
2557
+ for (const [fp, c] of files) {
2558
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2559
+ if (/kind:\s*Secret/.test(c) && /data:/.test(c) && !/sops|sealed-secrets|external-secrets|vault/.test(c)) findings.push({ ruleId: 'INFRA-055', category: 'infrastructure', severity: 'high', title: 'Kubernetes Secret stored as plain base64 in YAML — not encrypted', description: 'Use sealed-secrets, SOPS, or external-secrets-operator to encrypt secrets at rest.', file: fp, fix: null });
2560
+ }
2561
+ return findings;
2562
+ },
2563
+ });
2564
+
2565
+ // INFRA-056: No pod disruption budget
2566
+ rules.push({
2567
+ id: 'INFRA-056', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes Deployment without PodDisruptionBudget',
2568
+ check({ files }) {
2569
+ const findings = [];
2570
+ const hasDeployment = [...files.values()].some(c => /kind:\s*Deployment/.test(c));
2571
+ const hasPDB = [...files.values()].some(c => /kind:\s*PodDisruptionBudget/.test(c));
2572
+ if (hasDeployment && !hasPDB) findings.push({ ruleId: 'INFRA-056', category: 'infrastructure', severity: 'medium', title: 'No PodDisruptionBudget — voluntary disruptions may take all pods offline', description: 'Add PodDisruptionBudget with minAvailable or maxUnavailable to ensure availability during node maintenance.', fix: null });
2573
+ return findings;
2574
+ },
2575
+ });
2576
+
2577
+ // INFRA-057: Kubernetes container running as privileged
2578
+ rules.push({
2579
+ id: 'INFRA-057', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Kubernetes privileged container',
2580
+ check({ files }) {
2581
+ const findings = [];
2582
+ for (const [fp, c] of files) {
2583
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2584
+ if (/privileged:\s*true/.test(c)) findings.push({ ruleId: 'INFRA-057', category: 'infrastructure', severity: 'critical', title: 'Container running in privileged mode — full host access', description: 'Remove privileged: true. Privileged containers can access all host devices and escape containment.', file: fp, fix: null });
2585
+ }
2586
+ return findings;
2587
+ },
2588
+ });
2589
+
2590
+ // INFRA-058: Kubernetes hostNetwork enabled
2591
+ rules.push({
2592
+ id: 'INFRA-058', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes pod with hostNetwork',
2593
+ check({ files }) {
2594
+ const findings = [];
2595
+ for (const [fp, c] of files) {
2596
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2597
+ if (/hostNetwork:\s*true/.test(c)) findings.push({ ruleId: 'INFRA-058', category: 'infrastructure', severity: 'high', title: 'Pod uses host network namespace — bypasses network isolation', description: 'Remove hostNetwork: true unless absolutely necessary. Host networking breaks pod isolation.', file: fp, fix: null });
2598
+ }
2599
+ return findings;
2600
+ },
2601
+ });
2602
+
2603
+ // INFRA-059: Kubernetes hostPID enabled
2604
+ rules.push({
2605
+ id: 'INFRA-059', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes pod with hostPID',
2606
+ check({ files }) {
2607
+ const findings = [];
2608
+ for (const [fp, c] of files) {
2609
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2610
+ if (/hostPID:\s*true/.test(c)) findings.push({ ruleId: 'INFRA-059', category: 'infrastructure', severity: 'high', title: 'Pod uses host PID namespace — can see and signal host processes', description: 'Remove hostPID: true. Sharing the host PID namespace allows containers to view all host processes.', file: fp, fix: null });
2611
+ }
2612
+ return findings;
2613
+ },
2614
+ });
2615
+
2616
+ // INFRA-060: No Kubernetes RBAC
2617
+ rules.push({
2618
+ id: 'INFRA-060', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No Kubernetes RBAC configuration',
2619
+ check({ files }) {
2620
+ const findings = [];
2621
+ const hasK8s = [...files.keys()].some(fp => (fp.endsWith('.yaml') || fp.endsWith('.yml')) && /kind:\s*(?:Deployment|Pod)/.test(files.get(fp)));
2622
+ const hasRBAC = [...files.values()].some(c => /kind:\s*(?:Role|ClusterRole|RoleBinding|ClusterRoleBinding|ServiceAccount)/.test(c));
2623
+ if (hasK8s && !hasRBAC) findings.push({ ruleId: 'INFRA-060', category: 'infrastructure', severity: 'high', title: 'No RBAC resources defined — pods may use default service account', description: 'Define ServiceAccounts, Roles, and RoleBindings to enforce least-privilege access in the cluster.', fix: null });
2624
+ return findings;
2625
+ },
2626
+ });
2627
+
2628
+ // INFRA-061: Kubernetes default namespace usage
2629
+ rules.push({
2630
+ id: 'INFRA-061', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes resource in default namespace',
2631
+ check({ files }) {
2632
+ const findings = [];
2633
+ for (const [fp, c] of files) {
2634
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2635
+ if (/kind:\s*(?:Deployment|Service|Pod|StatefulSet)/.test(c) && (/namespace:\s*default/.test(c) || (!/namespace:/.test(c) && /kind:\s*(?:Deployment|Service|Pod|StatefulSet)/.test(c)))) findings.push({ ruleId: 'INFRA-061', category: 'infrastructure', severity: 'medium', title: 'Resource deployed to default namespace — use dedicated namespaces', description: 'Create dedicated namespaces per application/team for better isolation and RBAC scoping.', file: fp, fix: null });
2636
+ }
2637
+ return findings;
2638
+ },
2639
+ });
2640
+
2641
+ // INFRA-062: No Kubernetes Ingress TLS
2642
+ rules.push({
2643
+ id: 'INFRA-062', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes Ingress without TLS',
2644
+ check({ files }) {
2645
+ const findings = [];
2646
+ for (const [fp, c] of files) {
2647
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2648
+ if (/kind:\s*Ingress/.test(c) && !/tls:/.test(c)) findings.push({ ruleId: 'INFRA-062', category: 'infrastructure', severity: 'high', title: 'Ingress without TLS termination — traffic served over HTTP', description: 'Add TLS configuration with a valid certificate. Use cert-manager for automatic certificate provisioning.', file: fp, fix: null });
2649
+ }
2650
+ return findings;
2651
+ },
2652
+ });
2653
+
2654
+ // INFRA-063: Cloud storage bucket without encryption
2655
+ rules.push({
2656
+ id: 'INFRA-063', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Cloud storage without server-side encryption',
2657
+ check({ files }) {
2658
+ const findings = [];
2659
+ for (const [fp, c] of files) {
2660
+ if (!fp.endsWith('.tf')) continue;
2661
+ if (/resource\s+"aws_s3_bucket"/.test(c) && !/server_side_encryption_configuration|aws_s3_bucket_server_side_encryption/.test(c)) findings.push({ ruleId: 'INFRA-063', category: 'infrastructure', severity: 'high', title: 'S3 bucket without server-side encryption configuration', description: 'Enable server-side encryption (SSE-S3 or SSE-KMS) on all S3 buckets.', file: fp, fix: null });
2662
+ }
2663
+ return findings;
2664
+ },
2665
+ });
2666
+
2667
+ // INFRA-064: Cloud storage bucket public access
2668
+ rules.push({
2669
+ id: 'INFRA-064', category: 'infrastructure', severity: 'critical', confidence: 'likely', title: 'Cloud storage with public access enabled',
2670
+ check({ files }) {
2671
+ const findings = [];
2672
+ for (const [fp, c] of files) {
2673
+ if (!fp.endsWith('.tf')) continue;
2674
+ if (/resource\s+"aws_s3_bucket"/.test(c) && /acl\s*=\s*"public-read/.test(c)) findings.push({ ruleId: 'INFRA-064', category: 'infrastructure', severity: 'critical', title: 'S3 bucket with public-read ACL — data exposure risk', description: 'Remove public-read ACL. Use CloudFront or presigned URLs for controlled access.', file: fp, fix: null });
2675
+ }
2676
+ return findings;
2677
+ },
2678
+ });
2679
+
2680
+ // INFRA-065: No VPC flow logs
2681
+ rules.push({
2682
+ id: 'INFRA-065', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'VPC without flow logs enabled',
2683
+ check({ files }) {
2684
+ const findings = [];
2685
+ for (const [fp, c] of files) {
2686
+ if (!fp.endsWith('.tf')) continue;
2687
+ if (/resource\s+"aws_vpc"/.test(c) && !/aws_flow_log/.test(c)) findings.push({ ruleId: 'INFRA-065', category: 'infrastructure', severity: 'medium', title: 'VPC without flow logs — no network traffic visibility', description: 'Enable VPC flow logs for security monitoring and troubleshooting network connectivity.', file: fp, fix: null });
2688
+ }
2689
+ return findings;
2690
+ },
2691
+ });
2692
+
2693
+ // INFRA-066: Database publicly accessible
2694
+ rules.push({
2695
+ id: 'INFRA-066', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'RDS instance publicly accessible',
2696
+ check({ files }) {
2697
+ const findings = [];
2698
+ for (const [fp, c] of files) {
2699
+ if (!fp.endsWith('.tf')) continue;
2700
+ if (/resource\s+"aws_db_instance"/.test(c) && /publicly_accessible\s*=\s*true/.test(c)) findings.push({ ruleId: 'INFRA-066', category: 'infrastructure', severity: 'critical', title: 'RDS database publicly accessible from the internet', description: 'Set publicly_accessible = false. Place databases in private subnets with no internet gateway.', file: fp, fix: null });
2701
+ }
2702
+ return findings;
2703
+ },
2704
+ });
2705
+
2706
+ // INFRA-067: No CloudTrail logging
2707
+ rules.push({
2708
+ id: 'INFRA-067', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No CloudTrail audit logging configured',
2709
+ check({ files }) {
2710
+ const findings = [];
2711
+ const hasTF = [...files.keys()].some(fp => fp.endsWith('.tf'));
2712
+ const hasCloudTrail = [...files.values()].some(c => /aws_cloudtrail/.test(c));
2713
+ if (hasTF && !hasCloudTrail) findings.push({ ruleId: 'INFRA-067', category: 'infrastructure', severity: 'high', title: 'No CloudTrail configured — API calls not audited', description: 'Enable CloudTrail for all regions to log API activity for security auditing and compliance.', fix: null });
2714
+ return findings;
2715
+ },
2716
+ });
2717
+
2718
+ // INFRA-068: Security group with 0.0.0.0/0 SSH
2719
+ rules.push({
2720
+ id: 'INFRA-068', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'SSH open to the world',
2721
+ check({ files }) {
2722
+ const findings = [];
2723
+ for (const [fp, c] of files) {
2724
+ if (!fp.endsWith('.tf')) continue;
2725
+ if (/from_port\s*=\s*22/.test(c) && /cidr_blocks\s*=\s*\["0\.0\.0\.0\/0"\]/.test(c)) findings.push({ ruleId: 'INFRA-068', category: 'infrastructure', severity: 'critical', title: 'SSH port 22 open to 0.0.0.0/0 — brute force target', description: 'Restrict SSH access to specific IP ranges or use SSM Session Manager instead.', file: fp, fix: null });
2726
+ }
2727
+ return findings;
2728
+ },
2729
+ });
2730
+
2731
+ // INFRA-069: No WAF on ALB
2732
+ rules.push({
2733
+ id: 'INFRA-069', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Application Load Balancer without WAF',
2734
+ check({ files }) {
2735
+ const findings = [];
2736
+ const hasALB = [...files.values()].some(c => /resource\s+"aws_lb"/.test(c) || /resource\s+"aws_alb"/.test(c));
2737
+ const hasWAF = [...files.values()].some(c => /aws_wafv2_web_acl_association/.test(c));
2738
+ if (hasALB && !hasWAF) findings.push({ ruleId: 'INFRA-069', category: 'infrastructure', severity: 'medium', title: 'ALB without WAF — no L7 attack protection', description: 'Attach a WAFv2 Web ACL to the ALB to protect against SQL injection, XSS, and other L7 attacks.', fix: null });
2739
+ return findings;
2740
+ },
2741
+ });
2742
+
2743
+ // INFRA-070: No monitoring/alerting configured
2744
+ rules.push({
2745
+ id: 'INFRA-070', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No monitoring or alerting configuration',
2746
+ check({ files }) {
2747
+ const findings = [];
2748
+ const hasInfra = [...files.keys()].some(fp => fp.endsWith('.tf') || fp.endsWith('docker-compose.yml'));
2749
+ const hasMonitoring = [...files.keys()].some(fp => /prometheus|grafana|datadog|newrelic|cloudwatch|pagerduty|opsgenie/i.test(fp));
2750
+ const hasMonitoringContent = [...files.values()].some(c => /aws_cloudwatch_metric_alarm|prometheus|grafana|datadog|newrelic/i.test(c));
2751
+ if (hasInfra && !hasMonitoring && !hasMonitoringContent) findings.push({ ruleId: 'INFRA-070', category: 'infrastructure', severity: 'medium', title: 'No monitoring or alerting configuration detected', description: 'Set up monitoring (Prometheus/Grafana, Datadog, CloudWatch) with alerts for key infrastructure metrics.', fix: null });
2752
+ return findings;
2753
+ },
2754
+ });
2755
+
2756
+ // INFRA-071: Docker compose without restart policy
2757
+ rules.push({
2758
+ id: 'INFRA-071', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Docker Compose service without restart policy',
2759
+ check({ files }) {
2760
+ const findings = [];
2761
+ for (const [fp, c] of files) {
2762
+ if (!/docker-compose\.ya?ml$|compose\.ya?ml$/.test(fp)) continue;
2763
+ if (/services:/.test(c) && !/restart:/.test(c)) findings.push({ ruleId: 'INFRA-071', category: 'infrastructure', severity: 'medium', title: 'Docker Compose service without restart policy — no auto-recovery', description: 'Add restart: unless-stopped or restart: always to ensure services recover from crashes.', file: fp, fix: null });
2764
+ }
2765
+ return findings;
2766
+ },
2767
+ });
2768
+
2769
+ // INFRA-072: Docker compose without memory limits
2770
+ rules.push({
2771
+ id: 'INFRA-072', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Docker Compose without memory limits',
2772
+ check({ files }) {
2773
+ const findings = [];
2774
+ for (const [fp, c] of files) {
2775
+ if (!/docker-compose\.ya?ml$|compose\.ya?ml$/.test(fp)) continue;
2776
+ if (/services:/.test(c) && !/mem_limit|memory:|deploy:/.test(c)) findings.push({ ruleId: 'INFRA-072', category: 'infrastructure', severity: 'medium', title: 'Docker Compose services without memory limits — OOM risk to host', description: 'Set memory limits using deploy.resources.limits.memory or mem_limit to prevent a single service from exhausting host memory.', file: fp, fix: null });
2777
+ }
2778
+ return findings;
2779
+ },
2780
+ });
2781
+
2782
+ // INFRA-073: No health check in Docker Compose
2783
+ rules.push({
2784
+ id: 'INFRA-073', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Docker Compose without healthcheck',
2785
+ check({ files }) {
2786
+ const findings = [];
2787
+ for (const [fp, c] of files) {
2788
+ if (!/docker-compose\.ya?ml$|compose\.ya?ml$/.test(fp)) continue;
2789
+ if (/services:/.test(c) && !/healthcheck:/.test(c)) findings.push({ ruleId: 'INFRA-073', category: 'infrastructure', severity: 'medium', title: 'Docker Compose service without healthcheck — no liveness detection', description: 'Add healthcheck with test, interval, and timeout to detect unhealthy containers.', file: fp, fix: null });
2790
+ }
2791
+ return findings;
2792
+ },
2793
+ });
2794
+
2795
+ // INFRA-074: Kubernetes pod without liveness probe
2796
+ rules.push({
2797
+ id: 'INFRA-074', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Kubernetes container without livenessProbe',
2798
+ check({ files }) {
2799
+ const findings = [];
2800
+ for (const [fp, c] of files) {
2801
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2802
+ if (/kind:\s*Deployment/.test(c) && !/livenessProbe:/.test(c)) findings.push({ ruleId: 'INFRA-074', category: 'infrastructure', severity: 'high', title: 'Kubernetes Deployment without livenessProbe — hung containers not restarted', description: 'Add livenessProbe to containers so Kubernetes restarts pods that become unresponsive.', file: fp, fix: null });
2803
+ }
2804
+ return findings;
2805
+ },
2806
+ });
2807
+
2808
+ // INFRA-075: Kubernetes automountServiceAccountToken
2809
+ rules.push({
2810
+ id: 'INFRA-075', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes pod with automounted service account token',
2811
+ check({ files }) {
2812
+ const findings = [];
2813
+ for (const [fp, c] of files) {
2814
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2815
+ if (/kind:\s*(?:Deployment|Pod)/.test(c) && !/automountServiceAccountToken:\s*false/.test(c) && /kind:\s*(?:Deployment|Pod)/.test(c)) findings.push({ ruleId: 'INFRA-075', category: 'infrastructure', severity: 'medium', title: 'Service account token auto-mounted — unnecessary API access', description: 'Set automountServiceAccountToken: false unless the pod needs to call the Kubernetes API.', file: fp, fix: null });
2816
+ }
2817
+ return findings;
2818
+ },
2819
+ });
2820
+
2821
+ // INFRA-076: No container image pull policy
2822
+ rules.push({
2823
+ id: 'INFRA-076', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Kubernetes container without imagePullPolicy',
2824
+ check({ files }) {
2825
+ const findings = [];
2826
+ for (const [fp, c] of files) {
2827
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2828
+ if (/kind:\s*Deployment/.test(c) && !/imagePullPolicy:/.test(c)) findings.push({ ruleId: 'INFRA-076', category: 'infrastructure', severity: 'low', title: 'No imagePullPolicy set — may use cached stale images', description: 'Set imagePullPolicy: Always for production deployments to ensure the latest image is pulled.', file: fp, fix: null });
2829
+ }
2830
+ return findings;
2831
+ },
2832
+ });
2833
+
2834
+ // INFRA-077: Terraform state stored locally
2835
+ rules.push({
2836
+ id: 'INFRA-077', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Terraform state stored locally',
2837
+ check({ files }) {
2838
+ const findings = [];
2839
+ const hasTF = [...files.keys()].some(fp => fp.endsWith('.tf'));
2840
+ const hasRemoteBackend = [...files.values()].some(c => /backend\s+"(?:s3|gcs|azurerm|consul|remote)"/.test(c));
2841
+ if (hasTF && !hasRemoteBackend) findings.push({ ruleId: 'INFRA-077', category: 'infrastructure', severity: 'high', title: 'Terraform state stored locally — no team collaboration or locking', description: 'Configure a remote backend (S3, GCS, etc.) with state locking for safe team collaboration.', fix: null });
2842
+ return findings;
2843
+ },
2844
+ });
2845
+
2846
+ // INFRA-078: No DDoS protection
2847
+ rules.push({
2848
+ id: 'INFRA-078', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No DDoS protection configured',
2849
+ check({ files }) {
2850
+ const findings = [];
2851
+ const hasTF = [...files.keys()].some(fp => fp.endsWith('.tf'));
2852
+ const hasDDoS = [...files.values()].some(c => /aws_shield|cloudflare|aws_wafv2/.test(c));
2853
+ if (hasTF && !hasDDoS) findings.push({ ruleId: 'INFRA-078', category: 'infrastructure', severity: 'medium', title: 'No DDoS protection configured — exposed to volumetric attacks', description: 'Enable AWS Shield Advanced, Cloudflare, or WAFv2 rate-limiting rules for DDoS protection.', fix: null });
2854
+ return findings;
2855
+ },
2856
+ });
2857
+
2858
+ // INFRA-079: Docker socket mounted
2859
+ rules.push({
2860
+ id: 'INFRA-079', category: 'infrastructure', severity: 'critical', confidence: 'definite', title: 'Docker socket mounted in container',
2861
+ check({ files }) {
2862
+ const findings = [];
2863
+ for (const [fp, c] of files) {
2864
+ if (!/docker-compose\.ya?ml$|compose\.ya?ml$/.test(fp) && !fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2865
+ if (/\/var\/run\/docker\.sock/.test(c)) findings.push({ ruleId: 'INFRA-079', category: 'infrastructure', severity: 'critical', title: 'Docker socket mounted — container can control host Docker daemon', description: 'Mounting docker.sock gives the container full root access to the host. Use rootless Docker or a Docker proxy with ACLs.', file: fp, fix: null });
2866
+ }
2867
+ return findings;
2868
+ },
2869
+ });
2870
+
2871
+ // INFRA-080: No backup configuration
2872
+ rules.push({
2873
+ id: 'INFRA-080', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'No database backup configuration',
2874
+ check({ files }) {
2875
+ const findings = [];
2876
+ for (const [fp, c] of files) {
2877
+ if (!fp.endsWith('.tf')) continue;
2878
+ if (/resource\s+"aws_db_instance"/.test(c) && /backup_retention_period\s*=\s*0/.test(c)) findings.push({ ruleId: 'INFRA-080', category: 'infrastructure', severity: 'high', title: 'RDS backup retention set to 0 — no automated backups', description: 'Set backup_retention_period to at least 7 days for production databases.', file: fp, fix: null });
2879
+ }
2880
+ return findings;
2881
+ },
2882
+ });
2883
+
2884
+ // INFRA-081: Unencrypted RDS
2885
+ rules.push({
2886
+ id: 'INFRA-081', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'RDS instance without encryption',
2887
+ check({ files }) {
2888
+ const findings = [];
2889
+ for (const [fp, c] of files) {
2890
+ if (!fp.endsWith('.tf')) continue;
2891
+ if (/resource\s+"aws_db_instance"/.test(c) && !/storage_encrypted\s*=\s*true/.test(c)) findings.push({ ruleId: 'INFRA-081', category: 'infrastructure', severity: 'high', title: 'RDS instance without storage encryption at rest', description: 'Set storage_encrypted = true. Encryption at rest is a basic security requirement for databases.', file: fp, fix: null });
2892
+ }
2893
+ return findings;
2894
+ },
2895
+ });
2896
+
2897
+ // INFRA-082: No access logging on load balancer
2898
+ rules.push({
2899
+ id: 'INFRA-082', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Load balancer without access logging',
2900
+ check({ files }) {
2901
+ const findings = [];
2902
+ for (const [fp, c] of files) {
2903
+ if (!fp.endsWith('.tf')) continue;
2904
+ if (/resource\s+"aws_lb"/.test(c) && !/access_logs/.test(c)) findings.push({ ruleId: 'INFRA-082', category: 'infrastructure', severity: 'medium', title: 'ALB without access logging — no request audit trail', description: 'Enable access logs on the load balancer for security analysis and debugging.', file: fp, fix: null });
2905
+ }
2906
+ return findings;
2907
+ },
2908
+ });
2909
+
2910
+ // INFRA-083: Kubernetes container with writable root filesystem
2911
+ rules.push({
2912
+ id: 'INFRA-083', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes container with writable root filesystem',
2913
+ check({ files }) {
2914
+ const findings = [];
2915
+ for (const [fp, c] of files) {
2916
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2917
+ if (/kind:\s*Deployment/.test(c) && !/readOnlyRootFilesystem:\s*true/.test(c)) findings.push({ ruleId: 'INFRA-083', category: 'infrastructure', severity: 'medium', title: 'Container root filesystem writable — malware can write to disk', description: 'Set readOnlyRootFilesystem: true in securityContext and use emptyDir for writable paths.', file: fp, fix: null });
2918
+ }
2919
+ return findings;
2920
+ },
2921
+ });
2922
+
2923
+ // INFRA-084: No node affinity/anti-affinity rules
2924
+ rules.push({
2925
+ id: 'INFRA-084', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Kubernetes Deployment without affinity rules',
2926
+ check({ files }) {
2927
+ const findings = [];
2928
+ for (const [fp, c] of files) {
2929
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
2930
+ if (/kind:\s*Deployment/.test(c) && /replicas:\s*[2-9]/.test(c) && !/affinity:/.test(c)) findings.push({ ruleId: 'INFRA-084', category: 'infrastructure', severity: 'low', title: 'Multi-replica Deployment without pod anti-affinity — single node failure risk', description: 'Add podAntiAffinity to spread replicas across nodes for high availability.', file: fp, fix: null });
2931
+ }
2932
+ return findings;
2933
+ },
2934
+ });
2935
+
2936
+ // INFRA-085: No Kubernetes HPA
2937
+ rules.push({
2938
+ id: 'INFRA-085', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Kubernetes Deployment without HorizontalPodAutoscaler',
2939
+ check({ files }) {
2940
+ const findings = [];
2941
+ const hasDeployment = [...files.values()].some(c => /kind:\s*Deployment/.test(c));
2942
+ const hasHPA = [...files.values()].some(c => /kind:\s*HorizontalPodAutoscaler/.test(c));
2943
+ if (hasDeployment && !hasHPA) findings.push({ ruleId: 'INFRA-085', category: 'infrastructure', severity: 'low', title: 'No HPA configured — manual scaling only', description: 'Add HorizontalPodAutoscaler to automatically scale pods based on CPU/memory utilization.', fix: null });
2944
+ return findings;
2945
+ },
2946
+ });
2947
+
2948
+ // INFRA-086: Nginx config without rate limiting
2949
+ rules.push({
2950
+ id: 'INFRA-086', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Nginx without rate limiting',
2951
+ check({ files }) {
2952
+ const findings = [];
2953
+ for (const [fp, c] of files) {
2954
+ if (!fp.includes('nginx') || (!fp.endsWith('.conf') && !fp.endsWith('.cfg'))) continue;
2955
+ if (/server\s*\{/.test(c) && !/limit_req_zone|limit_req\s/.test(c)) findings.push({ ruleId: 'INFRA-086', category: 'infrastructure', severity: 'medium', title: 'Nginx config without rate limiting — vulnerable to abuse', description: 'Add limit_req_zone and limit_req directives to protect against request floods.', file: fp, fix: null });
2956
+ }
2957
+ return findings;
2958
+ },
2959
+ });
2960
+
2961
+ // INFRA-087: Nginx SSL/TLS misconfiguration
2962
+ rules.push({
2963
+ id: 'INFRA-087', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'Nginx with weak SSL/TLS configuration',
2964
+ check({ files }) {
2965
+ const findings = [];
2966
+ for (const [fp, c] of files) {
2967
+ if (!fp.includes('nginx') || !fp.endsWith('.conf')) continue;
2968
+ if (/ssl_protocols/.test(c) && /TLSv1(?:\s|;)|SSLv3/.test(c)) findings.push({ ruleId: 'INFRA-087', category: 'infrastructure', severity: 'high', title: 'Nginx allows TLSv1.0/SSLv3 — vulnerable to POODLE/BEAST', description: 'Set ssl_protocols TLSv1.2 TLSv1.3; to disable insecure protocol versions.', file: fp, fix: null });
2969
+ }
2970
+ return findings;
2971
+ },
2972
+ });
2973
+
2974
+ // INFRA-088: No log rotation configured
2975
+ rules.push({
2976
+ id: 'INFRA-088', category: 'infrastructure', severity: 'low', confidence: 'suggestion', title: 'Docker logging without log rotation',
2977
+ check({ files }) {
2978
+ const findings = [];
2979
+ for (const [fp, c] of files) {
2980
+ if (!/docker-compose\.ya?ml$|compose\.ya?ml$/.test(fp)) continue;
2981
+ if (/services:/.test(c) && !/logging:|max-size/.test(c)) findings.push({ ruleId: 'INFRA-088', category: 'infrastructure', severity: 'low', title: 'Docker containers without log rotation — disk fill risk', description: 'Configure logging driver with max-size and max-file options to prevent disk exhaustion.', file: fp, fix: null });
2982
+ }
2983
+ return findings;
2984
+ },
2985
+ });
2986
+
2987
+ // INFRA-089: No container security scanning
2988
+ rules.push({
2989
+ id: 'INFRA-089', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No container image scanning configured',
2990
+ check({ files }) {
2991
+ const findings = [];
2992
+ const hasDocker = [...files.keys()].some(fp => fp.endsWith('Dockerfile'));
2993
+ const hasScanning = [...files.values()].some(c => /trivy|snyk\s+container|docker\s+scout|grype|clair|anchore/i.test(c));
2994
+ if (hasDocker && !hasScanning) findings.push({ ruleId: 'INFRA-089', category: 'infrastructure', severity: 'medium', title: 'No container image vulnerability scanning in CI', description: 'Add Trivy, Snyk Container, or Docker Scout to CI pipeline to detect known CVEs in container images.', fix: null });
2995
+ return findings;
2996
+ },
2997
+ });
2998
+
2999
+ // INFRA-090: Kubernetes capability not dropped
3000
+ rules.push({
3001
+ id: 'INFRA-090', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'Kubernetes container without dropping capabilities',
3002
+ check({ files }) {
3003
+ const findings = [];
3004
+ for (const [fp, c] of files) {
3005
+ if (!fp.endsWith('.yaml') && !fp.endsWith('.yml')) continue;
3006
+ if (/kind:\s*Deployment/.test(c) && /securityContext:/.test(c) && !/drop:/.test(c)) findings.push({ ruleId: 'INFRA-090', category: 'infrastructure', severity: 'medium', title: 'Container capabilities not dropped — excessive Linux privileges', description: 'Add capabilities: { drop: ["ALL"] } and only add back specific capabilities that are required.', file: fp, fix: null });
3007
+ }
3008
+ return findings;
3009
+ },
3010
+ });
3011
+
3012
+ // INFRA-091: No Terraform plan in CI
3013
+ rules.push({
3014
+ id: 'INFRA-091', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'No Terraform plan step in CI/CD',
3015
+ check({ files }) {
3016
+ const findings = [];
3017
+ const hasTF = [...files.keys()].some(fp => fp.endsWith('.tf'));
3018
+ const hasTFPlan = [...files.values()].some(c => /terraform\s+plan|tf\s+plan|tfplan/.test(c));
3019
+ if (hasTF && !hasTFPlan) findings.push({ ruleId: 'INFRA-091', category: 'infrastructure', severity: 'medium', title: 'No terraform plan in CI — changes applied without review', description: 'Add terraform plan to CI pipeline and require plan output review before apply.', fix: null });
3020
+ return findings;
3021
+ },
3022
+ });
3023
+
3024
+ // INFRA-092: EBS volume unencrypted
3025
+ rules.push({
3026
+ id: 'INFRA-092', category: 'infrastructure', severity: 'high', confidence: 'likely', title: 'EBS volume without encryption',
3027
+ check({ files }) {
3028
+ const findings = [];
3029
+ for (const [fp, c] of files) {
3030
+ if (!fp.endsWith('.tf')) continue;
3031
+ if (/resource\s+"aws_ebs_volume"/.test(c) && !/encrypted\s*=\s*true/.test(c)) findings.push({ ruleId: 'INFRA-092', category: 'infrastructure', severity: 'high', title: 'EBS volume without encryption — data at rest exposed', description: 'Set encrypted = true on all EBS volumes. Enable default encryption in account settings.', file: fp, fix: null });
3032
+ }
3033
+ return findings;
3034
+ },
3035
+ });
3036
+
3037
+ // INFRA-093: No multi-AZ for RDS
3038
+ rules.push({
3039
+ id: 'INFRA-093', category: 'infrastructure', severity: 'medium', confidence: 'likely', title: 'RDS instance without Multi-AZ',
3040
+ check({ files }) {
3041
+ const findings = [];
3042
+ for (const [fp, c] of files) {
3043
+ if (!fp.endsWith('.tf')) continue;
3044
+ if (/resource\s+"aws_db_instance"/.test(c) && !/multi_az\s*=\s*true/.test(c)) findings.push({ ruleId: 'INFRA-093', category: 'infrastructure', severity: 'medium', title: 'RDS without Multi-AZ — single point of failure', description: 'Set multi_az = true for production databases to enable automatic failover.', file: fp, fix: null });
3045
+ }
3046
+ return findings;
3047
+ },
3048
+ });