#!/usr/bin/env python3
# For authorized security assessments and defensive monitoring only
"""Serverless Function Injection Detection Agent - Scans Lambda functions for injection vulnerabilities, layer hijacking, and IAM escalation paths."""

import argparse
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from datetime import datetime, timedelta, timezone

try:
    import boto3
    from botocore.exceptions import ClientError
except ImportError:
    print("ERROR: boto3 required. Install with: pip install boto3")
    sys.exit(1)

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

# Dangerous function patterns by runtime
INJECTION_PATTERNS = {
    "python": [
        {"pattern": r"\beval\s*\(", "sink": "eval()", "severity": "critical", "cwe": "CWE-95"},
        {"pattern": r"\bexec\s*\(", "sink": "exec()", "severity": "critical", "cwe": "CWE-95"},
        {"pattern": r"\bos\.system\s*\(", "sink": "os.system()", "severity": "critical", "cwe": "CWE-78"},
        {"pattern": r"\bos\.popen\s*\(", "sink": "os.popen()", "severity": "critical", "cwe": "CWE-78"},
        {"pattern": r"\bsubprocess\.call\s*\(.*shell\s*=\s*True", "sink": "subprocess.call(shell=True)", "severity": "critical", "cwe": "CWE-78"},
        {"pattern": r"\bsubprocess\.run\s*\(.*shell\s*=\s*True", "sink": "subprocess.run(shell=True)", "severity": "critical", "cwe": "CWE-78"},
        {"pattern": r"\bsubprocess\.Popen\s*\(.*shell\s*=\s*True", "sink": "subprocess.Popen(shell=True)", "severity": "critical", "cwe": "CWE-78"},
        {"pattern": r"\bpickle\.loads\s*\(", "sink": "pickle.loads()", "severity": "high", "cwe": "CWE-502"},
        {"pattern": r"\byaml\.load\s*\((?!.*Loader\s*=\s*yaml\.SafeLoader)", "sink": "yaml.load() without SafeLoader", "severity": "high", "cwe": "CWE-502"},
        {"pattern": r"\bjinja2\.Template\s*\(.*event", "sink": "jinja2.Template() with event data", "severity": "high", "cwe": "CWE-1336"},
        {"pattern": r"\b__import__\s*\(", "sink": "__import__()", "severity": "high", "cwe": "CWE-95"},
        {"pattern": r"f['\"].*\{.*event.*\}.*['\"].*\.execute\(", "sink": "SQL via f-string with event data", "severity": "critical", "cwe": "CWE-89"},
        {"pattern": r"['\"].*%s.*['\"].*%.*event", "sink": "SQL via string formatting with event data", "severity": "critical", "cwe": "CWE-89"},
    ],
    "nodejs": [
        {"pattern": r"\beval\s*\(", "sink": "eval()", "severity": "critical", "cwe": "CWE-95"},
        {"pattern": r"\bnew\s+Function\s*\(", "sink": "new Function()", "severity": "critical", "cwe": "CWE-95"},
        {"pattern": r"\bchild_process\.exec\s*\(", "sink": "child_process.exec()", "severity": "critical", "cwe": "CWE-78"},
        {"pattern": r"\bchild_process\.execSync\s*\(", "sink": "child_process.execSync()", "severity": "critical", "cwe": "CWE-78"},
        {"pattern": r"\bexecSync\s*\(", "sink": "execSync()", "severity": "critical", "cwe": "CWE-78"},
        {"pattern": r"\bexec\s*\((?!ute)", "sink": "exec()", "severity": "high", "cwe": "CWE-78"},
        {"pattern": r"\bvm\.runInNewContext\s*\(", "sink": "vm.runInNewContext()", "severity": "critical", "cwe": "CWE-95"},
        {"pattern": r"\bvm\.runInThisContext\s*\(", "sink": "vm.runInThisContext()", "severity": "critical", "cwe": "CWE-95"},
        {"pattern": r"\brequire\s*\(\s*['\"]child_process['\"]\s*\)", "sink": "require('child_process')", "severity": "medium", "cwe": "CWE-78"},
        {"pattern": r"`.*\$\{.*event.*\}`.*exec", "sink": "Template literal command injection", "severity": "critical", "cwe": "CWE-78"},
    ],
}

EVENT_DATA_ACCESSORS = [
    r"event\s*\[",
    r"event\s*\.",
    r"event\.get\s*\(",
    r"event\[.Records.\]",
    r"event\.body",
    r"event\.headers",
    r"event\.queryStringParameters",
    r"event\.pathParameters",
    r"event\.requestContext",
]


def detect_runtime_family(runtime):
    """Map Lambda runtime to language family."""
    if not runtime:
        return "unknown"
    runtime_lower = runtime.lower()
    if "python" in runtime_lower:
        return "python"
    if "node" in runtime_lower:
        return "nodejs"
    if "java" in runtime_lower:
        return "java"
    if "go" in runtime_lower:
        return "go"
    if "ruby" in runtime_lower:
        return "ruby"
    if "dotnet" in runtime_lower:
        return "dotnet"
    return "unknown"


def enumerate_functions(lambda_client):
    """Enumerate all Lambda functions with their configurations."""
    functions = []
    paginator = lambda_client.get_paginator("list_functions")
    for page in paginator.paginate():
        for func in page["Functions"]:
            func_info = {
                "function_name": func["FunctionName"],
                "function_arn": func["FunctionArn"],
                "runtime": func.get("Runtime", "container"),
                "runtime_family": detect_runtime_family(func.get("Runtime")),
                "handler": func.get("Handler"),
                "role": func["Role"],
                "memory_size": func.get("MemorySize"),
                "timeout": func.get("Timeout"),
                "last_modified": func.get("LastModified"),
                "layers": [l["Arn"] for l in func.get("Layers", [])],
                "environment_variables": list(func.get("Environment", {}).get("Variables", {}).keys()),
                "has_function_url": False,
                "has_secrets_in_env": False,
            }

            # Check for secrets in environment variable names
            secret_patterns = ["KEY", "SECRET", "PASSWORD", "TOKEN", "CREDENTIAL", "API_KEY", "PRIVATE"]
            for var_name in func_info["environment_variables"]:
                if any(pat in var_name.upper() for pat in secret_patterns):
                    func_info["has_secrets_in_env"] = True
                    break

            # Check for function URL
            try:
                url_config = lambda_client.get_function_url_config(FunctionName=func["FunctionName"])
                func_info["has_function_url"] = True
                func_info["function_url_auth"] = url_config.get("AuthType", "UNKNOWN")
            except ClientError:
                pass

            functions.append(func_info)

    logger.info("Enumerated %d Lambda functions", len(functions))
    return functions


def get_event_source_mappings(lambda_client):
    """Get all event source mappings to identify injection entry points."""
    mappings = []
    paginator = lambda_client.get_paginator("list_event_source_mappings")
    for page in paginator.paginate():
        for mapping in page["EventSourceMappings"]:
            source_arn = mapping.get("EventSourceArn", "")
            source_type = "unknown"
            if ":sqs:" in source_arn:
                source_type = "SQS"
            elif ":dynamodb:" in source_arn:
                source_type = "DynamoDB Stream"
            elif ":kinesis:" in source_arn:
                source_type = "Kinesis Stream"
            elif ":kafka" in source_arn:
                source_type = "Kafka"
            elif ":mq:" in source_arn:
                source_type = "MQ"

            mappings.append({
                "function_arn": mapping.get("FunctionArn"),
                "event_source_arn": source_arn,
                "source_type": source_type,
                "state": mapping.get("State"),
                "batch_size": mapping.get("BatchSize"),
            })

    logger.info("Found %d event source mappings", len(mappings))
    return mappings


def download_and_scan_function(lambda_client, function_name, runtime_family, work_dir):
    """Download function code and scan for injection patterns."""
    findings = []
    try:
        response = lambda_client.get_function(FunctionName=function_name)
        code_location = response["Code"]["Location"]

        import urllib.request
        zip_path = os.path.join(work_dir, f"{function_name}.zip")
        req = urllib.request.Request(code_location)
        with urllib.request.urlopen(req, timeout=60) as resp, open(zip_path, "wb") as out:
            out.write(resp.read())

        extract_dir = os.path.join(work_dir, function_name)
        os.makedirs(extract_dir, exist_ok=True)

        with zipfile.ZipFile(zip_path, "r") as zf:
            zf.extractall(extract_dir)

        # Determine file extensions to scan
        extensions = {
            "python": [".py"],
            "nodejs": [".js", ".mjs", ".ts"],
            "java": [".java"],
            "go": [".go"],
            "ruby": [".rb"],
        }
        target_exts = extensions.get(runtime_family, [".py", ".js"])

        patterns = INJECTION_PATTERNS.get(runtime_family, [])

        for root, dirs, files in os.walk(extract_dir):
            # Skip node_modules and vendor directories
            dirs[:] = [d for d in dirs if d not in ("node_modules", "vendor", "__pycache__", ".git")]

            for filename in files:
                if not any(filename.endswith(ext) for ext in target_exts):
                    continue

                filepath = os.path.join(root, filename)
                relative_path = os.path.relpath(filepath, extract_dir)

                try:
                    with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
                        lines = f.readlines()
                except Exception:
                    continue

                for line_num, line in enumerate(lines, 1):
                    for pattern_info in patterns:
                        if re.search(pattern_info["pattern"], line):
                            # Check if event data flows into this sink
                            context_start = max(0, line_num - 10)
                            context_lines = lines[context_start:line_num]
                            context_text = "".join(context_lines)

                            event_data_involved = any(
                                re.search(accessor, context_text)
                                for accessor in EVENT_DATA_ACCESSORS
                            )

                            findings.append({
                                "function_name": function_name,
                                "file": relative_path,
                                "line": line_num,
                                "code": line.strip()[:200],
                                "sink": pattern_info["sink"],
                                "severity": pattern_info["severity"],
                                "cwe": pattern_info["cwe"],
                                "event_data_flow": event_data_involved,
                                "confidence": "high" if event_data_involved else "medium",
                            })

    except ClientError as e:
        logger.warning("Cannot download %s: %s", function_name, e)
    except Exception as e:
        logger.warning("Error scanning %s: %s", function_name, e)

    return findings


def audit_layers(lambda_client, functions):
    """Audit Lambda layers for security issues."""
    findings = []
    layer_accounts = {}
    account_id = None

    for func in functions:
        for layer_arn in func.get("layers", []):
            # Extract account ID from layer ARN
            parts = layer_arn.split(":")
            if len(parts) >= 5:
                layer_account = parts[4]
                if account_id is None:
                    # Get our own account ID from function ARN
                    func_parts = func["function_arn"].split(":")
                    if len(func_parts) >= 5:
                        account_id = func_parts[4]

                if layer_account != account_id and account_id:
                    findings.append({
                        "type": "external_layer",
                        "function_name": func["function_name"],
                        "layer_arn": layer_arn,
                        "layer_account": layer_account,
                        "severity": "high",
                        "description": f"Function uses layer from external account {layer_account}",
                    })

                layer_accounts.setdefault(layer_arn, []).append(func["function_name"])

    # Check for layers used by many functions (high-impact if compromised)
    for layer_arn, func_names in layer_accounts.items():
        if len(func_names) >= 5:
            findings.append({
                "type": "high_impact_layer",
                "layer_arn": layer_arn,
                "affected_functions": func_names,
                "severity": "medium",
                "description": f"Layer is shared across {len(func_names)} functions - compromise would be high impact",
            })

    return findings


def detect_privilege_escalation_paths(iam_client, functions):
    """Identify Lambda functions with overprivileged execution roles."""
    findings = []
    checked_roles = {}

    dangerous_actions = [
        "iam:PassRole", "iam:CreateUser", "iam:CreateRole", "iam:AttachRolePolicy",
        "iam:AttachUserPolicy", "iam:PutRolePolicy", "iam:PutUserPolicy",
        "iam:CreateAccessKey", "iam:UpdateAssumeRolePolicy",
        "lambda:UpdateFunctionCode", "lambda:UpdateFunctionConfiguration",
        "lambda:CreateFunction", "lambda:InvokeFunction",
        "sts:AssumeRole",
    ]

    for func in functions:
        role_arn = func["role"]
        role_name = role_arn.split("/")[-1]

        if role_name in checked_roles:
            role_findings = checked_roles[role_name]
        else:
            role_findings = {"dangerous_permissions": [], "has_wildcard_resource": False, "has_admin": False}

            try:
                # Check attached policies
                attached = iam_client.list_attached_role_policies(RoleName=role_name)
                for policy in attached["AttachedPolicies"]:
                    if policy["PolicyName"] in ("AdministratorAccess", "PowerUserAccess"):
                        role_findings["has_admin"] = True

                    try:
                        policy_info = iam_client.get_policy(PolicyArn=policy["PolicyArn"])
                        version_id = policy_info["Policy"]["DefaultVersionId"]
                        policy_doc = iam_client.get_policy_version(
                            PolicyArn=policy["PolicyArn"], VersionId=version_id
                        )
                        for stmt in policy_doc["PolicyVersion"]["Document"].get("Statement", []):
                            if stmt.get("Effect") != "Allow":
                                continue
                            actions = stmt.get("Action", [])
                            if isinstance(actions, str):
                                actions = [actions]
                            resources = stmt.get("Resource", [])
                            if isinstance(resources, str):
                                resources = [resources]

                            if "*" in actions:
                                role_findings["has_admin"] = True
                            if "*" in resources:
                                role_findings["has_wildcard_resource"] = True

                            for action in actions:
                                if action in dangerous_actions or action == "*":
                                    role_findings["dangerous_permissions"].append(action)
                    except ClientError:
                        continue

                # Check inline policies
                inline = iam_client.list_role_policies(RoleName=role_name)
                for policy_name in inline["PolicyNames"]:
                    try:
                        policy_doc = iam_client.get_role_policy(
                            RoleName=role_name, PolicyName=policy_name
                        )
                        for stmt in policy_doc["PolicyDocument"].get("Statement", []):
                            if stmt.get("Effect") != "Allow":
                                continue
                            actions = stmt.get("Action", [])
                            if isinstance(actions, str):
                                actions = [actions]
                            for action in actions:
                                if action in dangerous_actions or action == "*":
                                    role_findings["dangerous_permissions"].append(action)
                    except ClientError:
                        continue

            except ClientError as e:
                logger.warning("Cannot audit role %s: %s", role_name, e)

            checked_roles[role_name] = role_findings

        if role_findings["has_admin"]:
            findings.append({
                "type": "admin_execution_role",
                "function_name": func["function_name"],
                "role": role_name,
                "severity": "critical",
                "description": "Function has administrative execution role - any code modification grants full account access",
            })
        elif role_findings["dangerous_permissions"]:
            findings.append({
                "type": "dangerous_permissions",
                "function_name": func["function_name"],
                "role": role_name,
                "permissions": list(set(role_findings["dangerous_permissions"])),
                "severity": "high",
                "description": f"Execution role has dangerous permissions: {', '.join(set(role_findings['dangerous_permissions']))}",
            })

    return findings


def check_cloudtrail_for_modifications(cloudtrail_client, days_back=7):
    """Search CloudTrail for suspicious Lambda modifications."""
    findings = []
    end_time = datetime.now(timezone.utc)
    start_time = end_time - timedelta(days=days_back)

    suspicious_events = [
        "UpdateFunctionCode20150331v2",
        "UpdateFunctionConfiguration20150331v2",
        "PublishLayerVersion20181031",
        "AddLayerVersionPermission20181031",
        "CreateFunction20150331",
    ]

    for event_name in suspicious_events:
        try:
            response = cloudtrail_client.lookup_events(
                LookupAttributes=[
                    {"AttributeKey": "EventName", "AttributeValue": event_name}
                ],
                StartTime=start_time,
                EndTime=end_time,
                MaxResults=50,
            )
            for event in response.get("Events", []):
                ct_event = json.loads(event.get("CloudTrailEvent", "{}"))
                req_params = ct_event.get("requestParameters", {})

                finding = {
                    "event_name": event_name,
                    "time": event["EventTime"].isoformat(),
                    "user": event.get("Username"),
                    "source_ip": ct_event.get("sourceIPAddress"),
                    "user_agent": ct_event.get("userAgent", "")[:100],
                    "function_name": req_params.get("functionName"),
                    "suspicious": False,
                    "indicators": [],
                }

                # Flag suspicious patterns
                user_agent = ct_event.get("userAgent", "")
                if "console.amazonaws.com" not in user_agent and "cloudformation" not in user_agent.lower():
                    if "UpdateFunctionCode" in event_name:
                        finding["suspicious"] = True
                        finding["indicators"].append("Function code updated outside console/CloudFormation")

                # Check for role changes
                if "role" in req_params and "UpdateFunctionConfiguration" in event_name:
                    finding["suspicious"] = True
                    finding["indicators"].append(f"Execution role changed to: {req_params['role']}")

                # Check for layer additions
                if "layers" in req_params and "UpdateFunctionConfiguration" in event_name:
                    finding["suspicious"] = True
                    finding["indicators"].append(f"Layers modified: {req_params['layers']}")

                # Off-hours modification
                event_hour = event["EventTime"].hour
                if event_hour < 6 or event_hour > 22:
                    finding["indicators"].append(f"Modification at unusual hour: {event_hour}:00 UTC")

                findings.append(finding)

        except ClientError as e:
            logger.warning("CloudTrail query failed for %s: %s", event_name, e)

    return findings


def check_function_url_security(lambda_client, functions):
    """Check Lambda function URLs for insecure authentication."""
    findings = []
    for func in functions:
        if func.get("has_function_url") and func.get("function_url_auth") == "NONE":
            findings.append({
                "type": "unauthenticated_function_url",
                "function_name": func["function_name"],
                "severity": "high",
                "description": "Function URL has AuthType=NONE - publicly accessible without authentication",
            })
    return findings


def generate_report(functions, event_sources, injection_findings, layer_findings,
                    escalation_findings, cloudtrail_findings, url_findings):
    """Generate comprehensive serverless injection detection report."""

    all_findings = []
    for f in injection_findings:
        f["category"] = "code_injection"
        all_findings.append(f)
    for f in layer_findings:
        f["category"] = "layer_security"
        all_findings.append(f)
    for f in escalation_findings:
        f["category"] = "privilege_escalation"
        all_findings.append(f)
    for f in cloudtrail_findings:
        if f.get("suspicious"):
            f["category"] = "suspicious_modification"
            f["severity"] = "high"
            all_findings.append(f)
    for f in url_findings:
        f["category"] = "function_url"
        all_findings.append(f)

    critical = [f for f in all_findings if f.get("severity") == "critical"]
    high = [f for f in all_findings if f.get("severity") == "high"]

    report = {
        "report_type": "Serverless Function Injection Assessment",
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "summary": {
            "functions_analyzed": len(functions),
            "event_source_mappings": len(event_sources),
            "total_findings": len(all_findings),
            "critical_findings": len(critical),
            "high_findings": len(high),
            "injection_sinks_found": len(injection_findings),
            "layer_issues": len(layer_findings),
            "escalation_paths": len(escalation_findings),
            "suspicious_modifications": len([f for f in cloudtrail_findings if f.get("suspicious")]),
        },
        "findings": all_findings,
        "functions": functions,
        "event_source_mappings": event_sources,
        "cloudtrail_events": cloudtrail_findings,
    }

    return report


def main():
    parser = argparse.ArgumentParser(description="Serverless Function Injection Detection Agent")
    parser.add_argument("--region", default="us-east-1", help="AWS region")
    parser.add_argument("--functions", nargs="+", help="Specific function names to scan (default: all)")
    parser.add_argument("--scan-code", action="store_true", help="Download and scan function code for injection sinks")
    parser.add_argument("--cloudtrail-days", type=int, default=7, help="Days of CloudTrail history to search")
    parser.add_argument("--output", default="serverless_injection_report.json", help="Output report file")
    args = parser.parse_args()

    session = boto3.Session(region_name=args.region)
    lambda_client = session.client("lambda")
    iam_client = session.client("iam")
    cloudtrail_client = session.client("cloudtrail")

    logger.info("Starting serverless function injection detection in %s", args.region)

    # Step 1: Enumerate functions
    all_functions = enumerate_functions(lambda_client)
    if args.functions:
        all_functions = [f for f in all_functions if f["function_name"] in args.functions]

    # Step 2: Get event source mappings
    event_sources = get_event_source_mappings(lambda_client)

    # Step 3: Scan code for injection patterns
    injection_findings = []
    if args.scan_code:
        work_dir = tempfile.mkdtemp(prefix="lambda_scan_")
        try:
            for func in all_functions:
                if func["runtime_family"] in INJECTION_PATTERNS:
                    logger.info("Scanning %s (%s)", func["function_name"], func["runtime"])
                    findings = download_and_scan_function(
                        lambda_client, func["function_name"],
                        func["runtime_family"], work_dir
                    )
                    injection_findings.extend(findings)
        finally:
            shutil.rmtree(work_dir, ignore_errors=True)

    # Step 4: Audit layers
    layer_findings = audit_layers(lambda_client, all_functions)

    # Step 5: Detect privilege escalation paths
    escalation_findings = detect_privilege_escalation_paths(iam_client, all_functions)

    # Step 6: Check CloudTrail for suspicious modifications
    cloudtrail_findings = check_cloudtrail_for_modifications(cloudtrail_client, args.cloudtrail_days)

    # Step 7: Check function URL security
    url_findings = check_function_url_security(lambda_client, all_functions)

    # Generate report
    report = generate_report(
        all_functions, event_sources, injection_findings, layer_findings,
        escalation_findings, cloudtrail_findings, url_findings
    )

    with open(args.output, "w") as f:
        json.dump(report, f, indent=2, default=str)
    logger.info("Report saved to %s", args.output)

    summary = report["summary"]
    logger.info(
        "Assessment complete: %d functions, %d findings (%d critical, %d high)",
        summary["functions_analyzed"],
        summary["total_findings"],
        summary["critical_findings"],
        summary["high_findings"],
    )

    if summary["critical_findings"] > 0:
        logger.warning("CRITICAL FINDINGS DETECTED:")
        for f in report["findings"]:
            if f.get("severity") == "critical":
                logger.warning("  [%s] %s: %s", f.get("category", ""), f.get("function_name", ""), f.get("sink", f.get("description", "")))

    return 0 if summary["critical_findings"] == 0 else 1


if __name__ == "__main__":
    sys.exit(main())
