"""CI/CD Analyzer — deep rule engine implementing OWASP CI/CD Top 10.

Each check method is self-contained and documents which OWASP CICD-SEC-*
control it addresses, plus corresponding SOC 2, CIS, and ISO 27001 mappings.
"""

from __future__ import annotations

import re
from typing import Any
from urllib.parse import urlparse

from sentinel.analyzers.base import BaseAnalyzer
from sentinel.collectors.cicd.parsers.gitlab_ci import GitLabCIParser
from sentinel.models.findings import (
    ComplianceMapping,
    Evidence,
    Finding,
    ScanResult,
    ScanStats,
    Severity,
)

# Secret-like variable name keywords (for CICD-SEC-6 checks)
_SECRET_VAR_KEYWORDS = re.compile(
    r"(PASSWORD|PASSWD|SECRET|TOKEN|KEY|CREDENTIAL|AUTH|API_KEY|PRIVATE|"
    r"ACCESS_KEY|CERT|PRIVATE_KEY|SIGNING)",
    re.IGNORECASE,
)

# Patterns that indicate hardcoded high-confidence secrets in variable values
_HARDCODED_SECRET_PATTERNS = [
    re.compile(r"(?<![A-Z0-9])AKIA[0-9A-Z]{16}(?![A-Z0-9])"),     # AWS access key
    re.compile(r"glpat-[0-9a-zA-Z\-_]{20}"),                        # GitLab PAT
    re.compile(r"ghp_[0-9a-zA-Z]{36}"),                             # GitHub PAT
    re.compile(r"sk_live_[0-9a-zA-Z]{24,}"),                        # Stripe
    re.compile(r"xoxb-[0-9]{10,}-[0-9]{10,}-[0-9a-zA-Z]{24}"),     # Slack bot
    re.compile(r"-----BEGIN( RSA| EC| OPENSSH)? PRIVATE KEY-----"),  # Private key
]


class CicdAnalyzer(BaseAnalyzer):
    module_name = "cicd"

    def analyze(self, collected: dict[str, Any]) -> ScanResult:
        if not collected.get("ci_file_found"):
            finding = Finding(
                id="cicd.no_ci_file",
                title="No .gitlab-ci.yml found",
                description=(
                    "No CI/CD configuration file was found at the repository root. "
                    "Without a CI pipeline, none of the automated security controls "
                    "(SAST, secret detection, dependency scanning) can be enforced."
                ),
                severity=Severity.CRITICAL,
                module=self.module_name,
                evidence=[Evidence(file=".gitlab-ci.yml", context="File not found")],
                remediation=(
                    "Create a .gitlab-ci.yml that includes GitLab's security templates:\n"
                    "  include:\n"
                    "    - template: Security/SAST.gitlab-ci.yml\n"
                    "    - template: Security/Secret-Detection.gitlab-ci.yml\n"
                    "    - template: Security/Dependency-Scanning.gitlab-ci.yml"
                ),
                compliance=ComplianceMapping(
                    owasp_cicd=["CICD-SEC-10"],
                    soc2=["CC7.1"],
                    cis=["CIS-GitLab-3.1", "CIS-GitLab-3.2", "CIS-GitLab-3.3"],
                    iso27001=["A.8.29"],
                ),
            )
            return self._make_result(
                [finding],
                ScanStats(files_scanned=0, rules_evaluated=1, findings_count=1),
            )

        findings: list[Finding] = []
        jobs: dict[str, Any] = collected.get("jobs", {})
        workflow = collected.get("workflow")
        global_vars = collected.get("global_variables", {})
        includes = collected.get("include", [])
        required_stages = collected.get("required_stages", [])
        trusted_domains = collected.get("trusted_remote_domains", ["gitlab.com"])
        runner_config = collected.get("runner_config")
        errors = collected.get("parse_errors", [])

        ci_path = collected.get("ci_file_path", ".gitlab-ci.yml")

        # Run all checks
        findings += self._check_security_stages(jobs, required_stages, includes, ci_path)
        findings += self._check_workflow_rules(workflow, ci_path)
        findings += self._check_docker_images(jobs, ci_path)
        findings += self._check_remote_includes(includes, trusted_domains, ci_path)
        findings += self._check_ppe(jobs, workflow, global_vars, ci_path)
        findings += self._check_credential_hygiene(jobs, global_vars, ci_path)
        findings += self._check_deploy_jobs(jobs, ci_path)
        findings += self._check_artifact_integrity(jobs, ci_path)
        findings += self._check_runner_config(runner_config)
        findings += self._check_debug_trace(jobs, global_vars, ci_path)

        # Deduplicate
        seen: set[str] = set()
        deduped: list[Finding] = []
        for f in findings:
            key = f.dedup_key()
            if key not in seen:
                seen.add(key)
                deduped.append(f)

        stats = ScanStats(
            files_scanned=1 + len(collected.get("included_files", [])),
            rules_evaluated=10,
            findings_count=len(deduped),
            extras={"parse_errors": len(errors)},
        )
        return self._make_result(deduped, stats, errors)

    # -----------------------------------------------------------------------
    # CICD-SEC-10: Security Stage Coverage
    # -----------------------------------------------------------------------
    def _check_security_stages(
        self,
        jobs: dict[str, Any],
        required_stages: list[str],
        includes: list[dict[str, Any]],
        ci_path: str,
    ) -> list[Finding]:
        findings: list[Finding] = []

        # Build a map: stage_keyword → list of matching job names
        stage_jobs: dict[str, list[str]] = {s: [] for s in required_stages}

        # Check official GitLab security template includes
        # (e.g. include: - template: Security/SAST.gitlab-ci.yml)
        for entry in includes:
            tmpl = entry.get("template", "")
            if not tmpl:
                continue
            tmpl_norm = (
                tmpl.lower().replace("-", "").replace("_", "").replace("/", "").replace(".", "")
            )
            for stage_kw in required_stages:
                stage_norm = stage_kw.lower().replace("_", "")
                if stage_norm in tmpl_norm:
                    stage_jobs[stage_kw].append(f"template:{tmpl}")

        for job_name, job_def in jobs.items():
            name_lower = job_name.lower()
            for stage_kw in required_stages:
                if stage_kw.lower().replace("_", "") in name_lower.replace("_", ""):
                    stage_jobs[stage_kw].append(job_name)
            # Also check if job uses the official template (via 'extends' or script hints)
            extends = job_def.get("extends", "")
            if isinstance(extends, str):
                extends = [extends]
            for ext in extends:
                for stage_kw in required_stages:
                    if stage_kw.lower().replace("_", "") in ext.lower().replace("_", ""):
                        stage_jobs[stage_kw].append(job_name)

        for stage_kw in required_stages:
            matching = list(set(stage_jobs[stage_kw]))

            if not matching:
                findings.append(Finding(
                    id=f"cicd.missing_stage.{stage_kw}",
                    title=f"Required security stage absent: {stage_kw}",
                    description=(
                        f"The pipeline does not include a '{stage_kw}' stage. "
                        "Automated security scanning is a mandatory control for "
                        "SOC 2 CC7.1 and OWASP CICD-SEC-10."
                    ),
                    severity=Severity.CRITICAL,
                    module=self.module_name,
                    evidence=[Evidence(
                        file=ci_path, context=f"No job matching '{stage_kw}' found"
                    )],
                    remediation=(
                        f"Add the official GitLab template:\n"
                        f"  include:\n"
                        f"    - template: Security/{self._template_name(stage_kw)}"
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-10"],
                        soc2=["CC7.1"],
                        cis=[f"CIS-GitLab-{self._cis_for_stage(stage_kw)}"],
                        iso27001=["A.8.29"],
                    ),
                    tags=["security-stage"],
                ))
                continue

            # Check if matching jobs have allow_failure: true
            for job_name in matching:
                job_def = jobs.get(job_name, {})
                if job_def.get("allow_failure") is True:
                    findings.append(Finding(
                        id=f"cicd.stage_allow_failure.{job_name}",
                        title=f"Security stage '{job_name}' is marked allow_failure",
                        description=(
                            f"The job '{job_name}' has `allow_failure: true`, meaning the "
                            "pipeline succeeds even when this security scan fails or finds "
                            "vulnerabilities. This silently bypasses the security gate."
                        ),
                        severity=Severity.HIGH,
                        module=self.module_name,
                        evidence=[Evidence(
                            file=ci_path,
                            context=f"{job_name}: allow_failure: true",
                        )],
                        remediation=(
                            f"Remove `allow_failure: true` from '{job_name}'. "
                            "Configure severity thresholds in the scanner itself instead."
                        ),
                        compliance=ComplianceMapping(
                            owasp_cicd=["CICD-SEC-10"],
                            soc2=["CC7.1"],
                            cis=["CIS-GitLab-3.1", "CIS-GitLab-3.2"],
                            iso27001=["A.8.29"],
                        ),
                        tags=["security-stage", "allow-failure"],
                    ))

                # Check if job only runs on specific branches (not MRs)
                rules = job_def.get("rules", [])
                only = job_def.get("only", [])
                if rules or only:
                    has_mr_trigger = self._job_runs_on_mr(rules, only)
                    if not has_mr_trigger:
                        findings.append(Finding(
                            id=f"cicd.stage_not_on_mr.{job_name}",
                            title=f"Security stage '{job_name}' does not run on merge requests",
                            description=(
                                f"The '{job_name}' job has branch/rule restrictions that "
                                "prevent it from running on merge requests. Security scans "
                                "should run on every MR to catch issues before they land."
                            ),
                            severity=Severity.MEDIUM,
                            module=self.module_name,
                            evidence=[Evidence(
                                file=ci_path, context=f"{job_name}: limited trigger"
                            )],
                            remediation=(
                                f"Add a rule to run '{job_name}' on merge requests:\n"
                                "  rules:\n"
                                "    - if: '$CI_PIPELINE_SOURCE == \"merge_request_event\"'"
                            ),
                            compliance=ComplianceMapping(
                                owasp_cicd=["CICD-SEC-10"],
                                soc2=["CC7.1"],
                                iso27001=["A.8.29"],
                            ),
                            tags=["security-stage"],
                        ))

        return findings

    # -----------------------------------------------------------------------
    # CICD-SEC-1: Workflow Rules
    # -----------------------------------------------------------------------
    def _check_workflow_rules(
        self, workflow: dict[str, Any] | None, ci_path: str
    ) -> list[Finding]:
        if workflow and workflow.get("rules"):
            return []
        return [Finding(
            id="cicd.no_workflow_rules",
            title="No pipeline workflow rules defined",
            description=(
                "No top-level `workflow: rules:` block is defined. The pipeline triggers "
                "on every push to every branch, including untrusted feature branches. "
                "This allows unreviewed code to trigger sensitive pipeline stages."
            ),
            severity=Severity.HIGH,
            module=self.module_name,
            evidence=[Evidence(file=ci_path, context="workflow: rules: not found")],
            remediation=(
                "Add a workflow: rules: block:\n"
                "  workflow:\n"
                "    rules:\n"
                '      - if: \'$CI_PIPELINE_SOURCE == "merge_request_event"\'\n'
                "      - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'"
            ),
            compliance=ComplianceMapping(
                owasp_cicd=["CICD-SEC-1"],
                soc2=["CC8.1"],
                cis=["CIS-GitLab-1.1"],
                iso27001=["A.8.32"],
            ),
        )]

    # -----------------------------------------------------------------------
    # CICD-SEC-3: Docker Image Pinning
    # -----------------------------------------------------------------------
    def _check_docker_images(
        self, jobs: dict[str, Any], ci_path: str
    ) -> list[Finding]:
        findings: list[Finding] = []
        seen_images: set[str] = set()

        for job_name, job_def in jobs.items():
            for image_ref in GitLabCIParser.job_images(job_def):
                if image_ref in seen_images:
                    continue
                seen_images.add(image_ref)
                is_unpinned, reason = GitLabCIParser.image_is_unpinned(image_ref)
                if not is_unpinned:
                    continue

                severity = (
                    Severity.HIGH if ":latest" in image_ref or ":" not in image_ref
                    else Severity.MEDIUM
                )
                findings.append(Finding(
                    id=f"cicd.unpinned_image.{re.sub(r'[^a-z0-9]', '_', image_ref.lower()[:40])}",
                    title=f"Docker image not pinned to digest: {image_ref!r}",
                    description=(
                        f"Job '{job_name}' uses image `{image_ref}` which is {reason}. "
                        "Mutable tags allow a compromised registry to substitute a malicious "
                        "image without changing the pipeline configuration."
                    ),
                    severity=severity,
                    module=self.module_name,
                    evidence=[Evidence(file=ci_path, context=f"{job_name}: image: {image_ref}")],
                    remediation=(
                        f"Pin the image to its SHA256 digest:\n"
                        f"  image: {image_ref.split(':')[0]}:<tag>@sha256:<digest>\n"
                        "Retrieve the digest with: docker manifest inspect <image>"
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-3"],
                        cis=["CIS-GitLab-4.1", "CIS-SSCS-2"],
                        iso27001=["A.8.8"],
                    ),
                    tags=["supply-chain"],
                ))

        # Check for curl|bash in all job scripts
        for job_name, job_def in jobs.items():
            scripts = GitLabCIParser.extract_all_scripts(job_def)
            risky = GitLabCIParser.scripts_contain_curl_pipe_bash(scripts)
            for line in risky:
                findings.append(Finding(
                    id=f"cicd.curl_pipe_bash.{job_name}",
                    title=f"Job '{job_name}' downloads and executes remote code",
                    description=(
                        f"Job '{job_name}' contains a `curl | bash` or `wget | sh` pattern. "
                        "This unconditionally executes code from a remote server without "
                        "integrity verification, making it a direct supply chain attack vector."
                    ),
                    severity=Severity.CRITICAL,
                    module=self.module_name,
                    evidence=[Evidence(file=ci_path, context=line)],
                    remediation=(
                        "Download the script to a file, verify its SHA256 checksum against a "
                        "known-good value, then execute. Better: vendor the script into the repo "
                        "or embed it in a container image."
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-3", "CICD-SEC-8"],
                        soc2=["CC6.1", "CC8.1"],
                        cis=["CIS-SSCS-1"],
                        iso27001=["A.8.26"],
                    ),
                    tags=["supply-chain", "rce"],
                ))

        return findings

    # -----------------------------------------------------------------------
    # CICD-SEC-3 / CICD-SEC-8: Remote Includes
    # -----------------------------------------------------------------------
    def _check_remote_includes(
        self,
        includes: list[dict[str, Any]],
        trusted_domains: list[str],
        ci_path: str,
    ) -> list[Finding]:
        findings: list[Finding] = []
        for entry in includes:
            remote_url = entry.get("remote")
            if not remote_url:
                continue
            parsed = urlparse(str(remote_url))
            if parsed.scheme == "http":
                findings.append(Finding(
                    id=f"cicd.remote_include_http.{abs(hash(remote_url))}",
                    title="CI config includes remote template over HTTP",
                    description=(
                        f"The pipeline includes `{remote_url}` over HTTP. An attacker "
                        "in a network-adjacent position can substitute arbitrary pipeline "
                        "configuration via a man-in-the-middle attack."
                    ),
                    severity=Severity.HIGH,
                    module=self.module_name,
                    evidence=[Evidence(file=ci_path, context=f"include: remote: {remote_url}")],
                    remediation="Change the include URL to use HTTPS.",
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-8"],
                        soc2=["CC6.7"],
                        iso27001=["A.5.14"],
                    ),
                    tags=["supply-chain"],
                ))
            elif parsed.scheme == "https":
                host = parsed.netloc.lower()
                if not any(host == d or host.endswith("." + d) for d in trusted_domains):
                    findings.append(Finding(
                        id=f"cicd.remote_include_untrusted.{abs(hash(remote_url))}",
                        title=f"CI config includes from untrusted domain: {host}",
                        description=(
                            f"The pipeline includes templates from `{host}`, which is not in "
                            "the trusted remote domains list. Remote includes execute with full "
                            "access to CI/CD variables and runner capabilities."
                        ),
                        severity=Severity.HIGH,
                        module=self.module_name,
                        evidence=[Evidence(file=ci_path, context=f"include: remote: {remote_url}")],
                        remediation=(
                            "Only include remote templates from trusted, version-pinned sources. "
                            "Prefer `include: project:` with a pinned `ref:`"
                            " from your own instance."
                        ),
                        compliance=ComplianceMapping(
                            owasp_cicd=["CICD-SEC-3", "CICD-SEC-8"],
                            soc2=["CC6.1"],
                            cis=["CIS-SSCS-1"],
                            iso27001=["A.8.26"],
                        ),
                        tags=["supply-chain"],
                    ))
        return findings

    # -----------------------------------------------------------------------
    # CICD-SEC-4: Poisoned Pipeline Execution (PPE)
    # -----------------------------------------------------------------------
    def _check_ppe(
        self,
        jobs: dict[str, Any],
        workflow: dict[str, Any] | None,
        global_vars: dict[str, Any],
        ci_path: str,
    ) -> list[Finding]:
        findings: list[Finding] = []

        # Check if pipeline triggers on MR events
        triggers_on_mr = self._pipeline_triggers_on_mr(workflow)

        if triggers_on_mr:
            # Check if any unprotected variables exist
            unprotected_secrets = [
                name for name, val in global_vars.items()
                if _SECRET_VAR_KEYWORDS.search(name)
                and isinstance(val, dict)
                and not val.get("protected", False)
            ]
            if unprotected_secrets:
                snippet = ", ".join(unprotected_secrets[:5])
                findings.append(Finding(
                    id="cicd.ppe_mr_unprotected_secrets",
                    title="MR-triggered pipeline has access to unprotected secret variables",
                    description=(
                        "The pipeline triggers on merge_request_event AND has unprotected "
                        f"CI/CD variables ({snippet}) accessible to that pipeline. An external "
                        "contributor could open a malicious MR that exfiltrates these secrets."
                    ),
                    severity=Severity.CRITICAL,
                    module=self.module_name,
                    evidence=[Evidence(
                        file=ci_path,
                        context=f"Unprotected secret vars: {snippet}",
                    )],
                    remediation=(
                        "Mark all sensitive variables as `protected: true`. "
                        "Enable 'Require approval for pipelines from forked projects' in "
                        "GitLab project settings → CI/CD → General pipelines."
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-4", "CICD-SEC-5"],
                        soc2=["CC6.1", "CC6.2"],
                        cis=["CIS-GitLab-2.2"],
                        iso27001=["A.8.3"],
                    ),
                    tags=["ppe"],
                ))

        # Check for CI variable injection in all job scripts
        for job_name, job_def in jobs.items():
            scripts = GitLabCIParser.extract_all_scripts(job_def)
            risky_lines = GitLabCIParser.scripts_contain_user_var_injection(scripts)
            for line in risky_lines:
                findings.append(Finding(
                    id=f"cicd.var_injection.{job_name}",
                    title=f"User-controlled CI variable interpolated in shell: '{job_name}'",
                    description=(
                        f"Job '{job_name}' interpolates a user-controlled variable "
                        f"(branch name, commit message, etc.) directly into a shell command: "
                        f"`{line}`. A crafted branch name can achieve command injection."
                    ),
                    severity=Severity.HIGH,
                    module=self.module_name,
                    evidence=[Evidence(file=ci_path, context=line)],
                    remediation=(
                        "Always double-quote CI variable expansions: `\"$CI_COMMIT_REF_NAME\"`. "
                        "Validate user-controlled input before using it in shell commands. "
                        "Use `--` to separate arguments from flags where possible."
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-4"],
                        soc2=["CC6.1"],
                        iso27001=["A.8.26"],
                    ),
                    tags=["ppe", "injection"],
                ))

        return findings

    # -----------------------------------------------------------------------
    # CICD-SEC-6: Credential Hygiene
    # -----------------------------------------------------------------------
    def _check_credential_hygiene(
        self,
        jobs: dict[str, Any],
        global_vars: dict[str, Any],
        ci_path: str,
    ) -> list[Finding]:
        findings: list[Finding] = []

        # Global variable checks
        for var_name, var_val in global_vars.items():
            if not _SECRET_VAR_KEYWORDS.search(var_name):
                continue

            if isinstance(var_val, str):
                # Plain string value — either hardcoded or referencing another var
                if self._looks_like_hardcoded_secret(var_val):
                    findings.append(self._hardcoded_secret_finding(
                        ci_path, var_name, var_val, "global variables"
                    ))
                else:
                    # Not masked, not structured → unmasked variable
                    findings.append(Finding(
                        id=f"cicd.unmasked_var.{var_name}",
                        title=f"Secret variable '{var_name}' is not masked or protected",
                        description=(
                            f"The variable '{var_name}' appears to contain a sensitive value "
                            "but is defined as a plain string (not using the structured "
                            "`value/masked/protected` format). It will appear in job logs."
                        ),
                        severity=Severity.HIGH,
                        module=self.module_name,
                        evidence=[Evidence(file=ci_path, context=f"variables: {var_name}: ...")],
                        remediation=(
                            f"Change '{var_name}' to use the structured format:\n"
                            f"  variables:\n"
                            f"    {var_name}:\n"
                            "      value: $MY_SECRET_FROM_ENV\n"
                            "      masked: true\n"
                            "      protected: true"
                        ),
                        compliance=ComplianceMapping(
                            owasp_cicd=["CICD-SEC-6"],
                            soc2=["CC6.1"],
                            cis=["CIS-GitLab-2.1"],
                            iso27001=["A.9.4.3"],
                        ),
                    ))
            elif isinstance(var_val, dict):
                masked = var_val.get("masked", False)
                protected = var_val.get("protected", False)
                value = str(var_val.get("value", ""))

                if self._looks_like_hardcoded_secret(value):
                    findings.append(self._hardcoded_secret_finding(
                        ci_path, var_name, value, "variables block"
                    ))

                if not masked:
                    findings.append(Finding(
                        id=f"cicd.var_not_masked.{var_name}",
                        title=f"Secret variable '{var_name}' is not masked",
                        description=(
                            f"'{var_name}' is not set as `masked: true`. Its value will "
                            "appear in plain text in job logs, accessible to anyone with "
                            "Reporter or higher project access."
                        ),
                        severity=Severity.HIGH,
                        module=self.module_name,
                        evidence=[Evidence(
                            file=ci_path, context=f"variables: {var_name}: masked: false"
                        )],
                        remediation=f"Add `masked: true` to the '{var_name}' variable definition.",
                        compliance=ComplianceMapping(
                            owasp_cicd=["CICD-SEC-6"],
                            soc2=["CC6.1"],
                            cis=["CIS-GitLab-2.1"],
                            iso27001=["A.9.4.3"],
                        ),
                    ))
                if not protected:
                    findings.append(Finding(
                        id=f"cicd.var_not_protected.{var_name}",
                        title=f"Secret variable '{var_name}' is not protected",
                        description=(
                            f"'{var_name}' is not set as `protected: true`. It is available "
                            "in all pipelines, including those triggered by unreviewed "
                            "merge requests from external contributors."
                        ),
                        severity=Severity.HIGH,
                        module=self.module_name,
                        evidence=[Evidence(
                            file=ci_path, context=f"variables: {var_name}: protected: false"
                        )],
                        remediation=(
                            f"Add `protected: true` to the '{var_name}' variable definition."
                        ),
                        compliance=ComplianceMapping(
                            owasp_cicd=["CICD-SEC-6", "CICD-SEC-4"],
                            soc2=["CC6.1"],
                            cis=["CIS-GitLab-2.2"],
                            iso27001=["A.8.3"],
                        ),
                    ))

        # Per-job variable checks + Docker build arg check
        for job_name, job_def in jobs.items():
            job_vars = self._extract_variables(job_def.get("variables", {}))
            for var_name, var_val in job_vars.items():
                if _SECRET_VAR_KEYWORDS.search(var_name):
                    value = var_val if isinstance(var_val, str) else str(var_val.get("value", ""))
                    if self._looks_like_hardcoded_secret(value):
                        findings.append(self._hardcoded_secret_finding(
                            ci_path, var_name, value, f"job '{job_name}'"
                        ))

            # Docker build arg pattern: docker build --build-arg SECRET_VAR=...
            scripts = GitLabCIParser.extract_all_scripts(job_def)
            for line in scripts:
                if re.search(r"docker\s+build.*--build-arg\s+\S*(SECRET|TOKEN|KEY|PASSWORD)\S*=",
                             line, re.IGNORECASE):
                    findings.append(Finding(
                        id=f"cicd.docker_build_arg_secret.{job_name}",
                        title=f"Secret passed as Docker build argument in '{job_name}'",
                        description=(
                            f"Job '{job_name}' passes a secret-like value via `--build-arg`. "
                            "Docker build arguments are permanently stored in the image's "
                            "layer history and can be extracted from any image copy."
                        ),
                        severity=Severity.HIGH,
                        module=self.module_name,
                        evidence=[Evidence(file=ci_path, context=line.strip())],
                        remediation=(
                            "Use Docker BuildKit secret mounts instead:\n"
                            "  docker build --secret id=mysecret,src=/run/secrets/mysecret .\n"
                            "In the Dockerfile: RUN --mount=type=secret,id=mysecret ..."
                        ),
                        compliance=ComplianceMapping(
                            owasp_cicd=["CICD-SEC-6"],
                            soc2=["CC6.1", "CC6.7"],
                            iso27001=["A.8.24"],
                        ),
                    ))

        return findings

    # -----------------------------------------------------------------------
    # CICD-SEC-5: Deploy Job Access Controls
    # -----------------------------------------------------------------------
    def _check_deploy_jobs(self, jobs: dict[str, Any], ci_path: str) -> list[Finding]:
        findings: list[Finding] = []
        for job_name, job_def in jobs.items():
            if not GitLabCIParser.job_looks_like_deploy(job_name, job_def):
                continue

            has_env = bool(job_def.get("environment"))
            has_rules = bool(job_def.get("rules") or job_def.get("only"))
            has_needs = bool(job_def.get("needs"))

            if not has_env:
                findings.append(Finding(
                    id=f"cicd.deploy_no_environment.{job_name}",
                    title=f"Deploy job '{job_name}' has no environment protection",
                    description=(
                        f"The deploy job '{job_name}' does not declare an `environment:`. "
                        "Without GitLab protected environments, any pipeline run (including "
                        "from unreviewed branches) can trigger this deployment."
                    ),
                    severity=Severity.HIGH,
                    module=self.module_name,
                    evidence=[Evidence(file=ci_path, context=f"{job_name}: (no environment:)")],
                    remediation=(
                        f"Add environment protection to '{job_name}':\n"
                        "  environment:\n"
                        "    name: production\n"
                        "Then configure it as a Protected Environment in GitLab."
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-5"],
                        soc2=["CC6.1", "CC8.1"],
                        cis=["CIS-GitLab-2.2"],
                        iso27001=["A.8.25", "A.8.32"],
                    ),
                ))

            if not has_rules:
                findings.append(Finding(
                    id=f"cicd.deploy_no_branch_restriction.{job_name}",
                    title=f"Deploy job '{job_name}' runs on all branches",
                    description=(
                        f"Deploy job '{job_name}' has no `rules:` or `only:` restriction. "
                        "It will run on every push to every branch, enabling deployments "
                        "from untrusted feature branches."
                    ),
                    severity=Severity.MEDIUM,
                    module=self.module_name,
                    evidence=[Evidence(file=ci_path, context=f"{job_name}: (no rules/only:)")],
                    remediation=(
                        f"Restrict '{job_name}' to protected branches:\n"
                        "  rules:\n"
                        "    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'\n"
                        "      when: manual"
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-1", "CICD-SEC-5"],
                        soc2=["CC6.1", "CC8.1"],
                        iso27001=["A.8.25", "A.8.32"],
                    ),
                ))

            if not has_needs:
                findings.append(Finding(
                    id=f"cicd.deploy_no_needs.{job_name}",
                    title=f"Deploy job '{job_name}' has no explicit security stage dependency",
                    description=(
                        f"Deploy job '{job_name}' does not declare `needs:` dependencies on "
                        "security scanning jobs. If the DAG is misconfigured, deployment could "
                        "proceed without security gates completing."
                    ),
                    severity=Severity.MEDIUM,
                    module=self.module_name,
                    evidence=[Evidence(file=ci_path, context=f"{job_name}: (no needs:)")],
                    remediation=(
                        f"Add explicit dependencies to '{job_name}':\n"
                        "  needs:\n"
                        "    - job: sast\n"
                        "    - job: secret_detection\n"
                        "    - job: dependency_scanning"
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-5"],
                        soc2=["CC7.1", "CC8.1"],
                        iso27001=["A.8.29"],
                    ),
                ))
        return findings

    # -----------------------------------------------------------------------
    # CICD-SEC-9: Artifact Integrity
    # -----------------------------------------------------------------------
    def _check_artifact_integrity(self, jobs: dict[str, Any], ci_path: str) -> list[Finding]:
        findings: list[Finding] = []
        has_any_artifacts = False

        for job_name, job_def in jobs.items():
            artifacts = job_def.get("artifacts")
            if not artifacts:
                continue
            has_any_artifacts = True

            expire_in = artifacts.get("expire_in", "")
            if str(expire_in).lower() in ("never", "0", ""):
                if GitLabCIParser.job_looks_like_deploy(job_name, job_def) or \
                   GitLabCIParser.job_looks_like_security_scan(job_name):
                    findings.append(Finding(
                        id=f"cicd.artifact_never_expires.{job_name}",
                        title=f"Sensitive job '{job_name}' artifacts never expire",
                        description=(
                            f"Job '{job_name}' produces artifacts with `expire_in: never`. "
                            "Non-expiring artifacts from security-sensitive jobs accumulate "
                            "indefinitely and may be accessible to anyone with Reporter access."
                        ),
                        severity=Severity.LOW,
                        module=self.module_name,
                        evidence=[Evidence(
                            file=ci_path, context=f"{job_name}: artifacts: expire_in: never"
                        )],
                        remediation="Set a reasonable expiry: `expire_in: 30 days`.",
                        compliance=ComplianceMapping(
                            owasp_cicd=["CICD-SEC-9"],
                            soc2=["CC6.1"],
                            iso27001=["A.8.3"],
                        ),
                    ))

        if has_any_artifacts:
            # Check if any job verifies artifact checksums
            checksum_pattern = re.compile(
                r"sha256sum|sha512sum|md5sum|checksum|verify", re.IGNORECASE
            )
            any_verifies = any(
                checksum_pattern.search(line)
                for job_def in jobs.values()
                for line in GitLabCIParser.extract_all_scripts(job_def)
            )
            if not any_verifies:
                findings.append(Finding(
                    id="cicd.no_artifact_checksum",
                    title="No artifact integrity verification detected",
                    description=(
                        "The pipeline passes artifacts between jobs without any checksum "
                        "verification. A compromised intermediate job could substitute "
                        "malicious artifacts that get deployed downstream."
                    ),
                    severity=Severity.MEDIUM,
                    module=self.module_name,
                    evidence=[Evidence(
                        file=ci_path, context="No sha256sum/checksum commands found"
                    )],
                    remediation=(
                        "Generate checksums for critical artifacts and verify them:\n"
                        "  sha256sum artifact.tar.gz > artifact.tar.gz.sha256\n"
                        "In consuming job: sha256sum -c artifact.tar.gz.sha256"
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-9"],
                        soc2=["CC8.1"],
                        cis=["CIS-SSCS-1"],
                        iso27001=["A.8.32"],
                    ),
                ))
        return findings

    # -----------------------------------------------------------------------
    # CICD-SEC-7: Runner Configuration
    # -----------------------------------------------------------------------
    def _check_runner_config(self, runner_config: dict[str, Any] | None) -> list[Finding]:
        if not runner_config:
            return []
        findings: list[Finding] = []
        runners = runner_config.get("runners", [])

        for i, runner in enumerate(runners):
            name = runner.get("name", f"runner[{i}]")
            docker = runner.get("docker", {})
            executor = runner.get("executor", "")

            if docker.get("privileged") is True:
                findings.append(Finding(
                    id=f"cicd.privileged_runner.{re.sub(r'[^a-z0-9]', '_', name.lower()[:30])}",
                    title=f"Runner '{name}' has privileged mode enabled",
                    description=(
                        f"Runner '{name}' in .gitlab-runner/config.toml has `privileged = true`. "
                        "A privileged container has near-complete access to the host kernel "
                        "and can escape container isolation."
                    ),
                    severity=Severity.CRITICAL,
                    module=self.module_name,
                    evidence=[Evidence(
                        file=".gitlab-runner/config.toml",
                        context=f"runners[{i}].docker.privileged = true",
                    )],
                    remediation=(
                        "Set `privileged = false`. For Docker image builds, use Kaniko or "
                        "rootless Docker instead of Docker-in-Docker with privileged mode."
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-7"],
                        soc2=["CC6.1", "CC6.6"],
                        cis=["CIS-GitLab-4.2"],
                        iso27001=["A.8.2"],
                    ),
                ))

            if executor == "shell":
                findings.append(Finding(
                    id=f"cicd.shell_executor.{re.sub(r'[^a-z0-9]', '_', name.lower()[:30])}",
                    title=f"Runner '{name}' uses shell executor",
                    description=(
                        f"Runner '{name}' uses the shell executor, running jobs directly on "
                        "the host without container isolation. Malicious jobs can modify the "
                        "runner host and persist across runs."
                    ),
                    severity=Severity.MEDIUM,
                    module=self.module_name,
                    evidence=[Evidence(
                        file=".gitlab-runner/config.toml",
                        context=f"runners[{i}].executor = shell",
                    )],
                    remediation=(
                        "Migrate to the Docker executor for job isolation. "
                        "If shell is required, ensure the host is hardened and isolated."
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-7"],
                        soc2=["CC6.6"],
                        cis=["CIS-GitLab-4.2"],
                        iso27001=["A.8.9"],
                    ),
                ))

            # Docker-in-Docker without TLS
            services = runner.get("services", [])
            uses_dind = any(
                "docker:dind" in str(s) for s in services
            )
            tls_dir = docker.get("tls_cert_path") or docker.get("tls_verify")
            ci_tls = runner.get("environment", [])
            dind_tls_disabled = any(
                "DOCKER_TLS_CERTDIR=" == e or 'DOCKER_TLS_CERTDIR=""' in e
                for e in ci_tls
            )
            if uses_dind and (dind_tls_disabled or not tls_dir):
                findings.append(Finding(
                    id=f"cicd.dind_no_tls.{i}",
                    title=f"Runner '{name}' uses DinD without TLS",
                    description=(
                        f"Runner '{name}' uses Docker-in-Docker without TLS enabled. "
                        "The Docker daemon is exposed unencrypted within the job network."
                    ),
                    severity=Severity.HIGH,
                    module=self.module_name,
                    evidence=[Evidence(
                        file=".gitlab-runner/config.toml",
                        context=f"runners[{i}]: dind without DOCKER_TLS_CERTDIR",
                    )],
                    remediation=(
                        "Set DOCKER_TLS_CERTDIR: \"/certs\" in the job environment and "
                        "configure the docker:dind service to use TLS."
                    ),
                    compliance=ComplianceMapping(
                        owasp_cicd=["CICD-SEC-7"],
                        soc2=["CC6.7"],
                        iso27001=["A.5.14", "A.8.24"],
                    ),
                ))
        return findings

    # -----------------------------------------------------------------------
    # Debug trace check
    # -----------------------------------------------------------------------
    def _check_debug_trace(
        self,
        jobs: dict[str, Any],
        global_vars: dict[str, Any],
        ci_path: str,
    ) -> list[Finding]:
        findings: list[Finding] = []

        # Global variables check
        if str(global_vars.get("CI_DEBUG_TRACE", "")).lower() == "true":
            findings.append(self._debug_trace_finding(ci_path, "global variables"))

        # Per-job check
        for job_name, job_def in jobs.items():
            job_vars = self._extract_variables(job_def.get("variables", {}))
            if str(job_vars.get("CI_DEBUG_TRACE", "")).lower() == "true":
                findings.append(self._debug_trace_finding(ci_path, f"job '{job_name}'"))

        return findings

    def _debug_trace_finding(self, ci_path: str, location: str) -> Finding:
        return Finding(
            id=f"cicd.debug_trace.{abs(hash(location))}",
            title="CI_DEBUG_TRACE is enabled",
            description=(
                f"CI_DEBUG_TRACE is set to true in {location}. This causes GitLab CI "
                "to print ALL environment variables — including secrets — to job logs. "
                "Anyone with access to job logs can extract credentials."
            ),
            severity=Severity.HIGH,
            module=self.module_name,
            evidence=[Evidence(file=ci_path, context=f"{location}: CI_DEBUG_TRACE: true")],
            remediation=(
                "Remove CI_DEBUG_TRACE: true from all configurations. "
                "If debugging is needed, use it temporarily and rotate all secrets afterward."
            ),
            compliance=ComplianceMapping(
                owasp_cicd=["CICD-SEC-4", "CICD-SEC-6"],
                soc2=["CC6.1"],
                cis=["CIS-GitLab-2.1"],
                iso27001=["A.9.4.3"],
            ),
        )

    # -----------------------------------------------------------------------
    # Private helpers
    # -----------------------------------------------------------------------

    def _pipeline_triggers_on_mr(self, workflow: dict[str, Any] | None) -> bool:
        if not workflow:
            # No workflow rules = triggers on everything including MRs
            return True
        for rule in workflow.get("rules", []):
            if isinstance(rule, dict):
                condition = str(rule.get("if", ""))
                if "merge_request" in condition.lower():
                    return True
        return False

    def _job_runs_on_mr(self, rules: list[Any], only: Any) -> bool:
        for rule in rules:
            if isinstance(rule, dict):
                condition = str(rule.get("if", ""))
                if "merge_request" in condition.lower():
                    return True
        if isinstance(only, list):
            return "merge_requests" in only
        return False

    def _looks_like_hardcoded_secret(self, value: str) -> bool:
        if not value or len(value) < 8:
            return False
        # Skip obvious placeholders
        placeholders = {"null", "none", "false", "true", "${", "{{", "<"}
        if any(value.startswith(p) for p in placeholders):
            return False
        if re.match(r'^\$\{?[A-Z_]+\}?$', value):
            return False  # env var reference
        for pattern in _HARDCODED_SECRET_PATTERNS:
            if pattern.search(value):
                return True
        return False

    def _hardcoded_secret_finding(
        self, ci_path: str, var_name: str, value: str, location: str
    ) -> Finding:
        redacted = f"{value[:4]}****{value[-4:]}" if len(value) > 8 else "****"
        return Finding(
            id=f"cicd.hardcoded_secret.{var_name}",
            title=f"Hardcoded secret detected in CI variable '{var_name}'",
            description=(
                f"Variable '{var_name}' in {location} appears to contain a hardcoded "
                f"credential ({redacted}). Secrets committed to CI config are exposed to "
                "anyone with repository read access and persist in git history."
            ),
            severity=Severity.CRITICAL,
            module=self.module_name,
            evidence=[Evidence(
                file=ci_path,
                snippet=redacted,
                context=f"{location}: {var_name}: {redacted}",
            )],
            remediation=(
                f"Remove the hardcoded value from '{var_name}'. Store the secret as a "
                "protected, masked GitLab CI/CD variable at the project or group level "
                "and reference it by name. Rotate the compromised credential immediately."
            ),
            compliance=ComplianceMapping(
                owasp_cicd=["CICD-SEC-6"],
                soc2=["CC6.1"],
                cis=["CIS-GitLab-2.3"],
                iso27001=["A.9.4.3"],
            ),
            tags=["hardcoded-secret"],
        )

    @staticmethod
    def _extract_variables(variables_block: Any) -> dict[str, Any]:
        if isinstance(variables_block, dict):
            return variables_block
        return {}

    @staticmethod
    def _template_name(stage_kw: str) -> str:
        mapping = {
            "sast": "SAST.gitlab-ci.yml",
            "dast": "DAST.gitlab-ci.yml",
            "secret_detection": "Secret-Detection.gitlab-ci.yml",
            "dependency_scanning": "Dependency-Scanning.gitlab-ci.yml",
            "container_scanning": "Container-Scanning.gitlab-ci.yml",
        }
        return mapping.get(stage_kw, f"{stage_kw}.gitlab-ci.yml")

    @staticmethod
    def _cis_for_stage(stage_kw: str) -> str:
        mapping = {
            "sast": "3.1",
            "secret_detection": "3.2",
            "dependency_scanning": "3.3",
            "container_scanning": "3.4",
            "dast": "3.4",
        }
        return mapping.get(stage_kw, "3.1")
