feat(security): verify public tls asset health

This commit is contained in:
ogt
2026-07-14 15:31:15 +08:00
parent 248cf07ce1
commit 27f9e46f3b
7 changed files with 815 additions and 12 deletions

View File

@@ -29,9 +29,13 @@ from __future__ import annotations
import asyncio
import hashlib
import ipaddress
import json as _json
import re
import socket
import ssl
import time as _time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -47,6 +51,14 @@ _FIRST_DELAY_SEC = 60 # 啟動後等 60s 再首掃 (其他 service init)
_KUBECTL_TIMEOUT_SEC = 30
_LOOP_BACKOFF_SEC = 300 # 異常時 backoff 5 分鐘
_PROJECT_ID = "awoooi"
_PUBLIC_TLS_PROBE_TIMEOUT_SEC = 5.0
_PUBLIC_TLS_PROBE_CONCURRENCY = 4
_PUBLIC_TLS_WARNING_DAYS = 30
_PUBLIC_TLS_CRITICAL_DAYS = 7
class _PublicTlsAddressPolicyError(RuntimeError):
pass
_OWNER_SIGNAL_KEYS = (
"wooo.work/owner-team",
@@ -1052,6 +1064,317 @@ def _normalize_public_domain(value: Any) -> str | None:
return domain
def _classify_public_tls_expiry(
expires_epoch: float,
*,
now_epoch: float | None = None,
) -> tuple[str, int, str]:
current_epoch = _time.time() if now_epoch is None else now_epoch
days_remaining = int((expires_epoch - current_epoch) // 86400)
if days_remaining < 0:
return "critical", days_remaining, "tls_certificate_expired"
if days_remaining < _PUBLIC_TLS_CRITICAL_DAYS:
return "critical", days_remaining, "tls_certificate_expiry_critical"
if days_remaining < _PUBLIC_TLS_WARNING_DAYS:
return "warning", days_remaining, "tls_certificate_expiry_warning"
return "healthy", days_remaining, "tls_certificate_valid"
def _public_tls_probe_result(
*,
status: str,
evidenced: bool,
healthy: bool,
chain_verified: bool,
hostname_verified: bool,
reason_code: str,
expires_at: str | None = None,
days_remaining: int | None = None,
) -> dict[str, Any]:
return {
"live_tls_probe_executed": True,
"live_tls_probe_status": status,
"live_tls_probe_evidenced": evidenced,
"live_tls_healthy": healthy,
"tls_chain_verified": chain_verified,
"tls_hostname_verified": hostname_verified,
"tls_expires_at": expires_at,
"tls_days_remaining": days_remaining,
"tls_probe_reason_code": reason_code,
"certificate_material_read": False,
"private_key_material_read": False,
"secret_value_read": False,
}
def _validated_public_tls_addresses(addresses: list[str]) -> list[str]:
validated: list[str] = []
seen: set[str] = set()
for raw_address in addresses:
try:
address = ipaddress.ip_address(raw_address)
except ValueError as exc:
raise _PublicTlsAddressPolicyError(
"tls_endpoint_address_invalid"
) from exc
if not address.is_global:
raise _PublicTlsAddressPolicyError("tls_endpoint_address_not_public")
normalized = str(address)
if normalized not in seen:
seen.add(normalized)
validated.append(normalized)
if not validated:
raise _PublicTlsAddressPolicyError("tls_endpoint_address_missing")
return validated
async def _resolve_public_tls_addresses(domain: str) -> list[str]:
records = await asyncio.wait_for(
asyncio.get_running_loop().getaddrinfo(
domain,
443,
type=socket.SOCK_STREAM,
proto=socket.IPPROTO_TCP,
),
timeout=_PUBLIC_TLS_PROBE_TIMEOUT_SEC,
)
return _validated_public_tls_addresses(
[str(record[4][0]) for record in records]
)
async def _probe_public_tls_endpoint(domain: str) -> dict[str, Any]:
"""Verify one public TLS endpoint without reading certificate/key files."""
writer: asyncio.StreamWriter | None = None
try:
addresses = await _resolve_public_tls_addresses(domain)
context = ssl.create_default_context()
async def connect() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
last_network_error: OSError | None = None
for address in addresses:
try:
return await asyncio.open_connection(
address,
443,
ssl=context,
server_hostname=domain,
)
except ssl.SSLError:
raise
except OSError as exc:
last_network_error = exc
if last_network_error is not None:
raise last_network_error
raise OSError("tls_endpoint_connection_missing")
_, writer = await asyncio.wait_for(
connect(),
timeout=_PUBLIC_TLS_PROBE_TIMEOUT_SEC,
)
ssl_object = writer.get_extra_info("ssl_object")
if ssl_object is None:
raise ValueError("tls_socket_metadata_missing")
certificate = ssl_object.getpeercert()
if not isinstance(certificate, dict) or not certificate.get("notAfter"):
raise ValueError("tls_expiry_metadata_missing")
expires_epoch = ssl.cert_time_to_seconds(str(certificate["notAfter"]))
status, days_remaining, reason_code = _classify_public_tls_expiry(
expires_epoch
)
return _public_tls_probe_result(
status=status,
evidenced=True,
healthy=status == "healthy",
chain_verified=True,
hostname_verified=True,
reason_code=reason_code,
expires_at=datetime.fromtimestamp(expires_epoch, UTC)
.replace(microsecond=0)
.isoformat(),
days_remaining=days_remaining,
)
except ssl.SSLCertVerificationError:
return _public_tls_probe_result(
status="critical",
evidenced=True,
healthy=False,
chain_verified=False,
hostname_verified=False,
reason_code="tls_certificate_verification_failed",
)
except _PublicTlsAddressPolicyError:
return _public_tls_probe_result(
status="unavailable",
evidenced=False,
healthy=False,
chain_verified=False,
hostname_verified=False,
reason_code="tls_endpoint_address_policy_rejected",
)
except (TimeoutError, asyncio.TimeoutError, OSError):
return _public_tls_probe_result(
status="unavailable",
evidenced=False,
healthy=False,
chain_verified=False,
hostname_verified=False,
reason_code="tls_endpoint_unavailable",
)
except (KeyError, TypeError, ValueError):
return _public_tls_probe_result(
status="critical",
evidenced=True,
healthy=False,
chain_verified=False,
hostname_verified=False,
reason_code="tls_certificate_metadata_invalid",
)
except Exception:
return _public_tls_probe_result(
status="unavailable",
evidenced=False,
healthy=False,
chain_verified=False,
hostname_verified=False,
reason_code="tls_probe_internal_error",
)
finally:
if writer is not None:
writer.close()
async def _probe_public_tls_endpoints(
domains: list[str],
) -> dict[str, dict[str, Any]]:
semaphore = asyncio.Semaphore(_PUBLIC_TLS_PROBE_CONCURRENCY)
async def bounded(domain: str) -> tuple[str, dict[str, Any]]:
async with semaphore:
return domain, await _probe_public_tls_endpoint(domain)
rows = await asyncio.gather(*(bounded(domain) for domain in sorted(set(domains))))
return dict(rows)
def _apply_domain_tls_probe_results(
assets: list[dict[str, Any]],
relationships: list[dict[str, str]],
probe_results: dict[str, dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
"""Join bounded endpoint results back to domain and certificate assets."""
assets_by_key = {str(asset["asset_key"]): asset for asset in assets}
result_by_domain_key: dict[str, dict[str, Any]] = {}
for asset in assets:
if asset.get("asset_type") != "website" or not str(
asset.get("asset_key") or ""
).startswith("repo/nginx-domain/"):
continue
domain = _normalize_public_domain(asset.get("host"))
if domain is None:
continue
result = probe_results.get(domain)
if result is None:
result = _public_tls_probe_result(
status="unavailable",
evidenced=False,
healthy=False,
chain_verified=False,
hostname_verified=False,
reason_code="tls_probe_result_missing",
)
asset["metadata"] = {**(asset.get("metadata") or {}), **result}
result_by_domain_key[str(asset["asset_key"])] = result
certificate_domains: dict[str, list[dict[str, Any]]] = {}
for relationship in relationships:
if relationship.get("relationship_type") != "authenticates_via":
continue
certificate_key = str(relationship.get("to_key") or "")
domain_result = result_by_domain_key.get(
str(relationship.get("from_key") or "")
)
if domain_result is not None and certificate_key in assets_by_key:
certificate_domains.setdefault(certificate_key, []).append(domain_result)
for certificate_key, endpoint_results in certificate_domains.items():
certificate = assets_by_key[certificate_key]
endpoint_count = len(endpoint_results)
evidenced_count = sum(
result.get("live_tls_probe_evidenced") is True
for result in endpoint_results
)
healthy_count = sum(
result.get("live_tls_healthy") is True for result in endpoint_results
)
warning_count = sum(
result.get("live_tls_probe_status") == "warning"
for result in endpoint_results
)
critical_count = sum(
result.get("live_tls_probe_status") == "critical"
for result in endpoint_results
)
unavailable_count = endpoint_count - evidenced_count
days_remaining = [
int(result["tls_days_remaining"])
for result in endpoint_results
if isinstance(result.get("tls_days_remaining"), int)
]
all_evidenced = endpoint_count > 0 and evidenced_count == endpoint_count
all_healthy = all_evidenced and healthy_count == endpoint_count
if critical_count > 0:
aggregate_status = "critical"
reason_code = "tls_certificate_endpoint_critical"
elif warning_count > 0:
aggregate_status = "warning"
reason_code = "tls_certificate_endpoint_warning"
elif unavailable_count > 0:
aggregate_status = "unavailable"
reason_code = "tls_certificate_endpoint_unavailable"
elif all_healthy:
aggregate_status = "healthy"
reason_code = "tls_certificate_endpoints_healthy"
else:
aggregate_status = "critical"
reason_code = "tls_certificate_endpoint_state_invalid"
certificate["metadata"] = {
**(certificate.get("metadata") or {}),
"live_tls_probe_executed": True,
"live_tls_probe_status": aggregate_status,
"live_tls_probe_evidenced": all_evidenced,
"live_tls_healthy": all_healthy,
"tls_chain_verified": all(
result.get("tls_chain_verified") is True
for result in endpoint_results
),
"tls_hostname_verified": all(
result.get("tls_hostname_verified") is True
for result in endpoint_results
),
"tls_endpoint_count": endpoint_count,
"tls_evidenced_endpoint_count": evidenced_count,
"tls_healthy_endpoint_count": healthy_count,
"tls_warning_endpoint_count": warning_count,
"tls_critical_endpoint_count": critical_count,
"tls_unavailable_endpoint_count": unavailable_count,
"tls_min_days_remaining": (
min(days_remaining) if days_remaining else None
),
"tls_probe_reason_code": reason_code,
"certificate_material_read": False,
"private_key_material_read": False,
"raw_certificate_path_stored": False,
"secret_value_read": False,
}
return assets, relationships
def _build_domain_tls_inventory_assets(
payload: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
@@ -1204,7 +1527,14 @@ async def _collect_domain_tls_inventory_assets() -> (
tuple[list[dict[str, Any]], list[dict[str, str]]]
):
payload = await asyncio.to_thread(_load_domain_tls_inventory_payload)
return _build_domain_tls_inventory_assets(payload)
assets, relationships = _build_domain_tls_inventory_assets(payload)
domains = [
str(asset["host"])
for asset in assets
if asset.get("asset_type") == "website" and asset.get("host")
]
probe_results = await _probe_public_tls_endpoints(domains)
return _apply_domain_tls_probe_results(assets, relationships, probe_results)
async def _collect_runtime_assets() -> (

View File

@@ -370,6 +370,8 @@ def _build_security_program_domains(
package_digest_unpinned_count: int,
certificate_live_verified_percent: int,
certificate_live_unverified_count: int,
certificate_live_healthy_percent: int,
certificate_live_unhealthy_count: int,
) -> list[dict[str, Any]]:
scope_by_id = {row["scope_id"]: row for row in asset_scopes}
function_by_id = {row["function_id"]: row for row in control_functions}
@@ -399,11 +401,12 @@ def _build_security_program_domains(
supply_chain_gap_code = "runtime_image_digest_pin_missing"
else:
supply_chain_gap_code = "sbom_signature_dependency_evidence_missing"
network_tls_gap_code = (
"certificate_live_tls_probe_missing"
if certificate_live_unverified_count > 0
else "network_dns_tls_runtime_evidence_missing"
)
if certificate_live_unverified_count > 0:
network_tls_gap_code = "certificate_live_tls_probe_missing"
elif certificate_live_unhealthy_count > 0:
network_tls_gap_code = "certificate_live_tls_unhealthy"
else:
network_tls_gap_code = "network_dns_tls_runtime_evidence_missing"
domain_values = (
(
@@ -461,6 +464,7 @@ def _build_security_program_domains(
100 if by_type.get("network", 0) > 0 else 0,
100 if by_type.get("certificate", 0) > 0 else 0,
certificate_live_verified_percent,
certificate_live_healthy_percent,
compliance_percent("ssl_cert_valid"),
),
by_type.get("network", 0)
@@ -687,6 +691,10 @@ def build_iwooos_security_asset_control_plane(
int(_value(row, "unverified_certificate_count", 0) or 0)
for row in inventory_source
)
certificate_live_unhealthy_count = sum(
int(_value(row, "unhealthy_certificate_count", 0) or 0)
for row in inventory_source
)
present_types = {
asset_type for asset_type in _ASSET_TYPES if by_type.get(asset_type, 0) > 0
}
@@ -907,6 +915,16 @@ def build_iwooos_security_asset_control_plane(
by_type.get("certificate", 0),
),
certificate_live_unverified_count=certificate_live_unverified_count,
certificate_live_healthy_percent=_percent(
max(
0,
by_type.get("certificate", 0)
- certificate_live_unverified_count
- certificate_live_unhealthy_count,
),
by_type.get("certificate", 0),
),
certificate_live_unhealthy_count=certificate_live_unhealthy_count,
)
work_items: list[dict[str, Any]] = []
@@ -962,17 +980,21 @@ def build_iwooos_security_asset_control_plane(
),
)
)
if certificate_live_unverified_count > 0:
certificate_live_gap_count = (
certificate_live_unverified_count + certificate_live_unhealthy_count
)
if certificate_live_gap_count > 0:
work_items.append(
_work_item(
"AIA-P0-006-02B",
"P0",
"certificate_live_verification",
"驗證 public TLS 憑證",
certificate_live_unverified_count,
"驗證並修復 public TLS 憑證",
certificate_live_gap_count,
(
"執行 bounded TLS handshake metadata probe驗證 hostname、"
"chain 與 expiry不得讀取 private key 或 certificate volume。"
"chain 與 expiry對 warning/critical 建立受控修復,"
"不得讀取 private key 或 certificate volume。"
),
)
)
@@ -1132,6 +1154,7 @@ def build_iwooos_security_asset_control_plane(
"certificate_live_unverified_count": (
certificate_live_unverified_count
),
"certificate_live_unhealthy_count": certificate_live_unhealthy_count,
"automation_coverage_green_percent": _percent(
coverage_green, coverage_total
),
@@ -1221,10 +1244,23 @@ def build_iwooos_security_asset_control_plane(
by_type.get("certificate", 0)
- certificate_live_unverified_count,
),
"live_tls_evidenced_count": max(
0,
by_type.get("certificate", 0)
- certificate_live_unverified_count,
),
"live_tls_healthy_count": max(
0,
by_type.get("certificate", 0)
- certificate_live_unverified_count
- certificate_live_unhealthy_count,
),
"live_tls_unverified_count": certificate_live_unverified_count,
"live_tls_unhealthy_count": certificate_live_unhealthy_count,
"live_tls_health_proven": (
by_type.get("certificate", 0) > 0
and certificate_live_unverified_count == 0
and certificate_live_unhealthy_count == 0
),
"raw_certificate_path_returned": False,
"certificate_material_read": False,
@@ -1272,6 +1308,7 @@ def build_unavailable_iwooos_security_asset_control_plane(
"package_digest_unpinned_count": 0,
"forbidden_github_supply_chain_asset_count": 0,
"certificate_live_unverified_count": 0,
"certificate_live_unhealthy_count": 0,
"automation_coverage_green_percent": 0,
"automation_coverage_unknown_count": 0,
"compliance_violation_count": 0,
@@ -1438,7 +1475,10 @@ def build_unavailable_iwooos_security_asset_control_plane(
"certificate_inventory": {
"certificate_asset_count": 0,
"live_tls_verified_count": 0,
"live_tls_evidenced_count": 0,
"live_tls_healthy_count": 0,
"live_tls_unverified_count": 0,
"live_tls_unhealthy_count": 0,
"live_tls_health_proven": False,
"raw_certificate_path_returned": False,
"certificate_material_read": False,
@@ -1672,7 +1712,18 @@ class IwoooSSecurityAssetControlPlaneService:
metadata->>'live_tls_probe_evidenced',
'false'
) <> 'true'
) AS unverified_certificate_count
) AS unverified_certificate_count,
count(*) FILTER (
WHERE asset_type = 'certificate'
AND COALESCE(
metadata->>'live_tls_probe_evidenced',
'false'
) = 'true'
AND COALESCE(
metadata->>'live_tls_healthy',
'false'
) <> 'true'
) AS unhealthy_certificate_count
FROM asset_inventory
WHERE lifecycle_state = 'active'
GROUP BY asset_type