security-mcp 1.3.1 → 1.3.4

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 (131) hide show
  1. package/README.md +286 -887
  2. package/defaults/cloud-controls/aws.json +10712 -0
  3. package/defaults/cloud-controls/azure.json +7201 -0
  4. package/defaults/cloud-controls/gcp.json +4061 -0
  5. package/defaults/control-catalog.json +24 -0
  6. package/dist/ci/pr-gate.js +22 -5
  7. package/dist/cli/index.js +73 -2
  8. package/dist/cli/install.js +4 -55
  9. package/dist/cli/onboarding.js +18 -10
  10. package/dist/gate/checks/agentic-instructions.js +515 -0
  11. package/dist/gate/checks/ai-governance.js +132 -0
  12. package/dist/gate/checks/ai.js +1 -1
  13. package/dist/gate/checks/cloud-controls.js +69 -0
  14. package/dist/gate/checks/crypto.js +1 -1
  15. package/dist/gate/checks/data-platform.js +954 -0
  16. package/dist/gate/checks/dependencies.js +14 -3
  17. package/dist/gate/checks/docker-deep.js +1236 -0
  18. package/dist/gate/checks/gitops.js +724 -0
  19. package/dist/gate/checks/iac.js +1230 -0
  20. package/dist/gate/checks/k8s.js +841 -1
  21. package/dist/gate/checks/secrets.js +49 -37
  22. package/dist/gate/cloud-controls/apply.js +115 -0
  23. package/dist/gate/cloud-controls/bicep.js +36 -0
  24. package/dist/gate/cloud-controls/cfn.js +125 -0
  25. package/dist/gate/cloud-controls/detect.js +104 -0
  26. package/dist/gate/cloud-controls/hcl.js +140 -0
  27. package/dist/gate/cloud-controls/types.js +87 -0
  28. package/dist/gate/exceptions.js +78 -7
  29. package/dist/gate/findings.js +15 -2
  30. package/dist/gate/policy.js +40 -3
  31. package/dist/gate/threat-intel.js +6 -0
  32. package/dist/mcp/audit-chain.js +9 -0
  33. package/dist/mcp/model-router.js +3 -3
  34. package/dist/mcp/orchestration.js +194 -41
  35. package/dist/mcp/server.js +124 -17
  36. package/dist/mcp/tool-audit.js +193 -0
  37. package/dist/repo/fs.js +14 -1
  38. package/dist/review/store.js +4 -2
  39. package/dist/tests/run.js +124 -1
  40. package/package.json +6 -4
  41. package/skills/advanced-dos-tester/SKILL.md +9 -0
  42. package/skills/agentic-instruction-auditor/SKILL.md +111 -0
  43. package/skills/agentic-loop-exploiter/SKILL.md +9 -0
  44. package/skills/ai-llm-redteam/SKILL.md +9 -0
  45. package/skills/ai-model-supply-chain-agent/SKILL.md +9 -0
  46. package/skills/algorithm-implementation-reviewer/SKILL.md +9 -0
  47. package/skills/android-penetration-tester/SKILL.md +9 -0
  48. package/skills/anti-replay-tester/SKILL.md +9 -0
  49. package/skills/appsec-code-auditor/SKILL.md +9 -0
  50. package/skills/artifact-integrity-analyst/SKILL.md +9 -0
  51. package/skills/attack-navigator/SKILL.md +9 -0
  52. package/skills/auth-session-hacker/SKILL.md +9 -0
  53. package/skills/aws-penetration-tester/SKILL.md +54 -0
  54. package/skills/azure-penetration-tester/SKILL.md +52 -0
  55. package/skills/binary-auth-validator/SKILL.md +9 -0
  56. package/skills/bot-detection-specialist/SKILL.md +9 -0
  57. package/skills/business-logic-attacker/SKILL.md +9 -0
  58. package/skills/capec-code-mapper/SKILL.md +9 -0
  59. package/skills/cert-pin-rotation-specialist/SKILL.md +9 -0
  60. package/skills/cicd-pipeline-hijacker/SKILL.md +9 -0
  61. package/skills/ciso-orchestrator/SKILL.md +11 -0
  62. package/skills/cloud-infra-specialist/SKILL.md +9 -0
  63. package/skills/compliance-gap-analyst/SKILL.md +9 -0
  64. package/skills/compliance-grc/SKILL.md +9 -0
  65. package/skills/compliance-lifecycle-tracker/SKILL.md +9 -0
  66. package/skills/container-hardening-auditor/SKILL.md +125 -0
  67. package/skills/credential-stuffing-specialist/SKILL.md +9 -0
  68. package/skills/crypto-pki-specialist/SKILL.md +9 -0
  69. package/skills/csa-ccm-mapper/SKILL.md +9 -0
  70. package/skills/csf2-governance-mapper/SKILL.md +9 -0
  71. package/skills/data-platform-auditor/SKILL.md +125 -0
  72. package/skills/deep-link-fuzzer/SKILL.md +9 -0
  73. package/skills/dependency-confusion-attacker/SKILL.md +9 -0
  74. package/skills/device-integrity-aggregator/SKILL.md +9 -0
  75. package/skills/dos-resilience-tester/SKILL.md +9 -0
  76. package/skills/dread-scorer/SKILL.md +9 -0
  77. package/skills/egress-policy-enforcer/SKILL.md +9 -0
  78. package/skills/evidence-collector/SKILL.md +9 -0
  79. package/skills/file-upload-attacker/SKILL.md +9 -0
  80. package/skills/gcp-penetration-tester/SKILL.md +51 -0
  81. package/skills/git-history-secret-scanner/SKILL.md +9 -0
  82. package/skills/gitops-delivery-auditor/SKILL.md +120 -0
  83. package/skills/iac-security-auditor/SKILL.md +125 -0
  84. package/skills/iam-privesc-graph-builder/SKILL.md +9 -0
  85. package/skills/incident-responder/SKILL.md +9 -0
  86. package/skills/injection-specialist/SKILL.md +9 -0
  87. package/skills/ios-security-auditor/SKILL.md +9 -0
  88. package/skills/json-ambiguity-tester/SKILL.md +0 -0
  89. package/skills/k8s-container-escaper/SKILL.md +22 -0
  90. package/skills/key-management-lifecycle-analyst/SKILL.md +9 -0
  91. package/skills/kill-switch-engineer/SKILL.md +9 -0
  92. package/skills/linddun-privacy-analyst/SKILL.md +9 -0
  93. package/skills/logic-race-fuzzer/SKILL.md +9 -0
  94. package/skills/mobile-api-network-attacker/SKILL.md +9 -0
  95. package/skills/mobile-binary-hardener/SKILL.md +9 -0
  96. package/skills/mobile-security-specialist/SKILL.md +9 -0
  97. package/skills/mobile-webview-auditor/SKILL.md +9 -0
  98. package/skills/model-extraction-attacker/SKILL.md +9 -0
  99. package/skills/multipart-abuse-tester/SKILL.md +9 -0
  100. package/skills/oauth-pkce-specialist/SKILL.md +9 -0
  101. package/skills/parser-exhaustion-tester/SKILL.md +9 -0
  102. package/skills/pentest-infra/SKILL.md +9 -0
  103. package/skills/pentest-social/SKILL.md +9 -0
  104. package/skills/pentest-team/SKILL.md +9 -0
  105. package/skills/pentest-web-api/SKILL.md +9 -0
  106. package/skills/privacy-flow-analyst/SKILL.md +9 -0
  107. package/skills/prompt-injection-specialist/SKILL.md +9 -0
  108. package/skills/quantum-migration-planner/SKILL.md +9 -0
  109. package/skills/rag-poisoning-specialist/SKILL.md +9 -0
  110. package/skills/registry-mirror-enforcer/SKILL.md +9 -0
  111. package/skills/rotation-validation-agent/SKILL.md +9 -0
  112. package/skills/samm-assessor/SKILL.md +9 -0
  113. package/skills/secrets-mask-bypass-tester/SKILL.md +9 -0
  114. package/skills/senior-security-engineer/SKILL.md +11 -0
  115. package/skills/serialization-memory-attacker/SKILL.md +9 -0
  116. package/skills/session-timeout-tester/SKILL.md +9 -0
  117. package/skills/slsa-level3-enforcer/SKILL.md +9 -0
  118. package/skills/slsa-provenance-enforcer/SKILL.md +9 -0
  119. package/skills/ssrf-detection-validator/SKILL.md +9 -0
  120. package/skills/step-up-auth-enforcer/SKILL.md +9 -0
  121. package/skills/stride-pasta-analyst/SKILL.md +9 -0
  122. package/skills/supply-chain-devsecops/SKILL.md +9 -0
  123. package/skills/threat-infrastructure-analyst/SKILL.md +9 -0
  124. package/skills/threat-modeler/SKILL.md +9 -0
  125. package/skills/tls-certificate-auditor/SKILL.md +9 -0
  126. package/skills/token-reuse-detector/SKILL.md +9 -0
  127. package/skills/trike-risk-modeler/SKILL.md +9 -0
  128. package/skills/unicode-homograph-tester/SKILL.md +9 -0
  129. package/skills/waf-rule-lifecycle-agent/SKILL.md +9 -0
  130. package/skills/webhook-security-tester/SKILL.md +9 -0
  131. package/skills/zero-trust-architect/SKILL.md +9 -0
@@ -0,0 +1,1230 @@
1
+ import { searchRepo } from "../../repo/search.js";
2
+ // ---------------------------------------------------------------------------
3
+ // Pattern definitions. Each string is kept well under 256 chars and contains
4
+ // no nested quantifiers (the searchRepo ReDoS guard rejects (x+)+, (a|b)+, etc.).
5
+ // Patterns are alternations of literal-ish tokens. String.raw is used so that
6
+ // backslashes survive into the regex source.
7
+ // ---------------------------------------------------------------------------
8
+ // 1. Terraform state secrets / unencrypted or unlocked remote backend.
9
+ const TF_STATE_PATTERN = String.raw `encrypt\s*=\s*false|` + // s3 backend encryption off
10
+ String.raw `backend\s+"local"|` + // local backend for shared infra
11
+ String.raw `skip_credentials_validation\s*=\s*true|` +
12
+ String.raw `skip_metadata_api_check\s*=\s*true`;
13
+ // Heuristic: an s3 backend block present but no dynamodb_table lock key.
14
+ const TF_BACKEND_S3_PATTERN = String.raw `backend\s+"s3"`;
15
+ const TF_BACKEND_LOCK_PATTERN = String.raw `dynamodb_table\s*=`;
16
+ // 2. Unpinned / mutable module & provider sources.
17
+ const TF_UNPINNED_PATTERN = String.raw `source\s*=\s*"git::|` + // git module source (ref checked below)
18
+ String.raw `source\s*=\s*"github\.com|` +
19
+ String.raw `\?ref=master"|\?ref=main"|\?ref=HEAD"`; // branch refs (mutable)
20
+ const TF_PROVIDER_PATTERN = String.raw `provider\s+"aws"|provider\s+"google"|provider\s+"azurerm"`;
21
+ const TF_VERSION_PATTERN = String.raw `version\s*=`;
22
+ // 3. local-exec / remote-exec provisioner RCE surface.
23
+ const TF_PROVISIONER_PATTERN = String.raw `provisioner\s+"local-exec"|` +
24
+ String.raw `provisioner\s+"remote-exec"|` +
25
+ String.raw `"local-exec"|"remote-exec"`;
26
+ // 4. Hardcoded secrets/credentials. Tight: token must be assigned a literal.
27
+ const SECRET_PATTERN_A = String.raw `access_key\s*=\s*"[A-Za-z0-9/+]{8}|` +
28
+ String.raw `secret_key\s*=\s*"[A-Za-z0-9/+]{8}|` +
29
+ String.raw `password\s*=\s*"[^"$\s]{4}|` +
30
+ String.raw `password\s*:\s*"[^"$\s]{4}`;
31
+ const SECRET_PATTERN_B = String.raw `private_key\s*=\s*"-----BEGIN|` +
32
+ String.raw `token\s*=\s*"[A-Za-z0-9_-]{12}|` +
33
+ String.raw `api_key\s*=\s*"[A-Za-z0-9_-]{12}|` +
34
+ String.raw `client_secret\s*=\s*"[A-Za-z0-9_-]{8}`;
35
+ // 5. Terraform outputs missing sensitive = true (heuristic markers).
36
+ const TF_OUTPUT_SECRET_PATTERN = String.raw `output\s+"[a-z_]*password|` +
37
+ String.raw `output\s+"[a-z_]*secret|` +
38
+ String.raw `output\s+"[a-z_]*token|` +
39
+ String.raw `output\s+"[a-z_]*private_key`;
40
+ const TF_SENSITIVE_PATTERN = String.raw `sensitive\s*=\s*true`;
41
+ // 6. Disabled validation / destructive safety toggles.
42
+ const TF_UNSAFE_PATTERN = String.raw `skip_final_snapshot\s*=\s*true|` +
43
+ String.raw `force_destroy\s*=\s*true|` +
44
+ String.raw `skip_provider_registration\s*=\s*true|` +
45
+ String.raw `disable_rollback\s*=\s*true`;
46
+ // 7. Wildcard / over-broad CloudFormation & inline IAM JSON.
47
+ const CFN_IAM_WILDCARD_PATTERN = String.raw `"Action"\s*:\s*"\*"|` +
48
+ String.raw `"Resource"\s*:\s*"\*"|` +
49
+ String.raw `"Action"\s*:\s*\[\s*"\*"`;
50
+ // 8. Pulumi plaintext secrets / hardcoded creds.
51
+ const PULUMI_PLAINTEXT_PATTERN = String.raw `config:[a-zA-Z0-9_-]*password|` + // Pulumi.<stack>.yaml plaintext value
52
+ String.raw `config:[a-zA-Z0-9_-]*secret|` +
53
+ String.raw `new\s+aws\.Provider\(|` + // inline provider with creds
54
+ String.raw `accessKey:\s*"[A-Za-z0-9/+]{8}|` +
55
+ String.raw `secretKey:\s*"[A-Za-z0-9/+]{8}`;
56
+ // 9. Ansible insecure task patterns.
57
+ const ANSIBLE_PATTERN_A = String.raw `no_log:\s*false|` +
58
+ String.raw `validate_certs:\s*no|` +
59
+ String.raw `validate_certs:\s*false|` +
60
+ String.raw `validate_certs:\s*"no"`;
61
+ const ANSIBLE_PATTERN_B = String.raw `ansible_become_pass:\s*[^{\s]|` + // hardcoded sudo password
62
+ String.raw `ansible_ssh_pass:\s*[^{\s]|` +
63
+ String.raw `ansible_password:\s*[^{\s]`;
64
+ // 10. Public exposure introduced by IaC (S3 ACL, RDS public).
65
+ const IAC_PUBLIC_PATTERN = String.raw `acl\s*=\s*"public-read"|` +
66
+ String.raw `acl\s*=\s*"public-read-write"|` +
67
+ String.raw `publicly_accessible\s*=\s*true|` +
68
+ String.raw `"PubliclyAccessible"\s*:\s*true`;
69
+ // ===========================================================================
70
+ // Round 2 — CloudFormation DEEP, CDK/SAM/Bicep/ARM breadth, Terraform DEPTH.
71
+ // ===========================================================================
72
+ // --- CloudFormation: public S3 (PublicAccessBlock off / policy Principal *) ---
73
+ const CFN_S3_PUBLIC_PATTERN = String.raw `"BlockPublicAcls"\s*:\s*false|` +
74
+ String.raw `"BlockPublicPolicy"\s*:\s*false|` +
75
+ String.raw `"IgnorePublicAcls"\s*:\s*false|` +
76
+ String.raw `"RestrictPublicBuckets"\s*:\s*false|` +
77
+ String.raw `"AccessControl"\s*:\s*"PublicRead`;
78
+ // --- CloudFormation: security group ingress open to the world ---
79
+ const CFN_SG_OPEN_PATTERN = String.raw `"CidrIp"\s*:\s*"0\.0\.0\.0/0"|` +
80
+ String.raw `"CidrIpv6"\s*:\s*"::/0"`;
81
+ // --- CloudFormation: RDS/Redshift publicly accessible ---
82
+ const CFN_DB_PUBLIC_PATTERN = String.raw `"PubliclyAccessible"\s*:\s*true|` +
83
+ String.raw `"PubliclyAccessible"\s*:\s*"true"`;
84
+ // --- CloudFormation: encryption disabled / missing ---
85
+ const CFN_ENCRYPTION_PATTERN = String.raw `"StorageEncrypted"\s*:\s*false|` +
86
+ String.raw `"Encrypted"\s*:\s*false|` +
87
+ String.raw `"SSEEnabled"\s*:\s*false|` +
88
+ String.raw `"BucketEncryption"\s*:\s*\{\s*\}`;
89
+ // --- CloudFormation: secret Parameter without NoEcho (heuristic per-line) ---
90
+ const CFN_PARAM_SECRET_PATTERN = String.raw `"[A-Za-z]*Password"\s*:\s*\{|` +
91
+ String.raw `"[A-Za-z]*Secret"\s*:\s*\{|` +
92
+ String.raw `"[A-Za-z]*Token"\s*:\s*\{|` +
93
+ String.raw `"[A-Za-z]*ApiKey"\s*:\s*\{`;
94
+ const CFN_NOECHO_PATTERN = String.raw `"NoEcho"\s*:\s*true`;
95
+ // --- CloudFormation: secret literal inline in template ---
96
+ const CFN_INLINE_SECRET_PATTERN = String.raw `"MasterUserPassword"\s*:\s*"[^"$\s{]|` +
97
+ String.raw `"Password"\s*:\s*"[^"$\s{]|` +
98
+ String.raw `"Token"\s*:\s*"[A-Za-z0-9_-]{8}|` +
99
+ String.raw `"SecretString"\s*:\s*"[^"$\s{]`;
100
+ // --- CloudFormation: IAM PassRole wildcard ---
101
+ const CFN_PASSROLE_PATTERN = String.raw `"iam:PassRole"|` +
102
+ String.raw `"Action"\s*:\s*"iam:\*"`;
103
+ // --- CloudFormation: IAM::User with inline access key ---
104
+ const CFN_IAM_USER_KEY_PATTERN = String.raw `AWS::IAM::AccessKey|` +
105
+ String.raw `"AccessKeyId"\s*:\s*"AKIA`;
106
+ // --- CloudFormation: Lambda public function URL (AuthType NONE) ---
107
+ const CFN_LAMBDA_URL_PATTERN = String.raw `"AuthType"\s*:\s*"NONE"`;
108
+ // --- CloudFormation: SNS/SQS/Lambda resource policy Principal "*" ---
109
+ const CFN_RESOURCE_PRINCIPAL_PATTERN = String.raw `"Principal"\s*:\s*"\*"|` +
110
+ String.raw `"Principal"\s*:\s*\{\s*"AWS"\s*:\s*"\*"`;
111
+ // --- CloudFormation: CloudTrail not multi-region / no log validation ---
112
+ const CFN_CLOUDTRAIL_PATTERN = String.raw `"IsMultiRegionTrail"\s*:\s*false|` +
113
+ String.raw `"EnableLogFileValidation"\s*:\s*false`;
114
+ // --- CloudFormation: !Sub / TemplateURL untrusted, cfn-init external URL ---
115
+ const CFN_UNTRUSTED_URL_PATTERN = String.raw `"TemplateURL"\s*:\s*"http://|` +
116
+ String.raw `"TemplateURL"\s*:\s*".*\.s3-website|` +
117
+ String.raw `source\s*=\s*"http://|` +
118
+ String.raw `"source"\s*:\s*"http://`;
119
+ // --- CloudFormation: stateful resource without DeletionPolicy: Retain ---
120
+ const CFN_STATEFUL_PATTERN = String.raw `AWS::RDS::DBInstance|` +
121
+ String.raw `AWS::DynamoDB::Table|` +
122
+ String.raw `AWS::S3::Bucket"`;
123
+ const CFN_DELETION_RETAIN_PATTERN = String.raw `"DeletionPolicy"\s*:\s*"Retain"`;
124
+ // --- CloudFormation: EC2 IMDSv1 (no token required) ---
125
+ const CFN_IMDS_PATTERN = String.raw `"HttpTokens"\s*:\s*"optional"`;
126
+ // --- CDK: escape-hatch wildcard / removalPolicy DESTROY ---
127
+ const CDK_PATTERN = String.raw `addToRolePolicy|` +
128
+ String.raw `actions:\s*\[\s*['"]\*['"]|` +
129
+ String.raw `resources:\s*\[\s*['"]\*['"]|` +
130
+ String.raw `RemovalPolicy\.DESTROY|` +
131
+ String.raw `removalPolicy:\s*cdk\.RemovalPolicy\.DESTROY`;
132
+ // --- SAM: Globals open CORS "*" ---
133
+ const SAM_CORS_PATTERN = String.raw `AllowOrigin\s*:\s*"'\*'"|` +
134
+ String.raw `AllowOrigin:\s*'\*'|` +
135
+ String.raw `"AllowOrigins"\s*:\s*\[\s*"\*"`;
136
+ // --- Bicep/ARM: insecure network / TLS / public blob / privileged role ---
137
+ const BICEP_PATTERN_A = String.raw `publicNetworkAccess:\s*'Enabled'|` +
138
+ String.raw `"publicNetworkAccess"\s*:\s*"Enabled"|` +
139
+ String.raw `supportsHttpsTrafficOnly:\s*false|` +
140
+ String.raw `allowBlobPublicAccess:\s*true`;
141
+ const BICEP_PATTERN_B = String.raw `minimumTlsVersion:\s*'TLS1_0'|` +
142
+ String.raw `minimumTlsVersion:\s*'TLS1_1'|` +
143
+ String.raw `defaultAction:\s*'Allow'|` +
144
+ String.raw `"defaultAction"\s*:\s*"Allow"`;
145
+ // Azure built-in Owner / Contributor role definition GUIDs.
146
+ const BICEP_ROLE_PATTERN = String.raw `8e3af657-a8ff-443c-a75c-2fe8c4bcb635|` + // Owner
147
+ String.raw `b24988ac-6180-42a0-ab88-20f7382dd24c`; // Contributor
148
+ // --- Terraform DEPTH ---
149
+ const TF_TFVARS_SECRET_PATTERN = String.raw `password\s*=\s*"[^"$\s]{4}|` +
150
+ String.raw `secret\s*=\s*"[^"$\s]{4}|` +
151
+ String.raw `token\s*=\s*"[A-Za-z0-9_-]{8}|` +
152
+ String.raw `api_key\s*=\s*"[A-Za-z0-9_-]{8}`;
153
+ const TF_SENSITIVE_FALSE_PATTERN = String.raw `sensitive\s*=\s*false`;
154
+ const TF_HTTP_DATA_PATTERN = String.raw `data\s+"http"|` +
155
+ String.raw `data\s+"terraform_remote_state".*http://|` +
156
+ String.raw `address\s*=\s*"http://`;
157
+ const TF_NULL_RESOURCE_PATTERN = String.raw `resource\s+"null_resource"`;
158
+ const TF_VAULT_TOKEN_PATTERN = String.raw `provider\s+"vault"|token\s*=\s*"s\.[A-Za-z0-9]{8}|token\s*=\s*"hvs\.[A-Za-z0-9]{8}`;
159
+ const TF_DEFAULT_VPC_PATTERN = String.raw `resource\s+"aws_default_vpc"|` +
160
+ String.raw `resource\s+"aws_default_security_group"|` +
161
+ String.raw `resource\s+"aws_default_subnet"`;
162
+ const TF_INSECURE_TLS_PATTERN = String.raw `allow_unverified_ssl\s*=\s*true|` +
163
+ String.raw `insecure\s*=\s*true|` +
164
+ String.raw `skip_tls_verify\s*=\s*true`;
165
+ // ===========================================================================
166
+ // Round 3 — EXTRA-DEEP Terraform-specific detection (prefix IAC_TF_).
167
+ // ===========================================================================
168
+ // Provider auth: hardcoded creds / committed credential files / inline JSON key.
169
+ const TF_PROVIDER_CREDS_PATTERN = String.raw `shared_credentials_file\s*=|` +
170
+ String.raw `credentials\s*=\s*file\(|` +
171
+ String.raw `credentials\s*=\s*"\{|` + // inline GCP JSON key
172
+ String.raw `client_secret\s*=\s*"[^"$\s{]|` + // azurerm inline secret
173
+ String.raw `"private_key_id"\s*:\s*"`; // committed GCP SA key json
174
+ // Backend: S3 without KMS key / no lock / http backend.
175
+ const TF_BACKEND_KMS_PATTERN = String.raw `kms_key_id\s*=`;
176
+ const TF_BACKEND_HTTP_PATTERN = String.raw `backend\s+"http"|` +
177
+ String.raw `backend\s+"http"\s*\{`;
178
+ // Module supply chain: git over http / registry without version / branch ref.
179
+ const TF_MODULE_GIT_HTTP_PATTERN = String.raw `source\s*=\s*"git::http://|` +
180
+ String.raw `source\s*=\s*"http://`;
181
+ const TF_REQUIRED_VERSION_OPEN_PATTERN = String.raw `required_version\s*=\s*">=|` +
182
+ String.raw `required_version\s*=\s*">\s`;
183
+ // S3 hardening: bucket present but SSE/public-access-block resources absent.
184
+ const TF_S3_BUCKET_PATTERN = String.raw `resource\s+"aws_s3_bucket"`;
185
+ const TF_S3_SSE_PATTERN = String.raw `aws_s3_bucket_server_side_encryption_configuration`;
186
+ const TF_S3_PAB_PATTERN = String.raw `aws_s3_bucket_public_access_block`;
187
+ // RDS hardening (storage_encrypted false / IAM auth disabled).
188
+ const TF_RDS_HARDENING_PATTERN = String.raw `storage_encrypted\s*=\s*false|` +
189
+ String.raw `iam_database_authentication_enabled\s*=\s*false`;
190
+ // Security group 0.0.0.0/0 on admin ports.
191
+ const TF_SG_OPEN_CIDR_PATTERN = String.raw `cidr_blocks\s*=\s*\[\s*"0\.0\.0\.0/0"|` +
192
+ String.raw `cidr_blocks\s*=\s*\["0\.0\.0\.0/0"|` +
193
+ String.raw `ipv6_cidr_blocks\s*=\s*\[\s*"::/0"`;
194
+ // IAM HCL policy wildcards / AssumeRole Principal "*".
195
+ const TF_IAM_WILDCARD_HCL_PATTERN = String.raw `"Action"\s*:\s*"\*"|` +
196
+ String.raw `actions\s*=\s*\[\s*"\*"|` +
197
+ String.raw `resources\s*=\s*\[\s*"\*"|` +
198
+ String.raw `identifiers\s*=\s*\[\s*"\*"`;
199
+ // EC2 IMDSv1 via metadata_options http_tokens optional.
200
+ const TF_IMDS_HCL_PATTERN = String.raw `http_tokens\s*=\s*"optional"`;
201
+ // EKS / ECR public.
202
+ const TF_EKS_ECR_PUBLIC_PATTERN = String.raw `endpoint_public_access\s*=\s*true|` +
203
+ String.raw `resource\s+"aws_ecrpublic_repository"|` +
204
+ String.raw `image_tag_mutability\s*=\s*"MUTABLE"`;
205
+ // KMS key rotation disabled.
206
+ const TF_KMS_ROTATION_PATTERN = String.raw `enable_key_rotation\s*=\s*false`;
207
+ // Long-lived IAM access key resource.
208
+ const TF_IAM_ACCESS_KEY_PATTERN = String.raw `resource\s+"aws_iam_access_key"`;
209
+ // CloudTrail log file validation disabled.
210
+ const TF_CLOUDTRAIL_VALIDATION_PATTERN = String.raw `enable_log_file_validation\s*=\s*false`;
211
+ // Root/EBS volume encrypted = false.
212
+ const TF_VOLUME_UNENCRYPTED_PATTERN = String.raw `root_block_device\s*\{|` +
213
+ String.raw `ebs_block_device\s*\{`;
214
+ const TF_VOLUME_ENC_FALSE_PATTERN = String.raw `encrypted\s*=\s*false`;
215
+ // Variable default that looks like a real secret.
216
+ const TF_VAR_DEFAULT_SECRET_PATTERN = String.raw `default\s*=\s*"AKIA[A-Z0-9]{6}|` +
217
+ String.raw `default\s*=\s*"ghp_[A-Za-z0-9]{8}|` +
218
+ String.raw `default\s*=\s*"sk-[A-Za-z0-9]{8}|` +
219
+ String.raw `default\s*=\s*"-----BEGIN`;
220
+ // user_data / templatefile embedding credentials.
221
+ const TF_USERDATA_SECRET_PATTERN = String.raw `user_data\s*=.*password|` +
222
+ String.raw `user_data\s*=.*secret|` +
223
+ String.raw `templatefile\(.*password|` +
224
+ String.raw `export\s+[A-Z_]*PASSWORD=|` +
225
+ String.raw `export\s+[A-Z_]*SECRET=`;
226
+ // lifecycle ignore_changes = all (masks drift/tampering).
227
+ const TF_IGNORE_ALL_PATTERN = String.raw `ignore_changes\s*=\s*all|` +
228
+ String.raw `ignore_changes\s*=\s*\[\s*all`;
229
+ // prevent_destroy = false on a lifecycle block.
230
+ const TF_PREVENT_DESTROY_FALSE_PATTERN = String.raw `prevent_destroy\s*=\s*false`;
231
+ // create_before_destroy on a security group (widens exposure window).
232
+ const TF_CBD_PATTERN = String.raw `create_before_destroy\s*=\s*true`;
233
+ // Committed wrapper scripts using -auto-approve / -target.
234
+ const TF_AUTO_APPROVE_PATTERN = String.raw `terraform\s+apply\s+.*-auto-approve|` +
235
+ String.raw `terraform\s+destroy\s+.*-auto-approve|` +
236
+ String.raw `-auto-approve`;
237
+ export async function checkIac(opts) {
238
+ void opts; // signature consistency; matching scans the whole repo via searchRepo
239
+ const findings = [];
240
+ const [tfState, backendS3, backendLock, unpinned, providers, versions, provisioners, secretsA, secretsB, outputSecrets, sensitiveMarkers, unsafe, cfnWildcard, pulumiPlaintext, ansibleA, ansibleB, iacPublic,] = await Promise.all([
241
+ searchRepo({ query: TF_STATE_PATTERN, isRegex: true, maxMatches: 200 }),
242
+ searchRepo({ query: TF_BACKEND_S3_PATTERN, isRegex: true, maxMatches: 200 }),
243
+ searchRepo({ query: TF_BACKEND_LOCK_PATTERN, isRegex: true, maxMatches: 200 }),
244
+ searchRepo({ query: TF_UNPINNED_PATTERN, isRegex: true, maxMatches: 200 }),
245
+ searchRepo({ query: TF_PROVIDER_PATTERN, isRegex: true, maxMatches: 200 }),
246
+ searchRepo({ query: TF_VERSION_PATTERN, isRegex: true, maxMatches: 200 }),
247
+ searchRepo({ query: TF_PROVISIONER_PATTERN, isRegex: true, maxMatches: 200 }),
248
+ searchRepo({ query: SECRET_PATTERN_A, isRegex: true, maxMatches: 200 }),
249
+ searchRepo({ query: SECRET_PATTERN_B, isRegex: true, maxMatches: 200 }),
250
+ searchRepo({ query: TF_OUTPUT_SECRET_PATTERN, isRegex: true, maxMatches: 200 }),
251
+ searchRepo({ query: TF_SENSITIVE_PATTERN, isRegex: true, maxMatches: 200 }),
252
+ searchRepo({ query: TF_UNSAFE_PATTERN, isRegex: true, maxMatches: 200 }),
253
+ searchRepo({ query: CFN_IAM_WILDCARD_PATTERN, isRegex: true, maxMatches: 200 }),
254
+ searchRepo({ query: PULUMI_PLAINTEXT_PATTERN, isRegex: true, maxMatches: 200 }),
255
+ searchRepo({ query: ANSIBLE_PATTERN_A, isRegex: true, maxMatches: 200 }),
256
+ searchRepo({ query: ANSIBLE_PATTERN_B, isRegex: true, maxMatches: 200 }),
257
+ searchRepo({ query: IAC_PUBLIC_PATTERN, isRegex: true, maxMatches: 200 }),
258
+ ]);
259
+ const ev = (m) => m.slice(0, 20).map((x) => `${x.file}:${x.line}: ${x.preview}`);
260
+ // 1. Unencrypted / unlocked / local Terraform state backend.
261
+ const stateEvidence = [...tfState];
262
+ if (backendS3.length > 0 && backendLock.length === 0) {
263
+ stateEvidence.push(...backendS3);
264
+ }
265
+ if (stateEvidence.length > 0) {
266
+ findings.push({
267
+ id: "IAC_TF_STATE_INSECURE",
268
+ title: "Terraform remote state is unencrypted, unlocked, or stored on a local backend — state contains plaintext secrets",
269
+ severity: "HIGH",
270
+ evidence: ev(stateEvidence),
271
+ requiredActions: [
272
+ "Set encrypt = true on the S3 backend so state (which holds plaintext secrets) is encrypted at rest.",
273
+ "Add dynamodb_table to the S3 backend to enable state locking and prevent concurrent corrupting applies.",
274
+ "Never use a local backend for shared infrastructure — use S3+DynamoDB, Terraform Cloud, or GCS with versioning.",
275
+ "Restrict the state bucket with a bucket policy, block public access, and enable a customer-managed KMS key.",
276
+ ],
277
+ });
278
+ }
279
+ // 2. Unpinned / mutable module & provider sources.
280
+ const providerNoVersion = providers.length > 0 && versions.length === 0;
281
+ if (unpinned.length > 0 || providerNoVersion) {
282
+ findings.push({
283
+ id: "IAC_TF_UNPINNED_SOURCE",
284
+ title: "Terraform module or provider source is unpinned/mutable — supply-chain tampering via moving ref",
285
+ severity: "HIGH",
286
+ evidence: ev(unpinned.length > 0 ? unpinned : providers),
287
+ requiredActions: [
288
+ "Pin every git module source to an immutable commit SHA: source = \"git::https://...//mod?ref=<40-char-sha>\".",
289
+ "Pin registry modules with an exact version = \"x.y.z\" (not a range).",
290
+ "Add a required_providers block with a pinned version constraint (= or ~> with a lockfile) for every provider.",
291
+ "Commit .terraform.lock.hcl so provider checksums are verified on every init.",
292
+ ],
293
+ });
294
+ }
295
+ // 3. Provisioner RCE surface.
296
+ if (provisioners.length > 0) {
297
+ findings.push({
298
+ id: "IAC_TF_PROVISIONER_EXEC",
299
+ title: "local-exec / remote-exec provisioner detected — command-injection and RCE surface during apply",
300
+ severity: "HIGH",
301
+ evidence: ev(provisioners),
302
+ requiredActions: [
303
+ "Remove local-exec/remote-exec provisioners; use a proper config-management tool or cloud-init instead.",
304
+ "If unavoidable, never interpolate untrusted variables into the command string — use environment/null_resource with fixed args.",
305
+ "Run terraform apply only from a hardened CI runner with no standing cloud credentials.",
306
+ "Audit who can submit plans, since provisioners execute arbitrary commands on the operator's host.",
307
+ ],
308
+ });
309
+ }
310
+ // 4. Hardcoded secrets.
311
+ const secretHits = [...secretsA, ...secretsB];
312
+ if (secretHits.length > 0) {
313
+ findings.push({
314
+ id: "IAC_HARDCODED_SECRET",
315
+ title: "Hardcoded credential or private key found in IaC source",
316
+ severity: "CRITICAL",
317
+ evidence: ev(secretHits),
318
+ requiredActions: [
319
+ "Remove the secret from source immediately and rotate it — assume it is already compromised.",
320
+ "Reference secrets via a secret manager data source (aws_secretsmanager_secret_version, vault_generic_secret, etc.).",
321
+ "Pass sensitive values as TF_VAR_ environment variables injected at runtime, never committed.",
322
+ "Add a pre-commit secret scanner (gitleaks/trufflehog) and purge the secret from git history.",
323
+ ],
324
+ });
325
+ }
326
+ // 5. Outputs exposing secrets without sensitive = true. Count-based so prose
327
+ // or remediation docs that merely mention "sensitive = true" cannot suppress
328
+ // a genuine unguarded secret output: fire when secret-named outputs outnumber
329
+ // the sensitive markers present.
330
+ if (outputSecrets.length > sensitiveMarkers.length) {
331
+ findings.push({
332
+ id: "IAC_TF_OUTPUT_NOT_SENSITIVE",
333
+ title: "Terraform output exposing a secret without sensitive = true — value leaks to plan/CI logs and state",
334
+ severity: "MEDIUM",
335
+ evidence: ev(outputSecrets),
336
+ requiredActions: [
337
+ "Mark each secret output sensitive, e.g.:",
338
+ " output \"db_password\" {",
339
+ " value = aws_db_instance.db.password",
340
+ " sensitive = true",
341
+ " }",
342
+ "Better: do not export secrets at all — read them on demand from the secret manager:",
343
+ " data \"aws_secretsmanager_secret_version\" \"db\" { secret_id = \"prod/db\" }",
344
+ "Scrub CI logs that may already contain the plaintext value, then verify with: terraform plan -no-color | grep -i password",
345
+ "Detect regressions in CI with: checkov -d . --check CKV_SECRET_6 ; trivy config .",
346
+ ],
347
+ });
348
+ }
349
+ // 6. Disabled validation / destructive toggles.
350
+ if (unsafe.length > 0) {
351
+ findings.push({
352
+ id: "IAC_TF_UNSAFE_DESTROY",
353
+ title: "Destructive or validation-skipping toggle enabled (force_destroy / skip_final_snapshot / disable_rollback)",
354
+ severity: "HIGH",
355
+ evidence: ev(unsafe),
356
+ requiredActions: [
357
+ "Set skip_final_snapshot = false on RDS so a snapshot is taken before deletion.",
358
+ "Remove force_destroy = true from buckets holding real data; require manual emptying instead.",
359
+ "Add a lifecycle { prevent_destroy = true } block to critical stateful resources.",
360
+ "Keep skip_provider_registration / disable_rollback at their safe defaults.",
361
+ ],
362
+ });
363
+ }
364
+ // 7. CloudFormation / inline IAM wildcards.
365
+ if (cfnWildcard.length > 0) {
366
+ findings.push({
367
+ id: "IAC_CFN_IAM_WILDCARD",
368
+ title: "CloudFormation/inline IAM policy grants wildcard Action or Resource — least-privilege violated",
369
+ severity: "HIGH",
370
+ evidence: ev(cfnWildcard),
371
+ requiredActions: [
372
+ "Replace \"Action\": \"*\" with the explicit minimal action list the resource needs.",
373
+ "Replace \"Resource\": \"*\" with specific ARNs scoped to this stack.",
374
+ "Add NoEcho: true to any CloudFormation parameter that carries a secret.",
375
+ "Validate templates with cfn-lint and cfn_nag / Checkov in CI before deploy.",
376
+ ],
377
+ });
378
+ }
379
+ // 8. Pulumi plaintext secrets.
380
+ if (pulumiPlaintext.length > 0) {
381
+ findings.push({
382
+ id: "IAC_PULUMI_PLAINTEXT_SECRET",
383
+ title: "Pulumi config secret stored in plaintext or provider credentials hardcoded",
384
+ severity: "HIGH",
385
+ evidence: ev(pulumiPlaintext),
386
+ requiredActions: [
387
+ "Set secret config with `pulumi config set --secret` so values are encrypted in Pulumi.<stack>.yaml.",
388
+ "Wrap sensitive program values with pulumi.secret() so they never appear in state or logs in cleartext.",
389
+ "Source provider credentials from environment / OIDC, never `new aws.Provider({ accessKey, secretKey })` literals.",
390
+ "Use a Pulumi secrets provider backed by AWS KMS / Azure Key Vault / GCP KMS.",
391
+ ],
392
+ });
393
+ }
394
+ // 9. Ansible insecure tasks.
395
+ const ansibleHits = [...ansibleA, ...ansibleB];
396
+ if (ansibleHits.length > 0) {
397
+ findings.push({
398
+ id: "IAC_ANSIBLE_INSECURE_TASK",
399
+ title: "Ansible task disables TLS verification, logging of secrets, or hardcodes a privileged password",
400
+ severity: "HIGH",
401
+ evidence: ev(ansibleHits),
402
+ requiredActions: [
403
+ "Remove validate_certs: no/false — always verify TLS certificates against a trusted CA.",
404
+ "Set no_log: true on any task that handles secrets so values are not printed to the play log.",
405
+ "Never hardcode ansible_become_pass / ansible_ssh_pass — store them in ansible-vault or a secret manager.",
406
+ "Avoid passing unsanitized variables to the shell/command modules; prefer purpose-built modules.",
407
+ ],
408
+ });
409
+ }
410
+ // 10. Public exposure via IaC.
411
+ if (iacPublic.length > 0) {
412
+ findings.push({
413
+ id: "IAC_PUBLIC_RESOURCE",
414
+ title: "IaC creates a publicly exposed resource (public-read ACL or publicly_accessible database)",
415
+ severity: "HIGH",
416
+ evidence: ev(iacPublic),
417
+ requiredActions: [
418
+ "Remove acl = \"public-read\"/\"public-read-write\"; use bucket policies with explicit principals instead.",
419
+ "Set publicly_accessible = false on all database instances and place them in private subnets.",
420
+ "Enable S3 Block Public Access at the account and bucket level to override accidental public ACLs.",
421
+ "Front any legitimately public asset bucket with CloudFront + Origin Access Control, not a public ACL.",
422
+ ],
423
+ });
424
+ }
425
+ // -------------------------------------------------------------------------
426
+ // Round 2: deep CloudFormation, CDK/SAM/Bicep, Terraform depth.
427
+ // -------------------------------------------------------------------------
428
+ const [cfnS3Public, cfnSgOpen, cfnDbPublic, cfnEncryption, cfnParamSecret, cfnNoEcho, cfnInlineSecret, cfnPassRole, cfnIamUserKey, cfnLambdaUrl, cfnResourcePrincipal, cfnCloudtrail, cfnUntrustedUrl, cfnStateful, cfnDeletionRetain, cfnImds, cdkHits, samCors, bicepA, bicepB, bicepRole, tfvarsSecret, tfSensitiveFalse, tfHttpData, tfNullResource, tfVaultToken, tfDefaultVpc, tfInsecureTls,] = await Promise.all([
429
+ searchRepo({ query: CFN_S3_PUBLIC_PATTERN, isRegex: true, maxMatches: 200 }),
430
+ searchRepo({ query: CFN_SG_OPEN_PATTERN, isRegex: true, maxMatches: 200 }),
431
+ searchRepo({ query: CFN_DB_PUBLIC_PATTERN, isRegex: true, maxMatches: 200 }),
432
+ searchRepo({ query: CFN_ENCRYPTION_PATTERN, isRegex: true, maxMatches: 200 }),
433
+ searchRepo({ query: CFN_PARAM_SECRET_PATTERN, isRegex: true, maxMatches: 200 }),
434
+ searchRepo({ query: CFN_NOECHO_PATTERN, isRegex: true, maxMatches: 200 }),
435
+ searchRepo({ query: CFN_INLINE_SECRET_PATTERN, isRegex: true, maxMatches: 200 }),
436
+ searchRepo({ query: CFN_PASSROLE_PATTERN, isRegex: true, maxMatches: 200 }),
437
+ searchRepo({ query: CFN_IAM_USER_KEY_PATTERN, isRegex: true, maxMatches: 200 }),
438
+ searchRepo({ query: CFN_LAMBDA_URL_PATTERN, isRegex: true, maxMatches: 200 }),
439
+ searchRepo({ query: CFN_RESOURCE_PRINCIPAL_PATTERN, isRegex: true, maxMatches: 200 }),
440
+ searchRepo({ query: CFN_CLOUDTRAIL_PATTERN, isRegex: true, maxMatches: 200 }),
441
+ searchRepo({ query: CFN_UNTRUSTED_URL_PATTERN, isRegex: true, maxMatches: 200 }),
442
+ searchRepo({ query: CFN_STATEFUL_PATTERN, isRegex: true, maxMatches: 200 }),
443
+ searchRepo({ query: CFN_DELETION_RETAIN_PATTERN, isRegex: true, maxMatches: 200 }),
444
+ searchRepo({ query: CFN_IMDS_PATTERN, isRegex: true, maxMatches: 200 }),
445
+ searchRepo({ query: CDK_PATTERN, isRegex: true, maxMatches: 200 }),
446
+ searchRepo({ query: SAM_CORS_PATTERN, isRegex: true, maxMatches: 200 }),
447
+ searchRepo({ query: BICEP_PATTERN_A, isRegex: true, maxMatches: 200 }),
448
+ searchRepo({ query: BICEP_PATTERN_B, isRegex: true, maxMatches: 200 }),
449
+ searchRepo({ query: BICEP_ROLE_PATTERN, isRegex: true, maxMatches: 200 }),
450
+ searchRepo({ query: TF_TFVARS_SECRET_PATTERN, isRegex: true, maxMatches: 200 }),
451
+ searchRepo({ query: TF_SENSITIVE_FALSE_PATTERN, isRegex: true, maxMatches: 200 }),
452
+ searchRepo({ query: TF_HTTP_DATA_PATTERN, isRegex: true, maxMatches: 200 }),
453
+ searchRepo({ query: TF_NULL_RESOURCE_PATTERN, isRegex: true, maxMatches: 200 }),
454
+ searchRepo({ query: TF_VAULT_TOKEN_PATTERN, isRegex: true, maxMatches: 200 }),
455
+ searchRepo({ query: TF_DEFAULT_VPC_PATTERN, isRegex: true, maxMatches: 200 }),
456
+ searchRepo({ query: TF_INSECURE_TLS_PATTERN, isRegex: true, maxMatches: 200 }),
457
+ ]);
458
+ // 11. CFN public S3 bucket.
459
+ if (cfnS3Public.length > 0) {
460
+ findings.push({
461
+ id: "IAC_CFN_S3_PUBLIC",
462
+ title: "CloudFormation S3 bucket disables Public Access Block or sets a public AccessControl/policy",
463
+ severity: "HIGH",
464
+ evidence: ev(cfnS3Public),
465
+ requiredActions: [
466
+ "Set every PublicAccessBlockConfiguration field (BlockPublicAcls, BlockPublicPolicy, IgnorePublicAcls, RestrictPublicBuckets) to true.",
467
+ "Remove AccessControl: PublicRead/PublicReadWrite and any bucket policy with Principal \"*\".",
468
+ "Front public assets with CloudFront + Origin Access Control instead of a public bucket.",
469
+ ],
470
+ });
471
+ }
472
+ // 12. CFN security group open to the internet.
473
+ if (cfnSgOpen.length > 0) {
474
+ findings.push({
475
+ id: "IAC_CFN_SG_OPEN_INGRESS",
476
+ title: "CloudFormation SecurityGroup ingress allows 0.0.0.0/0 or ::/0 — open to the entire internet",
477
+ severity: "HIGH",
478
+ evidence: ev(cfnSgOpen),
479
+ requiredActions: [
480
+ "Restrict CidrIp/CidrIpv6 to specific known CIDR ranges, never 0.0.0.0/0 or ::/0.",
481
+ "Use a bastion host or SSM Session Manager for admin access instead of open SSH/RDP ingress.",
482
+ "Reference security-group IDs as source instead of CIDRs for intra-VPC traffic.",
483
+ ],
484
+ });
485
+ }
486
+ // 13. CFN RDS/Redshift publicly accessible.
487
+ if (cfnDbPublic.length > 0) {
488
+ findings.push({
489
+ id: "IAC_CFN_DB_PUBLIC",
490
+ title: "CloudFormation RDS/Redshift instance set PubliclyAccessible: true — database reachable from the internet",
491
+ severity: "HIGH",
492
+ evidence: ev(cfnDbPublic),
493
+ requiredActions: [
494
+ "Set PubliclyAccessible: false on all DB and cluster resources.",
495
+ "Place databases in private subnets with no route to an internet gateway.",
496
+ "Restrict the DB security group to application subnets only.",
497
+ ],
498
+ });
499
+ }
500
+ // 14. CFN encryption disabled / missing.
501
+ if (cfnEncryption.length > 0) {
502
+ findings.push({
503
+ id: "IAC_CFN_ENCRYPTION_DISABLED",
504
+ title: "CloudFormation resource has encryption explicitly disabled or missing (StorageEncrypted/Encrypted/SSE false)",
505
+ severity: "HIGH",
506
+ evidence: ev(cfnEncryption),
507
+ requiredActions: [
508
+ "Set StorageEncrypted: true (RDS), Encrypted: true (EBS), and a BucketEncryption SSE rule (S3).",
509
+ "Specify a customer-managed KmsKeyId for regulated data instead of relying on defaults.",
510
+ "Enforce encryption org-wide with AWS Config rules / SCPs.",
511
+ ],
512
+ });
513
+ }
514
+ // 15. CFN secret Parameter without NoEcho.
515
+ if (cfnParamSecret.length > 0 && cfnNoEcho.length === 0) {
516
+ findings.push({
517
+ id: "IAC_CFN_PARAM_NO_NOECHO",
518
+ title: "CloudFormation Parameter carries a secret but no NoEcho: true — value leaks in console and describe-stacks",
519
+ severity: "MEDIUM",
520
+ evidence: ev(cfnParamSecret),
521
+ requiredActions: [
522
+ "Add NoEcho: true to every parameter that holds a password, secret, token, or API key.",
523
+ "Prefer resolving secrets at deploy time via dynamic references to Secrets Manager / SSM ('{{resolve:secretsmanager:...}}').",
524
+ "Never pass secrets as plaintext CLI parameter values that land in CloudTrail.",
525
+ ],
526
+ });
527
+ }
528
+ // 16. CFN inline secret literal.
529
+ if (cfnInlineSecret.length > 0) {
530
+ findings.push({
531
+ id: "IAC_CFN_INLINE_SECRET",
532
+ title: "CloudFormation template contains a hardcoded secret literal (MasterUserPassword / SecretString / Token)",
533
+ severity: "CRITICAL",
534
+ evidence: ev(cfnInlineSecret),
535
+ requiredActions: [
536
+ "Remove the literal and rotate the secret — assume compromise.",
537
+ "Use a dynamic reference '{{resolve:secretsmanager:MySecret}}' or a Secrets Manager generated secret.",
538
+ "Add a template secret scanner (cfn-lint + git secret scanning) to CI.",
539
+ ],
540
+ });
541
+ }
542
+ // 17. CFN IAM PassRole / iam:* wildcard.
543
+ if (cfnPassRole.length > 0) {
544
+ findings.push({
545
+ id: "IAC_CFN_IAM_PASSROLE_WILDCARD",
546
+ title: "CloudFormation IAM policy grants iam:PassRole or iam:* — privilege escalation to any role",
547
+ severity: "HIGH",
548
+ evidence: ev(cfnPassRole),
549
+ requiredActions: [
550
+ "Scope iam:PassRole to specific role ARNs with an iam:PassedToService condition.",
551
+ "Never grant iam:* — enumerate only the precise IAM actions needed.",
552
+ "Audit PassRole grants with IAM Access Analyzer for escalation paths.",
553
+ ],
554
+ });
555
+ }
556
+ // 18. CFN IAM::User with inline access key.
557
+ if (cfnIamUserKey.length > 0) {
558
+ findings.push({
559
+ id: "IAC_CFN_IAM_USER_ACCESS_KEY",
560
+ title: "CloudFormation provisions an AWS::IAM::AccessKey / long-lived IAM user key — static credentials in templates",
561
+ severity: "HIGH",
562
+ evidence: ev(cfnIamUserKey),
563
+ requiredActions: [
564
+ "Replace IAM users + access keys with IAM roles and STS short-lived credentials.",
565
+ "For workloads, use instance profiles / IRSA / Workload Identity instead of static keys.",
566
+ "If a key is unavoidable, store it in Secrets Manager and rotate automatically.",
567
+ ],
568
+ });
569
+ }
570
+ // 19. CFN Lambda public function URL.
571
+ if (cfnLambdaUrl.length > 0) {
572
+ findings.push({
573
+ id: "IAC_CFN_LAMBDA_URL_PUBLIC",
574
+ title: "CloudFormation Lambda FunctionUrlConfig AuthType: NONE — function publicly invocable by anyone",
575
+ severity: "HIGH",
576
+ evidence: ev(cfnLambdaUrl),
577
+ requiredActions: [
578
+ "Set AuthType: AWS_IAM on FunctionUrlConfig.",
579
+ "Front the function with API Gateway (IAM/Cognito) or CloudFront with signed URLs.",
580
+ "Add throttling and WAF if a public endpoint is genuinely required.",
581
+ ],
582
+ });
583
+ }
584
+ // 20. CFN SNS/SQS/Lambda resource policy Principal "*".
585
+ if (cfnResourcePrincipal.length > 0) {
586
+ findings.push({
587
+ id: "IAC_CFN_RESOURCE_POLICY_PUBLIC",
588
+ title: "CloudFormation resource policy uses Principal \"*\" — SNS/SQS/Lambda open to all AWS accounts",
589
+ severity: "HIGH",
590
+ evidence: ev(cfnResourcePrincipal),
591
+ requiredActions: [
592
+ "Replace Principal \"*\" with specific account IDs, service principals, or org-id conditions.",
593
+ "Add aws:SourceArn / aws:SourceAccount conditions to confused-deputy-prone policies.",
594
+ "Review topic/queue/function policies with IAM Access Analyzer for external exposure.",
595
+ ],
596
+ });
597
+ }
598
+ // 21. CFN CloudTrail not multi-region / no log validation.
599
+ if (cfnCloudtrail.length > 0) {
600
+ findings.push({
601
+ id: "IAC_CFN_CLOUDTRAIL_WEAK",
602
+ title: "CloudFormation CloudTrail is not multi-region or has log file validation disabled — audit gaps and tampering risk",
603
+ severity: "MEDIUM",
604
+ evidence: ev(cfnCloudtrail),
605
+ requiredActions: [
606
+ "Set IsMultiRegionTrail: true so actions in every region are captured.",
607
+ "Set EnableLogFileValidation: true to detect tampering of delivered logs.",
608
+ "Send logs to a dedicated cross-account bucket with MFA delete and Object Lock.",
609
+ ],
610
+ });
611
+ }
612
+ // 22. CFN untrusted nested-stack / cfn-init URL.
613
+ if (cfnUntrustedUrl.length > 0) {
614
+ findings.push({
615
+ id: "IAC_CFN_UNTRUSTED_TEMPLATE_URL",
616
+ title: "CloudFormation references a nested stack or cfn-init source over plaintext HTTP / untrusted URL — MITM and template tampering",
617
+ severity: "HIGH",
618
+ evidence: ev(cfnUntrustedUrl),
619
+ requiredActions: [
620
+ "Use HTTPS S3 URLs (https://...s3.amazonaws.com) for all TemplateURL and cfn-init sources.",
621
+ "Host nested templates in a controlled, access-restricted S3 bucket with bucket policy.",
622
+ "Verify artifact integrity (checksums / signed objects) before cfn-init fetches them.",
623
+ ],
624
+ });
625
+ }
626
+ // 23. CFN stateful resource without DeletionPolicy: Retain.
627
+ if (cfnStateful.length > 0 && cfnDeletionRetain.length === 0) {
628
+ findings.push({
629
+ id: "IAC_CFN_NO_DELETION_POLICY",
630
+ title: "CloudFormation stateful resource (RDS/DynamoDB/S3) has no DeletionPolicy: Retain — stack delete destroys data",
631
+ severity: "HIGH",
632
+ evidence: ev(cfnStateful),
633
+ requiredActions: [
634
+ "Add DeletionPolicy: Retain (and UpdateReplacePolicy: Retain) to RDS, DynamoDB, and S3 resources.",
635
+ "Enable termination protection on production stacks.",
636
+ "Take final snapshots / backups before any stack deletion.",
637
+ ],
638
+ });
639
+ }
640
+ // 24. CFN EC2 IMDSv1 still allowed.
641
+ if (cfnImds.length > 0) {
642
+ findings.push({
643
+ id: "IAC_CFN_IMDSV1_ALLOWED",
644
+ title: "CloudFormation EC2 MetadataOptions HttpTokens: optional — IMDSv1 reachable, SSRF can steal IAM credentials",
645
+ severity: "CRITICAL",
646
+ evidence: ev(cfnImds),
647
+ requiredActions: [
648
+ "Set HttpTokens: required in MetadataOptions to enforce IMDSv2.",
649
+ "Set HttpPutResponseHopLimit: 1 to block container-relayed metadata access.",
650
+ "Enforce IMDSv2 account-wide via EC2 default metadata options.",
651
+ ],
652
+ });
653
+ }
654
+ // 25. CDK escape hatch wildcard / removalPolicy DESTROY.
655
+ if (cdkHits.length > 0) {
656
+ findings.push({
657
+ id: "IAC_CDK_INSECURE_CONSTRUCT",
658
+ title: "AWS CDK grants wildcard IAM via escape hatch or sets RemovalPolicy.DESTROY on a data store",
659
+ severity: "HIGH",
660
+ evidence: ev(cdkHits),
661
+ requiredActions: [
662
+ "Replace actions/resources ['*'] in addToRolePolicy with explicit, scoped values.",
663
+ "Use RemovalPolicy.RETAIN (and removal protection) on stateful constructs (RDS, DynamoDB, S3).",
664
+ "Run cdk-nag in the pipeline to catch over-broad grants and destructive removal policies.",
665
+ ],
666
+ });
667
+ }
668
+ // 26. SAM Globals open CORS.
669
+ if (samCors.length > 0) {
670
+ findings.push({
671
+ id: "IAC_CDK_SAM_OPEN_CORS",
672
+ title: "SAM/CDK API exposes AllowOrigin \"*\" — wildcard CORS permits any site to call the API with credentials",
673
+ severity: "MEDIUM",
674
+ evidence: ev(samCors),
675
+ requiredActions: [
676
+ "Replace AllowOrigin '*' with an explicit allowlist of trusted origins.",
677
+ "Never combine wildcard AllowOrigin with AllowCredentials: true.",
678
+ "Define CORS per route and restrict allowed methods/headers.",
679
+ ],
680
+ });
681
+ }
682
+ // 27. Bicep/ARM public network + TLS + public blob.
683
+ const bicepNet = [...bicepA, ...bicepB];
684
+ if (bicepNet.length > 0) {
685
+ findings.push({
686
+ id: "IAC_BICEP_INSECURE_NETWORK",
687
+ title: "Bicep/ARM resource enables public network access, weak TLS, public blob access, or Allow-all network ACLs",
688
+ severity: "HIGH",
689
+ evidence: ev(bicepNet),
690
+ requiredActions: [
691
+ "Set publicNetworkAccess to 'Disabled' and use Private Endpoints + Private DNS.",
692
+ "Set supportsHttpsTrafficOnly: true and minimumTlsVersion: 'TLS1_2'.",
693
+ "Set allowBlobPublicAccess: false and networkAcls.defaultAction: 'Deny' with explicit allow rules.",
694
+ ],
695
+ });
696
+ }
697
+ // 28. Bicep/ARM Owner/Contributor role assignment.
698
+ if (bicepRole.length > 0) {
699
+ findings.push({
700
+ id: "IAC_BICEP_PRIVILEGED_ROLE",
701
+ title: "Bicep/ARM role assignment grants built-in Owner or Contributor — broad subscription/resource-group control",
702
+ severity: "HIGH",
703
+ evidence: ev(bicepRole),
704
+ requiredActions: [
705
+ "Replace Owner/Contributor with a least-privilege built-in or custom role scoped to the resource.",
706
+ "Scope role assignments to the narrowest resource scope, never the whole subscription.",
707
+ "Use PIM (Privileged Identity Management) for just-in-time elevation instead of standing Owner.",
708
+ ],
709
+ });
710
+ }
711
+ // 29. Terraform tfvars / auto.tfvars secrets.
712
+ if (tfvarsSecret.length > 0) {
713
+ findings.push({
714
+ id: "IAC_TF_TFVARS_SECRET",
715
+ title: "Hardcoded secret found in a Terraform variables file (.tfvars / .auto.tfvars)",
716
+ severity: "CRITICAL",
717
+ evidence: ev(tfvarsSecret),
718
+ requiredActions: [
719
+ "Remove the secret and rotate it; never commit .tfvars containing credentials.",
720
+ "Inject secret variables via TF_VAR_ environment variables or a secret manager data source.",
721
+ "Add *.tfvars (except example files) to .gitignore and scan history with gitleaks.",
722
+ ],
723
+ });
724
+ }
725
+ // 30. Terraform sensitive = false on secret variable.
726
+ if (tfSensitiveFalse.length > 0) {
727
+ findings.push({
728
+ id: "IAC_TF_SENSITIVE_FALSE",
729
+ title: "Terraform variable/output explicitly sets sensitive = false — value rendered in plan output and logs",
730
+ severity: "MEDIUM",
731
+ evidence: ev(tfSensitiveFalse),
732
+ requiredActions: [
733
+ "Set sensitive = true on any variable or output that holds a credential or PII.",
734
+ "Avoid sensitive = false on secret-bearing values — it overrides Terraform's redaction.",
735
+ "Scrub CI logs that may already contain the rendered value.",
736
+ ],
737
+ });
738
+ }
739
+ // 31. Terraform http data source / remote state over http (plaintext).
740
+ if (tfHttpData.length > 0) {
741
+ findings.push({
742
+ id: "IAC_TF_HTTP_PLAINTEXT",
743
+ title: "Terraform uses an http data source or remote state over plaintext HTTP — MITM and data tampering",
744
+ severity: "HIGH",
745
+ evidence: ev(tfHttpData),
746
+ requiredActions: [
747
+ "Use HTTPS endpoints for the http data source and terraform_remote_state backends.",
748
+ "Validate fetched content (checksums) before consuming it in resource arguments.",
749
+ "Prefer a native data source over fetching arbitrary URLs at plan time.",
750
+ ],
751
+ });
752
+ }
753
+ // 32. Terraform null_resource + local-exec.
754
+ if (tfNullResource.length > 0) {
755
+ findings.push({
756
+ id: "IAC_TF_NULL_RESOURCE_EXEC",
757
+ title: "Terraform null_resource detected — typically wraps local-exec, an arbitrary-command RCE surface during apply",
758
+ severity: "MEDIUM",
759
+ evidence: ev(tfNullResource),
760
+ requiredActions: [
761
+ "Avoid null_resource + local-exec for provisioning; use a proper provider or config-management tool.",
762
+ "If retained, never interpolate untrusted variables into the executed command.",
763
+ "Run apply only from a hardened CI runner with scoped, short-lived credentials.",
764
+ ],
765
+ });
766
+ }
767
+ // 33. Terraform vault provider with inline token.
768
+ if (tfVaultToken.length > 0) {
769
+ findings.push({
770
+ id: "IAC_TF_VAULT_TOKEN_INLINE",
771
+ title: "Terraform Vault provider configured with an inline token — long-lived root/admin token in source",
772
+ severity: "HIGH",
773
+ evidence: ev(tfVaultToken),
774
+ requiredActions: [
775
+ "Never set the Vault token inline; source it from VAULT_TOKEN env or an auth method (AppRole, OIDC, AWS).",
776
+ "Use short-lived, least-privilege Vault tokens issued per run, not a static root token.",
777
+ "Rotate any committed Vault token immediately and revoke it.",
778
+ ],
779
+ });
780
+ }
781
+ // 34. Terraform default VPC / default security group usage.
782
+ if (tfDefaultVpc.length > 0) {
783
+ findings.push({
784
+ id: "IAC_TF_DEFAULT_VPC",
785
+ title: "Terraform manages the AWS default VPC / default security group / default subnet — insecure permissive defaults",
786
+ severity: "MEDIUM",
787
+ evidence: ev(tfDefaultVpc),
788
+ requiredActions: [
789
+ "Provision purpose-built VPCs, subnets, and security groups instead of adopting AWS defaults.",
790
+ "The default security group allows all intra-group traffic — define explicit, scoped rules.",
791
+ "Restrict or delete the default VPC to prevent accidental public deployments.",
792
+ ],
793
+ });
794
+ }
795
+ // 35. Terraform allow_unverified_ssl / insecure / skip_tls_verify.
796
+ if (tfInsecureTls.length > 0) {
797
+ findings.push({
798
+ id: "IAC_TF_INSECURE_TLS",
799
+ title: "Terraform provider disables TLS verification (insecure / allow_unverified_ssl / skip_tls_verify = true)",
800
+ severity: "HIGH",
801
+ evidence: ev(tfInsecureTls),
802
+ requiredActions: [
803
+ "Remove insecure = true / allow_unverified_ssl = true / skip_tls_verify = true from provider configs.",
804
+ "Trust the proper CA bundle instead of disabling certificate verification.",
805
+ "If using a private CA, distribute its root cert rather than turning off verification.",
806
+ ],
807
+ });
808
+ }
809
+ // -------------------------------------------------------------------------
810
+ // Round 3: extra-deep Terraform checks. Each requiredActions entry is a
811
+ // copy-pasteable corrected HCL block plus a verify command.
812
+ // -------------------------------------------------------------------------
813
+ const [tfProviderCreds, tfBackendKms, tfBackendHttp, tfModuleGitHttp, tfReqVersionOpen, tfS3Bucket, tfS3Sse, tfS3Pab, tfRdsHardening, tfSgOpenCidr, tfIamWildcardHcl, tfImdsHcl, tfEksEcrPublic, tfKmsRotation, tfIamAccessKey, tfCloudtrailValidation, tfVolumeBlocks, tfVolumeEncFalse, tfVarDefaultSecret, tfUserdataSecret, tfIgnoreAll, tfPreventDestroyFalse, tfCbd, tfAutoApprove,] = await Promise.all([
814
+ searchRepo({ query: TF_PROVIDER_CREDS_PATTERN, isRegex: true, maxMatches: 200 }),
815
+ searchRepo({ query: TF_BACKEND_KMS_PATTERN, isRegex: true, maxMatches: 200 }),
816
+ searchRepo({ query: TF_BACKEND_HTTP_PATTERN, isRegex: true, maxMatches: 200 }),
817
+ searchRepo({ query: TF_MODULE_GIT_HTTP_PATTERN, isRegex: true, maxMatches: 200 }),
818
+ searchRepo({ query: TF_REQUIRED_VERSION_OPEN_PATTERN, isRegex: true, maxMatches: 200 }),
819
+ searchRepo({ query: TF_S3_BUCKET_PATTERN, isRegex: true, maxMatches: 200 }),
820
+ searchRepo({ query: TF_S3_SSE_PATTERN, isRegex: true, maxMatches: 200 }),
821
+ searchRepo({ query: TF_S3_PAB_PATTERN, isRegex: true, maxMatches: 200 }),
822
+ searchRepo({ query: TF_RDS_HARDENING_PATTERN, isRegex: true, maxMatches: 200 }),
823
+ searchRepo({ query: TF_SG_OPEN_CIDR_PATTERN, isRegex: true, maxMatches: 200 }),
824
+ searchRepo({ query: TF_IAM_WILDCARD_HCL_PATTERN, isRegex: true, maxMatches: 200 }),
825
+ searchRepo({ query: TF_IMDS_HCL_PATTERN, isRegex: true, maxMatches: 200 }),
826
+ searchRepo({ query: TF_EKS_ECR_PUBLIC_PATTERN, isRegex: true, maxMatches: 200 }),
827
+ searchRepo({ query: TF_KMS_ROTATION_PATTERN, isRegex: true, maxMatches: 200 }),
828
+ searchRepo({ query: TF_IAM_ACCESS_KEY_PATTERN, isRegex: true, maxMatches: 200 }),
829
+ searchRepo({ query: TF_CLOUDTRAIL_VALIDATION_PATTERN, isRegex: true, maxMatches: 200 }),
830
+ searchRepo({ query: TF_VOLUME_UNENCRYPTED_PATTERN, isRegex: true, maxMatches: 200 }),
831
+ searchRepo({ query: TF_VOLUME_ENC_FALSE_PATTERN, isRegex: true, maxMatches: 200 }),
832
+ searchRepo({ query: TF_VAR_DEFAULT_SECRET_PATTERN, isRegex: true, maxMatches: 200 }),
833
+ searchRepo({ query: TF_USERDATA_SECRET_PATTERN, isRegex: true, maxMatches: 200 }),
834
+ searchRepo({ query: TF_IGNORE_ALL_PATTERN, isRegex: true, maxMatches: 200 }),
835
+ searchRepo({ query: TF_PREVENT_DESTROY_FALSE_PATTERN, isRegex: true, maxMatches: 200 }),
836
+ searchRepo({ query: TF_CBD_PATTERN, isRegex: true, maxMatches: 200 }),
837
+ searchRepo({ query: TF_AUTO_APPROVE_PATTERN, isRegex: true, maxMatches: 200 }),
838
+ ]);
839
+ // 36. Provider auth: hardcoded creds / committed credential file / inline key.
840
+ if (tfProviderCreds.length > 0) {
841
+ findings.push({
842
+ id: "IAC_TF_PROVIDER_HARDCODED_CREDS",
843
+ title: "Terraform provider authenticates with hardcoded credentials, a committed credentials file, or an inline key",
844
+ severity: "CRITICAL",
845
+ evidence: ev(tfProviderCreds),
846
+ requiredActions: [
847
+ "Remove the inline credential and rotate it immediately — treat it as compromised.",
848
+ "AWS: authenticate via the default chain / OIDC, never inline keys:",
849
+ " provider \"aws\" { region = var.region } # creds via env, SSO, or IRSA/OIDC",
850
+ "GCP: use Workload Identity Federation instead of a committed JSON key:",
851
+ " provider \"google\" { project = var.project } # GOOGLE_APPLICATION_CREDENTIALS via WIF",
852
+ "azurerm: source client_secret from a Key Vault data source or OIDC, never a literal:",
853
+ " provider \"azurerm\" { features {}; use_oidc = true }",
854
+ "Add the key path to .gitignore, purge it from history (git filter-repo), then verify: trivy config . ; checkov -d . --check CKV_SECRET_6",
855
+ ],
856
+ });
857
+ }
858
+ // 37. S3 backend without KMS key.
859
+ if (backendS3.length > 0 && tfBackendKms.length === 0) {
860
+ findings.push({
861
+ id: "IAC_TF_BACKEND_NO_KMS",
862
+ title: "Terraform S3 backend has no kms_key_id — state (plaintext secrets) is not encrypted with a customer-managed key",
863
+ severity: "MEDIUM",
864
+ evidence: ev(backendS3),
865
+ requiredActions: [
866
+ "Encrypt remote state with a customer-managed KMS key:",
867
+ " terraform {",
868
+ " backend \"s3\" {",
869
+ " bucket = \"my-tfstate\"",
870
+ " key = \"prod/terraform.tfstate\"",
871
+ " region = \"us-east-1\"",
872
+ " encrypt = true",
873
+ " kms_key_id = \"arn:aws:kms:us-east-1:111122223333:key/abcd-...\"",
874
+ " dynamodb_table = \"tf-locks\"",
875
+ " }",
876
+ " }",
877
+ "Verify: terraform init -reconfigure && aws s3api get-bucket-encryption --bucket my-tfstate",
878
+ ],
879
+ });
880
+ }
881
+ // 38. http backend over plaintext.
882
+ if (tfBackendHttp.length > 0) {
883
+ findings.push({
884
+ id: "IAC_TF_BACKEND_HTTP",
885
+ title: "Terraform uses the http backend — state transferred over an unauthenticated/plaintext channel risks MITM",
886
+ severity: "HIGH",
887
+ evidence: ev(tfBackendHttp),
888
+ requiredActions: [
889
+ "Replace the http backend with S3+DynamoDB, GCS, or Terraform Cloud:",
890
+ " terraform {",
891
+ " backend \"s3\" {",
892
+ " bucket = \"my-tfstate\"; key = \"prod.tfstate\"; region = \"us-east-1\"",
893
+ " encrypt = true; dynamodb_table = \"tf-locks\"",
894
+ " }",
895
+ " }",
896
+ "If the http backend is mandatory, require HTTPS and lock/unlock addresses with auth, never plain http://.",
897
+ "Verify: terraform init -reconfigure",
898
+ ],
899
+ });
900
+ }
901
+ // 39. Module supply chain: git over http / branch ref.
902
+ if (tfModuleGitHttp.length > 0) {
903
+ findings.push({
904
+ id: "IAC_TF_MODULE_GIT_HTTP",
905
+ title: "Terraform module source uses plaintext git::http:// (or http://) — module code can be tampered with in transit",
906
+ severity: "HIGH",
907
+ evidence: ev(tfModuleGitHttp),
908
+ requiredActions: [
909
+ "Use https or ssh and pin to an immutable tag or commit SHA:",
910
+ " module \"vpc\" {",
911
+ " source = \"git::https://github.com/org/tf-vpc.git//modules/vpc?ref=v3.2.1\"",
912
+ " }",
913
+ "For registry modules add an exact version: version = \"5.1.0\"",
914
+ "Verify the pin took effect: terraform init && terraform get && terraform providers lock",
915
+ ],
916
+ });
917
+ }
918
+ // 40. required_version unbounded (LOW).
919
+ if (tfReqVersionOpen.length > 0) {
920
+ findings.push({
921
+ id: "IAC_TF_REQUIRED_VERSION_UNPINNED",
922
+ title: "Terraform required_version uses an open >= constraint with no upper bound — unexpected CLI upgrades can break or alter behavior",
923
+ severity: "LOW",
924
+ evidence: ev(tfReqVersionOpen),
925
+ requiredActions: [
926
+ "Pin the Terraform CLI to a bounded range:",
927
+ " terraform { required_version = \"~> 1.7.0\" } # or \">= 1.7.0, < 1.8.0\"",
928
+ "Pin providers too, e.g.:",
929
+ " required_providers { aws = { source = \"hashicorp/aws\", version = \"~> 5.40\" } }",
930
+ "Commit .terraform.lock.hcl and verify: terraform version && terraform providers lock",
931
+ ],
932
+ });
933
+ }
934
+ // 41. S3 bucket without SSE / public access block.
935
+ if (tfS3Bucket.length > 0 && (tfS3Sse.length === 0 || tfS3Pab.length === 0)) {
936
+ findings.push({
937
+ id: "IAC_TF_S3_MISSING_HARDENING",
938
+ title: "aws_s3_bucket has no server-side encryption and/or no public access block resource — data may be unencrypted or publicly exposable",
939
+ severity: "HIGH",
940
+ evidence: ev(tfS3Bucket),
941
+ requiredActions: [
942
+ "Add a server-side encryption configuration:",
943
+ " resource \"aws_s3_bucket_server_side_encryption_configuration\" \"this\" {",
944
+ " bucket = aws_s3_bucket.this.id",
945
+ " rule { apply_server_side_encryption_by_default { sse_algorithm = \"aws:kms\" } }",
946
+ " }",
947
+ "Add a public access block:",
948
+ " resource \"aws_s3_bucket_public_access_block\" \"this\" {",
949
+ " bucket = aws_s3_bucket.this.id",
950
+ " block_public_acls = true",
951
+ " block_public_policy = true",
952
+ " ignore_public_acls = true",
953
+ " restrict_public_buckets = true",
954
+ " }",
955
+ "Verify: checkov -d . --check CKV2_AWS_6,CKV_AWS_19 ; trivy config .",
956
+ ],
957
+ });
958
+ }
959
+ // 42. RDS hardening: storage_encrypted false / IAM auth disabled.
960
+ if (tfRdsHardening.length > 0) {
961
+ findings.push({
962
+ id: "IAC_TF_RDS_WEAK_HARDENING",
963
+ title: "aws_db_instance has storage_encrypted = false or iam_database_authentication_enabled = false",
964
+ severity: "HIGH",
965
+ evidence: ev(tfRdsHardening),
966
+ requiredActions: [
967
+ "Harden the DB instance:",
968
+ " resource \"aws_db_instance\" \"db\" {",
969
+ " storage_encrypted = true",
970
+ " kms_key_id = aws_kms_key.rds.arn",
971
+ " iam_database_authentication_enabled = true",
972
+ " publicly_accessible = false",
973
+ " deletion_protection = true",
974
+ " }",
975
+ "Verify: checkov -d . --check CKV_AWS_16,CKV_AWS_161 ; terraform plan",
976
+ ],
977
+ });
978
+ }
979
+ // 43. Security group open to 0.0.0.0/0.
980
+ if (tfSgOpenCidr.length > 0) {
981
+ findings.push({
982
+ id: "IAC_TF_SG_OPEN_WORLD",
983
+ title: "Security group rule allows 0.0.0.0/0 (or ::/0) — open to the entire internet, typically on SSH/RDP/all ports",
984
+ severity: "HIGH",
985
+ evidence: ev(tfSgOpenCidr),
986
+ requiredActions: [
987
+ "Restrict ingress to known CIDRs (or reference a source SG):",
988
+ " resource \"aws_security_group_rule\" \"ssh\" {",
989
+ " type = \"ingress\"",
990
+ " from_port = 22",
991
+ " to_port = 22",
992
+ " protocol = \"tcp\"",
993
+ " cidr_blocks = [var.admin_cidr] # never [\"0.0.0.0/0\"]",
994
+ " security_group_id = aws_security_group.app.id",
995
+ " }",
996
+ "Prefer SSM Session Manager over open SSH. Verify: checkov -d . --check CKV_AWS_24,CKV_AWS_260",
997
+ ],
998
+ });
999
+ }
1000
+ // 44. IAM HCL wildcard / AssumeRole Principal "*".
1001
+ if (tfIamWildcardHcl.length > 0) {
1002
+ findings.push({
1003
+ id: "IAC_TF_IAM_WILDCARD_HCL",
1004
+ title: "Terraform IAM policy/document uses a wildcard action, resource, or Principal \"*\" — least-privilege violated",
1005
+ severity: "HIGH",
1006
+ evidence: ev(tfIamWildcardHcl),
1007
+ requiredActions: [
1008
+ "Enumerate explicit actions/resources in the policy document:",
1009
+ " data \"aws_iam_policy_document\" \"app\" {",
1010
+ " statement {",
1011
+ " actions = [\"s3:GetObject\", \"s3:PutObject\"]",
1012
+ " resources = [\"${aws_s3_bucket.app.arn}/*\"]",
1013
+ " }",
1014
+ " }",
1015
+ "For trust policies, scope the principal to a specific ARN — never identifiers = [\"*\"].",
1016
+ "Verify with IAM Access Analyzer and: checkov -d . --check CKV_AWS_1,CKV_AWS_111",
1017
+ ],
1018
+ });
1019
+ }
1020
+ // 45. EC2 IMDSv1 (http_tokens optional).
1021
+ if (tfImdsHcl.length > 0) {
1022
+ findings.push({
1023
+ id: "IAC_TF_IMDSV1_OPTIONAL",
1024
+ title: "aws_instance metadata_options sets http_tokens = \"optional\" — IMDSv1 reachable, SSRF can steal IAM credentials",
1025
+ severity: "CRITICAL",
1026
+ evidence: ev(tfImdsHcl),
1027
+ requiredActions: [
1028
+ "Enforce IMDSv2 on the instance / launch template:",
1029
+ " metadata_options {",
1030
+ " http_endpoint = \"enabled\"",
1031
+ " http_tokens = \"required\"",
1032
+ " http_put_response_hop_limit = 1",
1033
+ " }",
1034
+ "Verify: checkov -d . --check CKV_AWS_79 ; aws ec2 describe-instances --query 'Reservations[].Instances[].MetadataOptions'",
1035
+ ],
1036
+ });
1037
+ }
1038
+ // 46. EKS/ECR public.
1039
+ if (tfEksEcrPublic.length > 0) {
1040
+ findings.push({
1041
+ id: "IAC_TF_EKS_ECR_PUBLIC",
1042
+ title: "EKS public endpoint enabled, public ECR repository, or mutable image tags — control-plane/registry exposed or tamperable",
1043
+ severity: "HIGH",
1044
+ evidence: ev(tfEksEcrPublic),
1045
+ requiredActions: [
1046
+ "Lock down the EKS API endpoint:",
1047
+ " vpc_config {",
1048
+ " endpoint_public_access = false",
1049
+ " endpoint_private_access = true",
1050
+ " }",
1051
+ "Make ECR tags immutable and scan on push:",
1052
+ " resource \"aws_ecr_repository\" \"app\" {",
1053
+ " image_tag_mutability = \"IMMUTABLE\"",
1054
+ " image_scanning_configuration { scan_on_push = true }",
1055
+ " }",
1056
+ "Verify: checkov -d . --check CKV_AWS_39,CKV_AWS_51",
1057
+ ],
1058
+ });
1059
+ }
1060
+ // 47. KMS key rotation disabled.
1061
+ if (tfKmsRotation.length > 0) {
1062
+ findings.push({
1063
+ id: "IAC_TF_KMS_NO_ROTATION",
1064
+ title: "aws_kms_key sets enable_key_rotation = false — keys are never rotated, increasing blast radius of a key compromise",
1065
+ severity: "MEDIUM",
1066
+ evidence: ev(tfKmsRotation),
1067
+ requiredActions: [
1068
+ "Enable automatic annual key rotation:",
1069
+ " resource \"aws_kms_key\" \"this\" {",
1070
+ " description = \"app data key\"",
1071
+ " enable_key_rotation = true",
1072
+ " }",
1073
+ "Verify: checkov -d . --check CKV_AWS_7 ; aws kms get-key-rotation-status --key-id <id>",
1074
+ ],
1075
+ });
1076
+ }
1077
+ // 48. Long-lived IAM access key resource.
1078
+ if (tfIamAccessKey.length > 0) {
1079
+ findings.push({
1080
+ id: "IAC_TF_IAM_ACCESS_KEY_RESOURCE",
1081
+ title: "aws_iam_access_key resource provisions long-lived static credentials — prefer short-lived STS/role-based auth",
1082
+ severity: "HIGH",
1083
+ evidence: ev(tfIamAccessKey),
1084
+ requiredActions: [
1085
+ "Replace static user keys with an assumable role:",
1086
+ " resource \"aws_iam_role\" \"app\" {",
1087
+ " assume_role_policy = data.aws_iam_policy_document.trust.json",
1088
+ " }",
1089
+ "For workloads use IRSA / instance profiles / OIDC instead of aws_iam_access_key.",
1090
+ "If a key is unavoidable, store it in Secrets Manager and rotate on a schedule.",
1091
+ "Verify: checkov -d . --check CKV_AWS_273 ; aws iam list-access-keys --user-name <user>",
1092
+ ],
1093
+ });
1094
+ }
1095
+ // 49. CloudTrail log file validation disabled.
1096
+ if (tfCloudtrailValidation.length > 0) {
1097
+ findings.push({
1098
+ id: "IAC_TF_CLOUDTRAIL_NO_VALIDATION",
1099
+ title: "aws_cloudtrail sets enable_log_file_validation = false — delivered logs can be tampered without detection",
1100
+ severity: "MEDIUM",
1101
+ evidence: ev(tfCloudtrailValidation),
1102
+ requiredActions: [
1103
+ "Enable log file validation:",
1104
+ " resource \"aws_cloudtrail\" \"main\" {",
1105
+ " enable_log_file_validation = true",
1106
+ " is_multi_region_trail = true",
1107
+ " kms_key_id = aws_kms_key.trail.arn",
1108
+ " }",
1109
+ "Verify: checkov -d . --check CKV_AWS_36 ; aws cloudtrail validate-logs --trail-arn <arn> --start-time <t>",
1110
+ ],
1111
+ });
1112
+ }
1113
+ // 50. Root/EBS volume encrypted = false (only when a volume block is present).
1114
+ if (tfVolumeBlocks.length > 0 && tfVolumeEncFalse.length > 0) {
1115
+ findings.push({
1116
+ id: "IAC_TF_VOLUME_UNENCRYPTED",
1117
+ title: "Root or EBS block device sets encrypted = false — instance storage holds data at rest unencrypted",
1118
+ severity: "HIGH",
1119
+ evidence: ev(tfVolumeEncFalse),
1120
+ requiredActions: [
1121
+ "Encrypt every block device:",
1122
+ " root_block_device {",
1123
+ " encrypted = true",
1124
+ " kms_key_id = aws_kms_key.ebs.arn",
1125
+ " }",
1126
+ "Enable account-wide EBS encryption by default: aws ec2 enable-ebs-encryption-by-default",
1127
+ "Verify: checkov -d . --check CKV_AWS_8 ; trivy config .",
1128
+ ],
1129
+ });
1130
+ }
1131
+ // 51. Variable default that is a real-looking secret.
1132
+ if (tfVarDefaultSecret.length > 0) {
1133
+ findings.push({
1134
+ id: "IAC_TF_VAR_DEFAULT_SECRET",
1135
+ title: "Terraform variable default contains a real-looking secret (AWS key / GitHub PAT / OpenAI key / PEM)",
1136
+ severity: "CRITICAL",
1137
+ evidence: ev(tfVarDefaultSecret),
1138
+ requiredActions: [
1139
+ "Remove the default and rotate the secret immediately.",
1140
+ "Declare the variable without a default and inject it at runtime:",
1141
+ " variable \"db_password\" { type = string; sensitive = true } # no default",
1142
+ " # supplied via TF_VAR_db_password or a secret manager data source",
1143
+ "Or read it from the secret manager:",
1144
+ " data \"aws_secretsmanager_secret_version\" \"db\" { secret_id = \"prod/db\" }",
1145
+ "Purge from history (git filter-repo) and verify: gitleaks detect ; checkov -d . --check CKV_SECRET_6",
1146
+ ],
1147
+ });
1148
+ }
1149
+ // 52. user_data / templatefile embedding credentials.
1150
+ if (tfUserdataSecret.length > 0) {
1151
+ findings.push({
1152
+ id: "IAC_TF_USERDATA_SECRET",
1153
+ title: "Credentials embedded in user_data / templatefile / cloud-init — secrets land in EC2 metadata and the state file",
1154
+ severity: "HIGH",
1155
+ evidence: ev(tfUserdataSecret),
1156
+ requiredActions: [
1157
+ "Never bake secrets into user_data — fetch them at boot from a secret manager:",
1158
+ " # in cloud-init:",
1159
+ " aws secretsmanager get-secret-value --secret-id prod/app --query SecretString --output text",
1160
+ "Grant the instance role only secretsmanager:GetSecretValue on that one secret ARN.",
1161
+ "Mark any user_data variables sensitive = true so they are redacted in plan output.",
1162
+ "Verify: checkov -d . --check CKV_AWS_46 ; terraform plan -no-color | grep -i password",
1163
+ ],
1164
+ });
1165
+ }
1166
+ // 53. lifecycle ignore_changes = all.
1167
+ if (tfIgnoreAll.length > 0) {
1168
+ findings.push({
1169
+ id: "IAC_TF_IGNORE_CHANGES_ALL",
1170
+ title: "lifecycle { ignore_changes = all } masks configuration drift — tampering with the live resource goes undetected by Terraform",
1171
+ severity: "MEDIUM",
1172
+ evidence: ev(tfIgnoreAll),
1173
+ requiredActions: [
1174
+ "Scope ignore_changes to the specific attributes that legitimately drift, never `all`:",
1175
+ " lifecycle {",
1176
+ " ignore_changes = [tags[\"LastScanned\"]] # explicit, minimal list",
1177
+ " }",
1178
+ "Run drift detection on a schedule: terraform plan -detailed-exitcode (exit 2 = drift).",
1179
+ ],
1180
+ });
1181
+ }
1182
+ // 54. prevent_destroy = false on a lifecycle block.
1183
+ if (tfPreventDestroyFalse.length > 0) {
1184
+ findings.push({
1185
+ id: "IAC_TF_PREVENT_DESTROY_FALSE",
1186
+ title: "lifecycle { prevent_destroy = false } explicitly allows destruction of a (likely stateful) resource",
1187
+ severity: "MEDIUM",
1188
+ evidence: ev(tfPreventDestroyFalse),
1189
+ requiredActions: [
1190
+ "Protect stateful resources from accidental destroy:",
1191
+ " lifecycle { prevent_destroy = true }",
1192
+ "Pair with provider-level guards (deletion_protection = true, skip_final_snapshot = false).",
1193
+ "Verify a destroy is blocked: terraform plan -destroy (should error on the protected resource).",
1194
+ ],
1195
+ });
1196
+ }
1197
+ // 55. create_before_destroy on a security group.
1198
+ if (tfCbd.length > 0) {
1199
+ findings.push({
1200
+ id: "IAC_TF_CBD_SECURITY_GROUP",
1201
+ title: "create_before_destroy on a security group can transiently widen exposure during replacement",
1202
+ severity: "LOW",
1203
+ evidence: ev(tfCbd),
1204
+ requiredActions: [
1205
+ "Audit create_before_destroy on aws_security_group — during replacement both the old and new SG exist briefly.",
1206
+ "Manage rules as separate aws_security_group_rule / aws_vpc_security_group_ingress_rule resources so the group itself is not replaced:",
1207
+ " resource \"aws_vpc_security_group_ingress_rule\" \"https\" { ... }",
1208
+ "Verify no unintended replacement: terraform plan (look for -/+ on the security group).",
1209
+ ],
1210
+ });
1211
+ }
1212
+ // 56. Committed wrapper scripts using -auto-approve / -target.
1213
+ if (tfAutoApprove.length > 0) {
1214
+ findings.push({
1215
+ id: "IAC_TF_AUTO_APPROVE_SCRIPT",
1216
+ title: "Committed script runs terraform apply/destroy with -auto-approve — unreviewed, non-interactive changes to infrastructure",
1217
+ severity: "MEDIUM",
1218
+ evidence: ev(tfAutoApprove),
1219
+ requiredActions: [
1220
+ "Require a reviewed plan artifact before applying, instead of blind -auto-approve:",
1221
+ " terraform plan -out=tfplan",
1222
+ " # human/PR review of tfplan, then:",
1223
+ " terraform apply tfplan",
1224
+ "Gate apply behind CI approval (environments/required reviewers); restrict who can run destroy.",
1225
+ "Avoid broad -target in committed scripts — it produces partial, drift-prone applies.",
1226
+ ],
1227
+ });
1228
+ }
1229
+ return findings;
1230
+ }