#!/usr/bin/env python3
"""vexor_check_deps - application dependency health, run BY the NRPE agent on the
node and returned to Vexor over check_nrpe. Read-only: it only inspects manifests
and queries update/advisory metadata, it never installs or changes anything.

One script, two modes over three ecosystems (npm, pip, composer):

  --mode outdated  Count dependencies with a newer version available.
  --mode audit     Count known security vulnerabilities (CVEs/advisories).

Target a project directory with --path (the ecosystem is auto-detected from the
manifest files present), or scan a root with --discover to aggregate every
project found underneath it.

Output: one Nagios line + perfdata, exit 0/1/2/3 = OK/WARN/CRIT/UNKNOWN.

Runs as the unprivileged nrpe user, so it needs read access to the project dir
and the ecosystem's CLI (npm / pip / composer) on PATH. outdated/audit generally
contact the registry/advisory DB, so give the check a generous timeout.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile

OK, WARN, CRIT, UNKNOWN = 0, 1, 2, 3
WORD = {OK: "OK", WARN: "WARNING", CRIT: "CRITICAL", UNKNOWN: "UNKNOWN"}
SEV_ORDER = ["low", "moderate", "high", "critical"]
# normalise various vendor severity spellings to our scale
SEV_ALIAS = {
    "info": "low", "informational": "low", "negligible": "low", "unknown": None,
    "low": "low", "medium": "moderate", "moderate": "moderate",
    "high": "high", "important": "high",
    "critical": "critical", "crit": "critical",
}

MANIFESTS = {
    "npm": ["package.json"],
    "pip": ["requirements.txt", "pyproject.toml", "Pipfile", "setup.py"],
    "composer": ["composer.json"],
}


def which(cmd):
    return shutil.which(cmd)


def run(cmd, cwd, env, timeout):
    """Run a command; return (rc, stdout, stderr). Never raises."""
    try:
        p = subprocess.run(cmd, cwd=cwd, env=env, timeout=timeout,
                           stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                           universal_newlines=True)
        return p.returncode, p.stdout or "", p.stderr or ""
    except subprocess.TimeoutExpired:
        return 124, "", "timed out after %ss" % timeout
    except FileNotFoundError as e:
        return 127, "", str(e)
    except Exception as e:  # noqa: BLE001
        return 1, "", str(e)


def detect_ecosystem(path):
    for eco, files in MANIFESTS.items():
        for f in files:
            if os.path.exists(os.path.join(path, f)):
                return eco
    return None


def _major(ver):
    """Best-effort leading integer of a version string."""
    num = ""
    for ch in str(ver).lstrip("^~=v>< "):
        if ch.isdigit():
            num += ch
        else:
            break
    try:
        return int(num)
    except ValueError:
        return None


def norm_sev(s):
    return SEV_ALIAS.get(str(s).strip().lower(), None)


# ---------------------------------------------------------------------------
# Per-ecosystem probes. Each returns a dict:
#   outdated: {"ok":bool, "err":str, "total":int, "major":int, "examples":[str]}
#   audit:    {"ok":bool, "err":str, "sev":{low,moderate,high,critical,unknown},
#              "total":int, "examples":[str]}
# ---------------------------------------------------------------------------
def npm_outdated(path, env, timeout):
    if not which("npm"):
        return {"ok": False, "err": "npm not found on PATH"}
    rc, out, err = run(["npm", "outdated", "--json"], path, env, timeout)
    if rc == 124:
        return {"ok": False, "err": err}
    out = out.strip()
    if not out:
        return {"ok": True, "total": 0, "major": 0, "examples": []}
    try:
        data = json.loads(out)
    except ValueError:
        return {"ok": False, "err": "could not parse npm output"}
    total = len(data)
    major = 0
    ex = []
    for name, info in data.items():
        cur = info.get("current") or info.get("wanted") or ""
        latest = info.get("latest") or ""
        cm, lm = _major(cur), _major(latest)
        if cm is not None and lm is not None and lm > cm:
            major += 1
        if len(ex) < 3:
            ex.append("%s %s->%s" % (name, cur, latest))
    return {"ok": True, "total": total, "major": major, "examples": ex}


def npm_audit(path, env, timeout):
    if not which("npm"):
        return {"ok": False, "err": "npm not found on PATH"}
    if not (os.path.exists(os.path.join(path, "package-lock.json"))
            or os.path.exists(os.path.join(path, "npm-shrinkwrap.json"))):
        return {"ok": False, "err": "no package-lock.json (run npm install first)"}
    rc, out, err = run(["npm", "audit", "--json"], path, env, timeout)
    if rc == 124:
        return {"ok": False, "err": err}
    try:
        data = json.loads(out)
    except ValueError:
        return {"ok": False, "err": "could not parse npm audit output"}
    sev = {k: 0 for k in SEV_ORDER}
    sev["unknown"] = 0
    meta = (data.get("metadata") or {}).get("vulnerabilities") or {}
    if meta:  # npm v7+
        for k, v in meta.items():
            nk = norm_sev(k)
            if nk in sev:
                sev[nk] += int(v)
    else:  # npm v6 advisories
        for adv in (data.get("advisories") or {}).values():
            nk = norm_sev(adv.get("severity", "unknown")) or "unknown"
            sev[nk] += 1
    total = sum(sev.values())
    return {"ok": True, "sev": sev, "total": total, "examples": []}


def _pip_bin(path):
    for rel in ["bin/pip", ".venv/bin/pip", "venv/bin/pip", "env/bin/pip"]:
        cand = os.path.join(path, rel)
        if os.path.exists(cand):
            return cand
    return which("pip3") or which("pip")


def pip_outdated(path, env, timeout):
    pip = _pip_bin(path)
    if not pip:
        return {"ok": False, "err": "pip not found"}
    rc, out, err = run([pip, "list", "--outdated", "--format=json"], path, env, timeout)
    if rc == 124:
        return {"ok": False, "err": err}
    try:
        data = json.loads(out.strip() or "[]")
    except ValueError:
        return {"ok": False, "err": "could not parse pip output"}
    total = len(data)
    major = 0
    ex = []
    for d in data:
        cur, latest = d.get("version", ""), d.get("latest_version", "")
        cm, lm = _major(cur), _major(latest)
        if cm is not None and lm is not None and lm > cm:
            major += 1
        if len(ex) < 3:
            ex.append("%s %s->%s" % (d.get("name", "?"), cur, latest))
    return {"ok": True, "total": total, "major": major, "examples": ex}


def pip_audit(path, env, timeout):
    tool = which("pip-audit")
    if not tool:
        return {"ok": False, "err": "pip-audit not installed (pip install pip-audit)"}
    cmd = [tool, "-f", "json", "--progress-spinner=off"]
    req = os.path.join(path, "requirements.txt")
    if os.path.exists(req):
        cmd += ["-r", req]
    rc, out, err = run(cmd, path, env, timeout)
    if rc == 124:
        return {"ok": False, "err": err}
    try:
        data = json.loads(out.strip() or "{}")
    except ValueError:
        return {"ok": False, "err": "could not parse pip-audit output"}
    deps = data.get("dependencies", data) if isinstance(data, dict) else data
    if isinstance(deps, dict):
        deps = deps.get("dependencies", [])
    sev = {k: 0 for k in SEV_ORDER}
    sev["unknown"] = 0
    ex = []
    for d in deps or []:
        for v in d.get("vulns", []) or []:
            nk = None
            # pip-audit rarely carries severity; try common fields
            for fld in ("severity", "cvss_severity"):
                if v.get(fld):
                    nk = norm_sev(v.get(fld))
                    break
            sev[nk if nk in sev else "unknown"] += 1
            if len(ex) < 3:
                ex.append("%s %s" % (d.get("name", "?"), v.get("id", "")))
    total = sum(sev.values())
    return {"ok": True, "sev": sev, "total": total, "examples": ex}


def composer_outdated(path, env, timeout):
    if not which("composer"):
        return {"ok": False, "err": "composer not found on PATH"}
    rc, out, err = run(["composer", "outdated", "--format=json", "--no-interaction",
                        "-d", path], path, env, timeout)
    if rc == 124:
        return {"ok": False, "err": err}
    try:
        data = json.loads(out.strip() or "{}")
    except ValueError:
        return {"ok": False, "err": "could not parse composer output"}
    inst = data.get("installed", [])
    total = 0
    major = 0
    ex = []
    for d in inst:
        cur, latest = d.get("version", ""), d.get("latest", "")
        status = d.get("latest-status", "")
        if latest and cur and cur != latest:
            total += 1
            cm, lm = _major(cur), _major(latest)
            if status == "semver-safe-update":
                pass
            if cm is not None and lm is not None and lm > cm:
                major += 1
            if len(ex) < 3:
                ex.append("%s %s->%s" % (d.get("name", "?"), cur, latest))
    return {"ok": True, "total": total, "major": major, "examples": ex}


def composer_audit(path, env, timeout):
    if not which("composer"):
        return {"ok": False, "err": "composer not found on PATH"}
    rc, out, err = run(["composer", "audit", "--format=json", "--no-interaction",
                        "-d", path], path, env, timeout)
    if rc == 124:
        return {"ok": False, "err": err}
    try:
        data = json.loads(out.strip() or "{}")
    except ValueError:
        return {"ok": False, "err": "could not parse composer audit output"}
    sev = {k: 0 for k in SEV_ORDER}
    sev["unknown"] = 0
    ex = []
    advisories = data.get("advisories", {})
    for pkg, advs in advisories.items():
        for a in (advs if isinstance(advs, list) else [advs]):
            nk = norm_sev(a.get("severity", "unknown")) or "unknown"
            sev[nk] += 1
            if len(ex) < 3:
                ex.append("%s %s" % (pkg, a.get("cve") or a.get("advisoryId") or ""))
    total = sum(sev.values())
    return {"ok": True, "sev": sev, "total": total, "examples": ex}


PROBES = {
    ("npm", "outdated"): npm_outdated, ("npm", "audit"): npm_audit,
    ("pip", "outdated"): pip_outdated, ("pip", "audit"): pip_audit,
    ("composer", "outdated"): composer_outdated, ("composer", "audit"): composer_audit,
}


def count_at_or_above(sev, minsev):
    """Count vulns at/above minsev; 'unknown'-severity vulns always count."""
    idx = SEV_ORDER.index(minsev)
    return sum(sev.get(s, 0) for s in SEV_ORDER[idx:]) + sev.get("unknown", 0)


def discover(root, wanted_eco):
    """Find project dirs under root. Returns list of (path, ecosystem)."""
    skip = {"node_modules", "vendor", ".git", ".venv", "venv", "env",
            "__pycache__", ".cache", "site-packages"}
    found = []
    root = os.path.abspath(root)
    for dirpath, dirnames, filenames in os.walk(root):
        depth = dirpath[len(root):].count(os.sep)
        if depth >= 5:
            dirnames[:] = []
            continue
        dirnames[:] = [d for d in dirnames if d not in skip and not d.startswith(".")]
        eco = None
        for e, files in MANIFESTS.items():
            if any(f in filenames for f in files):
                eco = e
                break
        if eco and (wanted_eco in ("auto", eco)):
            found.append((dirpath, eco))
            # don't descend into a project's subtree once matched
            dirnames[:] = []
        if len(found) >= 100:
            break
    return found


def emit(status, label, text, perf):
    if perf:
        print("%s %s - %s|%s" % (label, WORD[status], text, perf))
    else:
        print("%s %s - %s" % (label, WORD[status], text))
    sys.exit(status)


def eval_outdated(res, warn, crit):
    total, major = res["total"], res.get("major", 0)
    st = OK
    if crit > 0 and total >= crit:
        st = CRIT
    elif warn > 0 and total >= warn:
        st = WARN
    txt = "%d outdated (%d major)" % (total, major)
    if res.get("examples"):
        txt += ": " + ", ".join(res["examples"])
    perf = "outdated=%d;%d;%d;0 major=%d;;;0" % (total, warn, crit, major)
    return st, txt, perf


def eval_audit(res, warn, crit, minsev):
    sev = res["sev"]
    counted = count_at_or_above(sev, minsev)
    st = OK
    if crit > 0 and counted >= crit:
        st = CRIT
    elif warn > 0 and counted >= warn:
        st = WARN
    txt = ("%d vuln(s) >=%s (crit=%d high=%d moderate=%d low=%d unknown=%d)"
           % (counted, minsev, sev.get("critical", 0), sev.get("high", 0),
              sev.get("moderate", 0), sev.get("low", 0), sev.get("unknown", 0)))
    if res.get("examples"):
        txt += ": " + ", ".join(res["examples"])
    perf = ("vulns=%d;%d;%d;0 critical=%d;;;0 high=%d;;;0 moderate=%d;;;0"
            % (counted, warn, crit, sev.get("critical", 0), sev.get("high", 0),
               sev.get("moderate", 0)))
    return st, txt, perf


def main():
    ap = argparse.ArgumentParser(add_help=True, allow_abbrev=False)
    ap.add_argument("--ecosystem", default="auto")
    ap.add_argument("--mode", default="audit")
    ap.add_argument("--path", default=".")
    ap.add_argument("--discover", action="store_true")
    ap.add_argument("--root", default="/srv")
    ap.add_argument("--warn", default="1")
    ap.add_argument("--crit", default="0")
    ap.add_argument("--min-severity", dest="minsev", default="high")
    ap.add_argument("--timeout", default="120")
    # NOTE: do NOT strip empty tokens here. NRPE pads unset $ARGn$ with empty
    # strings; if we removed them a valued option like `--ecosystem ""` would
    # swallow the following flag. Instead we keep them and treat "" as default.
    args, _ = ap.parse_known_args(sys.argv[1:])

    def _dflt(v, d):
        v = (v or "").strip()
        return v if v else d

    def _sint(v, d):
        try:
            return int((v or "").strip())
        except (ValueError, AttributeError):
            return d

    args.ecosystem = _dflt(args.ecosystem, "auto")
    args.mode = _dflt(args.mode, "audit")
    args.path = _dflt(args.path, ".")
    args.root = _dflt(args.root, "/srv")
    args.minsev = _dflt(args.minsev, "high")
    args.warn = _sint(args.warn, 1)
    args.crit = _sint(args.crit, 0)
    args.timeout = _sint(args.timeout, 120)

    if args.ecosystem not in ("auto", "npm", "pip", "composer"):
        emit(UNKNOWN, "DEPS", "invalid --ecosystem '%s'" % args.ecosystem, "")
    if args.mode not in ("outdated", "audit"):
        emit(UNKNOWN, "DEPS", "invalid --mode '%s'" % args.mode, "")
    if args.minsev not in SEV_ORDER:
        emit(UNKNOWN, "DEPS", "invalid --min-severity '%s'" % args.minsev, "")

    mode = args.mode
    label_mode = "AUDIT" if mode == "audit" else "OUTDATED"

    env = os.environ.copy()
    tmp = tempfile.mkdtemp(prefix="vexordeps_")
    env["HOME"] = tmp
    env["npm_config_cache"] = os.path.join(tmp, "npm")
    env["npm_config_update_notifier"] = "false"
    env["npm_config_fund"] = "false"
    env["COMPOSER_HOME"] = os.path.join(tmp, "composer")
    env["COMPOSER_NO_INTERACTION"] = "1"
    env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"

    try:
        if args.discover:
            projects = discover(args.root, args.ecosystem)
            if not projects:
                emit(UNKNOWN, "DEPS-%s" % label_mode,
                     "no %s projects found under %s" % (args.ecosystem, args.root), "")
            worst = OK
            agg_total = 0
            agg_sev = {k: 0 for k in SEV_ORDER + ["unknown"]}
            errors = 0
            offenders = []
            for path, eco in projects:
                probe = PROBES.get((eco, mode))
                if not probe:
                    continue
                res = probe(path, env, args.timeout)
                if not res.get("ok"):
                    errors += 1
                    continue
                if mode == "outdated":
                    st, _, _ = eval_outdated(res, args.warn, args.crit)
                    agg_total += res["total"]
                    if res["total"] > 0 and len(offenders) < 3:
                        offenders.append("%s(%d)" % (os.path.basename(path), res["total"]))
                else:
                    st, _, _ = eval_audit(res, args.warn, args.crit, args.minsev)
                    for k in agg_sev:
                        agg_sev[k] += res["sev"].get(k, 0)
                    c = count_at_or_above(res["sev"], args.minsev)
                    if c > 0 and len(offenders) < 3:
                        offenders.append("%s(%d)" % (os.path.basename(path), c))
                worst = max(worst, st) if st != UNKNOWN else worst
            n = len(projects)
            if mode == "outdated":
                txt = "%d project(s), %d outdated dep(s) total" % (n, agg_total)
                if offenders:
                    txt += ": " + ", ".join(offenders)
                perf = "projects=%d;;;0 outdated=%d;%d;%d;0" % (n, agg_total, args.warn, args.crit)
            else:
                counted = count_at_or_above(agg_sev, args.minsev)
                txt = ("%d project(s), %d vuln(s) >=%s (crit=%d high=%d)"
                       % (n, counted, args.minsev, agg_sev["critical"], agg_sev["high"]))
                if offenders:
                    txt += ": " + ", ".join(offenders)
                perf = ("projects=%d;;;0 vulns=%d;%d;%d;0 critical=%d;;;0 high=%d;;;0"
                        % (n, counted, args.warn, args.crit, agg_sev["critical"], agg_sev["high"]))
            if errors:
                txt += " [%d project(s) unreadable/skipped]" % errors
            emit(worst, "DEPS-%s" % label_mode, txt, perf)

        # single project ----------------------------------------------------
        path = os.path.abspath(args.path)
        if not os.path.isdir(path):
            emit(UNKNOWN, "DEPS-%s" % label_mode, "path not found: %s" % path, "")
        eco = args.ecosystem
        if eco == "auto":
            eco = detect_ecosystem(path)
            if not eco:
                emit(UNKNOWN, "DEPS-%s" % label_mode,
                     "no npm/pip/composer manifest in %s" % path, "")
        probe = PROBES.get((eco, mode))
        if not probe:
            emit(UNKNOWN, "DEPS-%s" % label_mode,
                 "mode %s not supported for %s" % (mode, eco), "")
        res = probe(path, env, args.timeout)
        label = "DEPS-%s-%s" % (eco.upper(), label_mode)
        if not res.get("ok"):
            emit(UNKNOWN, label, res.get("err", "check failed"), "")
        if mode == "outdated":
            st, txt, perf = eval_outdated(res, args.warn, args.crit)
        else:
            st, txt, perf = eval_audit(res, args.warn, args.crit, args.minsev)
        emit(st, label, "%s (%s)" % (txt, eco), perf)
    finally:
        shutil.rmtree(tmp, ignore_errors=True)


if __name__ == "__main__":
    try:
        main()
    except SystemExit:
        raise
    except Exception as e:  # noqa: BLE001
        print("DEPS UNKNOWN - internal error: %s" % e)
        sys.exit(UNKNOWN)
