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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""CSV formatter for scan results."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import io
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_csv(
|
|
11
|
+
results: dict[str, Any],
|
|
12
|
+
findings: list[dict[str, Any]],
|
|
13
|
+
output_file: str | None = None,
|
|
14
|
+
) -> str | None:
|
|
15
|
+
"""Format scan results as flat CSV (one row per asset).
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
results: Scan results dict
|
|
19
|
+
findings: Security findings list (not included in CSV — use JSON for that)
|
|
20
|
+
output_file: If set, write to file
|
|
21
|
+
"""
|
|
22
|
+
output = io.StringIO() if not output_file else open(output_file, "w", newline="", encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
writer = csv.writer(output)
|
|
25
|
+
writer.writerow([
|
|
26
|
+
"subscription", "vault", "type", "name", "enabled", "expires",
|
|
27
|
+
"days_until_expiry", "content_type", "version", "tags",
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
now = datetime.now(timezone.utc)
|
|
31
|
+
|
|
32
|
+
for sub in results.get("subscriptions", []):
|
|
33
|
+
sub_name = sub.get("name", sub.get("id", ""))
|
|
34
|
+
for vault in sub.get("vaults", []):
|
|
35
|
+
vault_name = vault.get("name", "")
|
|
36
|
+
for item_type, items in [
|
|
37
|
+
("secret", vault.get("secrets", [])),
|
|
38
|
+
("certificate", vault.get("certificates", [])),
|
|
39
|
+
("key", vault.get("keys", [])),
|
|
40
|
+
]:
|
|
41
|
+
for item in items:
|
|
42
|
+
days_left = ""
|
|
43
|
+
expires_on = item.get("expiresOn")
|
|
44
|
+
if expires_on:
|
|
45
|
+
try:
|
|
46
|
+
expiry = datetime.fromisoformat(expires_on.replace("Z", "+00:00"))
|
|
47
|
+
if expiry.tzinfo is None:
|
|
48
|
+
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
49
|
+
days_left = str((expiry - now).days)
|
|
50
|
+
except (ValueError, TypeError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
tags = item.get("tags", {})
|
|
54
|
+
tags_str = "; ".join(f"{k}={v}" for k, v in tags.items()) if tags else ""
|
|
55
|
+
|
|
56
|
+
writer.writerow([
|
|
57
|
+
sub_name,
|
|
58
|
+
vault_name,
|
|
59
|
+
item_type,
|
|
60
|
+
item.get("name", ""),
|
|
61
|
+
item.get("enabled", ""),
|
|
62
|
+
expires_on or "",
|
|
63
|
+
days_left,
|
|
64
|
+
item.get("contentType", ""),
|
|
65
|
+
item.get("version", ""),
|
|
66
|
+
tags_str,
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
if output_file:
|
|
70
|
+
output.close()
|
|
71
|
+
else:
|
|
72
|
+
print(output.getvalue(), end="")
|
|
73
|
+
|
|
74
|
+
return None
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""HTML report formatter for scan results."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import html
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .. import __version__
|
|
9
|
+
from ..filters import compute_expiry_summary
|
|
10
|
+
|
|
11
|
+
SEVERITY_COLORS = {
|
|
12
|
+
"CRITICAL": "#ef4444",
|
|
13
|
+
"HIGH": "#f97316",
|
|
14
|
+
"MEDIUM": "#eab308",
|
|
15
|
+
"LOW": "#3b82f6",
|
|
16
|
+
"INFO": "#6b7280",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
STATUS_COLORS = {
|
|
20
|
+
"expired": "#ef4444",
|
|
21
|
+
"7_days": "#f97316",
|
|
22
|
+
"30_days": "#eab308",
|
|
23
|
+
"90_days": "#a3e635",
|
|
24
|
+
"ok": "#22c55e",
|
|
25
|
+
"no_expiry": "#6b7280",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_html(
|
|
30
|
+
results: dict[str, Any],
|
|
31
|
+
findings: list[dict[str, Any]],
|
|
32
|
+
output_file: str | None = None,
|
|
33
|
+
) -> str | None:
|
|
34
|
+
"""Format scan results as a standalone HTML report."""
|
|
35
|
+
scan_time = results.get("scannedAt", datetime.now(timezone.utc).isoformat())
|
|
36
|
+
total = results.get("totalSecrets", 0) + results.get("totalCertificates", 0) + results.get("totalKeys", 0)
|
|
37
|
+
expiry = compute_expiry_summary(results)
|
|
38
|
+
# Build HTML
|
|
39
|
+
h = []
|
|
40
|
+
h.append("<!DOCTYPE html>")
|
|
41
|
+
h.append('<html lang="en"><head><meta charset="utf-8">')
|
|
42
|
+
h.append('<meta name="viewport" content="width=device-width, initial-scale=1.0">')
|
|
43
|
+
h.append(f"<title>CertifyClouds Scanner Report — {scan_time[:10]}</title>")
|
|
44
|
+
h.append(f"<style>{_css()}</style>")
|
|
45
|
+
h.append("</head><body>")
|
|
46
|
+
|
|
47
|
+
# Header
|
|
48
|
+
h.append('<header><h1>CertifyClouds Scanner Report</h1>')
|
|
49
|
+
h.append(f'<p>Generated: {scan_time} | Scanner v{__version__}</p></header>')
|
|
50
|
+
|
|
51
|
+
# Summary cards
|
|
52
|
+
h.append('<section class="cards">')
|
|
53
|
+
for label, value in [
|
|
54
|
+
("Subscriptions", len(results.get("subscriptions", []))),
|
|
55
|
+
("Vaults", results.get("totalVaults", 0)),
|
|
56
|
+
("Secrets", results.get("totalSecrets", 0)),
|
|
57
|
+
("Certificates", results.get("totalCertificates", 0)),
|
|
58
|
+
("Keys", results.get("totalKeys", 0)),
|
|
59
|
+
]:
|
|
60
|
+
h.append(f'<div class="card"><div class="card-value">{value}</div><div class="card-label">{label}</div></div>')
|
|
61
|
+
h.append("</section>")
|
|
62
|
+
|
|
63
|
+
# Expiry breakdown
|
|
64
|
+
h.append("<section><h2>Expiry Status</h2>")
|
|
65
|
+
h.append('<div class="expiry-bars">')
|
|
66
|
+
for bucket, label in [("expired", "Expired"), ("7_days", "< 7 days"), ("30_days", "< 30 days"), ("90_days", "< 90 days"), ("ok", "OK"), ("no_expiry", "No expiry")]:
|
|
67
|
+
count = sum(expiry[bucket].values())
|
|
68
|
+
color = STATUS_COLORS[bucket]
|
|
69
|
+
width = min(max(count / max(total, 1) * 100, 2), 100) if count > 0 else 0
|
|
70
|
+
h.append(f'<div class="bar-row"><span class="bar-label">{label}</span>')
|
|
71
|
+
h.append(f'<div class="bar" style="width:{width}%;background:{color};">{count}</div></div>')
|
|
72
|
+
h.append("</div></section>")
|
|
73
|
+
|
|
74
|
+
# Security findings
|
|
75
|
+
if findings:
|
|
76
|
+
h.append(f"<section><h2>Security Findings ({len(findings)})</h2>")
|
|
77
|
+
h.append('<table><tr><th>Severity</th><th>Finding</th><th>Resource</th><th>Recommendation</th></tr>')
|
|
78
|
+
for f in findings:
|
|
79
|
+
color = SEVERITY_COLORS.get(f["severity"], "#6b7280")
|
|
80
|
+
h.append(f'<tr><td><span class="badge" style="background:{color}">{html.escape(f["severity"])}</span></td>')
|
|
81
|
+
h.append(f'<td>{html.escape(f["title"])}</td>')
|
|
82
|
+
h.append(f'<td>{html.escape(f["resource"])}</td>')
|
|
83
|
+
h.append(f'<td>{html.escape(f.get("recommendation", ""))}</td></tr>')
|
|
84
|
+
h.append("</table></section>")
|
|
85
|
+
|
|
86
|
+
# Per-vault details
|
|
87
|
+
h.append("<section><h2>Vault Details</h2>")
|
|
88
|
+
for sub in results.get("subscriptions", []):
|
|
89
|
+
sub_name = html.escape(sub.get("name", sub.get("id", "")))
|
|
90
|
+
h.append(f"<h3>{sub_name}</h3>")
|
|
91
|
+
for vault in sub.get("vaults", []):
|
|
92
|
+
vault_name = html.escape(vault.get("name", ""))
|
|
93
|
+
location = html.escape(vault.get("location", ""))
|
|
94
|
+
error = vault.get("error")
|
|
95
|
+
h.append(f'<h4>{vault_name} <span class="dim">({location})</span></h4>')
|
|
96
|
+
if error:
|
|
97
|
+
h.append(f'<p class="error">{html.escape(error)}</p>')
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
all_items = []
|
|
101
|
+
for item in vault.get("secrets", []):
|
|
102
|
+
all_items.append(("secret", item))
|
|
103
|
+
for item in vault.get("certificates", []):
|
|
104
|
+
all_items.append(("certificate", item))
|
|
105
|
+
for item in vault.get("keys", []):
|
|
106
|
+
all_items.append(("key", item))
|
|
107
|
+
|
|
108
|
+
if not all_items:
|
|
109
|
+
h.append("<p>No items found.</p>")
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
h.append('<table><tr><th>Name</th><th>Type</th><th>Enabled</th><th>Expires</th><th>Status</th></tr>')
|
|
113
|
+
now = datetime.now(timezone.utc)
|
|
114
|
+
for item_type, item in all_items:
|
|
115
|
+
name = html.escape(item.get("name", ""))
|
|
116
|
+
enabled = "Yes" if item.get("enabled") else "No"
|
|
117
|
+
expires = item.get("expiresOn", "")
|
|
118
|
+
status = ""
|
|
119
|
+
if expires:
|
|
120
|
+
try:
|
|
121
|
+
exp_dt = datetime.fromisoformat(expires.replace("Z", "+00:00"))
|
|
122
|
+
if exp_dt.tzinfo is None:
|
|
123
|
+
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
|
|
124
|
+
days = (exp_dt - now).days
|
|
125
|
+
if days < 0:
|
|
126
|
+
status = f'<span style="color:#ef4444">Expired ({abs(days)}d ago)</span>'
|
|
127
|
+
elif days <= 30:
|
|
128
|
+
status = f'<span style="color:#eab308">{days}d left</span>'
|
|
129
|
+
else:
|
|
130
|
+
status = f"{days}d left"
|
|
131
|
+
expires = expires[:10]
|
|
132
|
+
except (ValueError, TypeError):
|
|
133
|
+
pass
|
|
134
|
+
else:
|
|
135
|
+
status = '<span style="color:#6b7280">No expiry</span>'
|
|
136
|
+
h.append(f"<tr><td>{name}</td><td>{item_type}</td><td>{enabled}</td><td>{expires}</td><td>{status}</td></tr>")
|
|
137
|
+
h.append("</table>")
|
|
138
|
+
h.append("</section>")
|
|
139
|
+
|
|
140
|
+
# Footer
|
|
141
|
+
h.append('<footer><p>Generated by <a href="https://certifyclouds.com/scanner">CertifyClouds Scanner</a>')
|
|
142
|
+
h.append(' — Automate rotation and compliance with <a href="https://certifyclouds.com/pro">CertifyClouds Pro</a></p></footer>')
|
|
143
|
+
h.append("</body></html>")
|
|
144
|
+
|
|
145
|
+
html_str = "\n".join(h)
|
|
146
|
+
|
|
147
|
+
if output_file:
|
|
148
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
149
|
+
f.write(html_str)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
print(html_str)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _css() -> str:
|
|
157
|
+
"""Return embedded CSS for the HTML report."""
|
|
158
|
+
return """
|
|
159
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
160
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f0f1a;color:#e2e8f0;padding:2rem;line-height:1.6}
|
|
161
|
+
header{text-align:center;padding:2rem 0;border-bottom:1px solid rgba(139,92,246,0.3);margin-bottom:2rem}
|
|
162
|
+
header h1{font-size:1.8rem;background:linear-gradient(135deg,#8b5cf6,#06b6d4);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
|
163
|
+
header p{color:#94a3b8;font-size:0.85rem;margin-top:0.5rem}
|
|
164
|
+
section{margin-bottom:2rem}
|
|
165
|
+
h2{font-size:1.3rem;color:#e2e8f0;margin-bottom:1rem;padding-bottom:0.5rem;border-bottom:1px solid rgba(139,92,246,0.2)}
|
|
166
|
+
h3{font-size:1.1rem;color:#8b5cf6;margin:1.5rem 0 0.5rem}
|
|
167
|
+
h4{font-size:1rem;color:#cbd5e1;margin:1rem 0 0.5rem}
|
|
168
|
+
.cards{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
|
|
169
|
+
.card{background:#1a1a2e;border:1px solid rgba(139,92,246,0.2);border-radius:8px;padding:1.2rem;text-align:center;flex:1;min-width:120px}
|
|
170
|
+
.card-value{font-size:2rem;font-weight:700;color:#8b5cf6}
|
|
171
|
+
.card-label{font-size:0.8rem;color:#94a3b8;margin-top:0.3rem}
|
|
172
|
+
table{width:100%;border-collapse:collapse;margin:0.5rem 0 1rem}
|
|
173
|
+
th{text-align:left;padding:0.5rem 0.75rem;background:#1a1a2e;color:#94a3b8;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em}
|
|
174
|
+
td{padding:0.5rem 0.75rem;border-bottom:1px solid rgba(255,255,255,0.05);font-size:0.85rem}
|
|
175
|
+
tr:hover{background:rgba(139,92,246,0.05)}
|
|
176
|
+
.badge{display:inline-block;padding:0.15rem 0.6rem;border-radius:10px;color:#fff;font-size:0.75rem;font-weight:600}
|
|
177
|
+
.dim{color:#64748b}
|
|
178
|
+
.error{color:#ef4444;font-size:0.85rem}
|
|
179
|
+
.expiry-bars{display:flex;flex-direction:column;gap:0.4rem}
|
|
180
|
+
.bar-row{display:flex;align-items:center;gap:0.75rem}
|
|
181
|
+
.bar-label{width:80px;text-align:right;font-size:0.8rem;color:#94a3b8}
|
|
182
|
+
.bar{height:24px;border-radius:4px;color:#fff;font-size:0.75rem;display:flex;align-items:center;padding-left:0.5rem;min-width:fit-content}
|
|
183
|
+
footer{text-align:center;padding:2rem 0;border-top:1px solid rgba(139,92,246,0.2);margin-top:2rem;color:#64748b;font-size:0.8rem}
|
|
184
|
+
footer a{color:#8b5cf6;text-decoration:none}
|
|
185
|
+
@media print{body{background:#fff;color:#1a1a2e}header h1{-webkit-text-fill-color:#8b5cf6}.card{border-color:#e2e8f0}th{background:#f1f5f9}}
|
|
186
|
+
@media(max-width:768px){.cards{flex-direction:column}body{padding:1rem}table{font-size:0.75rem}}
|
|
187
|
+
"""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""JSON formatter for scan results."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_json(
|
|
9
|
+
results: dict[str, Any],
|
|
10
|
+
findings: list[dict[str, Any]],
|
|
11
|
+
output_file: str | None = None,
|
|
12
|
+
) -> str | None:
|
|
13
|
+
"""Format scan results as JSON.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
results: Scan results dict
|
|
17
|
+
findings: Security findings list
|
|
18
|
+
output_file: If set, write to file
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
JSON string if no output_file, None otherwise
|
|
22
|
+
"""
|
|
23
|
+
output = {
|
|
24
|
+
**results,
|
|
25
|
+
"securityFindings": findings,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
json_str = json.dumps(output, indent=2, default=str)
|
|
29
|
+
|
|
30
|
+
if output_file:
|
|
31
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
|
32
|
+
f.write(json_str)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
print(json_str)
|
|
36
|
+
return None
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Rich terminal table formatter for scan results."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from rich.tree import Tree
|
|
10
|
+
|
|
11
|
+
from ..filters import compute_expiry_summary
|
|
12
|
+
from ..security import count_by_severity
|
|
13
|
+
|
|
14
|
+
SEVERITY_COLORS = {
|
|
15
|
+
"CRITICAL": "bold red",
|
|
16
|
+
"HIGH": "red",
|
|
17
|
+
"MEDIUM": "yellow",
|
|
18
|
+
"LOW": "blue",
|
|
19
|
+
"INFO": "dim",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_table(
|
|
24
|
+
results: dict[str, Any],
|
|
25
|
+
findings: list[dict[str, Any]],
|
|
26
|
+
output_file: str | None = None,
|
|
27
|
+
) -> str | None:
|
|
28
|
+
"""Format scan results as Rich terminal output.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
results: Scan results dict
|
|
32
|
+
findings: Security findings list
|
|
33
|
+
output_file: If set, write to file instead of stdout (falls back to plain text)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
None (prints to console) or string if output_file is set
|
|
37
|
+
"""
|
|
38
|
+
console = Console(file=open(output_file, "w") if output_file else None, force_terminal=output_file is None)
|
|
39
|
+
|
|
40
|
+
# Check for scan error
|
|
41
|
+
if results.get("error"):
|
|
42
|
+
console.print(f"[red]Error: {results['error']}[/red]")
|
|
43
|
+
if output_file:
|
|
44
|
+
console.file.close()
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
# Subscription tree
|
|
48
|
+
for sub in results.get("subscriptions", []):
|
|
49
|
+
sub_name = sub.get("name", sub.get("id", "unknown"))
|
|
50
|
+
tree = Tree(f"[bold]{sub_name}[/bold] ({sub.get('id', '')})")
|
|
51
|
+
|
|
52
|
+
for vault in sub.get("vaults", []):
|
|
53
|
+
vault_name = vault.get("name", "unknown")
|
|
54
|
+
location = vault.get("location", "")
|
|
55
|
+
error = vault.get("error")
|
|
56
|
+
|
|
57
|
+
if error:
|
|
58
|
+
tree.add(f"[dim]{vault_name}[/dim] ({location}) — [red]{error}[/red]")
|
|
59
|
+
else:
|
|
60
|
+
s_count = len(vault.get("secrets", []))
|
|
61
|
+
c_count = len(vault.get("certificates", []))
|
|
62
|
+
k_count = len(vault.get("keys", []))
|
|
63
|
+
tree.add(f"{vault_name} ({location}) — {s_count} secrets, {c_count} certificates, {k_count} keys")
|
|
64
|
+
|
|
65
|
+
console.print(tree)
|
|
66
|
+
console.print()
|
|
67
|
+
|
|
68
|
+
# Summary
|
|
69
|
+
total_assets = results.get("totalSecrets", 0) + results.get("totalCertificates", 0) + results.get("totalKeys", 0)
|
|
70
|
+
console.rule("[bold]SUMMARY[/bold]")
|
|
71
|
+
console.print(
|
|
72
|
+
f" Subscriptions: {len(results.get('subscriptions', []))} "
|
|
73
|
+
f"Vaults: {results.get('totalVaults', 0)} "
|
|
74
|
+
f"Total assets: {total_assets}"
|
|
75
|
+
)
|
|
76
|
+
console.print(
|
|
77
|
+
f" Secrets: {results.get('totalSecrets', 0)} "
|
|
78
|
+
f"Certificates: {results.get('totalCertificates', 0)} "
|
|
79
|
+
f"Keys: {results.get('totalKeys', 0)}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if results.get("vaultsWithErrors", 0) > 0:
|
|
83
|
+
console.print(f" [red]Vaults with errors: {results['vaultsWithErrors']}[/red]")
|
|
84
|
+
|
|
85
|
+
console.print()
|
|
86
|
+
|
|
87
|
+
# Expiry warnings
|
|
88
|
+
expiry = compute_expiry_summary(results)
|
|
89
|
+
has_expiry_issues = any(
|
|
90
|
+
sum(expiry[bucket].values()) > 0
|
|
91
|
+
for bucket in ("expired", "7_days", "30_days", "90_days")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if has_expiry_issues:
|
|
95
|
+
console.print("[bold]EXPIRY WARNINGS[/bold]")
|
|
96
|
+
_print_expiry_line(console, "EXPIRED", expiry["expired"], "bold red")
|
|
97
|
+
_print_expiry_line(console, "< 7 days", expiry["7_days"], "red")
|
|
98
|
+
_print_expiry_line(console, "< 30 days", expiry["30_days"], "yellow")
|
|
99
|
+
_print_expiry_line(console, "< 90 days", expiry["90_days"], "dim yellow")
|
|
100
|
+
console.print()
|
|
101
|
+
|
|
102
|
+
# Security findings
|
|
103
|
+
if findings:
|
|
104
|
+
counts = count_by_severity(findings)
|
|
105
|
+
console.print("[bold]SECURITY FINDINGS[/bold]")
|
|
106
|
+
|
|
107
|
+
# Summary counts
|
|
108
|
+
parts = []
|
|
109
|
+
for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"):
|
|
110
|
+
if sev in counts:
|
|
111
|
+
color = SEVERITY_COLORS.get(sev, "")
|
|
112
|
+
parts.append(f"[{color}]{sev}: {counts[sev]}[/{color}]")
|
|
113
|
+
console.print(" " + " ".join(parts))
|
|
114
|
+
console.print()
|
|
115
|
+
|
|
116
|
+
# Top findings (max 15)
|
|
117
|
+
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False, padding=(0, 2))
|
|
118
|
+
table.add_column("Severity", width=10)
|
|
119
|
+
table.add_column("Finding", min_width=30)
|
|
120
|
+
table.add_column("Resource", min_width=20)
|
|
121
|
+
|
|
122
|
+
for finding in findings[:15]:
|
|
123
|
+
sev = finding["severity"]
|
|
124
|
+
color = SEVERITY_COLORS.get(sev, "")
|
|
125
|
+
table.add_row(
|
|
126
|
+
Text(sev, style=color),
|
|
127
|
+
finding["title"],
|
|
128
|
+
finding["resource"],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
console.print(table)
|
|
132
|
+
|
|
133
|
+
remaining = len(findings) - 15
|
|
134
|
+
if remaining > 0:
|
|
135
|
+
console.print(f" [dim]... and {remaining} more findings[/dim]")
|
|
136
|
+
console.print()
|
|
137
|
+
|
|
138
|
+
if output_file:
|
|
139
|
+
console.file.close()
|
|
140
|
+
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def format_table_security_only(
|
|
145
|
+
results: dict[str, Any],
|
|
146
|
+
findings: list[dict[str, Any]],
|
|
147
|
+
output_file: str | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Show only security findings summary (--security flag)."""
|
|
150
|
+
console = Console(file=open(output_file, "w") if output_file else None, force_terminal=output_file is None)
|
|
151
|
+
|
|
152
|
+
if not findings:
|
|
153
|
+
console.print("[green]No security findings.[/green]")
|
|
154
|
+
if output_file:
|
|
155
|
+
console.file.close()
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
counts = count_by_severity(findings)
|
|
159
|
+
console.print("[bold]SECURITY FINDINGS[/bold]")
|
|
160
|
+
|
|
161
|
+
parts = []
|
|
162
|
+
for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"):
|
|
163
|
+
if sev in counts:
|
|
164
|
+
color = SEVERITY_COLORS.get(sev, "")
|
|
165
|
+
parts.append(f"[{color}]{sev}: {counts[sev]}[/{color}]")
|
|
166
|
+
console.print(" " + " ".join(parts))
|
|
167
|
+
console.print()
|
|
168
|
+
|
|
169
|
+
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False, padding=(0, 2))
|
|
170
|
+
table.add_column("Severity", width=10)
|
|
171
|
+
table.add_column("Finding", min_width=30)
|
|
172
|
+
table.add_column("Resource", min_width=20)
|
|
173
|
+
table.add_column("Recommendation", min_width=20)
|
|
174
|
+
|
|
175
|
+
for finding in findings:
|
|
176
|
+
sev = finding["severity"]
|
|
177
|
+
color = SEVERITY_COLORS.get(sev, "")
|
|
178
|
+
table.add_row(
|
|
179
|
+
Text(sev, style=color),
|
|
180
|
+
finding["title"],
|
|
181
|
+
finding["resource"],
|
|
182
|
+
finding.get("recommendation", ""),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
console.print(table)
|
|
186
|
+
console.print()
|
|
187
|
+
|
|
188
|
+
if output_file:
|
|
189
|
+
console.file.close()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _print_expiry_line(console: Console, label: str, counts: dict[str, int], style: str) -> None:
|
|
193
|
+
"""Print an expiry summary line if there are any items in this bucket."""
|
|
194
|
+
parts = []
|
|
195
|
+
if counts.get("secrets", 0) > 0:
|
|
196
|
+
parts.append(f"{counts['secrets']} secrets")
|
|
197
|
+
if counts.get("certificates", 0) > 0:
|
|
198
|
+
parts.append(f"{counts['certificates']} certificates")
|
|
199
|
+
if counts.get("keys", 0) > 0:
|
|
200
|
+
parts.append(f"{counts['keys']} keys")
|
|
201
|
+
|
|
202
|
+
if parts:
|
|
203
|
+
console.print(f" [{style}]{label:12s}{', '.join(parts)}[/{style}]")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""In-CLI registration flow for CertifyClouds Scanner.
|
|
2
|
+
|
|
3
|
+
Prompts for email, calls the license server, saves the key locally.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import httpx
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from . import config as cfg
|
|
14
|
+
|
|
15
|
+
LICENSE_SERVER_URL = "https://license.certifyclouds.com"
|
|
16
|
+
REGISTER_ENDPOINT = f"{LICENSE_SERVER_URL}/api/scanner/register"
|
|
17
|
+
REQUEST_TIMEOUT = 15.0
|
|
18
|
+
|
|
19
|
+
EMAIL_REGEX = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register() -> str | None:
|
|
25
|
+
"""Run the interactive registration flow. Returns the API key or None on failure."""
|
|
26
|
+
console.print()
|
|
27
|
+
console.print("[bold]Register for a free API key to get started.[/bold]")
|
|
28
|
+
console.print("Takes 10 seconds — no password, no credit card.")
|
|
29
|
+
console.print()
|
|
30
|
+
|
|
31
|
+
# Prompt for email
|
|
32
|
+
email = click.prompt(" Email", type=str)
|
|
33
|
+
email = email.strip()
|
|
34
|
+
|
|
35
|
+
if not EMAIL_REGEX.match(email):
|
|
36
|
+
console.print("[red]Invalid email format. Please try again.[/red]")
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
# Prompt for company (optional)
|
|
40
|
+
company = click.prompt(" Company (optional)", type=str, default="", show_default=False)
|
|
41
|
+
company = company.strip()
|
|
42
|
+
|
|
43
|
+
console.print()
|
|
44
|
+
console.print(" Registering...", end=" ")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
resp = httpx.post(
|
|
48
|
+
REGISTER_ENDPOINT,
|
|
49
|
+
json={"email": email, "company": company or None, "source": "cli"},
|
|
50
|
+
timeout=REQUEST_TIMEOUT,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if resp.status_code == 429:
|
|
54
|
+
console.print("[red]Rate limited — try again in a few minutes.[/red]")
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if resp.status_code >= 400:
|
|
58
|
+
data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
|
59
|
+
error = data.get("error", f"HTTP {resp.status_code}")
|
|
60
|
+
console.print(f"[red]Failed: {error}[/red]")
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
data = resp.json()
|
|
64
|
+
key = data.get("key")
|
|
65
|
+
|
|
66
|
+
if not key:
|
|
67
|
+
console.print("[red]Failed: no key returned from server.[/red]")
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
# Save to config
|
|
71
|
+
cfg.save_registration(key, email)
|
|
72
|
+
|
|
73
|
+
console.print("[green]done.[/green]")
|
|
74
|
+
console.print(f" Key: [bold]{key}[/bold]")
|
|
75
|
+
console.print(f" Saved to {cfg.CONFIG_FILE}")
|
|
76
|
+
console.print()
|
|
77
|
+
|
|
78
|
+
return key
|
|
79
|
+
|
|
80
|
+
except httpx.ConnectError:
|
|
81
|
+
console.print("[red]Failed: could not reach license.certifyclouds.com[/red]")
|
|
82
|
+
console.print(" Check your internet connection and try again.")
|
|
83
|
+
console.print(" If you're behind a proxy, set HTTPS_PROXY in your environment.")
|
|
84
|
+
return None
|
|
85
|
+
except httpx.TimeoutException:
|
|
86
|
+
console.print("[red]Failed: request timed out.[/red]")
|
|
87
|
+
console.print(" Check your internet connection and try again.")
|
|
88
|
+
return None
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
console.print(f"[red]Failed: {exc}[/red]")
|
|
91
|
+
return None
|