1150 lines
40 KiB
Python
1150 lines
40 KiB
Python
#!/usr/bin/env python3
|
|
"""Small stdlib adapter for explicitly scoped device-test evidence collection.
|
|
|
|
No installers, arbitrary arguments, credential persistence, or test verdicts.
|
|
Run --help for commands. Global options must precede the subcommand.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import hashlib
|
|
import io
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qsl, unquote, urlsplit
|
|
|
|
BASE = Path(__file__).resolve().parents[1]
|
|
CATALOG = BASE / "assets" / "tool-catalog.json"
|
|
SERIAL_SCRIPT = BASE / "scripts" / "vendor" / "serial_monitor.ps1"
|
|
SERIAL_SHA256 = "173584db05950b9842f2485eec5b33c0b16062f944216c283a18e038a9138a67"
|
|
EXECUTABLES = {
|
|
"ffprobe": ("ffprobe.exe", "ffprobe"),
|
|
"ffmpeg": ("ffmpeg.exe", "ffmpeg"),
|
|
"tshark": ("tshark.exe", "tshark"),
|
|
"dumpcap": ("dumpcap.exe", "dumpcap"),
|
|
"nmap": ("nmap.exe", "nmap"),
|
|
"binwalk": ("binwalk.exe", "binwalk"),
|
|
"powershell": ("powershell.exe", "pwsh.exe", "powershell", "pwsh"),
|
|
}
|
|
VERSION_FLAGS = {
|
|
"ffprobe": "-version",
|
|
"ffmpeg": "-version",
|
|
"binwalk": "--help",
|
|
"powershell": "-Version",
|
|
}
|
|
PCAP_FIELDS = (
|
|
"frame.number",
|
|
"frame.time_relative",
|
|
"frame.len",
|
|
"ip.src",
|
|
"ip.dst",
|
|
"ipv6.src",
|
|
"ipv6.dst",
|
|
"tcp.srcport",
|
|
"tcp.dstport",
|
|
"udp.srcport",
|
|
"udp.dstport",
|
|
"_ws.col.Protocol",
|
|
)
|
|
STREAM_FIELDS = (
|
|
"index",
|
|
"codec_name",
|
|
"codec_type",
|
|
"width",
|
|
"height",
|
|
"avg_frame_rate",
|
|
"r_frame_rate",
|
|
"sample_rate",
|
|
"channels",
|
|
"bit_rate",
|
|
)
|
|
FORMAT_FIELDS = ("format_name", "duration", "size", "bit_rate")
|
|
MEDIAINFO_FIELDS = {
|
|
"general": ("Format", "Duration", "FileSize", "OverallBitRate"),
|
|
"video": (
|
|
"Format",
|
|
"CodecID",
|
|
"Width",
|
|
"Height",
|
|
"FrameRate",
|
|
"FrameCount",
|
|
"BitRate",
|
|
"Duration",
|
|
),
|
|
"audio": ("Format", "CodecID", "SamplingRate", "Channels", "BitRate", "Duration"),
|
|
}
|
|
SECRET_KEYS = {
|
|
"password",
|
|
"passwd",
|
|
"pwd",
|
|
"secret",
|
|
"token",
|
|
"access_token",
|
|
"api_key",
|
|
"apikey",
|
|
"authorization",
|
|
"密码",
|
|
"口令",
|
|
}
|
|
|
|
|
|
class ValidationError(ValueError):
|
|
"""The message is an internal constant, never an untrusted argument."""
|
|
|
|
|
|
class ToolMissing(RuntimeError):
|
|
pass
|
|
|
|
|
|
class SafeParser(argparse.ArgumentParser):
|
|
def error(self, message):
|
|
# argparse normally repeats invalid arguments, which can contain secrets.
|
|
raise ValidationError("invalid_arguments_use_help")
|
|
|
|
|
|
def parse_args(argv=None):
|
|
p = SafeParser(description=__doc__, allow_abbrev=False)
|
|
p.add_argument(
|
|
"--profile",
|
|
help="JSON profile; tools maps tool_id to an existing executable path",
|
|
)
|
|
p.add_argument(
|
|
"--tool-root",
|
|
action="append",
|
|
default=[],
|
|
help="Trusted extracted-tool directory (repeatable)",
|
|
)
|
|
p.add_argument("--timeout", help="Process timeout, integer 1..3600 seconds")
|
|
p.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Validate and print a redacted plan without launching",
|
|
)
|
|
sub = p.add_subparsers(dest="command", required=True, parser_class=SafeParser)
|
|
sub.add_parser(
|
|
"list",
|
|
help="Read the tool catalog and discover local executables",
|
|
allow_abbrev=False,
|
|
)
|
|
doctor = sub.add_parser(
|
|
"doctor",
|
|
help="Check local availability; does not contact devices",
|
|
allow_abbrev=False,
|
|
)
|
|
doctor.add_argument(
|
|
"--check-versions",
|
|
action="store_true",
|
|
help="Explicitly run bounded version/help commands",
|
|
)
|
|
probe = sub.add_parser(
|
|
"probe",
|
|
help="Inspect local media or an RTSP URL from an environment variable",
|
|
allow_abbrev=False,
|
|
)
|
|
source = probe.add_mutually_exclusive_group(required=True)
|
|
source.add_argument("--input")
|
|
source.add_argument("--rtsp-env")
|
|
probe.add_argument("--transport", choices=("tcp", "udp"), default="tcp")
|
|
media = sub.add_parser(
|
|
"media-info",
|
|
help="Read local media metadata with a configured Windows MediaInfo.dll",
|
|
allow_abbrev=False,
|
|
)
|
|
media.add_argument("--input", required=True)
|
|
pcap = sub.add_parser(
|
|
"analyze-pcap",
|
|
help="Read a local capture using fixed non-payload fields",
|
|
allow_abbrev=False,
|
|
)
|
|
pcap.add_argument("--input", required=True)
|
|
pcap.add_argument("--max-packets", default="1000")
|
|
cap = sub.add_parser(
|
|
"capture",
|
|
help="Capture one host on an explicitly selected interface",
|
|
allow_abbrev=False,
|
|
)
|
|
cap.add_argument("--interface", required=True)
|
|
cap.add_argument("--host", required=True)
|
|
cap.add_argument("--duration", required=True)
|
|
cap.add_argument("--output", required=True)
|
|
scan = sub.add_parser(
|
|
"scan",
|
|
help="TCP connect scan: one IP and explicit numeric ports only",
|
|
allow_abbrev=False,
|
|
)
|
|
scan.add_argument("--host", required=True)
|
|
scan.add_argument("--ports", required=True)
|
|
firmware = sub.add_parser(
|
|
"firmware-scan",
|
|
help="Local Binwalk default signature scan; no extraction",
|
|
allow_abbrev=False,
|
|
)
|
|
firmware.add_argument("--input", required=True)
|
|
serial = sub.add_parser(
|
|
"serial", help="Bounded read-only Windows serial capture", allow_abbrev=False
|
|
)
|
|
serial.add_argument("--port", required=True)
|
|
serial.add_argument("--baud", required=True)
|
|
serial.add_argument("--duration", required=True)
|
|
serial.add_argument("--output", required=True)
|
|
serial.add_argument(
|
|
"--dtr-enable",
|
|
action="store_true",
|
|
help="Explicitly enable DTR to match the authorized device session; default off",
|
|
)
|
|
serial.add_argument(
|
|
"--rts-enable",
|
|
action="store_true",
|
|
help="Explicitly enable RTS to match the authorized device session; default off",
|
|
)
|
|
return p.parse_args(argv)
|
|
|
|
|
|
def validate_profile(profile):
|
|
if not isinstance(profile, dict) or not isinstance(profile.get("tools", {}), dict):
|
|
raise ValidationError("profile_must_be_object_with_tools_mapping")
|
|
|
|
def walk(value):
|
|
if isinstance(value, dict):
|
|
for key, item in value.items():
|
|
normalized = (
|
|
re.sub(r"([a-z])([A-Z])", r"\1_\2", str(key))
|
|
.lower()
|
|
.replace("-", "_")
|
|
)
|
|
is_secret = normalized in SECRET_KEYS or any(
|
|
normalized.endswith("_" + word) for word in SECRET_KEYS
|
|
)
|
|
if (
|
|
is_secret
|
|
and not normalized.endswith("_env")
|
|
and item not in (None, "")
|
|
):
|
|
raise ValidationError(
|
|
"plaintext_credentials_forbidden_use_env_reference"
|
|
)
|
|
walk(item)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
walk(item)
|
|
elif isinstance(value, str) and re.search(
|
|
r"(?i)rtsps?://|[a-z][a-z0-9+.-]*://[^/\s]*@|[?&](?:password|passwd|token|access_token|api_key|secret)=",
|
|
value,
|
|
):
|
|
raise ValidationError("credential_or_rtsp_url_forbidden_in_profile")
|
|
|
|
walk(profile)
|
|
for key, value in profile.get("tools", {}).items():
|
|
if key not in EXECUTABLES or not isinstance(value, str) or not value.strip():
|
|
raise ValidationError("tools_requires_known_tool_id_and_executable_path")
|
|
for section, keys in (
|
|
("runtimes", {"binwalk_python"}),
|
|
("libraries", {"mediainfo"}),
|
|
):
|
|
values = profile.get(section, {})
|
|
if not isinstance(values, dict) or any(
|
|
key not in keys or not isinstance(value, str) or not value.strip()
|
|
for key, value in values.items()
|
|
):
|
|
raise ValidationError("runtime_and_library_require_known_id_and_path_only")
|
|
return profile
|
|
|
|
|
|
def load_profile(path):
|
|
if path is None:
|
|
return {}
|
|
try:
|
|
profile = json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
|
except (OSError, ValueError):
|
|
raise ValidationError("profile_unreadable_or_invalid_json") from None
|
|
validate_profile(profile)
|
|
# Relative tool paths belong to the profile directory, not the caller's cwd.
|
|
for section in ("tools", "runtimes", "libraries"):
|
|
for key, value in profile.get(section, {}).items():
|
|
if not Path(value).is_absolute():
|
|
profile[section][key] = str(
|
|
(Path(path).resolve().parent / value).resolve()
|
|
)
|
|
return profile
|
|
|
|
|
|
def root_candidates(roots):
|
|
"""Only enumerate filenames; never open archives or run their contents."""
|
|
for value in roots:
|
|
root = Path(value).resolve()
|
|
if not root.is_dir():
|
|
raise ValidationError("tool_root_must_be_existing_directory")
|
|
count = 0
|
|
for current, dirs, files in os.walk(root, followlinks=False):
|
|
depth = len(Path(current).relative_to(root).parts)
|
|
dirs[:] = (
|
|
sorted(
|
|
d
|
|
for d in dirs
|
|
if not d.startswith(".") and not (Path(current) / d).is_symlink()
|
|
)
|
|
if depth < 4
|
|
else []
|
|
)
|
|
for name in sorted(files):
|
|
count += 1
|
|
if count > 20000:
|
|
return
|
|
yield Path(current) / name
|
|
|
|
|
|
def resolve_tool(tool_id, profile, roots):
|
|
names = EXECUTABLES[tool_id]
|
|
configured = profile.get("tools", {}).get(tool_id)
|
|
if configured:
|
|
path = Path(configured).resolve()
|
|
if path.name.lower() not in names:
|
|
raise ValidationError(
|
|
"unexpected_executable_name_no_installers_or_wrappers"
|
|
)
|
|
if not path.is_file():
|
|
raise ToolMissing(tool_id)
|
|
return str(path)
|
|
# Explicit roots precede PATH and require extracted, exact-name executables.
|
|
for path in root_candidates(roots):
|
|
if path.name.lower() in names and path.is_file():
|
|
return str(path.resolve())
|
|
for name in names:
|
|
found = shutil.which(name)
|
|
if found and Path(found).name.lower() in names:
|
|
return str(Path(found).resolve())
|
|
raise ToolMissing(tool_id)
|
|
|
|
|
|
def configured_local_path(value, tool_id, name_pattern):
|
|
if not isinstance(value, str) or not value:
|
|
raise ToolMissing(tool_id)
|
|
if (
|
|
"://" in value
|
|
or value.startswith(("\\\\", "//"))
|
|
or re.search(r"[\x00-\x1f]", value)
|
|
):
|
|
raise ValidationError("runtime_and_library_require_local_file")
|
|
path = Path(value).resolve()
|
|
if not re.fullmatch(name_pattern, path.name, re.IGNORECASE):
|
|
raise ValidationError("unexpected_runtime_or_library_name")
|
|
if not path.is_file():
|
|
raise ToolMissing(tool_id)
|
|
return str(path)
|
|
|
|
|
|
def command_prefix(tool_id, profile, roots):
|
|
if tool_id == "binwalk" and profile.get("runtimes", {}).get("binwalk_python"):
|
|
python = configured_local_path(
|
|
profile["runtimes"]["binwalk_python"],
|
|
"binwalk",
|
|
r"python(?:3(?:\.\d+)?)?(?:\.exe)?",
|
|
)
|
|
return [python, "-I", "-m", "binwalk"]
|
|
if tool_id == "mediainfo":
|
|
library = configured_local_path(
|
|
profile.get("libraries", {}).get("mediainfo"),
|
|
"mediainfo",
|
|
r"MediaInfo\.dll",
|
|
)
|
|
return [
|
|
sys.executable,
|
|
"-I",
|
|
str(Path(__file__).resolve()),
|
|
"_media-info-worker",
|
|
"--library",
|
|
library,
|
|
]
|
|
return [resolve_tool(tool_id, profile, roots)]
|
|
|
|
|
|
def bounded_int(value, low, high, error):
|
|
if not re.fullmatch(r"[0-9]+", str(value)) or not low <= int(value) <= high:
|
|
raise ValidationError(error)
|
|
return int(value)
|
|
|
|
|
|
def host_ip(value):
|
|
try:
|
|
if "%" in value:
|
|
raise ValueError
|
|
address = ipaddress.ip_address(value)
|
|
if (
|
|
address.is_multicast
|
|
or address.is_unspecified
|
|
or str(address) == "255.255.255.255"
|
|
):
|
|
raise ValueError
|
|
return str(address)
|
|
except ValueError:
|
|
raise ValidationError(
|
|
"one_explicit_unicast_ip_required_no_hostname_or_cidr"
|
|
) from None
|
|
|
|
|
|
def local_input(value):
|
|
if (
|
|
re.search(r"[\x00-\x1f]", value)
|
|
or "://" in value
|
|
or value.startswith(("\\\\", "//"))
|
|
):
|
|
raise ValidationError("local_regular_file_required")
|
|
path = Path(value).resolve()
|
|
if not path.is_file():
|
|
raise ValidationError("local_input_file_missing")
|
|
return str(path)
|
|
|
|
|
|
def new_output(value):
|
|
if (
|
|
re.search(r"[\x00-\x1f]", value)
|
|
or "://" in value
|
|
or value.startswith(("\\\\", "//"))
|
|
):
|
|
raise ValidationError("local_output_path_required")
|
|
path = Path(value).resolve()
|
|
if path.exists() or not path.parent.is_dir():
|
|
raise ValidationError("output_must_be_new_file_in_existing_directory")
|
|
return str(path)
|
|
|
|
|
|
def redact(text, secrets=()):
|
|
value = str(text)
|
|
for secret in sorted((str(s) for s in secrets if s), key=len, reverse=True):
|
|
value = value.replace(secret, "[REDACTED]")
|
|
value = re.sub(r"(?i)rtsps?://[^\s\"<>]+", "[RTSP_URL_REDACTED]", value)
|
|
value = re.sub(
|
|
r"(?i)(password|passwd|pwd|token|secret|authorization)\s*[:=]\s*[^\s,;]+",
|
|
r"\1=[REDACTED]",
|
|
value,
|
|
)
|
|
return value
|
|
|
|
|
|
@dataclass
|
|
class Plan:
|
|
command: str
|
|
tool_id: str
|
|
argv: list[str] = field(repr=False)
|
|
timeout: int
|
|
metadata: dict = field(default_factory=dict)
|
|
secrets: tuple[str, ...] = field(default=(), repr=False)
|
|
sensitive_argument: str | None = field(default=None, repr=False)
|
|
|
|
def public(self):
|
|
# Only the RTSP argument contains values taken from its credentials/query.
|
|
# Replacing a short token in fixed flags or executable paths corrupts plans.
|
|
safe = [
|
|
"[RTSP_URL_FROM_ENV]" if a == self.sensitive_argument else redact(a)
|
|
for a in self.argv
|
|
]
|
|
return {
|
|
"command": self.command,
|
|
"tool_id": self.tool_id,
|
|
"argv": safe,
|
|
"timeout_seconds": self.timeout,
|
|
**self.metadata,
|
|
}
|
|
|
|
|
|
def build_plan(args, profile, environ):
|
|
validate_profile(profile)
|
|
timeout = (
|
|
bounded_int(args.timeout, 1, 3600, "timeout_must_be_1_to_3600_seconds")
|
|
if args.timeout is not None
|
|
else 60
|
|
)
|
|
command = args.command
|
|
secrets = ()
|
|
sensitive = None
|
|
meta = {}
|
|
if command == "probe":
|
|
tool_id = "ffprobe"
|
|
entries = (
|
|
"stream=" + ",".join(STREAM_FIELDS) + ":format=" + ",".join(FORMAT_FIELDS)
|
|
)
|
|
tail = [
|
|
"-v",
|
|
"quiet",
|
|
"-show_entries",
|
|
entries,
|
|
"-of",
|
|
"json",
|
|
"-probesize",
|
|
"5000000",
|
|
"-analyzeduration",
|
|
"5000000",
|
|
]
|
|
if args.rtsp_env:
|
|
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", args.rtsp_env):
|
|
raise ValidationError("invalid_environment_variable_name")
|
|
url = environ.get(args.rtsp_env, "")
|
|
try:
|
|
parsed = urlsplit(url)
|
|
if (
|
|
not url
|
|
or re.search(r"[\x00-\x20\x7f]", url)
|
|
or parsed.scheme not in ("rtsp", "rtsps")
|
|
or not parsed.hostname
|
|
or parsed.port == 0
|
|
or parsed.fragment
|
|
):
|
|
raise ValueError
|
|
# Accessing .port validates the valid range as well.
|
|
_ = parsed.port
|
|
except ValueError:
|
|
raise ValidationError(
|
|
"environment_must_contain_complete_rtsp_url"
|
|
) from None
|
|
secrets = tuple(
|
|
filter(
|
|
None,
|
|
(
|
|
url,
|
|
parsed.username,
|
|
parsed.password,
|
|
unquote(parsed.password or ""),
|
|
*(v for _, v in parse_qsl(parsed.query)),
|
|
),
|
|
)
|
|
)
|
|
sensitive = url
|
|
meta["rtsp_env"] = args.rtsp_env
|
|
tail += [
|
|
"-rtsp_transport",
|
|
args.transport,
|
|
"-rw_timeout",
|
|
str(timeout * 1000000),
|
|
"-i",
|
|
url,
|
|
]
|
|
else:
|
|
tail += ["-protocol_whitelist", "file", "-i", local_input(args.input)]
|
|
elif command == "media-info":
|
|
tool_id = "mediainfo"
|
|
tail = ["--input", local_input(args.input)]
|
|
elif command == "analyze-pcap":
|
|
tool_id = "tshark"
|
|
limit = bounded_int(
|
|
args.max_packets, 1, 10000, "max_packets_must_be_1_to_10000"
|
|
)
|
|
tail = [
|
|
"-n",
|
|
"-r",
|
|
local_input(args.input),
|
|
"-c",
|
|
str(limit),
|
|
"-T",
|
|
"fields",
|
|
"-E",
|
|
"header=y",
|
|
"-E",
|
|
"separator=/t",
|
|
"-E",
|
|
"quote=d",
|
|
]
|
|
for item in PCAP_FIELDS:
|
|
tail += ["-e", item]
|
|
meta["max_packets"] = limit
|
|
elif command in ("capture", "serial"):
|
|
duration = bounded_int(
|
|
args.duration, 1, 600, "duration_must_be_1_to_600_seconds"
|
|
)
|
|
timeout = duration + 10 if args.timeout is None else timeout
|
|
if timeout < duration + 5:
|
|
raise ValidationError("timeout_must_exceed_duration_by_at_least_5_seconds")
|
|
output = new_output(args.output)
|
|
meta.update(
|
|
{
|
|
"output": output,
|
|
"duration_seconds": duration,
|
|
"artifact_may_contain_sensitive_data": True,
|
|
}
|
|
)
|
|
if command == "capture":
|
|
tool_id = "dumpcap"
|
|
interface = args.interface
|
|
if (
|
|
not interface
|
|
or interface.startswith("-")
|
|
or "://" in interface
|
|
or re.search(r"[\x00-\x1f]", interface)
|
|
):
|
|
raise ValidationError("explicit_local_interface_required")
|
|
ip = host_ip(args.host)
|
|
tail = [
|
|
"-q",
|
|
"-i",
|
|
interface,
|
|
"-f",
|
|
"host " + ip,
|
|
"-s",
|
|
"256",
|
|
"-a",
|
|
"duration:" + str(duration),
|
|
"-a",
|
|
"filesize:102400",
|
|
"-w",
|
|
output,
|
|
]
|
|
meta["host"] = ip
|
|
else:
|
|
tool_id = "powershell"
|
|
if not re.fullmatch(r"COM[1-9][0-9]{0,3}", args.port, re.IGNORECASE):
|
|
raise ValidationError("explicit_windows_com_port_required")
|
|
baud = bounded_int(args.baud, 1, 4000000, "baud_must_be_1_to_4000000")
|
|
if Path(output + ".raw.bin").exists():
|
|
raise ValidationError("serial_raw_output_already_exists")
|
|
if (
|
|
not SERIAL_SCRIPT.is_file()
|
|
or hashlib.sha256(SERIAL_SCRIPT.read_bytes()).hexdigest()
|
|
!= SERIAL_SHA256
|
|
):
|
|
raise ValidationError(
|
|
"vendor_serial_script_missing_or_checksum_changed"
|
|
)
|
|
tail = [
|
|
"-NoLogo",
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-File",
|
|
str(SERIAL_SCRIPT),
|
|
"-Port",
|
|
args.port.upper(),
|
|
"-BaudRate",
|
|
str(baud),
|
|
"-DurationSeconds",
|
|
str(duration),
|
|
"-LogPath",
|
|
output,
|
|
]
|
|
if args.dtr_enable:
|
|
tail.append("-DtrEnable")
|
|
if args.rts_enable:
|
|
tail.append("-RtsEnable")
|
|
meta["raw_output"] = output + ".raw.bin"
|
|
meta.update(dtr_enabled=args.dtr_enable, rts_enabled=args.rts_enable)
|
|
elif command == "scan":
|
|
tool_id = "nmap"
|
|
ip = host_ip(args.host)
|
|
if not re.fullmatch(r"[0-9]+(?:,[0-9]+)*", args.ports):
|
|
raise ValidationError("ports_require_explicit_comma_separated_numbers")
|
|
ports = sorted(
|
|
{
|
|
bounded_int(x, 1, 65535, "port_out_of_range")
|
|
for x in args.ports.split(",")
|
|
}
|
|
)
|
|
if len(ports) > 128:
|
|
raise ValidationError("at_most_128_explicit_ports_allowed")
|
|
tail = [
|
|
"-sT",
|
|
"-n",
|
|
"-Pn",
|
|
"--max-retries",
|
|
"1",
|
|
"--host-timeout",
|
|
str(max(1, timeout - 2)) + "s",
|
|
"-p",
|
|
",".join(map(str, ports)),
|
|
"-oX",
|
|
"-",
|
|
]
|
|
if ":" in ip:
|
|
tail += ["-6"]
|
|
tail += [ip]
|
|
meta.update({"host": ip, "ports": ports})
|
|
elif command == "firmware-scan":
|
|
tool_id = "binwalk"
|
|
# Both Binwalk 2.x and 3.x default to signature scanning. No -e/-M.
|
|
tail = [local_input(args.input)]
|
|
else:
|
|
raise ValidationError("unsupported_execution_command")
|
|
prefix = command_prefix(tool_id, profile, args.tool_root)
|
|
return Plan(command, tool_id, prefix + tail, timeout, meta, secrets, sensitive)
|
|
|
|
|
|
def safe_data(plan, stdout):
|
|
if plan.command == "probe":
|
|
source = json.loads(stdout)
|
|
data = {
|
|
"streams": [
|
|
{
|
|
k: v
|
|
for k, v in row.items()
|
|
if k in STREAM_FIELDS and isinstance(v, (str, int, float))
|
|
}
|
|
for row in source.get("streams", [])
|
|
if isinstance(row, dict)
|
|
],
|
|
"format": {
|
|
k: v
|
|
for k, v in source.get("format", {}).items()
|
|
if k in FORMAT_FIELDS and isinstance(v, (str, int, float))
|
|
},
|
|
}
|
|
# Redact decoded values before JSON escaping (passwords may include quotes).
|
|
for row in data["streams"] + [data["format"]]:
|
|
for key, value in row.items():
|
|
if isinstance(value, str):
|
|
# Numeric measurements are structured data, not echoed URL data.
|
|
# Preserve valid numbers even when a query token is e.g. "1".
|
|
numeric = key not in (
|
|
"codec_name",
|
|
"codec_type",
|
|
"format_name",
|
|
) and re.fullmatch(r"-?\d+(?:\.\d+)?(?:/\d+)?", value)
|
|
row[key] = value if numeric else redact(value, plan.secrets)
|
|
return data
|
|
if plan.command == "media-info":
|
|
source = json.loads(stdout)
|
|
data = {"version": redact(str(source.get("version", "")))}
|
|
for group, fields in MEDIAINFO_FIELDS.items():
|
|
rows = (
|
|
[source.get(group, {})] if group == "general" else source.get(group, [])
|
|
)
|
|
filtered = [
|
|
{
|
|
key: redact(str(value))
|
|
for key, value in row.items()
|
|
if key in fields and isinstance(value, (str, int, float))
|
|
}
|
|
for row in rows[:100]
|
|
if isinstance(row, dict)
|
|
]
|
|
data[group] = (
|
|
filtered[0]
|
|
if group == "general" and filtered
|
|
else {}
|
|
if group == "general"
|
|
else filtered
|
|
)
|
|
return data
|
|
if plan.command == "analyze-pcap":
|
|
rows = csv.DictReader(io.StringIO(stdout), delimiter="\t")
|
|
return {
|
|
"packets": [
|
|
{
|
|
k: redact(str(v or ""), plan.secrets)
|
|
for k, v in row.items()
|
|
if k in PCAP_FIELDS
|
|
}
|
|
for _, row in zip(range(plan.metadata["max_packets"]), rows)
|
|
]
|
|
}
|
|
if plan.command == "scan":
|
|
root = ET.fromstring(stdout)
|
|
requested_ports = list(plan.metadata["ports"])
|
|
hosts = []
|
|
for host in root.findall("host"):
|
|
state = host.find("status")
|
|
ports = []
|
|
for port in host.findall("ports/port"):
|
|
status = port.find("state")
|
|
ports.append(
|
|
{
|
|
"port": port.get("portid"),
|
|
"protocol": port.get("protocol"),
|
|
"state": status.get("state") if status is not None else None,
|
|
}
|
|
)
|
|
aggregates = []
|
|
for group in host.findall("ports/extraports"):
|
|
reasons = [
|
|
{
|
|
"reason": reason.get("reason"),
|
|
"count": int(reason.get("count", "0")),
|
|
}
|
|
for reason in group.findall("extrareasons")
|
|
]
|
|
aggregates.append(
|
|
{
|
|
"state": group.get("state"),
|
|
"count": int(group.get("count", "0")),
|
|
"reasons": reasons,
|
|
}
|
|
)
|
|
# Aggregated groups do not identify the state of individual ports.
|
|
# Count only explicit TCP port states when reporting attribution.
|
|
reported = {
|
|
int(port["port"])
|
|
for port in ports
|
|
if port["protocol"] == "tcp" and port["state"]
|
|
}
|
|
missing = [port for port in requested_ports if port not in reported]
|
|
coverage = (
|
|
"complete"
|
|
if not missing
|
|
else "partial"
|
|
if len(missing) < len(requested_ports)
|
|
else "none"
|
|
)
|
|
hosts.append(
|
|
{
|
|
"state": state.get("state") if state is not None else None,
|
|
"ports": ports,
|
|
"aggregate_states": aggregates,
|
|
"ports_not_individually_reported": missing,
|
|
"individual_port_coverage": coverage,
|
|
}
|
|
)
|
|
return {
|
|
"requested_ports": requested_ports,
|
|
"host_results_present": bool(hosts),
|
|
"hosts": hosts,
|
|
}
|
|
if plan.command == "firmware-scan":
|
|
return {
|
|
"signature_report": redact(stdout[:64000]),
|
|
"output_truncated": len(stdout) > 64000,
|
|
}
|
|
return {}
|
|
|
|
|
|
def process_lifecycle():
|
|
import importlib.util
|
|
|
|
name = "_artemis_device_test_process_lifecycle"
|
|
if name not in sys.modules:
|
|
spec = importlib.util.spec_from_file_location(
|
|
name, Path(__file__).with_name("process_lifecycle.py")
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[name] = module
|
|
spec.loader.exec_module(module)
|
|
return sys.modules[name]
|
|
|
|
|
|
PROCESS = process_lifecycle()
|
|
|
|
|
|
def _run_in_windows_job(argv, **kwargs):
|
|
return PROCESS.run(argv, **kwargs)
|
|
|
|
|
|
def run_process(argv, tool_id, **kwargs):
|
|
def run(**options):
|
|
if os.name == "nt":
|
|
return _run_in_windows_job(argv, **options)
|
|
# This plugin targets Windows; fail closed rather than claim containment elsewhere.
|
|
raise PROCESS.ManagedProcessError("process_ownership_requires_windows")
|
|
|
|
if tool_id != "binwalk":
|
|
return run(**kwargs)
|
|
env = dict(kwargs.pop("env", os.environ))
|
|
env.pop("PYTHONPATH", None)
|
|
env.pop("PYTHONHOME", None)
|
|
with tempfile.TemporaryDirectory(prefix="artemis-binwalk-") as config:
|
|
env["XDG_CONFIG_HOME"] = config
|
|
return run(env=env, **kwargs)
|
|
|
|
|
|
def media_info_worker(argv):
|
|
"""Private fixed DLL worker; parent process owns the hard timeout."""
|
|
p = SafeParser(allow_abbrev=False)
|
|
p.add_argument("--library", required=True)
|
|
source = p.add_mutually_exclusive_group(required=True)
|
|
source.add_argument("--input")
|
|
source.add_argument("--version", action="store_true")
|
|
try:
|
|
args = p.parse_args(argv)
|
|
library = configured_local_path(args.library, "mediainfo", r"MediaInfo\.dll")
|
|
media = local_input(args.input) if args.input else None
|
|
import ctypes as C
|
|
|
|
lib = C.WinDLL(library)
|
|
signatures = {
|
|
"New": ([], C.c_void_p),
|
|
"Delete": ([C.c_void_p], None),
|
|
"Option": ([C.c_void_p, C.c_wchar_p, C.c_wchar_p], C.c_wchar_p),
|
|
"Open": ([C.c_void_p, C.c_wchar_p], C.c_size_t),
|
|
"Close": ([C.c_void_p], None),
|
|
"Get": (
|
|
[C.c_void_p, C.c_int, C.c_size_t, C.c_wchar_p, C.c_int, C.c_int],
|
|
C.c_wchar_p,
|
|
),
|
|
"Count_Get": ([C.c_void_p, C.c_int, C.c_size_t], C.c_size_t),
|
|
}
|
|
for name, (argtypes, restype) in signatures.items():
|
|
function = getattr(lib, "MediaInfo_" + name)
|
|
function.argtypes, function.restype = argtypes, restype
|
|
# MediaInfo 21.09 enables this option for every nonempty value,
|
|
# including "No". Require its explicit disabled readback before parsing.
|
|
lib.MediaInfo_Option(None, "Internet", "")
|
|
if lib.MediaInfo_Option(None, "Internet_Get", "") != "":
|
|
raise ValueError
|
|
version = lib.MediaInfo_Option(None, "Info_Version", "") or ""
|
|
if args.version:
|
|
print(redact(version))
|
|
return 0
|
|
handle = lib.MediaInfo_New()
|
|
if not handle:
|
|
raise ValueError
|
|
try:
|
|
lib.MediaInfo_Option(handle, "Internet", "")
|
|
if lib.MediaInfo_Option(handle, "Internet_Get", "") != "":
|
|
raise ValueError
|
|
if lib.MediaInfo_Open(handle, media) == 0:
|
|
raise ValueError
|
|
data = {"version": version}
|
|
for kind, group in enumerate(("general", "video", "audio")):
|
|
count = min(
|
|
100, lib.MediaInfo_Count_Get(handle, kind, C.c_size_t(-1).value)
|
|
)
|
|
rows = [
|
|
{
|
|
key: lib.MediaInfo_Get(handle, kind, index, key, 1, 0) or ""
|
|
for key in MEDIAINFO_FIELDS[group]
|
|
}
|
|
for index in range(count)
|
|
]
|
|
data[group] = (
|
|
rows[0]
|
|
if group == "general" and rows
|
|
else {}
|
|
if group == "general"
|
|
else rows
|
|
)
|
|
print(
|
|
json.dumps(
|
|
safe_data(Plan("media-info", "mediainfo", [], 1), json.dumps(data)),
|
|
ensure_ascii=True,
|
|
)
|
|
)
|
|
return 0
|
|
finally:
|
|
lib.MediaInfo_Close(handle)
|
|
lib.MediaInfo_Delete(handle)
|
|
except (ValidationError, ToolMissing, OSError, ValueError, AttributeError):
|
|
# Never return exception text from native libraries or media metadata.
|
|
print(json.dumps({"error_code": "mediainfo_library_or_input_unavailable"}))
|
|
return 1
|
|
|
|
|
|
def execute(plan):
|
|
result = {
|
|
"execution_status": "not_started",
|
|
"test_verdict": "not_evaluated",
|
|
**plan.public(),
|
|
}
|
|
child_env = os.environ.copy()
|
|
# FFREPORT can silently persist full RTSP command lines even with quiet logs.
|
|
child_env.pop("FFREPORT", None)
|
|
if "rtsp_env" in plan.metadata:
|
|
child_env.pop(plan.metadata["rtsp_env"], None)
|
|
try:
|
|
completed = run_process(
|
|
list(plan.argv),
|
|
plan.tool_id,
|
|
shell=False,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL
|
|
if plan.command in ("capture", "serial")
|
|
else subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=plan.timeout,
|
|
check=False,
|
|
env=child_env,
|
|
)
|
|
result["returncode"] = completed.returncode
|
|
result["execution_status"] = (
|
|
"completed" if completed.returncode == 0 else "failed"
|
|
)
|
|
result["stderr_suppressed"] = True
|
|
if completed.returncode == 0:
|
|
try:
|
|
result["data"] = safe_data(plan, completed.stdout or "")
|
|
except (ValueError, TypeError, AttributeError, ET.ParseError):
|
|
result["execution_status"] = "output_invalid"
|
|
result["error_code"] = "tool_output_not_expected_format"
|
|
else:
|
|
result["error_code"] = "tool_returned_nonzero_sensitive_output_suppressed"
|
|
except subprocess.TimeoutExpired:
|
|
result.update(
|
|
execution_status="timed_out",
|
|
error_code="process_timeout_partial_output_suppressed",
|
|
)
|
|
except PROCESS.ManagedProcessError as exc:
|
|
status = (
|
|
"cleanup_unconfirmed"
|
|
if exc.error_code == "process_cleanup_unconfirmed"
|
|
else "output_limit"
|
|
if exc.error_code == "process_output_limit"
|
|
else "unknown"
|
|
if exc.may_have_started
|
|
else "launch_failed"
|
|
)
|
|
result.update(
|
|
execution_status=status,
|
|
error_code=exc.error_code,
|
|
may_have_started=exc.may_have_started,
|
|
)
|
|
except OSError:
|
|
# Includes config-directory cleanup after execution; absence of phase proof is unknown.
|
|
result.update(
|
|
execution_status="unknown",
|
|
error_code="process_io_unconfirmed",
|
|
may_have_started=True,
|
|
)
|
|
return result
|
|
|
|
|
|
def inventory(profile, roots, check_versions=False):
|
|
validate_profile(profile)
|
|
catalog = json.loads(CATALOG.read_text(encoding="utf-8"))
|
|
rows = []
|
|
for entry in catalog["tools"]:
|
|
row = dict(entry)
|
|
tool_id = entry["tool_id"]
|
|
if tool_id not in EXECUTABLES and tool_id != "mediainfo":
|
|
row["availability"] = {
|
|
"mcp": "mcp-check-required",
|
|
"orchestrator": "orchestrator-context",
|
|
}.get(entry.get("interface"), "manual-interface")
|
|
else:
|
|
try:
|
|
prefix = command_prefix(tool_id, profile, roots)
|
|
if tool_id == "mediainfo":
|
|
row.update(availability="library-found", library=prefix[-1])
|
|
elif tool_id == "binwalk" and len(prefix) > 1:
|
|
row.update(
|
|
availability="python-module-configured",
|
|
executable=prefix[0],
|
|
module="binwalk",
|
|
)
|
|
else:
|
|
row.update(availability="executable-found", executable=prefix[0])
|
|
if check_versions:
|
|
# PowerShell -Version requires a version argument; use help only.
|
|
flag = (
|
|
"-?"
|
|
if tool_id == "powershell"
|
|
else VERSION_FLAGS.get(tool_id, "--version")
|
|
)
|
|
try:
|
|
proc = run_process(
|
|
prefix + [flag],
|
|
tool_id,
|
|
shell=False,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=5,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
check=False,
|
|
)
|
|
first_line = next(
|
|
(
|
|
line.strip()
|
|
for line in (proc.stdout or "").splitlines()
|
|
if line.strip()
|
|
),
|
|
"",
|
|
)
|
|
row["version_check"] = {
|
|
"execution_status": "completed"
|
|
if proc.returncode == 0
|
|
else "failed",
|
|
"returncode": proc.returncode,
|
|
"first_line": redact(first_line[:240]),
|
|
}
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
row["version_check"] = {"execution_status": "unavailable"}
|
|
except ToolMissing:
|
|
patterns = entry.get("package_name_patterns", [])
|
|
found = next(
|
|
(
|
|
p.name
|
|
for p in root_candidates(roots)
|
|
if any(
|
|
fragment.lower() in p.name.lower() for fragment in patterns
|
|
)
|
|
and p.suffix.lower() in (".zip", ".7z")
|
|
),
|
|
None,
|
|
)
|
|
row["availability"] = "package-only" if found else "missing"
|
|
if found:
|
|
row["package_filename"] = found
|
|
rows.append(row)
|
|
return {
|
|
"execution_status": "completed",
|
|
"test_verdict": "not_evaluated",
|
|
"tools": rows,
|
|
"note": "Executable discovery is not an installation, driver, privilege, connection or test-pass check.",
|
|
}
|
|
|
|
|
|
def main(argv=None):
|
|
try:
|
|
args = parse_args(argv)
|
|
profile = load_profile(args.profile)
|
|
if args.command in ("list", "doctor"):
|
|
result = inventory(
|
|
profile,
|
|
args.tool_root,
|
|
check_versions=bool(
|
|
getattr(args, "check_versions", False) and not args.dry_run
|
|
),
|
|
)
|
|
else:
|
|
plan = build_plan(args, profile, os.environ)
|
|
result = (
|
|
{
|
|
"execution_status": "planned",
|
|
"test_verdict": "not_evaluated",
|
|
**plan.public(),
|
|
}
|
|
if args.dry_run
|
|
else execute(plan)
|
|
)
|
|
code = 0 if result["execution_status"] in ("planned", "completed") else 1
|
|
except ValidationError as error:
|
|
result = {
|
|
"execution_status": "invalid_arguments",
|
|
"test_verdict": "not_evaluated",
|
|
"error_code": str(error),
|
|
}
|
|
code = 2
|
|
except ToolMissing as error:
|
|
result = {
|
|
"execution_status": "tool_missing",
|
|
"test_verdict": "not_evaluated",
|
|
"tool_id": str(error),
|
|
"error_code": "configure_trusted_executable_via_profile_tools_path_or_tool_root",
|
|
}
|
|
code = 3
|
|
except (OSError, ValueError):
|
|
result = {
|
|
"execution_status": "configuration_error",
|
|
"test_verdict": "not_evaluated",
|
|
"error_code": "local_file_unreadable_or_invalid",
|
|
}
|
|
code = 2
|
|
print(json.dumps(result, ensure_ascii=True, indent=2))
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(
|
|
media_info_worker(sys.argv[2:])
|
|
if sys.argv[1:2] == ["_media-info-worker"]
|
|
else main()
|
|
)
|