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 +3 -0
- cc_scanner/auth.py +93 -0
- cc_scanner/cli.py +287 -0
- cc_scanner/config.py +90 -0
- cc_scanner/filters.py +142 -0
- cc_scanner/formatters/__init__.py +19 -0
- cc_scanner/formatters/csv_fmt.py +74 -0
- cc_scanner/formatters/html.py +187 -0
- cc_scanner/formatters/json_fmt.py +36 -0
- cc_scanner/formatters/table.py +203 -0
- cc_scanner/registration.py +91 -0
- cc_scanner/scanner.py +558 -0
- cc_scanner/security.py +175 -0
- cc_scanner/thumbprint.py +98 -0
- certifyclouds-1.2.0.dist-info/METADATA +253 -0
- certifyclouds-1.2.0.dist-info/RECORD +19 -0
- certifyclouds-1.2.0.dist-info/WHEEL +4 -0
- certifyclouds-1.2.0.dist-info/entry_points.txt +2 -0
- certifyclouds-1.2.0.dist-info/licenses/LICENSE +190 -0
cc_scanner/__init__.py
ADDED
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
|