#!/usr/bin/env python
"""
Simple script to analyse a "package.json" / "pom.xml" / "build.gradle"  / "requirements.txt" descriptor 
and list packages not present in NPM/Maven/PyPI online official registry.

For Gradle, the Maven registry is used.

It leverage the data provided by the Google site "https://deps.dev/".

The goal is to evaluate exposure to a "dependency confusion" attack:
    https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610
    https://dhiyaneshgeek.github.io/web/security/2021/09/04/dependency-confusion/
    https://systemweakness.com/rce-via-dependency-confusion-e0ed2a127013
    https://portswigger.net/daily-swig/open-source-ecosystem-ripe-for-dependency-confusion-attacks-research-finds

Dependency: pip install requests
"""
import requests
import re
import json
import sys
import xml.etree.ElementTree as ET
from os.path import exists


def list_packages_npm(json_content):
    packages = []
    for pkg_name in json_content["dependencies"].keys():
        if pkg_name not in packages:
            packages.append(pkg_name)
    if "devDependencies" in json_content:
        for pkg_name in json_content["devDependencies"].keys():
            if pkg_name not in packages:
                packages.append(pkg_name)
    return packages


def list_packages_maven(xml_content):
    namespaces = {"mvn": "http://maven.apache.org/POM/4.0.0"}
    dependencies = []
    root = ET.fromstring(xml_content)
    dep_nodes = root.findall(".//mvn:dependency", namespaces)
    for dep_node in dep_nodes:
        group_id = dep_node.find("mvn:groupId", namespaces).text
        artifact_id = dep_node.find("mvn:artifactId", namespaces).text
        pkg_name = f"{group_id}:{artifact_id}"
        if pkg_name not in dependencies:
            dependencies.append(pkg_name)
    dep_nodes = root.findall(".//mvn:plugin", namespaces)
    for dep_node in dep_nodes:
        group_id = dep_node.find("mvn:groupId", namespaces).text
        artifact_id = dep_node.find("mvn:artifactId", namespaces).text
        pkg_name = f"{group_id}:{artifact_id}"
        if pkg_name not in dependencies:
            dependencies.append(pkg_name)
    return dependencies


def list_packages_gradle(text_content):
    packages = []
    expression = r'[\'"]{1}([\d\w_\-\.]+:[\d\w_\-\.]+:[\d\w_\-\.]+)[\'"]{1}'
    dependencies = re.findall(expression, text_content, re.MULTILINE)
    for pkg_name in dependencies:
        # remove the version
        parts = pkg_name.split(":")
        pkg = f"{parts[0]}:{parts[1]}"
        if pkg not in packages:
            packages.append(pkg)
    expression = r'(group:\s[\'"]{1}[\d\w_\-\.]+[\'"]\s*,\s*name:\s[\'"]{1}[\d\w_\-\.]+[\'"])'
    dependencies = re.findall(expression, text_content, re.MULTILINE)
    for pkg_name in dependencies:
        pkg = pkg_name.replace("group:", "").replace("name:", "").replace(" ", "").replace("'", "").replace("\"", "")
        parts = pkg.split(",")
        pkg = f"{parts[0]}:{parts[1]}"
        if pkg not in packages:
            packages.append(pkg)
    return packages


def list_packages_pypi(text_content):
    packages = []
    for line in text_content.split("\n"):
        pkg_name = line.split("==")[0].strip(" \n\t\r")
        if len(pkg_name) > 0:
            packages.append(pkg_name.lower())
    return packages


def find_package_absent_from_registry(pkg_names_list, registry_name):
    absent = []
    with requests.Session() as session:
        for pkg_name in pkg_names_list:
            print(f"\rPackage {pkg_name:<75}", end="", flush=True)
            name_encoded = pkg_name.replace(
                "/", "%2F").replace("@", "%40").replace(":", "%3A")
            response = session.get(
                url=f"https://deps.dev/_/s/{registry_name}/p/{name_encoded}/versions")
            if len(response.json()["versions"]) == 0:
                absent.append(pkg_name)
    absent.sort()
    return absent


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Syntax: {sys.argv[0]} package.json")
        print(f"        {sys.argv[0]} pom.xml")
        print(f"        {sys.argv[0]} build.gradle")
        print(f"        {sys.argv[0]} requirements.txt")
        sys.exit(1)
    descriptor_name = sys.argv[1]
    if not exists(descriptor_name):
        print(f"File '{descriptor_name}' do not exists!")
        sys.exit(1)
    with open(descriptor_name, "r") as f:
        if descriptor_name.endswith(".json"):
            content = json.load(f)
            registry_name = "npm"
        elif descriptor_name.endswith(".xml"):
            content = f.read()
            registry_name = "maven"
        elif descriptor_name.endswith(".gradle"):
            content = f.read()
            registry_name = "gradle"
        elif descriptor_name.endswith(".txt"):
            content = f.read()
            registry_name = "pypi"
        else:
            print("XML, JSON, GRADLE or TXT file expected!")
            sys.exit(1)
    print("[+] Extract the list of packages...")
    if registry_name == "npm":
        pkg_names_list = list_packages_npm(content)
    elif registry_name == "maven":
        pkg_names_list = list_packages_maven(content)
    elif registry_name == "pypi":
        pkg_names_list = list_packages_pypi(content)
    else:
        pkg_names_list = list_packages_gradle(content)
    print(f"{len(pkg_names_list)} packages identified.")
    print(
        f"[+] Verify presence in the official {registry_name.upper()} registry...")
    # Gradle is not supported by "deps.dev" so we use the Maven one
    if registry_name == "gradle":
        registry_name = "maven"
    absents = find_package_absent_from_registry(pkg_names_list, registry_name)
    if(len(absents)) == 0:
        print(f"\r{'[V] All packages are present in the registry.':<75}")
    else:
        print(f"\r{'[!] The following packages are absent:':<75}")
        print("\n".join(absents))
    sys.exit(len(absents))
