import base64 import json import os import re import subprocess from enum import Enum from pathlib import Path import boto3 class Language(Enum): """Enumeration of programming languages.""" PYTHON = "python" JAVASCRIPT = "js" PHP = "php" RUST = "rust" RUBY = "ruby" JAVA = "java" GO = "go" ECR_PUBLIC_REGISTRY = "public.ecr.aws" MAJOR_VERSION_REGEX = re.compile(r"^(\d+).*$") LANGUAGE_PACKAGES_MAPPING = { Language.PYTHON: ["python"], Language.JAVASCRIPT: ["node"], Language.PHP: ["php"], Language.RUST: ["rust"], Language.RUBY: ["ruby"], Language.JAVA: ["openjdk", "openjdk8-jre"], Language.GO: ["stdlib"], } def determine_language_from_manifest(codebase_path: Path): """ Determines the programming language of a codebase. :param codebase_path: The path to the codebase. :return: The programming language of the codebase. """ if ( (codebase_path / "requirements.txt").exists() or (codebase_path / "pyproject.toml").exists() or (codebase_path / "setup.py").exists() or (codebase_path / "Pipfile").exists() ): return Language.PYTHON if (codebase_path / "package.json").exists(): return Language.JAVASCRIPT if (codebase_path / "composer.json").exists(): return Language.PHP if (codebase_path / "Cargo.toml").exists(): return Language.RUST return None def determine_package_manager(codebase_path: Path): """ Determines the package manager used in a codebase. :param codebase_path: The path to the codebase. :return: The package manager used in the service. """ language = determine_language_from_manifest(codebase_path) match language: case Language.PYTHON: if (codebase_path / "uv.lock").exists(): return "uv" if (codebase_path / "poetry.lock").exists(): return "poetry" if (codebase_path / "Pipfile.lock").exists(): return "pipenv" return "pip" # Assume pip if no specific lock file is found case Language.JAVASCRIPT: if (codebase_path / "yarn.lock").exists(): return "yarn" if (codebase_path / "pnpm-lock.yaml").exists(): return "pnpm" return "npm" # Assume npm if no specific lock file is found case Language.PHP: return "composer" case Language.RUST: return "cargo" return "unknown" def determine_metadata_from_lambda_runtime(runtime: str) -> tuple[Language, str] | None: """ Determines the language and language version from an AWS Lambda runtime string. :param runtime: The AWS Lambda runtime string (e.g., "python3.11", "nodejs20.x", "nodejs8.10"). :return: Tuple containing the language and language version, or None if unsupported. """ # Parse runtime string (e.g., "python3.11", "nodejs20.x", or "nodejs8.10") if runtime.startswith("python"): # Extract version from patterns like "python3.11" or "python3.12" version = runtime.replace("python", "") return Language.PYTHON, version elif runtime.startswith("nodejs"): # Extract major version only from patterns like "nodejs20.x", "nodejs18.x", or "nodejs8.10" version_str = runtime.replace("nodejs", "") major_version = version_str.split(".")[0] return Language.JAVASCRIPT, major_version return None def determine_metadata_from_image_name(image_repo, image_tag) -> tuple[Language, str] | None: """ Naively determines the language and language version of the service based on its parent image metadata. :param image_repo: The repository of the parent image. :param image_tag: The tag of the parent image. :return: Tuple containing the language and language version. """ registry, repository_name = image_repo.split("/", 1) if "/" in image_repo else (None, image_repo) # Handle services derived from internal docker-parent-images repository images if repository_name == "docker-parent-images": python_match = re.compile(r".*python(\d)(\d+).*$").match(image_tag) if python_match: return Language.PYTHON, f"{python_match.group(1)}.{python_match.group(2)}" node_match = re.compile(r"^node(\d+).*$").match(image_tag) if node_match: return Language.JAVASCRIPT, node_match.group(1) php_match = re.compile(r"^.*php(\d)(\d)$").match(image_tag) if php_match: return Language.PHP, f"{php_match.group(1)}.{php_match.group(2)}" if image_tag == "kafka-connect": return Language.JAVA, "11" if image_tag == "kafka-connect77": return Language.JAVA, "17" if image_tag == "kafka-connect81": return Language.JAVA, "21" # AWS Lambda python images if (registry == "amazon" and repository_name == "aws-lambda-python") or ( registry == ECR_PUBLIC_REGISTRY and repository_name == "lambda/python" ): match = re.compile(r"^(\d+)\.(\d+).*$").match(image_tag) if match: return Language.PYTHON, f"{match.group(1)}.{match.group(2)}" # AWS Lambda nodejs images if (registry == "amazon" and repository_name == "aws-lambda-nodejs") or ( registry == ECR_PUBLIC_REGISTRY and repository_name == "lambda/nodejs" ): match = MAJOR_VERSION_REGEX.match(image_tag) if match: return Language.JAVASCRIPT, match.group(1) # Official Python images if repository_name == "python": match = re.compile(r"^(\d+)\.(\d+).*$").match(image_tag) if match: return Language.PYTHON, f"{match.group(1)}.{match.group(2)}" # Official Node images if repository_name == "node": match = MAJOR_VERSION_REGEX.match(image_tag) if match: return Language.JAVASCRIPT, match.group(1) # Amazon Corretto (Java) images if repository_name == "amazoncorretto" or ( registry == ECR_PUBLIC_REGISTRY and repository_name == "amazoncorretto/amazoncorretto" ): match = MAJOR_VERSION_REGEX.match(image_tag) if match: return Language.JAVA, match.group(1) return None def determine_metadata_from_sbom(image_repo: str, image_tag: str) -> tuple[Language, str] | None: """ Determines the programming language and version of a service based on its SBOM. :param image_repo: The repository of the image. :param image_tag: The tag of the image. :return: Tuple containing the programming language and version. """ sbom = _generate_sbom(image_repo, image_tag) for language, search_terms in LANGUAGE_PACKAGES_MAPPING.items(): for artifact in sbom.get("artifacts", []): name = artifact.get("name", "").lower() if name in search_terms: print(f"Detected programming language: {language.value}") version = artifact.get("version") version = version.removeprefix("v") if language == Language.GO: version = version.removeprefix("go") match language: case Language.GO | Language.PYTHON | Language.PHP | Language.RUBY | Language.RUST: # Extract major.minor version_parts = version.split(".") if len(version_parts) >= 2: version = f"{version_parts[0]}.{version_parts[1]}" case Language.JAVA | Language.JAVASCRIPT: # Extract major version version = version.split(".")[0] print(f"Detected {language.value} version: {version}") return language, version print("Could not determine programming language from SBOM.") return None def _generate_sbom(image_repo: str, image_tag: str): image = f"{image_repo}:{image_tag}" print(f"Generating SBOM for image {image}...") registry = image_repo.split("/", 1)[0] if "/" in image_repo else None # Prepare environment variables for authentication env = os.environ.copy() ecr_regex = re.compile(r"^\d+\.dkr\.ecr\.(?P[a-z0-9-]+)\.amazonaws\.com$") if registry: ecr_match = ecr_regex.match(registry) if ecr_match: print("Detected ECR image. Generating authentication token...") region = ecr_match.group("region") try: # Get ECR login password ecr_client = boto3.client("ecr", region_name=region) response = ecr_client.get_authorization_token() token = response["authorizationData"][0]["authorizationToken"] # Decode the token (it's base64 encoded as "AWS:password") decoded_token = base64.b64decode(token).decode("utf-8") username, password = decoded_token.split(":", 1) # Set environment variables for syft env["SYFT_REGISTRY_AUTH_AUTHORITY"] = registry env["SYFT_REGISTRY_AUTH_USERNAME"] = username env["SYFT_REGISTRY_AUTH_PASSWORD"] = password print(f"Successfully generated ECR authentication token for region {region}.") except Exception as e: print(f"Warning: Failed to generate ECR authentication token: {e}") raise e try: result = subprocess.run( ["syft", image, "-o", "json", "-q"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True, env=env, ) sbom = json.loads(result.stdout) print(f"Successfully generated SBOM for image {image}.") return sbom except subprocess.CalledProcessError as e: print(f"Error running Syft. {e.stderr}") raise e except json.JSONDecodeError as e: print("Error: Could not parse Syft JSON output.") raise e def determine_metadata_from_nvmrc(codebase_path: Path) -> tuple[Language, str] | None: """ Determines the language and language version from .nvmrc file. :param codebase_path: The path to the codebase containing .nvmrc file. :return: Tuple containing the language and language version, or None if .nvmrc doesn't exist. """ nvmrc_path = codebase_path / ".nvmrc" if not nvmrc_path.exists(): return None try: # Read and strip whitespace version_str = nvmrc_path.read_text().strip() # Remove 'v' prefix if present version_str = version_str.removeprefix("v") # Extract major version (first part before dot) major_version = version_str.split(".")[0] print(f"Detected {Language.JAVASCRIPT.value} version: {major_version}") return Language.JAVASCRIPT, major_version except Exception as e: print(f"Error reading .nvmrc file: {e}") return None def determine_metadata_from_package_json_engines(codebase_path: Path) -> tuple[Language, str] | None: """ Determines the Node.js version from the engines.node field in package.json. Used for Vercel-deployed services which have no Dockerfile. :param codebase_path: The path to the codebase. :return: Tuple containing the language and language version, or None if not found. """ pkg_json_path = codebase_path / "package.json" if not pkg_json_path.exists(): return None try: data = json.loads(pkg_json_path.read_text()) node_version = data.get("engines", {}).get("node") if not node_version: return None # Extract major version from semver range (e.g. "20.x", "^20.0.0", ">=20.0.0") match = re.search(r"(\d+)", node_version) if not match: return None major_version = match.group(1) print(f"Detected {Language.JAVASCRIPT.value} version: {major_version}") return Language.JAVASCRIPT, major_version except Exception as e: print(f"Error reading package.json engines: {e}") return None