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/security.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Security findings rules engine for CertifyClouds Scanner.
|
|
2
|
+
|
|
3
|
+
Analyzes scan results and generates actionable security findings.
|
|
4
|
+
Uses only data already present in scan results — no additional API calls.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def analyze(results: dict[str, Any]) -> list[dict[str, Any]]:
|
|
16
|
+
"""Analyze scan results and return a list of security findings.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
results: Scan results dict from scanner.scan()
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
List of finding dicts, sorted by severity (most severe first)
|
|
23
|
+
"""
|
|
24
|
+
findings: list[dict[str, Any]] = []
|
|
25
|
+
now = datetime.now(timezone.utc)
|
|
26
|
+
|
|
27
|
+
for sub in results.get("subscriptions", []):
|
|
28
|
+
sub_name = sub.get("name", sub.get("id", "unknown"))
|
|
29
|
+
|
|
30
|
+
for vault in sub.get("vaults", []):
|
|
31
|
+
vault_name = vault.get("name", "unknown")
|
|
32
|
+
|
|
33
|
+
# Vault-level findings
|
|
34
|
+
_check_vault_security(vault, vault_name, sub_name, findings)
|
|
35
|
+
|
|
36
|
+
# Asset-level findings
|
|
37
|
+
for secret in vault.get("secrets", []):
|
|
38
|
+
_check_asset_expiry(secret, "secret", vault_name, sub_name, now, findings)
|
|
39
|
+
|
|
40
|
+
for cert in vault.get("certificates", []):
|
|
41
|
+
_check_asset_expiry(cert, "certificate", vault_name, sub_name, now, findings)
|
|
42
|
+
|
|
43
|
+
# Sort by severity
|
|
44
|
+
findings.sort(key=lambda f: SEVERITY_ORDER.get(f["severity"], 99))
|
|
45
|
+
return findings
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_vault_security(
|
|
49
|
+
vault: dict[str, Any],
|
|
50
|
+
vault_name: str,
|
|
51
|
+
sub_name: str,
|
|
52
|
+
findings: list[dict[str, Any]],
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Check vault-level security configuration."""
|
|
55
|
+
if vault.get("public_network_access") == "Enabled":
|
|
56
|
+
findings.append({
|
|
57
|
+
"id": "SEC-003",
|
|
58
|
+
"severity": "HIGH",
|
|
59
|
+
"title": "Public network access enabled",
|
|
60
|
+
"resource": vault_name,
|
|
61
|
+
"subscription": sub_name,
|
|
62
|
+
"recommendation": "Restrict access to specific VNets/IPs",
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if vault.get("soft_delete_enabled") is False:
|
|
66
|
+
findings.append({
|
|
67
|
+
"id": "SEC-004",
|
|
68
|
+
"severity": "HIGH",
|
|
69
|
+
"title": "Soft delete disabled",
|
|
70
|
+
"resource": vault_name,
|
|
71
|
+
"subscription": sub_name,
|
|
72
|
+
"recommendation": "Enable soft delete for recovery protection",
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
if vault.get("purge_protection_enabled") is False:
|
|
76
|
+
findings.append({
|
|
77
|
+
"id": "SEC-005",
|
|
78
|
+
"severity": "HIGH",
|
|
79
|
+
"title": "Purge protection disabled",
|
|
80
|
+
"resource": vault_name,
|
|
81
|
+
"subscription": sub_name,
|
|
82
|
+
"recommendation": "Enable purge protection to prevent permanent deletion",
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if vault.get("rbac_enabled") is False:
|
|
86
|
+
findings.append({
|
|
87
|
+
"id": "SEC-008",
|
|
88
|
+
"severity": "MEDIUM",
|
|
89
|
+
"title": "RBAC authorization not enabled",
|
|
90
|
+
"resource": vault_name,
|
|
91
|
+
"subscription": sub_name,
|
|
92
|
+
"recommendation": "Enable RBAC for fine-grained access control",
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
if vault.get("sku") == "standard":
|
|
96
|
+
findings.append({
|
|
97
|
+
"id": "SEC-009",
|
|
98
|
+
"severity": "LOW",
|
|
99
|
+
"title": "Standard SKU (no HSM backing)",
|
|
100
|
+
"resource": vault_name,
|
|
101
|
+
"subscription": sub_name,
|
|
102
|
+
"recommendation": "Consider Premium SKU for HSM-backed keys",
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _check_asset_expiry(
|
|
107
|
+
asset: dict[str, Any],
|
|
108
|
+
asset_type: str,
|
|
109
|
+
vault_name: str,
|
|
110
|
+
sub_name: str,
|
|
111
|
+
now: datetime,
|
|
112
|
+
findings: list[dict[str, Any]],
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Check individual asset expiry status."""
|
|
115
|
+
name = asset.get("name", "unknown")
|
|
116
|
+
expires_on = asset.get("expiresOn")
|
|
117
|
+
enabled = asset.get("enabled", True)
|
|
118
|
+
|
|
119
|
+
if expires_on is None:
|
|
120
|
+
# No expiry set
|
|
121
|
+
findings.append({
|
|
122
|
+
"id": "SEC-006" if asset_type == "secret" else "SEC-007",
|
|
123
|
+
"severity": "MEDIUM",
|
|
124
|
+
"title": f"{asset_type.capitalize()} has no expiry date",
|
|
125
|
+
"resource": f"{vault_name}/{name}",
|
|
126
|
+
"subscription": sub_name,
|
|
127
|
+
"recommendation": "Set an expiry date to enforce rotation",
|
|
128
|
+
})
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
expiry = datetime.fromisoformat(expires_on.replace("Z", "+00:00"))
|
|
133
|
+
if expiry.tzinfo is None:
|
|
134
|
+
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
135
|
+
except (ValueError, TypeError):
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if expiry < now and enabled:
|
|
139
|
+
# Expired but still enabled — critical
|
|
140
|
+
findings.append({
|
|
141
|
+
"id": "SEC-001" if asset_type == "secret" else "SEC-002",
|
|
142
|
+
"severity": "CRITICAL",
|
|
143
|
+
"title": f"Expired {asset_type} still enabled",
|
|
144
|
+
"resource": f"{vault_name}/{name}",
|
|
145
|
+
"subscription": sub_name,
|
|
146
|
+
"recommendation": f"Disable or rotate this {asset_type} immediately",
|
|
147
|
+
})
|
|
148
|
+
elif expiry < now and not enabled:
|
|
149
|
+
# Expired and disabled — no finding needed
|
|
150
|
+
pass
|
|
151
|
+
else:
|
|
152
|
+
days_left = (expiry - now).days
|
|
153
|
+
if days_left <= 30:
|
|
154
|
+
findings.append({
|
|
155
|
+
"id": "SEC-010" if asset_type == "secret" else "SEC-011",
|
|
156
|
+
"severity": "INFO",
|
|
157
|
+
"title": f"{asset_type.capitalize()} expiring in {days_left} days",
|
|
158
|
+
"resource": f"{vault_name}/{name}",
|
|
159
|
+
"subscription": sub_name,
|
|
160
|
+
"recommendation": f"Plan {'rotation' if asset_type == 'secret' else 'renewal'} before expiry",
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def count_by_severity(findings: list[dict[str, Any]]) -> dict[str, int]:
|
|
165
|
+
"""Count findings by severity level."""
|
|
166
|
+
counts: dict[str, int] = {}
|
|
167
|
+
for f in findings:
|
|
168
|
+
sev = f["severity"]
|
|
169
|
+
counts[sev] = counts.get(sev, 0) + 1
|
|
170
|
+
return counts
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def has_critical_or_high(findings: list[dict[str, Any]]) -> bool:
|
|
174
|
+
"""Check if any findings are CRITICAL or HIGH severity."""
|
|
175
|
+
return any(f["severity"] in ("CRITICAL", "HIGH") for f in findings)
|
cc_scanner/thumbprint.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Thumbprint Normalization Utility
|
|
2
|
+
|
|
3
|
+
Handles certificate thumbprint normalization across different sources:
|
|
4
|
+
- Azure Key Vault SDK: Returns `bytes[20]` (raw SHA1 hash)
|
|
5
|
+
- Microsoft Graph API: Returns base64-encoded string
|
|
6
|
+
- Display format: Lowercase hex string (40 characters)
|
|
7
|
+
|
|
8
|
+
All thumbprints are normalized to lowercase hex for consistent matching.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_thumbprint(thumbprint: str | bytes | None) -> str:
|
|
19
|
+
"""Normalize a thumbprint to lowercase hex string format.
|
|
20
|
+
|
|
21
|
+
Handles multiple input formats:
|
|
22
|
+
1. bytes (from Azure Key Vault SDK's x509_thumbprint)
|
|
23
|
+
2. Base64 string (from Microsoft Graph API's customKeyIdentifier)
|
|
24
|
+
3. Hex string (already formatted, possibly uppercase)
|
|
25
|
+
4. None or empty (returns empty string)
|
|
26
|
+
"""
|
|
27
|
+
if not thumbprint:
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
# Case 1: bytes (from Azure Key Vault SDK)
|
|
32
|
+
if isinstance(thumbprint, bytes):
|
|
33
|
+
result = thumbprint.hex().lower()
|
|
34
|
+
logger.debug(
|
|
35
|
+
"[Thumbprint] Normalized from bytes: len=%d -> hex=%s...",
|
|
36
|
+
len(thumbprint), result[:16]
|
|
37
|
+
)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
# Case 2: String input - need to determine format
|
|
41
|
+
if isinstance(thumbprint, str):
|
|
42
|
+
thumbprint_str = thumbprint.strip()
|
|
43
|
+
|
|
44
|
+
if not thumbprint_str:
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
# Check if it's already a valid hex string (40 chars for SHA1, or 64 for SHA256)
|
|
48
|
+
if _is_hex_string(thumbprint_str):
|
|
49
|
+
result = thumbprint_str.lower()
|
|
50
|
+
logger.debug(
|
|
51
|
+
"[Thumbprint] Normalized from hex: %s... -> %s...",
|
|
52
|
+
thumbprint_str[:16], result[:16]
|
|
53
|
+
)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
# Try to decode as base64 (Graph API format)
|
|
57
|
+
try:
|
|
58
|
+
decoded = base64.b64decode(thumbprint_str)
|
|
59
|
+
result = decoded.hex().lower()
|
|
60
|
+
logger.debug(
|
|
61
|
+
"[Thumbprint] Normalized from base64: %s... -> %s...",
|
|
62
|
+
thumbprint_str[:16], result[:16]
|
|
63
|
+
)
|
|
64
|
+
return result
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# Last resort: just lowercase whatever we have
|
|
69
|
+
logger.warning(
|
|
70
|
+
"[Thumbprint] Unknown format, using as-is (lowercased): %s...",
|
|
71
|
+
thumbprint_str[:16]
|
|
72
|
+
)
|
|
73
|
+
return thumbprint_str.lower()
|
|
74
|
+
|
|
75
|
+
# Unknown type
|
|
76
|
+
logger.warning(
|
|
77
|
+
"[Thumbprint] Unexpected type %s, converting to string",
|
|
78
|
+
type(thumbprint).__name__
|
|
79
|
+
)
|
|
80
|
+
return str(thumbprint).lower()
|
|
81
|
+
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(
|
|
84
|
+
"[Thumbprint] Failed to normalize thumbprint: %s - %s",
|
|
85
|
+
type(thumbprint).__name__, e
|
|
86
|
+
)
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_hex_string(s: str) -> bool:
|
|
91
|
+
"""Check if a string is a valid hexadecimal string of expected thumbprint length."""
|
|
92
|
+
if len(s) not in (40, 64):
|
|
93
|
+
return False
|
|
94
|
+
try:
|
|
95
|
+
int(s, 16)
|
|
96
|
+
return True
|
|
97
|
+
except ValueError:
|
|
98
|
+
return False
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: certifyclouds
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Scan Azure Key Vaults for expiring secrets, certificates, and keys
|
|
5
|
+
Project-URL: Homepage, https://certifyclouds.com/scanner
|
|
6
|
+
Project-URL: Documentation, https://docs.certifyclouds.com/scanner
|
|
7
|
+
Project-URL: Repository, https://codeberg.org/hus/cc
|
|
8
|
+
Author-email: CertifyClouds <hello@certifyclouds.com>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: azure,certificates,expiry,keyvault,rotation,scanner,secrets,security
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Security
|
|
25
|
+
Classifier: Topic :: System :: Systems Administration
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: azure-core>=1.30
|
|
28
|
+
Requires-Dist: azure-identity>=1.20
|
|
29
|
+
Requires-Dist: azure-keyvault-certificates>=4.9
|
|
30
|
+
Requires-Dist: azure-keyvault-keys>=4.10
|
|
31
|
+
Requires-Dist: azure-keyvault-secrets>=4.9
|
|
32
|
+
Requires-Dist: azure-mgmt-keyvault>=13.0
|
|
33
|
+
Requires-Dist: azure-mgmt-resource>=24.0
|
|
34
|
+
Requires-Dist: click>=8.0
|
|
35
|
+
Requires-Dist: httpx>=0.27
|
|
36
|
+
Requires-Dist: rich>=13.0
|
|
37
|
+
Requires-Dist: tenacity>=9.0
|
|
38
|
+
Provides-Extra: dev
|
|
39
|
+
Requires-Dist: pytest-mock>=3.10; extra == 'dev'
|
|
40
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# cc-scanner
|
|
44
|
+
|
|
45
|
+
**Do you know which Azure secrets expire this month?**
|
|
46
|
+
|
|
47
|
+
Scan all your Azure Key Vaults across every subscription in 30 seconds. Free, open source, works in Azure Cloud Shell.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
pip install cc-scanner
|
|
51
|
+
cc-scan
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## What you get
|
|
55
|
+
|
|
56
|
+
- Cross-subscription inventory of every secret, certificate, and key
|
|
57
|
+
- Expiry warnings: expired, <7 days, <30 days, <90 days (color-coded)
|
|
58
|
+
- Security findings: public access, soft delete, RBAC, missing expiry dates
|
|
59
|
+
- Export to JSON, CSV, or standalone HTML report
|
|
60
|
+
- Works in Azure Cloud Shell with zero setup
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# 1. Install
|
|
66
|
+
pip install cc-scanner
|
|
67
|
+
|
|
68
|
+
# 2. Authenticate (skip in Azure Cloud Shell)
|
|
69
|
+
az login
|
|
70
|
+
|
|
71
|
+
# 3. Scan
|
|
72
|
+
cc-scan
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
On first run, you'll be prompted to register with your email (free, takes 10 seconds). Your API key is saved locally to `~/.certifyclouds/config.json`.
|
|
76
|
+
|
|
77
|
+
## Output
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
CertifyClouds Scanner v1.2.0
|
|
81
|
+
Authenticated as: you@company.com
|
|
82
|
+
|
|
83
|
+
Scanning Azure subscriptions...
|
|
84
|
+
|
|
85
|
+
Production (sub-abc123)
|
|
86
|
+
vault-prod (eastus) - 45 secrets, 12 certificates, 3 keys
|
|
87
|
+
vault-shared (westeurope) - 23 secrets, 5 certificates, 1 key
|
|
88
|
+
|
|
89
|
+
SUMMARY
|
|
90
|
+
Subscriptions: 2 Vaults: 4 Total assets: 112
|
|
91
|
+
Secrets: 88 Certificates: 20 Keys: 4
|
|
92
|
+
|
|
93
|
+
EXPIRY WARNINGS
|
|
94
|
+
EXPIRED 3 secrets, 1 certificate
|
|
95
|
+
< 7 days 2 secrets
|
|
96
|
+
< 30 days 8 secrets, 2 certificates
|
|
97
|
+
|
|
98
|
+
SECURITY FINDINGS
|
|
99
|
+
CRITICAL: 3 HIGH: 2 MEDIUM: 47 LOW: 1
|
|
100
|
+
|
|
101
|
+
CRITICAL Expired secret still enabled vault-prod/db-password
|
|
102
|
+
HIGH Public network access enabled vault-legacy
|
|
103
|
+
MEDIUM Secret has no expiry date vault-prod/api-key
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Usage
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Output formats
|
|
110
|
+
cc-scan --format table # Default: pretty terminal output
|
|
111
|
+
cc-scan --format json # Full JSON (pipe-friendly)
|
|
112
|
+
cc-scan --format csv # Flat CSV (for spreadsheets)
|
|
113
|
+
cc-scan --format html # Standalone HTML report
|
|
114
|
+
|
|
115
|
+
# Write to file
|
|
116
|
+
cc-scan --output report.html --format html
|
|
117
|
+
cc-scan --output scan.json --format json
|
|
118
|
+
|
|
119
|
+
# Filters
|
|
120
|
+
cc-scan --expiring 30 # Only items expiring within 30 days
|
|
121
|
+
cc-scan --expired # Only already-expired items
|
|
122
|
+
cc-scan --type secrets # Only secrets (or: certificates, keys)
|
|
123
|
+
cc-scan --vault vault-prod # Specific vault(s) (repeatable)
|
|
124
|
+
cc-scan --subscription sub-123 # Specific subscription(s) (repeatable)
|
|
125
|
+
|
|
126
|
+
# Security summary only
|
|
127
|
+
cc-scan --security # Show findings without per-item details
|
|
128
|
+
|
|
129
|
+
# Azure auth
|
|
130
|
+
cc-scan --auth cli # Azure CLI credential (default)
|
|
131
|
+
cc-scan --auth default # DefaultAzureCredential (managed identity/SP)
|
|
132
|
+
|
|
133
|
+
# Tuning
|
|
134
|
+
cc-scan --workers 10 # Parallel workers (default: 5, max: 20)
|
|
135
|
+
cc-scan --timeout 600 # Scan timeout in seconds (default: 300)
|
|
136
|
+
|
|
137
|
+
# Other
|
|
138
|
+
cc-scan --offline # Skip license server validation
|
|
139
|
+
cc-scan --register # Re-register / change email
|
|
140
|
+
cc-scan --key cc-scan-xxxxx # Use a specific API key
|
|
141
|
+
cc-scan --version # Show version
|
|
142
|
+
cc-scan --help # Show all options
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Environment Variables
|
|
146
|
+
|
|
147
|
+
All flags can be set via environment variables (flags take priority):
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
CC_SCAN_KEY=cc-scan-xxxxx # API key
|
|
151
|
+
CC_SCAN_AUTH=cli # Auth method
|
|
152
|
+
CC_SCAN_FORMAT=json # Output format
|
|
153
|
+
CC_SCAN_WORKERS=10 # Workers
|
|
154
|
+
CC_SCAN_TIMEOUT=600 # Timeout
|
|
155
|
+
CC_SCAN_OFFLINE=true # Offline mode
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
For service principal auth (used with `--auth default`):
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
AZURE_CLIENT_ID=...
|
|
162
|
+
AZURE_CLIENT_SECRET=...
|
|
163
|
+
AZURE_TENANT_ID=...
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Exit Codes
|
|
167
|
+
|
|
168
|
+
| Code | Meaning |
|
|
169
|
+
|------|---------|
|
|
170
|
+
| 0 | Scan completed, no critical/high findings |
|
|
171
|
+
| 1 | Scan completed, critical or high findings detected |
|
|
172
|
+
| 2 | Scan failed (auth error, network error, timeout) |
|
|
173
|
+
| 3 | Registration required / invalid API key |
|
|
174
|
+
|
|
175
|
+
Non-zero exit codes make this CI/cron-friendly: `cc-scan || notify-team`.
|
|
176
|
+
|
|
177
|
+
## Azure Permissions
|
|
178
|
+
|
|
179
|
+
Your identity needs these roles on each Key Vault:
|
|
180
|
+
|
|
181
|
+
| Role | Why |
|
|
182
|
+
|------|-----|
|
|
183
|
+
| Key Vault Secrets User | List and read secret properties |
|
|
184
|
+
| Key Vault Certificate User | List and read certificate properties |
|
|
185
|
+
| Key Vault Crypto User | List and read key properties |
|
|
186
|
+
| Reader | List Key Vaults across subscriptions |
|
|
187
|
+
|
|
188
|
+
Assign roles in Azure Portal: Key Vault > Access control (IAM) > Add role assignment.
|
|
189
|
+
|
|
190
|
+
If a vault has firewall restrictions, you'll see an inline error (the scan continues to the next vault).
|
|
191
|
+
|
|
192
|
+
## Security Findings
|
|
193
|
+
|
|
194
|
+
The scanner checks for 11 security rules:
|
|
195
|
+
|
|
196
|
+
| ID | Severity | What it checks |
|
|
197
|
+
|----|----------|---------------|
|
|
198
|
+
| SEC-001 | CRITICAL | Expired secret still enabled |
|
|
199
|
+
| SEC-002 | CRITICAL | Expired certificate still enabled |
|
|
200
|
+
| SEC-003 | HIGH | Public network access enabled on vault |
|
|
201
|
+
| SEC-004 | HIGH | Soft delete disabled |
|
|
202
|
+
| SEC-005 | HIGH | Purge protection disabled |
|
|
203
|
+
| SEC-006 | MEDIUM | Secret has no expiry date set |
|
|
204
|
+
| SEC-007 | MEDIUM | Certificate has no expiry date set |
|
|
205
|
+
| SEC-008 | MEDIUM | RBAC authorization not enabled |
|
|
206
|
+
| SEC-009 | LOW | Standard SKU (no HSM backing) |
|
|
207
|
+
| SEC-010 | INFO | Secret expiring within 30 days |
|
|
208
|
+
| SEC-011 | INFO | Certificate expiring within 30 days |
|
|
209
|
+
|
|
210
|
+
## Privacy & Telemetry
|
|
211
|
+
|
|
212
|
+
CertifyClouds Scanner sends **aggregate usage statistics** to improve the product. Here's exactly what is sent:
|
|
213
|
+
|
|
214
|
+
**Sent:** API key, CLI version, counts (subscriptions, vaults, secrets, certificates, keys, expired, expiring soon, security findings, vaults with errors), scan duration.
|
|
215
|
+
|
|
216
|
+
**Never sent:** Vault names or URIs, secret/certificate/key names, secret values, subscription IDs or names, Azure tenant IDs, resource group names, tags, certificate subjects or thumbprints.
|
|
217
|
+
|
|
218
|
+
Use `--offline` to disable all network calls to the license server. The scan works fully offline.
|
|
219
|
+
|
|
220
|
+
Source code is open and auditable: [codeberg.org/hus/cc/scanner](https://codeberg.org/hus/cc)
|
|
221
|
+
|
|
222
|
+
## Troubleshooting
|
|
223
|
+
|
|
224
|
+
**"No Azure credentials found"**
|
|
225
|
+
Run `az login` to authenticate, or set `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID` for service principal auth.
|
|
226
|
+
|
|
227
|
+
**Vault shows "Firewall restriction"**
|
|
228
|
+
Run cc-scan from an allowed network or add your IP in Azure Portal (Key Vault > Networking).
|
|
229
|
+
|
|
230
|
+
**Vault shows "Access denied"**
|
|
231
|
+
Your identity needs the roles listed in [Azure Permissions](#azure-permissions).
|
|
232
|
+
|
|
233
|
+
**"Could not reach license.certifyclouds.com"**
|
|
234
|
+
Check internet connection. Behind a proxy? Set `HTTPS_PROXY`. Or use `--offline` mode.
|
|
235
|
+
|
|
236
|
+
**Slow scans**
|
|
237
|
+
Increase workers: `cc-scan --workers 10`. Each subscription is scanned in parallel.
|
|
238
|
+
|
|
239
|
+
## Requirements
|
|
240
|
+
|
|
241
|
+
- Python 3.9+
|
|
242
|
+
- Azure CLI (`az login`) or service principal credentials
|
|
243
|
+
- Network access to Azure management and Key Vault data plane APIs
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
Apache 2.0. See [LICENSE](LICENSE).
|
|
248
|
+
|
|
249
|
+
## Links
|
|
250
|
+
|
|
251
|
+
- [CertifyClouds](https://certifyclouds.com) - Automate secret rotation & compliance
|
|
252
|
+
- [Documentation](https://docs.certifyclouds.com/scanner)
|
|
253
|
+
- [Source Code](https://codeberg.org/hus/cc)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
cc_scanner/__init__.py,sha256=UvDKQpOLThCNfGAnT7jqr3SXEuVUZMGtUZfRbALIoaA,123
|
|
2
|
+
cc_scanner/auth.py,sha256=LEUPT1OM4Ut3-igSzvRP5LQu-fjnp8l7kv5VLJ_4FWo,3035
|
|
3
|
+
cc_scanner/cli.py,sha256=RubxCSnhQYj0jJNlHMmummx1jhv8lWn8wtg6G0YC1AI,12309
|
|
4
|
+
cc_scanner/config.py,sha256=2mWfMu_jWfF66SOcySYze4xGKuAO1hppcpZR5eOY1FY,2766
|
|
5
|
+
cc_scanner/filters.py,sha256=yauU88xWDkab_hhr2mni9f7xBWW3Tiyd2I4nNj23WE8,4905
|
|
6
|
+
cc_scanner/registration.py,sha256=2bbE7UQ_8zeBFqjXu9oosoR65FA3UMJUOfqSe4aKdWc,2928
|
|
7
|
+
cc_scanner/scanner.py,sha256=di82PqwGn47gxzQTT62p5IBjgMrTAXfPUCcPfR--6YE,30382
|
|
8
|
+
cc_scanner/security.py,sha256=X8OqghtRC6IQI7R2IQxK0JVXXazNbV5knwC4fL7K2TA,6025
|
|
9
|
+
cc_scanner/thumbprint.py,sha256=hwcg5qqy0p-f4W4uS8eIbVRKBppeF_PcxiLu_0ov7w0,3197
|
|
10
|
+
cc_scanner/formatters/__init__.py,sha256=ZpxcJqEBRtXJpfaM2k02i_I-m1kGOyFruqiZb2r04Ac,555
|
|
11
|
+
cc_scanner/formatters/csv_fmt.py,sha256=vtC_fhIgS_YEI6ZAIdwCTjml50KcYkN-tB1SYhpQi2M,2588
|
|
12
|
+
cc_scanner/formatters/html.py,sha256=IbWyvhP-zfDRAHVcv-j2rlB0uKg_ZTXDk3X8udpM5Vs,8840
|
|
13
|
+
cc_scanner/formatters/json_fmt.py,sha256=LVJK8pNyZiumF7zLKCiWr8Vw8QosgaIkwCrFtSn4-hU,793
|
|
14
|
+
cc_scanner/formatters/table.py,sha256=dcdR3X_7qx_oQ6n5CB3cbmy9ePCmQJEXenKFkiiZVno,6823
|
|
15
|
+
certifyclouds-1.2.0.dist-info/METADATA,sha256=7ECsp68t8s3lTqJLUm0keYZCYUCEHYFSWmNFrbNSKhQ,8673
|
|
16
|
+
certifyclouds-1.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
17
|
+
certifyclouds-1.2.0.dist-info/entry_points.txt,sha256=vxwSbE52iqCuQEHIx17VwPPdMIE6am0e46kmYAbCZ7g,48
|
|
18
|
+
certifyclouds-1.2.0.dist-info/licenses/LICENSE,sha256=LstlN_mRiSNm-hVYNdlu6sEDIRozNIXd-0wnYXoqbSA,10764
|
|
19
|
+
certifyclouds-1.2.0.dist-info/RECORD,,
|