kulshan 0.1.0__py3-none-any.whl

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 (165) hide show
  1. kulshan/__init__.py +9 -0
  2. kulshan/__version__.py +1 -0
  3. kulshan/adapter.py +244 -0
  4. kulshan/checks/__init__.py +9 -0
  5. kulshan/checks/age/__init__.py +134 -0
  6. kulshan/checks/age/scanner/__init__.py +0 -0
  7. kulshan/checks/age/scanner/eol_db.py +124 -0
  8. kulshan/checks/age/scanner/freshness.py +241 -0
  9. kulshan/checks/age/scoring/__init__.py +0 -0
  10. kulshan/checks/age/scoring/engine.py +78 -0
  11. kulshan/checks/age/utils/__init__.py +0 -0
  12. kulshan/checks/age/utils/aws.py +52 -0
  13. kulshan/checks/cost/__init__.py +434 -0
  14. kulshan/checks/cost/analyzers/__init__.py +0 -0
  15. kulshan/checks/cost/analyzers/advanced.py +519 -0
  16. kulshan/checks/cost/analyzers/anomaly.py +258 -0
  17. kulshan/checks/cost/analyzers/budgets.py +146 -0
  18. kulshan/checks/cost/analyzers/efficiency.py +192 -0
  19. kulshan/checks/cost/analyzers/insights.py +144 -0
  20. kulshan/checks/cost/analyzers/network.py +81 -0
  21. kulshan/checks/cost/analyzers/network_cost.py +154 -0
  22. kulshan/checks/cost/analyzers/tag_attribution.py +110 -0
  23. kulshan/checks/cost/analyzers/trends.py +78 -0
  24. kulshan/checks/cost/analyzers/waste.py +67 -0
  25. kulshan/checks/cost/analyzers/wow.py +325 -0
  26. kulshan/checks/cost/attribution.py +205 -0
  27. kulshan/checks/cost/aws_native.py +307 -0
  28. kulshan/checks/cost/config.py +0 -0
  29. kulshan/checks/cost/cost_fetcher.py +878 -0
  30. kulshan/checks/cost/reporters/__init__.py +1 -0
  31. kulshan/checks/cost/reporters/exporter.py +458 -0
  32. kulshan/checks/cost/reporters/html_report.py +470 -0
  33. kulshan/checks/cost/reporters/summary_pdf.py +148 -0
  34. kulshan/checks/cost/reporters/terminal.py +1889 -0
  35. kulshan/checks/dr/__init__.py +160 -0
  36. kulshan/checks/dr/scanner/__init__.py +0 -0
  37. kulshan/checks/dr/scanner/backup.py +113 -0
  38. kulshan/checks/dr/scanner/compute.py +128 -0
  39. kulshan/checks/dr/scanner/database.py +171 -0
  40. kulshan/checks/dr/scanner/dns.py +98 -0
  41. kulshan/checks/dr/scanner/spof.py +96 -0
  42. kulshan/checks/dr/scanner/storage.py +86 -0
  43. kulshan/checks/dr/scoring/__init__.py +0 -0
  44. kulshan/checks/dr/scoring/engine.py +162 -0
  45. kulshan/checks/dr/scoring/simulator.py +170 -0
  46. kulshan/checks/dr/utils/__init__.py +0 -0
  47. kulshan/checks/dr/utils/aws.py +69 -0
  48. kulshan/checks/drift/__init__.py +119 -0
  49. kulshan/checks/drift/scanner/__init__.py +0 -0
  50. kulshan/checks/drift/scanner/cfn_drift.py +205 -0
  51. kulshan/checks/drift/scanner/coverage.py +127 -0
  52. kulshan/checks/drift/scoring/__init__.py +0 -0
  53. kulshan/checks/drift/scoring/engine.py +106 -0
  54. kulshan/checks/drift/utils/__init__.py +0 -0
  55. kulshan/checks/drift/utils/aws.py +68 -0
  56. kulshan/checks/limit/__init__.py +107 -0
  57. kulshan/checks/limit/scanner/__init__.py +0 -0
  58. kulshan/checks/limit/scanner/quotas.py +228 -0
  59. kulshan/checks/limit/scoring/__init__.py +0 -0
  60. kulshan/checks/limit/scoring/engine.py +138 -0
  61. kulshan/checks/limit/utils/__init__.py +0 -0
  62. kulshan/checks/limit/utils/aws.py +69 -0
  63. kulshan/checks/pulse/__init__.py +133 -0
  64. kulshan/checks/pulse/scanner/__init__.py +0 -0
  65. kulshan/checks/pulse/scanner/alarms.py +167 -0
  66. kulshan/checks/pulse/scanner/logging.py +141 -0
  67. kulshan/checks/pulse/scanner/tracing.py +135 -0
  68. kulshan/checks/pulse/scoring/__init__.py +0 -0
  69. kulshan/checks/pulse/scoring/engine.py +151 -0
  70. kulshan/checks/pulse/utils/__init__.py +0 -0
  71. kulshan/checks/pulse/utils/aws.py +68 -0
  72. kulshan/checks/security/__init__.py +239 -0
  73. kulshan/checks/security/blame.py +83 -0
  74. kulshan/checks/security/crown_jewels.py +126 -0
  75. kulshan/checks/security/graph/__init__.py +0 -0
  76. kulshan/checks/security/graph/builder.py +113 -0
  77. kulshan/checks/security/remediation.py +201 -0
  78. kulshan/checks/security/scanner/__init__.py +0 -0
  79. kulshan/checks/security/scanner/base.py +63 -0
  80. kulshan/checks/security/scanner/compute.py +115 -0
  81. kulshan/checks/security/scanner/data.py +129 -0
  82. kulshan/checks/security/scanner/encryption.py +79 -0
  83. kulshan/checks/security/scanner/iam.py +444 -0
  84. kulshan/checks/security/scanner/logging_monitor.py +128 -0
  85. kulshan/checks/security/scanner/network.py +251 -0
  86. kulshan/checks/security/scoring/__init__.py +0 -0
  87. kulshan/checks/security/scoring/breach_cost.py +54 -0
  88. kulshan/checks/security/scoring/compliance.py +75 -0
  89. kulshan/checks/security/scoring/diff.py +48 -0
  90. kulshan/checks/security/scoring/engine.py +85 -0
  91. kulshan/checks/security/scoring/exposure.py +116 -0
  92. kulshan/checks/security/scoring/history.py +77 -0
  93. kulshan/checks/security/scoring/inventory.py +28 -0
  94. kulshan/checks/security/utils/__init__.py +0 -0
  95. kulshan/checks/security/utils/aws.py +134 -0
  96. kulshan/checks/sweep/__init__.py +126 -0
  97. kulshan/checks/sweep/scanner/__init__.py +0 -0
  98. kulshan/checks/sweep/scanner/compute.py +168 -0
  99. kulshan/checks/sweep/scanner/database.py +70 -0
  100. kulshan/checks/sweep/scanner/monitoring.py +136 -0
  101. kulshan/checks/sweep/scanner/network.py +164 -0
  102. kulshan/checks/sweep/scanner/storage.py +87 -0
  103. kulshan/checks/sweep/scoring/__init__.py +0 -0
  104. kulshan/checks/sweep/scoring/engine.py +123 -0
  105. kulshan/checks/sweep/utils/__init__.py +0 -0
  106. kulshan/checks/sweep/utils/aws.py +70 -0
  107. kulshan/checks/tag/__init__.py +135 -0
  108. kulshan/checks/tag/analysis/__init__.py +1 -0
  109. kulshan/checks/tag/analysis/compliance.py +85 -0
  110. kulshan/checks/tag/analysis/consistency.py +96 -0
  111. kulshan/checks/tag/analysis/cost_impact.py +63 -0
  112. kulshan/checks/tag/scanner/__init__.py +1 -0
  113. kulshan/checks/tag/scanner/tagging_api.py +91 -0
  114. kulshan/checks/tag/scoring/__init__.py +1 -0
  115. kulshan/checks/tag/scoring/engine.py +82 -0
  116. kulshan/checks/tag/utils/__init__.py +1 -0
  117. kulshan/checks/tag/utils/aws.py +77 -0
  118. kulshan/checks/topo/__init__.py +131 -0
  119. kulshan/checks/topo/scanner/__init__.py +0 -0
  120. kulshan/checks/topo/scanner/topology.py +377 -0
  121. kulshan/checks/topo/scoring/__init__.py +0 -0
  122. kulshan/checks/topo/scoring/engine.py +95 -0
  123. kulshan/checks/topo/utils/__init__.py +0 -0
  124. kulshan/checks/topo/utils/aws.py +46 -0
  125. kulshan/ci/__init__.py +1 -0
  126. kulshan/cli.py +631 -0
  127. kulshan/commands/__init__.py +1 -0
  128. kulshan/completion.py +173 -0
  129. kulshan/config/__init__.py +1 -0
  130. kulshan/constants.py +21 -0
  131. kulshan/diagnostics.py +190 -0
  132. kulshan/errors.py +29 -0
  133. kulshan/findings.py +5 -0
  134. kulshan/findings_ranker.py +134 -0
  135. kulshan/help_formatter.py +66 -0
  136. kulshan/history/__init__.py +229 -0
  137. kulshan/license/__init__.py +1 -0
  138. kulshan/models.py +1111 -0
  139. kulshan/orchestrator.py +389 -0
  140. kulshan/plugins/__init__.py +1 -0
  141. kulshan/preflight.py +143 -0
  142. kulshan/question_mark.py +234 -0
  143. kulshan/redact.py +295 -0
  144. kulshan/remediation.py +103 -0
  145. kulshan/repl.py +343 -0
  146. kulshan/report/__init__.py +1 -0
  147. kulshan/report/csv_export.py +56 -0
  148. kulshan/report/html.py +1534 -0
  149. kulshan/report/sarif.py +173 -0
  150. kulshan/report/terminal.py +308 -0
  151. kulshan/scoring_utils.py +23 -0
  152. kulshan/session.py +64 -0
  153. kulshan/setup.py +87 -0
  154. kulshan/slm/__init__.py +1 -0
  155. kulshan/slm/backends/__init__.py +1 -0
  156. kulshan/telemetry/__init__.py +1 -0
  157. kulshan/theme.py +97 -0
  158. kulshan/theme_constants.py +44 -0
  159. kulshan/trust/__init__.py +1 -0
  160. kulshan/utils/__init__.py +1 -0
  161. kulshan-0.1.0.dist-info/METADATA +136 -0
  162. kulshan-0.1.0.dist-info/RECORD +165 -0
  163. kulshan-0.1.0.dist-info/WHEEL +4 -0
  164. kulshan-0.1.0.dist-info/entry_points.txt +2 -0
  165. kulshan-0.1.0.dist-info/licenses/LICENSE.txt +189 -0
kulshan/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Kulshan: CLI for AWS cost, security, and waste analysis.
3
+
4
+ Local-first, non-mutating AWS audits. Optional integrations require explicit invocation.
5
+ """
6
+
7
+ from kulshan.__version__ import __version__
8
+
9
+ __all__ = ["__version__"]
kulshan/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
kulshan/adapter.py ADDED
@@ -0,0 +1,244 @@
1
+ """Adapter layer for converting pack-internal finding dicts to the canonical shape.
2
+
3
+ Packs can be migrated incrementally. Until a pack emits canonical findings
4
+ directly, this adapter handles the translation from its internal
5
+ representation to the canonical Finding dict shape defined in ``models.py``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Optional
11
+
12
+ from kulshan.models import (
13
+ CONFIDENCE_ENUM_MAP,
14
+ SEVERITY_SCORE_IMPACT,
15
+ VALID_EFFORT,
16
+ VALID_RISK,
17
+ VALID_SEVERITY,
18
+ compute_fingerprint,
19
+ make_finding_id,
20
+ )
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Mapping tables
24
+ # ---------------------------------------------------------------------------
25
+
26
+ EFFORT_MINUTES_MAP: Dict[str, tuple] = {
27
+ "trivial": (0, 15),
28
+ "low": (16, 60),
29
+ "medium": (61, 240),
30
+ "high": (241, None),
31
+ }
32
+ """Maps effort category → (min_minutes, max_minutes). max=None means unbounded."""
33
+
34
+
35
+ def _effort_from_minutes(minutes: int) -> str:
36
+ """Bucket an effort_minutes integer into a categorical effort string."""
37
+ if minutes <= 15:
38
+ return "trivial"
39
+ elif minutes <= 60:
40
+ return "low"
41
+ elif minutes <= 240:
42
+ return "medium"
43
+ else:
44
+ return "high"
45
+
46
+
47
+ # Default values for fields missing from the raw finding.
48
+ DEFAULTS: Dict[str, Any] = {
49
+ "effort": "medium",
50
+ "risk": "safe",
51
+ "confidence": 0.5,
52
+ "estimated_monthly_impact": 0.0,
53
+ "description": "",
54
+ "evidence": {},
55
+ "recommended_action": "",
56
+ "compliance_frameworks": [],
57
+ "schema_version": "2.0",
58
+ }
59
+
60
+ # The canonical keys that belong on a Finding dict (used to detect extras).
61
+ _CANONICAL_KEYS = frozenset({
62
+ "id", "pack", "kind", "fingerprint",
63
+ "title", "severity", "score_impact",
64
+ "estimated_monthly_impact", "confidence",
65
+ "effort", "risk",
66
+ "account_id", "region", "resource_arn", "resource_type", "service",
67
+ "description", "evidence", "recommended_action",
68
+ "compliance_frameworks", "detected_at", "schema_version",
69
+ })
70
+
71
+ # Keys that are recognized as aliases/legacy names (not extras).
72
+ _ALIAS_KEYS = frozenset({
73
+ "tool", "check_id", "monthly_impact_usd", "effort_minutes",
74
+ "account", "usage_type", "operation", "resource_id",
75
+ "why_it_matters", "remediation_text", "owner_hint",
76
+ })
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Public API
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def adapt(pack: str, raw_finding: dict) -> dict:
84
+ """Convert a pack-internal finding dict to the canonical shape.
85
+
86
+ Parameters
87
+ ----------
88
+ pack : str
89
+ The pack name (e.g. "cost", "security"). Used as fallback for the
90
+ ``pack`` field and for fingerprint computation.
91
+ raw_finding : dict
92
+ The raw finding dict emitted by the pack's internal logic.
93
+
94
+ Returns
95
+ -------
96
+ dict
97
+ A canonical finding dict suitable for validation and ``Finding.from_dict()``.
98
+ """
99
+ raw = dict(raw_finding) # shallow copy to avoid mutating caller's dict
100
+ out: Dict[str, Any] = {}
101
+
102
+ # --- Identity -----------------------------------------------------------
103
+
104
+ # Map tool → pack, check_id → kind
105
+ out["pack"] = raw.pop("pack", None) or raw.pop("tool", None) or pack
106
+ out["kind"] = raw.pop("kind", None) or raw.pop("check_id", None) or "unknown"
107
+
108
+ # --- Severity -----------------------------------------------------------
109
+
110
+ sev_raw = raw.pop("severity", "info")
111
+ if hasattr(sev_raw, "value"):
112
+ # Enum instance (e.g. Severity.HIGH)
113
+ sev_str = str(sev_raw.value).lower()
114
+ else:
115
+ sev_str = str(sev_raw).lower()
116
+
117
+ if sev_str not in VALID_SEVERITY:
118
+ sev_str = "info"
119
+ out["severity"] = sev_str
120
+
121
+ # --- score_impact (derived from severity) -------------------------------
122
+
123
+ out["score_impact"] = SEVERITY_SCORE_IMPACT.get(out["severity"], 0)
124
+
125
+ # --- Confidence ---------------------------------------------------------
126
+
127
+ conf_raw = raw.pop("confidence", None)
128
+ if conf_raw is None:
129
+ out["confidence"] = DEFAULTS["confidence"]
130
+ elif isinstance(conf_raw, (int, float)):
131
+ val = float(conf_raw)
132
+ out["confidence"] = max(0.0, min(1.0, val))
133
+ elif isinstance(conf_raw, str):
134
+ out["confidence"] = CONFIDENCE_ENUM_MAP.get(conf_raw.lower(), DEFAULTS["confidence"])
135
+ else:
136
+ out["confidence"] = DEFAULTS["confidence"]
137
+
138
+ # --- Impact (monthly_impact_usd Decimal string → float) -----------------
139
+
140
+ impact_raw = raw.pop("estimated_monthly_impact", None)
141
+ if impact_raw is None:
142
+ impact_raw = raw.pop("monthly_impact_usd", None)
143
+
144
+ if impact_raw is not None:
145
+ try:
146
+ out["estimated_monthly_impact"] = float(str(impact_raw))
147
+ except (ValueError, TypeError):
148
+ out["estimated_monthly_impact"] = DEFAULTS["estimated_monthly_impact"]
149
+ else:
150
+ out["estimated_monthly_impact"] = DEFAULTS["estimated_monthly_impact"]
151
+
152
+ # --- Effort (effort string or effort_minutes bucketing) -----------------
153
+
154
+ effort_raw = raw.pop("effort", None)
155
+ effort_minutes_raw = raw.pop("effort_minutes", None)
156
+
157
+ if effort_raw is not None and str(effort_raw) in VALID_EFFORT:
158
+ out["effort"] = str(effort_raw)
159
+ elif effort_minutes_raw is not None:
160
+ try:
161
+ minutes = int(effort_minutes_raw)
162
+ out["effort"] = _effort_from_minutes(minutes)
163
+ except (ValueError, TypeError):
164
+ out["effort"] = DEFAULTS["effort"]
165
+ else:
166
+ out["effort"] = DEFAULTS["effort"]
167
+
168
+ # --- Risk ---------------------------------------------------------------
169
+
170
+ risk_raw = raw.pop("risk", None)
171
+ if risk_raw is not None and str(risk_raw) in VALID_RISK:
172
+ out["risk"] = str(risk_raw)
173
+ else:
174
+ out["risk"] = DEFAULTS["risk"]
175
+
176
+ # --- Title --------------------------------------------------------------
177
+
178
+ out["title"] = raw.pop("title", "")
179
+
180
+ # --- Location -----------------------------------------------------------
181
+
182
+ out["account_id"] = raw.pop("account_id", None) or raw.pop("account", None)
183
+ out["region"] = raw.pop("region", None)
184
+ out["resource_arn"] = raw.pop("resource_arn", None) or raw.pop("resource_id", None)
185
+ out["resource_type"] = raw.pop("resource_type", None)
186
+ out["service"] = raw.pop("service", None)
187
+
188
+ # --- Explanation --------------------------------------------------------
189
+
190
+ out["description"] = raw.pop("description", None) or raw.pop("why_it_matters", None) or DEFAULTS["description"]
191
+ out["evidence"] = raw.pop("evidence", None)
192
+ if not isinstance(out["evidence"], dict):
193
+ out["evidence"] = {}
194
+ out["recommended_action"] = raw.pop("recommended_action", None) or raw.pop("remediation_text", None) or DEFAULTS["recommended_action"]
195
+
196
+ # --- Metadata -----------------------------------------------------------
197
+
198
+ out["compliance_frameworks"] = list(raw.pop("compliance_frameworks", DEFAULTS["compliance_frameworks"]))
199
+ out["detected_at"] = raw.pop("detected_at", None)
200
+ out["schema_version"] = raw.pop("schema_version", DEFAULTS["schema_version"])
201
+
202
+ # --- Fingerprint (compute if absent) ------------------------------------
203
+
204
+ fingerprint = raw.pop("fingerprint", None)
205
+ if not fingerprint:
206
+ fingerprint = compute_fingerprint(
207
+ pack=out["pack"],
208
+ kind=out["kind"],
209
+ account=out["account_id"],
210
+ service=out["service"],
211
+ usage_type=raw.pop("usage_type", None),
212
+ period=out["detected_at"],
213
+ )
214
+ else:
215
+ # Still pop usage_type if present to avoid it ending up in extras
216
+ raw.pop("usage_type", None)
217
+ out["fingerprint"] = fingerprint
218
+
219
+ # --- ID (compute if absent) ---------------------------------------------
220
+
221
+ id_raw = raw.pop("id", None)
222
+ if id_raw:
223
+ out["id"] = id_raw
224
+ else:
225
+ out["id"] = make_finding_id(
226
+ pack=out["pack"],
227
+ kind=out["kind"],
228
+ fingerprint=out["fingerprint"],
229
+ )
230
+
231
+ # --- Preserve unrecognized fields in evidence["extra"] ------------------
232
+
233
+ # Remove any remaining known alias keys that were already handled
234
+ for alias_key in list(raw.keys()):
235
+ if alias_key in _ALIAS_KEYS:
236
+ raw.pop(alias_key, None)
237
+
238
+ # Whatever remains in raw is unrecognized / extra
239
+ if raw:
240
+ extra = out["evidence"].get("extra", {})
241
+ extra.update(raw)
242
+ out["evidence"]["extra"] = extra
243
+
244
+ return out
@@ -0,0 +1,9 @@
1
+ """Kulshan check packs.
2
+
3
+ Each subpackage exposes a top-level ``run_scan`` function with the contract:
4
+ run_scan(session, regions, *, quick: bool = False, **kwargs) -> dict
5
+
6
+ The orchestrator iterates ``TOOL_ORDER`` (defined in Kulshan.orchestrator) and
7
+ calls each pack's ``run_scan``. Pack keys are the orchestrator keys used in
8
+ TOOL_ORDER, TOOL_LABELS, TOOL_WEIGHTS, and the JSON report shape.
9
+ """
@@ -0,0 +1,134 @@
1
+ """Age check pack, Lambda runtimes, RDS engines, ACM certificates, EBS modernization."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ from typing import List
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["__version__", "run_scan"]
9
+
10
+
11
+ def _fingerprint(pack: str, kind: str, resource_id: str) -> str:
12
+ raw = f"{pack}|{kind}|{resource_id}"
13
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
14
+
15
+
16
+ def _extract_findings(result: dict) -> list:
17
+ """Extract canonical findings from freshness scan results."""
18
+ findings = []
19
+
20
+ # EOL runtimes (Lambda, RDS engines, EKS versions)
21
+ for item in result.get("eol_runtimes", []):
22
+ resource_id = item.get("resource_id", "unknown")
23
+ fp = _fingerprint("age", "eol-runtime", resource_id)
24
+ days_past = item.get("days_past_eol", 0)
25
+ severity = "critical" if days_past > 180 else "high" if days_past > 0 else "medium"
26
+ findings.append({
27
+ "id": f"age-eol-runtime-{fp}",
28
+ "pack": "age",
29
+ "kind": "eol-runtime",
30
+ "title": f"{item.get('resource_type', 'Resource')} '{resource_id}' uses EOL runtime ({item.get('runtime', '?')})",
31
+ "severity": severity,
32
+ "confidence": 0.99,
33
+ "effort": "medium",
34
+ "risk": "medium",
35
+ "resource_id": resource_id,
36
+ "resource_arn": item.get("resource_arn", ""),
37
+ "region": item.get("region", "us-east-1"),
38
+ "description": f"Runtime '{item.get('runtime', '?')}' is end-of-life ({days_past} days past EOL). No security patches.",
39
+ "recommended_action": f"Upgrade to a supported runtime version.",
40
+ "estimated_monthly_impact": 0,
41
+ "fingerprint": fp,
42
+ })
43
+
44
+ # Expiring certificates
45
+ for item in result.get("expiring_certs", []):
46
+ resource_id = item.get("resource_id", "unknown")
47
+ fp = _fingerprint("age", "expiring-certificate", resource_id)
48
+ days_left = item.get("days_until_expiry", 0)
49
+ severity = "critical" if days_left <= 7 else "high" if days_left <= 30 else "medium"
50
+ findings.append({
51
+ "id": f"age-expiring-certificate-{fp}",
52
+ "pack": "age",
53
+ "kind": "expiring-certificate",
54
+ "title": f"Certificate '{resource_id}' expires in {days_left} days",
55
+ "severity": severity,
56
+ "confidence": 0.99,
57
+ "effort": "low",
58
+ "risk": "safe",
59
+ "resource_id": resource_id,
60
+ "resource_arn": item.get("resource_arn", ""),
61
+ "region": item.get("region", "us-east-1"),
62
+ "description": f"ACM certificate expires in {days_left} days. Renew or enable auto-renewal.",
63
+ "recommended_action": f"aws acm renew-certificate --certificate-arn {item.get('resource_arn', resource_id)}",
64
+ "estimated_monthly_impact": 0,
65
+ "fingerprint": fp,
66
+ })
67
+
68
+ # Stale AMIs
69
+ for item in result.get("stale_amis", []):
70
+ resource_id = item.get("resource_id", "unknown")
71
+ fp = _fingerprint("age", "stale-ami", resource_id)
72
+ age_days = item.get("age_days", 0)
73
+ findings.append({
74
+ "id": f"age-stale-ami-{fp}",
75
+ "pack": "age",
76
+ "kind": "stale-ami",
77
+ "title": f"AMI '{resource_id}' is {age_days} days old and unused",
78
+ "severity": "low",
79
+ "confidence": 0.70,
80
+ "effort": "trivial",
81
+ "risk": "low",
82
+ "resource_id": resource_id,
83
+ "resource_arn": "",
84
+ "region": item.get("region", "us-east-1"),
85
+ "description": f"AMI is {age_days} days old and not used by any running instance.",
86
+ "recommended_action": f"aws ec2 deregister-image --image-id {resource_id}",
87
+ "estimated_monthly_impact": 0,
88
+ "fingerprint": fp,
89
+ })
90
+
91
+ return findings
92
+
93
+
94
+ def run_scan(session, regions: List[str], *, quick: bool = False, **kwargs) -> dict:
95
+ """Run the lifecycle/staleness scan and return a scored result dict."""
96
+ from .scanner.freshness import scan_freshness
97
+ from .scoring.engine import calculate_score
98
+
99
+ if quick:
100
+ regions = regions[:3]
101
+
102
+ all_errors: list[str] = []
103
+ try:
104
+ result, errors = scan_freshness(session, regions)
105
+ all_errors.extend(errors)
106
+ except Exception as e:
107
+ result = {}
108
+ all_errors.append(str(e))
109
+
110
+ scores = calculate_score(result) if result else {
111
+ "overall_score": 100, "grade": "A+", "total_aging": 0,
112
+ "severity_counts": {}, "breakdown": {},
113
+ }
114
+
115
+ findings = _extract_findings(result) if result else []
116
+
117
+ sev: dict = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
118
+ for f in findings:
119
+ s = f.get("severity", "info")
120
+ if s in sev:
121
+ sev[s] += 1
122
+
123
+ return {
124
+ "tool": "age",
125
+ "findings": findings,
126
+ "scores": {
127
+ "overall_score": int(scores.get("overall_score", 100)),
128
+ "grade": scores.get("grade", "A+"),
129
+ "total_findings": len(findings),
130
+ "severity_counts": {k: v for k, v in sev.items() if v > 0},
131
+ "breakdown": scores.get("breakdown", {}),
132
+ },
133
+ "errors": all_errors,
134
+ }
File without changes
@@ -0,0 +1,124 @@
1
+ """Embedded EOL/EOS date database for AWS runtimes and engines."""
2
+
3
+ import json
4
+ import os
5
+
6
+ LAST_UPDATED = "2026-02-27"
7
+ _OVERRIDES_PATH = os.path.join(os.path.expanduser("~"), ".Kulshan", "age", "eol_overrides.json")
8
+
9
+ # Lambda runtime EOL dates (Phase 2 = no updates, Phase 3 = no create)
10
+ LAMBDA_EOL = {
11
+ "python3.8": {"eol": "2024-10-14", "status": "eol", "upgrade": "python3.12"},
12
+ "python3.9": {"eol": "2025-11-01", "status": "approaching", "upgrade": "python3.12"},
13
+ "python3.10": {"eol": "2026-07-01", "status": "approaching", "upgrade": "python3.13"},
14
+ "python3.11": {"eol": "2027-03-01", "status": "current", "upgrade": "python3.13"},
15
+ "python3.12": {"eol": "2028-03-01", "status": "current", "upgrade": None},
16
+ "python3.13": {"eol": "2029-03-01", "status": "current", "upgrade": None},
17
+ "nodejs14.x": {"eol": "2023-12-04", "status": "eol", "upgrade": "nodejs20.x"},
18
+ "nodejs16.x": {"eol": "2024-06-12", "status": "eol", "upgrade": "nodejs20.x"},
19
+ "nodejs18.x": {"eol": "2025-09-01", "status": "approaching", "upgrade": "nodejs22.x"},
20
+ "nodejs20.x": {"eol": "2026-10-01", "status": "current", "upgrade": None},
21
+ "nodejs22.x": {"eol": "2027-10-01", "status": "current", "upgrade": None},
22
+ "java8": {"eol": "2024-01-08", "status": "eol", "upgrade": "java21"},
23
+ "java8.al2": {"eol": "2025-02-01", "status": "approaching", "upgrade": "java21"},
24
+ "java11": {"eol": "2025-09-01", "status": "approaching", "upgrade": "java21"},
25
+ "java17": {"eol": "2026-09-01", "status": "current", "upgrade": "java21"},
26
+ "java21": {"eol": "2028-09-01", "status": "current", "upgrade": None},
27
+ "dotnet6": {"eol": "2024-07-12", "status": "eol", "upgrade": "dotnet8"},
28
+ "dotnet8": {"eol": "2026-11-01", "status": "current", "upgrade": None},
29
+ "ruby3.2": {"eol": "2026-03-01", "status": "current", "upgrade": "ruby3.3"},
30
+ "ruby3.3": {"eol": "2027-03-01", "status": "current", "upgrade": None},
31
+ "go1.x": {"eol": "2024-01-08", "status": "eol", "upgrade": "provided.al2023"},
32
+ "provided": {"eol": "2024-01-08", "status": "eol", "upgrade": "provided.al2023"},
33
+ "provided.al2": {"eol": "2025-09-01", "status": "approaching", "upgrade": "provided.al2023"},
34
+ "provided.al2023": {"eol": "2028-03-01", "status": "current", "upgrade": None},
35
+ }
36
+
37
+ # RDS engine end-of-standard-support dates (after this, Extended Support = 3x cost)
38
+ RDS_EOL = {
39
+ "mysql": {
40
+ "5.7": {"eos": "2024-02-29", "status": "extended_support", "upgrade": "8.0", "surcharge": True},
41
+ "8.0": {"eos": "2026-04-30", "status": "current", "upgrade": "8.4", "surcharge": False},
42
+ },
43
+ "postgres": {
44
+ "11": {"eos": "2024-02-29", "status": "extended_support", "upgrade": "16", "surcharge": True},
45
+ "12": {"eos": "2025-02-28", "status": "extended_support", "upgrade": "16", "surcharge": True},
46
+ "13": {"eos": "2025-11-13", "status": "approaching", "upgrade": "16", "surcharge": False},
47
+ "14": {"eos": "2026-11-12", "status": "current", "upgrade": "16", "surcharge": False},
48
+ "15": {"eos": "2027-11-11", "status": "current", "upgrade": None, "surcharge": False},
49
+ "16": {"eos": "2028-11-09", "status": "current", "upgrade": None, "surcharge": False},
50
+ },
51
+ "mariadb": {
52
+ "10.4": {"eos": "2024-02-29", "status": "extended_support", "upgrade": "10.11", "surcharge": True},
53
+ "10.5": {"eos": "2025-02-28", "status": "approaching", "upgrade": "10.11", "surcharge": False},
54
+ "10.6": {"eos": "2026-02-28", "status": "current", "upgrade": "10.11", "surcharge": False},
55
+ "10.11": {"eos": "2028-02-28", "status": "current", "upgrade": None, "surcharge": False},
56
+ },
57
+ }
58
+
59
+ # EKS Kubernetes version EOL
60
+ EKS_EOL = {
61
+ "1.24": {"eos": "2024-01-31", "status": "eol", "upgrade": "1.30"},
62
+ "1.25": {"eos": "2024-05-01", "status": "eol", "upgrade": "1.30"},
63
+ "1.26": {"eos": "2024-06-11", "status": "eol", "upgrade": "1.30"},
64
+ "1.27": {"eos": "2024-07-24", "status": "eol", "upgrade": "1.31"},
65
+ "1.28": {"eos": "2025-03-01", "status": "approaching", "upgrade": "1.31"},
66
+ "1.29": {"eos": "2025-06-01", "status": "approaching", "upgrade": "1.31"},
67
+ "1.30": {"eos": "2025-11-01", "status": "current", "upgrade": "1.31"},
68
+ "1.31": {"eos": "2026-03-01", "status": "current", "upgrade": None},
69
+ }
70
+
71
+ # ElastiCache Redis version staleness
72
+ REDIS_EOL = {
73
+ "5": {"status": "eol", "upgrade": "7"},
74
+ "6": {"status": "approaching", "upgrade": "7"},
75
+ "7": {"status": "current", "upgrade": None},
76
+ }
77
+
78
+
79
+ def load_eol_overrides():
80
+ """Load user overrides from ~/.Kulshan/age/eol_overrides.json and merge over defaults."""
81
+ if not os.path.exists(_OVERRIDES_PATH):
82
+ return
83
+
84
+ try:
85
+ with open(_OVERRIDES_PATH, "r") as f:
86
+ overrides = json.load(f)
87
+
88
+ # Merge lambda overrides
89
+ for runtime, info in overrides.get("lambda", {}).items():
90
+ LAMBDA_EOL[runtime] = info
91
+
92
+ # Merge RDS overrides
93
+ for engine, versions in overrides.get("rds", {}).items():
94
+ if engine not in RDS_EOL:
95
+ RDS_EOL[engine] = {}
96
+ for ver, info in versions.items():
97
+ RDS_EOL[engine][ver] = info
98
+
99
+ # Merge EKS overrides
100
+ for ver, info in overrides.get("eks", {}).items():
101
+ EKS_EOL[ver] = info
102
+
103
+ # Merge Redis overrides
104
+ for ver, info in overrides.get("redis", {}).items():
105
+ REDIS_EOL[ver] = info
106
+ except Exception:
107
+ pass # Silently ignore malformed overrides
108
+
109
+
110
+ def check_staleness():
111
+ """Check if the embedded EOL database is potentially outdated."""
112
+ from datetime import datetime
113
+ try:
114
+ updated = datetime.strptime(LAST_UPDATED, "%Y-%m-%d")
115
+ age_days = (datetime.now() - updated).days
116
+ if age_days > 180:
117
+ return f"EOL database is {age_days} days old."
118
+ except Exception:
119
+ pass
120
+ return None
121
+
122
+
123
+ # Auto-load overrides on import
124
+ load_eol_overrides()