Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4e6bbf79f | ||
|
|
b45444c831 | ||
|
|
e8eadb46c6 | ||
|
|
55635ab551 |
+80
-9
@@ -835,11 +835,13 @@ class LinuxDriver(OSDriver):
|
||||
def _get_packages_apt(self) -> list[PackageDict]:
|
||||
out = self._send(
|
||||
"dpkg-query -W -f='${Package}\\t${Version}\\t${Installed-Size}"
|
||||
"\\t${source:Package}\\t${binary:Summary}\\n' 2>/dev/null"
|
||||
"\\t${source:Package}\\t${source:Version}\\t${binary:Summary}\\n' 2>/dev/null"
|
||||
)
|
||||
packages: list[PackageDict] = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split("\t", 4)
|
||||
# Summary stays last and keeps whatever it contains: maxsplit must
|
||||
# equal the number of tabs the format writes, not the field count.
|
||||
parts = line.split("\t", 5)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
name = parts[0].strip()
|
||||
@@ -847,7 +849,21 @@ class LinuxDriver(OSDriver):
|
||||
size = int(parts[2].strip()) * 1024 if len(parts) > 2 and parts[2].strip().isdigit() else 0
|
||||
# Debian source package (e.g. openssh-server → openssh) for OSV matching.
|
||||
source_package = parts[3].strip() if len(parts) > 3 and parts[3].strip() else name
|
||||
description = parts[4].strip() if len(parts) > 4 else ""
|
||||
# And its version, which is a different number from this package's.
|
||||
#
|
||||
# OSV states Debian ranges in *source* versions. A source package
|
||||
# that ships several binaries gives each its own upstream version:
|
||||
# libldb2 is 2:2.11.0+samba4.22.11+dfsg-… while its source, samba,
|
||||
# is 2:4.22.11+dfsg-…. A consumer matching on source_package and
|
||||
# comparing `version` compares two unrelated numbers — dpkg reads
|
||||
# ldb's 2.11.0 as older than the 2:4.17.4+dfsg-1 that fixed
|
||||
# CVE-2022-44640, and a host five releases past the fix was reported
|
||||
# vulnerable on four packages at once.
|
||||
#
|
||||
# dpkg leaves this empty when it equals `Version`; so does an older
|
||||
# dpkg that does not know the field at all.
|
||||
source_version = parts[4].strip() if len(parts) > 4 and parts[4].strip() else version
|
||||
description = parts[5].strip() if len(parts) > 5 else ""
|
||||
packages.append({
|
||||
"name": name,
|
||||
"version": version,
|
||||
@@ -856,6 +872,7 @@ class LinuxDriver(OSDriver):
|
||||
"size": size,
|
||||
"source": "apt",
|
||||
"source_package": source_package,
|
||||
"source_version": source_version,
|
||||
})
|
||||
return packages
|
||||
|
||||
@@ -1028,24 +1045,78 @@ class LinuxDriver(OSDriver):
|
||||
success = not any(kw in low for kw in ("error:", "failed", "no packages", "not found", "unable to locate", "no match"))
|
||||
return {"success": success, "output": raw.strip()}
|
||||
|
||||
def uninstall_package(self, name: str) -> dict[str, Any]:
|
||||
"""Remove a package by name. Returns ``{"success": bool, "output": str}``."""
|
||||
#: Words in a package manager's output that mean it did not do the job.
|
||||
_UNINSTALL_FAILED = ("error:", "failed", "not found", "is not installed", "no packages")
|
||||
|
||||
def uninstall_package(self, name: str, purge: bool = False) -> dict[str, Any]:
|
||||
"""Remove a package by name. Returns ``{"success": bool, "output": str}``.
|
||||
|
||||
``purge`` also removes the package's configuration where the package
|
||||
manager distinguishes the two. Off by default: configuration somebody
|
||||
may want back is not this function's to delete unless it was asked for.
|
||||
|
||||
It matters for more than tidiness. A package's apt source survives a
|
||||
plain ``remove``, so the repository keeps being fetched on every
|
||||
``apt-get update`` long after the package itself is gone — which is what
|
||||
the Wazuh agent left behind on thirteen hosts.
|
||||
|
||||
**The dpkg fallback.** A package whose ``postinst`` failed sits at
|
||||
``install ok unpacked``, and apt cannot remove it: it configures a
|
||||
package before removing it, and configuring is precisely what is broken.
|
||||
Seven of those thirteen hosts were in that state after an upgrade whose
|
||||
postinst could not reach a manager that had been decommissioned, and on
|
||||
one of them only ``dpkg --purge --force-all`` got it out.
|
||||
|
||||
So the fallback runs **only after apt has failed**, never as a routine
|
||||
second step: forcing dpkg past its own consistency checks is a bigger
|
||||
hammer than apt, and a caller who reaches for it every time will
|
||||
eventually break something apt would have refused to.
|
||||
"""
|
||||
from shlex import quote as _q
|
||||
safe = _q(name)
|
||||
pm = self._pkg_manager
|
||||
if pm == "apt":
|
||||
raw = self._sudo(f"DEBIAN_FRONTEND=noninteractive apt-get remove -y {safe} 2>&1 || true")
|
||||
action = "purge" if purge else "remove"
|
||||
raw = self._sudo(
|
||||
f"DEBIAN_FRONTEND=noninteractive apt-get {action} -y {safe} 2>&1 || true"
|
||||
)
|
||||
elif pm in ("dnf", "yum"):
|
||||
raw = self._sudo(f"{pm} remove -y {safe} 2>&1 || true")
|
||||
elif pm == "apk":
|
||||
# apk and pacman have no separate purge; asking for one is not an
|
||||
# error, it simply has nothing extra to do.
|
||||
raw = self._sudo(f"apk del {safe} 2>&1 || true")
|
||||
elif pm == "pacman":
|
||||
raw = self._sudo(f"pacman -R --noconfirm {safe} 2>&1 || true")
|
||||
else:
|
||||
return {"success": False, "output": f"Unsupported package manager: {pm}"}
|
||||
low = raw.lower()
|
||||
success = not any(kw in low for kw in ("error:", "failed", "not found", "is not installed", "no packages"))
|
||||
return {"success": success, "output": raw.strip()}
|
||||
|
||||
if self._uninstall_failed(raw) and pm == "apt":
|
||||
forced = self._sudo(f"dpkg --purge --force-all {safe} 2>&1 || true")
|
||||
if not self._uninstall_failed(forced):
|
||||
return {
|
||||
"success": True,
|
||||
"output": f"{raw.strip()}\n--- dpkg --purge --force-all ---\n{forced.strip()}",
|
||||
}
|
||||
raw = f"{raw.strip()}\n--- dpkg --purge --force-all ---\n{forced.strip()}"
|
||||
|
||||
return {"success": not self._uninstall_failed(raw), "output": raw.strip()}
|
||||
|
||||
def _uninstall_failed(self, output: str) -> bool:
|
||||
"""Whether the package manager said it did not do the job.
|
||||
|
||||
apt prefixes its own errors with ``E: `` at the start of a line, and
|
||||
the commonest of them — ``E: Sub-process /usr/bin/dpkg returned an
|
||||
error code (1)`` — contains neither "error:" nor "failed". The keyword
|
||||
list alone therefore read a failed removal as a success, which is the
|
||||
worst direction for this particular answer to be wrong in.
|
||||
|
||||
Matched at line start rather than anywhere: "note: " ends in "e: ".
|
||||
"""
|
||||
low = output.lower()
|
||||
if any(line.lstrip().startswith("e: ") for line in low.splitlines()):
|
||||
return True
|
||||
return any(kw in low for kw in self._UNINSTALL_FAILED)
|
||||
|
||||
def get_available_updates(self) -> list[UpdateDict]:
|
||||
if self._pkg_manager == "apt":
|
||||
|
||||
+129
-6
@@ -153,9 +153,22 @@ def test_parse_uptime_invalid(driver):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
#: What `dpkg-query` actually returns for the format this driver asks for.
|
||||
#:
|
||||
#: The previous fixture carried four fields against a format string asking for
|
||||
#: five, so `source_package` was silently receiving the description and no
|
||||
#: assertion noticed. A fixture simpler than the data cannot fail the way the
|
||||
#: data does.
|
||||
APT_PKG_OUTPUT = (
|
||||
"openssh-server\t1:9.2p1-2+deb12u2\t512\tsecure shell server\n"
|
||||
"curl\t7.88.1-10+deb12u5\t1024\tcommand line tool for transferring data\n"
|
||||
"openssh-server\t1:9.2p1-2+deb12u2\t512\topenssh\t1:9.2p1-2+deb12u2"
|
||||
"\tsecure shell server\n"
|
||||
"curl\t7.88.1-10+deb12u5\t1024\tcurl\t7.88.1-10+deb12u5"
|
||||
"\tcommand line tool for transferring data\n"
|
||||
# The shape that matters: a binary package whose own upstream version has
|
||||
# nothing to do with its source package's. ldb 2.11.0 is built from samba
|
||||
# 4.22.11, and OSV states Debian ranges in source versions.
|
||||
"libldb2\t2:2.11.0+samba4.22.11+dfsg-0+deb13u1\t2048\tsamba"
|
||||
"\t2:4.22.11+dfsg-0+deb13u1\tLDB shared library\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -163,11 +176,45 @@ def test_get_packages_apt(driver):
|
||||
driver._pkg_manager = "apt"
|
||||
with patch.object(driver, "_send", return_value=APT_PKG_OUTPUT):
|
||||
pkgs = driver.get_packages()
|
||||
assert len(pkgs) == 2
|
||||
assert len(pkgs) == 3
|
||||
assert pkgs[0]["name"] == "openssh-server"
|
||||
assert pkgs[0]["version"] == "1:9.2p1-2+deb12u2"
|
||||
assert pkgs[0]["installed"] is True
|
||||
assert pkgs[0]["source"] == "apt"
|
||||
assert pkgs[0]["description"] == "secure shell server"
|
||||
|
||||
|
||||
def test_get_packages_apt_keeps_the_source_package_and_its_version(driver):
|
||||
"""OSV states Debian ranges in *source* package versions.
|
||||
|
||||
A consumer that matches on the source package and then compares the binary
|
||||
package's version is comparing two unrelated numbers. On a Debian 13 host
|
||||
that reported four Samba libraries as vulnerable to CVE-2022-44640 while
|
||||
running samba 4.22.11 — five releases past the fix — because dpkg reads
|
||||
ldb's own `2.11.0` as older than samba's `2:4.17.4+dfsg-1`.
|
||||
|
||||
The driver cannot fix the comparison, but it is the only place that can
|
||||
supply the number to compare.
|
||||
"""
|
||||
driver._pkg_manager = "apt"
|
||||
with patch.object(driver, "_send", return_value=APT_PKG_OUTPUT):
|
||||
pkgs = {p["name"]: p for p in driver.get_packages()}
|
||||
|
||||
assert pkgs["libldb2"]["source_package"] == "samba"
|
||||
assert pkgs["libldb2"]["source_version"] == "2:4.22.11+dfsg-0+deb13u1"
|
||||
assert pkgs["libldb2"]["version"] == "2:2.11.0+samba4.22.11+dfsg-0+deb13u1"
|
||||
assert pkgs["libldb2"]["description"] == "LDB shared library"
|
||||
|
||||
|
||||
def test_get_packages_apt_falls_back_when_dpkg_gives_no_source(driver):
|
||||
"""`source:Package` is empty for a package whose source name equals its own.
|
||||
Older dpkg builds leave `source:Version` empty in that case too."""
|
||||
driver._pkg_manager = "apt"
|
||||
with patch.object(driver, "_send", return_value="curl\t7.88.1-10\t1024\t\t\ttool\n"):
|
||||
(pkg,) = driver.get_packages()
|
||||
|
||||
assert pkg["source_package"] == "curl"
|
||||
assert pkg["source_version"] == "7.88.1-10"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -639,13 +686,16 @@ def test_get_facts_baremetal_vendor_model_serial(driver):
|
||||
platform = {"vendor": "Dell Inc.", "model": "PowerEdge R720", "serial": "ABC123", "is_vm": False}
|
||||
with patch.object(driver, "_collect_platform_info", return_value=platform), \
|
||||
patch.object(driver, "_parse_uptime", return_value=86400), \
|
||||
patch.object(driver, "_send", side_effect=["myhost", "myhost.example.com", "Debian GNU/Linux 12", "eth0\neth1"]):
|
||||
patch.object(driver, "_send", side_effect=["myhost", "myhost.example.com", "Debian GNU/Linux 12", "eth0\neth1",
|
||||
"6.1.0-18-amd64"]):
|
||||
facts = driver.get_facts()
|
||||
assert facts["vendor"] == "Dell Inc."
|
||||
assert facts["model"] == "PowerEdge R720"
|
||||
assert facts["serial_number"] == "ABC123"
|
||||
assert facts["hostname"] == "myhost"
|
||||
assert facts["uptime"] == 86400
|
||||
# Booted kernel, not the newest installed one — kernel CVE relevance needs it.
|
||||
assert facts["running_kernel"] == "6.1.0-18-amd64"
|
||||
|
||||
|
||||
def test_get_facts_vm_kvm(driver):
|
||||
@@ -655,7 +705,8 @@ def test_get_facts_vm_kvm(driver):
|
||||
}
|
||||
with patch.object(driver, "_collect_platform_info", return_value=platform), \
|
||||
patch.object(driver, "_parse_uptime", return_value=3600), \
|
||||
patch.object(driver, "_send", side_effect=["vmhost", "vmhost.local", "Ubuntu 22.04 LTS", "eth0"]):
|
||||
patch.object(driver, "_send", side_effect=["vmhost", "vmhost.local", "Ubuntu 22.04 LTS", "eth0",
|
||||
"5.15.0-91-generic"]):
|
||||
facts = driver.get_facts()
|
||||
assert facts["vendor"] == "KVM"
|
||||
assert facts["model"] == "Virtual Machine"
|
||||
@@ -666,7 +717,7 @@ def test_get_facts_fallback_vendor_when_dmi_empty(driver):
|
||||
platform = {"vendor": "", "model": "", "serial": "", "is_vm": False}
|
||||
with patch.object(driver, "_collect_platform_info", return_value=platform), \
|
||||
patch.object(driver, "_parse_uptime", return_value=0), \
|
||||
patch.object(driver, "_send", side_effect=["host", "host.local", "Alpine Linux 3.19", "eth0"]):
|
||||
patch.object(driver, "_send", side_effect=["host", "host.local", "Alpine Linux 3.19", "eth0", "6.6.7-0-lts"]):
|
||||
facts = driver.get_facts()
|
||||
assert facts["vendor"] == "Linux" # fallback to VENDOR class attribute
|
||||
|
||||
@@ -861,3 +912,75 @@ class TestDockerBinHook:
|
||||
assert docker_cmds
|
||||
for cmd in docker_cmds:
|
||||
assert "/opt/cs/docker" in cmd, f"unconverted call site: {cmd}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# uninstall_package – purge, and getting out of `install ok unpacked`
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUninstallPackage:
|
||||
"""Removing a package that does not want to go.
|
||||
|
||||
Both cases here were found during a fleet-wide Wazuh rollback. Of thirteen
|
||||
hosts carrying the agent, seven sat at `install ok unpacked` with the unit
|
||||
failed — an upgrade whose postinst could not reach a manager that no longer
|
||||
existed. `apt-get remove` cannot help there: apt configures a package before
|
||||
removing it, and configuring is exactly what was broken.
|
||||
|
||||
And `remove` leaves the configuration behind by design, which for the Wazuh
|
||||
agent means its apt source keeps being fetched on every update, long after
|
||||
the package is gone.
|
||||
"""
|
||||
|
||||
def test_remove_is_still_the_default(self, driver):
|
||||
"""Callers that did not ask for a purge must not get one: configuration
|
||||
somebody may want back is not this function's to delete."""
|
||||
_mock_send(driver, "Removing wazuh-agent ...")
|
||||
|
||||
driver.uninstall_package("wazuh-agent")
|
||||
|
||||
sent = driver._device.send_command.call_args[0][0]
|
||||
assert "apt-get remove" in sent
|
||||
assert "purge" not in sent
|
||||
|
||||
def test_purge_is_asked_for_explicitly(self, driver):
|
||||
_mock_send(driver, "Purging configuration files for wazuh-agent ...")
|
||||
|
||||
driver.uninstall_package("wazuh-agent", purge=True)
|
||||
|
||||
assert "apt-get purge" in driver._device.send_command.call_args[0][0]
|
||||
|
||||
def test_a_half_configured_package_falls_back_to_dpkg(self, driver):
|
||||
"""`install ok unpacked` is the state apt cannot get out of. On one host
|
||||
only `dpkg --purge --force-all` removed it."""
|
||||
driver._device.send_command.side_effect = [
|
||||
"E: Sub-process /usr/bin/dpkg returned an error code (1)",
|
||||
"Removing wazuh-agent (4.14.7-1) ...",
|
||||
]
|
||||
|
||||
result = driver.uninstall_package("wazuh-agent", purge=True)
|
||||
|
||||
assert result["success"] is True
|
||||
second = driver._device.send_command.call_args_list[1][0][0]
|
||||
assert "dpkg --purge --force-all" in second
|
||||
|
||||
def test_the_fallback_is_not_tried_when_the_first_pass_worked(self, driver):
|
||||
"""A forced dpkg purge is a bigger hammer than apt and must stay a last
|
||||
resort, not a routine second step."""
|
||||
_mock_send(driver, "Removing wazuh-agent ...")
|
||||
|
||||
driver.uninstall_package("wazuh-agent", purge=True)
|
||||
|
||||
assert driver._device.send_command.call_count == 1
|
||||
|
||||
def test_a_package_manager_without_purge_still_removes(self, driver):
|
||||
"""apk and pacman have no separate purge; asking for one must not turn
|
||||
into a failure or a command they do not understand."""
|
||||
driver._pkg_manager = "apk"
|
||||
_mock_send(driver, "(1/1) Purging wazuh-agent")
|
||||
|
||||
result = driver.uninstall_package("wazuh-agent", purge=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "apk del" in driver._device.send_command.call_args[0][0]
|
||||
|
||||
Reference in New Issue
Block a user