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.
- kulshan/__init__.py +9 -0
- kulshan/__version__.py +1 -0
- kulshan/adapter.py +244 -0
- kulshan/checks/__init__.py +9 -0
- kulshan/checks/age/__init__.py +134 -0
- kulshan/checks/age/scanner/__init__.py +0 -0
- kulshan/checks/age/scanner/eol_db.py +124 -0
- kulshan/checks/age/scanner/freshness.py +241 -0
- kulshan/checks/age/scoring/__init__.py +0 -0
- kulshan/checks/age/scoring/engine.py +78 -0
- kulshan/checks/age/utils/__init__.py +0 -0
- kulshan/checks/age/utils/aws.py +52 -0
- kulshan/checks/cost/__init__.py +434 -0
- kulshan/checks/cost/analyzers/__init__.py +0 -0
- kulshan/checks/cost/analyzers/advanced.py +519 -0
- kulshan/checks/cost/analyzers/anomaly.py +258 -0
- kulshan/checks/cost/analyzers/budgets.py +146 -0
- kulshan/checks/cost/analyzers/efficiency.py +192 -0
- kulshan/checks/cost/analyzers/insights.py +144 -0
- kulshan/checks/cost/analyzers/network.py +81 -0
- kulshan/checks/cost/analyzers/network_cost.py +154 -0
- kulshan/checks/cost/analyzers/tag_attribution.py +110 -0
- kulshan/checks/cost/analyzers/trends.py +78 -0
- kulshan/checks/cost/analyzers/waste.py +67 -0
- kulshan/checks/cost/analyzers/wow.py +325 -0
- kulshan/checks/cost/attribution.py +205 -0
- kulshan/checks/cost/aws_native.py +307 -0
- kulshan/checks/cost/config.py +0 -0
- kulshan/checks/cost/cost_fetcher.py +878 -0
- kulshan/checks/cost/reporters/__init__.py +1 -0
- kulshan/checks/cost/reporters/exporter.py +458 -0
- kulshan/checks/cost/reporters/html_report.py +470 -0
- kulshan/checks/cost/reporters/summary_pdf.py +148 -0
- kulshan/checks/cost/reporters/terminal.py +1889 -0
- kulshan/checks/dr/__init__.py +160 -0
- kulshan/checks/dr/scanner/__init__.py +0 -0
- kulshan/checks/dr/scanner/backup.py +113 -0
- kulshan/checks/dr/scanner/compute.py +128 -0
- kulshan/checks/dr/scanner/database.py +171 -0
- kulshan/checks/dr/scanner/dns.py +98 -0
- kulshan/checks/dr/scanner/spof.py +96 -0
- kulshan/checks/dr/scanner/storage.py +86 -0
- kulshan/checks/dr/scoring/__init__.py +0 -0
- kulshan/checks/dr/scoring/engine.py +162 -0
- kulshan/checks/dr/scoring/simulator.py +170 -0
- kulshan/checks/dr/utils/__init__.py +0 -0
- kulshan/checks/dr/utils/aws.py +69 -0
- kulshan/checks/drift/__init__.py +119 -0
- kulshan/checks/drift/scanner/__init__.py +0 -0
- kulshan/checks/drift/scanner/cfn_drift.py +205 -0
- kulshan/checks/drift/scanner/coverage.py +127 -0
- kulshan/checks/drift/scoring/__init__.py +0 -0
- kulshan/checks/drift/scoring/engine.py +106 -0
- kulshan/checks/drift/utils/__init__.py +0 -0
- kulshan/checks/drift/utils/aws.py +68 -0
- kulshan/checks/limit/__init__.py +107 -0
- kulshan/checks/limit/scanner/__init__.py +0 -0
- kulshan/checks/limit/scanner/quotas.py +228 -0
- kulshan/checks/limit/scoring/__init__.py +0 -0
- kulshan/checks/limit/scoring/engine.py +138 -0
- kulshan/checks/limit/utils/__init__.py +0 -0
- kulshan/checks/limit/utils/aws.py +69 -0
- kulshan/checks/pulse/__init__.py +133 -0
- kulshan/checks/pulse/scanner/__init__.py +0 -0
- kulshan/checks/pulse/scanner/alarms.py +167 -0
- kulshan/checks/pulse/scanner/logging.py +141 -0
- kulshan/checks/pulse/scanner/tracing.py +135 -0
- kulshan/checks/pulse/scoring/__init__.py +0 -0
- kulshan/checks/pulse/scoring/engine.py +151 -0
- kulshan/checks/pulse/utils/__init__.py +0 -0
- kulshan/checks/pulse/utils/aws.py +68 -0
- kulshan/checks/security/__init__.py +239 -0
- kulshan/checks/security/blame.py +83 -0
- kulshan/checks/security/crown_jewels.py +126 -0
- kulshan/checks/security/graph/__init__.py +0 -0
- kulshan/checks/security/graph/builder.py +113 -0
- kulshan/checks/security/remediation.py +201 -0
- kulshan/checks/security/scanner/__init__.py +0 -0
- kulshan/checks/security/scanner/base.py +63 -0
- kulshan/checks/security/scanner/compute.py +115 -0
- kulshan/checks/security/scanner/data.py +129 -0
- kulshan/checks/security/scanner/encryption.py +79 -0
- kulshan/checks/security/scanner/iam.py +444 -0
- kulshan/checks/security/scanner/logging_monitor.py +128 -0
- kulshan/checks/security/scanner/network.py +251 -0
- kulshan/checks/security/scoring/__init__.py +0 -0
- kulshan/checks/security/scoring/breach_cost.py +54 -0
- kulshan/checks/security/scoring/compliance.py +75 -0
- kulshan/checks/security/scoring/diff.py +48 -0
- kulshan/checks/security/scoring/engine.py +85 -0
- kulshan/checks/security/scoring/exposure.py +116 -0
- kulshan/checks/security/scoring/history.py +77 -0
- kulshan/checks/security/scoring/inventory.py +28 -0
- kulshan/checks/security/utils/__init__.py +0 -0
- kulshan/checks/security/utils/aws.py +134 -0
- kulshan/checks/sweep/__init__.py +126 -0
- kulshan/checks/sweep/scanner/__init__.py +0 -0
- kulshan/checks/sweep/scanner/compute.py +168 -0
- kulshan/checks/sweep/scanner/database.py +70 -0
- kulshan/checks/sweep/scanner/monitoring.py +136 -0
- kulshan/checks/sweep/scanner/network.py +164 -0
- kulshan/checks/sweep/scanner/storage.py +87 -0
- kulshan/checks/sweep/scoring/__init__.py +0 -0
- kulshan/checks/sweep/scoring/engine.py +123 -0
- kulshan/checks/sweep/utils/__init__.py +0 -0
- kulshan/checks/sweep/utils/aws.py +70 -0
- kulshan/checks/tag/__init__.py +135 -0
- kulshan/checks/tag/analysis/__init__.py +1 -0
- kulshan/checks/tag/analysis/compliance.py +85 -0
- kulshan/checks/tag/analysis/consistency.py +96 -0
- kulshan/checks/tag/analysis/cost_impact.py +63 -0
- kulshan/checks/tag/scanner/__init__.py +1 -0
- kulshan/checks/tag/scanner/tagging_api.py +91 -0
- kulshan/checks/tag/scoring/__init__.py +1 -0
- kulshan/checks/tag/scoring/engine.py +82 -0
- kulshan/checks/tag/utils/__init__.py +1 -0
- kulshan/checks/tag/utils/aws.py +77 -0
- kulshan/checks/topo/__init__.py +131 -0
- kulshan/checks/topo/scanner/__init__.py +0 -0
- kulshan/checks/topo/scanner/topology.py +377 -0
- kulshan/checks/topo/scoring/__init__.py +0 -0
- kulshan/checks/topo/scoring/engine.py +95 -0
- kulshan/checks/topo/utils/__init__.py +0 -0
- kulshan/checks/topo/utils/aws.py +46 -0
- kulshan/ci/__init__.py +1 -0
- kulshan/cli.py +631 -0
- kulshan/commands/__init__.py +1 -0
- kulshan/completion.py +173 -0
- kulshan/config/__init__.py +1 -0
- kulshan/constants.py +21 -0
- kulshan/diagnostics.py +190 -0
- kulshan/errors.py +29 -0
- kulshan/findings.py +5 -0
- kulshan/findings_ranker.py +134 -0
- kulshan/help_formatter.py +66 -0
- kulshan/history/__init__.py +229 -0
- kulshan/license/__init__.py +1 -0
- kulshan/models.py +1111 -0
- kulshan/orchestrator.py +389 -0
- kulshan/plugins/__init__.py +1 -0
- kulshan/preflight.py +143 -0
- kulshan/question_mark.py +234 -0
- kulshan/redact.py +295 -0
- kulshan/remediation.py +103 -0
- kulshan/repl.py +343 -0
- kulshan/report/__init__.py +1 -0
- kulshan/report/csv_export.py +56 -0
- kulshan/report/html.py +1534 -0
- kulshan/report/sarif.py +173 -0
- kulshan/report/terminal.py +308 -0
- kulshan/scoring_utils.py +23 -0
- kulshan/session.py +64 -0
- kulshan/setup.py +87 -0
- kulshan/slm/__init__.py +1 -0
- kulshan/slm/backends/__init__.py +1 -0
- kulshan/telemetry/__init__.py +1 -0
- kulshan/theme.py +97 -0
- kulshan/theme_constants.py +44 -0
- kulshan/trust/__init__.py +1 -0
- kulshan/utils/__init__.py +1 -0
- kulshan-0.1.0.dist-info/METADATA +136 -0
- kulshan-0.1.0.dist-info/RECORD +165 -0
- kulshan-0.1.0.dist-info/WHEEL +4 -0
- kulshan-0.1.0.dist-info/entry_points.txt +2 -0
- kulshan-0.1.0.dist-info/licenses/LICENSE.txt +189 -0
kulshan/__init__.py
ADDED
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()
|