First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
This commit is contained in:
Executable
+911
@@ -0,0 +1,911 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# ARTEX host triage — a read-only responder helper for a suspected ARTEX host.
|
||||
#
|
||||
# The rest of detections/ serves defenders who run a SIEM (Sigma), a network
|
||||
# sensor (Suricata), or a threat-intel platform (the MISP / CSV indicators). This
|
||||
# script serves the other responder: the one standing at a single suspect host's
|
||||
# shell, with no SIEM, who needs to answer "did ARTEX run here?" from local state.
|
||||
# It operationalizes the same indicators the rest of the directory ships, plus the
|
||||
# three host/DB indicators the indicator list deliberately carries WITHOUT a Sigma
|
||||
# rule because they are not log- or network-observable and can only be checked on
|
||||
# the box itself (see detections/indicators/artex_indicators.csv — the rows whose
|
||||
# `rule` column is empty: server-listen-port, recording-proxy-endpoint,
|
||||
# postgres-exploration-schema).
|
||||
#
|
||||
# It is a TRIAGE LEAD generator, not an alerting rule. Every check is grounded in
|
||||
# a string or path verified in this repository's source, and every finding carries
|
||||
# the same honest caveat the matching Sigma rule or indicator row carries: ports
|
||||
# are configurable, the MITM CA filename is shared with standalone mitmproxy, the
|
||||
# guard marker also appears in logs that merely quote this guide. A hit is a reason
|
||||
# to look closer, never an attribution on its own, and the absence of every finding
|
||||
# is NOT a clean bill of health — an operator can rename the binary, move the data
|
||||
# directory, or change the ports.
|
||||
#
|
||||
# What it checks (each cites the source it is grounded in):
|
||||
# 1. Listening ports :8787 (admin UI) and 127.0.0.1:8788 (recording proxy)
|
||||
# — defaults of the --addr / --proxy flags in
|
||||
# cmd/artex/main.go. Parsed from `ss`/`netstat`/`lsof`
|
||||
# on the live host, or from --ports-from FILE.
|
||||
# 2. Recording-proxy MITM <data-dir>/traffic/_ca/mitmproxy-ca-cert.pem and the
|
||||
# CA + stores sibling _index/index.sqlite and _blobs/ the recorder
|
||||
# writes on first start (traffic/traffic.go; the data
|
||||
# dir default is data/ next to the binary — see
|
||||
# cmd/artex/main.go). The CA is the trust anchor of an
|
||||
# adversary-in-the-middle traffic recorder (ATT&CK
|
||||
# T1557).
|
||||
# 3. Log markers the enrichment prober UA `artex-enrich/1.0`
|
||||
# (enrich/enrich.go), the self-update egress UA
|
||||
# `artex-selfupdate` (selfupdate/github.go), and the
|
||||
# platform-guard audit marker (guard/guard.go) in the
|
||||
# log file(s) you point it at. Rotated logs that
|
||||
# logrotate compressed as .gz/.bz2/.xz are read
|
||||
# through their standard-library codec so their
|
||||
# history is scanned too; a format with no stdlib
|
||||
# codec (.zst/.lz4) is reported as skipped, never
|
||||
# silently treated as clean.
|
||||
# 4. PostgreSQL schema the dual-graph exploration tables (exploration_nodes /
|
||||
# _edges / _anchors with assets / companies / activity
|
||||
# and the agent_prompts seed) in the ARTEX store
|
||||
# (db/schema.sql). Run against a DSN with `psql` if
|
||||
# available; otherwise the script prints the exact
|
||||
# read-only query for you to run by hand.
|
||||
# 5. Process env injection a running process whose environment carries the
|
||||
# recording proxy (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY)
|
||||
# together with a toolchain CA-trust var (SSL_CERT_FILE /
|
||||
# CURL_CA_BUNDLE / REQUESTS_CA_BUNDLE / GIT_SSL_CAINFO /
|
||||
# NODE_EXTRA_CA_CERTS) pointing at a mitmproxy-ca-cert.pem.
|
||||
# ARTEX injects exactly these into every worker tool it
|
||||
# spawns (agent/worker.go proxyEnv, asserted by
|
||||
# agent/proxyenv_test.go). The variable NAMES are
|
||||
# hard-coded in the source, so this tell survives an
|
||||
# operator renaming the binary or changing the ports —
|
||||
# a stronger signal than the bare listen port. Read from
|
||||
# /proc on the live Linux host, or from --proc-from FILE.
|
||||
#
|
||||
# Safety: pure Python standard library, no network, no writes anywhere except the
|
||||
# self-test's own temporary directory. It reads host state (open ports, a data
|
||||
# directory, log files, the environments of running processes via /proc, and — only
|
||||
# if you pass a DSN — the database) and prints what it found. Use it only on a host
|
||||
# you own or are authorized in writing to inspect.
|
||||
#
|
||||
# Usage:
|
||||
# detections/triage/artex_host_triage.py --data-dir /opt/artex/data \
|
||||
# --log /var/log/syslog --log-dir /var/log/artex
|
||||
# detections/triage/artex_host_triage.py --pg-dsn "$ARTEX_PG_DSN"
|
||||
# detections/triage/artex_host_triage.py --proc-from proc_env_dump.txt # offline
|
||||
# detections/triage/artex_host_triage.py --self-test # reproducible fixture test
|
||||
# detections/triage/artex_host_triage.py --json # machine-readable findings
|
||||
#
|
||||
# Exit code: 0 by default (triage, not a gate). With --exit-code, exits 1 if any
|
||||
# finding fired. --self-test exits non-zero on any self-test failure.
|
||||
|
||||
import argparse
|
||||
import bz2
|
||||
import gzip
|
||||
import json
|
||||
import lzma
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# --- grounded constants (every value is verified in this repository's source) ---
|
||||
|
||||
# Default listen / recording-proxy ports (cmd/artex/main.go --addr / --proxy).
|
||||
SERVER_PORT = 8787
|
||||
PROXY_HOST = "127.0.0.1"
|
||||
PROXY_PORT = 8788
|
||||
|
||||
# Recording-proxy artifacts under <data-dir>/traffic/ (traffic/traffic.go;
|
||||
# server/manager.go opens traffic.Open(filepath.Join(dir, "traffic"), ...)).
|
||||
TRAFFIC_SUBDIR = "traffic"
|
||||
CA_RELPATH = os.path.join("_ca", "mitmproxy-ca-cert.pem")
|
||||
INDEX_RELPATH = os.path.join("_index", "index.sqlite")
|
||||
BLOBS_RELDIR = "_blobs"
|
||||
|
||||
# Log markers. The guard marker is the original (untranslated) framing string the
|
||||
# platform guard writes to the audit log on a blocked tool call (guard/guard.go);
|
||||
# it is kept verbatim here because that is the exact byte sequence a responder
|
||||
# greps for, and translating it would stop the match.
|
||||
LOG_MARKERS = [
|
||||
{
|
||||
"value": "artex-enrich/1.0",
|
||||
"title": "enrichment prober User-Agent",
|
||||
"source": "enrich/enrich.go",
|
||||
"severity": "high",
|
||||
"caveat": "An operator can change the User-Agent; absence is not safety.",
|
||||
},
|
||||
{
|
||||
"value": "artex-selfupdate",
|
||||
"title": "self-update egress User-Agent",
|
||||
"source": "selfupdate/github.go",
|
||||
"severity": "medium",
|
||||
"caveat": "Seen in outbound logs from a host running ARTEX; the string is configurable.",
|
||||
},
|
||||
{
|
||||
"value": "【ARTEX 平台管控·非目标防御】",
|
||||
"title": "platform-guard audit-log framing marker",
|
||||
"source": "guard/guard.go",
|
||||
"severity": "high",
|
||||
"caveat": "Also appears in logs that merely quote this defense guide or the ARTEX source.",
|
||||
},
|
||||
]
|
||||
|
||||
# Dual-graph exploration schema fingerprint (db/schema.sql). Their presence
|
||||
# together is the host-forensic tell; any one table name is generic.
|
||||
SCHEMA_TABLES = [
|
||||
"exploration_nodes",
|
||||
"exploration_edges",
|
||||
"exploration_anchors",
|
||||
"assets",
|
||||
"companies",
|
||||
"activity",
|
||||
"agent_prompts",
|
||||
]
|
||||
|
||||
# Recording-proxy environment injection into spawned worker tools (agent/worker.go
|
||||
# proxyEnv; asserted by agent/proxyenv_test.go). ARTEX routes every worker tool's
|
||||
# traffic through the recording MITM proxy and, when a CA is present, makes the
|
||||
# toolchain trust it — by setting these exact variables in the subprocess env. The
|
||||
# variable NAMES are hard-coded in worker.go (only the values are configurable), so
|
||||
# a tool process carrying a recording-proxy address in a proxy var AND a CA var
|
||||
# pointing at a mitmproxy-ca-cert.pem is a far more specific tell than the bare
|
||||
# listen port: it survives the operator renaming the binary or moving the data dir.
|
||||
PROXY_ENV_VARS = (
|
||||
"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy",
|
||||
)
|
||||
CA_ENV_VARS = (
|
||||
"SSL_CERT_FILE", "CURL_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "GIT_SSL_CAINFO", "NODE_EXTRA_CA_CERTS",
|
||||
)
|
||||
CA_BASENAME = "mitmproxy-ca-cert.pem" # basename of CA_RELPATH; the value a CA var points at
|
||||
# The recording-proxy default endpoint (cmd/artex/main.go --proxy). An operator can
|
||||
# point --proxy elsewhere, so the CA var is the anchor and this is only the fallback.
|
||||
PROXY_DEFAULT_ENDPOINT = f"{PROXY_HOST}:{PROXY_PORT}" # 127.0.0.1:8788
|
||||
|
||||
|
||||
class Finding:
|
||||
def __init__(self, check, severity, title, detail, source, caveat):
|
||||
self.check = check
|
||||
self.severity = severity
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
self.source = source
|
||||
self.caveat = caveat
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"check": self.check,
|
||||
"severity": self.severity,
|
||||
"title": self.title,
|
||||
"detail": self.detail,
|
||||
"source": self.source,
|
||||
"caveat": self.caveat,
|
||||
}
|
||||
|
||||
|
||||
# --- check 1: listening ports -------------------------------------------------
|
||||
|
||||
# Parse a port-listing produced by `ss -ltnp`, `netstat -ltnp`, or
|
||||
# `lsof -nP -iTCP -sTCP:LISTEN`. Kept a pure function of its text input so the
|
||||
# self-test can feed synthetic output without opening a real socket. Returns the
|
||||
# set of (host, port) LISTEN endpoints it can parse out of any of those formats.
|
||||
LISTEN_RE = re.compile(
|
||||
r"(?P<host>\[?[0-9a-fA-F:.*]+\]?):(?P<port>\d{1,5})\b"
|
||||
)
|
||||
|
||||
|
||||
def parse_listen_endpoints(listing):
|
||||
endpoints = set()
|
||||
for line in listing.splitlines():
|
||||
low = line.lower()
|
||||
# ss/netstat lines for listeners contain the LISTEN state; lsof lines
|
||||
# contain "(LISTEN)". Skip anything that is not a listening socket so a
|
||||
# connected session to :8787 elsewhere is not misread as a local listener.
|
||||
if "listen" not in low:
|
||||
continue
|
||||
for m in LISTEN_RE.finditer(line):
|
||||
host = m.group("host").strip("[]")
|
||||
try:
|
||||
port = int(m.group("port"))
|
||||
except ValueError:
|
||||
continue
|
||||
if 0 < port < 65536:
|
||||
endpoints.add((host, port))
|
||||
return endpoints
|
||||
|
||||
|
||||
def gather_listen_listing():
|
||||
"""Run the first available port tool; return its stdout, or '' if none work."""
|
||||
for cmd in (
|
||||
["ss", "-ltnp"],
|
||||
["netstat", "-ltnp"],
|
||||
["lsof", "-nP", "-iTCP", "-sTCP:LISTEN"],
|
||||
):
|
||||
if shutil.which(cmd[0]) is None:
|
||||
continue
|
||||
try:
|
||||
out = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=15, check=False
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
if out.stdout:
|
||||
return out.stdout
|
||||
return ""
|
||||
|
||||
|
||||
def check_listening_ports(listing):
|
||||
findings = []
|
||||
endpoints = parse_listen_endpoints(listing)
|
||||
for host, port in sorted(endpoints):
|
||||
if port == SERVER_PORT:
|
||||
findings.append(
|
||||
Finding(
|
||||
"listening-port",
|
||||
"medium",
|
||||
"ARTEX default admin-UI port is listening",
|
||||
f"a process is listening on {host}:{port} (ARTEX --addr default :{SERVER_PORT})",
|
||||
"cmd/artex/main.go",
|
||||
"The port is configurable; confirm the process with `ss -ltnp` / `lsof`.",
|
||||
)
|
||||
)
|
||||
if port == PROXY_PORT and (host == PROXY_HOST or host in ("*", "0.0.0.0", "::")):
|
||||
findings.append(
|
||||
Finding(
|
||||
"listening-port",
|
||||
"high",
|
||||
"ARTEX recording-proxy loopback port is listening",
|
||||
f"a process is listening on {host}:{port} (ARTEX --proxy default {PROXY_HOST}:{PROXY_PORT})",
|
||||
"cmd/artex/main.go",
|
||||
"Loopback-only and configurable; correlate with the MITM CA file under traffic/_ca/.",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
# --- check 2: recording-proxy artifacts --------------------------------------
|
||||
|
||||
|
||||
def check_recording_proxy_artifacts(data_dir):
|
||||
findings = []
|
||||
traffic = os.path.join(data_dir, TRAFFIC_SUBDIR)
|
||||
ca = os.path.join(traffic, CA_RELPATH)
|
||||
if os.path.isfile(ca):
|
||||
findings.append(
|
||||
Finding(
|
||||
"recording-proxy-ca",
|
||||
"medium",
|
||||
"ARTEX recording-proxy MITM CA certificate present",
|
||||
f"found {ca}",
|
||||
"traffic/traffic.go",
|
||||
"A bare mitmproxy-ca-cert.pem is shared with standalone mitmproxy; "
|
||||
"the traffic/_ca/ layout narrows it to ARTEX.",
|
||||
)
|
||||
)
|
||||
index = os.path.join(traffic, INDEX_RELPATH)
|
||||
if os.path.isfile(index):
|
||||
findings.append(
|
||||
Finding(
|
||||
"recording-proxy-index",
|
||||
"medium",
|
||||
"ARTEX recording-proxy traffic index store present",
|
||||
f"found {index}",
|
||||
"traffic/traffic.go",
|
||||
"The recorder's SQLite index of captured HTTP(S) exchanges; a forensic artifact of a run.",
|
||||
)
|
||||
)
|
||||
blobs = os.path.join(traffic, BLOBS_RELDIR)
|
||||
if os.path.isdir(blobs):
|
||||
findings.append(
|
||||
Finding(
|
||||
"recording-proxy-blobs",
|
||||
"low",
|
||||
"ARTEX recording-proxy body blob store present",
|
||||
f"found {blobs}/",
|
||||
"traffic/traffic.go",
|
||||
"Spilled response bodies from the traffic recorder; corroborates the index/CA.",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
# --- check 3: log markers -----------------------------------------------------
|
||||
|
||||
# logrotate (and journald) compress rotated logs. gzip is the historical default;
|
||||
# bzip2 and xz show up when configured. Open those through their standard-library
|
||||
# codec so the markers inside a rotated file are scanned too — a bare text open()
|
||||
# would read the compressed bytes as UTF-8 and silently miss every marker in the
|
||||
# host's log history, exactly the kind of "absence is not safety" gap this tool
|
||||
# warns about. Formats with no stdlib codec (zstd, lz4) cannot be read here; they
|
||||
# are reported as skipped so the responder decompresses them by hand rather than
|
||||
# mistaking an unscanned file for a clean one.
|
||||
STDLIB_LOG_OPENERS = {
|
||||
".gz": gzip.open,
|
||||
".bz2": bz2.open,
|
||||
".xz": lzma.open,
|
||||
".lzma": lzma.open,
|
||||
}
|
||||
UNSUPPORTED_COMPRESSED_EXTS = {".zst", ".zstd", ".lz4", ".lz", ".zip", ".7z", ".br"}
|
||||
|
||||
|
||||
def open_log_stream(path):
|
||||
"""Return a UTF-8 text stream for a log file, transparently decompressing a
|
||||
gzip/bzip2/xz rotated log by extension. The caller uses it as a context
|
||||
manager. Plaintext and anything unrecognized fall through to a plain open."""
|
||||
opener = STDLIB_LOG_OPENERS.get(os.path.splitext(path)[1].lower())
|
||||
if opener is not None:
|
||||
return opener(path, "rt", encoding="utf-8", errors="replace")
|
||||
return open(path, "r", encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def iter_log_files(logs, log_dirs):
|
||||
seen = set()
|
||||
for p in logs:
|
||||
if os.path.isfile(p) and p not in seen:
|
||||
seen.add(p)
|
||||
yield p
|
||||
for d in log_dirs:
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for root, _dirs, files in os.walk(d):
|
||||
for name in sorted(files):
|
||||
p = os.path.join(root, name)
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
yield p
|
||||
|
||||
|
||||
def scan_logs(logs, log_dirs):
|
||||
"""Scan the log files/dirs for ARTEX markers. Returns (findings, skipped),
|
||||
where skipped lists paths in a compressed format with no stdlib codec
|
||||
(e.g. .zst/.lz4) that could not be read and so were NOT scanned."""
|
||||
findings = []
|
||||
skipped = []
|
||||
for path in iter_log_files(logs, log_dirs):
|
||||
if os.path.splitext(path)[1].lower() in UNSUPPORTED_COMPRESSED_EXTS:
|
||||
# No stdlib codec: do not read gibberish and do not pretend it is
|
||||
# clean — record it so run_checks can tell the responder to grep it
|
||||
# by hand (zstdcat / lz4cat).
|
||||
skipped.append(path)
|
||||
continue
|
||||
# Stream line by line instead of f.read(): the sanctioned log targets are
|
||||
# whole syslogs (--log /var/log/syslog) that can be hundreds of MB, and all
|
||||
# three markers live within a single line, so a line at a time keeps memory
|
||||
# bounded to one line while matching exactly what a full read would.
|
||||
# open_log_stream transparently decompresses a .gz/.bz2/.xz rotated log so
|
||||
# its history is scanned too. Report each marker at most once per file (the
|
||||
# full-read "value in text" did too), and stop early once all have fired.
|
||||
fired = set()
|
||||
try:
|
||||
with open_log_stream(path) as f:
|
||||
for line in f:
|
||||
for marker in LOG_MARKERS:
|
||||
if marker["value"] in fired:
|
||||
continue
|
||||
if marker["value"] in line:
|
||||
fired.add(marker["value"])
|
||||
findings.append(
|
||||
Finding(
|
||||
"log-marker",
|
||||
marker["severity"],
|
||||
f"ARTEX {marker['title']} in log",
|
||||
f"{path!r} contains {marker['value']!r}",
|
||||
marker["source"],
|
||||
marker["caveat"],
|
||||
)
|
||||
)
|
||||
if len(fired) == len(LOG_MARKERS):
|
||||
break
|
||||
except (OSError, EOFError, lzma.LZMAError):
|
||||
# Unreadable or a corrupt/mislabeled compressed file (gzip.BadGzipFile
|
||||
# and bz2 errors are OSError subclasses; lzma raises LZMAError). Skip it
|
||||
# the same way the plain-read path always skipped an unreadable file.
|
||||
continue
|
||||
return findings, skipped
|
||||
|
||||
|
||||
# --- check 4: PostgreSQL exploration schema -----------------------------------
|
||||
|
||||
# A single read-only query: how many of the dual-graph tables exist in the public
|
||||
# schema. Printed for manual use when psql is unavailable or no DSN was given.
|
||||
SCHEMA_QUERY = (
|
||||
"SELECT count(*) FROM information_schema.tables "
|
||||
"WHERE table_schema='public' AND table_name IN ("
|
||||
+ ", ".join(f"'{t}'" for t in SCHEMA_TABLES)
|
||||
+ ");"
|
||||
)
|
||||
|
||||
|
||||
def check_pg_schema(dsn):
|
||||
findings = []
|
||||
if not dsn:
|
||||
return findings, (
|
||||
"PostgreSQL schema check skipped (no --pg-dsn / ARTEX_PG_DSN). "
|
||||
"To check by hand, run this read-only query against the suspected store:\n"
|
||||
f" psql <DSN> -c \"{SCHEMA_QUERY}\"\n"
|
||||
f" (a count at or near {len(SCHEMA_TABLES)} of these tables together is the dual-graph tell; db/schema.sql)"
|
||||
)
|
||||
if shutil.which("psql") is None:
|
||||
return findings, (
|
||||
"PostgreSQL schema check skipped (psql not found on PATH). "
|
||||
f"Run by hand:\n psql <DSN> -c \"{SCHEMA_QUERY}\""
|
||||
)
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["psql", dsn, "-tAc", SCHEMA_QUERY],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
return findings, f"PostgreSQL schema check could not run: {e}"
|
||||
if out.returncode != 0:
|
||||
return findings, (
|
||||
"PostgreSQL schema check could not connect: "
|
||||
+ (out.stderr.strip().splitlines()[-1] if out.stderr.strip() else "psql returned non-zero")
|
||||
)
|
||||
count = out.stdout.strip()
|
||||
try:
|
||||
n = int(count)
|
||||
except ValueError:
|
||||
return findings, f"PostgreSQL schema check returned an unexpected result: {count!r}"
|
||||
if n >= 4:
|
||||
findings.append(
|
||||
Finding(
|
||||
"postgres-schema",
|
||||
"high" if n >= 6 else "medium",
|
||||
"ARTEX dual-graph exploration schema present",
|
||||
f"{n} of {len(SCHEMA_TABLES)} ARTEX exploration-graph tables found in the public schema",
|
||||
"db/schema.sql",
|
||||
"Inspect the database to confirm; a few table names overlap generic apps, the set does not.",
|
||||
)
|
||||
)
|
||||
return findings, f"PostgreSQL schema check: {n} of {len(SCHEMA_TABLES)} ARTEX tables present."
|
||||
|
||||
|
||||
# --- check 5: recording-proxy env injection in running processes --------------
|
||||
|
||||
|
||||
def _proxy_env_hit(env):
|
||||
"""First proxy var set to a non-empty value, as (var, value), else None."""
|
||||
for var in PROXY_ENV_VARS:
|
||||
val = env.get(var, "").strip()
|
||||
if val:
|
||||
return var, val
|
||||
return None
|
||||
|
||||
|
||||
def _ca_env_hit(env):
|
||||
"""First CA-trust var pointing at a mitmproxy-ca-cert.pem, as (var, value), else None."""
|
||||
for var in CA_ENV_VARS:
|
||||
val = env.get(var, "").strip()
|
||||
if val and os.path.basename(val) == CA_BASENAME:
|
||||
return var, val
|
||||
return None
|
||||
|
||||
|
||||
def scan_process_env(label, env):
|
||||
"""Findings for one process's environment dict. Pure function of its input so
|
||||
the self-test can feed synthetic env without reading /proc. The strongest tell
|
||||
is a proxy var AND a mitmproxy CA var together (the worker proxyEnv signature);
|
||||
a mitmproxy CA alone, or the ARTEX default proxy endpoint alone, is a weaker
|
||||
lead. A corporate proxy with no mitmproxy CA is deliberately not flagged."""
|
||||
proxy = _proxy_env_hit(env)
|
||||
ca = _ca_env_hit(env)
|
||||
if ca and proxy:
|
||||
pv, pval = proxy
|
||||
cv, cval = ca
|
||||
return [Finding(
|
||||
"process-env-injection", "high",
|
||||
"ARTEX recording-proxy env injection in a running process",
|
||||
f"{label}: {pv}={pval} with {cv}={cval} — the worker proxyEnv signature "
|
||||
"(routes through a proxy and trusts a mitmproxy CA)",
|
||||
"agent/worker.go",
|
||||
"A standalone mitmproxy or a MITM test harness can set these too; a proxy "
|
||||
"together with a trusted mitmproxy-ca-cert.pem matches ARTEX's worker "
|
||||
"injection. Capture can be disabled (--proxy ''), so absence is not safety.",
|
||||
)]
|
||||
if ca:
|
||||
cv, cval = ca
|
||||
return [Finding(
|
||||
"process-env-injection", "medium",
|
||||
"A running process is told to trust a mitmproxy CA",
|
||||
f"{label}: {cv}={cval} points a toolchain CA-trust var at a mitmproxy-ca-cert.pem",
|
||||
"agent/worker.go",
|
||||
"The recording proxy injects this CA path into worker tools; the bare "
|
||||
"filename is shared with standalone mitmproxy, so correlate with "
|
||||
"traffic/_ca/ and the proxy port.",
|
||||
)]
|
||||
if proxy and PROXY_DEFAULT_ENDPOINT in proxy[1]:
|
||||
pv, pval = proxy
|
||||
return [Finding(
|
||||
"process-env-injection", "medium",
|
||||
"A running process routes through the ARTEX recording-proxy default endpoint",
|
||||
f"{label}: {pv}={pval} (ARTEX --proxy default {PROXY_DEFAULT_ENDPOINT})",
|
||||
"agent/worker.go",
|
||||
"The endpoint is the --proxy default and is configurable; correlate with "
|
||||
"the MITM CA under traffic/_ca/.",
|
||||
)]
|
||||
return []
|
||||
|
||||
|
||||
def _parse_environ_bytes(raw):
|
||||
"""Parse a NUL-separated /proc/<pid>/environ blob into a KEY->VALUE dict."""
|
||||
env = {}
|
||||
for tok in raw.split(b"\x00"):
|
||||
if not tok:
|
||||
continue
|
||||
s = tok.decode("utf-8", "replace")
|
||||
if "=" in s:
|
||||
k, v = s.split("=", 1)
|
||||
env[k] = v
|
||||
return env
|
||||
|
||||
|
||||
def parse_proc_dump(text):
|
||||
r"""Parse a captured process-environment dump into [(label, env), ...]. Blocks
|
||||
are separated by a blank line; a line starting with '#' sets the block label;
|
||||
other entries are KEY=VALUE (NUL or newline separated). Produce such a dump on
|
||||
the host with:
|
||||
for p in /proc/[0-9]*; do echo "# $p"; tr '\0' '\n' < "$p/environ"; echo; done
|
||||
"""
|
||||
procs = []
|
||||
for block in re.split(r"\n[ \t]*\n", text.replace("\x00", "\n")):
|
||||
label = None
|
||||
env = {}
|
||||
for line in block.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
if line.lstrip().startswith("#"):
|
||||
label = line.lstrip()[1:].strip() or label
|
||||
continue
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
env[k.strip()] = v
|
||||
if env:
|
||||
procs.append((label or "process", env))
|
||||
return procs
|
||||
|
||||
|
||||
def gather_process_envs():
|
||||
"""(procs, note, unreadable): read each /proc/<pid>/environ on the live Linux
|
||||
host. note is non-empty when /proc is unavailable (non-Linux) or some environs
|
||||
were unreadable, so the caller never mistakes 'did not run' for 'clean'."""
|
||||
if not sys.platform.startswith("linux") or not os.path.isdir("/proc"):
|
||||
return [], (
|
||||
"process-env check skipped (no /proc on this OS; run on the Linux host, "
|
||||
"or pass --proc-from a captured env dump)."
|
||||
), 0
|
||||
procs = []
|
||||
unreadable = 0
|
||||
mypid = str(os.getpid())
|
||||
try:
|
||||
pids = os.listdir("/proc")
|
||||
except OSError as e:
|
||||
return [], f"process-env check could not list /proc: {e}", 0
|
||||
for pid in pids:
|
||||
if not pid.isdigit() or pid == mypid:
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join("/proc", pid, "environ"), "rb") as f:
|
||||
raw = f.read()
|
||||
except OSError:
|
||||
unreadable += 1
|
||||
continue
|
||||
env = _parse_environ_bytes(raw)
|
||||
if not env:
|
||||
continue
|
||||
comm = pid
|
||||
try:
|
||||
with open(os.path.join("/proc", pid, "comm"), "r", encoding="utf-8", errors="replace") as f:
|
||||
comm = f.read().strip() or pid
|
||||
except OSError:
|
||||
pass
|
||||
procs.append((f"pid {pid} ({comm})", env))
|
||||
note = ""
|
||||
if unreadable:
|
||||
note = (
|
||||
f"process-env check: {unreadable} process(es) had an unreadable "
|
||||
"/proc/<pid>/environ — run as root to cover every process; absence is not safety."
|
||||
)
|
||||
return procs, note, unreadable
|
||||
|
||||
|
||||
# --- reporting ----------------------------------------------------------------
|
||||
|
||||
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
||||
|
||||
|
||||
def run_checks(args):
|
||||
findings = []
|
||||
notes = []
|
||||
|
||||
if args.ports_from:
|
||||
try:
|
||||
with open(args.ports_from, "r", encoding="utf-8", errors="replace") as f:
|
||||
listing = f.read()
|
||||
except OSError as e:
|
||||
listing = ""
|
||||
notes.append(f"could not read --ports-from {args.ports_from}: {e}")
|
||||
else:
|
||||
listing = gather_listen_listing()
|
||||
if not listing:
|
||||
notes.append(
|
||||
"listening-port check skipped (no ss/netstat/lsof output; "
|
||||
"run on the host as a user that can see listeners, or pass --ports-from)."
|
||||
)
|
||||
findings += check_listening_ports(listing)
|
||||
|
||||
if args.data_dir:
|
||||
for d in args.data_dir:
|
||||
findings += check_recording_proxy_artifacts(d)
|
||||
else:
|
||||
notes.append(
|
||||
"recording-proxy artifact check skipped (no --data-dir; "
|
||||
"ARTEX's default is data/ next to the binary — cmd/artex/main.go)."
|
||||
)
|
||||
|
||||
if args.log or args.log_dir:
|
||||
log_findings, skipped = scan_logs(args.log, args.log_dir)
|
||||
findings += log_findings
|
||||
if skipped:
|
||||
notes.append(
|
||||
f"log-marker check could not read {len(skipped)} compressed log "
|
||||
"file(s) with no standard-library codec (e.g. .zst/.lz4), so their "
|
||||
"history was NOT scanned — decompress them first or grep them by "
|
||||
"hand (e.g. `zstdcat FILE | grep -F artex-`): "
|
||||
+ ", ".join(sorted(skipped))
|
||||
)
|
||||
else:
|
||||
notes.append("log-marker check skipped (no --log / --log-dir).")
|
||||
|
||||
pg_findings, pg_note = check_pg_schema(args.pg_dsn or os.environ.get("ARTEX_PG_DSN"))
|
||||
findings += pg_findings
|
||||
if pg_note:
|
||||
notes.append(pg_note)
|
||||
|
||||
if args.proc_from:
|
||||
try:
|
||||
with open(args.proc_from, "r", encoding="utf-8", errors="replace") as f:
|
||||
dump = f.read()
|
||||
except OSError as e:
|
||||
dump = ""
|
||||
notes.append(f"could not read --proc-from {args.proc_from}: {e}")
|
||||
procs = parse_proc_dump(dump)
|
||||
if not procs and dump.strip():
|
||||
notes.append(
|
||||
"process-env check: --proc-from file parsed no process blocks "
|
||||
"(expected '# label' + KEY=VALUE lines, blocks split by a blank line)."
|
||||
)
|
||||
for label, env in procs:
|
||||
findings += scan_process_env(label, env)
|
||||
else:
|
||||
procs, proc_note, _unreadable = gather_process_envs()
|
||||
for label, env in procs:
|
||||
findings += scan_process_env(label, env)
|
||||
if proc_note:
|
||||
notes.append(proc_note)
|
||||
|
||||
findings.sort(key=lambda f: (SEVERITY_ORDER.get(f.severity, 9), f.check, f.title))
|
||||
return findings, notes
|
||||
|
||||
|
||||
def print_report(findings, notes):
|
||||
print("ARTEX host triage — read-only; findings are triage leads, not attribution.")
|
||||
print("Use only on a host you own or are authorized in writing to inspect.\n")
|
||||
if findings:
|
||||
print(f"{len(findings)} indicator(s) fired:\n")
|
||||
for f in findings:
|
||||
print(f" [{f.severity.upper():6}] {f.title}")
|
||||
print(f" {f.detail}")
|
||||
print(f" grounded in: {f.source}")
|
||||
print(f" caveat: {f.caveat}\n")
|
||||
else:
|
||||
print("No ARTEX indicators fired in the checks that ran.")
|
||||
print("This is NOT a clean bill of health: an operator can rename the binary,")
|
||||
print("move the data directory, or change the ports. Absence is not safety.\n")
|
||||
if notes:
|
||||
print("Notes:")
|
||||
for n in notes:
|
||||
print(" - " + n.replace("\n", "\n "))
|
||||
|
||||
|
||||
# --- self-test ----------------------------------------------------------------
|
||||
|
||||
|
||||
def self_test():
|
||||
failures = []
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'PASS' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
# 1. parse_listen_endpoints across ss / netstat / lsof shapes.
|
||||
ss_out = (
|
||||
"State Recv-Q Send-Q Local Address:Port Peer Address:Port Process\n"
|
||||
"LISTEN 0 4096 *:8787 *:* users:((\"artex\"))\n"
|
||||
"LISTEN 0 4096 127.0.0.1:8788 0.0.0.0:* users:((\"artex\"))\n"
|
||||
"LISTEN 0 128 127.0.0.1:5432 0.0.0.0:* users:((\"postgres\"))\n"
|
||||
)
|
||||
eps = parse_listen_endpoints(ss_out)
|
||||
check("ports: ss output parses :8787 and 127.0.0.1:8788", ("*", 8787) in eps and ("127.0.0.1", 8788) in eps)
|
||||
pf = check_listening_ports(ss_out)
|
||||
checks_hit = {f.title for f in pf}
|
||||
check("ports: both ARTEX listeners reported", len(pf) == 2)
|
||||
check("ports: admin-UI listener reported", any("admin-UI" in t for t in checks_hit))
|
||||
check("ports: recording-proxy listener reported", any("recording-proxy" in t for t in checks_hit))
|
||||
|
||||
lsof_out = "artex 42 root 7u IPv4 TCP 127.0.0.1:8788 (LISTEN)\n"
|
||||
check("ports: lsof shape parses the proxy listener", ("127.0.0.1", 8788) in parse_listen_endpoints(lsof_out))
|
||||
|
||||
# a connected (non-LISTEN) session to :8787 must not be read as a local listener
|
||||
estab = "ESTAB 0 0 10.0.0.5:51000 93.184.216.34:8787\n"
|
||||
check("ports: a non-LISTEN session to :8787 is ignored", len(check_listening_ports(estab)) == 0)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# 2. recording-proxy artifacts under <data>/traffic/
|
||||
data = os.path.join(tmp, "data")
|
||||
traffic = os.path.join(data, TRAFFIC_SUBDIR)
|
||||
os.makedirs(os.path.join(traffic, "_ca"))
|
||||
os.makedirs(os.path.join(traffic, "_index"))
|
||||
os.makedirs(os.path.join(traffic, "_blobs"))
|
||||
open(os.path.join(traffic, CA_RELPATH), "w").close()
|
||||
open(os.path.join(traffic, INDEX_RELPATH), "w").close()
|
||||
af = check_recording_proxy_artifacts(data)
|
||||
kinds = {f.check for f in af}
|
||||
check("ca: CA + index + blobs all reported", kinds == {"recording-proxy-ca", "recording-proxy-index", "recording-proxy-blobs"})
|
||||
|
||||
# 3. log markers
|
||||
logpath = os.path.join(tmp, "app.log")
|
||||
with open(logpath, "w", encoding="utf-8") as f:
|
||||
f.write("GET / HTTP/1.1 artex-enrich/1.0\n")
|
||||
f.write("outbound artex-selfupdate to release host\n")
|
||||
f.write("blocked: " + LOG_MARKERS[2]["value"] + " this operation is denied\n")
|
||||
f.write("a normal line with no markers\n")
|
||||
lf, _ = scan_logs([logpath], [])
|
||||
check("logs: all three markers fire", len(lf) == 3)
|
||||
|
||||
# 3b. rotated (compressed) logs under a --log-dir are scanned too, not
|
||||
# silently skipped. A responder pointing at /var/log/artex expects the
|
||||
# rotated history to be covered; a plain read of the compressed bytes
|
||||
# would miss every marker inside. Plant one marker per container: a
|
||||
# plaintext current log, a .gz, a .bz2, and an .xz rotation.
|
||||
rot = os.path.join(tmp, "rotated")
|
||||
os.makedirs(rot)
|
||||
with open(os.path.join(rot, "artex.log"), "w", encoding="utf-8") as f:
|
||||
f.write("outbound artex-selfupdate to release host\n")
|
||||
with gzip.open(os.path.join(rot, "artex.log.1.gz"), "wt", encoding="utf-8") as f:
|
||||
f.write("GET / HTTP/1.1 artex-enrich/1.0\n")
|
||||
with bz2.open(os.path.join(rot, "artex.log.2.bz2"), "wt", encoding="utf-8") as f:
|
||||
f.write("blocked: " + LOG_MARKERS[2]["value"] + " this operation is denied\n")
|
||||
with lzma.open(os.path.join(rot, "artex.log.3.xz"), "wt", encoding="utf-8") as f:
|
||||
f.write("another GET / artex-enrich/1.0 probe\n")
|
||||
rf, rskip = scan_logs([], [rot])
|
||||
rtitles = [f.title for f in rf]
|
||||
check("rotated: plaintext + .gz + .bz2 + .xz markers all fire via --log-dir", len(rf) == 4)
|
||||
check("rotated: the .gz/.xz enrichment markers (missed by a plain read) are found",
|
||||
sum("enrichment prober" in t for t in rtitles) == 2)
|
||||
check("rotated: nothing is reported as skipped when every file has a stdlib codec", rskip == [])
|
||||
|
||||
# 3c. a format with no stdlib codec (.zst) is reported as skipped, never
|
||||
# silently treated as clean.
|
||||
zstpath = os.path.join(rot, "artex.log.4.zst")
|
||||
with open(zstpath, "wb") as f:
|
||||
f.write(b"\x28\xb5\x2f\xfd and bytes a plain read would mis-handle")
|
||||
_rf2, rskip2 = scan_logs([], [rot])
|
||||
check("rotated: a .zst log (no stdlib codec) is reported as skipped", zstpath in rskip2)
|
||||
|
||||
# 4. clean host: nothing fires, no false positives
|
||||
clean = os.path.join(tmp, "clean")
|
||||
os.makedirs(clean)
|
||||
cleanlog = os.path.join(tmp, "clean.log")
|
||||
with open(cleanlog, "w", encoding="utf-8") as f:
|
||||
f.write("nothing to see here\nGET /health 200\n")
|
||||
clean_lf, clean_skip = scan_logs([cleanlog], [])
|
||||
check("clean: no artifact findings on an empty data dir", len(check_recording_proxy_artifacts(clean)) == 0)
|
||||
check("clean: no log findings on a benign log", len(clean_lf) == 0 and clean_skip == [])
|
||||
check("clean: no port findings on empty listing", len(check_listening_ports("")) == 0)
|
||||
|
||||
# 5. pg schema: skipped path returns a manual-query note, no finding
|
||||
pgf, pgnote = check_pg_schema("")
|
||||
check("pg: no DSN yields a manual-query note and no finding", len(pgf) == 0 and "psql" in pgnote and SCHEMA_TABLES[0] in SCHEMA_QUERY)
|
||||
|
||||
# 6. recording-proxy env injection in running processes (agent/worker.go proxyEnv)
|
||||
dump = (
|
||||
"# pid 101 (curl)\n"
|
||||
"PATH=/usr/bin\n"
|
||||
"HTTP_PROXY=127.0.0.1:8788\n"
|
||||
"HTTPS_PROXY=127.0.0.1:8788\n"
|
||||
"REQUESTS_CA_BUNDLE=/opt/artex/data/traffic/_ca/mitmproxy-ca-cert.pem\n"
|
||||
"\n"
|
||||
"# pid 202 (nginx)\n"
|
||||
"PATH=/usr/sbin\n"
|
||||
"HOME=/var/www\n"
|
||||
"\n"
|
||||
"# pid 303 (apt)\n"
|
||||
"HTTP_PROXY=http://corp-proxy.local:3128\n"
|
||||
"\n"
|
||||
"# pid 404 (python)\n"
|
||||
"REQUESTS_CA_BUNDLE=/opt/artex/data/traffic/_ca/mitmproxy-ca-cert.pem\n"
|
||||
"\n"
|
||||
"# pid 505 (wget)\n"
|
||||
"https_proxy=127.0.0.1:8788\n"
|
||||
)
|
||||
procs = parse_proc_dump(dump)
|
||||
check("procenv: dump parses five process blocks", len(procs) == 5)
|
||||
by_label = {label: env for label, env in procs}
|
||||
inj = scan_process_env("pid 101 (curl)", by_label.get("pid 101 (curl)", {}))
|
||||
check("procenv: proxy + mitmproxy CA fires one HIGH injection finding",
|
||||
len(inj) == 1 and inj[0].severity == "high" and inj[0].check == "process-env-injection")
|
||||
benign = scan_process_env("pid 202 (nginx)", by_label.get("pid 202 (nginx)", {}))
|
||||
check("procenv: a benign process fires nothing", len(benign) == 0)
|
||||
corp = scan_process_env("pid 303 (apt)", by_label.get("pid 303 (apt)", {}))
|
||||
check("procenv: a corporate proxy (not :8788, no mitm CA) is not a false positive", len(corp) == 0)
|
||||
caonly = scan_process_env("pid 404 (python)", by_label.get("pid 404 (python)", {}))
|
||||
check("procenv: a mitmproxy CA alone fires one MEDIUM finding",
|
||||
len(caonly) == 1 and caonly[0].severity == "medium")
|
||||
proxyonly = scan_process_env("pid 505 (wget)", by_label.get("pid 505 (wget)", {}))
|
||||
check("procenv: the ARTEX default proxy endpoint alone fires one MEDIUM finding",
|
||||
len(proxyonly) == 1 and proxyonly[0].severity == "medium")
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"RESULT: FAIL ({len(failures)} assertion(s) failed)")
|
||||
return 1
|
||||
print("RESULT: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(
|
||||
description="Read-only host triage for a suspected ARTEX host (detections/triage).",
|
||||
)
|
||||
p.add_argument("--data-dir", action="append", default=[], metavar="PATH",
|
||||
help="ARTEX data directory to check for recording-proxy artifacts (repeatable).")
|
||||
p.add_argument("--log", action="append", default=[], metavar="PATH",
|
||||
help="log file to scan for ARTEX markers (repeatable).")
|
||||
p.add_argument("--log-dir", action="append", default=[], metavar="PATH",
|
||||
help="directory of log files to scan recursively; rotated "
|
||||
".gz/.bz2/.xz logs are decompressed and scanned too, while "
|
||||
".zst/.lz4 (no stdlib codec) are reported as skipped "
|
||||
"(repeatable).")
|
||||
p.add_argument("--pg-dsn", default=None, metavar="DSN",
|
||||
help="PostgreSQL DSN to check for the exploration schema (defaults to $ARTEX_PG_DSN).")
|
||||
p.add_argument("--ports-from", default=None, metavar="FILE",
|
||||
help="read a port listing from FILE instead of running ss/netstat/lsof.")
|
||||
p.add_argument("--proc-from", default=None, metavar="FILE",
|
||||
help="read a captured process-environment dump from FILE instead of "
|
||||
"reading /proc on the live host (offline / forensic-image triage). "
|
||||
"Format: '# label' + KEY=VALUE lines, process blocks split by a blank line.")
|
||||
p.add_argument("--json", action="store_true", help="emit findings as JSON.")
|
||||
p.add_argument("--exit-code", action="store_true",
|
||||
help="exit 1 if any indicator fired (default: always exit 0).")
|
||||
p.add_argument("--self-test", action="store_true",
|
||||
help="run the built-in fixture test and exit.")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.self_test:
|
||||
return self_test()
|
||||
findings, notes = run_checks(args)
|
||||
if args.json:
|
||||
print(json.dumps(
|
||||
{"findings": [f.as_dict() for f in findings], "notes": notes},
|
||||
ensure_ascii=False, indent=2,
|
||||
))
|
||||
else:
|
||||
print_report(findings, notes)
|
||||
if args.exit_code and findings:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user