feat(security): project runtime service asset types
This commit is contained in:
@@ -416,12 +416,113 @@ def _build_workload_asset(item: dict[str, Any], kind: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _build_workload_component_assets(
|
||||
item: dict[str, Any],
|
||||
kind: str,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||||
"""Project workload labels into product and observability component assets."""
|
||||
meta = item.get("metadata", {}) or {}
|
||||
labels = meta.get("labels", {}) or {}
|
||||
namespace = meta.get("namespace") or "default"
|
||||
name = meta.get("name") or "unknown"
|
||||
signals = " ".join(
|
||||
str(value)
|
||||
for value in (
|
||||
name,
|
||||
labels.get("app"),
|
||||
labels.get("component"),
|
||||
labels.get("system"),
|
||||
labels.get("app.kubernetes.io/name"),
|
||||
labels.get("app.kubernetes.io/component"),
|
||||
)
|
||||
if value
|
||||
).lower()
|
||||
tokens = {token for token in re.split(r"[^a-z0-9]+", signals) if token}
|
||||
|
||||
component_types: list[str] = []
|
||||
if tokens & {"web", "frontend", "ui", "portal"}:
|
||||
component_types.append("frontend")
|
||||
if tokens & {"api", "backend", "server", "worker", "processor", "broker"}:
|
||||
component_types.append("backend")
|
||||
if tokens & {"grafana", "dashboard", "kibana"}:
|
||||
component_types.append("dashboard")
|
||||
if tokens & {
|
||||
"log",
|
||||
"logs",
|
||||
"logging",
|
||||
"promtail",
|
||||
"fluentbit",
|
||||
"fluent",
|
||||
"vector",
|
||||
"otel",
|
||||
"opentelemetry",
|
||||
}:
|
||||
component_types.append("log_stream")
|
||||
|
||||
owner = _normalize_owner_team(labels.get("system")) or _owner_from_namespace(
|
||||
namespace
|
||||
)
|
||||
workload_key = f"k8s/{kind.lower()}/{namespace}/{name}"
|
||||
assets: list[dict[str, Any]] = []
|
||||
relationships: list[dict[str, str]] = []
|
||||
for component_type in component_types:
|
||||
component_owner = owner
|
||||
if component_owner is None and component_type in {"dashboard", "log_stream"}:
|
||||
component_owner = "platform_security"
|
||||
component_key = (
|
||||
f"k8s/component/{component_type}/{kind.lower()}/{namespace}/{name}"
|
||||
)
|
||||
tags = [
|
||||
"source:k8s_workload_labels",
|
||||
f"component_type:{component_type}",
|
||||
]
|
||||
if component_owner:
|
||||
tags.append(f"owner:{component_owner}")
|
||||
assets.append(
|
||||
{
|
||||
"asset_key": component_key,
|
||||
"asset_type": component_type,
|
||||
"host": None,
|
||||
"namespace": namespace,
|
||||
"name": name,
|
||||
"metadata": {
|
||||
"kind": f"{kind}ComponentProjection",
|
||||
"workload_asset_key": workload_key,
|
||||
"label_signals": {
|
||||
key: str(labels[key])
|
||||
for key in (
|
||||
"app",
|
||||
"component",
|
||||
"system",
|
||||
"environment",
|
||||
"app.kubernetes.io/name",
|
||||
"app.kubernetes.io/component",
|
||||
)
|
||||
if labels.get(key)
|
||||
},
|
||||
"container_command_or_env_read": False,
|
||||
"secret_value_read": False,
|
||||
},
|
||||
"tags": tags,
|
||||
}
|
||||
)
|
||||
relationships.append(
|
||||
{
|
||||
"from_key": component_key,
|
||||
"to_key": workload_key,
|
||||
"relationship_type": "depends_on",
|
||||
}
|
||||
)
|
||||
return assets, relationships
|
||||
|
||||
|
||||
def _build_service_asset(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Service → asset_inventory row (asset_type='k8s_resource')."""
|
||||
meta = item.get("metadata", {}) or {}
|
||||
ns = meta.get("namespace") or "default"
|
||||
name = meta.get("name") or "unknown"
|
||||
spec = item.get("spec", {}) or {}
|
||||
labels = meta.get("labels", {}) or {}
|
||||
|
||||
return {
|
||||
"asset_key": f"k8s/service/{ns}/{name}",
|
||||
@@ -435,11 +536,57 @@ def _build_service_asset(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"cluster_ip": spec.get("clusterIP"),
|
||||
"selector": spec.get("selector", {}),
|
||||
"ports": spec.get("ports", []),
|
||||
"labels": labels,
|
||||
},
|
||||
"tags": [f"svc_type:{spec.get('type', '')}"],
|
||||
}
|
||||
|
||||
|
||||
def _build_service_network_asset(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the logical network identity exposed by one Kubernetes Service."""
|
||||
meta = item.get("metadata", {}) or {}
|
||||
spec = item.get("spec", {}) or {}
|
||||
namespace = meta.get("namespace") or "default"
|
||||
name = meta.get("name") or "unknown"
|
||||
labels = meta.get("labels", {}) or {}
|
||||
owner = _normalize_owner_team(labels.get("system")) or _owner_from_namespace(
|
||||
namespace
|
||||
)
|
||||
tags = ["source:k8s_service", "network_kind:service"]
|
||||
if owner:
|
||||
tags.append(f"owner:{owner}")
|
||||
|
||||
return {
|
||||
"asset_key": f"k8s/network/service/{namespace}/{name}",
|
||||
"asset_type": "network",
|
||||
"host": None,
|
||||
"namespace": namespace,
|
||||
"name": name,
|
||||
"metadata": {
|
||||
"kind": "KubernetesServiceNetwork",
|
||||
"service_type": spec.get("type"),
|
||||
"cluster_ip": spec.get("clusterIP"),
|
||||
"cluster_ips": spec.get("clusterIPs", []),
|
||||
"ip_families": spec.get("ipFamilies", []),
|
||||
"external_name": spec.get("externalName"),
|
||||
"ports": [
|
||||
{
|
||||
"name": port.get("name"),
|
||||
"protocol": port.get("protocol"),
|
||||
"port": port.get("port"),
|
||||
"target_port": port.get("targetPort"),
|
||||
"node_port": port.get("nodePort"),
|
||||
}
|
||||
for port in spec.get("ports", []) or []
|
||||
],
|
||||
"labels": labels,
|
||||
"packet_payload_read": False,
|
||||
"secret_value_read": False,
|
||||
},
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
def _build_node_asset(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Node → asset_inventory row (asset_type='host')."""
|
||||
meta = item.get("metadata", {}) or {}
|
||||
@@ -572,6 +719,98 @@ def _build_secret_reference_asset(
|
||||
}
|
||||
|
||||
|
||||
def _is_service_registry_configmap(item: dict[str, Any]) -> bool:
|
||||
metadata = item.get("metadata", {}) or {}
|
||||
return (
|
||||
metadata.get("namespace") == "awoooi-prod"
|
||||
and metadata.get("name") == "service-registry"
|
||||
)
|
||||
|
||||
|
||||
def _collect_service_registry_assets(
|
||||
item: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||||
"""Read allowlisted service identity fields from the runtime registry."""
|
||||
import yaml
|
||||
|
||||
if not _is_service_registry_configmap(item):
|
||||
return [], []
|
||||
raw_registry = str(
|
||||
((item.get("data") or {}).get("service-registry.yaml") or "")
|
||||
)
|
||||
if not raw_registry or len(raw_registry) > 1_000_000:
|
||||
raise RuntimeError("service_registry_config_invalid")
|
||||
payload = yaml.safe_load(raw_registry)
|
||||
services = payload.get("services") if isinstance(payload, dict) else None
|
||||
if not isinstance(services, list):
|
||||
raise RuntimeError("service_registry_services_invalid")
|
||||
|
||||
assets: list[dict[str, Any]] = []
|
||||
relationships: list[dict[str, str]] = []
|
||||
for row in services:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
name = _normalize_owner_team(row.get("name"))
|
||||
if not name:
|
||||
continue
|
||||
host = str(row.get("host") or "").strip()
|
||||
display_name = str(row.get("display_name") or "")
|
||||
reason = str(row.get("reason") or "")
|
||||
classification_text = f"{name} {display_name} {reason}".lower()
|
||||
asset_types: list[str] = []
|
||||
if name == "grafana" or "dashboard" in classification_text:
|
||||
asset_types.append("dashboard")
|
||||
if (
|
||||
"redis" in classification_text
|
||||
or "cache" in classification_text
|
||||
or "快取" in classification_text
|
||||
):
|
||||
asset_types.append("cache")
|
||||
if any(
|
||||
marker in classification_text
|
||||
for marker in ("message queue", "task queue", "queue", "佇列", "stream")
|
||||
):
|
||||
asset_types.append("message_queue")
|
||||
|
||||
owner = "awoooi" if name.startswith("awoooi_") else "platform_security"
|
||||
for asset_type in asset_types:
|
||||
asset_key = f"service-registry/{asset_type}/{name}"
|
||||
assets.append(
|
||||
{
|
||||
"asset_key": asset_key,
|
||||
"asset_type": asset_type,
|
||||
"host": host if _is_valid_ipv4(host) else None,
|
||||
"namespace": "awoooi-prod",
|
||||
"name": name,
|
||||
"metadata": {
|
||||
"kind": "ServiceRegistryProjection",
|
||||
"display_name": display_name,
|
||||
"stateful_level": str(row.get("stateful_level") or "unknown"),
|
||||
"alert_only": row.get("alert_only") is True,
|
||||
"requires_pre_backup": row.get("requires_pre_backup") is True,
|
||||
"container_count": len(row.get("containers") or []),
|
||||
"source_configmap": "awoooi-prod/service-registry",
|
||||
"allowlisted_fields_only": True,
|
||||
"raw_config_persisted": False,
|
||||
"secret_value_read": False,
|
||||
},
|
||||
"tags": [
|
||||
"source:k8s_service_registry",
|
||||
f"owner:{owner}",
|
||||
],
|
||||
}
|
||||
)
|
||||
if _is_valid_ipv4(host):
|
||||
relationships.append(
|
||||
{
|
||||
"from_key": asset_key,
|
||||
"to_key": f"host/{host}",
|
||||
"relationship_type": "depends_on",
|
||||
}
|
||||
)
|
||||
return assets, relationships
|
||||
|
||||
|
||||
def _collect_ingress_assets(
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||||
@@ -838,7 +1077,21 @@ async def _collect_runtime_assets() -> (
|
||||
payload = await _fetch_kubectl_json(resource)
|
||||
for item in payload.get("items", []) or []:
|
||||
assets.append(_build_workload_asset(item, kind))
|
||||
component_assets, component_relationships = (
|
||||
_build_workload_component_assets(item, kind)
|
||||
)
|
||||
assets.extend(component_assets)
|
||||
relationships.extend(component_relationships)
|
||||
reconciled_prefixes.add(f"k8s/{kind.lower()}/")
|
||||
for component_type in (
|
||||
"frontend",
|
||||
"backend",
|
||||
"dashboard",
|
||||
"log_stream",
|
||||
):
|
||||
reconciled_prefixes.add(
|
||||
f"k8s/component/{component_type}/{kind.lower()}/"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"collect_{resource}_failed", error=str(e))
|
||||
collector_errors.append(f"k8s_{resource}")
|
||||
@@ -848,7 +1101,16 @@ async def _collect_runtime_assets() -> (
|
||||
payload = await _fetch_kubectl_json("services")
|
||||
for item in payload.get("items", []) or []:
|
||||
svc = _build_service_asset(item)
|
||||
network = _build_service_network_asset(item)
|
||||
assets.append(svc)
|
||||
assets.append(network)
|
||||
relationships.append(
|
||||
{
|
||||
"from_key": svc["asset_key"],
|
||||
"to_key": network["asset_key"],
|
||||
"relationship_type": "depends_on",
|
||||
}
|
||||
)
|
||||
|
||||
# 為該 Service 找出匹配的 Pod
|
||||
selector = (item.get("spec", {}) or {}).get("selector") or {}
|
||||
@@ -871,6 +1133,7 @@ async def _collect_runtime_assets() -> (
|
||||
}
|
||||
)
|
||||
reconciled_prefixes.add("k8s/service/")
|
||||
reconciled_prefixes.add("k8s/network/service/")
|
||||
except Exception as e:
|
||||
logger.warning("collect_services_failed", error=str(e))
|
||||
collector_errors.append("k8s_services")
|
||||
@@ -878,9 +1141,25 @@ async def _collect_runtime_assets() -> (
|
||||
# 5. ConfigMaps
|
||||
try:
|
||||
payload = await _fetch_kubectl_json("configmaps")
|
||||
service_registry_seen = False
|
||||
for item in payload.get("items", []) or []:
|
||||
assets.append(_build_configmap_asset(item))
|
||||
if _is_service_registry_configmap(item):
|
||||
service_registry_seen = True
|
||||
registry_assets, registry_relationships = (
|
||||
_collect_service_registry_assets(item)
|
||||
)
|
||||
assets.extend(registry_assets)
|
||||
relationships.extend(registry_relationships)
|
||||
reconciled_prefixes.add("k8s/configmap/")
|
||||
if service_registry_seen:
|
||||
reconciled_prefixes.update(
|
||||
(
|
||||
"service-registry/dashboard/",
|
||||
"service-registry/cache/",
|
||||
"service-registry/message_queue/",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("collect_configmaps_failed", error=str(e))
|
||||
collector_errors.append("k8s_configmaps")
|
||||
|
||||
@@ -554,6 +554,106 @@ def test_k8s_metadata_assets_never_read_volume_secret_or_command_data() -> None:
|
||||
assert secret["metadata"]["secret_value_read"] is False
|
||||
|
||||
|
||||
def test_workload_network_and_service_registry_projections_are_bounded() -> None:
|
||||
web_assets, web_relationships = (
|
||||
asset_scanner_job._build_workload_component_assets(
|
||||
{
|
||||
"metadata": {
|
||||
"namespace": "awoooi-prod",
|
||||
"name": "awoooi-web",
|
||||
"labels": {
|
||||
"app": "awoooi-web",
|
||||
"system": "awoooi",
|
||||
},
|
||||
}
|
||||
},
|
||||
"Deployment",
|
||||
)
|
||||
)
|
||||
log_assets, _ = asset_scanner_job._build_workload_component_assets(
|
||||
{
|
||||
"metadata": {
|
||||
"namespace": "observability",
|
||||
"name": "otel-collector",
|
||||
"labels": {
|
||||
"app.kubernetes.io/component": "log-collector",
|
||||
},
|
||||
}
|
||||
},
|
||||
"DaemonSet",
|
||||
)
|
||||
network = asset_scanner_job._build_service_network_asset(
|
||||
{
|
||||
"metadata": {
|
||||
"namespace": "awoooi-prod",
|
||||
"name": "awoooi-api",
|
||||
"labels": {"system": "awoooi"},
|
||||
},
|
||||
"spec": {
|
||||
"type": "ClusterIP",
|
||||
"clusterIP": "10.43.0.10",
|
||||
"ports": [
|
||||
{
|
||||
"name": "http",
|
||||
"protocol": "TCP",
|
||||
"port": 80,
|
||||
"targetPort": 8000,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
registry_assets, registry_relationships = (
|
||||
asset_scanner_job._collect_service_registry_assets(
|
||||
{
|
||||
"metadata": {
|
||||
"namespace": "awoooi-prod",
|
||||
"name": "service-registry",
|
||||
},
|
||||
"data": {
|
||||
"service-registry.yaml": """
|
||||
services:
|
||||
- name: grafana
|
||||
display_name: Grafana
|
||||
host: 192.168.0.110
|
||||
stateful_level: STANDARD_HITL
|
||||
containers: [grafana]
|
||||
- name: redis
|
||||
display_name: Redis cache
|
||||
host: 192.168.0.188
|
||||
stateful_level: CRITICAL_HITL
|
||||
containers: [redis]
|
||||
- name: sentry-redis
|
||||
display_name: Sentry task queue
|
||||
host: 192.168.0.110
|
||||
stateful_level: CRITICAL_HITL
|
||||
containers: [sentry-redis]
|
||||
""",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert [asset["asset_type"] for asset in web_assets] == ["frontend"]
|
||||
assert web_relationships[0]["to_key"] == (
|
||||
"k8s/deployment/awoooi-prod/awoooi-web"
|
||||
)
|
||||
assert [asset["asset_type"] for asset in log_assets] == ["log_stream"]
|
||||
assert network["asset_type"] == "network"
|
||||
assert network["metadata"]["packet_payload_read"] is False
|
||||
assert {asset["asset_type"] for asset in registry_assets} == {
|
||||
"dashboard",
|
||||
"cache",
|
||||
"message_queue",
|
||||
}
|
||||
assert all(
|
||||
asset["metadata"]["raw_config_persisted"] is False
|
||||
and asset["metadata"]["secret_value_read"] is False
|
||||
for asset in registry_assets
|
||||
)
|
||||
assert len(registry_relationships) == len(registry_assets)
|
||||
|
||||
|
||||
def test_runtime_collection_integrates_new_asset_types_and_reconciliation_prefixes(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
@@ -584,6 +684,94 @@ def test_runtime_collection_integrates_new_asset_types_and_reconciliation_prefix
|
||||
}
|
||||
]
|
||||
}
|
||||
if resource == "deployments":
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"metadata": {
|
||||
"namespace": "awoooi-prod",
|
||||
"name": "awoooi-web",
|
||||
"labels": {
|
||||
"app": "awoooi-web",
|
||||
"system": "awoooi",
|
||||
},
|
||||
},
|
||||
"spec": {},
|
||||
"status": {},
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"namespace": "awoooi-prod",
|
||||
"name": "awoooi-api",
|
||||
"labels": {
|
||||
"app": "awoooi-api",
|
||||
"system": "awoooi",
|
||||
},
|
||||
},
|
||||
"spec": {},
|
||||
"status": {},
|
||||
},
|
||||
]
|
||||
}
|
||||
if resource == "daemonsets":
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"metadata": {
|
||||
"namespace": "observability",
|
||||
"name": "otel-collector",
|
||||
"labels": {
|
||||
"app.kubernetes.io/component": "log-collector"
|
||||
},
|
||||
},
|
||||
"spec": {},
|
||||
"status": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
if resource == "services":
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"metadata": {
|
||||
"namespace": "awoooi-prod",
|
||||
"name": "awoooi-api",
|
||||
"labels": {"system": "awoooi"},
|
||||
},
|
||||
"spec": {
|
||||
"type": "ClusterIP",
|
||||
"clusterIP": "10.43.0.10",
|
||||
"ports": [{"port": 80, "targetPort": 8000}],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
if resource == "configmaps":
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"metadata": {
|
||||
"namespace": "awoooi-prod",
|
||||
"name": "service-registry",
|
||||
},
|
||||
"data": {
|
||||
"service-registry.yaml": """
|
||||
services:
|
||||
- name: grafana
|
||||
display_name: Grafana
|
||||
host: 192.168.0.110
|
||||
stateful_level: STANDARD_HITL
|
||||
containers: [grafana]
|
||||
- name: sentry-redis
|
||||
display_name: Sentry task queue
|
||||
host: 192.168.0.110
|
||||
stateful_level: CRITICAL_HITL
|
||||
containers: [sentry-redis]
|
||||
""",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
if resource == "ingresses":
|
||||
return {
|
||||
"items": [
|
||||
@@ -663,6 +851,13 @@ def test_runtime_collection_integrates_new_asset_types_and_reconciliation_prefix
|
||||
"api_endpoint",
|
||||
"certificate",
|
||||
"scheduled_job",
|
||||
"network",
|
||||
"frontend",
|
||||
"backend",
|
||||
"log_stream",
|
||||
"dashboard",
|
||||
"cache",
|
||||
"message_queue",
|
||||
} <= {asset["asset_type"] for asset in assets}
|
||||
assert {
|
||||
"k8s/secret-ref/",
|
||||
@@ -671,6 +866,13 @@ def test_runtime_collection_integrates_new_asset_types_and_reconciliation_prefix
|
||||
"k8s/ingress-route/",
|
||||
"k8s/certificate-ref/",
|
||||
"k8s/cronjob/",
|
||||
"k8s/network/service/",
|
||||
"k8s/component/frontend/deployment/",
|
||||
"k8s/component/backend/deployment/",
|
||||
"k8s/component/log_stream/daemonset/",
|
||||
"service-registry/dashboard/",
|
||||
"service-registry/cache/",
|
||||
"service-registry/message_queue/",
|
||||
} <= set(prefixes)
|
||||
assert any(
|
||||
relationship["to_key"] == "k8s/secret-ref/prod/database-ref"
|
||||
|
||||
Reference in New Issue
Block a user