Guilgo Blog

Notes from my daily work with technology.

On June 9, 2026, one of the most significant supply chain attacks in the Arch Linux ecosystem was disclosed: more than 1,600 AUR (Arch User Repository) packages had been compromised with an infostealer and an eBPF rootkit. This article explains how I integrated automatic detection into my homelab using Wazuh so that if an infected package lands on my laptop, I get a Telegram alert in under an hour.

This does not replace manually reviewing what you installed during the attack window. What it adds is continuous monitoring: the community list keeps growing, and you may install a package without knowing it was compromised weeks ago.

If you already run Wazuh in your lab, this fits with what’s documented on the blog about building a proactive SOC, Wazuh 5 in production, and Telegram alerts.

The attack: atomic-lockfile and js-digest

The campaign, known as atomic-lockfile, worked like this:

  1. Attackers took over maintainer accounts for orphaned AUR packages (some via identity spoofing, with forged git commit --author).
  2. They injected npm install atomic-lockfile or bun install js-digest into .install or .hook files in PKGBUILDs.
  3. Those npm/bun packages contained a Rust ELF binary that, when run during installation, stole credentials (GitHub tokens, Discord, SSH keys, browser cookies, CI/CD secrets) and exfiltrated them via temp.sh to a C2 over Tor.
  4. If the user installed with root privileges, the malware also deployed an eBPF rootkit that hid processes, files, and socket inodes.

Two distinct waves:

  • Wave 1 (Jun 9–12): atomic-lockfile / lockfile-js via npm — accounts krisztinavarga, franziskaweber, tobiaswesterburg, ellenmyklebust.
  • Wave 2 (Jun 12): js-digest via bun — accounts custodiatovar, veramagalhaes.

The lenucksi/aur-malware-check repository consolidated community detection lists: ~1,619 compromised packages and check scripts.

Am I affected?

Risk exists only if all three conditions are met:

  • You use AUR packages (not official Arch repos).
  • You installed one during the attack window (June 9–12, 2026, or later documented waves).
  • That specific package was compromised at install time.

Official Arch Linux repositories were not affected. This is also not a remote exploit against pacman: you must have run installation of a poisoned PKGBUILD. If you don’t use AUR, direct risk is zero; if you do, the operational question is whether any of your packages were on the list — and whether you keep installing from AUR without reviewing maintainer history.

Detection architecture

The idea is simple: every hour, the Wazuh agent on the laptop runs a script that compares installed AUR packages against the compromised list. On a match, the manager raises a level-12 alert and I get Telegram.

Laptop (Wazuh agent)
  └── logcollector full_command every 1h
        └── aur-malware-check-agent.sh
              ├── pacman -Qmq  → installed AUR packages
              ├── curl → package_list.txt (24h cache)
              └── comm -12 → intersection
                    ├── Clean     → "AUR_MALWARE_CHECK: clean"     (level 0, silent)
                    └── Infected  → "AUR_MALWARE_DETECTED: infected_packages=pkg1 pkg2"
                                          ↓
Wazuh manager (homelab)
  ├── Decoder: extracts package list as extra_data
  ├── Rule 125001 level 12 → alert
  └── Telegram integration → immediate notification

Agent script

The script runs as root (executed by wazuh-logcollector) and uses comm to compute the intersection between installed AUR packages and the compromised list, cached locally to avoid an HTTP request on every run:

#!/bin/bash
# /usr/local/bin/aur-malware-check-agent.sh

PACKAGE_LIST_URL="https://raw.githubusercontent.com/lenucksi/aur-malware-check/master/package_list.txt"
LIST_CACHE="/var/ossec/tmp/aur-malware-list.txt"
LIST_MAX_AGE=86400  # 24h

mkdir -p "$(dirname "$LIST_CACHE")"

# Refresh cache if older than 24h
if [ ! -f "$LIST_CACHE" ]; then
    NEEDS_REFRESH=true
else
    AGE=$(( $(date +%s) - $(stat -c %Y "$LIST_CACHE" 2>/dev/null || echo 0) ))
    [ "$AGE" -gt "$LIST_MAX_AGE" ] && NEEDS_REFRESH=true || NEEDS_REFRESH=false
fi

if [ "$NEEDS_REFRESH" = "true" ]; then
    curl -s --max-time 20 --fail "$PACKAGE_LIST_URL" -o "${LIST_CACHE}.tmp" 2>/dev/null \
        && mv "${LIST_CACHE}.tmp" "$LIST_CACHE" \
        || { [ ! -f "$LIST_CACHE" ] && echo "AUR_MALWARE_CHECK: ERROR cannot fetch package list" && exit 1; }
fi

AUR_PKGS=$(pacman -Qmq 2>/dev/null)
[ -z "$AUR_PKGS" ] && echo "AUR_MALWARE_CHECK: clean" && exit 0

INFECTED=$(comm -12 <(echo "$AUR_PKGS" | sort) <(sort "$LIST_CACHE"))

if [ -n "$INFECTED" ]; then
    echo "AUR_MALWARE_DETECTED: infected_packages=$(echo "$INFECTED" | tr '\n' ' ' | sed 's/ $//')"
    exit 2
else
    echo "AUR_MALWARE_CHECK: clean"
fi

Output is always a single line with a fixed prefix, which makes parsing in Wazuh straightforward.

Wazuh decoder

Here is the most important technical gotcha: Wazuh regex is not POSIX. In Wazuh’s rules engine, . is a literal dot and \. means “any character” (the opposite of usual regex). Using (.+) in a Wazuh decoder does not capture free text — it captures one or more literal dots. The correct form is (\.+).

Also, when using full_command with an alias on the agent, Wazuh extracts that alias as program_name in the syslog pre-decoder. So the parent decoder must use <program_name> instead of <prematch>:

<!-- localfile alias ("aur-malware-check") arrives as program_name -->
<decoder name="aur-malware-check">
    <program_name>aur-malware-check</program_name>
</decoder>

<!-- \. = any character in Wazuh regex (not a literal dot) -->
<decoder name="aur-malware-detected">
    <parent>aur-malware-check</parent>
    <regex>AUR_MALWARE_DETECTED: infected_packages=(\.+)</regex>
    <order>extra_data</order>
</decoder>

<decoder name="aur-malware-clean">
    <parent>aur-malware-check</parent>
    <prematch>AUR_MALWARE_CHECK: clean</prematch>
</decoder>

<decoder name="aur-malware-error">
    <parent>aur-malware-check</parent>
    <regex>AUR_MALWARE_CHECK: ERROR (\.+)</regex>
    <order>extra_data</order>
</decoder>

Rules

Four rules cover the possible cases. The ones that matter for alerting are 125001 (detected) and 125002 (first event per agent, FTS):

<group name="aur_malware,supply_chain,arch_linux,">

  <!-- decoded_as references the parent decoder name (how Wazuh reports it) -->
  <!-- match distinguishes infected vs clean within the same decoder family   -->
  <rule id="125001" level="12">
    <decoded_as>aur-malware-check</decoded_as>
    <match>AUR_MALWARE_DETECTED:</match>
    <description>AUR SUPPLY CHAIN: compromised packages installed - $(extra_data)</description>
    <group>aur_malware,supply_chain,gdpr_IV_35.7.d,pci_dss_11.5,</group>
    <mitre>
      <id>T1195.001</id>  <!-- Supply Chain Compromise -->
      <id>T1546</id>      <!-- Persistence via systemd  -->
      <id>T1056</id>      <!-- Credential Theft         -->
    </mitre>
  </rule>

  <rule id="125002" level="14">
    <if_sid>125001</if_sid>
    <if_fts />
    <description>AUR SUPPLY CHAIN (FIRST EVENT): new infected host - $(extra_data)</description>
    <group>aur_malware,supply_chain,</group>
  </rule>

  <rule id="125010" level="5">
    <decoded_as>aur-malware-check</decoded_as>
    <match>AUR_MALWARE_CHECK: ERROR</match>
    <description>AUR malware check: error running check - $(extra_data)</description>
    <group>aur_malware,</group>
  </rule>

  <rule id="125020" level="0">
    <decoded_as>aur-malware-check</decoded_as>
    <match>AUR_MALWARE_CHECK: clean</match>
    <description>AUR malware check: system clean</description>
    <group>aur_malware,</group>
  </rule>

</group>

On the manager, I added the aur_malware group to the Telegram integration already configured for other homelab cases (same pattern as parental control with Wazuh and Telegram): minimum level 7, JSON format, custom script forwarding to the bot.

Agent configuration (ossec.conf on the laptop)

The key is the localfile block with full_command and an alias that matches the decoder’s program_name:

<localfile>
  <log_format>full_command</log_format>
  <command>/usr/local/bin/aur-malware-check-agent.sh</command>
  <alias>aur-malware-check</alias>
  <frequency>3600</frequency>
</localfile>

The alias is what Wazuh uses as program_name in the internally generated syslog log. Without it, the decoder won’t match.

Validation with wazuh-logtest

To validate the full pipeline before deploy, Wazuh includes wazuh-logtest. The trick is to send a full syslog line (with timestamp and hostname) followed by a blank line:

printf 'Jun 14 12:00:00 laptop aur-malware-check: AUR_MALWARE_DETECTED: infected_packages=foo-pkg bar-pkg\n\n' \
  | docker exec -i wazuh-manager /var/ossec/bin/wazuh-logtest

Expected output:

**Phase 1: Completed pre-decoding.
    program_name: 'aur-malware-check'

**Phase 2: Completed decoding.
    name: 'aur-malware-check'
    extra_data: 'foo-pkg bar-pkg'

**Phase 3: Completed filtering (rules).
    id: '125001'
    level: '12'
    description: 'AUR SUPPLY CHAIN: compromised packages installed - foo-pkg bar-pkg'
    mitre.id: ['T1195.001', 'T1546', 'T1056']
**Alert to be generated.

Lessons learned along the way

1. Wazuh regex is not POSIX

(.+) does not capture free text. In Wazuh, . is literal and \. is the wildcard. It took several wazuh-logtest cycles to figure that out.

2. decoded_as uses the parent decoder name

When a child decoder extracts fields, Wazuh reports the parent decoder name as name in Phase 2. So <decoded_as> in the rule must reference the parent, not the child. To distinguish cases (infected vs clean), add <match> with the specific text.

3. full_command needs alias for the decoder to work

Without alias, program_name in the internal syslog log would be the full script path. With alias, it’s the clean name that matches the decoder.

4. log_format: systemd is not available on all agent versions

On CachyOS with the AUR wazuh-agent package, logcollector rejects systemd as log_format. Use syslog with an explicit path, or omit it.

5. agent-auth uses -A for the name, not -n

On the agent version I use, -n is for the network interface. The correct flag for the agent name is -A.

Outcome

My Arch Linux / CachyOS laptop shows up on the manager as an active agent. The check runs automatically every hour. If I install a compromised AUR package tomorrow, I’ll get a Telegram message with its name before the next hour passes.

At the time of writing: 0 infected packages. None of my installed AUR packages appear on the compromised list.

Attacks like this show that security no longer depends only on the operating system or antivirus. The software supply chain has become one of the most effective vectors for compromising machines. With under 50 lines of Bash and a few Wazuh rules, you can add a continuous monitoring layer that catches this kind of incident before it goes unnoticed.

Resources