diff --git a/client/patchman-client b/client/patchman-client index de7189cd..b1a2a7f7 100755 --- a/client/patchman-client +++ b/client/patchman-client @@ -1,4 +1,5 @@ #!/bin/bash +# shellcheck disable=SC1001,SC1090,SC1091,SC2001,SC2002,SC2012,SC2013,SC2016,SC2034,SC2045,SC2046,SC2086,SC2143,SC2153,SC2154,SC2181,SC2206,SC2219,SC2236 export LC_ALL=C export FULL_IFS=$' \t\n' @@ -11,11 +12,12 @@ debug=false report=false local_updates=false repo_check=true +dry_run=false tags='' api_key='' usage() { - echo "${0} [-v] [-d] [-n] [-u] [-r] [-s SERVER] [-c FILE] [-t TAGS] [-h HOSTNAME] [-p PROTOCOL] [-k API_KEY]" + echo "${0} [-v] [-d] [-n] [-u] [-y] [-r] [-s SERVER] [-c FILE] [-t TAGS] [-H HOSTNAME] [-p PROTOCOL] [-k API_KEY]" echo "-v: verbose output (default is silent)" echo "-d: debug output" echo "-n: no repo check (required when used as an apt or yum plugin)" @@ -24,16 +26,17 @@ usage() { echo "-s SERVER: web server address, e.g. https://patchman.example.com" echo "-c FILE: config file location (default is /etc/patchman/patchman-client.conf)" echo "-t TAGS: comma-separated list of tags, e.g. -t www,dev" - echo "-h HOSTNAME: specify the hostname of the local host" + echo "-H HOSTNAME: specify the hostname of the local host" echo "-p PROTOCOL: protocol version (1 or 2, default is 1)" echo "-k API_KEY: API key for protocol 2 authentication" + echo "-y: dry run (collect data but do not submit)" echo echo "Command line options override config file options." exit 0 } parseopts() { - while getopts "vdnurs:c:t:h:p:k:" opt; do + while getopts "vdnuyrs:c:t:h:H:p:k:" opt; do case ${opt} in v) verbose=true @@ -48,6 +51,9 @@ parseopts() { u) local_updates=true ;; + y) + dry_run=true + ;; r) cli_report=true ;; @@ -60,7 +66,7 @@ parseopts() { t) cli_tags="${OPTARG}" ;; - h) + h|H) cli_hostname=${OPTARG} ;; p) @@ -121,12 +127,11 @@ check_conf() { fi if [ -z "${conf}" ] || [ ! -f "${conf}" ] ; then - if ${verbose} ; then - echo "Warning: config file '${conf}' not found." - fi - else - source "${conf}" + echo "patchman-client: config file not found: ${conf}" >&2 + echo " Create the config file and set server= to your patchman server." >&2 + exit 1 fi + source "${conf}" conf_dir=$(dirname "${conf}")/conf.d if [ -d "${conf_dir}" ] ; then @@ -136,9 +141,21 @@ check_conf() { fi fi - if [ -z "${server}" ] && [ -z "${cli_server}" ] ; then - echo 'Patchman server not set, exiting.' - exit 1 + # check server is configured and not the example placeholder + if ! ${dry_run} ; then + if [ -z "${server}" ] && [ -z "${cli_server}" ] ; then + echo "patchman-client: server not configured." >&2 + echo " Edit ${conf} and set server= to your patchman server URL." >&2 + exit 1 + fi + if [ ! -z "${cli_server}" ] ; then + server=${cli_server} + fi + if echo "${server}" | grep -qE 'patchman\.example\.com' ; then + echo "patchman-client: server not configured." >&2 + echo " Edit ${conf} and set server= to your patchman server URL." >&2 + exit 1 + fi else if [ ! -z "${cli_server}" ] ; then server=${cli_server} @@ -191,7 +208,7 @@ check_conf() { if [ ! -z "${api_key}" ] ; then echo "API Key: ${api_key:0:12}..." fi - for var in report local_updates repo_check verbose debug ; do + for var in report local_updates repo_check dry_run verbose debug ; do eval val=\$${var} echo "${var}: ${val}" done @@ -199,7 +216,7 @@ check_conf() { } check_booleans() { - for var in report local_updates repo_check verbose debug ; do + for var in report local_updates repo_check dry_run verbose debug ; do eval val=\$${var} if [ -z ${val} ] || [ "${val}" == "0" ] || [ "${val,,}" == "false" ] ; then eval ${var}=false @@ -490,6 +507,30 @@ get_zypper_updates() { zypper -q -n -s11 lu -r ${1} | grep ^v | awk '{print $2"."$5,$4}' | sed -e "s/$/ ${1}/" >> "${tmpfile_bug}" } +get_apt_updates() { + if ! check_command_exists apt ; then + return + fi + if ${verbose} ; then + echo 'Finding apt updates...' + fi + apt list --upgradable 2>/dev/null | grep -v '^Listing' | while IFS= read -r line ; do + if [ -z "${line}" ] ; then + continue + fi + # Format: package/suite version arch [upgradable from: old-version] + pkg=$(echo "${line}" | cut -d '/' -f 1) + suite=$(echo "${line}" | cut -d '/' -f 2 | cut -d ' ' -f 1) + version=$(echo "${line}" | awk '{print $2}') + arch=$(echo "${line}" | awk '{print $3}') + if echo "${suite}" | grep -qi 'security' ; then + echo "${pkg}.${arch} ${version}" >> "${tmpfile_sec}" + else + echo "${pkg}.${arch} ${version}" >> "${tmpfile_bug}" + fi + done +} + get_repos() { IFS=${NL_IFS} @@ -505,12 +546,12 @@ get_repos() { fi # replace this with a dedicated awk or simple python script? yum_repolist=$(yum repolist enabled --verbose 2>/dev/null | sed -e "s/:\? *([0-9]\+ more)$//g" -e "s/ ([0-9]\+$//g" -e "s/:\? more)$//g" -e "s/'//g" -e "s/%/%%/g") - for i in $(echo "${yum_repolist}" | awk '{ if ($1=="Repo-id") {printf "'"'"'"; for (i=3; i0){print ""} n++; url=0; printf "'"'"'"; for (i=3; i0) print ""}' | sed -e "s/\/'/'/g" | sed -e "s/ ' /' /") ; do full_id=$(echo ${i} | cut -d \' -f 2) id=$(echo ${i} | cut -d \' -f 2 | cut -d \/ -f 1) - name=$(echo ${i} | cut -d \' -f 4) + orig_name=$(echo ${i} | cut -d \' -f 4) # Strip " - arch arch" suffix pattern to avoid duplicates like "EPEL - x86_64 x86_64" - name=$(echo "${name}" | sed -e "s/ - ${host_arch} ${host_arch}$/ ${host_arch}/") + name=$(echo "${orig_name}" | sed -e "s/ - ${host_arch} ${host_arch}$/ ${host_arch}/") if [ "${priorities}" != "" ] ; then priority=$(echo "${priorities}" | grep "'${name}'" | sed -e "s/priority=\(.*\) '${name}'/\1/") fi @@ -528,7 +569,7 @@ get_repos() { if [ ! -z ${CPE_NAME} ] ; then id="${CPE_NAME}-${id}" fi - j=$(echo ${i} | sed -e "s#'${full_id}' '${name}'#'${name}' '${id}' '${priority}'#" | sed -e "s/'\[/'/g" -e "s/\]'/'/g") + j=$(echo ${i} | sed -e "s#'${full_id}' '${orig_name}'#'${name}' '${id}' '${priority}'#" | sed -e "s/'\[/'/g" -e "s/\]'/'/g") echo "'rpm' ${j}" >> "${tmpfile_rep}" unset priority done @@ -673,13 +714,23 @@ get_repos() { } reboot_required() { - # On debian-based clients, the update-notifier-common - # package needs to be installed for this to work. + # Debian/Ubuntu: update-notifier-common sets this file if [ -e /var/run/reboot-required ] ; then reboot=True - else - reboot=ServerCheck + return + fi + + # Compare running vs installed kernel via /boot/vmlinuz symlink + if [ -e /proc/sys/kernel/osrelease ] && [ -L /boot/vmlinuz ] ; then + running_kernel=$(cat /proc/sys/kernel/osrelease) + installed_kernel=$(readlink /boot/vmlinuz | sed -e 's/^vmlinuz-//') + if [ "${running_kernel}" != "${installed_kernel}" ] ; then + reboot=True + return + fi fi + + reboot=ServerCheck } build_packages_json() { @@ -1083,8 +1134,30 @@ get_modules if ${repo_check} ; then get_repos fi +if ${local_updates} ; then + get_apt_updates +fi reboot_required +if ${dry_run} ; then + echo + echo "=== Dry Run Summary ===" + echo "Hostname: ${hostname}" + echo "OS: ${os}" + echo "Arch: ${host_arch}" + echo "Kernel: ${host_kernel}" + echo "Packages: $(wc -l < ${tmpfile_pkg})" + echo "Repos: $(wc -l < ${tmpfile_rep})" + echo "Modules: $(wc -l < ${tmpfile_mod})" + echo "Security updates: $(wc -l < ${tmpfile_sec})" + echo "Bugfix updates: $(wc -l < ${tmpfile_bug})" + echo "Reboot required: ${reboot}" + if [ ! -z "${tags}" ] ; then + echo "Tags: ${tags}" + fi + exit 0 +fi + # Use protocol 2 (JSON) or protocol 1 (form data) based on config if [ "${protocol}" == "2" ] ; then post_json_data diff --git a/reports/tests/test_parsing.py b/reports/tests/test_parsing.py index 9d50d2f3..f9c94098 100644 --- a/reports/tests/test_parsing.py +++ b/reports/tests/test_parsing.py @@ -19,6 +19,7 @@ from packages.models import Package from reports.utils import ( _get_package_type, _get_repo_type, parse_packages, parse_repos, + process_repo_text, ) from repos.models import Repository @@ -178,3 +179,61 @@ def test_get_repo_type_unknown(self): """Test unknown repo type returns None.""" self.assertIsNone(_get_repo_type('')) self.assertIsNone(_get_repo_type('invalid')) + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class ProcessRepoTextTests(TestCase): + """Tests for process_repo_text() - handles malformed repo data gracefully.""" + + def setUp(self): + from arch.models import MachineArchitecture + self.arch, _ = MachineArchitecture.objects.get_or_create(name='x86_64') + + def test_rpm_normal(self): + """Test normal RPM repo parsing.""" + repo = ['rpm', 'Rocky BaseOS x86_64', 'baseos', '99', + 'https://dl.rockylinux.org/vault/rocky/9/BaseOS/x86_64/os/'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNotNone(result) + self.assertEqual(priority, -99) + + def test_rpm_url_as_priority(self): + """Test RPM repo where URL appears where priority should be (metalink merge bug).""" + repo = ['rpm', 'EPEL 9 x86_64', 'epel', + 'https://mirrors.fedoraproject.org/metalink?repo=epel-9&arch=x86_64'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNone(result) + self.assertEqual(priority, 0) + + def test_rpm_missing_priority(self): + """Test RPM repo with missing priority field skips gracefully.""" + repo = ['rpm', 'EPEL x86_64', 'epel'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNone(result) + self.assertEqual(priority, 0) + + def test_deb_normal(self): + """Test normal Debian repo parsing.""" + repo = ['deb', 'Ubuntu Main x86_64', '500', + 'http://archive.ubuntu.com/ubuntu'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNotNone(result) + self.assertEqual(priority, 500) + + def test_deb_url_as_priority(self): + """Test Debian repo where URL appears where priority should be.""" + repo = ['deb', 'Ubuntu Main x86_64', + 'http://archive.ubuntu.com/ubuntu'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNone(result) + self.assertEqual(priority, 0) + + def test_unknown_type(self): + """Test unknown repo type returns None.""" + repo = ['unknown', 'test', '0', 'http://example.com'] + result, priority = process_repo_text(repo, 'x86_64') + self.assertIsNone(result) + self.assertEqual(priority, 0) diff --git a/reports/utils.py b/reports/utils.py index f45cd19d..9b2a4196 100644 --- a/reports/utils.py +++ b/reports/utils.py @@ -34,7 +34,7 @@ from patchman.signals import pbar_start, pbar_update from repos.models import Mirror, MirrorPackage, Repository from repos.utils import get_or_create_repo -from util.logging import debug_message, info_message +from util.logging import debug_message, error_message, info_message def process_repos(report, host): @@ -281,23 +281,27 @@ def process_repo_text(repo, arch): """ r_id = None - if repo[0] == 'deb': - r_type = Repository.DEB - r_priority = int(repo[2]) - elif repo[0] == 'rpm': - r_type = Repository.RPM - r_id = repo.pop(2) - r_priority = int(repo[2]) * -1 - elif repo[0] == 'arch': - r_type = Repository.ARCH - r_id = repo[2] - r_priority = 0 - elif repo[0] == 'gentoo': - r_type = Repository.GENTOO - r_id = repo.pop(2) - r_priority = repo[2] - arch = 'any' - else: + try: + if repo[0] == 'deb': + r_type = Repository.DEB + r_priority = int(repo[2]) + elif repo[0] == 'rpm': + r_type = Repository.RPM + r_id = repo.pop(2) + r_priority = int(repo[2]) * -1 + elif repo[0] == 'arch': + r_type = Repository.ARCH + r_id = repo[2] + r_priority = 0 + elif repo[0] == 'gentoo': + r_type = Repository.GENTOO + r_id = repo.pop(2) + r_priority = int(repo[2]) + arch = 'any' + else: + return None, 0 + except (ValueError, IndexError) as e: + error_message(text=f'Skipping malformed repo line: {repo} ({e})') return None, 0 r_name = repo[1] if repo[1] else ''