cloud-audit 2.2.0__tar.gz → 2.2.1__tar.gz

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 (162) hide show
  1. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/CHANGELOG.md +41 -0
  2. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/PKG-INFO +1 -1
  3. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/pyproject.toml +1 -1
  4. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/ses_phishing.py +57 -46
  5. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/trufflehog_ua.py +30 -15
  6. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/test_ses_phishing.py +96 -32
  7. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/test_trufflehog_ua.py +18 -0
  8. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.cloud-audit.example.yml +0 -0
  9. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.github/FUNDING.yml +0 -0
  10. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  11. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  12. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  13. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.github/dependabot.yml +0 -0
  14. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.github/workflows/ci.yml +0 -0
  15. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.github/workflows/docs.yml +0 -0
  16. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.github/workflows/example-scan.yml +0 -0
  17. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.github/workflows/release.yml +0 -0
  18. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.gitignore +0 -0
  19. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.mcp.json +0 -0
  20. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/.pre-commit-hooks.yaml +0 -0
  21. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/CODEOWNERS +0 -0
  22. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/CODE_OF_CONDUCT.md +0 -0
  23. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/CONTRIBUTING.md +0 -0
  24. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/Dockerfile +0 -0
  25. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/LICENSE +0 -0
  26. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/Makefile +0 -0
  27. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/README.md +0 -0
  28. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/ROADMAP.md +0 -0
  29. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/SECURITY.md +0 -0
  30. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/action.yml +0 -0
  31. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/assets/demo.gif +0 -0
  32. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/assets/logo-nobg.png +0 -0
  33. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/assets/logo.png +0 -0
  34. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/assets/report-preview.png +0 -0
  35. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/assets/social-preview.png +0 -0
  36. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/examples/daily-scan-with-diff.yml +0 -0
  37. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/examples/github-actions.yml +0 -0
  38. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/examples/post-deploy-scan.yml +0 -0
  39. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/mkdocs.yml +0 -0
  40. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/overrides/main.html +0 -0
  41. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/scripts/generate_demo_gif.py +0 -0
  42. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/scripts/generate_report_screenshot.py +0 -0
  43. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/server.json +0 -0
  44. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/__init__.py +0 -0
  45. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/__main__.py +0 -0
  46. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/cli.py +0 -0
  47. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/compliance/__init__.py +0 -0
  48. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/compliance/engine.py +0 -0
  49. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/compliance/frameworks/bsi_c5_2020.json +0 -0
  50. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/compliance/frameworks/cis_aws_v3.json +0 -0
  51. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/compliance/frameworks/hipaa_security.json +0 -0
  52. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/compliance/frameworks/iso27001_2022.json +0 -0
  53. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/compliance/frameworks/nis2_directive.json +0 -0
  54. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/compliance/frameworks/soc2_type2.json +0 -0
  55. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/config.py +0 -0
  56. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/correlate.py +0 -0
  57. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/cost_model.py +0 -0
  58. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/diff.py +0 -0
  59. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/history.py +0 -0
  60. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/mcp_server.py +0 -0
  61. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/models.py +0 -0
  62. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/__init__.py +0 -0
  63. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/__init__.py +0 -0
  64. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/__init__.py +0 -0
  65. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/account.py +0 -0
  66. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/backup.py +0 -0
  67. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/bedrock.py +0 -0
  68. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/cloudtrail.py +0 -0
  69. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/cloudwatch.py +0 -0
  70. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/config_.py +0 -0
  71. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/ec2.py +0 -0
  72. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/ecs.py +0 -0
  73. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/efs.py +0 -0
  74. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/eip.py +0 -0
  75. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/guardduty.py +0 -0
  76. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/iam.py +0 -0
  77. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/inspector.py +0 -0
  78. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/kms.py +0 -0
  79. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/lambda_.py +0 -0
  80. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/rds.py +0 -0
  81. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/s3.py +0 -0
  82. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/sagemaker.py +0 -0
  83. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/secrets.py +0 -0
  84. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/securityhub.py +0 -0
  85. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/ssm.py +0 -0
  86. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/vpc.py +0 -0
  87. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/checks/waf.py +0 -0
  88. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/iam_analyzer.py +0 -0
  89. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/iam_trust_graph.py +0 -0
  90. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/provider.py +0 -0
  91. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/__init__.py +0 -0
  92. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/cloudtrail_tampering.py +0 -0
  93. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/cryptomining_role.py +0 -0
  94. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/datazone_overgrant.py +0 -0
  95. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/lambda_function_url.py +0 -0
  96. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/mmdsv1_in_use.py +0 -0
  97. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/quarantine_policy.py +0 -0
  98. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/roles_anywhere_abuse.py +0 -0
  99. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/aws/threat_feed/whoami_confusion.py +0 -0
  100. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/providers/base.py +0 -0
  101. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/py.typed +0 -0
  102. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/reports/__init__.py +0 -0
  103. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/reports/compliance_html.py +0 -0
  104. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/reports/compliance_markdown.py +0 -0
  105. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/reports/diff_markdown.py +0 -0
  106. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/reports/html.py +0 -0
  107. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/reports/markdown.py +0 -0
  108. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/reports/sarif.py +0 -0
  109. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/reports/templates/report.html.j2 +0 -0
  110. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/root_cause.py +0 -0
  111. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/scanner.py +0 -0
  112. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/src/cloud_audit/simulate.py +0 -0
  113. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/__init__.py +0 -0
  114. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/__init__.py +0 -0
  115. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_bedrock.py +0 -0
  116. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_cis_checks.py +0 -0
  117. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_cloudtrail.py +0 -0
  118. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_cloudwatch.py +0 -0
  119. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_config.py +0 -0
  120. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_ec2.py +0 -0
  121. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_ecs.py +0 -0
  122. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_eip.py +0 -0
  123. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_guardduty.py +0 -0
  124. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_iam.py +0 -0
  125. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_iam_analyzer.py +0 -0
  126. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_iam_trust_graph.py +0 -0
  127. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_kms.py +0 -0
  128. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_lambda.py +0 -0
  129. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_rds.py +0 -0
  130. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_s3.py +0 -0
  131. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_sagemaker.py +0 -0
  132. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_secrets.py +0 -0
  133. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_ssm.py +0 -0
  134. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/test_vpc.py +0 -0
  135. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/__init__.py +0 -0
  136. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/test_cloudtrail_tampering.py +0 -0
  137. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/test_cryptomining_role.py +0 -0
  138. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/test_datazone_overgrant.py +0 -0
  139. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/test_lambda_function_url.py +0 -0
  140. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/test_mmdsv1_in_use.py +0 -0
  141. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/test_quarantine_policy.py +0 -0
  142. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/test_roles_anywhere_abuse.py +0 -0
  143. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/aws/threat_feed/test_whoami_confusion.py +0 -0
  144. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/conftest.py +0 -0
  145. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_cli.py +0 -0
  146. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_cli_scan.py +0 -0
  147. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_compliance_frameworks.py +0 -0
  148. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_config.py +0 -0
  149. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_correlate.py +0 -0
  150. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_cost_model.py +0 -0
  151. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_diff.py +0 -0
  152. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_history.py +0 -0
  153. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_html.py +0 -0
  154. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_markdown.py +0 -0
  155. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_mcp_server.py +0 -0
  156. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_models.py +0 -0
  157. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_provider.py +0 -0
  158. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_root_cause.py +0 -0
  159. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_sarif.py +0 -0
  160. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_scanner.py +0 -0
  161. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_simulate.py +0 -0
  162. {cloud_audit-2.2.0 → cloud_audit-2.2.1}/tests/test_soc2_framework.py +0 -0
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.1] - 2026-05-12
11
+
12
+ ### Changed
13
+
14
+ - **TF-001 (SES phishing setup)** - severity escalation logic rewritten.
15
+ HIGH now requires BOTH out-of-sandbox AND a burst of >=2 recent
16
+ identity verifications in the same account scan. The previous
17
+ "email identity without matching domain" escalation has been removed:
18
+ it modeled the wrong attacker behaviour. Wiz's September 2025 research
19
+ documented attackers *"adding multiple domains as verified identities
20
+ using the CreateEmailIdentity API"* in quick succession - a burst
21
+ pattern, not a single typosquat email. The new logic matches what
22
+ the source incident actually documented.
23
+
24
+ - **TF-004 (leaked-creds scanner UA)** - removed `cloudgrappler` and
25
+ `detention-dodger` from the user-agent signature list. Both are
26
+ Permiso DEFENSIVE tools - their UA appearing in CloudTrail means a
27
+ defender is running them against the account, not that the account
28
+ is under attack. The detector now only matches OFFENSIVE scanner
29
+ signatures (`trufflehog`, `gitleaks`, `noseyparker`, `secretscanner`).
30
+ Module docstring updated with an explicit detection caveat: scanners
31
+ using stock AWS SDK / boto3 / aws-cli default user-agents look
32
+ identical to legitimate traffic and will not trigger this pattern.
33
+
34
+ - **TF-004 references** - replaced a fabricated TruffleHog blog URL in
35
+ the references list with the verified BleepingComputer / Kaspersky
36
+ May 2026 SES abuse coverage and the official TruffleHog GitHub repo.
37
+
38
+ ### Tests
39
+
40
+ - 742 -> 747 (+5 net). New regression tests:
41
+ - `test_email_no_matching_domain_does_not_escalate` (TF-001) proves
42
+ the removed typosquat heuristic does not return.
43
+ - `test_burst_out_of_sandbox_escalates_to_high` and
44
+ `test_burst_in_sandbox_stays_medium` cover the new escalation rule.
45
+ - `test_burst_only_counts_recent_identities` verifies the burst
46
+ counter respects the 14-day window.
47
+ - `test_cloudgrappler_ua_not_flagged` and
48
+ `test_detention_dodger_ua_not_flagged` (TF-004) prove defensive
49
+ tools are now excluded.
50
+
10
51
  ## [2.2.0] - 2026-05-12
11
52
 
12
53
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloud-audit
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Open-source AWS security scanner. Threat Feed v1 (10 active-abuse patterns from 2025-2026 incidents), 64 IAM escalation methods, What-If simulator, security trends, AI-SPM (Bedrock/SageMaker), 6 compliance frameworks, 31 attack chain rules, breach cost estimation, and MCP server. Every finding includes CLI + Terraform remediation.
5
5
  Project-URL: Homepage, https://haitmg.pl/cloud-audit/
6
6
  Project-URL: Documentation, https://haitmg.pl/cloud-audit/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cloud-audit"
7
- version = "2.2.0"
7
+ version = "2.2.1"
8
8
  description = "Open-source AWS security scanner. Threat Feed v1 (10 active-abuse patterns from 2025-2026 incidents), 64 IAM escalation methods, What-If simulator, security trends, AI-SPM (Bedrock/SageMaker), 6 compliance frameworks, 31 attack chain rules, breach cost estimation, and MCP server. Every finding includes CLI + Terraform remediation."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,22 +1,28 @@
1
1
  """TF-001: SES phishing setup precursors.
2
2
 
3
- The SES abuse campaigns documented by Wiz (May 2025) and BleepingComputer
3
+ The SES abuse campaigns documented by Wiz (September 2025) and BleepingComputer
4
4
  (May 2026) follow a consistent pattern: an attacker compromises AWS
5
- credentials, calls GetSendQuota to confirm out-of-sandbox status, verifies
6
- a fresh email or domain identity (often a typosquat of a trusted brand),
7
- and blasts phishing through SES so messages arrive with the trust signal
8
- of AWS-IP-sourced delivery.
5
+ credentials, calls GetSendQuota to confirm out-of-sandbox status, then -
6
+ per Wiz's direct quote - "adds multiple domains as verified identities
7
+ using the CreateEmailIdentity API" so they can blast phishing through SES
8
+ from common prefixes (admin@, billing@, sales@, noreply@) on those domains.
9
9
 
10
10
  We surface the precursor that is visible from the control plane: SES email
11
- identities verified RECENTLY in an account that has production sending
12
- enabled. We do not have visibility into whether the identity is benign
13
- (legitimate marketing setup) or malicious - we flag at MEDIUM and let the
14
- operator triage.
15
-
16
- Two stronger sub-signals raise severity to HIGH when present:
17
- - Identity is an email address that doesn't match a domain identity in the
18
- same account (typosquats / one-off addresses are red flags)
19
- - Account has elevated send quota (production sending out of sandbox)
11
+ or domain identities verified RECENTLY. We do not have visibility into
12
+ whether the identity is benign (legitimate marketing setup) or malicious -
13
+ we flag at MEDIUM and let the operator triage.
14
+
15
+ Severity escalates to HIGH when BOTH of these are true:
16
+ - The account has production sending enabled (out of SES sandbox), so
17
+ abuse would reach external recipients without the per-recipient
18
+ verify-first restriction.
19
+ - The account has TWO OR MORE identities verified in the recent window
20
+ (the "burst" pattern Wiz documented - attackers add multiple domains
21
+ in quick succession, not one).
22
+
23
+ Severity does NOT escalate on "email identity without matching domain"
24
+ (an earlier draft of this detector used that signal - it was wrong;
25
+ Wiz documented attackers verifying domains, not single-email typosquats).
20
26
 
21
27
  References:
22
28
  - https://www.wiz.io/blog/wiz-discovers-cloud-email-abuse-campaign
@@ -51,20 +57,25 @@ _RECENT_DAYS = 14
51
57
  14 days catches campaign-style bursts without flagging legitimate identities
52
58
  that have been around for months."""
53
59
 
60
+ _BURST_THRESHOLD = 2
61
+ """Number of recent verifications that triggers HIGH severity escalation.
62
+
63
+ Wiz documented attackers "adding multiple domains as verified identities"
64
+ in quick succession. Two or more recent verifications in the same account
65
+ matches that burst signature."""
66
+
54
67
 
55
68
  def _build_finding(
56
69
  identity_name: str,
57
70
  identity_type: str,
58
71
  region: str,
59
- created_at: datetime | None,
72
+ created_at: datetime,
60
73
  out_of_sandbox: bool,
61
- is_email_no_matching_domain: bool,
74
+ recent_burst_count: int,
62
75
  ) -> Finding:
63
- severity = Severity.HIGH if (out_of_sandbox and is_email_no_matching_domain) else PATTERN_SEVERITY
64
- age_str = "unknown age"
65
- if created_at:
66
- age_days = (datetime.now(timezone.utc) - created_at).days
67
- age_str = f"verified {age_days} days ago"
76
+ is_burst = recent_burst_count >= _BURST_THRESHOLD
77
+ severity = Severity.HIGH if (out_of_sandbox and is_burst) else PATTERN_SEVERITY
78
+ age_days = (datetime.now(timezone.utc) - created_at).days
68
79
 
69
80
  sandbox_note = (
70
81
  " The account has production sending enabled (out of SES sandbox), so messages "
@@ -73,11 +84,11 @@ def _build_finding(
73
84
  if out_of_sandbox
74
85
  else " The account is still in SES sandbox - external impact limited."
75
86
  )
76
- typosquat_note = (
77
- " The identity is an EMAIL address with no matching DOMAIN identity in the same "
78
- "account - a common typosquat / one-off setup pattern in attacker-driven SES "
79
- "abuse campaigns."
80
- if is_email_no_matching_domain
87
+ burst_note = (
88
+ f" {recent_burst_count} identities have been verified in this account in the last "
89
+ f"{_RECENT_DAYS} days - a burst pattern matching Wiz's documented incident where "
90
+ "attackers added multiple domains as verified identities in quick succession."
91
+ if is_burst
81
92
  else ""
82
93
  )
83
94
  return Finding(
@@ -89,12 +100,12 @@ def _build_finding(
89
100
  resource_id=f"ses:{region}:{identity_name}",
90
101
  region=region,
91
102
  description=(
92
- f"SES identity '{identity_name}' ({age_str}) is verified in {region}. The Wiz "
93
- "May 2025 research and BleepingComputer May 2026 follow-up document a "
94
- "consistent SES abuse pattern in stolen-credential incidents: attackers verify "
95
- "a fresh identity to send phishing through SES so messages carry AWS IP "
96
- f"reputation.{sandbox_note}{typosquat_note} Confirm the identity was created "
97
- "by an authorized operator."
103
+ f"SES identity '{identity_name}' was verified {age_days} days ago in {region}. "
104
+ "The Wiz September 2025 research and BleepingComputer May 2026 follow-up "
105
+ "document a consistent SES abuse pattern in stolen-credential incidents: "
106
+ "attackers verify SES identities and blast phishing through SES so messages "
107
+ f"carry AWS IP reputation.{sandbox_note}{burst_note} Confirm the verification "
108
+ "was performed by an authorized operator."
98
109
  ),
99
110
  recommendation=(
100
111
  "(1) Confirm the verification was authorized by your team. (2) If unexpected, "
@@ -163,39 +174,39 @@ def _scan_region(provider: AWSProvider, region: str) -> tuple[int, list[Finding]
163
174
  return 0, []
164
175
  raise
165
176
 
166
- # Build set of verified domains (for typosquat detection)
167
- verified_domains = {i.get("IdentityName", "") for i in identities if i.get("IdentityType") == "DOMAIN"}
168
-
169
177
  cutoff = datetime.now(timezone.utc) - timedelta(days=_RECENT_DAYS)
170
178
 
179
+ # First pass: collect every recently-verified identity with its CreatedTimestamp.
180
+ # The burst count is the SAME for every finding emitted from a single account scan -
181
+ # it reflects the total recent activity in the SES inventory, which is the signature
182
+ # Wiz documented (attackers add multiple identities, not one). We must count first
183
+ # so that the count is correct on every emitted finding.
184
+ recent_entries: list[tuple[str, datetime]] = []
171
185
  for identity in identities:
172
186
  scanned += 1
173
187
  name = identity.get("IdentityName", "")
174
188
  if not name:
175
189
  continue
176
190
  if not identity.get("VerifiedForSendingStatus", False):
177
- continue # Pending/failed identities aren't usable for phishing
178
-
179
- # Look up details for created timestamp
191
+ continue
180
192
  try:
181
193
  detail = ses.get_email_identity(EmailIdentity=name)
182
194
  except Exception:
183
195
  continue
184
196
  created_at = detail.get("CreatedTimestamp")
185
197
  if not isinstance(created_at, datetime):
186
- # Some boto3 versions return raw datetime, others may return a string.
187
- # If we can't parse, skip rather than over-flag.
188
198
  continue
189
199
  if created_at.tzinfo is None:
190
200
  created_at = created_at.replace(tzinfo=timezone.utc)
191
201
  if created_at < cutoff:
192
- continue # Not recent
202
+ continue
203
+ recent_entries.append((name, created_at))
193
204
 
194
- is_email_no_match = False
195
- if _is_email(name):
196
- domain_part = name.rsplit("@", 1)[-1]
197
- is_email_no_match = domain_part not in verified_domains
205
+ recent_burst_count = len(recent_entries)
206
+ if recent_burst_count == 0:
207
+ return scanned, []
198
208
 
209
+ for name, created_at in recent_entries:
199
210
  findings.append(
200
211
  _build_finding(
201
212
  identity_name=name,
@@ -203,7 +214,7 @@ def _scan_region(provider: AWSProvider, region: str) -> tuple[int, list[Finding]
203
214
  region=region,
204
215
  created_at=created_at,
205
216
  out_of_sandbox=out_of_sandbox,
206
- is_email_no_matching_domain=is_email_no_match,
217
+ recent_burst_count=recent_burst_count,
207
218
  )
208
219
  )
209
220
 
@@ -1,4 +1,4 @@
1
- """TF-004: TruffleHog (and similar leaked-creds-discovery tools) user-agent in CloudTrail.
1
+ """TF-004: leaked-creds-discovery scanner user-agent in CloudTrail.
2
2
 
3
3
  When credentials leak to public sources (GitHub commits, paste sites,
4
4
  shipping logs), automated discovery tools immediately scrape and validate
@@ -6,19 +6,32 @@ them. The most common validation is `aws sts get-caller-identity` because
6
6
  it requires zero permissions to call but tells the attacker who the
7
7
  credentials belong to.
8
8
 
9
- TruffleHog (the most common scanner) leaves a recognisable user-agent
10
- signature when it makes that call. Other scanners (gitleaks, CloudGrappler,
11
- DetentionDodger validators) have similar signatures.
9
+ TruffleHog, gitleaks, NoseyParker and similar OFFENSIVE leaked-credentials
10
+ scanners *can* leave a recognisable user-agent substring when their HTTP
11
+ clients are invoked with a custom UA (e.g. TruffleHog's `--user-agent-suffix`
12
+ flag). TF-004 catches those cases.
12
13
 
13
- Seeing such a UA in CloudTrail = your credentials were validated by an
14
- external automated scanner = treat as confirmed exposure even before any
15
- exploitation event lands. We surface CRITICAL because the next step in the
16
- attack chain is typically CreateFunction / RunInstances / VerifyEmailIdentity
17
- within minutes.
14
+ DETECTION LIMITATION (be honest with yourself): when these scanners use the
15
+ stock AWS SDK / boto3 / aws-cli default user-agent, their calls look
16
+ identical to legitimate traffic and TF-004 will NOT flag them. Absence of a
17
+ hit does not mean nothing scanned your keys; presence of a hit is a strong
18
+ signal that something did. Pair this with TF-003 (quarantine policy) and
19
+ behavioural CloudTrail monitoring (e.g. Prowler's
20
+ `cloudtrail_threat_detection_enumeration`) for fuller coverage.
21
+
22
+ We deliberately exclude DEFENSIVE tools like Permiso's CloudGrappler and
23
+ DetentionDodger from the signature list - those running against your account
24
+ are your own (or your auditor's) and should not generate findings.
25
+
26
+ Seeing such a UA in CloudTrail = your credentials were likely validated by
27
+ an external offensive scanner = treat as confirmed exposure. We surface
28
+ CRITICAL because the next step in the attack chain is typically
29
+ CreateFunction / RunInstances / VerifyEmailIdentity within minutes.
18
30
 
19
31
  References:
20
- - https://trufflesecurity.com/blog/the-mechanics-of-trufflehog-validation
32
+ - https://www.bleepingcomputer.com/news/security/researchers-report-amazon-ses-abused-in-phishing-to-evade-detection/
21
33
  - https://permiso.io/blog/introducing-detention-dodger
34
+ - https://github.com/trufflesecurity/trufflehog
22
35
  - https://github.com/Cybr-Inc/fwdcloudsec-2025-summaries
23
36
  """
24
37
 
@@ -40,17 +53,19 @@ PATTERN_SEVERITY = Severity.CRITICAL
40
53
  DOC_URL = "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-record-contents.html"
41
54
 
42
55
  _REFERENCES = [
43
- "https://trufflesecurity.com/blog/the-mechanics-of-trufflehog-validation",
56
+ "https://www.bleepingcomputer.com/news/security/researchers-report-amazon-ses-abused-in-phishing-to-evade-detection/",
44
57
  "https://permiso.io/blog/introducing-detention-dodger",
58
+ "https://github.com/trufflesecurity/trufflehog",
45
59
  ]
46
60
 
47
- # Substrings (case-insensitive) in CloudTrail userAgent that indicate a
48
- # known leaked-credentials discovery scanner. Easy to extend as new tools surface.
61
+ # Substrings (case-insensitive) in CloudTrail userAgent that indicate an
62
+ # OFFENSIVE leaked-credentials discovery scanner. DEFENSIVE tools
63
+ # (CloudGrappler, DetentionDodger) are intentionally excluded - operators
64
+ # running them against their own account are not the threat. Easy to extend
65
+ # as new offensive tools surface; resist the urge to add defensive ones.
49
66
  _SCANNER_UA_SIGNATURES = (
50
67
  "trufflehog",
51
68
  "gitleaks",
52
- "cloudgrappler",
53
- "detention-dodger",
54
69
  "noseyparker",
55
70
  "secretscanner",
56
71
  )
@@ -1,4 +1,12 @@
1
- """Tests for TF-001: SES phishing setup precursor detector."""
1
+ """Tests for TF-001: SES phishing setup precursor detector.
2
+
3
+ v2.2.1: severity escalation rewritten. HIGH now requires BOTH out-of-sandbox
4
+ AND a burst of >=2 recent verifications in the same account scan (matches
5
+ Wiz's documented "multiple domains" pattern). The earlier "email identity
6
+ without matching domain" signal was removed - it pointed at the wrong
7
+ attacker behaviour (Wiz documented attackers adding domains, not single
8
+ typosquats).
9
+ """
2
10
 
3
11
  from __future__ import annotations
4
12
 
@@ -49,6 +57,8 @@ def _identity(name: str, identity_type: str = "EMAIL_ADDRESS", verified: bool =
49
57
 
50
58
 
51
59
  # -----------------------------------------------------------------------------
60
+ # Detection tests
61
+ # -----------------------------------------------------------------------------
52
62
 
53
63
 
54
64
  def test_no_identities_no_findings() -> None:
@@ -59,7 +69,7 @@ def test_no_identities_no_findings() -> None:
59
69
 
60
70
 
61
71
  def test_old_identity_not_flagged() -> None:
62
- """Identity verified 60 days ago = not in attack-window, no flag."""
72
+ """Identity verified 60 days ago = outside the recent window, no flag."""
63
73
  name = "newsletter@company.example"
64
74
  ses = _ses_client(
65
75
  out_of_sandbox=True,
@@ -70,11 +80,11 @@ def test_old_identity_not_flagged() -> None:
70
80
  assert result.findings == []
71
81
 
72
82
 
73
- def test_recent_identity_in_sandbox_medium() -> None:
74
- """Recent identity but account in sandbox = MEDIUM (limited blast radius)."""
75
- name = "test@dev.example"
83
+ def test_single_recent_identity_out_of_sandbox_medium() -> None:
84
+ """One recent identity, even with production sending, is MEDIUM - not a burst yet."""
85
+ name = "team@trusted.example"
76
86
  ses = _ses_client(
77
- out_of_sandbox=False,
87
+ out_of_sandbox=True,
78
88
  identities=[_identity(name)],
79
89
  details={name: {"CreatedTimestamp": _now() - timedelta(days=2)}},
80
90
  )
@@ -84,45 +94,79 @@ def test_recent_identity_in_sandbox_medium() -> None:
84
94
  assert f.severity == Severity.MEDIUM
85
95
  assert f.category == Category.THREAT
86
96
  assert f.threat_pattern_id == ses_phishing.PATTERN_ID
87
- assert "test@dev.example" in f.title
97
+
98
+
99
+ def test_single_recent_identity_in_sandbox_medium() -> None:
100
+ """Single recent + sandbox = MEDIUM (limited blast radius)."""
101
+ name = "test@dev.example"
102
+ ses = _ses_client(
103
+ out_of_sandbox=False,
104
+ identities=[_identity(name)],
105
+ details={name: {"CreatedTimestamp": _now() - timedelta(days=2)}},
106
+ )
107
+ result = ses_phishing.detect(_provider(ses))
108
+ assert len(result.findings) == 1
109
+ f = result.findings[0]
110
+ assert f.severity == Severity.MEDIUM
88
111
  assert "sandbox" in f.description.lower()
89
112
 
90
113
 
91
- def test_recent_identity_out_of_sandbox_with_matching_domain_medium() -> None:
92
- """Recent email identity + production sending + matching DOMAIN identity in account = MEDIUM."""
93
- email = "alerts@trusted.example"
94
- domain = "trusted.example"
114
+ def test_burst_out_of_sandbox_escalates_to_high() -> None:
115
+ """TWO+ recent verifications + production sending = HIGH (Wiz burst pattern)."""
95
116
  ses = _ses_client(
96
117
  out_of_sandbox=True,
97
118
  identities=[
98
- _identity(email, identity_type="EMAIL_ADDRESS"),
99
- _identity(domain, identity_type="DOMAIN"),
119
+ _identity("domain1.example", identity_type="DOMAIN"),
120
+ _identity("domain2.example", identity_type="DOMAIN"),
100
121
  ],
101
122
  details={
102
- email: {"CreatedTimestamp": _now() - timedelta(days=2)},
103
- domain: {"CreatedTimestamp": _now() - timedelta(days=200)}, # old, won't be flagged
123
+ "domain1.example": {"CreatedTimestamp": _now() - timedelta(days=1)},
124
+ "domain2.example": {"CreatedTimestamp": _now() - timedelta(days=3)},
104
125
  },
105
126
  )
106
127
  result = ses_phishing.detect(_provider(ses))
107
- # Only the recent email is flagged. Domain match -> MEDIUM (not HIGH).
108
- flagged = [f for f in result.findings if f.severity != Severity.INFO]
109
- assert len(flagged) == 1
110
- assert flagged[0].severity == Severity.MEDIUM
128
+ assert len(result.findings) == 2
129
+ # BOTH findings escalate - the burst count is account-scoped, not per-finding
130
+ for f in result.findings:
131
+ assert f.severity == Severity.HIGH
132
+ assert "burst pattern" in f.description.lower()
133
+ assert "wiz" in f.description.lower()
111
134
 
112
135
 
113
- def test_recent_email_no_matching_domain_out_of_sandbox_high() -> None:
114
- """Recent email + out-of-sandbox + NO matching domain = HIGH (typosquat pattern)."""
115
- name = "support@typosquat.example"
136
+ def test_burst_in_sandbox_stays_medium() -> None:
137
+ """Burst but account still in sandbox = MEDIUM (no external blast radius)."""
138
+ ses = _ses_client(
139
+ out_of_sandbox=False,
140
+ identities=[
141
+ _identity("d1.example", identity_type="DOMAIN"),
142
+ _identity("d2.example", identity_type="DOMAIN"),
143
+ ],
144
+ details={
145
+ "d1.example": {"CreatedTimestamp": _now() - timedelta(days=1)},
146
+ "d2.example": {"CreatedTimestamp": _now() - timedelta(days=2)},
147
+ },
148
+ )
149
+ result = ses_phishing.detect(_provider(ses))
150
+ assert len(result.findings) == 2
151
+ for f in result.findings:
152
+ assert f.severity == Severity.MEDIUM
153
+
154
+
155
+ def test_email_no_matching_domain_does_not_escalate() -> None:
156
+ """v2.2.1 regression check: the removed 'typosquat' heuristic must not return.
157
+
158
+ Wiz documented attackers verifying DOMAINS in bursts, not single emails
159
+ without a matching domain. A single email identity, even out-of-sandbox,
160
+ must stay MEDIUM unless the burst threshold is met.
161
+ """
116
162
  ses = _ses_client(
117
163
  out_of_sandbox=True,
118
- identities=[_identity(name)],
119
- details={name: {"CreatedTimestamp": _now() - timedelta(days=1)}},
164
+ identities=[_identity("support@typosquat.example")],
165
+ details={"support@typosquat.example": {"CreatedTimestamp": _now() - timedelta(days=1)}},
120
166
  )
121
167
  result = ses_phishing.detect(_provider(ses))
122
168
  assert len(result.findings) == 1
123
- f = result.findings[0]
124
- assert f.severity == Severity.HIGH
125
- assert "typosquat" in f.description.lower()
169
+ assert result.findings[0].severity == Severity.MEDIUM
126
170
 
127
171
 
128
172
  def test_pending_identity_not_flagged() -> None:
@@ -137,6 +181,30 @@ def test_pending_identity_not_flagged() -> None:
137
181
  assert result.findings == []
138
182
 
139
183
 
184
+ def test_burst_only_counts_recent_identities() -> None:
185
+ """Old (>14d) identities do not contribute to the burst count.
186
+
187
+ Account has 1 recent identity and 1 old identity. Old one is filtered
188
+ out before the burst check, so burst_count = 1, severity stays MEDIUM
189
+ even out-of-sandbox.
190
+ """
191
+ ses = _ses_client(
192
+ out_of_sandbox=True,
193
+ identities=[
194
+ _identity("recent.example", identity_type="DOMAIN"),
195
+ _identity("old.example", identity_type="DOMAIN"),
196
+ ],
197
+ details={
198
+ "recent.example": {"CreatedTimestamp": _now() - timedelta(days=2)},
199
+ "old.example": {"CreatedTimestamp": _now() - timedelta(days=200)},
200
+ },
201
+ )
202
+ result = ses_phishing.detect(_provider(ses))
203
+ assert len(result.findings) == 1
204
+ assert "recent.example" in result.findings[0].resource_id
205
+ assert result.findings[0].severity == Severity.MEDIUM
206
+
207
+
140
208
  def test_remediation_includes_delete_and_audit() -> None:
141
209
  name = "phish-staging@bad.example"
142
210
  ses = _ses_client(
@@ -174,11 +242,7 @@ def test_pattern_metadata_exposed() -> None:
174
242
 
175
243
 
176
244
  def test_provider_client_failure_swallowed_per_region() -> None:
177
- """sesv2 client init failure in a region is swallowed (region opt-in is the common cause).
178
-
179
- This differs from patterns where AWS perms errors should surface - SES is region-scoped
180
- and many regions don't have sesv2 at all. We trade error visibility for noise reduction.
181
- """
245
+ """sesv2 client init failure in a region is swallowed (region opt-in is the common cause)."""
182
246
  p = MagicMock(spec=AWSProvider)
183
247
  p.regions = ["us-east-1"]
184
248
  p.client.side_effect = RuntimeError("boom")
@@ -136,3 +136,21 @@ def test_pattern_metadata_exposed() -> None:
136
136
  assert trufflehog_ua.PATTERN_ID == "TF-004-trufflehog-ua-cloudtrail"
137
137
  assert trufflehog_ua.CHECK_ID == "aws-tf-004"
138
138
  assert trufflehog_ua.PATTERN_SEVERITY == Severity.CRITICAL
139
+
140
+
141
+ def test_cloudgrappler_ua_not_flagged() -> None:
142
+ """v2.2.1: defensive tools (Permiso CloudGrappler) must NOT be flagged.
143
+
144
+ Their UA appearing in CloudTrail means a defender is running them
145
+ against the account, not that the account is under attack.
146
+ """
147
+ ct = _ct_with_events(events=[_event("cloudgrappler/1.0")])
148
+ result = trufflehog_ua.detect(_provider(ct))
149
+ assert result.findings == []
150
+
151
+
152
+ def test_detention_dodger_ua_not_flagged() -> None:
153
+ """v2.2.1: defensive tools (Permiso DetentionDodger) must NOT be flagged."""
154
+ ct = _ct_with_events(events=[_event("detention-dodger/1.0")])
155
+ result = trufflehog_ua.detect(_provider(ct))
156
+ assert result.findings == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes