#!/usr/bin/python3
#
# OBS Source Service to vendor all crates.io and dependencies for a
# Rust project locally.
#
# (C) 2021 William Brown <william at blackhats.net.au>
#
# Mozilla Public License Version 2.0
# See LICENSE.md for details.

"""\
OBS Source Service to audit all crates.io and dependencies for security
issues that are known upstream.

To do this manually you can run "cargo audit" inside the source of the
project.

This requires a decompressed version of you sources. Either you need to
provide this manually, or you can use obs_scm to generate this as part
of the osc services.

See README.md for additional documentation.
"""

import tarfile
import tempfile
import logging
import argparse
import os
import json

from subprocess import run
from subprocess import PIPE
from subprocess import STDOUT
from subprocess import CalledProcessError

service_name = "obs-service-cargo_audit"
description = __doc__

if os.getenv('DEBUG') is not None:
    logging.basicConfig(level=logging.DEBUG)
else:
    logging.basicConfig(level=logging.INFO)

log = logging.getLogger(service_name)

parser = argparse.ArgumentParser(
    description=description, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--srcdir", default=None)
parser.add_argument("--lockfile", default=None)
# We always ignore this parameter, but it has to exist.
parser.add_argument("--outdir", default=None)
args = parser.parse_args()

srcdir = args.srcdir
lockfile = args.lockfile

def find_file(path, filename):
    return [
        os.path.join(root, filename)
        for root, dirs, files in os.walk(path)
        if filename in files and 'vendor' not in root
    ]

def generate_lock(path):
    log.debug(f" Running cargo generate-lockfile against: {path}/Cargo.toml")
    cmd = [
        "cargo", "generate-lockfile", "-q",
        "--manifest-path", f"{path}/Cargo.toml",
    ]
    dcmd = " ".join(cmd)
    log.debug(f" Running {dcmd}")
    proc = run(cmd, check=False, stdout=PIPE, stderr=STDOUT)
    output = proc.stdout.decode("utf-8").strip()
    log.debug(f" return: {proc.returncode}")
    if proc.returncode != 0:
        log.error(f" Could not generate Cargo.lock under {path}")
        exit(1)

def cargo_audit(lock_file):
    log.debug(f"Running cargo audit against: {lock_file}")
    cmd = [
        "cargo-audit", "audit",
        "--json",
        "-c", "never",
        "-D", "warnings",
        # Once we have cargo-audit packaged, these flags can be used.
        "-n", "-d", "/usr/share/cargo-audit-advisory-db/",
        "-f", lock_file,
    ]
    dcmd = " ".join(cmd)
    log.debug(f"Running {dcmd}")
    proc = run(cmd, check=False, stdout=PIPE, stderr=STDOUT)
    output = proc.stdout.decode("utf-8").strip()
    log.debug(f"return: {proc.returncode}")
    details = json.loads(output)
    # log.debug(json.dumps(details, sort_keys=True, indent=4))
    if proc.returncode != 0:
        # Issue may have been found!
        vuln_count = details["vulnerabilities"]["count"]
        if vuln_count > 0:
            log.error(f" 🚨 possible vulnerabilties: {vuln_count}")
            log.error(f" {lock_file}")
            log.error(f" For more information you SHOULD inspect the output of cargo audit manually")
            vulns = details["vulnerabilities"]["list"]
            for vuln in vulns:
                affects = vuln["advisory"]["package"]
                cvss = vuln["advisory"]["cvss"]
                vid = vuln["advisory"]["id"]
                categories = vuln["advisory"]["categories"]
                log.error(f" * {vid} -> crate: {affects}, cvss: {cvss}, class: {categories}")
            return True
    log.info(f"  ✅ No known issues detected in {lock_file}")
    return False

def do_extract(tgt_dir):
    cwd = os.getcwd()
    content = os.listdir(cwd)
    maybe_src = [
        x for x in content
        if '.tar' in x and 'vendor' not in x and not x.endswith('.asc')
    ]
    for src in maybe_src:
        log.debug(f"Unpacking assumed source tar {src} to {tgt_dir}")
        with tarfile.open(f"{cwd}/{src}", "r:*") as tar:
            tar.extractall(path=tgt_dir)

def main():
    log.info(f" Running OBS Source Service 🛒: {service_name}")
    log.debug(f" Current working dir: {os.getcwd()}")

    lsrcdir = srcdir
    status = False
    # Setup our temp dir, in case we need it.
    with tempfile.TemporaryDirectory() as tmpdirname:
        if lsrcdir is None:
            # We likely need to unpack sources.
            log.debug(f" setting up sources into {tmpdirname}")
            lsrcdir = tmpdirname
            do_extract(tmpdirname)

        cargo_lock_paths = []
        if lockfile:
            log.info(f" _service configured lock file: {lockfile}")
            cargo_lock_paths = [os.path.join(lsrcdir, lockfile)]
            log.debug(f" {cargo_lock_paths}")
        else:
            log.debug(f" Searching for Cargo.lock in: {lsrcdir}")
            cargo_lock_paths = find_file(lsrcdir, "Cargo.lock")

            if not cargo_lock_paths:
                log.info(f" No Rust Cargo.lock found under {lsrcdir}")
                log.info(f" Searching for Cargo.toml in: {lsrcdir}")
                if find_file(lsrcdir, "Cargo.toml"):
                    generate_lock(lsrcdir)
                else:
                    log.error(f" No Rust Cargo.toml found under {lsrcdir}")
                    exit(1)
            else:
                log.debug(f" Detected Rust lock files: {cargo_lock_paths}")

        # Do the scan.
        status = any([cargo_audit(cargo_lock_path) for cargo_lock_path in cargo_lock_paths])

    # We exit the context manager, so tempdirname is now removed.
    if status:
        log.error(" ⚠️  Vulnerabilities may have been found. You must review these.")
        exit(1)
    log.info(" 🎉 🦀 No known issues detected")

if __name__ == "__main__":
    main()
