"""Vulnerability scanner plugin for Xelo AI SBOMs.

Runs three complementary passes:

Phase 1 — Structural rules (always, no network required)
  Deterministic, rule-based checks against the SBOM graph and metadata.
  Every finding has a stable rule ID, severity, affected components, and a
  remediation hint.

  VLA-001  No guardrails protecting AI models                    CRITICAL
  VLA-002  PHI/PII data handled by external LLM providers        CRITICAL
  VLA-003  PHI/PII-sensitive API endpoints with minimal auth     HIGH
  VLA-004  Privileged-access components without guardrails       HIGH
  VLA-005  Voice modality enabled with PHI data present          HIGH
  VLA-006  Models with no output validation                      MEDIUM
  VLA-007  Prompt templates with injection risk                   MEDIUM
  VLA-008  Multi-provider LLM fan-out (data residency risk)      MEDIUM
  VLA-009  Auth node count insufficient for API surface          LOW

  IaC / Auth / Privilege / Resilience rules (require IaC adapter output):
  VLA-010  PHI workload — no encryption at rest                  CRITICAL
  VLA-011  PHI encrypted without CMK/BYOK                        HIGH
  VLA-012  Secrets in env vars or no secret store                HIGH
  VLA-013  No secret management service                          MEDIUM
  VLA-014  Overly permissive IAM with PHI                        HIGH
  VLA-015  Cluster-scoped role without permission boundary       HIGH
  VLA-016  GHA accessing cloud without OIDC                      HIGH
  VLA-017  GHA write permissions with PHI                        MEDIUM
  VLA-018  Single-AZ deployment with PHI                         HIGH
  VLA-019  AI workloads without health checks                    MEDIUM
  VLA-020  AI workloads without resource limits                  LOW
  VLA-021  Containers running as root                            HIGH

Phase 2 — OSV dependency scan (requires ``--provider osv`` or ``all``)
  Queries https://api.osv.dev for known CVEs / GHSA advisories in the
  SBOM ``deps`` list.  Each finding carries the advisory ID, CVE aliases,
  summary, severity, affected version range, and an OSV URL.
  Network errors produce a warning and are silently skipped.

Phase 3 — Grype scan (requires ``--provider grype`` or ``all``)
  Calls the ``grype`` CLI binary (must be installed separately) to scan:
    - Package dependencies via a CycloneDX BOM temp file
    - Container images referenced by CONTAINER_IMAGE nodes in the SBOM
  Grype queries NVD, GitHub Advisory, OSV, and distro-specific advisories.
  Gracefully skipped when grype is not on PATH.
"""

from __future__ import annotations

import logging
from typing import Any, Callable

from xelo.toolbox.models import ToolResult
from xelo.toolbox.plugin_base import ToolPlugin

_log = logging.getLogger("toolbox.plugins.vuln")

# ── Severity ordering (for sorting) ─────────────────────────────────────────
_SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}

# ── External LLM providers whose APIs leave your trust boundary ───────────────
_EXTERNAL_PROVIDERS = {
    "openai",
    "anthropic",
    "google",
    "cohere",
    "mistral",
    "deepseek",
    "ai21",
    "amazon",
    "azure",
}

# ── Component types ──────────────────────────────────────────────────────────
_GUARDRAIL_TYPES = {"GUARDRAIL"}
_MODEL_AGENT_TYPES = {"MODEL", "AGENT"}
_EXTERNAL_MODEL_TYPES = {"MODEL"}


def _node_extras(node: dict[str, Any]) -> dict[str, Any]:
    return node.get("metadata", {}).get("extras", {}) or {}


def _depl_meta(node: dict[str, Any]) -> dict[str, Any]:
    """Return a flattened view of deployment/IaC metadata for a node.

    Merges typed fields from ``metadata`` (e.g. ``secret_store``,
    ``encryption_at_rest``) with the ``extras`` dict so callers can
    use a single dict regardless of where the field is stored.
    """
    meta: dict[str, Any] = node.get("metadata") or {}
    extras: dict[str, Any] = meta.get("extras") or {}
    # Start with extras, then overwrite with typed top-level fields
    merged = dict(extras)
    for key in (
        "deployment_target",
        "secret_store",
        "encryption_at_rest",
        "encryption_key_ref",
        "runs_as_root",
        "has_health_check",
        "has_resource_limits",
        "ha_mode",
        "availability_zones",
        "iam_type",
        "permissions",
        "iam_scope",
        "trust_principals",
        "base_image",
        "image_name",
    ):
        v = meta.get(key)
        if v is not None:
            merged[key] = v
    return merged


def _gha_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Return DEPLOYMENT nodes produced by the GitHub Actions adapter."""
    return [
        n
        for n in nodes
        if n.get("component_type") == "DEPLOYMENT"
        and _depl_meta(n).get("deployment_target") == "github-actions"
    ]


def _iam_nodes_list(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Return all IAM-type component nodes."""
    return [n for n in nodes if n.get("component_type") == "IAM"]


def _has_wildcard_perm(permissions: Any) -> bool:
    """True when any permission string contains a ``*`` wildcard."""
    if not isinstance(permissions, list):
        return False
    return any("*" in str(p) for p in permissions)


def _has_phi_pii(labels: list[str]) -> bool:
    return bool(set(labels) & {"PHI", "PII"})


def _finding(
    rule_id: str,
    severity: str,
    title: str,
    description: str,
    affected: list[str],
    remediation: str,
) -> dict[str, Any]:
    return {
        "rule_id": rule_id,
        "severity": severity,
        "title": title,
        "description": description,
        "affected": affected,
        "remediation": remediation,
    }


# ── Individual rules ─────────────────────────────────────────────────────────


def _rule_vla001_no_guardrails(nodes: list[dict[str, Any]], **_: Any) -> list[dict[str, Any]]:
    """CRITICAL — AI models/agents in the graph with zero guardrail nodes."""
    guardrail_nodes = [n for n in nodes if n.get("component_type", "") in _GUARDRAIL_TYPES]
    model_agents = [n for n in nodes if n.get("component_type", "") in _MODEL_AGENT_TYPES]
    if not model_agents or guardrail_nodes:
        return []
    return [
        _finding(
            "VLA-001",
            "CRITICAL",
            "No guardrails protecting AI models",
            f"{len(model_agents)} model/agent node(s) detected with 0 guardrail or policy "
            "nodes in the SBOM graph. Responses from these models are unfiltered.",
            [n.get("name", "") for n in model_agents],
            "Add content-filtering guardrails (e.g. prompt-shield, output classifiers) "
            "and ensure they appear as GUARDRAIL nodes in the SBOM.",
        )
    ]


def _rule_vla002_phi_to_external_llm(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """CRITICAL — PHI/PII data present while external LLM providers are used."""
    dc_labels: list[str] = summary.get("data_classification") or []
    if not _has_phi_pii(dc_labels):
        return []

    external_models: list[str] = []
    for n in nodes:
        if n.get("component_type", "") not in _EXTERNAL_MODEL_TYPES:
            continue
        extras = _node_extras(n)
        provider = (extras.get("provider") or "").lower()
        if any(ep in provider for ep in _EXTERNAL_PROVIDERS):
            external_models.append(n.get("name", ""))

    if not external_models:
        return []

    phi_tables: list[str] = summary.get("classified_tables") or []
    return [
        _finding(
            "VLA-002",
            "CRITICAL",
            "PHI/PII data handled by external LLM providers",
            f"The SBOM contains {', '.join(sorted(set(dc_labels)))} data "
            f"({len(phi_tables)} classified table(s)) and calls external LLM "
            f"provider(s): {', '.join(external_models)}. Patient data may be "
            "transmitted outside your trust boundary in violation of HIPAA/GDPR.",
            external_models,
            "Ensure PHI is stripped or anonymised before being included in prompts "
            "sent to external providers. Consider a self-hosted model for PHI workloads "
            "or obtain a HIPAA BAA from each provider.",
        )
    ]


def _rule_vla003_phi_api_endpoints(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """HIGH — API endpoints serving PHI/PII paths with limited auth coverage.

    Enhancement: also fires when cluster/account-scoped IAM nodes exist
    alongside PHI API paths, because an overly broad IAM role bypasses
    endpoint-level authentication controls.
    """
    dc_labels: list[str] = summary.get("data_classification") or []
    if not _has_phi_pii(dc_labels):
        return []

    api_paths: list[str] = summary.get("api_endpoints") or []
    phi_keywords = (
        "patient",
        "medical",
        "health",
        "record",
        "history",
        "diagnosis",
        "prescription",
        "appointment",
    )
    phi_paths = [p for p in api_paths if any(kw in p.lower() for kw in phi_keywords)]
    if not phi_paths:
        return []

    auth_count = sum(1 for n in nodes if n.get("component_type", "") == "AUTH")

    # Enhancement: check broad-scope IAM as an additional auth-bypass vector
    broad_iam = [
        n
        for n in _iam_nodes_list(nodes)
        if _depl_meta(n).get("iam_scope") in ("cluster", "account", "project", "subscription")
    ]
    iam_note = ""
    if broad_iam:
        iam_note = (
            f" Additionally, {len(broad_iam)} broad-scope IAM node(s) "
            f"({', '.join(n.get('name', '?') for n in broad_iam[:3])}) may "
            "provide lateral access that circumvents endpoint-level auth."
        )

    return [
        _finding(
            "VLA-003",
            "HIGH",
            "PHI/PII-sensitive API endpoints with minimal auth coverage",
            f"{len(phi_paths)} API path(s) appear to serve PHI/PII data "
            f"({', '.join(phi_paths[:4])}{'...' if len(phi_paths) > 4 else ''}). "
            f"Only {auth_count} AUTH node(s) detected in the SBOM.{iam_note}",
            phi_paths,
            "Verify every PHI-serving endpoint requires authenticated sessions. "
            "Add rate-limiting, audit logging, and field-level encryption at rest. "
            "If broad-scope IAM roles exist, restrict them to namespace or resource scope.",
        )
    ]


def _rule_vla004_privilege_no_guardrail(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """HIGH/CRITICAL — PRIVILEGE-type component present with no guardrail nodes.

    Enhancement: escalates to CRITICAL when PHI is present AND there is a
    cluster/account-scoped IAM node with wildcard permissions, indicating the
    privileged component operates with essentially unconstrained access.
    """
    privilege_nodes = [n for n in nodes if n.get("component_type", "") == "PRIVILEGE"]
    guardrail_nodes = [n for n in nodes if n.get("component_type", "") in _GUARDRAIL_TYPES]
    if not privilege_nodes or guardrail_nodes:
        return []

    dc_labels: list[str] = summary.get("data_classification") or []
    has_phi = _has_phi_pii(dc_labels)

    # Check for broad-scope wildcard IAM co-located with the PRIVILEGE node
    unconstrained_iam = [
        n
        for n in _iam_nodes_list(nodes)
        if _depl_meta(n).get("iam_scope") in ("cluster", "account")
        and _has_wildcard_perm(_depl_meta(n).get("permissions"))
    ]

    severity = "CRITICAL" if (has_phi and unconstrained_iam) else "HIGH"
    extra = ""
    if unconstrained_iam:
        extra = (
            f" {len(unconstrained_iam)} cluster/account-scoped IAM node(s) with "
            "wildcard permissions compound this risk — privileged actions are "
            "effectively unconstrained."
        )

    return [
        _finding(
            "VLA-004",
            severity,
            "Privileged-access AI components without guardrails",
            f"{len(privilege_nodes)} PRIVILEGE node(s) detected (elevated system access) "
            f"with no GUARDRAIL nodes present to constrain their behaviour.{extra}",
            [n.get("name", "") for n in privilege_nodes],
            "Wrap privileged tool calls behind a policy/guardrail layer. "
            "Apply the principle of least privilege; restrict which agents can invoke "
            "high-privilege tools. Remove wildcard permissions from cluster/account-scoped "
            "IAM roles.",
        )
    ]


def _rule_vla005_voice_with_phi(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """HIGH — Voice modality active with PHI data in scope."""
    dc_labels: list[str] = summary.get("data_classification") or []
    modalities: list[str] = [m.upper() for m in (summary.get("modalities") or [])]
    if "VOICE" not in modalities or not _has_phi_pii(dc_labels):
        return []
    phi_tables = summary.get("classified_tables") or []
    return [
        _finding(
            "VLA-005",
            "HIGH",
            "Voice modality enabled with PHI data present",
            "The application supports voice input/output and the SBOM contains PHI data "
            f"in {len(phi_tables)} classified table(s). Audio recordings of PHI are "
            "themselves PHI under HIPAA and require additional safeguards.",
            [
                n.get("name", "")
                for n in nodes
                if n.get("component_type", "") == "MODEL"
                and "voice" in str(_node_extras(n)).lower()
            ]
            or ["(voice-capable components)"],
            "Ensure voice data is encrypted in transit and at rest, transcripts are "
            "treated as PHI, and audio is not retained beyond the minimum necessary period. "
            "Obtain a HIPAA BAA from any third-party voice/STT provider.",
        )
    ]


def _rule_vla006_models_no_output_validation(
    nodes: list[dict[str, Any]], edges: list[dict[str, Any]], **_: Any
) -> list[dict[str, Any]]:
    """MEDIUM — LLM models with no connected guardrail in the edge graph."""
    guardrail_ids = {n.get("id") for n in nodes if n.get("component_type", "") in _GUARDRAIL_TYPES}
    if guardrail_ids:
        return []  # guardrails exist — edge-level check is redundant

    model_nodes = [n for n in nodes if n.get("component_type", "") == "MODEL"]
    if not model_nodes:
        return []
    return [
        _finding(
            "VLA-006",
            "MEDIUM",
            "LLM models with no output validation",
            f"{len(model_nodes)} LLM model(s) produce output that flows directly to "
            "downstream components with no output-validation step detected in the graph.",
            [n.get("name", "") for n in model_nodes],
            "Implement structured output parsing and validation. Consider response-level "
            "classifiers to detect hallucinations, policy violations, or PHI leakage "
            "before responses reach end-users or downstream tools.",
        )
    ]


def _rule_vla007_prompt_injection(nodes: list[dict[str, Any]], **_: Any) -> list[dict[str, Any]]:
    """MEDIUM — Prompt nodes with elevated injection risk or template variables."""
    risky_prompts: list[str] = []
    for n in nodes:
        if n.get("component_type", "") != "PROMPT":
            continue
        extras = _node_extras(n)
        score = extras.get("injection_risk_score") or 0.0
        is_tpl = extras.get("is_template") or extras.get("is_template_literal") or False
        tpl_vars = extras.get("template_variables") or []
        if score > 0.3 or (is_tpl and tpl_vars):
            risky_prompts.append(n.get("name", ""))

    if not risky_prompts:
        return []
    return [
        _finding(
            "VLA-007",
            "MEDIUM",
            "Prompt templates with injection risk",
            f"{len(risky_prompts)} prompt node(s) use template variables or have an "
            "elevated injection risk score. Unsanitised user input embedded in prompts "
            "can lead to prompt injection attacks.",
            risky_prompts,
            "Sanitise and validate all user-supplied values before interpolating them "
            "into prompts. Use structured inputs (e.g. JSON tool-call parameters) "
            "rather than free-form string interpolation where possible.",
        )
    ]


def _rule_vla008_multi_provider_fanout(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """MEDIUM — Data sent to multiple distinct external LLM providers."""
    dc_labels: list[str] = summary.get("data_classification") or []
    if not _has_phi_pii(dc_labels):
        return []

    providers: set[str] = set()
    provider_nodes: list[str] = []
    for n in nodes:
        if n.get("component_type", "") not in _EXTERNAL_MODEL_TYPES:
            continue
        provider = (_node_extras(n).get("provider") or "").lower()
        if any(ep in provider for ep in _EXTERNAL_PROVIDERS):
            providers.add(provider)
            provider_nodes.append(n.get("name", ""))

    if len(providers) < 2:
        return []
    return [
        _finding(
            "VLA-008",
            "MEDIUM",
            "Multi-provider LLM fan-out with PHI in scope",
            f"PHI/PII data is in scope and {len(providers)} distinct external LLM "
            f"provider(s) are used: {', '.join(sorted(providers))}. "
            "Each provider relationship requires its own DPA / HIPAA BAA.",
            provider_nodes,
            "Consolidate to the minimum number of external providers necessary. "
            "Maintain a data-processing agreement with each provider and document "
            "data flows in your HIPAA risk assessment.",
        )
    ]


def _rule_vla009_auth_coverage(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """LOW — More API endpoints than AUTH nodes, or GHA PR trigger without restrictions.

    Enhancement: also fires when a GitHub Actions workflow uses a
    ``pull_request`` trigger (which runs in the context of the PR author's
    permissions) and contains API-serving code, widening the attack surface
    for supply-chain attacks.
    """
    api_paths = summary.get("api_endpoints") or []
    auth_nodes = [n for n in nodes if n.get("component_type", "") == "AUTH"]
    findings: list[dict[str, Any]] = []

    if api_paths and len(auth_nodes) < len(api_paths):
        findings.append(
            _finding(
                "VLA-009",
                "LOW",
                "API surface may exceed auth coverage",
                f"{len(api_paths)} API endpoint(s) detected, {len(auth_nodes)} AUTH "
                "node(s) in the SBOM. Some endpoints may lack authentication.",
                api_paths,
                "Review each endpoint for authentication requirements. Ensure all "
                "non-public routes enforce token validation and role-based access control.",
            )
        )

    # Enhancement: GHA pull_request trigger without branch restrictions
    if api_paths:
        unsafe_gha = [
            n
            for n in _gha_nodes(nodes)
            if "pull_request" in (_depl_meta(n).get("workflow_triggers") or [])
        ]
        if unsafe_gha:
            findings.append(
                _finding(
                    "VLA-009",
                    "LOW",
                    "GitHub Actions workflow triggers on pull_request with API code present",
                    f"{len(unsafe_gha)} GHA workflow(s) trigger on 'pull_request' "
                    "events. PRs from forked repositories can execute workflow code with "
                    "access to repository secrets, potentially targeting API services.",
                    [n.get("name", "") for n in unsafe_gha],
                    "Use 'pull_request_target' with explicit allow-lists or require "
                    "workflow approval for first-time contributors. Restrict secrets "
                    "access to trusted branches with 'branches:' filters.",
                )
            )

    return findings


# ── IaC / Auth / Privilege / Resilience rules ────────────────────────────────


def _rule_vla010_phi_no_encryption(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """CRITICAL — PHI/PII workload with no encryption at rest detected in IaC."""
    dc_labels: list[str] = summary.get("data_classification") or []
    if not _has_phi_pii(dc_labels):
        return []
    if summary.get("encryption_at_rest_coverage"):
        return []
    # Only fire when we actually scanned IaC — i.e. DEPLOYMENT nodes exist
    deployment_nodes = [n for n in nodes if n.get("component_type") == "DEPLOYMENT"]
    if not deployment_nodes:
        _log.debug("VLA-010 skipped: no DEPLOYMENT nodes; IaC may not have been scanned")
        return []
    _log.debug(
        "VLA-010: PHI present, encryption_at_rest_coverage=False, %d DEPLOYMENT node(s)",
        len(deployment_nodes),
    )
    return [
        _finding(
            "VLA-010",
            "CRITICAL",
            "PHI/PII workload — no encryption at rest",
            f"The SBOM contains {', '.join(sorted(set(dc_labels)))} data across "
            f"{len(deployment_nodes)} deployment resource(s), but no IaC resource "
            "has encryption-at-rest enabled. HIPAA §164.312(a)(2)(iv) requires "
            "encryption of ePHI at rest.",
            [n.get("name", "") for n in deployment_nodes],
            "Enable storage/disk encryption: set 'encrypted = true' in Terraform "
            "(aws_db_instance, aws_ebs_volume, etc.), configure K8s encryption "
            "providers at rest, or enable EBS/Azure Disk/GCS CMEK.",
        )
    ]


def _rule_vla011_phi_no_cmk(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """HIGH — PHI workload is encrypted at rest but uses only provider-managed keys."""
    dc_labels: list[str] = summary.get("data_classification") or []
    if not _has_phi_pii(dc_labels):
        return []
    if not summary.get("encryption_at_rest_coverage"):
        return []  # VLA-010 covers the no-encryption case
    # Check whether any DEPLOYMENT node has a CMK reference
    cmk_nodes = [
        n
        for n in nodes
        if n.get("component_type") == "DEPLOYMENT" and _depl_meta(n).get("encryption_key_ref")
    ]
    if cmk_nodes:
        _log.debug("VLA-011 skipped: %d node(s) with CMK found", len(cmk_nodes))
        return []
    encrypted_nodes = [
        n
        for n in nodes
        if n.get("component_type") == "DEPLOYMENT"
        and _depl_meta(n).get("encryption_at_rest") is True
    ]
    if not encrypted_nodes:
        return []
    _log.debug(
        "VLA-011: encryption present but no CMK reference in %d node(s)", len(encrypted_nodes)
    )
    return [
        _finding(
            "VLA-011",
            "HIGH",
            "PHI encrypted at rest without customer-managed key (CMK/BYOK)",
            f"{len(encrypted_nodes)} resource(s) use provider-managed encryption keys "
            "for PHI data. Provider-managed keys (PMK) cannot be revoked by the "
            "customer — NIST CSF DE.CM-5 and common HIPAA risk assessments recommend "
            "BYOK for sensitive data stores.",
            [n.get("name", "") for n in encrypted_nodes],
            "Configure a KMS customer-managed key: 'kms_key_id' (AWS), "
            "'disk_encryption_set_id' (Azure), or 'crypto_key_id' (GCP). "
            "Implement key rotation policy and access auditing via CloudTrail / "
            "Azure Monitor / GCP Audit Logs.",
        )
    ]


def _rule_vla012_secrets_in_env(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """HIGH — Secrets exposed as environment variables or no secret store configured."""
    security_findings: list[str] = summary.get("security_findings") or []
    secret_stores: list[str] = summary.get("secret_stores") or []
    deployment_nodes = [n for n in nodes if n.get("component_type") == "DEPLOYMENT"]

    has_secrets_in_env = "secrets_in_env_vars" in security_findings
    has_no_store = not secret_stores and deployment_nodes

    if not has_secrets_in_env and not has_no_store:
        return []

    affected = [
        n.get("name", "")
        for n in deployment_nodes
        if has_secrets_in_env or not _depl_meta(n).get("secret_store")
    ]
    if not affected:
        affected = [n.get("name", "") for n in deployment_nodes]

    detail = ""
    if has_secrets_in_env:
        detail = "Secrets are referenced as plain environment variables. "
    if has_no_store:
        detail += (
            f"{len(deployment_nodes)} deployment resource(s) have no secret "
            "management store configured. "
        )
    detail += (
        "Plaintext secrets appear in process listings, 'docker inspect' output, and CI system logs."
    )
    _log.debug(
        "VLA-012: secrets_in_env=%s, no_store=%s, affected=%d",
        has_secrets_in_env,
        has_no_store,
        len(affected),
    )
    return [
        _finding(
            "VLA-012",
            "HIGH",
            "Secrets handled insecurely — env vars or no secret store",
            detail,
            affected,
            "Migrate to a dedicated secret management service: AWS Secrets Manager, "
            "Azure Key Vault, GCP Secret Manager, or HashiCorp Vault. "
            "For GitHub Actions, replace static 'secrets.X' with OIDC federation "
            "(aws-actions/configure-aws-credentials, google-github-actions/auth).",
        )
    ]


def _rule_vla013_no_secret_store(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """MEDIUM — No structured secret management service across any deployment resource."""
    secret_stores: list[str] = summary.get("secret_stores") or []
    if secret_stores:
        return []
    deployment_nodes = [n for n in nodes if n.get("component_type") == "DEPLOYMENT"]
    if not deployment_nodes:
        return []
    # Avoid double-firing with VLA-012 when env-var secrets were already flagged
    if "secrets_in_env_vars" in (summary.get("security_findings") or []):
        _log.debug("VLA-013 suppressed: VLA-012 already covers this path")
        return []
    _log.debug("VLA-013: no secret stores, %d DEPLOYMENT node(s)", len(deployment_nodes))
    return [
        _finding(
            "VLA-013",
            "MEDIUM",
            "No secret management service detected across deployment",
            f"{len(deployment_nodes)} deployment resource(s) have no reference to any "
            "structured secret management service (AWS Secrets Manager, Azure Key Vault, "
            "GCP Secret Manager, HashiCorp Vault, K8s Secrets). Credentials may be "
            "embedded in config files, container images, or source code.",
            [n.get("name", "") for n in deployment_nodes],
            "Adopt a centralised secrets management solution. Start by scanning the "
            "repository for hardcoded credentials (git-secrets, truffleHog, Gitleaks). "
            "Configure secret rotation and audit logging.",
        )
    ]


def _rule_vla014_permissive_iam_phi(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """HIGH — Overly permissive IAM roles/policies when PHI is in scope."""
    dc_labels: list[str] = summary.get("data_classification") or []
    if not _has_phi_pii(dc_labels):
        return []
    security_findings: list[str] = summary.get("security_findings") or []
    iam_nodes = _iam_nodes_list(nodes)
    if not iam_nodes:
        return []

    # Collect wildcard-permission IAM nodes
    wildcard_nodes = [
        n
        for n in iam_nodes
        if (
            "overly_permissive_iam" in security_findings
            or _has_wildcard_perm(_depl_meta(n).get("permissions"))
        )
    ]
    if not wildcard_nodes:
        return []
    _log.debug("VLA-014: %d wildcard IAM node(s) with PHI in scope", len(wildcard_nodes))
    return [
        _finding(
            "VLA-014",
            "HIGH",
            "Overly permissive IAM with PHI in scope",
            f"{len(wildcard_nodes)} IAM node(s) have wildcard permissions "
            f"({', '.join(n.get('name', '?') for n in wildcard_nodes[:4])}) while "
            f"{', '.join(sorted(set(dc_labels)))} data is in scope. "
            "HIPAA minimum-necessary (§164.502(b)) requires that access be limited "
            "to the minimum necessary to accomplish the intended purpose.",
            [n.get("name", "") for n in wildcard_nodes],
            "Replace wildcard ('*') verbs and resources with explicit action lists. "
            "Use K8s RBAC with specific 'resources:' and 'verbs:' arrays. "
            "Apply AWS IAM permission boundaries or GCP IAM conditions to limit "
            "scope of service account keys.",
        )
    ]


def _rule_vla015_cluster_scope_iam(nodes: list[dict[str, Any]], **_: Any) -> list[dict[str, Any]]:
    """HIGH — Cluster-scoped IAM role or service account without permission boundary."""
    iam_nodes = _iam_nodes_list(nodes)
    risky: list[dict[str, Any]] = []
    for n in iam_nodes:
        meta = _depl_meta(n)
        if meta.get("iam_scope") != "cluster":
            continue
        if meta.get("iam_type") not in ("role", "service_account"):
            continue
        perms = meta.get("permissions")
        if perms is None or _has_wildcard_perm(perms):
            risky.append(n)
    if not risky:
        return []
    _log.debug("VLA-015: %d cluster-scoped IAM node(s) without permission boundary", len(risky))
    return [
        _finding(
            "VLA-015",
            "HIGH",
            "Cluster-scoped IAM role/service-account without permission boundary",
            f"{len(risky)} cluster-scoped Kubernetes role(s) or service account(s) "
            "have no explicit permission boundary (wildcard permissions or no "
            "permissions defined). Cluster-wide RBAC can be exploited for lateral "
            "movement — a compromised workload can pivot to any namespace.",
            [n.get("name", "") for n in risky],
            "Replace ClusterRole/ClusterRoleBinding with namespace-scoped Role + "
            "RoleBinding pairs. Define explicit 'rules:' with minimal 'verbs:' and "
            "'resources:'. Audit with 'kubectl auth can-i --list'.",
        )
    ]


def _rule_vla016_gha_no_oidc(nodes: list[dict[str, Any]], **_: Any) -> list[dict[str, Any]]:
    """HIGH — GitHub Actions workflow accesses cloud provider without OIDC federation."""
    risky: list[dict[str, Any]] = []
    for n in _gha_nodes(nodes):
        meta = _depl_meta(n)
        uses_oidc = meta.get("uses_oidc")
        cloud_providers = meta.get("cloud_providers") or []
        if cloud_providers and uses_oidc is False:
            risky.append(n)
    if not risky:
        return []
    _log.debug("VLA-016: %d GHA workflow(s) with cloud access but no OIDC", len(risky))
    return [
        _finding(
            "VLA-016",
            "HIGH",
            "GitHub Actions workflow accesses cloud without OIDC federation",
            f"{len(risky)} GitHub Actions workflow(s) interact with cloud provider(s) "
            "using static credentials rather than OIDC token exchange. "
            "Long-lived secrets in GitHub cannot be automatically rotated and are "
            "exposed if the repository is compromised or a secret is accidentally logged.",
            [n.get("name", "") for n in risky],
            "Replace static cloud credentials with OIDC federation: "
            "'aws-actions/configure-aws-credentials@v4' (AWS), "
            "'google-github-actions/auth' (GCP), "
            "'azure/login' with federated identity (Azure). "
            "Set 'permissions: id-token: write' at the job level.",
        )
    ]


def _rule_vla017_gha_write_phi(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """MEDIUM — GitHub Actions workflow has write permissions with PHI in scope."""
    dc_labels: list[str] = summary.get("data_classification") or []
    if not _has_phi_pii(dc_labels):
        return []
    risky: list[dict[str, Any]] = []
    for n in _gha_nodes(nodes):
        perms = _depl_meta(n).get("permissions") or []
        if any((":write" in p or "admin" in p) for p in perms):
            risky.append(n)
    if not risky:
        return []
    _log.debug("VLA-017: %d GHA workflow(s) with write permissions and PHI", len(risky))
    return [
        _finding(
            "VLA-017",
            "MEDIUM",
            "GitHub Actions write permissions with PHI in scope",
            f"{len(risky)} GHA workflow(s) have write or admin-level GitHub permissions "
            "while PHI data is in scope. Broad workflow permissions combined with PHI "
            "create a data-exfiltration vector — a compromised dependency or third-party "
            "action can read and transmit repository contents.",
            [n.get("name", "") for n in risky],
            "Restrict GHA 'permissions:' to the minimum required "
            "(e.g. 'contents: read', 'id-token: write'). "
            "Set 'permissions: {}' at the workflow level and grant only what each "
            "job needs. Configure required reviewers for PHI-touching workflows.",
        )
    ]


def _rule_vla018_single_az_phi(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """HIGH — Single-AZ deployment for AI service handling PHI — no failover path."""
    dc_labels: list[str] = summary.get("data_classification") or []
    if not _has_phi_pii(dc_labels):
        return []
    if summary.get("availability_zones"):
        return []  # AZs configured
    deployment_nodes = [n for n in nodes if n.get("component_type") == "DEPLOYMENT"]
    ha_nodes = [n for n in deployment_nodes if _depl_meta(n).get("ha_mode")]
    if ha_nodes:
        return []  # Some HA mode set
    if not deployment_nodes:
        return []
    _log.debug(
        "VLA-018: no AZs or HA mode in %d DEPLOYMENT node(s) with PHI", len(deployment_nodes)
    )
    return [
        _finding(
            "VLA-018",
            "HIGH",
            "Single-AZ deployment for AI service handling PHI",
            f"{len(deployment_nodes)} deployment resource(s) have no multi-AZ "
            "configuration or HA mode detected. HIPAA §164.312(a)(2)(ii) requires a "
            "contingency plan ensuring availability of ePHI. Single-AZ deployments "
            "have no automatic failover path.",
            [n.get("name", "") for n in deployment_nodes],
            "Configure multi-AZ: 'availability_zones' in Terraform, "
            "K8s 'topologySpreadConstraints' with 'topologyKey: topology.kubernetes.io/zone', "
            "RDS 'multi_az = true', or equivalent. "
            "Document your recovery point objective (RPO) and recovery time objective (RTO).",
        )
    ]


def _rule_vla019_no_health_checks(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """MEDIUM — AI workloads lack liveness/readiness probes or HEALTHCHECK instructions."""
    if "missing_health_check" not in (summary.get("security_findings") or []):
        return []
    ai_nodes = [n for n in nodes if n.get("component_type") in ("AGENT", "MODEL", "FRAMEWORK")]
    if not ai_nodes:
        return []
    # Collect the DEPLOYMENT/CONTAINER_IMAGE nodes explicitly without health checks
    unhealthy = [
        n
        for n in nodes
        if n.get("component_type") in ("DEPLOYMENT", "CONTAINER_IMAGE")
        and _depl_meta(n).get("has_health_check") is False
    ]
    affected_names = [n.get("name", "") for n in unhealthy] or [
        n.get("name", "") for n in ai_nodes[:5]
    ]
    _log.debug(
        "VLA-019: %d node(s) without health checks, %d AI node(s)", len(unhealthy), len(ai_nodes)
    )
    return [
        _finding(
            "VLA-019",
            "MEDIUM",
            "AI workloads without health checks",
            f"{len(unhealthy) or len(ai_nodes)} deployment/container resource(s) have no "
            "liveness/readiness probes or HEALTHCHECK instructions. Without probes, failed "
            "AI containers remain in the load-balancer rotation delivering degraded or "
            "incorrect responses silently — Kubernetes cannot self-heal.",
            affected_names,
            "Add 'livenessProbe' and 'readinessProbe' to K8s container specs "
            "(HTTP GET /healthz or exec command). Add 'HEALTHCHECK' instruction to "
            "Dockerfiles. Set appropriate 'initialDelaySeconds' for LLM inference "
            "warm-up time.",
        )
    ]


def _rule_vla020_no_resource_limits(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """LOW — AI workloads have no container resource limits set."""
    if "no_resource_limits" not in (summary.get("security_findings") or []):
        return []
    unlimited = [
        n
        for n in nodes
        if n.get("component_type") in ("DEPLOYMENT", "CONTAINER_IMAGE")
        and _depl_meta(n).get("has_resource_limits") is False
    ]
    if not unlimited:
        # Fall back: any DEPLOYMENT node exists
        unlimited = [n for n in nodes if n.get("component_type") == "DEPLOYMENT"]
    if not unlimited:
        return []
    _log.debug("VLA-020: %d node(s) without resource limits", len(unlimited))
    return [
        _finding(
            "VLA-020",
            "LOW",
            "AI workloads without container resource limits",
            f"{len(unlimited)} deployment/container resource(s) have no CPU or memory "
            "limits set. LLM inference can consume unbounded resources, causing OOM kills, "
            "noisy-neighbour effects on co-located services, and unpredictable cloud costs.",
            [n.get("name", "") for n in unlimited],
            "Set 'resources.limits.memory' and 'resources.limits.cpu' on all K8s "
            "container specs. Use a Kubernetes LimitRange to enforce defaults "
            "cluster-wide. Consider GPU resource limits for inference workloads.",
        )
    ]


def _rule_vla021_runs_as_root(
    nodes: list[dict[str, Any]], summary: dict[str, Any], **_: Any
) -> list[dict[str, Any]]:
    """HIGH — Container images or K8s workloads running as root."""
    security_findings: list[str] = summary.get("security_findings") or []
    root_nodes = [
        n
        for n in nodes
        if n.get("component_type") in ("DEPLOYMENT", "CONTAINER_IMAGE")
        and _depl_meta(n).get("runs_as_root") is True
    ]
    if not root_nodes and "container_runs_as_root" not in security_findings:
        return []
    if not root_nodes:
        # security_findings tag set but no individual node flagged — report top-level
        root_nodes = [n for n in nodes if n.get("component_type") == "CONTAINER_IMAGE"]
    if not root_nodes:
        return []
    _log.debug("VLA-021: %d root-running container/deployment node(s)", len(root_nodes))
    return [
        _finding(
            "VLA-021",
            "HIGH",
            "Containers running as root",
            f"{len(root_nodes)} container/workload node(s) run as root (UID 0). "
            "Root containers can write to the host filesystem via volume mounts and "
            "trivially escalate privileges on a container escape. "
            "NIST SP 800-190 §4.4 explicitly recommends running containers as non-root.",
            [n.get("name", "") for n in root_nodes],
            "Add 'USER nonroot' (or a specific non-zero UID) to Dockerfiles. "
            "Set 'securityContext.runAsNonRoot: true' and 'runAsUser: 1000' in K8s "
            "pod specs. Use distroless or rootless base images. "
            "Apply 'allowPrivilegeEscalation: false' and drop all Linux capabilities.",
        )
    ]


# ── Registry ─────────────────────────────────────────────────────────────────

_RULES: list[Callable[..., list[dict[str, Any]]]] = [
    _rule_vla001_no_guardrails,
    _rule_vla002_phi_to_external_llm,
    _rule_vla003_phi_api_endpoints,
    _rule_vla004_privilege_no_guardrail,
    _rule_vla005_voice_with_phi,
    _rule_vla006_models_no_output_validation,
    _rule_vla007_prompt_injection,
    _rule_vla008_multi_provider_fanout,
    _rule_vla009_auth_coverage,
    # IaC / Auth / Privilege / Resilience rules (VLA-010 – VLA-021)
    _rule_vla010_phi_no_encryption,
    _rule_vla011_phi_no_cmk,
    _rule_vla012_secrets_in_env,
    _rule_vla013_no_secret_store,
    _rule_vla014_permissive_iam_phi,
    _rule_vla015_cluster_scope_iam,
    _rule_vla016_gha_no_oidc,
    _rule_vla017_gha_write_phi,
    _rule_vla018_single_az_phi,
    _rule_vla019_no_health_checks,
    _rule_vla020_no_resource_limits,
    _rule_vla021_runs_as_root,
]


# ── OSV dep finding builder ──────────────────────────────────────────────────


def _osv_to_finding(osv: dict[str, Any]) -> dict[str, Any]:
    """Convert an osv_client result dict to the standard finding shape."""
    cve_ids = osv.get("cve_ids") or []
    adv_id = osv.get("advisory_id", "")
    title = f"Known vulnerability in {osv.get('dep_name', '?')} ({adv_id})"
    if cve_ids:
        title += f" [{', '.join(cve_ids[:2])}]"

    return {
        "rule_id": adv_id,
        "severity": osv.get("severity", "UNKNOWN"),
        "title": title,
        "description": (
            f"{osv.get('summary', adv_id)}  "
            f"Affected versions: {osv.get('affected_versions', 'see advisory')}.  "
            f"Package: {osv.get('dep_name')} {osv.get('dep_version', '')}."
        ),
        "affected": [osv.get("purl", osv.get("dep_name", "?"))],
        "remediation": (
            f"Upgrade {osv.get('dep_name')} to a version outside the affected range. "
            f"See {osv.get('url', 'https://osv.dev')} for details."
        ),
        "source": "osv",
        "advisory_url": osv.get("url"),
        "cve_ids": cve_ids,
    }


# ── Grype finding builder ─────────────────────────────────────────────────────


def _grype_to_finding(grype: dict[str, Any]) -> dict[str, Any]:
    """Convert a grype_client result dict to the standard finding shape."""
    cve_ids = grype.get("cve_ids") or []
    adv_id = grype.get("advisory_id", "")
    title = f"Known vulnerability in {grype.get('dep_name', '?')} ({adv_id})"
    if cve_ids:
        title += f" [{', '.join(cve_ids[:2])}]"

    target = grype.get("scan_target", "")
    target_note = f" (image: {target})" if target and target != "sbom" else ""

    return {
        "rule_id": adv_id,
        "severity": grype.get("severity", "UNKNOWN"),
        "title": title,
        "description": (
            f"{grype.get('summary', adv_id)}  "
            f"Affected versions: {grype.get('affected_versions', 'see advisory')}.  "
            f"Package: {grype.get('dep_name')} {grype.get('dep_version', '')}."
            f"{target_note}"
        ),
        "affected": [grype.get("purl", grype.get("dep_name", "?"))],
        "remediation": (
            f"Upgrade {grype.get('dep_name')} to a version outside the affected range. "
            f"See {grype.get('url', 'https://github.com/anchore/grype')} for details."
        ),
        "source": "grype",
        "advisory_url": grype.get("url"),
        "cve_ids": cve_ids,
    }


# ── Plugin ───────────────────────────────────────────────────────────────────


class VulnerabilityScannerPlugin(ToolPlugin):
    name = "vuln_scan"

    def run(self, sbom: dict[str, Any], config: dict[str, Any]) -> ToolResult:
        nodes = sbom.get("nodes") or []
        edges = sbom.get("edges") or []
        summary = sbom.get("summary") or {}
        deps = sbom.get("deps") or []
        provider = config.get("provider", "all")
        timeout = float(config.get("timeout", 15.0))

        # ── Phase 1: structural rules ────────────────────────────────────────
        _log.debug(
            "phase 1: running %d structural rule(s) against %d node(s)", len(_RULES), len(nodes)
        )
        ctx = {"nodes": nodes, "edges": edges, "summary": summary}
        findings: list[dict[str, Any]] = []
        for rule in _RULES:
            try:
                findings.extend(rule(**ctx))
            except Exception as exc:
                _log.warning(
                    "structural rule %s raised an error and was skipped: %s", rule.__name__, exc
                )
        _log.info("phase 1 complete: %d structural finding(s)", len(findings))

        # ── Phase 2: OSV dep scan ────────────────────────────────────────────
        osv_findings: list[dict[str, Any]] = []
        osv_ran = False
        if provider in ("osv", "all"):
            from xelo.toolbox.osv_client import query_osv  # lazy import

            osv_ran = True
            _log.info("phase 2: querying OSV for %d dep(s)", len(deps))
            for osv in query_osv(deps, timeout=timeout):
                osv_findings.append(_osv_to_finding(osv))
            _log.info("phase 2 complete: %d OSV advisory finding(s)", len(osv_findings))

        # ── Phase 3: Grype scan ───────────────────────────────────────────────
        grype_findings: list[dict[str, Any]] = []
        grype_ran = False
        if provider in ("grype", "all"):
            from xelo.toolbox.grype_client import query_grype_sbom, query_grype_images

            grype_ran = True
            grype_timeout = float(config.get("grype_timeout", 60.0))

            _log.info("phase 3: running grype sbom scan")
            for g in query_grype_sbom(sbom, timeout=grype_timeout):
                grype_findings.append(_grype_to_finding(g))

            container_nodes = [n for n in nodes if n.get("component_type") == "CONTAINER_IMAGE"]
            if container_nodes:
                _log.info(
                    "phase 3: running grype on %d container image(s)",
                    len(container_nodes),
                )
                for g in query_grype_images(container_nodes, timeout=grype_timeout):
                    grype_findings.append(_grype_to_finding(g))

            _log.info("phase 3 complete: %d grype finding(s)", len(grype_findings))

        all_findings = findings + osv_findings + grype_findings
        all_findings.sort(key=lambda f: _SEVERITY_ORDER.get(f["severity"], 99))

        counts = {sev: 0 for sev in _SEVERITY_ORDER}
        for f in all_findings:
            counts[f["severity"]] = counts.get(f["severity"], 0) + 1

        # Status semantics:
        #   failed  — confirmed CVE (OSV or Grype source) at HIGH or CRITICAL severity
        #   warning — structural/heuristic advisories (VLA-xxx), or low-severity findings
        #   ok      — no findings
        confirmed_critical = any(
            f.get("source") in ("osv", "grype") and f["severity"] in ("CRITICAL", "HIGH")
            for f in (osv_findings + grype_findings)
        )
        if confirmed_critical:
            overall = "failed"
        elif all_findings:
            overall = "warning"
        else:
            overall = "ok"

        msg_parts = [f"{v} {k}" for k, v in counts.items() if v]
        return ToolResult(
            status=overall,
            tool=self.name,
            message=(f"Found {len(all_findings)} finding(s): " + ", ".join(msg_parts))
            if all_findings
            else "No vulnerabilities detected",
            details={
                "provider": provider,
                "findings": all_findings,
                "osv_ran": osv_ran,
                "grype_ran": grype_ran,
                "summary": {
                    "total": len(all_findings),
                    "structural": len(findings),
                    "dep_advisories": len(osv_findings) + len(grype_findings),
                    "critical": counts["CRITICAL"],
                    "high": counts["HIGH"],
                    "medium": counts["MEDIUM"],
                    "low": counts["LOW"],
                },
            },
        )
