feat(security): verify public tls asset health
This commit is contained in:
@@ -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() -> (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user