certifyclouds 1.3.6__tar.gz → 1.3.9__tar.gz
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.
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/.gitignore +30 -1
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/PKG-INFO +10 -11
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/README.md +0 -3
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/pyproject.toml +12 -7
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/__init__.py +1 -1
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/auth.py +6 -1
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/cli.py +20 -1
- certifyclouds-1.3.9/src/cc_scanner/formatters/csv_fmt.py +77 -0
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/formatters/table.py +26 -15
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/scanner.py +37 -18
- certifyclouds-1.3.6/src/cc_scanner/formatters/csv_fmt.py +0 -74
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/LICENSE +0 -0
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/config.py +0 -0
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/filters.py +0 -0
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/formatters/__init__.py +0 -0
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/formatters/html.py +0 -0
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/formatters/json_fmt.py +0 -0
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/registration.py +0 -0
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/security.py +0 -0
- {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/thumbprint.py +0 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
.dev.vars
|
|
6
6
|
.release-env
|
|
7
7
|
.release-env.*
|
|
8
|
+
!.release-env.example
|
|
8
9
|
*.pem
|
|
9
10
|
*.key
|
|
10
11
|
.mcp.json
|
|
@@ -40,6 +41,10 @@ dist/
|
|
|
40
41
|
*.egg
|
|
41
42
|
.venv/
|
|
42
43
|
venv/
|
|
44
|
+
# backend/venv is a symlink to the main repo venv in this worktree; `venv/` (above) only
|
|
45
|
+
# matches directories, not the symlink, so it needs its own entry. (.gitignore has no
|
|
46
|
+
# inline comments — the comment must be on its own line or it becomes part of the pattern.)
|
|
47
|
+
backend/venv
|
|
43
48
|
ENV/
|
|
44
49
|
.pytest_cache/
|
|
45
50
|
.ruff_cache/
|
|
@@ -72,7 +77,10 @@ pnpm-debug.log*
|
|
|
72
77
|
|
|
73
78
|
# IDE
|
|
74
79
|
.idea/
|
|
75
|
-
.vscode
|
|
80
|
+
.vscode/*
|
|
81
|
+
# share team format-on-save config; ignore personal IDE files
|
|
82
|
+
!.vscode/settings.json
|
|
83
|
+
!.vscode/extensions.json
|
|
76
84
|
*.swp
|
|
77
85
|
*.swo
|
|
78
86
|
*~
|
|
@@ -121,6 +129,7 @@ claude-*.md
|
|
|
121
129
|
|
|
122
130
|
# Frontend test coverage
|
|
123
131
|
frontend/coverage/
|
|
132
|
+
frontend/.fallow/
|
|
124
133
|
|
|
125
134
|
# Backup files
|
|
126
135
|
backups/
|
|
@@ -177,3 +186,23 @@ scripts/lab/.db-password-*.backup
|
|
|
177
186
|
scripts/lab/.deploy-sso-secret
|
|
178
187
|
scripts/lab/.cloud-creds/
|
|
179
188
|
.preflight-venv/
|
|
189
|
+
|
|
190
|
+
# Working-draft customer reply files (not committed)
|
|
191
|
+
REPLY_TO_*.txt
|
|
192
|
+
heartland-call-brief-*.md
|
|
193
|
+
# Root-anchored: a bare `enterprise` also ignored docs-public/docs/enterprise/
|
|
194
|
+
/enterprise
|
|
195
|
+
|
|
196
|
+
# Script backups (build-once refactor + future)
|
|
197
|
+
scripts/build/*.bak-*
|
|
198
|
+
|
|
199
|
+
# Superpowers skill framework (local agent tooling, not project code)
|
|
200
|
+
.superpowers/
|
|
201
|
+
|
|
202
|
+
# Codex CLI per-repo artifacts (hooks.json etc) — dropped by /gpt runs
|
|
203
|
+
.codex/
|
|
204
|
+
|
|
205
|
+
# Graphify knowledge-graph output (generated, not project code)
|
|
206
|
+
graphify-out/
|
|
207
|
+
pre-bless-artifacts/
|
|
208
|
+
stress-artifacts/
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: certifyclouds
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.9
|
|
4
4
|
Summary: Scan Azure Key Vaults for expiring secrets, certificates, and keys
|
|
5
5
|
Project-URL: Homepage, https://certifyclouds.com/scanner
|
|
6
6
|
Project-URL: Documentation, https://docs.certifyclouds.com/scanner
|
|
7
|
-
Project-URL: Repository, https://codeberg.org/hus/cc
|
|
8
7
|
Author-email: CertifyClouds <hello@certifyclouds.com>
|
|
9
8
|
License: Apache-2.0
|
|
10
9
|
License-File: LICENSE
|
|
@@ -16,26 +15,29 @@ Classifier: Intended Audience :: System Administrators
|
|
|
16
15
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
17
16
|
Classifier: Operating System :: OS Independent
|
|
18
17
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
21
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
22
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
24
21
|
Classifier: Topic :: Security
|
|
25
22
|
Classifier: Topic :: System :: Systems Administration
|
|
26
|
-
Requires-Python: >=3.
|
|
23
|
+
Requires-Python: >=3.11
|
|
27
24
|
Requires-Dist: azure-core>=1.38.0
|
|
28
25
|
Requires-Dist: azure-identity>=1.20
|
|
29
26
|
Requires-Dist: azure-keyvault-certificates>=4.9
|
|
30
27
|
Requires-Dist: azure-keyvault-keys>=4.10
|
|
31
28
|
Requires-Dist: azure-keyvault-secrets>=4.9
|
|
32
29
|
Requires-Dist: azure-mgmt-keyvault>=14.0.1
|
|
33
|
-
Requires-Dist: azure-mgmt-resource
|
|
34
|
-
Requires-Dist: azure-mgmt-subscription>=3.1
|
|
30
|
+
Requires-Dist: azure-mgmt-resource-subscriptions==1.0.0
|
|
35
31
|
Requires-Dist: click>=8.0
|
|
32
|
+
Requires-Dist: cryptography>=46.0.7
|
|
36
33
|
Requires-Dist: httpx>=0.27
|
|
34
|
+
Requires-Dist: idna>=3.15
|
|
35
|
+
Requires-Dist: pygments>=2.20.0
|
|
36
|
+
Requires-Dist: pyjwt>=2.13.0
|
|
37
|
+
Requires-Dist: requests>=2.33.0
|
|
37
38
|
Requires-Dist: rich>=15.0
|
|
38
39
|
Requires-Dist: tenacity>=9.0
|
|
40
|
+
Requires-Dist: urllib3>=2.7.0
|
|
39
41
|
Provides-Extra: dev
|
|
40
42
|
Requires-Dist: pytest-mock>=3.10; extra == 'dev'
|
|
41
43
|
Requires-Dist: pytest>=9.0.3; extra == 'dev'
|
|
@@ -221,8 +223,6 @@ CertifyClouds Scanner sends **aggregate usage statistics** to improve the produc
|
|
|
221
223
|
|
|
222
224
|
Use `--offline` to skip license server validation calls (key telemetry and version checks). Azure SDK calls are unaffected — the scanner still requires Azure connectivity to read Key Vault data.
|
|
223
225
|
|
|
224
|
-
Source code is open and auditable: [codeberg.org/hus/cc/scanner](https://codeberg.org/hus/cc)
|
|
225
|
-
|
|
226
226
|
## Troubleshooting
|
|
227
227
|
|
|
228
228
|
**"No Azure credentials found"**
|
|
@@ -254,4 +254,3 @@ Apache 2.0. See [LICENSE](LICENSE).
|
|
|
254
254
|
|
|
255
255
|
- [CertifyClouds](https://certifyclouds.com) - Automate secret rotation & compliance
|
|
256
256
|
- [Documentation](https://docs.certifyclouds.com/scanner)
|
|
257
|
-
- [Source Code](https://codeberg.org/hus/cc)
|
|
@@ -178,8 +178,6 @@ CertifyClouds Scanner sends **aggregate usage statistics** to improve the produc
|
|
|
178
178
|
|
|
179
179
|
Use `--offline` to skip license server validation calls (key telemetry and version checks). Azure SDK calls are unaffected — the scanner still requires Azure connectivity to read Key Vault data.
|
|
180
180
|
|
|
181
|
-
Source code is open and auditable: [codeberg.org/hus/cc/scanner](https://codeberg.org/hus/cc)
|
|
182
|
-
|
|
183
181
|
## Troubleshooting
|
|
184
182
|
|
|
185
183
|
**"No Azure credentials found"**
|
|
@@ -211,4 +209,3 @@ Apache 2.0. See [LICENSE](LICENSE).
|
|
|
211
209
|
|
|
212
210
|
- [CertifyClouds](https://certifyclouds.com) - Automate secret rotation & compliance
|
|
213
211
|
- [Documentation](https://docs.certifyclouds.com/scanner)
|
|
214
|
-
- [Source Code](https://codeberg.org/hus/cc)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "certifyclouds"
|
|
3
|
-
version = "1.3.
|
|
3
|
+
version = "1.3.9"
|
|
4
4
|
description = "Scan Azure Key Vaults for expiring secrets, certificates, and keys"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Apache-2.0" }
|
|
7
|
-
requires-python = ">=3.
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
8
|
authors = [{ name = "CertifyClouds", email = "hello@certifyclouds.com" }]
|
|
9
9
|
keywords = ["azure", "keyvault", "secrets", "certificates", "security", "scanner", "expiry", "rotation"]
|
|
10
10
|
classifiers = [
|
|
@@ -16,8 +16,6 @@ classifiers = [
|
|
|
16
16
|
"Topic :: Security",
|
|
17
17
|
"Topic :: System :: Systems Administration",
|
|
18
18
|
"Programming Language :: Python :: 3",
|
|
19
|
-
"Programming Language :: Python :: 3.9",
|
|
20
|
-
"Programming Language :: Python :: 3.10",
|
|
21
19
|
"Programming Language :: Python :: 3.11",
|
|
22
20
|
"Programming Language :: Python :: 3.12",
|
|
23
21
|
"Programming Language :: Python :: 3.13",
|
|
@@ -26,8 +24,7 @@ classifiers = [
|
|
|
26
24
|
|
|
27
25
|
dependencies = [
|
|
28
26
|
"azure-identity>=1.20",
|
|
29
|
-
"azure-mgmt-resource
|
|
30
|
-
"azure-mgmt-subscription>=3.1",
|
|
27
|
+
"azure-mgmt-resource-subscriptions==1.0.0",
|
|
31
28
|
"azure-mgmt-keyvault>=14.0.1",
|
|
32
29
|
"azure-keyvault-secrets>=4.9",
|
|
33
30
|
"azure-keyvault-certificates>=4.9",
|
|
@@ -37,6 +34,15 @@ dependencies = [
|
|
|
37
34
|
"click>=8.0",
|
|
38
35
|
"rich>=15.0",
|
|
39
36
|
"httpx>=0.27",
|
|
37
|
+
# Security floors on transitive deps (pip-audit 2026-06-04). Pinned here so a
|
|
38
|
+
# fresh `pip install certifyclouds` can't resolve a known-vulnerable version;
|
|
39
|
+
# drop a pin once the parent SDK requires the patched floor itself.
|
|
40
|
+
"pyjwt>=2.13.0",
|
|
41
|
+
"cryptography>=46.0.7",
|
|
42
|
+
"requests>=2.33.0",
|
|
43
|
+
"urllib3>=2.7.0",
|
|
44
|
+
"idna>=3.15",
|
|
45
|
+
"pygments>=2.20.0",
|
|
40
46
|
]
|
|
41
47
|
|
|
42
48
|
[project.optional-dependencies]
|
|
@@ -51,7 +57,6 @@ cc-scan = "cc_scanner.cli:main"
|
|
|
51
57
|
[project.urls]
|
|
52
58
|
Homepage = "https://certifyclouds.com/scanner"
|
|
53
59
|
Documentation = "https://docs.certifyclouds.com/scanner"
|
|
54
|
-
Repository = "https://codeberg.org/hus/cc"
|
|
55
60
|
|
|
56
61
|
[build-system]
|
|
57
62
|
requires = ["hatchling"]
|
|
@@ -42,7 +42,12 @@ def validate_key(key: str, stats: dict[str, Any] | None = None) -> ValidationRes
|
|
|
42
42
|
Returns:
|
|
43
43
|
ValidationResult with valid/invalid/offline status
|
|
44
44
|
"""
|
|
45
|
-
# Check if we can skip validation (cached grace period)
|
|
45
|
+
# Check if we can skip validation (cached grace period).
|
|
46
|
+
# CAUTION: this cached path never sees a server-side revocation — a
|
|
47
|
+
# revoked key stays valid=True for up to the 7-day grace window. The CLI
|
|
48
|
+
# always passes stats (cli.py builds them before calling), so this branch
|
|
49
|
+
# is only reachable from future callers/tests that omit stats — keep it
|
|
50
|
+
# that way, or revocation enforcement silently degrades.
|
|
46
51
|
if stats is None and cfg.is_validation_fresh():
|
|
47
52
|
logger.debug("Skipping validation — last validated within grace period")
|
|
48
53
|
return ValidationResult(valid=True, offline=False)
|
|
@@ -123,6 +123,23 @@ def _setup_logging(verbosity: int) -> None:
|
|
|
123
123
|
logging.getLogger(name).setLevel(logging.WARNING)
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
|
|
127
|
+
def _exit_now(code: int) -> None:
|
|
128
|
+
"""Exit without waiting for in-flight scan threads.
|
|
129
|
+
|
|
130
|
+
``scan()`` returns at the timeout deadline (pool shut down with
|
|
131
|
+
``wait=False``), but the interpreter still joins non-daemon pool threads at
|
|
132
|
+
exit — ``threading._shutdown`` waits for them, and ThreadPoolExecutor has
|
|
133
|
+
not allowed daemon threads since Python 3.9. So a plain ``sys.exit`` here
|
|
134
|
+
holds the command open until the abandoned Azure call finishes, which is
|
|
135
|
+
exactly what --timeout promises not to do. ``os._exit`` after flushing
|
|
136
|
+
skips the join. Only used on the timed-out paths, where the lingering
|
|
137
|
+
thread is abandoned by design; normal exits keep ``sys.exit``.
|
|
138
|
+
"""
|
|
139
|
+
sys.stdout.flush()
|
|
140
|
+
sys.stderr.flush()
|
|
141
|
+
os._exit(code)
|
|
142
|
+
|
|
126
143
|
@click.command()
|
|
127
144
|
@click.option("--format", "fmt", type=click.Choice(["table", "json", "csv", "html"]),
|
|
128
145
|
default=None, help="Output format (default: table)")
|
|
@@ -249,6 +266,8 @@ def main(
|
|
|
249
266
|
console.print(" AZURE_TENANT_ID")
|
|
250
267
|
elif fmt == "json":
|
|
251
268
|
get_formatter("json")(results, [], output_file)
|
|
269
|
+
if results.get("timed_out"):
|
|
270
|
+
_exit_now(2)
|
|
252
271
|
sys.exit(2)
|
|
253
272
|
|
|
254
273
|
if results.get("timed_out"):
|
|
@@ -319,7 +338,7 @@ def main(
|
|
|
319
338
|
|
|
320
339
|
# Exit code
|
|
321
340
|
if results.get("timed_out"):
|
|
322
|
-
|
|
341
|
+
_exit_now(2)
|
|
323
342
|
if has_critical_or_high(findings):
|
|
324
343
|
sys.exit(1)
|
|
325
344
|
sys.exit(0)
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
def _write(output) -> None:
|
|
23
|
+
writer = csv.writer(output)
|
|
24
|
+
writer.writerow([
|
|
25
|
+
"subscription", "vault", "type", "name", "enabled", "expires",
|
|
26
|
+
"days_until_expiry", "content_type", "version", "tags",
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
now = datetime.now(timezone.utc)
|
|
30
|
+
|
|
31
|
+
for sub in results.get("subscriptions", []):
|
|
32
|
+
sub_name = sub.get("name", sub.get("id", ""))
|
|
33
|
+
for vault in sub.get("vaults", []):
|
|
34
|
+
vault_name = vault.get("name", "")
|
|
35
|
+
for item_type, items in [
|
|
36
|
+
("secret", vault.get("secrets", [])),
|
|
37
|
+
("certificate", vault.get("certificates", [])),
|
|
38
|
+
("key", vault.get("keys", [])),
|
|
39
|
+
]:
|
|
40
|
+
for item in items:
|
|
41
|
+
days_left = ""
|
|
42
|
+
expires_on = item.get("expiresOn")
|
|
43
|
+
if expires_on:
|
|
44
|
+
try:
|
|
45
|
+
expiry = datetime.fromisoformat(expires_on.replace("Z", "+00:00"))
|
|
46
|
+
if expiry.tzinfo is None:
|
|
47
|
+
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
48
|
+
days_left = str((expiry - now).days)
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
tags = item.get("tags", {})
|
|
53
|
+
tags_str = "; ".join(f"{k}={v}" for k, v in tags.items()) if tags else ""
|
|
54
|
+
|
|
55
|
+
writer.writerow([
|
|
56
|
+
sub_name,
|
|
57
|
+
vault_name,
|
|
58
|
+
item_type,
|
|
59
|
+
item.get("name", ""),
|
|
60
|
+
item.get("enabled", ""),
|
|
61
|
+
expires_on or "",
|
|
62
|
+
days_left,
|
|
63
|
+
item.get("contentType", ""),
|
|
64
|
+
item.get("version", ""),
|
|
65
|
+
tags_str,
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
# Context manager guarantees the handle closes even if writing raises.
|
|
69
|
+
if output_file:
|
|
70
|
+
with open(output_file, "w", newline="", encoding="utf-8") as output:
|
|
71
|
+
_write(output)
|
|
72
|
+
else:
|
|
73
|
+
output = io.StringIO()
|
|
74
|
+
_write(output)
|
|
75
|
+
print(output.getvalue(), end="")
|
|
76
|
+
|
|
77
|
+
return None
|
|
@@ -35,14 +35,27 @@ def format_table(
|
|
|
35
35
|
Returns:
|
|
36
36
|
None (prints to console) or string if output_file is set
|
|
37
37
|
"""
|
|
38
|
-
|
|
38
|
+
# Manage the file handle with a context manager so it always closes, even if
|
|
39
|
+
# Rich rendering raises mid-write (e.g. a malformed finding dict) — a bare
|
|
40
|
+
# open() passed to Console leaked the handle on any exception.
|
|
41
|
+
if output_file:
|
|
42
|
+
with open(output_file, "w") as fh:
|
|
43
|
+
_render_table(Console(file=fh, force_terminal=False), results, findings)
|
|
44
|
+
else:
|
|
45
|
+
_render_table(Console(force_terminal=True), results, findings)
|
|
46
|
+
return None
|
|
39
47
|
|
|
48
|
+
|
|
49
|
+
def _render_table(
|
|
50
|
+
console: Console,
|
|
51
|
+
results: dict[str, Any],
|
|
52
|
+
findings: list[dict[str, Any]],
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Render the full scan table (tree + summary + findings) to a console."""
|
|
40
55
|
# Check for scan error
|
|
41
56
|
if results.get("error"):
|
|
42
57
|
console.print(f"[red]Error: {results['error']}[/red]")
|
|
43
|
-
|
|
44
|
-
console.file.close()
|
|
45
|
-
return None
|
|
58
|
+
return
|
|
46
59
|
|
|
47
60
|
# Subscription tree
|
|
48
61
|
for sub in results.get("subscriptions", []):
|
|
@@ -135,11 +148,6 @@ def format_table(
|
|
|
135
148
|
console.print(f" [dim]... and {remaining} more findings[/dim]")
|
|
136
149
|
console.print()
|
|
137
150
|
|
|
138
|
-
if output_file:
|
|
139
|
-
console.file.close()
|
|
140
|
-
|
|
141
|
-
return None
|
|
142
|
-
|
|
143
151
|
|
|
144
152
|
def format_table_security_only(
|
|
145
153
|
results: dict[str, Any],
|
|
@@ -147,12 +155,18 @@ def format_table_security_only(
|
|
|
147
155
|
output_file: str | None = None,
|
|
148
156
|
) -> None:
|
|
149
157
|
"""Show only security findings summary (--security flag)."""
|
|
150
|
-
|
|
158
|
+
# Context manager guarantees the handle closes even if rendering raises.
|
|
159
|
+
if output_file:
|
|
160
|
+
with open(output_file, "w") as fh:
|
|
161
|
+
_render_security_table(Console(file=fh, force_terminal=False), findings)
|
|
162
|
+
else:
|
|
163
|
+
_render_security_table(Console(force_terminal=True), findings)
|
|
151
164
|
|
|
165
|
+
|
|
166
|
+
def _render_security_table(console: Console, findings: list[dict[str, Any]]) -> None:
|
|
167
|
+
"""Render the security-only findings summary to a console."""
|
|
152
168
|
if not findings:
|
|
153
169
|
console.print("[green]No security findings.[/green]")
|
|
154
|
-
if output_file:
|
|
155
|
-
console.file.close()
|
|
156
170
|
return
|
|
157
171
|
|
|
158
172
|
counts = count_by_severity(findings)
|
|
@@ -185,9 +199,6 @@ def format_table_security_only(
|
|
|
185
199
|
console.print(table)
|
|
186
200
|
console.print()
|
|
187
201
|
|
|
188
|
-
if output_file:
|
|
189
|
-
console.file.close()
|
|
190
|
-
|
|
191
202
|
|
|
192
203
|
def _print_expiry_line(console: Console, label: str, counts: dict[str, int], style: str) -> None:
|
|
193
204
|
"""Print an expiry summary line if there are any items in this bucket."""
|
|
@@ -24,7 +24,7 @@ try:
|
|
|
24
24
|
from azure.keyvault.keys import KeyClient # type: ignore
|
|
25
25
|
from azure.keyvault.secrets import SecretClient # type: ignore
|
|
26
26
|
from azure.mgmt.keyvault import KeyVaultManagementClient # type: ignore
|
|
27
|
-
from azure.mgmt.
|
|
27
|
+
from azure.mgmt.resource.subscriptions import SubscriptionClient # type: ignore
|
|
28
28
|
|
|
29
29
|
AZURE_SDK_AVAILABLE = True
|
|
30
30
|
except ImportError:
|
|
@@ -524,28 +524,47 @@ def scan(
|
|
|
524
524
|
# Execute subscription scans in parallel
|
|
525
525
|
worker_count = min(max_workers, len(subs_to_scan)) if subs_to_scan else 1
|
|
526
526
|
|
|
527
|
+
# NB: deliberately NOT a `with` block. ThreadPoolExecutor.__exit__ calls
|
|
528
|
+
# shutdown(wait=True), which blocks until every in-flight subscription
|
|
529
|
+
# scan finishes — so `timeout` bounded only when we stopped COLLECTING
|
|
530
|
+
# results, not when the caller got control back. A --timeout 30 on a
|
|
531
|
+
# ten-minute scan still returned after ten minutes, making the flag inert.
|
|
532
|
+
# Shut down without waiting instead, cancelling anything not yet started.
|
|
533
|
+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=worker_count)
|
|
527
534
|
try:
|
|
528
|
-
|
|
529
|
-
future_to_sub = {executor.submit(scan_subscription, sub): sub for sub in subs_to_scan}
|
|
535
|
+
future_to_sub = {executor.submit(scan_subscription, sub): sub for sub in subs_to_scan}
|
|
530
536
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
537
|
+
for future in concurrent.futures.as_completed(future_to_sub, timeout=timeout):
|
|
538
|
+
try:
|
|
539
|
+
sub_entry = future.result()
|
|
540
|
+
results["subscriptions"].append(sub_entry)
|
|
541
|
+
results["totalVaults"] += len(sub_entry["vaults"])
|
|
542
|
+
for vault in sub_entry["vaults"]:
|
|
543
|
+
results["totalSecrets"] += len(vault.get("secrets", []))
|
|
544
|
+
results["totalCertificates"] += len(vault.get("certificates", []))
|
|
545
|
+
results["totalKeys"] += len(vault.get("keys", []))
|
|
546
|
+
if vault.get("error"):
|
|
547
|
+
results["vaultsWithErrors"] += 1
|
|
548
|
+
except Exception as exc:
|
|
549
|
+
sub = future_to_sub[future]
|
|
550
|
+
logger.error("Scan failed for subscription %s: %s", getattr(sub, "display_name", sub.subscription_id), exc)
|
|
545
551
|
except concurrent.futures.TimeoutError:
|
|
546
|
-
|
|
552
|
+
completed = len(results["subscriptions"])
|
|
553
|
+
logger.error(
|
|
554
|
+
"Scan timed out after %s seconds; returning partial results (%s of %s subscription(s) completed)",
|
|
555
|
+
timeout,
|
|
556
|
+
completed,
|
|
557
|
+
len(subs_to_scan),
|
|
558
|
+
)
|
|
547
559
|
results["timed_out"] = True
|
|
548
560
|
results["error"] = f"Scan timed out after {timeout} seconds"
|
|
561
|
+
# Subscriptions still queued are cancelled outright. One already
|
|
562
|
+
# running cannot be interrupted mid-Azure-call — Python cannot kill a
|
|
563
|
+
# thread — but it no longer holds up the caller, and each SDK call
|
|
564
|
+
# carries its own request timeout.
|
|
565
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
566
|
+
else:
|
|
567
|
+
executor.shutdown(wait=True)
|
|
549
568
|
|
|
550
569
|
return results
|
|
551
570
|
|
|
@@ -1,74 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|