certifyclouds 1.2.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.
cc_scanner/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CertifyClouds Scanner — scan Azure Key Vaults for expiring secrets, certificates, and keys."""
2
+
3
+ __version__ = "1.2.0"
cc_scanner/auth.py ADDED
@@ -0,0 +1,93 @@
1
+ """API key validation and telemetry for CertifyClouds Scanner.
2
+
3
+ Called after each scan to validate the key and send aggregate usage stats.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from . import __version__
13
+ from . import config as cfg
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ LICENSE_SERVER_URL = "https://license.certifyclouds.com"
18
+ VALIDATE_ENDPOINT = f"{LICENSE_SERVER_URL}/api/scanner/validate"
19
+ REQUEST_TIMEOUT = 10.0
20
+
21
+
22
+ class ValidationResult:
23
+ """Result of a key validation call."""
24
+ def __init__(self, valid: bool, error: str | None = None,
25
+ latest_version: str | None = None, offline: bool = False):
26
+ self.valid = valid
27
+ self.error = error
28
+ self.latest_version = latest_version
29
+ self.offline = offline
30
+
31
+
32
+ def validate_key(key: str, stats: dict[str, Any] | None = None) -> ValidationResult:
33
+ """Validate the API key and send usage stats.
34
+
35
+ Args:
36
+ key: The cc-scan API key
37
+ stats: Aggregate scan stats (counts only, no names/values)
38
+
39
+ Returns:
40
+ ValidationResult with valid/invalid/offline status
41
+ """
42
+ # Check if we can skip validation (cached grace period)
43
+ if stats is None and cfg.is_validation_fresh():
44
+ logger.debug("Skipping validation — last validated within grace period")
45
+ return ValidationResult(valid=True, offline=False)
46
+
47
+ payload: dict[str, Any] = {
48
+ "key": key,
49
+ "version": __version__,
50
+ }
51
+ if stats:
52
+ payload["stats"] = stats
53
+
54
+ try:
55
+ resp = httpx.post(
56
+ VALIDATE_ENDPOINT,
57
+ json=payload,
58
+ timeout=REQUEST_TIMEOUT,
59
+ )
60
+
61
+ if resp.status_code == 429:
62
+ # Rate limited — treat as valid (don't block the user)
63
+ logger.debug("Validation rate limited, treating as valid")
64
+ return ValidationResult(valid=True)
65
+
66
+ data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
67
+
68
+ if resp.status_code == 403 or not data.get("valid", False):
69
+ error = data.get("error", "Invalid API key")
70
+ cfg.clear_validation()
71
+ return ValidationResult(valid=False, error=error)
72
+
73
+ # Valid — update cache
74
+ cfg.update_last_validated()
75
+ return ValidationResult(
76
+ valid=True,
77
+ latest_version=data.get("latestVersion"),
78
+ )
79
+
80
+ except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPError) as exc:
81
+ logger.debug("Validation call failed: %s", exc)
82
+
83
+ # Offline — check cached validation
84
+ if cfg.is_validation_fresh():
85
+ return ValidationResult(valid=True, offline=True)
86
+
87
+ # No cached validation but still let them scan
88
+ return ValidationResult(valid=True, offline=True,
89
+ error="Could not reach license server")
90
+
91
+ except Exception as exc:
92
+ logger.debug("Unexpected validation error: %s", exc)
93
+ return ValidationResult(valid=True, offline=True)
cc_scanner/cli.py ADDED
@@ -0,0 +1,287 @@
1
+ """CertifyClouds Scanner CLI entry point.
2
+
3
+ Orchestrates: config → register → scan → filter → analyze → format → validate
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ import sys
10
+ import time
11
+
12
+ import click
13
+ from rich.console import Console
14
+ from rich.logging import RichHandler
15
+
16
+ from . import __version__
17
+ from . import config as cfg
18
+ from .auth import validate_key
19
+ from .filters import apply_filters
20
+ from .formatters import get_formatter
21
+ from .registration import register
22
+ from .scanner import scan
23
+ from .security import analyze, has_critical_or_high
24
+
25
+ console = Console()
26
+
27
+
28
+ _LOGO_LINES = [
29
+ " ▄▄███▄▄ ",
30
+ " ▄▄███▀▀ ▀▀███▄▄ ",
31
+ " ███▀▀ ▀▀███ ",
32
+ " ▄██ ▄▄███▄ ██ ",
33
+ " ▄▄█████ ██████▀▄██ ███▄▄▄▄ ",
34
+ " ▄███▀▀ ██ ██▄▀▀▄███▀ ██▀▀▀███▄ ",
35
+ " ██ ██▄ ▀███████▀ ▄██ ▀██▄ ",
36
+ " ██ ▀▀██▄▄ ▀▀█▀▀ ▄▄██▀▀ ██ ",
37
+ " ██▄ ▀▀██▄▄ ▄▄██▀▀ ▄██ ",
38
+ " ▀▀███▄▄▄▄▄▄▄▄▄███████▄▄▄▄▄▄▄▄▄▄▄██▀▀ ",
39
+ " ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ",
40
+ ]
41
+
42
+ _TEXT_LINES = [
43
+ " ▄███ ████ ███▄ ████ ██ ████ ██ ██ ▄███ ██ ▄███▄ ██ ██ ████▄ ▄███",
44
+ " ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ",
45
+ " ██ ████ ███▀ ██ ██ ████ ▀██▀ ██ ██ ██ ██ ██ ██ ██ ██ ▀█▄",
46
+ " ██ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██",
47
+ " ▀███ ████ ██ █ ██ ██ ██ ██ ▀███ ████ ▀███▀ ▀███▀ ████▀ ███▀",
48
+ ]
49
+
50
+ # Brand gradient from logo SVG: purple → mid-purple → blue → cyan
51
+ _GRADIENT_STOPS = [(137, 63, 254), (83, 83, 254), (40, 118, 254), (6, 182, 212)]
52
+
53
+
54
+ def _gradient_text(line: str):
55
+ """Apply brand gradient colour across a line of text."""
56
+ from rich.style import Style
57
+ from rich.text import Text
58
+
59
+ text = Text()
60
+ max_i = max(len(line) - 1, 1)
61
+ stops = _GRADIENT_STOPS
62
+ segments = len(stops) - 1
63
+ for i, ch in enumerate(line):
64
+ t = i / max_i
65
+ seg = min(int(t * segments), segments - 1)
66
+ t2 = (t * segments) - seg
67
+ c1, c2 = stops[seg], stops[seg + 1]
68
+ r = int(c1[0] + (c2[0] - c1[0]) * t2)
69
+ g = int(c1[1] + (c2[1] - c1[1]) * t2)
70
+ b = int(c1[2] + (c2[2] - c1[2]) * t2)
71
+ text.append(ch, style=Style(color=f"#{r:02x}{g:02x}{b:02x}", bold=True))
72
+ return text
73
+
74
+
75
+ def _print_banner(email: str | None = None) -> None:
76
+ """Print a professional startup banner with logo and ASCII art in brand gradient."""
77
+ console.print()
78
+ for line in _LOGO_LINES:
79
+ console.print(_gradient_text(line))
80
+ for line in _TEXT_LINES:
81
+ console.print(_gradient_text(line))
82
+ console.print()
83
+ console.print(f" [bold]Scanner[/bold] [dim]v{__version__}[/dim] [dim]·[/dim] [dim]Azure Key Vault security audit[/dim]")
84
+ if email:
85
+ console.print(f" [dim]{email}[/dim]")
86
+ console.print()
87
+
88
+
89
+ def _setup_logging(verbosity: int) -> None:
90
+ """Configure logging based on verbosity level."""
91
+ if verbosity >= 2:
92
+ level = logging.DEBUG
93
+ elif verbosity >= 1:
94
+ level = logging.INFO
95
+ else:
96
+ level = logging.WARNING
97
+
98
+ logging.basicConfig(
99
+ level=level,
100
+ format="%(message)s",
101
+ handlers=[RichHandler(show_time=False, show_path=False, console=Console(stderr=True))],
102
+ )
103
+
104
+ # Suppress noisy Azure SDK and HTTP client logs
105
+ for name in ("azure", "azure.core", "azure.identity", "azure.mgmt", "azure.keyvault",
106
+ "msal", "msal_extensions", "urllib3", "httpx", "httpcore"):
107
+ logging.getLogger(name).setLevel(logging.WARNING)
108
+
109
+
110
+ @click.command()
111
+ @click.option("--format", "fmt", type=click.Choice(["table", "json", "csv", "html"]),
112
+ default=None, help="Output format (default: table)")
113
+ @click.option("--output", "-o", "output_file", type=str, default=None,
114
+ help="Write output to file instead of stdout")
115
+ @click.option("--expiring", type=int, default=None,
116
+ help="Only show items expiring within N days")
117
+ @click.option("--expired", is_flag=True, default=False,
118
+ help="Only show already-expired items")
119
+ @click.option("--type", "asset_type", type=click.Choice(["secrets", "certificates", "keys"]),
120
+ default=None, help="Only show this asset type")
121
+ @click.option("--vault", "vault_names", type=str, multiple=True,
122
+ help="Only scan specific vault(s) (repeatable)")
123
+ @click.option("--subscription", "subscription_ids", type=str, multiple=True,
124
+ help="Only scan specific subscription(s) (repeatable)")
125
+ @click.option("--security", "security_only", is_flag=True, default=False,
126
+ help="Show security findings summary only")
127
+ @click.option("--auth", "auth_method", type=click.Choice(["cli", "default"]),
128
+ default=None, help="Azure auth method (default: cli)")
129
+ @click.option("--workers", type=int, default=None,
130
+ help="Parallel scan workers (default: 5, max: 20)")
131
+ @click.option("--timeout", type=int, default=None,
132
+ help="Scan timeout in seconds (default: 300)")
133
+ @click.option("--key", "api_key", type=str, default=None,
134
+ help="API key (overrides saved config)")
135
+ @click.option("--register", "force_register", is_flag=True, default=False,
136
+ help="Re-register / change email")
137
+ @click.option("--offline", is_flag=True, default=False,
138
+ help="Skip license server validation")
139
+ @click.option("-v", "--verbose", count=True,
140
+ help="Increase verbosity (-v for info, -vv for debug)")
141
+ @click.version_option(version=__version__, prog_name="cc-scan")
142
+ def main(
143
+ fmt: str | None,
144
+ output_file: str | None,
145
+ expiring: int | None,
146
+ expired: bool,
147
+ asset_type: str | None,
148
+ vault_names: tuple[str, ...],
149
+ subscription_ids: tuple[str, ...],
150
+ security_only: bool,
151
+ auth_method: str | None,
152
+ workers: int | None,
153
+ timeout: int | None,
154
+ api_key: str | None,
155
+ force_register: bool,
156
+ offline: bool,
157
+ verbose: int,
158
+ ) -> None:
159
+ """Scan Azure Key Vaults for expiring secrets, certificates, and keys."""
160
+ _setup_logging(verbose)
161
+
162
+ # Resolve settings: flag > env var > default
163
+ fmt = fmt or os.environ.get("CC_SCAN_FORMAT", "table")
164
+ auth_method = auth_method or os.environ.get("CC_SCAN_AUTH", "cli")
165
+ workers = workers or int(os.environ.get("CC_SCAN_WORKERS", "5"))
166
+ timeout = timeout or int(os.environ.get("CC_SCAN_TIMEOUT", "300"))
167
+ offline = offline or os.environ.get("CC_SCAN_OFFLINE", "").lower() in ("true", "1")
168
+ workers = min(max(workers, 1), 20)
169
+
170
+ # Resolve API key: flag > env var > config file
171
+ key = api_key or os.environ.get("CC_SCAN_KEY") or cfg.get_key()
172
+
173
+ # Registration flow
174
+ if force_register or not key:
175
+ if not force_register and not key:
176
+ console.print()
177
+ console.print(f"[bold]CertifyClouds Scanner[/bold] v{__version__}")
178
+
179
+ key = register()
180
+ if not key:
181
+ sys.exit(3)
182
+
183
+ email = cfg.get_email()
184
+
185
+ # Print header (table format only)
186
+ if fmt == "table":
187
+ _print_banner(email)
188
+
189
+ # Run scan
190
+ if fmt == "table":
191
+ console.print("[dim]Scanning Azure subscriptions...[/dim]")
192
+ console.print()
193
+
194
+ start_time = time.time()
195
+
196
+ try:
197
+ results = scan(
198
+ auth_method=auth_method,
199
+ subscription_filter=list(subscription_ids) if subscription_ids else None,
200
+ vault_filter=list(vault_names) if vault_names else None,
201
+ max_workers=workers,
202
+ timeout=timeout,
203
+ )
204
+ except KeyboardInterrupt:
205
+ console.print()
206
+ console.print("[yellow]Scan interrupted.[/yellow]")
207
+ sys.exit(2)
208
+
209
+ scan_duration_ms = int((time.time() - start_time) * 1000)
210
+
211
+ # Check for fatal scan error
212
+ if results.get("error") and not results.get("subscriptions"):
213
+ if fmt == "table":
214
+ console.print(f"[red]Error: {results['error']}[/red]")
215
+ console.print()
216
+ if "credentials" in results["error"].lower() or "login" in results["error"].lower():
217
+ console.print("Run 'az login' to authenticate with Azure CLI, or set these environment variables")
218
+ console.print("for service principal auth:")
219
+ console.print(" AZURE_CLIENT_ID")
220
+ console.print(" AZURE_CLIENT_SECRET")
221
+ console.print(" AZURE_TENANT_ID")
222
+ elif fmt == "json":
223
+ get_formatter("json")(results, [], output_file)
224
+ sys.exit(2)
225
+
226
+ # Apply filters
227
+ apply_filters(results, expiring_days=expiring, expired_only=expired, asset_type=asset_type)
228
+
229
+ # Security analysis
230
+ findings = analyze(results)
231
+
232
+ # Compute expiry summary before any formatting modifies results
233
+ from .filters import compute_expiry_summary
234
+ expiry_summary = compute_expiry_summary(results)
235
+
236
+ # Format output
237
+ formatter = get_formatter(fmt)
238
+ if security_only and fmt == "table":
239
+ from .formatters.table import format_table_security_only
240
+ format_table_security_only(results, findings, output_file)
241
+ else:
242
+ formatter(results, findings, output_file)
243
+
244
+ # Post-scan validation (telemetry) — only if not offline
245
+ if not offline:
246
+ stats = {
247
+ "subscriptions": len(results.get("subscriptions", [])),
248
+ "vaults": results.get("totalVaults", 0),
249
+ "secrets": results.get("totalSecrets", 0),
250
+ "certificates": results.get("totalCertificates", 0),
251
+ "keys": results.get("totalKeys", 0),
252
+ "expired": sum(expiry_summary["expired"].values()),
253
+ "expiringSoon": sum(expiry_summary["30_days"].values()),
254
+ "securityFindings": len(findings),
255
+ "vaultsWithErrors": results.get("vaultsWithErrors", 0),
256
+ "scanDurationMs": scan_duration_ms,
257
+ }
258
+
259
+ validation = validate_key(key, stats=stats)
260
+
261
+ if fmt == "table":
262
+ if not validation.valid:
263
+ console.print(f"[yellow]Warning: {validation.error}[/yellow]")
264
+ console.print("[yellow]Run 'cc-scan --register' to get a new key.[/yellow]")
265
+ console.print()
266
+ elif validation.offline and validation.error:
267
+ console.print(f"[dim]{validation.error}. Scanning in offline mode.[/dim]")
268
+ elif validation.latest_version and validation.latest_version != __version__:
269
+ console.print(f"[dim]Update available: cc-scan {__version__} → {validation.latest_version} — pip install --upgrade cc-scanner[/dim]")
270
+
271
+ # CTA (table format only, after version nudge per plan section 11.3)
272
+ if fmt == "table":
273
+ total_expired = sum(expiry_summary["expired"].values())
274
+ if total_expired > 5:
275
+ console.print(f"[dim]{total_expired} expired items found. CertifyClouds Pro auto-rotates these → certifyclouds.com/pro[/dim]")
276
+ else:
277
+ console.print("[dim]Automate rotation & compliance → certifyclouds.com[/dim]")
278
+ console.print()
279
+
280
+ # Exit code
281
+ if has_critical_or_high(findings):
282
+ sys.exit(1)
283
+ sys.exit(0)
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()
cc_scanner/config.py ADDED
@@ -0,0 +1,90 @@
1
+ """Local configuration management for CertifyClouds Scanner.
2
+
3
+ Reads/writes ~/.certifyclouds/config.json to persist API key and validation state.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ CONFIG_DIR = Path.home() / ".certifyclouds"
16
+ CONFIG_FILE = CONFIG_DIR / "config.json"
17
+
18
+
19
+ def load_config() -> dict[str, Any]:
20
+ """Load config from ~/.certifyclouds/config.json. Returns empty dict if missing or corrupt."""
21
+ if not CONFIG_FILE.exists():
22
+ return {}
23
+ try:
24
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
25
+ except (json.JSONDecodeError, OSError) as exc:
26
+ logger.warning("Config file corrupt or unreadable (%s), treating as empty", exc)
27
+ return {}
28
+
29
+
30
+ def save_config(data: dict[str, Any]) -> None:
31
+ """Save config to ~/.certifyclouds/config.json. Creates directory if needed."""
32
+ CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
33
+ CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
34
+ # Set file permissions to owner-only (best-effort on Windows)
35
+ try:
36
+ CONFIG_FILE.chmod(0o600)
37
+ except OSError:
38
+ pass
39
+
40
+
41
+ def get_key() -> str | None:
42
+ """Return the saved API key, or None."""
43
+ return load_config().get("key")
44
+
45
+
46
+ def get_email() -> str | None:
47
+ """Return the saved email, or None."""
48
+ return load_config().get("email")
49
+
50
+
51
+ def save_registration(key: str, email: str) -> None:
52
+ """Save a new registration to config."""
53
+ config = load_config()
54
+ config.update({
55
+ "key": key,
56
+ "email": email,
57
+ "registered_at": datetime.now(timezone.utc).isoformat(),
58
+ "last_validated": datetime.now(timezone.utc).isoformat(),
59
+ })
60
+ save_config(config)
61
+
62
+
63
+ def update_last_validated() -> None:
64
+ """Update the last_validated timestamp."""
65
+ config = load_config()
66
+ config["last_validated"] = datetime.now(timezone.utc).isoformat()
67
+ save_config(config)
68
+
69
+
70
+ def clear_validation() -> None:
71
+ """Clear the last_validated timestamp (key became invalid)."""
72
+ config = load_config()
73
+ config.pop("last_validated", None)
74
+ save_config(config)
75
+
76
+
77
+ def is_validation_fresh(grace_days: int = 7) -> bool:
78
+ """Check if last_validated is within grace_days (skip re-validation)."""
79
+ config = load_config()
80
+ last = config.get("last_validated")
81
+ if not last:
82
+ return False
83
+ try:
84
+ last_dt = datetime.fromisoformat(last)
85
+ if last_dt.tzinfo is None:
86
+ last_dt = last_dt.replace(tzinfo=timezone.utc)
87
+ age = datetime.now(timezone.utc) - last_dt
88
+ return age.days < grace_days
89
+ except (ValueError, TypeError):
90
+ return False
cc_scanner/filters.py ADDED
@@ -0,0 +1,142 @@
1
+ """Result filtering for CertifyClouds Scanner.
2
+
3
+ Filters scan results by expiry, asset type, and vault name.
4
+ Applied after scanning, before formatting.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+
11
+
12
+ def apply_filters(
13
+ results: dict[str, Any],
14
+ expiring_days: int | None = None,
15
+ expired_only: bool = False,
16
+ asset_type: str | None = None,
17
+ ) -> dict[str, Any]:
18
+ """Apply filters to scan results, modifying the results dict in place.
19
+
20
+ Args:
21
+ results: Scan results dict from scanner.scan()
22
+ expiring_days: Only show items expiring within N days (None = no filter)
23
+ expired_only: Only show already-expired items
24
+ asset_type: Only show this type: "secrets", "certificates", or "keys"
25
+ """
26
+ if not expiring_days and not expired_only and not asset_type:
27
+ return results
28
+
29
+ now = datetime.now(timezone.utc)
30
+
31
+ for sub in results.get("subscriptions", []):
32
+ for vault in sub.get("vaults", []):
33
+ # Type filtering
34
+ if asset_type:
35
+ if asset_type != "secrets":
36
+ vault["secrets"] = []
37
+ if asset_type != "certificates":
38
+ vault["certificates"] = []
39
+ if asset_type != "keys":
40
+ vault["keys"] = []
41
+
42
+ # Expiry filtering
43
+ if expiring_days is not None or expired_only:
44
+ vault["secrets"] = [s for s in vault["secrets"] if _passes_expiry_filter(s, now, expiring_days, expired_only)]
45
+ vault["certificates"] = [c for c in vault["certificates"] if _passes_expiry_filter(c, now, expiring_days, expired_only)]
46
+ vault["keys"] = [k for k in vault["keys"] if _passes_expiry_filter(k, now, expiring_days, expired_only)]
47
+
48
+ # Recount totals
49
+ results["totalSecrets"] = 0
50
+ results["totalCertificates"] = 0
51
+ results["totalKeys"] = 0
52
+ for sub in results.get("subscriptions", []):
53
+ for vault in sub.get("vaults", []):
54
+ results["totalSecrets"] += len(vault.get("secrets", []))
55
+ results["totalCertificates"] += len(vault.get("certificates", []))
56
+ results["totalKeys"] += len(vault.get("keys", []))
57
+
58
+ return results
59
+
60
+
61
+ def _passes_expiry_filter(
62
+ asset: dict[str, Any],
63
+ now: datetime,
64
+ expiring_days: int | None,
65
+ expired_only: bool,
66
+ ) -> bool:
67
+ """Check if an asset passes the expiry filter."""
68
+ expires_on = asset.get("expiresOn")
69
+
70
+ if expires_on is None:
71
+ # No expiry — exclude when filtering by expiry
72
+ return False
73
+
74
+ try:
75
+ expiry = datetime.fromisoformat(expires_on.replace("Z", "+00:00"))
76
+ if expiry.tzinfo is None:
77
+ expiry = expiry.replace(tzinfo=timezone.utc)
78
+ except (ValueError, TypeError):
79
+ return False
80
+
81
+ if expired_only:
82
+ return expiry < now
83
+
84
+ if expiring_days is not None:
85
+ days_left = (expiry - now).days
86
+ return days_left <= expiring_days
87
+
88
+ return True
89
+
90
+
91
+ def compute_expiry_summary(results: dict[str, Any]) -> dict[str, dict[str, int]]:
92
+ """Compute expiry summary buckets from scan results.
93
+
94
+ Returns:
95
+ Dict with keys: "expired", "7_days", "30_days", "90_days", "ok", "no_expiry"
96
+ Each value is a dict with keys: "secrets", "certificates", "keys"
97
+ """
98
+ now = datetime.now(timezone.utc)
99
+ buckets = {
100
+ "expired": {"secrets": 0, "certificates": 0, "keys": 0},
101
+ "7_days": {"secrets": 0, "certificates": 0, "keys": 0},
102
+ "30_days": {"secrets": 0, "certificates": 0, "keys": 0},
103
+ "90_days": {"secrets": 0, "certificates": 0, "keys": 0},
104
+ "ok": {"secrets": 0, "certificates": 0, "keys": 0},
105
+ "no_expiry": {"secrets": 0, "certificates": 0, "keys": 0},
106
+ }
107
+
108
+ for sub in results.get("subscriptions", []):
109
+ for vault in sub.get("vaults", []):
110
+ for item_type, items in [("secrets", vault.get("secrets", [])), ("certificates", vault.get("certificates", [])), ("keys", vault.get("keys", []))]:
111
+ for item in items:
112
+ bucket = _get_expiry_bucket(item, now)
113
+ buckets[bucket][item_type] += 1
114
+
115
+ return buckets
116
+
117
+
118
+ def _get_expiry_bucket(asset: dict[str, Any], now: datetime) -> str:
119
+ """Determine which expiry bucket an asset falls into."""
120
+ expires_on = asset.get("expiresOn")
121
+
122
+ if expires_on is None:
123
+ return "no_expiry"
124
+
125
+ try:
126
+ expiry = datetime.fromisoformat(expires_on.replace("Z", "+00:00"))
127
+ if expiry.tzinfo is None:
128
+ expiry = expiry.replace(tzinfo=timezone.utc)
129
+ except (ValueError, TypeError):
130
+ return "no_expiry"
131
+
132
+ if expiry < now:
133
+ return "expired"
134
+
135
+ days_left = (expiry - now).days
136
+ if days_left <= 7:
137
+ return "7_days"
138
+ if days_left <= 30:
139
+ return "30_days"
140
+ if days_left <= 90:
141
+ return "90_days"
142
+ return "ok"
@@ -0,0 +1,19 @@
1
+ """Output formatters for scan results."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def get_formatter(format_name: str):
7
+ """Return the formatter function for the given format name."""
8
+ if format_name == "json":
9
+ from .json_fmt import format_json
10
+ return format_json
11
+ elif format_name == "csv":
12
+ from .csv_fmt import format_csv
13
+ return format_csv
14
+ elif format_name == "html":
15
+ from .html import format_html
16
+ return format_html
17
+ else:
18
+ from .table import format_table
19
+ return format_table