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.
Files changed (20) hide show
  1. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/.gitignore +30 -1
  2. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/PKG-INFO +10 -11
  3. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/README.md +0 -3
  4. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/pyproject.toml +12 -7
  5. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/__init__.py +1 -1
  6. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/auth.py +6 -1
  7. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/cli.py +20 -1
  8. certifyclouds-1.3.9/src/cc_scanner/formatters/csv_fmt.py +77 -0
  9. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/formatters/table.py +26 -15
  10. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/scanner.py +37 -18
  11. certifyclouds-1.3.6/src/cc_scanner/formatters/csv_fmt.py +0 -74
  12. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/LICENSE +0 -0
  13. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/config.py +0 -0
  14. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/filters.py +0 -0
  15. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/formatters/__init__.py +0 -0
  16. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/formatters/html.py +0 -0
  17. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/formatters/json_fmt.py +0 -0
  18. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/registration.py +0 -0
  19. {certifyclouds-1.3.6 → certifyclouds-1.3.9}/src/cc_scanner/security.py +0 -0
  20. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: certifyclouds
3
- Version: 1.3.6
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.9
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>=24.0
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.6"
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.9"
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>=24.0",
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"]
@@ -1,3 +1,3 @@
1
1
  """CertifyClouds Scanner — scan Azure Key Vaults for expiring secrets, certificates, and keys."""
2
2
 
3
- __version__ = "1.3.6"
3
+ __version__ = "1.3.9"
@@ -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
- sys.exit(2)
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
- console = Console(file=open(output_file, "w") if output_file else None, force_terminal=output_file is None)
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
- if output_file:
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
- console = Console(file=open(output_file, "w") if output_file else None, force_terminal=output_file is None)
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.subscription import SubscriptionClient # type: ignore
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
- with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as executor:
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
- for future in concurrent.futures.as_completed(future_to_sub, timeout=timeout):
532
- try:
533
- sub_entry = future.result()
534
- results["subscriptions"].append(sub_entry)
535
- results["totalVaults"] += len(sub_entry["vaults"])
536
- for vault in sub_entry["vaults"]:
537
- results["totalSecrets"] += len(vault.get("secrets", []))
538
- results["totalCertificates"] += len(vault.get("certificates", []))
539
- results["totalKeys"] += len(vault.get("keys", []))
540
- if vault.get("error"):
541
- results["vaultsWithErrors"] += 1
542
- except Exception as exc:
543
- sub = future_to_sub[future]
544
- logger.error("Scan failed for subscription %s: %s", getattr(sub, "display_name", sub.subscription_id), exc)
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
- logger.error("Scan timed out after %s seconds; returning partial results", timeout)
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