← Files OGENIC GOD TOOLKITARCHIVED FILE

scripts/netwalk_exec.py

21.8 KB · Oct 3, 2026 · 06:33 UTC

↓ Download file

#!/usr/bin/env python3
"""Run a READ-ONLY command on a device whose credentials live in the netwalk vault.

The agent calls this instead of calling ssh itself. Two reasons:
  1. Credentials never enter the agent's context - this process reads the vault,
     the agent only ever passes a site slug and a host id.
  2. Every command is gated by netwalk_policy.check() before it leaves the machine,
     so "netwalk never changes a device" is enforced by code, not by good intentions.

  netwalk_exec.py run    --site S --host H --cmd 'show version'
  netwalk_exec.py run    --site S --host H --cmd-file cmds.txt --evidence run.jsonl
  netwalk_exec.py check  --vendor mikrotik --cmd '/export'      # policy only, no connection
  netwalk_exec.py probe  --site S --host H                      # can we log in at all?
"""
from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import netwalk_common as C  # noqa: E402
import netwalk_policy as policy  # noqa: E402

DEFAULT_TIMEOUT = 45
DEFAULT_MAX_BYTES = 200_000

# Plenty of field gear is a decade old, so we widen the algorithm lists - but only
# with algorithms this machine's ssh still knows about. Naming a retired one (ssh-dss
# on OpenSSH 10) makes ssh refuse the whole command line, which looks exactly like
# "device unreachable" and sends you debugging the wrong end of the cable.
LEGACY = {
    "HostKeyAlgorithms": ("key", ["ssh-rsa", "ssh-dss"]),
    "PubkeyAcceptedAlgorithms": ("key", ["ssh-rsa"]),
    "KexAlgorithms": ("kex", ["diffie-hellman-group14-sha1", "diffie-hellman-group1-sha1",
                              "diffie-hellman-group-exchange-sha1"]),
    "Ciphers": ("cipher", ["aes128-cbc", "aes256-cbc", "3des-cbc"]),
}


def _supported(kind: str) -> set[str]:
    try:
        out = subprocess.run(["ssh", "-Q", kind], capture_output=True, timeout=5)
        return set(out.stdout.decode().split())
    except Exception:
        return set()


def _legacy_opts() -> list[str]:
    opts: list[str] = []
    for name, (kind, wanted) in LEGACY.items():
        have = _supported(kind)
        usable = [a for a in wanted if a in have] if have else []
        if usable:
            opts += ["-o", f"{name}=+{','.join(usable)}"]
    return opts


SSH_BASE = [
    "-o", "StrictHostKeyChecking=accept-new",
    "-o", f"UserKnownHostsFile={C.known_hosts()}",
    "-o", "ConnectTimeout=10",
    "-o", "LogLevel=ERROR",
    "-o", "NumberOfPasswordPrompts=1",
] + _legacy_opts()


def now_iso() -> str:
    return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")


def die(msg: str, code: int = 1):
    print(f"netwalk_exec: {msg}", file=sys.stderr)
    raise SystemExit(code)


def load_host(site: str, host: str) -> dict:
    p = C.creds_dir() / f"{C.slugify(site)}.json"
    if not p.exists():
        die(f"no credential store for site {site!r}. Run /netwalk-login first.", 3)
    vault = json.loads(p.read_text(encoding="utf-8"))
    entry = vault.get("hosts", {}).get(host)
    if not entry:
        known = ", ".join(sorted(vault.get("hosts", {}))) or "(none)"
        die(f"host {host!r} is not in the {site!r} store. Known: {known}", 3)

    # The user ticked "not ours" on the login form. That is an instruction about
    # someone else's equipment, so it is enforced here rather than left to whoever
    # is driving the tool to remember.
    method = entry.get("method")
    if method == "not-ours":
        die(f"{host!r} was marked OUT OF SCOPE on the login form - netwalk will not "
            f"connect to it. Re-run /netwalk-login and change the answer if that was "
            f"a mistake.", 5)
    if method in ("unknown", "skip", "known-no-cred"):
        state = {"unknown": "the user did not recognise this device",
                 "skip": "the user deferred this device",
                 "known-no-cred": (f"the user described it as "
                                   f"{entry.get('described') or 'a known device'} but has no login "
                                   f"for it")}[method]
        die(f"no credential for {host!r}: {state} on the login form. Record it as "
            f"reachable:false with that reason and move on.", 3)
    return entry


def scrub(text: str, secrets: list[str]) -> str:
    """Mask credentials we know about (from the vault) wherever a device echoes them."""
    for s in secrets:
        if s and len(s) >= 3:
            text = text.replace(s, "***")
    return text


# Secrets that live in the DEVICE's own config rather than in our vault: PSKs, SNMP
# communities, RADIUS shared secrets, password hashes. A config export is full of
# them. Anything this tool prints ends up in the caller's context - and for an AI
# agent that means it is transmitted and retained - so it is masked by default.
# The file written by --out is never redacted; full fidelity stays on disk.
# Keyword that names a secret. Real configs prefix these constantly - MYSQL_PASSWORD,
# GF_SECURITY_ADMIN_PASSWORD, DB_PASSWORD, wpa2-pre-shared-key - so the prefix has to be
# part of the pattern. Missing that is exactly how a container env block full of database
# passwords sails through a redactor that only looks for a bare `password=`.
_SECRET_WORD = (r'[\w.\-]*'
                r'(?:passwords?|passwd|pwd|secrets?|psk|passphrase|pre[-_]?shared[-_]?key|'
                r'shared[-_]?secret|auth[-_]?key|enc(?:ryption)?[-_]?key|community|'
                r'api[-_]?key|access[-_]?key|token|credentials?)')
# Stop at a comma or a quote: env strings pack several secrets into one value and a
# greedy match would either miss the later ones or eat the whole line.
_SECRET_VAL = r'("[^"]*"|\'[^\']*\'|[^\s,"\']+)'

DEVICE_SECRETS = [
    (re.compile(r'(?i)((?:^|[^\w.\-])' + _SECRET_WORD + r'\s*[:=]\s*)' + _SECRET_VAL),
     r"\1<redacted>"),
    # whitespace-separated, trusted only for a quoted value so ordinary prose survives
    (re.compile(r'(?i)((?:^|[^\w.\-])' + _SECRET_WORD + r'\s+)("[^"]*"|\'[^\']*\')'),
     r"\1<redacted>"),
    (re.compile(r'(?im)^(\s*(?:enable\s+)?secret\s+\d?\s*)(\S+)'), r"\1<redacted>"),
    (re.compile(r'(?im)^(\s*username\s+\S+\s+(?:privilege\s+\d+\s+)?'
                r'(?:secret|password)\s+\d?\s*)(\S+)'), r"\1<redacted>"),
    (re.compile(r'(?i)(snmp-server\s+community\s+)(\S+)'), r"\1<redacted>"),
    (re.compile(r'(?i)(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?'
                r'(-----END [A-Z ]*PRIVATE KEY-----)'), r"\1<redacted>\2"),
]

# RouterOS (and others) wrap long lines with a trailing backslash, which can split a
# secret in half and let the tail through. Join continuations before matching.
_CONTINUATION = re.compile(r"\\\n\s*")


def redact_device_secrets(text: str) -> tuple[str, int]:
    hits = 0
    text = _CONTINUATION.sub("", text)
    for pat, repl in DEVICE_SECRETS:
        text, n = pat.subn(repl, text)
        hits += n
    return text, hits


def _needs_password(entry: dict) -> bool:
    return bool(entry.get("password")) and entry.get("method") in ("password", "key+password")


def _secrets_of(entry: dict) -> list[str]:
    return [entry.get(k) for k in ("password", "enable_password", "api_token") if entry.get(k)]


def build_ssh_argv(entry: dict, command: str, backend: str) -> tuple[list[str], dict]:
    user = entry.get("username") or ""
    ip = entry.get("ip") or ""
    port = str(entry.get("port") or 22)
    method = entry.get("method") or "password"
    target = f"{user}@{ip}" if user else ip
    env = dict(os.environ)
    argv: list[str] = []

    if backend == "sshpass":
        env["SSHPASS"] = entry["password"]
        argv += ["sshpass", "-e"]

    argv += ["ssh", "-T", "-p", port, *SSH_BASE]

    # A site whose gear only answers from inside is the normal case, not the exception.
    # The jump host comes from the login form, so the user names it once in the browser
    # rather than being asked for it in conversation.
    if entry.get("jump_host"):
        argv += ["-J", entry["jump_host"]]

    if method in ("key", "key+password") and entry.get("key_path"):
        kp = os.path.expanduser(entry["key_path"])
        if not os.path.exists(kp):
            die(f"key file not found: {kp} (fix it with /netwalk-login)", 3)
        argv += ["-i", kp, "-o", "IdentitiesOnly=yes"]
        if method == "key":
            argv += ["-o", "BatchMode=yes", "-o", "PasswordAuthentication=no"]
    elif method == "password":
        argv += ["-o", "PubkeyAuthentication=no",
                 "-o", "PreferredAuthentications=password,keyboard-interactive"]

    argv += [target, command]
    return argv, env


def _run_subprocess(argv: list[str], env: dict, timeout: int) -> tuple[str, str, int]:
    try:
        proc = subprocess.run(argv, env=env, capture_output=True, timeout=timeout)
        return (proc.stdout.decode("utf-8", "replace"),
                proc.stderr.decode("utf-8", "replace"), proc.returncode)
    except subprocess.TimeoutExpired:
        return "", f"timed out after {timeout}s", 124
    except FileNotFoundError as e:
        die(f"missing binary: {e}", 4)
        raise


def _run_paramiko(entry: dict, command: str, timeout: int) -> tuple[str, str, int]:
    """Pure-Python transport. The only one that does passwords on Windows without
    a third-party binary, so it is what makes this toolkit portable."""
    import paramiko

    client = paramiko.SSHClient()
    kh = C.known_hosts()
    if kh.exists():
        try:
            client.load_host_keys(str(kh))
        except Exception:  # noqa: BLE001
            pass
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    kwargs = {
        "hostname": entry.get("ip"), "port": int(entry.get("port") or 22),
        "username": entry.get("username") or None, "timeout": 10,
        "banner_timeout": 25, "auth_timeout": 25,
        "look_for_keys": False, "allow_agent": False,
    }
    if entry.get("key_path"):
        kwargs["key_filename"] = os.path.expanduser(entry["key_path"])
    if entry.get("password"):
        kwargs["password"] = entry["password"]
    try:
        client.connect(**kwargs)
        _, out_f, err_f = client.exec_command(command, timeout=timeout)
        out = out_f.read().decode("utf-8", "replace")
        err = err_f.read().decode("utf-8", "replace")
        rc = out_f.channel.recv_exit_status()
        return out, err, rc
    except Exception as e:  # noqa: BLE001
        return "", f"paramiko: {type(e).__name__}: {e}", 255
    finally:
        try:
            client.save_host_keys(str(kh))
        except Exception:  # noqa: BLE001
            pass
        client.close()


def _run_plink(entry: dict, command: str, timeout: int) -> tuple[str, str, int]:
    argv = ["plink", "-ssh", "-batch", "-P", str(entry.get("port") or 22)]
    if entry.get("key_path"):
        argv += ["-i", os.path.expanduser(entry["key_path"])]
    if entry.get("password"):
        argv += ["-pw", entry["password"]]
    user = entry.get("username") or ""
    argv += [f"{user}@{entry.get('ip')}" if user else str(entry.get("ip")), command]
    return _run_subprocess(argv, dict(os.environ), timeout)


def run_one(entry: dict, command: str, timeout: int, max_bytes: int,
            redact: bool = True) -> dict:
    vendor = entry.get("vendor") or "unknown"
    verdict = policy.check(command, vendor)
    started = now_iso()
    base = {"command": command, "at": started, "profile": verdict.profile}

    if not verdict.allowed:
        return {**base, "allowed": False, "reason": verdict.reason,
                "exit_code": None, "stdout": "", "stderr": ""}

    if entry.get("method") == "api":
        return {**base, "allowed": True, "exit_code": None, "stdout": "",
                "reason": "api method - drive the vendor HTTP API yourself",
                "stderr": "netwalk_exec only speaks SSH; this host is registered as method=api"}

    backend, why = C.detect_transport(_needs_password(entry))
    if backend == "none":
        die(f"cannot connect to {entry.get('ip')}: {why}", 4)

    if backend == "paramiko" and entry.get("jump_host"):
        die("this host is set to connect through a jump host, which needs the OpenSSH "
            "client. Install OpenSSH, or clear the jump host on the login form.", 4)
    if backend == "paramiko":
        out, err, rc = _run_paramiko(entry, command, timeout)
    elif backend == "plink":
        out, err, rc = _run_plink(entry, command, timeout)
    else:
        argv, env = build_ssh_argv(entry, command, backend)
        out, err, rc = _run_subprocess(argv, env, timeout)

    secrets = _secrets_of(entry)
    raw = scrub(out, secrets)          # full fidelity, for --out / the operator's disk
    shown = raw
    redacted = 0
    if redact:
        shown, redacted = redact_device_secrets(shown)
    truncated = len(shown) > max_bytes
    if truncated:
        shown = shown[:max_bytes]

    return {**base, "allowed": True, "backend": backend, "exit_code": rc,
            "stdout": shown, "raw_stdout": raw, "stderr": scrub(err, secrets),
            "truncated": truncated, "bytes_out": len(raw), "redacted": redacted}


def append_evidence(path: str, host: str, res: dict) -> None:
    rec = {"host_id": host, "command": res["command"], "at": res["at"],
           "exit_code": res.get("exit_code"), "bytes_out": res.get("bytes_out", 0)}
    if not res.get("allowed"):
        rec["blocked"] = res.get("reason")
    with open(path, "a", encoding="utf-8") as fh:
        fh.write(json.dumps(rec) + "\n")


def cmd_run(args: argparse.Namespace) -> int:
    commands: list[str] = list(args.cmd)
    if args.cmd_file:
        with open(args.cmd_file, "r", encoding="utf-8") as fh:
            commands += [ln.strip() for ln in fh if ln.strip() and not ln.lstrip().startswith("#")]
    if not commands:
        die("give at least one --cmd or a --cmd-file")

    entry = load_host(args.site, args.host)
    out_fh = None
    if args.out:
        Path(args.out).parent.mkdir(parents=True, exist_ok=True)
        out_fh = open(args.out, "w", encoding="utf-8")
    results = []
    worst = 0
    for c in commands:
        res = run_one(entry, c, args.timeout, args.max_bytes, redact=not args.raw)
        if out_fh and res.get("allowed"):
            out_fh.write(f"### {c}\n{res.get('raw_stdout', '')}\n")
        results.append(res)
        if args.evidence:
            append_evidence(args.evidence, args.host, res)
        if not res["allowed"]:
            worst = max(worst, 2)
        elif res.get("exit_code"):
            worst = max(worst, 1)

    if out_fh:
        out_fh.close()
        harden = C.harden_path(Path(args.out))
        total = sum(r.get("bytes_out", 0) for r in results)
        print(f"wrote {total:,} bytes from {len(results)} command(s) to {args.out} [{harden[1]}]")
        print("NOT printed here on purpose - a config export contains PSKs, community "
              "strings and password hashes, and anything printed enters the agent's context.")
        for res in results:
            flag = "BLOCKED" if not res["allowed"] else ("ok" if not res.get("exit_code") else f"exit {res['exit_code']}")
            print(f"  {flag:>8}  {res['command']}")
        return worst

    if args.json:
        for r in results:
            r.pop("raw_stdout", None)
        print(json.dumps({"host": args.host, "site": args.site,
                          "vendor": entry.get("vendor"), "results": results}, indent=2))
        return worst

    for res in results:
        print(f"\n===== [{args.host}] $ {res['command']}")
        if not res["allowed"]:
            print(f"BLOCKED (read-only policy / {res['profile']}): {res['reason']}")
            continue
        if res.get("stdout"):
            print(res["stdout"].rstrip())
        if res.get("truncated"):
            print(f"... [truncated at {args.max_bytes} bytes]")
        if res.get("redacted"):
            print(f"--- {res['redacted']} secret value(s) in this output were replaced with "
                  f"<redacted>; use --out to keep the full text on disk", file=sys.stderr)
        if res.get("stderr", "").strip():
            print(f"--- stderr: {res['stderr'].strip()}", file=sys.stderr)
        if res.get("exit_code"):
            print(f"--- exit {res['exit_code']}", file=sys.stderr)
    return worst


def cmd_check(args: argparse.Namespace) -> int:
    v = policy.check(args.cmd, args.vendor)
    print(f"{'ALLOW' if v.allowed else 'DENY '} [{v.profile}] {v.reason}")
    return 0 if v.allowed else 1


def server_auth_methods(entry: dict, timeout: int = 10) -> list[str]:
    """Ask the device which authentication methods it will accept.

    `Permission denied (publickey)` on a host where you stored a password reads like
    a wrong password. It is not - it means the device will never accept one. Saying
    so turns a retype-the-password loop into a one-line fix.
    """
    ssh = C.ssh_binary()
    if not ssh:
        return []
    # -v is required: the method list is a debug1 line. Every auth type is switched
    # off so this asks the question without making a login attempt the device would
    # log as a failure.
    argv = [ssh, "-v", "-o", "BatchMode=yes",
            "-o", "PubkeyAuthentication=no", "-o", "PasswordAuthentication=no",
            "-o", "KbdInteractiveAuthentication=no",
            "-o", f"UserKnownHostsFile={C.known_hosts()}",
            "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=8",
            "-p", str(entry.get("port") or 22),
            f"{entry.get('username','')}@{entry.get('ip')}" if entry.get("username")
            else str(entry.get("ip")), "exit"]
    try:
        r = subprocess.run(argv, capture_output=True, timeout=timeout)
    except Exception:  # noqa: BLE001
        return []
    m = re.search(r"Authentications that can continue:\s*([\w,\-]+)",
                  r.stderr.decode("utf-8", "replace"))
    return m.group(1).split(",") if m else []


def diagnose_auth(entry: dict) -> str:
    """One actionable line explaining an authentication failure."""
    accepts = server_auth_methods(entry)
    if not accepts:
        return ("could not determine which authentication methods the device accepts - "
                "check the IP, the port, and whether SSH is enabled at all")
    stored = entry.get("method") or "unknown"
    have = "publickey" if stored in ("key", "key+password") else "password"
    if have in accepts or (have == "password" and "keyboard-interactive" in accepts):
        return (f"the device accepts {','.join(accepts)} and you stored a {stored} credential, "
                f"so the credential itself is being rejected - check the username and the value")
    if stored == "password" and accepts == ["publickey"]:
        return ("this device accepts publickey ONLY - a password will never work here, however "
                "correct it is. Re-run /netwalk-login, choose 'SSH key file', and give the path "
                "to a key already authorised on the device (RouterOS: /user ssh-keys print)")
    return (f"the device accepts {','.join(accepts)} but you stored a {stored} credential - "
            f"re-run /netwalk-login and pick a method the device supports")


PROBE = {
    "mikrotik": "/system resource print",
    "cisco": "show version",
    "aruba": "show version",
    "hp": "show version",
    "fortinet": "get system status",
    "juniper": "show version",
    "linux": "uname -a",
    "windows": "hostname",
}


def cmd_probe(args: argparse.Namespace) -> int:
    entry = load_host(args.site, args.host)
    name, _ = policy.resolve_profile(entry.get("vendor"))
    res = run_one(entry, PROBE.get(name, "show version"), args.timeout, 4000)
    ok = res.get("exit_code") == 0 and res.get("stdout", "").strip()
    backend, why = C.detect_transport(_needs_password(entry))
    via = f" via {entry['jump_host']}" if entry.get("jump_host") else ""
    print(f"{'REACHABLE' if ok else 'FAILED'} {args.host} ({entry.get('ip')}){via} "
          f"vendor={entry.get('vendor')} profile={name} transport={backend}")
    if ok:
        print(res["stdout"].strip()[:600])
        return 0
    err = (res.get("stderr") or res.get("reason") or "no output").strip()
    print(err[:400], file=sys.stderr)
    if "permission denied" in err.lower() or "authentication" in err.lower():
        print(f"  -> {diagnose_auth(entry)}", file=sys.stderr)
    return 1


def main() -> int:
    ap = argparse.ArgumentParser(prog="netwalk_exec.py", description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)

    r = sub.add_parser("run")
    r.add_argument("--site", required=True)
    r.add_argument("--host", required=True)
    r.add_argument("--cmd", action="append", default=[])
    r.add_argument("--cmd-file")
    r.add_argument("--evidence", help="append a JSONL line per command for the report's evidence log")
    r.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
    r.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES)
    r.add_argument("--out", help="write the FULL device output to this file and print only a "
                                 "summary - use this for config exports so their secrets never "
                                 "enter the caller's context")
    r.add_argument("--raw", action="store_true",
                   help="do not mask PSKs/community strings/hashes in what is printed. "
                        "Only for a human at a terminal.")
    r.add_argument("--json", action="store_true")
    r.set_defaults(func=cmd_run)

    c = sub.add_parser("check", help="ask the policy about a command without connecting")
    c.add_argument("--vendor", required=True)
    c.add_argument("--cmd", required=True)
    c.set_defaults(func=cmd_check)

    p = sub.add_parser("probe", help="verify the stored credential actually logs in")
    p.add_argument("--site", required=True)
    p.add_argument("--host", required=True)
    p.add_argument("--timeout", type=int, default=20)
    p.set_defaults(func=cmd_probe)

    args = ap.parse_args()
    return args.func(args)


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

SHA-256: e021ef4e6695b36304ae8ce1e92d85f069590df2eda2e335cd72e9c07456e976