Detecting APT repository tampering on Ubuntu with Wazuh

| by | Wazuh 4.14.10
Post icon

Ubuntu systems rely on the Advanced Package Tool (APT) to install and update software from trusted sources. APT repository tampering abuses this trust by changing package sources or weakening verification settings. This allows attackers with root access to make systems install malicious software as if it were a normal update. Attackers can add malicious repositories, import unauthorized signing keys, disable signature checks, or increase the priority of their packages over official ones. These changes can go unnoticed, allowing subsequent apt upgrade operations to install attacker-controlled packages and maintain access to compromised systems. MITRE ATT&CK describes it as Compromise Software Supply Chain (T1195.002).

The Wazuh platform compares APT configurations against an approved baseline with its Security Configuration Assessment (SCA) and File integrity monitoring (FIM) capabilities. It also reads APT update errors from the systemd journal and alerts on signature, integrity, expiry, and network failures.

In this blog post, we demonstrate how Wazuh detects APT repository tampering on Ubuntu 24.04 endpoints. The detections cover unexpected repositories, signing-key trust changes, weakened verification, unexpected package selection, and APT update failures.

APT repository security

APT verifies repository metadata using trusted signing keys, and checks downloaded packages against their checksums, as described in apt-secure(8). These checks help detect unauthorized modifications. However, a valid signature only confirms that a trusted key signed the metadata. It does not guarantee that the packages are safe. Certain APT settings weaken these protections:

  • The Signed-By option restricts the signing keys APT accepts for a repository. These keys are stored in files called keyrings, as described in sources.list(5). An attacker who removes or changes this restriction alters which keys APT trusts.
  • APT pinning controls the priority of package versions, as described in apt_preferences(5). An unauthorized preference can cause APT to select packages from an unapproved repository.

The following table lists the main APT configuration files that attackers modify to tamper with repositories.

ConfigurationPurposeLocations
Repository definitionsSpecify package sources/etc/apt/sources.list
/etc/apt/sources.list.d/
Signing keysEstablish which keys can authenticate a repository/etc/apt/keyrings/
/usr/share/keyrings/
/etc/apt/trusted.gpg.d/
/etc/apt/trusted.gpg
Global APT settingsControl verification behavior/etc/apt/apt.conf
/etc/apt/apt.conf.d/
Package prioritiesInfluence which package version APT selects/etc/apt/preferences
/etc/apt/preferences.d/

How Wazuh detects APT repository tampering

Wazuh compares APT configurations against a baseline of approved repositories, signing keys, verification settings, and package preferences. Administrators and software installers may also modify these configurations legitimately, so detected deviations require investigation.

Wazuh uses the following capabilities to detect configuration deviations and APT update failures:

  • Security Configuration Assessment (SCA): Compares APT configuration values against the approved baseline. Two helper scripts provide signing-key and package-preference information for these checks.
  • File integrity monitoring (FIM): Detects changes to APT configuration and baseline files. With who-data monitoring enabled, FIM also identifies the user and process responsible for each change.
  • Log data collection: Reads systemd journal entries through the Journald log source. On systemd endpoints, the Wazuh agent installer adds this source to /var/ossec/etc/ossec.conf. Custom rules detect APT update failures related to repository signatures, integrity checks, expired metadata, and network connectivity.

These capabilities identify deviations and errors, not confirmed attacks. Update failures can also have operational causes, such as network outages or incorrect system clocks. Coverage also depends on the SCA policy and the approved baseline.

The following table summarizes the detection use cases and their expected results.

Use caseDetection methodExpected result 
Unapproved repositorySCA and FIMA repository differs from the approved configuration.
Signing-key trust changesSCA and FIMThe trusted signers of a repository differ from the baseline.
Weakened repository verificationSCA and FIMAPT settings permit weaker verification than the approved configuration.
Package selection changesSCA and FIMPackage preferences or the selected package source differ from approved values.
APT update failuresLog data collectionThe daily update reports a signature, integrity, expiry, or network failure for a repository.

Infrastructure

The test environment includes a Wazuh server and an Ubuntu endpoint with the Docker repository configured to simulate APT repository tampering.

We use the following infrastructure to demonstrate the detections:

  • A prebuilt, ready-to-use Wazuh 4.14.10 virtual machine (OVA), which includes the Wazuh central components (Wazuh server, Wazuh indexer, and Wazuh dashboard). Follow the Wazuh virtual machine guide to download and set up the Wazuh virtual machine. 
  • An Ubuntu 24.04 endpoint with:
    • The Wazuh agent 4.14.10 installed and enrolled in the Wazuh server.
    • The Docker apt repository configured. This setup creates the /etc/apt/keyrings/docker.asc signing key and the /etc/apt/sources.list.d/docker.sources repository file, which the simulation modifies. 

Configure the detection

The detection uses a custom decoder and rules on the Wazuh server, along with baseline files, an SCA policy, and monitoring settings on the Ubuntu endpoint.

Ubuntu endpoint

Perform the following steps on the monitored Ubuntu endpoint as the root user:

Install the required packages

The detection uses additional packages to inspect repository signing keys and identify users and processes responsible for file changes.

Install auditd for who-data monitoring and GnuPG for signing-key checks:

# apt-get install -y auditd gnupg

Prepare the APT monitoring baseline

We establish a trusted baseline for evaluating APT configurations. It includes scripts that inspect signing keys and package preferences, along with a list of approved signers. 

Note

The scripts in this section are a proof of concept. Review and validate them to confirm they meet the operational and security requirements of your environment.

  1. Create the directory apt-trust in /var/ossec/etc/ with permissions 750, owned by root, to store the detection scripts and approved signer list: 
# install -d -m 750 -o root -g root /var/ossec/etc/apt-trust
  1. Create three empty files in /var/ossec/etc/apt-trust/ with permissions 640, owned by user root and group root, to hold the scripts and signer list: 
# install -m 640 -o root -g root /dev/null /var/ossec/etc/apt-trust/apt-trust-keys.py
# install -m 640 -o root -g root /dev/null /var/ossec/etc/apt-trust/apt-trust-prefs.py
# install -m 640 -o root -g root /dev/null /var/ossec/etc/apt-trust/apt-trust-signers.txt

The following table describes the files.

FilePurpose
apt-trust-keys.pyIdentifies signing keys accepted by APT and compares them with approved signers.
apt-trust-prefs.pyPrepares APT preference records for baseline comparison.
apt-trust-signers.txtContains the approved signing-key fingerprints.
  1. Add the following content to /var/ossec/etc/apt-trust/apt-trust-keys.py. The script identifies the signing keys that APT accepts for each repository and compares them with the approved list in /var/ossec/etc/apt-trust/apt-trust-signers.txt. 
#!/usr/bin/env python3
"""apt-trust-keys: compare the signers APT accepts for each repository with an
approved list, and print one line per repository for a Wazuh SCA check.

Output lines:
  OK <uri>                         signers match the approved set
  UNAPPROVED <uri> signers=<set>   signers differ from the approved set
  UNLISTED <uri>                   repository not in the list (treated as a failure)
  ERROR <uri> <reason>             signers could not be determined (treated as a failure)
  ERROR baseline|sources <reason>  the signer list or the source files could not be evaluated
The exit status is 1 when any ERROR line is printed.
"""
import glob, os, re, subprocess, sys, tempfile, urllib.parse

PRINT = "--print-baseline" in sys.argv  # print current signer sets for review instead of comparing
args = [a for a in sys.argv[1:] if a != "--print-baseline"]
APPROVED = args[0] if args else "/var/ossec/etc/apt-trust/apt-trust-signers.txt"
GLOBAL = sorted(glob.glob("/etc/apt/trusted.gpg.d/*.gpg") + glob.glob("/etc/apt/trusted.gpg.d/*.asc"))
if os.path.isfile("/etc/apt/trusted.gpg"):
    GLOBAL.insert(0, "/etc/apt/trusted.gpg")


def norm(uri):
    p = urllib.parse.urlsplit(uri)  # drop user:password@ and the trailing slash
    return f"{p.scheme}://{p.hostname}{':%d' % p.port if p.port else ''}{p.path.rstrip('/')}"


def keys(path=None, data=None):
    """{primary fingerprint: [subkey fingerprints]} for a keyring file or an armored key."""
    if path:  # APT verifies signatures as the unprivileged _apt user: test real access, including directories and ACLs
        if subprocess.run(["runuser", "-u", "_apt", "--", "test", "-r", path], capture_output=True, timeout=30).returncode != 0:
            raise ValueError(f"{path} is not readable by _apt")
    with tempfile.TemporaryDirectory() as home:
        res = subprocess.run(["gpg", "--homedir", home, "--batch", "--with-colons", "--show-keys"] + ([path] if path else []),
                             input=data, capture_output=True, text=True, timeout=30)
    if res.returncode != 0:  # never accept a partial key listing
        raise ValueError(f"gpg failed on {path or 'embedded Signed-By'}: " + (res.stderr.strip().splitlines() or ["exit %d" % res.returncode])[-1])
    out = res.stdout
    result, primary, last = {}, None, None
    for f in (l.split(":") for l in out.splitlines()):
        if f[0] in ("pub", "sub"):
            last = f[0]
        elif f[0] == "fpr" and last == "pub":
            primary = f[9]; result[primary] = []; last = None
        elif f[0] == "fpr" and last == "sub":
            result[primary].append(f[9]); last = None
    if not result:
        raise ValueError(f"no OpenPGP key in {path or 'embedded Signed-By'}")
    return result


def signers(signed_by):
    """Signer set APT accepts: "P" = key P and its subkeys, "P!" = P only, "P/S" = subkey S of P."""
    if signed_by is None or not signed_by.strip():
        return ["*global*"]  # no or empty Signed-By (APT 2.8 treats both alike): any global key is accepted
    if "BEGIN PGP PUBLIC KEY BLOCK" in signed_by:
        return sorted(keys(data=signed_by))
    tokens = [t for t in re.split(r"[\s,]+", signed_by) if t]
    unsupported = [t for t in tokens if not (t.startswith("/") or re.fullmatch(r"[0-9A-Fa-f]{40}!?", t))]
    if unsupported:  # never read an unknown value as global trust
        raise ValueError("unsupported Signed-By value: " + " ".join(unsupported))
    paths = [t for t in tokens if t.startswith("/")]
    selectors = [t.upper() for t in tokens if not t.startswith("/")]
    pool = {}
    for p in paths or GLOBAL:
        pool.update(keys(path=p))
    if not selectors:
        return sorted(pool)
    out = []
    for s in selectors:
        f, bang = s.rstrip("!"), "!" if s.endswith("!") else ""
        out += [f + bang for p in pool if p == f] + [f"{p}/{f}{bang}" for p, subs in pool.items() if f in subs]
    if not out:
        raise ValueError("no key matches Signed-By")
    return sorted(out)


def sources():
    """(uri, Signed-By value) for every enabled source in both source formats."""
    for path in ["/etc/apt/sources.list"] + sorted(glob.glob("/etc/apt/sources.list.d/*.list")):
        for line in open(path) if os.path.isfile(path) else []:
            m = re.match(r"\s*deb(?:-src)?\s+(?:\[([^\]]*)\]\s+)?(\S+)", line.split("#", 1)[0])
            if m:
                opts = dict(o.split("=", 1) for o in (m.group(1) or "").split() if "=" in o)
                yield m.group(2), opts.get("signed-by")
    for path in sorted(glob.glob("/etc/apt/sources.list.d/*.sources")):
        for stanza in re.split(r"\n\s*\n", open(path).read()):
            fields, key = {}, None
            for line in stanza.splitlines():
                if line.startswith("#"):
                    continue
                if line[:1] in (" ", "\t") and key:
                    fields[key] += "\n" + ("" if line.strip() == "." else line[1:])
                elif ":" in line:
                    key, value = line.split(":", 1)
                    key = key.strip().lower(); fields[key] = value.strip()
            if not fields or fields.get("enabled", "yes").lower() in ("no", "false", "0"):
                continue
            if not (fields.get("types") and fields.get("uris") and fields.get("suites")):
                raise ValueError(f"malformed entry in {path}: Types, URIs, and Suites are required")
            for uri in fields.get("uris", "").split():
                yield uri, fields["signed-by"].strip() if "signed-by" in fields else None


def apt_uris():
    """Repository URIs that APT itself uses: a URI-level consistency check that the parser saw every source."""
    res = subprocess.run(["apt-get", "indextargets", "--no-release-info", "--format", "$(REPO_URI)"],
                         capture_output=True, text=True, timeout=60)
    if res.returncode != 0 or not res.stdout.strip():
        raise ValueError("apt-get indextargets failed: " + (res.stderr.strip().splitlines() or ["no output"])[-1])
    return {norm(u) for u in res.stdout.split()}


errors = 0
SIGNER = re.compile(r"(\*global\*|none|[0-9A-F]{40}(/[0-9A-F]{40})?!?)")
approved = {}
try:
    for n, line in enumerate([] if PRINT else open(APPROVED), 1):
        if line.strip() and not line.startswith("#"):
            fields = line.split()  # several lines for one repository = several accepted signer sets
            if len(fields) != 2 or not (fields[0] == "global" or re.match(r"https?://", fields[0])) \
                    or not all(SIGNER.fullmatch(f) for f in fields[1].split(",")):
                raise ValueError(f"line {n} is not '<uri> <signer,...>'")
            uri, sset = fields
            approved.setdefault("global" if uri == "global" else norm(uri), []).append([] if sset == "none" else sorted(sset.split(",")))
except (ValueError, OSError) as exc:
    print(f"ERROR baseline {APPROVED}: {exc}")
    sys.exit(1)

try:
    pool = {}
    for p in GLOBAL:
        pool.update(keys(path=p))
    if PRINT:
        print("global " + (",".join(sorted(pool)) or "none"))  # "none": no keys in the global keyrings
    else:
        print(("OK" if sorted(pool) in approved.get("global", []) else "UNAPPROVED") + " global signers=" + (",".join(sorted(pool)) or "none"))
except (ValueError, OSError, KeyError, subprocess.TimeoutExpired) as exc:
    print(f"ERROR global {exc}"); errors += 1

try:
    entries = list(sources())
    unparsed = apt_uris() - {norm(u) for u, _ in entries}
    if unparsed:
        raise ValueError("APT uses sources that this script did not read: " + " ".join(sorted(unparsed)))
except (ValueError, OSError, subprocess.TimeoutExpired) as exc:
    print(f"ERROR sources {exc}"); errors += 1
    entries = []

seen = set()
for uri, signed_by in entries:
    if (norm(uri), signed_by) in seen:
        continue
    seen.add((norm(uri), signed_by))
    if not PRINT and norm(uri) not in approved:
        print(f"UNLISTED {norm(uri)}")
        continue
    try:
        s = signers(signed_by)
        if PRINT:
            print(f"{norm(uri)}/ {','.join(s)}")
            continue
        print(("OK " if s in approved[norm(uri)] else "UNAPPROVED ") + norm(uri) + " signers=" + ",".join(s))
    except (ValueError, OSError, KeyError, subprocess.TimeoutExpired) as exc:
        print(f"ERROR {norm(uri)} {exc}"); errors += 1
sys.exit(1 if errors else 0)
  1. Add the following content to /var/ossec/etc/apt-trust/apt-trust-prefs.py. The preference script converts each APT preference record into one normalized line for validation against approved values defined in the SCA policy.
#!/usr/bin/env python3
"""apt-trust-prefs: print every APT preference record as one normalized line, for a Wazuh SCA check.

Output lines:
  RECORD <line>+<line>+...         one preference record: its non-comment lines, trimmed, joined with "+"
  ERROR <file> <reason>            a preference file could not be read (treated as a failure)
  DONE files=<n> records=<n>       printed last when every file was read
  INCOMPLETE files=<n> errors=<n>  printed last instead of DONE when a file could not be read
A record is a block of lines between blank lines, as in apt_preferences(5).
"""
import glob, os

files = (["/etc/apt/preferences"] if os.path.lexists("/etc/apt/preferences") else []) + \
    sorted(glob.glob("/etc/apt/preferences.d/*"))
records = errors = 0
for path in files:
    try:
        with open(path, encoding="utf-8") as f:
            text = f.read()
    except (OSError, UnicodeDecodeError) as exc:
        print(f"ERROR {path} {exc}")
        errors += 1
        continue
    block = []
    for line in text.splitlines() + [""]:  # the final empty line closes the last record
        if line.strip():
            if not line.lstrip().startswith("#"):
                block.append(" ".join(line.split()))
        elif block:
            print("RECORD " + "+".join(block))
            records += 1
            block = []
print(f"INCOMPLETE files={len(files)} errors={errors}" if errors else f"DONE files={len(files)} records={records}")
  1. Run /var/ossec/etc/apt-trust/apt-trust-keys.py with the --print-baseline option to list the current signers for each repository on the Ubuntu endpoint:
# python3 /var/ossec/etc/apt-trust/apt-trust-keys.py --print-baseline
global 843938DF228D22F7B3742BC0D94AA3F0EFE21092,F6ECB3762474EDA9D21B7022871920D1991BC93C
https://download.docker.com/linux/ubuntu/ 9DC858229FC7DD38854AE2D88D81803C0EBFCD88
http://archive.ubuntu.com/ubuntu/ 790BC7277767219C42C86F933B4FE6ACC0B21F32,843938DF228D22F7B3742BC0D94AA3F0EFE21092,F6ECB3762474EDA9D21B7022871920D1991BC93C
http://security.ubuntu.com/ubuntu/ 790BC7277767219C42C86F933B4FE6ACC0B21F32,843938DF228D22F7B3742BC0D94AA3F0EFE21092,F6ECB3762474EDA9D21B7022871920D1991BC93C

The output shows what the Ubuntu endpoint currently trusts, which might already include an unauthorized key. The command does not establish that the configuration is secure. Verify each fingerprint against the vendor documentation, and then exclude unauthorized repositories and signers from the list.

  1. Add the approved entries to /var/ossec/etc/apt-trust/apt-trust-signers.txt. Replace the example fingerprints and repository Uniform Resource Identifiers (URIs) with the approved values for your environment:
# Approved signers per repository: <repository URI> <signer set, comma-separated>
# "P" = key P and its subkeys, "P!" = key P only, "P/S" or "P/S!" = subkey S of key P,
# "*global*" = no Signed-By (any key in the global keyrings). "none" = empty signer set (global line only).
# Several lines for one repository approve several signer sets, for example during a key rotation.
global 843938DF228D22F7B3742BC0D94AA3F0EFE21092,F6ECB3762474EDA9D21B7022871920D1991BC93C
http://archive.ubuntu.com/ubuntu/ 790BC7277767219C42C86F933B4FE6ACC0B21F32,843938DF228D22F7B3742BC0D94AA3F0EFE21092,F6ECB3762474EDA9D21B7022871920D1991BC93C
http://security.ubuntu.com/ubuntu/ 790BC7277767219C42C86F933B4FE6ACC0B21F32,843938DF228D22F7B3742BC0D94AA3F0EFE21092,F6ECB3762474EDA9D21B7022871920D1991BC93C
https://download.docker.com/linux/ubuntu/ 9DC858229FC7DD38854AE2D88D81803C0EBFCD88

Each repository entry contains its URI and the approved signing-key fingerprints. The global entry lists the keys in the global keyrings. A key can carry subkeys, additional keys that it certifies.

Note

The policy and the signer list in this blog post use the default Ubuntu 24.04 mirrors, http://archive.ubuntu.com/ubuntu/ and http://security.ubuntu.com/ubuntu/. On an endpoint that uses another mirror, such as a country or cloud mirror, checks 120110, 120111, 120120, and 120153 fail on a clean scan. Run apt-get indextargets --no-release-info --format '$(REPO_URI)' | sort -u to list the repository URIs that APT uses. Then replace the default URIs in apt-trust-signers.txt and in checks 120110, 120111, and 120153.

Define the SCA policy

The SCA policy defines checks to detect APT configuration deviations. These checks cover repository definitions, signing-key trust, verification settings, and package selection.

  1. Create the file apt_trust.yml in the /var/ossec/etc/apt-trust/ directory to store the SCA policy:
# install -m 640 -o root -g root /dev/null /var/ossec/etc/apt-trust/apt_trust.yml
  1. Add the following content to the SCA policy file /var/ossec/etc/apt-trust/apt_trust.yml to check for APT configuration deviations:
# APT trust policy for Wazuh SCA.
# Allow lists are this fleet's reviewed baseline: change them only through review.
policy:
  id: "apt_trust"
  file: "apt_trust.yml"
  name: "APT repository trust"
  description: "Approved APT repositories, signers, verification settings, and package selection."
  regex_type: "pcre2"

requirements:
  title: "Ubuntu endpoint with APT"
  description: "The policy applies to Ubuntu endpoints that use APT."
  condition: all
  rules:
    - 'f:/etc/os-release -> r:^ID=ubuntu$'
    - 'f:/usr/bin/apt-get'

variables:
  $signer_script: /var/ossec/etc/apt-trust/apt-trust-keys.py
  $prefs_script: /var/ossec/etc/apt-trust/apt-trust-prefs.py

checks:
  # Guards: each one proves that a command used by the checks below produced valid output.
  # SCA matches the output of a command even when it exits with an error, and empty output never matches.
  # Either case lets a "none" check pass.
  - id: 120100
    title: "Guard: apt-config reports the expected configuration root"
    condition: all
    rules:
      - 'c:apt-config dump Dir -> r:^Dir "/";$'

  - id: 120101
    title: "Guard: APT lists the configured repositories"
    condition: all
    rules:
      - 'c:apt-get indextargets --no-release-info --format "$(TARGET_OF) $(REPO_URI) $(RELEASE) $(COMPONENT)" "Identifier: Packages" -> r:^deb \S+ \S+'

  - id: 120102
    title: "Guard: the signer check ran and evaluated the global keyrings"
    condition: all
    rules:
      - 'c:python3 $signer_script -> r:^(OK|UNAPPROVED) global '

  - id: 120103
    title: "Guard: the preference script read every preference file"
    condition: all
    rules:
      - 'c:python3 $prefs_script -> r:^DONE files=\d+ records=\d+$'

  # Use case 1: unexpected repository
  - id: 120110
    title: "Only approved APT repositories, suites, and components are enabled"
    description: "Every repository target that APT uses must match the approved list."
    compliance:
      - mitre_techniques: ["T1195.002"]
    condition: none
    rules:
      - 'c:apt-get indextargets --no-release-info --format "$(TARGET_OF) $(REPO_URI) $(RELEASE) $(COMPONENT)" "Identifier: Packages" -> !r:^(deb http://archive\.ubuntu\.com/ubuntu/ noble(-updates|-backports)? (main|restricted|universe|multiverse)|deb http://security\.ubuntu\.com/ubuntu/ noble-security (main|restricted|universe|multiverse)|deb https://download\.docker\.com/linux/ubuntu/ noble stable)?$'

  - id: 120111
    title: "Required APT repositories are configured (Ubuntu release, updates, and security)"
    description: "Removing one of these repositories stops the endpoint from receiving its updates."
    condition: all
    rules:
      - 'c:apt-get indextargets --no-release-info --format "$(TARGET_OF) $(REPO_URI) $(RELEASE) $(COMPONENT)" "Identifier: Packages" -> r:^deb http://archive\.ubuntu\.com/ubuntu/ noble main$'
      - 'c:apt-get indextargets --no-release-info --format "$(TARGET_OF) $(REPO_URI) $(RELEASE) $(COMPONENT)" "Identifier: Packages" -> r:^deb http://archive\.ubuntu\.com/ubuntu/ noble-updates main$'
      - 'c:apt-get indextargets --no-release-info --format "$(TARGET_OF) $(REPO_URI) $(RELEASE) $(COMPONENT)" "Identifier: Packages" -> r:^deb http://security\.ubuntu\.com/ubuntu/ noble-security main$'

  # Use case 2: signing-key trust changed
  - id: 120120
    title: "Every repository has approved signers, and only those signers"
    description: "apt-trust-keys.py resolves Signed-By for each source and compares the signer set with apt-trust-signers.txt. A repository missing from the list fails too."
    compliance:
      - mitre_techniques: ["T1553"]
    condition: none
    rules:
      - 'c:python3 $signer_script -> r:^(UNAPPROVED|UNLISTED) '

  - id: 120121
    title: "Guard: the signer script determines the signers of every approved repository"
    description: "A key file that is missing, unreadable by _apt, or not an OpenPGP key makes the signer set unknown."
    condition: none
    rules:
      - 'c:python3 $signer_script -> r:^ERROR '

  # Use case 3: verification weakened (global settings, effective values)
  - id: 120130
    title: "APT does not allow insecure repositories (Acquire::AllowInsecureRepositories)"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:apt-config shell V Acquire::AllowInsecureRepositories/b -> r:^V=.true.$'
  - id: 120131
    title: "APT does not allow weak repository signatures (Acquire::AllowWeakRepositories)"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:apt-config shell V Acquire::AllowWeakRepositories/b -> r:^V=.true.$'
  - id: 120132
    title: "APT does not allow downgrades to insecure repositories (Acquire::AllowDowngradeToInsecureRepositories)"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:apt-config shell V Acquire::AllowDowngradeToInsecureRepositories/b -> r:^V=.true.$'
  - id: 120133
    title: "APT does not install unauthenticated packages (APT::Get::AllowUnauthenticated)"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:apt-config shell V APT::Get::AllowUnauthenticated/b -> r:^V=.true.$'
  - id: 120134
    title: "APT checks metadata expiry (Acquire::Check-Valid-Until)"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:apt-config shell V Acquire::Check-Valid-Until/b -> r:^V=.false.$'
  - id: 120135
    title: "APT checks metadata dates (Acquire::Check-Date)"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:apt-config shell V Acquire::Check-Date/b -> r:^V=.false.$'
  - id: 120136
    title: "APT configuration files and directories are not redirected (Dir::Etc)"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:apt-config dump Dir::Etc -> r:^Dir::Etc "(?!etc/apt/?")'
      - 'c:apt-config dump Dir::Etc::main -> r:^Dir::Etc::main "(?!apt\.conf")'
      - 'c:apt-config dump Dir::Etc::parts -> r:^Dir::Etc::parts "(?!apt\.conf\.d")'
      - 'c:apt-config dump Dir::Etc::sourcelist -> r:^Dir::Etc::sourcelist "(?!sources\.list")'
      - 'c:apt-config dump Dir::Etc::sourceparts -> r:^Dir::Etc::sourceparts "(?!sources\.list\.d")'
      - 'c:apt-config dump Dir::Etc::trusted -> r:^Dir::Etc::trusted "(?!trusted\.gpg")'
      - 'c:apt-config dump Dir::Etc::trustedparts -> r:^Dir::Etc::trustedparts "(?!trusted\.gpg\.d")'
      - 'c:apt-config dump Dir::Etc::preferences -> r:^Dir::Etc::preferences "(?!preferences")'
      - 'c:apt-config dump Dir::Etc::preferencesparts -> r:^Dir::Etc::preferencesparts "(?!preferences\.d")'

  - id: 120137
    title: "APT uses its default signature verification programs (Dir::Bin::methods, Dir::Bin::apt-key, APT::Key::gpgvcommand, Acquire::gpgv::Options)"
    description: "These settings choose the programs that check repository signatures and the arguments they receive."
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:apt-config dump Dir::Bin::methods -> r:^Dir::Bin::methods(::| "(?!/usr/lib/apt/methods/?"))'
      - 'c:apt-config dump Dir::Bin::apt-key -> r:^Dir::Bin::apt-key "(?!/usr/bin/apt-key")'
      - 'c:apt-config dump APT::Key::gpgvcommand -> r:(?i)^APT::Key::gpgvcommand "\S'
      - 'c:apt-config dump Acquire::gpgv::Options -> r:^Acquire::gpgv::Options::\S* "\S'

  # Use case 3: verification weakened (per-source options in .list and deb822 files)
  - id: 120140
    title: "No source sets the trusted option (trusted= / Trusted:)"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:grep -rhiE "trusted" /etc/apt/sources.list /etc/apt/sources.list.d -> r:(?i)^\s*(deb(-src)?\s+\[[^\]]*\btrusted=|trusted:)'
  - id: 120141
    title: "No source sets allow-insecure or allow-weak"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:grep -rhiE "allow-(insecure|weak)" /etc/apt/sources.list /etc/apt/sources.list.d -> r:(?i)^\s*(deb(-src)?\s+\[[^\]]*\ballow-(insecure|weak)=|allow-(insecure|weak):)'
  - id: 120142
    title: "No source sets allow-downgrade-to-insecure"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:grep -rhiE "allow-downgrade-to-insecure" /etc/apt/sources.list /etc/apt/sources.list.d -> r:(?i)^\s*(deb(-src)?\s+\[[^\]]*\ballow-downgrade-to-insecure=|allow-downgrade-to-insecure:)'
  - id: 120143
    title: "No source sets check-valid-until or check-date"
    compliance:
      - mitre_techniques: ["T1562.001"]
    condition: none
    rules:
      - 'c:grep -rhiE "check-(valid-until|date)" /etc/apt/sources.list /etc/apt/sources.list.d -> r:(?i)^\s*(deb(-src)?\s+\[[^\]]*\bcheck-(valid-until|date)=|check-(valid-until|date):)'

  # Use case 4: package selection manipulated
  - id: 120150
    title: "Only approved APT preference files exist"
    compliance:
      - mitre_techniques: ["T1195.002"]
    condition: none
    rules:
      - 'c:ls -1 /etc/apt/preferences.d -> !r:^(ubuntu-pro-esm-apps|ubuntu-pro-esm-infra)?$'
      - 'c:ls -1 /etc/apt -> r:^preferences$'
  - id: 120151
    title: "No APT pin has a priority of 1000 or more"
    compliance:
      - mitre_techniques: ["T1195.002"]
    condition: none
    rules:
      - 'c:grep -rshi "^Pin-Priority" /etc/apt/preferences /etc/apt/preferences.d -> n:(?i)^Pin-Priority:\s*(-?\d+) compare >= 1000'
  - id: 120152
    title: "Watched package cockpit-doc is selected from the Ubuntu release, updates, or security suites"
    compliance:
      - mitre_techniques: ["T1195.002"]
    condition: all
    rules:
      - 'c:apt-get -s install --reinstall cockpit-doc -> r:^Inst cockpit-doc (\[\S+\] )?\(\S+ Ubuntu:24\.04/noble(-updates|-security)?(, Ubuntu:24\.04/noble(-updates|-security)?)* \['
  - id: 120153
    title: "Watched package cockpit-doc downloads from the Ubuntu archive"
    compliance:
      - mitre_techniques: ["T1195.002"]
    condition: all
    rules:
      - 'c:apt-get download --print-uris cockpit-doc -> r:^.http://(archive|security)\.ubuntu\.com/ubuntu/pool/'
  - id: 120154
    title: "APT preference files contain only approved preference records"
    description: "apt-trust-prefs.py prints each preference record as one line. Every record must equal an approved record; merged, split, edited, or unreadable records fail."
    compliance:
      - mitre_techniques: ["T1195.002"]
    condition: none
    rules:
      - 'c:python3 $prefs_script -> !r:^(RECORD Package: \*\+Pin: release o=UbuntuESM\+Pin-Priority: 510|RECORD Package: \*\+Pin: release o=UbuntuESMApps\+Pin-Priority: 510|DONE files=\d+ records=\d+)?$'

  # Use case 5 prerequisite: the real update path logs its errors
  - id: 120160
    title: "apt-daily logs APT errors to the journal (APT::Periodic::Verbose 2)"
    condition: all
    rules:
      - 'c:apt-config dump APT::Periodic::Verbose -> r:^APT::Periodic::Verbose "([2-9]|[1-9][0-9]+)";$'

The SCA policy organizes its checks by ID according to their detection purpose.

Check IDsPurpose
120100 to 120103Check that APT commands and helper scripts produce the expected output.
120110 and 120111Identify unapproved repositories and missing required repositories.
120120 and 120121Identify signing-key trust changes and errors in signer evaluation.
120130 to 120143Detect weakened verification settings and changes to APT configuration or signature-verification paths.
120150 to 120154Detect unapproved preference files, package priorities, preference records, and package sources.
120160Check that APT verbose logging is configured for scheduled updates.

Configure the Wazuh agent

We configure SCA, FIM, and APT update log collection. These capabilities let Wazuh report configuration deviations, file changes, and update failures. 

  1. Enable verbose APT logging to capture scheduled-update errors in the systemd journal:
# echo 'APT::Periodic::Verbose "2";' > /etc/apt/apt.conf.d/52wazuh-periodic-verbose
  1. Append the following configuration to the /var/ossec/etc/ossec.conf file to enable the SCA policy:
<ossec_config>
  <sca>
    <enabled>yes</enabled>
    <scan_on_start>yes</scan_on_start>
    <interval>15m</interval>
    <policies> 
      <policy>/var/ossec/etc/apt-trust/apt_trust.yml</policy>  
    </policies>
  </sca>
</ossec_config>

Note

The <interval> value applies to every SCA policy on the agent, including the default Center for Internet Security (CIS) policy. All of them run every 15 minutes with this configuration.

  1. Add the following configuration within the <syscheck> block of /var/ossec/etc/ossec.conf to monitor changes to APT configuration files, signing keys, and baseline files:
    <directories check_all="yes" whodata="yes">/etc/apt</directories>
    <directories check_all="yes" whodata="yes">/usr/share/keyrings</directories>
    <directories check_all="yes" whodata="yes">/var/ossec/etc/apt-trust</directories>
  1. Restart the Wazuh agent to apply the configuration changes:
# systemctl restart wazuh-agent

Wazuh server

To detect failed scheduled updates and baseline-file changes, configure the required decoder and rules on the Wazuh server using the Wazuh dashboard.

  1. Navigate to Server management > Decoders, then click Add new decoders file.
  2. Copy and paste the decoder below, and name the file apt_trust_decoders.xml. Click Save, then Reload to apply the change:
<decoder name="apt-systemd-daily-url">
  <parent>systemd</parent>
  <regex type="pcre2">(https?://[^\s']+)</regex>
  <order>url</order>
</decoder>
  1. Navigate to Server management > Rules, then click Add new rules file. 
  2. Copy and paste the rules below, and name the file apt_trust_rules.xml. Click Save, then Reload to apply the changes:
<group name="apt_trust,">

  <!-- Scheduled update path: APT messages logged by apt.systemd.daily (apt-daily.service) -->
  <rule id="102000" level="0">
    <decoded_as>systemd</decoded_as>
    <program_name>^apt.systemd.daily$</program_name>
    <description>APT scheduled update message.</description>
  </rule>

  <rule id="102001" level="12">
    <if_sid>102000</if_sid>
    <match type="pcre2">^(W: An error occurred during the signature verification|E: The repository .* is not signed)</match>
    <description>APT repository $(url) failed signature verification during a scheduled update.</description>
    <mitre>
      <id>T1195.002</id>
    </mitre>
  </rule>

  <rule id="102002" level="12">
    <if_sid>102000</if_sid>
    <match type="pcre2">^[WE]: Failed to fetch \S+\s+(Hash Sum mismatch|File has unexpected size)</match>
    <description>APT index $(url) failed integrity verification during a scheduled update.</description>
    <mitre>
      <id>T1195.002</id>
    </mitre>
  </rule>

  <rule id="102003" level="8">
    <if_sid>102000</if_sid>
    <match type="pcre2">^E: Release file for \S+ is (expired|not valid yet)</match>
    <description>APT index $(url) is expired or not yet valid during a scheduled update.</description>
    <mitre>
      <id>T1195.002</id>
    </mitre>
  </rule>

  <rule id="102004" level="5">
    <if_sid>102000</if_sid>
    <match type="pcre2">^[WE]: Failed to fetch \S+\s+(Could not connect|Unable to connect|Temporary failure resolving|Could not resolve|Connection (refused|timed out)|[45]\d\d\s+\S)</match>
    <description>APT failed to fetch index $(url) during a scheduled update due to a connection, DNS resolution, or HTTP error.</description>
  </rule>

  <!-- FIM evidence: raw file changes, kept separate from the SCA results -->
  <rule id="102005" level="7">
    <if_sid>550,553,554</if_sid>
    <field name="file" type="pcre2">^(/etc/apt/|/usr/share/keyrings/)</field>
    <description>APT trust configuration file $(file) changed.</description>
  </rule>

  <!-- Baseline files change only through an approved rollout. -->
  <rule id="102006" level="10">
    <if_sid>550,553,554</if_sid>
    <field name="file" type="pcre2">^/var/ossec/etc/apt-trust/</field>
    <description>APT trust baseline file $(file) changed on the endpoint.</description>
    <mitre>
      <id>T1562.001</id>
    </mitre>
  </rule>

</group>

Where:

  • Rule 102000 is the base rule for apt.systemd.daily logs.
  • Rule 102001 triggers when the scheduled update reports a signature failure for a repository.
  • Rule 102002 triggers when the scheduled update reports an integrity failure, such as an index that does not match its signed checksum.
  • Rule 102003 triggers when the scheduled update reports expired or not-yet-valid repository metadata.
  • Rule 102004 triggers when the scheduled update cannot reach a repository due to network failure, DNS resolution failure, or HTTP errors.
  • Rule 102005 triggers when FIM reports a change to an APT source, key, setting, or preference file.
  • Rule 102006 triggers when FIM reports a change to the baseline files on the endpoint. During an approved rollout, expect this alert for each updated file.

Simulate APT repository tampering

Before you simulate repository tampering, confirm that the initial SCA scan passes every check, as described in Initial SCA scan result. Perform the following steps on the Ubuntu endpoint to simulate APT repository tampering.

Warning: To contain changes that weaken repository trust and can interrupt package updates, perform these tests only on an isolated test endpoint. Restore the original configuration afterward.
  1. Back up the Docker repository configuration and signing key to restore them after testing:
# mkdir -p /root/apt-trust-test
# cp -a /etc/apt/sources.list.d/docker.sources /etc/apt/keyrings/docker.asc /root/apt-trust-test/
  1. Generate a temporary signing key to simulate an unauthorized replacement of the Docker repository key:
# export GNUPGHOME=$(mktemp -d)
# gpg --batch --pinentry-mode loopback --passphrase '' --quick-gen-key "Unapproved Signer <rogue@example.invalid>" ed25519 sign 1y
# gpg --batch --armor --export rogue@example.invalid > /root/apt-trust-test/rogue.asc
# rm -rf "$GNUPGHOME"; unset GNUPGHOME
  1. Confirm that cockpit-doc is available from both the noble and noble-backports suites:
# apt-cache policy cockpit-doc
cockpit-doc:
  Installed: (none)
  Candidate: 314-1
  Version table:
     362-1~bpo24.04.1 100
        100 http://archive.ubuntu.com/ubuntu noble-backports/universe amd64 Packages
     329-1~bpo24.04.1 100
        100 http://archive.ubuntu.com/ubuntu noble-backports/universe amd64 Packages
     316-1~bpo24.04.1 100
        100 http://archive.ubuntu.com/ubuntu noble-backports/universe amd64 Packages
     314-1 500
        500 http://archive.ubuntu.com/ubuntu noble/universe amd64 Packages
  1. Add a repository outside the approved list to test repository detection: 
# echo "deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] http://archive.example.invalid/ubuntu noble main" > /etc/apt/sources.list.d/extra.list
  1. Replace the Docker repository key with the generated key to test signer detection:
# cat /root/apt-trust-test/rogue.asc > /etc/apt/keyrings/docker.asc
  1. Start a scheduled APT update to generate signature and network errors:
# rm -f /var/lib/apt/periodic/update-stamp
# systemctl start apt-daily.service

The replacement Docker key causes a signature-verification error. The test repository also generates a network error because APT can’t resolve its hostname.

  1. Set the repository metadata validity period to one second, then run a scheduled APT update:
# echo 'Acquire::Max-ValidTime "1";' > /etc/apt/apt.conf.d/99-test-expiry
# rm -f /var/lib/apt/periodic/update-stamp
# systemctl start apt-daily.service

The expiry errors occur when repository metadata exceeds the configured validity period.

  1. Weaken repository verification by marking the Docker source as trusted, allowing insecure repositories, and disabling the metadata expiry check: 
# sed -i '/^Signed-By:/a Trusted: yes' /etc/apt/sources.list.d/docker.sources
# echo 'Acquire::AllowInsecureRepositories "true";' > /etc/apt/apt.conf.d/99-weak-insecure
# echo 'Acquire { Check-Valid-Until "false"; };' > /etc/apt/apt.conf.d/99-weak-validity
  1. Pin cockpit-doc to noble-backports with a higher priority, and compare its candidate version, the version that APT selects, before and after: 
# apt-get -s install --reinstall cockpit-doc | grep '^Inst cockpit-doc'
# printf 'Package: cockpit-doc\nPin: release a=noble-backports\nPin-Priority: 1001\n' > /etc/apt/preferences.d/cockpit-doc
# apt-get -s install --reinstall cockpit-doc | grep '^Inst cockpit-doc'
Inst cockpit-doc (314-1 Ubuntu:24.04/noble [all])
Inst cockpit-doc (362-1~bpo24.04.1 Ubuntu:24.04/noble-backports [all])

The candidate moves from noble to noble-backports. A malicious pin and a legitimate one look alike, so the baseline decides which pins are approved. The -s option only simulates the installation, and the versions depend on the package indexes on your endpoint.

  1. Modify a baseline file outside an approved rollout to test FIM detection:
# echo "# test edit" >> /var/ossec/etc/apt-trust/apt-trust-signers.txt
  1. Start a scheduled APT update to generate repository verification errors:
# rm -f /var/lib/apt/periodic/update-stamp
# systemctl start apt-daily.service

The replacement Docker key makes the Docker repository fail signature verification. Wait a few seconds for the Wazuh agent to forward the journal entries before you restart it.

Note

systemd skips apt-daily.service when the endpoint reports that it runs on battery power only, as a virtual machine might. To run the update in that case, use a transient unit: systemd-run --wait /usr/lib/apt/apt.systemd.daily update.

  1. Restart the Wazuh agent to trigger an SCA scan that evaluates the changes:
# systemctl restart wazuh-agent

Visualize the alerts

The Wazuh dashboard displays alerts generated by the simulated APT configuration changes and update failures.

Initial SCA scan result

Navigate to Endpoint security > Configuration Assessment and select the Ubuntu endpoint. Open the APT repository trust policy to review its results.

The initial scan shows 26 passed checks, no failed checks, and a 100% score. The result confirms that the monitored APT configuration satisfies the checks defined in the custom policy.

nitial APT repository trust assessment results for the Ubuntu endpoint.
Figure 1: Initial APT repository trust assessment results for the Ubuntu endpoint.

Final SCA scan result

Navigate to Endpoint security > Configuration Assessment on the Wazuh dashboard. Select the monitored Ubuntu endpoint, and then select APT repository trust to view the scan results.

The scan reports a compliance score of 65%, with 17 passed checks and 9 failed checks.

Configuration Assessment dashboard after repository tampering.
Figure 2: Configuration Assessment dashboard after repository tampering.

Threat Hunting result

Threat Hunting shows the FIM, journal, and SCA alerts from the emulation, including rules 102001 and 102003 to 102006.

  1. Navigate to Threat intelligence > Threat Hunting, then click the Events tab to view the configuration change alerts.
  2. In the search bar, enter rule.groups:apt_trust OR data.sca.policy:"APT repository trust", and click Update.
Threat Hunting events after repository tampering.
Figure 3: Threat Hunting events after repository tampering.

Revert the changes

Restore the Ubuntu endpoint after testing so that package updates and the SCA baseline work again.

  1. Restore the original configuration and restart the Wazuh agent to return the endpoint to its baseline:
# rm -f /etc/apt/sources.list.d/extra.list /etc/apt/preferences.d/cockpit-doc /etc/apt/apt.conf.d/99-test-expiry /etc/apt/apt.conf.d/99-weak-insecure /etc/apt/apt.conf.d/99-weak-validity
# sed -i '/^# test edit$/d' /var/ossec/etc/apt-trust/apt-trust-signers.txt
# cp -a /root/apt-trust-test/docker.sources /etc/apt/sources.list.d/docker.sources
# cp -a /root/apt-trust-test/docker.asc /etc/apt/keyrings/docker.asc
# rm -rf /root/apt-trust-test/
# systemctl restart wazuh-agent
  1. Confirm that APT reads the original repositories and the signer script approves every repository:
# apt-get update
# python3 /var/ossec/etc/apt-trust/apt-trust-keys.py

Detection limits

The detection use cases in this blog post identify suspicious activities and security events within the environment. However, these alerts do not necessarily indicate a successful attack or compromise. Consider the following limitations when interpreting the results.

  • Detection scope. Failed checks and update errors require investigation. They do not confirm tampering. Correlate the alerts with FIM events, system logs, and approved changes. Repository synchronization problems and incorrect system clocks can also cause verification failures. This setup does not monitor installed package behavior.
  • Detection timing. SCA runs at configured intervals and can miss changes reverted between scans. FIM records these changes when they happen. Package-selection checks use locally stored package indexes, not live repository data.
  • Scheduled updates. The journal rules detect matching errors from scheduled updates run by apt-daily.service. Manual apt update commands display errors in the terminal, not in the journal. Systems running on battery power can skip scheduled updates.
  • Signing-key restrictions. The signer script does not evaluate Signed-By restrictions inherited from previously downloaded Release metadata. It reads /etc/apt directly. Check 120136 flags redirected APT configuration paths.
  • Detection coverage. FIM monitors the configured directories, including unused keyrings. Check 120111 requires only the configured Ubuntu repositories. Checks 120152 and 120153 cover only the watched package. Checks 120140 to 120143 also flag options in disabled repository definitions.
  • Root-level tampering. An attacker with root access can modify baseline files, change monitoring settings, or stop the Wazuh agent. FIM and agent-disconnection alerts provide evidence, not protection. Restrict write access to /var/ossec/etc/apt-trust/ to root, because unauthorized changes to scripts or command-based policies can enable privileged command execution.

Conclusion

This configuration helps you detect changes to the repositories that your Ubuntu fleet trusts and investigate verification failures across it. An SCA policy holds the reviewed baseline, and two Python scripts prepare the signer and preference data that the policy checks. Journal collection reports failed scheduled updates, and FIM with who-data records who made each change.

Each endpoint holds the same reviewed baseline files, so Wazuh checks every endpoint against the same baseline.

Wazuh is a free and open source security platform. Check out our documentation to learn more, and join our community for assistance.

References