Last active
June 14, 2026 20:51
-
-
Save tienngtr/83a1f17f133dc85d8dc4287f0a03f6f0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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