Skip to content

Instantly share code, notes, and snippets.

@tienngtr
Last active June 14, 2026 20:51
Show Gist options
  • Select an option

  • Save tienngtr/83a1f17f133dc85d8dc4287f0a03f6f0 to your computer and use it in GitHub Desktop.

Select an option

Save tienngtr/83a1f17f133dc85d8dc4287f0a03f6f0 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# Hardened Atomic Arch / atomic-lockfile triage helper.
# Best-effort IOC scanner. It is not proof that a host is clean.
# Default behavior is read-only except for its own cache file under XDG_CACHE_HOME.
set -Eeuo pipefail
IFS=$'\n\t'
VERSION="2026-06-15.hardened4"
SCRIPT_NAME="$(basename "$0")"
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[1;33m'
CYAN=$'\033[0;36m'
NC=$'\033[0m'
if [[ ! -t 1 || "${NO_COLOR:-}" != "" ]]; then
RED=""; GREEN=""; YELLOW=""; CYAN=""; NC=""
fi
# Keep dynamic IOC fetching. This intentionally preserves the original script's
# trust model for latest community lists, with local cache fallback.
REMOTE_LISTS=(
"https://gist.githubusercontent.com/quantenProjects/3f768dce7331618310f016d975bf8547/raw/beef579f8a8efeed6ccf60788e5b768775550095/packages"
"https://cscs.pastes.sh/raw/aurvulnlist20260611.txt"
"https://md.archlinux.org/s/SxbqukK6IA/download"
)
# High-confidence package-name IOCs reported in the AUR waves.
MALICIOUS_JS_PKGS=(atomic-lockfile js-digest lockfile-js nextfile-js)
# No lower-confidence package-name IOCs are enabled by default. In particular,
# is intentionally ignored: it is a common benign npm dependency and
# is too noisy to use as an IOC by package name alone.
LOW_CONFIDENCE_JS_PKGS=()
MALICIOUS_JS_ERE='atomic-lockfile|js-digest|lockfile-js|nextfile-js'
LOW_CONFIDENCE_JS_ERE='a^'
ALL_JS_IOC_ERE='atomic-lockfile|js-digest|lockfile-js|nextfile-js'
SUSPICIOUS_PORT_RE='(8080|4443|8443|1337|4444|4445|5555|6666|7777|9001|9050|31337)'
CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/atomic-arch-check"
IOC_CACHE="$CACHE_DIR/infected-packages.txt"
PACMAN_LOG_GLOB="${PACMAN_LOG_GLOB:-/var/log/pacman.log*}"
INCIDENT_START="${INCIDENT_START:-2026-06-09}"
INCIDENT_END="${INCIDENT_END:-}"
SCAN_ALL_USERS=false
VERBOSE=false
REMOTE_OK=false
TMP_FILES=()
PASS=0
WARN=0
FAIL=0
SKIP=0
TOTAL=0
DETAILS=()
usage() {
cat <<USAGE
Usage: $SCRIPT_NAME [OPTIONS]
Options:
--all-users Scan readable homes under /home plus /root for shell/user persistence/npm artifacts.
--incident-start=DD Override incident start date. Default: $INCIDENT_START.
--incident-end=DD Optional inclusive incident end date. Default: open-ended.
--pacman-log-glob=G Override pacman log glob. Default: $PACMAN_LOG_GLOB.
--verbose, -v Print additional detail.
--help, -h Show this help.
Environment:
INCIDENT_START=YYYY-MM-DD
INCIDENT_END=YYYY-MM-DD
PACMAN_LOG_GLOB='/var/log/pacman.log*'
NO_COLOR=1
Exit codes:
0: no fail/warn results
1: warning or skipped important check
2: one or more failure indicators
USAGE
}
for arg in "$@"; do
case "$arg" in
--all-users) SCAN_ALL_USERS=true ;;
--incident-start=*) INCIDENT_START="${arg#*=}" ;;
--incident-end=*) INCIDENT_END="${arg#*=}" ;;
--pacman-log-glob=*) PACMAN_LOG_GLOB="${arg#*=}" ;;
--verbose|-v) VERBOSE=true ;;
--help|-h) usage; exit 0 ;;
*) echo "Unknown option: $arg" >&2; usage >&2; exit 2 ;;
esac
done
cleanup() {
((${#TMP_FILES[@]} > 0)) && rm -f -- "${TMP_FILES[@]}" 2>/dev/null || true
}
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
mktemp_tracked() {
local f
f="$(mktemp)"
TMP_FILES+=("$f")
printf '%s\n' "$f"
}
have() { command -v "$1" >/dev/null 2>&1; }
vlog() {
$VERBOSE && printf ' %s[INFO]%s %s\n' "$CYAN" "$NC" "$*"
}
add_detail() {
DETAILS+=("$1")
}
begin_check() {
TOTAL=$((TOTAL + 1))
printf '%s[%02d]%s %s... ' "$CYAN" "$TOTAL" "$NC" "$1"
}
finish_pass() {
PASS=$((PASS + 1))
printf '%sPASS%s\n' "$GREEN" "$NC"
[[ $# -gt 0 && -n "$1" ]] && add_detail "PASS: $1"
return 0
}
finish_warn() {
WARN=$((WARN + 1))
printf '%sWARN%s\n' "$YELLOW" "$NC"
add_detail "WARN: $1"
return 0
}
finish_fail() {
FAIL=$((FAIL + 1))
printf '%sFAIL%s\n' "$RED" "$NC"
add_detail "FAIL: $1"
return 0
}
finish_skip() {
SKIP=$((SKIP + 1))
printf '%sSKIP%s\n' "$YELLOW" "$NC"
add_detail "SKIP: $1"
return 0
}
normalize_pkg_list() {
# Extract package-name-looking tokens. This intentionally does not validate
# provenance; it only prevents arbitrary prose from becoming shell input.
tr '[:space:]",;()[]{}' '\n' \
| sed -nE '/^[A-Za-z0-9][A-Za-z0-9@._+-]*$/p' \
| sort -u
}
fetch_one() {
local url="$1" out="$2"
if have curl; then
curl -fsSL --connect-timeout 3 --max-time 8 -- "$url" > "$out" 2>/dev/null
elif have wget; then
wget -qO- --timeout=8 -- "$url" > "$out" 2>/dev/null
else
return 127
fi
}
fetch_ioc_list() {
local combined raw parsed url source_count=0
combined="$(mktemp_tracked)"
: > "$combined"
if ! have curl && ! have wget; then
return 2
fi
for url in "${REMOTE_LISTS[@]}"; do
raw="$(mktemp_tracked)"
parsed="$(mktemp_tracked)"
if fetch_one "$url" "$raw" && [[ -s "$raw" ]]; then
normalize_pkg_list < "$raw" > "$parsed"
if [[ -s "$parsed" ]]; then
cat "$parsed" >> "$combined"
source_count=$((source_count + 1))
vlog "fetched $(wc -l < "$parsed") package tokens from $url"
fi
else
vlog "fetch failed: $url"
fi
done
if ((source_count == 0)); then
return 1
fi
mkdir -p -- "$CACHE_DIR"
sort -u "$combined" > "$IOC_CACHE"
REMOTE_OK=true
return 0
}
load_ioc_list() {
local ioc_file="$1"
begin_check "Loading known-infected package list"
if fetch_ioc_list; then
cp -- "$IOC_CACHE" "$ioc_file"
finish_pass "loaded $(wc -l < "$ioc_file") packages from ${#REMOTE_LISTS[@]} remote source(s); cache updated at $IOC_CACHE"
return 0
fi
if [[ -s "$IOC_CACHE" ]]; then
cp -- "$IOC_CACHE" "$ioc_file"
finish_warn "remote fetch failed; using cached list from $IOC_CACHE ($(wc -l < "$ioc_file") packages)"
return 0
fi
finish_skip "no remote list fetched and no cache exists; package-name exposure checks cannot run"
return 1
}
intersect_files() {
local a="$1" b="$2"
grep -Fxf "$a" "$b" 2>/dev/null | sort -u || true
}
join_lines() {
paste -sd ', ' - 2>/dev/null | sed 's/,/ /2g'
}
collect_homes() {
local homes=()
homes+=("$HOME")
if $SCAN_ALL_USERS; then
local d
for d in /home/* /root; do
[[ -d "$d" ]] || continue
homes+=("$d")
done
elif ((EUID == 0)); then
homes+=("/root")
fi
printf '%s\n' "${homes[@]}" | awk 'NF && !seen[$0]++'
}
# Print a package list under known global node_modules roots. This avoids relying
# only on the current user's npm prefix and includes scoped packages.
collect_node_module_roots() {
local roots=() root home
if have npm; then
root="$(npm root -g 2>/dev/null || true)"
[[ -n "$root" ]] && roots+=("$root")
fi
roots+=(
/usr/lib/node_modules
/usr/local/lib/node_modules
/opt/node_modules
"$HOME/.local/lib/node_modules"
)
while IFS= read -r home; do
[[ -d "$home" ]] || continue
roots+=("$home/.local/lib/node_modules")
if [[ -d "$home/.nvm/versions/node" ]]; then
local nvm_root
for nvm_root in "$home"/.nvm/versions/node/*/lib/node_modules; do
[[ -d "$nvm_root" ]] && roots+=("$nvm_root")
done
fi
done < <(collect_homes)
printf '%s\n' "${roots[@]}" | awk 'NF && !seen[$0]++'
}
collect_bun_module_roots() {
local roots=() home
roots+=("$HOME/.bun/install/global/node_modules")
while IFS= read -r home; do
[[ -d "$home" ]] || continue
roots+=("$home/.bun/install/global/node_modules")
done < <(collect_homes)
printf '%s\n' "${roots[@]}" | awk 'NF && !seen[$0]++'
}
read_log_file() {
local f="$1"
case "$f" in
*.gz)
if have gzip; then gzip -cd -- "$f" 2>/dev/null || true; else return 3; fi
;;
*.zst)
if have zstdcat; then zstdcat -- "$f" 2>/dev/null || true; else return 3; fi
;;
*)
cat -- "$f" 2>/dev/null || true
;;
esac
}
collect_pacman_events() {
local out="$1" unreadable="$2" files=()
: > "$out"
: > "$unreadable"
# shellcheck disable=SC2206
files=( $PACMAN_LOG_GLOB )
((${#files[@]} > 0)) || return 1
local f tmp
for f in "${files[@]}"; do
[[ -f "$f" ]] || continue
tmp="$(mktemp_tracked)"
if read_log_file "$f" > "$tmp"; then
sed -nE 's/^\[([0-9]{4}-[0-9]{2}-[0-9]{2})[^]]*\] \[ALPM\] (installed|upgraded) ([^ ]+) .*/\1 \2 \3/p' "$tmp" >> "$out"
else
printf '%s\n' "$f" >> "$unreadable"
fi
done
[[ -s "$out" || -s "$unreadable" ]]
}
check_current_foreign_packages() {
local ioc_file="$1" foreign hits_file hits
begin_check "Checking currently installed foreign packages"
if [[ ! -s "$ioc_file" ]]; then
finish_skip "IOC package list unavailable"
return
fi
if ! have pacman; then
finish_skip "pacman not found"
return
fi
foreign="$(mktemp_tracked)"
hits_file="$(mktemp_tracked)"
pacman -Qqm 2>/dev/null | sort -u > "$foreign" || true
intersect_files "$ioc_file" "$foreign" > "$hits_file"
if [[ -s "$hits_file" ]]; then
hits="$(join_lines < "$hits_file")"
finish_fail "known-infected foreign package(s) are currently installed: $hits"
else
finish_pass
fi
}
check_pacman_history() {
local ioc_file="$1" events unreadable all_pkgs window_pkgs hist_hits window_hits older_hits msg
begin_check "Checking pacman history for removed-or-current exposure"
if [[ ! -s "$ioc_file" ]]; then
finish_skip "IOC package list unavailable"
return
fi
events="$(mktemp_tracked)"
unreadable="$(mktemp_tracked)"
all_pkgs="$(mktemp_tracked)"
window_pkgs="$(mktemp_tracked)"
hist_hits="$(mktemp_tracked)"
window_hits="$(mktemp_tracked)"
older_hits="$(mktemp_tracked)"
if ! collect_pacman_events "$events" "$unreadable"; then
finish_skip "no readable pacman logs matched: $PACMAN_LOG_GLOB"
return
fi
awk '{print $3}' "$events" | sort -u > "$all_pkgs"
awk -v start="$INCIDENT_START" -v end="$INCIDENT_END" '
$1 >= start && (end == "" || $1 <= end) { print $3 }
' "$events" | sort -u > "$window_pkgs"
intersect_files "$ioc_file" "$all_pkgs" > "$hist_hits"
intersect_files "$ioc_file" "$window_pkgs" > "$window_hits"
comm -23 "$hist_hits" "$window_hits" > "$older_hits" || true
if [[ -s "$window_hits" ]]; then
msg="known-infected package(s) were installed/upgraded in incident window ${INCIDENT_START}..${INCIDENT_END:-open}: $(join_lines < "$window_hits")"
[[ -s "$unreadable" ]] && msg="$msg; unreadable compressed logs: $(join_lines < "$unreadable")"
finish_fail "$msg"
elif [[ -s "$older_hits" ]]; then
msg="known-infected package name(s) appear in older pacman history outside the configured incident window: $(join_lines < "$older_hits"); review exact install dates manually"
[[ -s "$unreadable" ]] && msg="$msg; unreadable compressed logs: $(join_lines < "$unreadable")"
finish_warn "$msg"
elif [[ -s "$unreadable" ]]; then
finish_warn "no matching pacman history found, but some logs were unreadable: $(join_lines < "$unreadable")"
else
finish_pass
fi
}
check_js_package_and_metadata() {
local fail_hits warn_hits roots_file root home dir pkg pattern high_csv low_csv cache_hit
begin_check "Checking malicious npm/bun package indicators and pacman/AUR metadata"
fail_hits="$(mktemp_tracked)"
warn_hits="$(mktemp_tracked)"
roots_file="$(mktemp_tracked)"
: > "$fail_hits"
: > "$warn_hits"
high_csv="$(IFS=,; printf '%s' "${MALICIOUS_JS_PKGS[*]}")"
low_csv="$(IFS=,; printf '%s' "${LOW_CONFIDENCE_JS_PKGS[*]}")"
while IFS= read -r root; do
[[ -d "$root" ]] && printf '%s\0' "$root"
done < <(collect_node_module_roots; collect_bun_module_roots) > "$roots_file"
if have python3 && [[ -s "$roots_file" ]]; then
python3 - "$roots_file" "$high_csv" "$low_csv" <<'PYCODE' >> "$fail_hits" 2>>"$warn_hits" || true
import json, os, sys
roots_path, high_csv, low_csv = sys.argv[1:4]
high = {x for x in high_csv.split(',') if x}
low = {x for x in low_csv.split(',') if x}
with open(roots_path, 'rb') as f:
roots = [p.decode('utf-8', 'replace') for p in f.read().split(b'\0') if p]
seen = set()
for root in roots:
for base, dirs, files in os.walk(root):
rel = os.path.relpath(base, root)
depth = 0 if rel == '.' else rel.count(os.sep) + 1
if depth > 5:
dirs[:] = []
continue
if 'package.json' not in files:
continue
path = os.path.join(base, 'package.json')
if path in seen:
continue
seen.add(path)
try:
with open(path, 'r', encoding='utf-8', errors='replace') as pf:
pkg = json.load(pf)
except Exception:
continue
name = pkg.get('name') or os.path.basename(base)
if name in high:
print(f'node_modules:{path}:{name}')
elif name in low:
print(f'warn-node_modules:{path}:{name}', file=sys.stderr)
PYCODE
elif [[ -s "$roots_file" ]]; then
printf 'python3 unavailable; cannot parse package.json names under global npm/bun roots\n' >> "$warn_hits"
fi
if [[ -d /var/lib/pacman/local ]]; then
# Current installed-package metadata. High-confidence package names are
# suspicious anywhere in pacman's local metadata. No lower-confidence
# package-name IOCs are enabled by default.
while IFS= read -r meta_file; do
[[ -r "$meta_file" ]] || continue
if grep -Iq . "$meta_file" 2>/dev/null && grep -Eq -- "$MALICIOUS_JS_ERE" "$meta_file" 2>/dev/null; then
printf 'pacman-local-js-ioc:%s\n' "$meta_file" >> "$fail_hits"
fi
if grep -Iq . "$meta_file" 2>/dev/null && grep -Eq -- "$LOW_CONFIDENCE_JS_ERE" "$meta_file" 2>/dev/null; then
case "$meta_file" in
/var/lib/pacman/local/npm-*/files)
# Official package file lists can legitimately contain benign JS
# dependency paths. These are not useful as IOCs by themselves.
;;
*/install)
printf 'pacman-local-lowconf-install-ioc:%s\n' "$meta_file" >> "$fail_hits"
;;
*)
printf 'warn-pacman-local-lowconf-ioc:%s\n' "$meta_file" >> "$warn_hits"
;;
esac
fi
done < <(find /var/lib/pacman/local -mindepth 2 -maxdepth 2 -type f \
\( -name desc -o -name install -o -name files \) -print 2>/dev/null)
find /var/lib/pacman/local -name 'install' -type f -print0 2>/dev/null \
| xargs -0r grep -IlE "(npm|bun)[[:space:]]+(install|add)[^#;|&]*($ALL_JS_IOC_ERE)" 2>/dev/null \
| sed 's#^#pacman-local-install-command:#' >> "$fail_hits" || true
if grep -RIlP "\$'\\\\[x0]" /var/lib/pacman/local/*/install 2>/dev/null \
| sed 's#^#pacman-local-obfuscated-install:#' >> "$fail_hits"; then
:
fi
fi
if have npm; then
for pkg in "${MALICIOUS_JS_PKGS[@]}"; do
cache_hit="$(npm cache ls 2>/dev/null | grep -F "$pkg" | head -10 || true)"
[[ -n "$cache_hit" ]] && printf 'npm-cache:%s:%s\n' "$pkg" "$cache_hit" >> "$fail_hits"
done
for pkg in "${LOW_CONFIDENCE_JS_PKGS[@]}"; do
cache_hit="$(npm cache ls 2>/dev/null | grep -F "$pkg" | head -10 || true)"
[[ -n "$cache_hit" ]] && printf 'warn-npm-cache:%s:%s\n' "$pkg" "$cache_hit" >> "$warn_hits"
done
fi
if have bun; then
# bun pm ls output is not a stable machine-readable interface, but it gives a
# useful quick signal when Bun was the delivery path.
bun pm ls 2>/dev/null | grep -E "$MALICIOUS_JS_ERE" \
| sed 's#^#bun-pm-ls:#' >> "$fail_hits" || true
bun pm ls 2>/dev/null | grep -E "$LOW_CONFIDENCE_JS_ERE" \
| sed 's#^#warn-bun-pm-ls:#' >> "$warn_hits" || true
fi
# Light-touch AUR helper cache check. Avoid recursive grep over arbitrary /tmp.
while IFS= read -r home; do
[[ -d "$home" ]] || continue
for dir in "$home/.cache/yay" "$home/.cache/paru" "$home/.cache/pikaur" "$home/.cache/trizen"; do
[[ -d "$dir" ]] || continue
find "$dir" -maxdepth 5 \( -iname 'PKGBUILD' -o -iname '*.install' -o -iname 'install' -o -iname '.INSTALL' \) -type f -print0 2>/dev/null \
| xargs -0r grep -IlE "(npm|bun)[[:space:]]+(install|add)[^#;|&]*($ALL_JS_IOC_ERE)|bun[[:space:]]+(add|install)|\$'\\\\[x0]" 2>/dev/null \
| sed 's#^#aur-cache-install-ioc:#' >> "$fail_hits" || true
find "$dir" -maxdepth 5 \( -iname "*atomic-lockfile*" -o -iname "*js-digest*" -o -iname "*lockfile-js*" -o -iname "*nextfile-js*" \) -print 2>/dev/null \
| sed 's#^#aur-cache-name-ioc:#' >> "$fail_hits" || true
done
done < <(collect_homes)
if [[ -s "$fail_hits" ]]; then
local msg
msg="malicious npm/bun/package-manager indicator(s): $(head -30 "$fail_hits" | paste -sd '; ' -)"
[[ -s "$warn_hits" ]] && msg="$msg; lower-confidence package-name hit(s): $(head -10 "$warn_hits" | paste -sd '; ' -)"
finish_fail "$msg"
elif [[ -s "$warn_hits" ]]; then
finish_warn "lower-confidence package-name hit(s); inspect manually: $(head -20 "$warn_hits" | paste -sd '; ' -)"
else
finish_pass
fi
}
check_ebpf() {
local hits info prog_count map_count link_count pinned_count pinned suspicious_bpf
begin_check "Checking eBPF visibility and suspicious pinned/program names"
hits="$(mktemp_tracked)"
info="$(mktemp_tracked)"
: > "$hits"
: > "$info"
if ((EUID != 0)); then
printf 'limited eBPF visibility as non-root; rerun with sudo for stronger inspection\n' >> "$info"
fi
if [[ -d /sys/fs/bpf ]]; then
pinned="$(mktemp_tracked)"
find /sys/fs/bpf -mindepth 1 -maxdepth 5 -print 2>/dev/null | sort > "$pinned" || true
pinned_count="$(wc -l < "$pinned")"
if ((pinned_count > 0)); then
grep -Ei '/(atomic|hide|hook|scales|rootkit|stealth|rk|c2|rat)(/|$)' "$pinned" >> "$hits" || true
printf '%s pinned eBPF path(s) under /sys/fs/bpf; inspect if unexpected\n' "$pinned_count" >> "$info"
fi
else
printf '/sys/fs/bpf not mounted or not visible\n' >> "$info"
fi
if have bpftool; then
suspicious_bpf="$(mktemp_tracked)"
bpftool prog show 2>/dev/null | tee /tmp/atomic_arch_bpftool_prog.$$ 2>/dev/null \
| grep -Ei 'name (atomic|hide|hook|scales|rootkit|stealth|rk|c2|rat)|tag .* (atomic|hide|hook|scales|rootkit|stealth|rk|c2|rat)' > "$suspicious_bpf" || true
[[ -s "$suspicious_bpf" ]] && sed 's#^#bpftool-prog:#' "$suspicious_bpf" >> "$hits"
prog_count="$(bpftool prog show 2>/dev/null | grep -c '^' || true)"
map_count="$(bpftool map show 2>/dev/null | grep -c '^' || true)"
link_count="$(bpftool link show 2>/dev/null | grep -c '^' || true)"
printf 'bpftool visible objects: prog_lines=%s map_lines=%s link_lines=%s\n' "$prog_count" "$map_count" "$link_count" >> "$info"
rm -f /tmp/atomic_arch_bpftool_prog.$$ 2>/dev/null || true
else
printf 'bpftool unavailable; install bpftool for loaded-program/map/link inspection\n' >> "$info"
fi
if [[ -s "$hits" ]]; then
finish_fail "suspicious eBPF names/paths: $(head -10 "$hits" | paste -sd '; ' -)"
elif grep -q 'limited\|unavailable\|pinned' "$info"; then
finish_warn "$(paste -sd '; ' "$info")"
else
finish_pass
fi
}
check_hidden_processes() {
local hidden pid_dir pid comm
begin_check "Checking for /proc PIDs hidden from ps"
hidden="$(mktemp_tracked)"
: > "$hidden"
if ! have ps || [[ ! -d /proc ]]; then
finish_skip "ps or /proc unavailable"
return
fi
for pid_dir in /proc/[0-9]*; do
[[ -d "$pid_dir" ]] || continue
pid="${pid_dir##*/}"
[[ -r "$pid_dir/status" ]] || continue
comm="$(cat "$pid_dir/comm" 2>/dev/null || true)"
[[ -n "$comm" ]] || continue
if ! ps -p "$pid" >/dev/null 2>&1; then
printf '%s:%s\n' "$pid" "$comm" >> "$hidden"
fi
done
if [[ -s "$hidden" ]]; then
finish_fail "process(es) visible in /proc but not ps: $(head -20 "$hidden" | paste -sd '; ' -)"
else
finish_pass
fi
}
owned_by_pkg() {
local path="$1" pkg="$2"
have pacman || return 1
pacman -Qo -- "$path" 2>/dev/null | grep -Fq " is owned by $pkg "
}
is_known_benign_persistence_file() {
local path="$1"
case "$path" in
/usr/lib/systemd/system/xfs_scrub@.service|\
/usr/lib/systemd/system/xfs_scrub_media@.service|\
/usr/lib/systemd/system/xfs_scrub_all.service|\
/usr/lib/systemd/system/xfs_scrub_all.timer|\
/usr/lib/systemd/system/xfs_scrub_fail@.service)
owned_by_pkg "$path" xfsprogs && return 0
;;
esac
return 1
}
check_systemd_persistence() {
local hits raw_hits dirs dir file
begin_check "Checking systemd and desktop persistence for suspicious commands"
hits="$(mktemp_tracked)"
raw_hits="$(mktemp_tracked)"
: > "$hits"
: > "$raw_hits"
dirs=(/etc/systemd/system /usr/lib/systemd/system /run/systemd/system /etc/xdg/autostart /etc/pacman.d/hooks)
while IFS= read -r dir; do
dirs+=("$dir/.config/systemd/user" "$dir/.config/autostart")
done < <(collect_homes)
for dir in "${dirs[@]}"; do
[[ -d "$dir" ]] || continue
grep -RInE -- 'atomic-lockfile|js-digest|lockfile-js|nextfile-js|systemd-initd|(^|[^[:alnum:]_-])rat([^[:alnum:]_-]|$)|/dev/shm|/var/tmp/[^[:space:]]*|/tmp/[^[:space:]]+|curl[[:space:]][^|;&]*\|[[:space:]]*(sudo[[:space:]]+)?(sh|bash|zsh|fish)|wget[[:space:]][^|;&]*\|[[:space:]]*(sudo[[:space:]]+)?(sh|bash|zsh|fish)|bun[[:space:]]+(add|install)|base64[[:space:]-]+d|LD_PRELOAD|Exec(Start|)=.*(^|[^[:alnum:]_-])(nc|ncat|socat)[[:space:]]' "$dir" 2>/dev/null \
| grep -Ev '^[^:]+:[0-9]+:[[:space:]]*#' \
| sed 's/:.*//' >> "$raw_hits" || true
done
sort -u "$raw_hits" | while IFS= read -r file; do
[[ -n "$file" ]] || continue
is_known_benign_persistence_file "$file" && continue
printf '%s\n' "$file"
done > "$hits"
if [[ -s "$hits" ]]; then
finish_fail "suspicious persistence file(s): $(head -30 "$hits" | paste -sd '; ' -)"
else
finish_pass
fi
}
check_network() {
local ssout hits warnhits
begin_check "Checking suspicious established network connections"
if ! have ss; then
finish_skip "ss not found"
return
fi
ssout="$(mktemp_tracked)"
hits="$(mktemp_tracked)"
warnhits="$(mktemp_tracked)"
ss -Htnap 2>/dev/null > "$ssout" || ss -Htn 2>/dev/null > "$ssout" || true
if [[ ! -s "$ssout" ]]; then
finish_skip "no ss output; insufficient permissions or no TCP data"
return
fi
grep -E 'ESTAB' "$ssout" \
| grep -E 'users:\(\("(sh|bash|dash|zsh|fish)"' \
| grep -Ev '127\.0\.0\.1|::1' >> "$hits" || true
grep -E 'ESTAB' "$ssout" \
| grep -E ":$SUSPICIOUS_PORT_RE[[:space:]]" >> "$warnhits" || true
grep -E 'ESTAB' "$ssout" \
| grep -E 'users:\(\("(python|python3|perl|ruby|node|php)"' \
| grep -Ev '127\.0\.0\.1|::1' >> "$warnhits" || true
if [[ -s "$hits" ]]; then
finish_fail "shell interpreter with established TCP connection(s): $(head -10 "$hits" | paste -sd '; ' -)"
elif [[ -s "$warnhits" ]]; then
local msg
msg="established connection(s) involving suspicious port or script runtime; inspect manually: $(head -10 "$warnhits" | paste -sd '; ' -)"
((EUID != 0)) && msg="$msg; process names may be incomplete as non-root"
finish_warn "$msg"
elif ((EUID != 0)); then
finish_warn "no suspicious connection found, but process ownership details may be incomplete as non-root"
else
finish_pass
fi
}
check_ld_preload() {
begin_check "Checking /etc/ld.so.preload"
if [[ -s /etc/ld.so.preload ]]; then
finish_fail "/etc/ld.so.preload exists and is non-empty: $(tr '\n' ' ' < /etc/ld.so.preload 2>/dev/null | cut -c1-300)"
else
finish_pass
fi
}
check_volatile_executables() {
local hits pid_dir pid exe comm cmdline
begin_check "Checking running executables from volatile/deleted paths"
hits="$(mktemp_tracked)"
: > "$hits"
[[ -d /proc ]] || { finish_skip "/proc unavailable"; return; }
for pid_dir in /proc/[0-9]*; do
[[ -d "$pid_dir" ]] || continue
pid="${pid_dir##*/}"
exe="$(readlink "$pid_dir/exe" 2>/dev/null || true)"
[[ -n "$exe" ]] || continue
comm="$(cat "$pid_dir/comm" 2>/dev/null || true)"
cmdline="$(tr '\0' ' ' < "$pid_dir/cmdline" 2>/dev/null | cut -c1-160 || true)"
case "$comm" in
chrome|google-chrome|chromium|chromium-browser|electron|electron[0-9]*|firefox|firefox-bin|brave|brave-browser|opera|opera-browser|vivaldi|vivaldi-bin|msedge|microsoft-edge|nacl_helper|chrome_crashpad|chrome_sandbox|PepperFlash|renderer|gpu-process|utility)
[[ "$exe" == *"(deleted)"* ]] && continue
;;
esac
case "$exe" in
/tmp/*|/dev/shm/*|/var/tmp/*|*"(deleted)"*)
printf '%s:%s:%s:%s\n' "$pid" "$comm" "$exe" "$cmdline" >> "$hits"
;;
esac
done
if [[ -s "$hits" ]]; then
finish_fail "process executable path indicator(s): $(head -20 "$hits" | paste -sd '; ' -)"
else
finish_pass
fi
}
check_shell_configs() {
local hits home files f pattern
begin_check "Checking shell/session config injection patterns"
hits="$(mktemp_tracked)"
: > "$hits"
# Extended but still heuristic. The output must be manually reviewed.
pattern='(curl|wget)[[:space:]][^|;&]*\|[[:space:]]*(sudo[[:space:]]+)?(sh|bash|zsh|fish|python|python3|perl|ruby|node)\b|\b(sh|bash|zsh|fish)[[:space:]]*<\([[:space:]]*(curl|wget)\b|eval[[:space:]]+["'"'"']?\$\([[:space:]]*(curl|wget|base64|python|python3|perl|ruby|node|bash|sh)\b|base64[[:space:]]+(-d|--decode)[[:space:]]*\||LD_PRELOAD=|BASH_ENV=|ENV=|PROMPT_COMMAND=|bun[[:space:]]+(add|install)|atomic-lockfile|js-digest|lockfile-js|nextfile-js|/dev/shm/|/var/tmp/|/tmp/.*(sh|bash|zsh|fish|python|perl|ruby|node)'
while IFS= read -r home; do
[[ -d "$home" ]] || continue
files=(
"$home/.bashrc" "$home/.bash_profile" "$home/.bash_login" "$home/.profile"
"$home/.zshrc" "$home/.zprofile" "$home/.zshenv" "$home/.zlogin"
"$home/.xprofile" "$home/.config/fish/config.fish"
)
for f in "$home"/.config/fish/conf.d/*.fish; do
[[ -f "$f" ]] && files+=("$f")
done
for f in "${files[@]}"; do
[[ -f "$f" && -r "$f" ]] || continue
grep -nE "$pattern" "$f" 2>/dev/null \
| grep -Ev "^[0-9]+:[[:space:]]*#" \
| head -5 \
| sed "s#^#$f:#" >> "$hits" || true
done
done < <(collect_homes)
if [[ -s "$hits" ]]; then
finish_fail "suspicious shell/session config line(s): $(head -20 "$hits" | paste -sd '; ' -)"
else
finish_pass
fi
}
check_npm_hooks() {
local hits roots_file
begin_check "Checking global npm/bun package lifecycle hooks recursively"
hits="$(mktemp_tracked)"
roots_file="$(mktemp_tracked)"
: > "$hits"
if ! have python3; then
finish_skip "python3 not found; cannot safely parse package.json files"
return
fi
while IFS= read -r root; do
if [[ -d "$root" ]]; then
printf '%s\0' "$root"
fi
done < <(collect_node_module_roots; collect_bun_module_roots) > "$roots_file"
if [[ ! -s "$roots_file" ]]; then
finish_pass "no global node_modules roots found"
return
fi
python3 - "$roots_file" <<'PYCODE' >> "$hits" 2>/dev/null || true
import json, os, re, sys
roots_path = sys.argv[1]
needle = re.compile(r'(atomic-lockfile|js-digest|lockfile-js|nextfile-js|\bcurl\b|\bwget\b|\beval\b|child_process|base64\s+(-d|--decode)|bun\s+(add|install)|/dev/shm|/var/tmp|/tmp/|(^|[^A-Za-z0-9_-])(nc|ncat|socat)([^A-Za-z0-9_-]|$))', re.I)
hooks = ('preinstall', 'install', 'postinstall', 'prepare', 'prepublish', 'prepublishOnly')
with open(roots_path, 'rb') as f:
roots = [p.decode('utf-8', 'replace') for p in f.read().split(b'\0') if p]
seen = set()
for root in roots:
for base, dirs, files in os.walk(root):
rel = os.path.relpath(base, root)
depth = 0 if rel == '.' else rel.count(os.sep) + 1
if depth > 3:
dirs[:] = []
continue
if 'package.json' not in files:
continue
path = os.path.join(base, 'package.json')
if path in seen:
continue
seen.add(path)
try:
with open(path, 'r', encoding='utf-8', errors='replace') as pf:
pkg = json.load(pf)
except Exception:
continue
name = pkg.get('name') or os.path.basename(base)
scripts = pkg.get('scripts') or {}
for hook in hooks:
val = scripts.get(hook)
if isinstance(val, str) and needle.search(val):
print(f'{path}:{name}:{hook}:{val[:220]}')
PYCODE
if [[ -s "$hits" ]]; then
finish_fail "suspicious npm lifecycle hook(s): $(head -20 "$hits" | paste -sd '; ' -)"
else
finish_pass
fi
}
check_ssh_authorized_keys() {
local hits home f count
begin_check "Checking SSH authorized_keys presence and forced commands"
hits="$(mktemp_tracked)"
: > "$hits"
while IFS= read -r home; do
f="$home/.ssh/authorized_keys"
[[ -f "$f" && -r "$f" ]] || continue
count="$(grep -cve '^[[:space:]]*#' -e '^[[:space:]]*$' "$f" 2>/dev/null || true)"
if [[ "$count" =~ ^[0-9]+$ ]] && ((count > 0)); then
if grep -qE '(^|,)command=' "$f" 2>/dev/null; then
printf '%s:%s-key(s):forced-command\n' "$f" "$count" >> "$hits"
else
printf '%s:%s-key(s)\n' "$f" "$count" >> "$hits"
fi
fi
done < <(collect_homes)
if [[ -s "$hits" ]]; then
finish_warn "authorized_keys exists; verify all keys are expected: $(paste -sd '; ' "$hits")"
else
finish_pass
fi
}
print_header() {
cat <<HDR
${CYAN}============================================================${NC}
Atomic Arch / atomic-lockfile triage helper $VERSION
Remote IOC fetching: enabled, latest-list trust model intentionally preserved
Incident window: ${INCIDENT_START}..${INCIDENT_END:-open}
Pacman log glob: $PACMAN_LOG_GLOB
Effective user: $(id -un 2>/dev/null || echo unknown) (uid=$EUID), HOME=$HOME
All-user scan: $SCAN_ALL_USERS
High-confidence JS package IOCs: $(printf '%s ' "${MALICIOUS_JS_PKGS[@]}" | sed 's/[[:space:]]$//')
Lower-confidence JS package IOCs: $([[ ${#LOW_CONFIDENCE_JS_PKGS[@]} -gt 0 ]] && printf '%s ' "${LOW_CONFIDENCE_JS_PKGS[@]}" | sed 's/[[:space:]]$//' || printf 'none')
${CYAN}============================================================${NC}
HDR
}
print_summary() {
echo
echo "${CYAN}============================================================${NC}"
echo "Results: ${GREEN}$PASS pass${NC}, ${YELLOW}$WARN warn${NC}, ${RED}$FAIL fail${NC}, ${YELLOW}$SKIP skip${NC}, total=$TOTAL"
echo "${CYAN}============================================================${NC}"
if ((${#DETAILS[@]} > 0)); then
echo
printf '%s\n' "${DETAILS[@]}"
fi
if ((FAIL > 0)); then
cat <<MSG
${RED}Indicators of compromise were found.${NC}
If atomic-lockfile/js-digest/lockfile-js/nextfile-js or an affected AUR package was installed during the incident window,
treat the host as compromised. A clean reinstall and credential/key rotation are safer than trying
to surgically remove a rootkit-capable compromise from the live system.
MSG
elif ((WARN > 0 || SKIP > 0)); then
cat <<MSG
${YELLOW}No hard fail was found, but some checks were warning/limited/skipped.${NC}
Run as root for stronger eBPF, network-process, /root, and system-wide visibility.
MSG
else
echo
echo "${GREEN}No indicators found by this best-effort scanner.${NC}"
fi
cat <<MSG
Disclaimer: this is a heuristic scanner. It can miss sophisticated compromise and can produce false positives.
MSG
}
main() {
local ioc_file
ioc_file="$(mktemp_tracked)"
print_header
load_ioc_list "$ioc_file" || true
check_current_foreign_packages "$ioc_file"
check_pacman_history "$ioc_file"
check_js_package_and_metadata
check_ebpf
check_hidden_processes
check_systemd_persistence
check_network
check_ld_preload
check_volatile_executables
check_shell_configs
check_npm_hooks
check_ssh_authorized_keys
print_summary
if ((FAIL > 0)); then
exit 2
elif ((WARN > 0 || SKIP > 0)); then
exit 1
else
exit 0
fi
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment