Operating Systems / Ubuntu
Script

Bash Script to Automate Ubuntu Security Patch Audits

This guide shows how to build a Bash script that audits Ubuntu security patches by checking installed packages against available security updates, with safe defaults, validation, and exportable results.

Bash Script to Automate Ubuntu Security Patch Audits

Introduction

Security patch audits often fail operationally because the review process is manual, inconsistent, or too slow to support change windows and compliance evidence. On Ubuntu systems, that means teams may know updates exist, but not whether the machine is missing security fixes, which packages are affected, or how to capture results in a repeatable way.

In this guide, you will build a practical Bash script to automate an Ubuntu security patch audit. The script will check whether security updates are available, list the affected packages, support a safe dry-run mode by default, and produce machine-readable output you can use for reporting or follow-up. You will also see what the script does not do, what must be verified before production use, and how to validate the results without changing the system.

Script language and scope

This solution uses Bash and standard Ubuntu package-management tools. It is intentionally narrow in scope: it audits the local system for pending security updates and reports what is missing. It does not install packages, restart services, patch remotely, or open tickets.

For broader system hygiene, pair this audit with your normal access-control and firewall review process. If you also need to validate host exposure before patching, a configuration review such as How to Configure UFW Firewall Rules on Ubuntu Server can help confirm whether the machine is restricted appropriately before maintenance work begins.

What the script does and does not do

Does

  • Checks whether the local Ubuntu host has security updates available.
  • Filters package results to security-relevant updates when repository metadata supports that distinction.
  • Runs in safe, read-only mode by default.
  • Validates input parameters before doing any work.
  • Emits human-readable output and optional JSON output for automation.
  • Exits with clear status codes so schedulers and pipelines can interpret the result.

Does not

  • Install or upgrade packages.
  • Reboot the machine.
  • Verify kernel live patching status.
  • Aggregate results from multiple hosts.
  • Guarantee that every security fix is tagged perfectly by every repository or mirror configuration.

That last point matters: the script depends on what package metadata is available on the host. You should verify repository health, update freshness, and your Ubuntu release support status before trusting the audit as a compliance source.

Assumptions, requirements, and permissions

Assumptions

  • The host is Ubuntu and uses apt/apt-get.
  • Security repository metadata is available through the configured package sources.
  • The machine can reach its package mirrors or an internal mirror.
  • You want local audit output, not fleet orchestration.

Requirements

  • Bash 4+.
  • apt-get, apt-cache, grep, awk, sed, sort, mktemp, and flock if you want to prevent concurrent runs.
  • python3 if you want JSON formatting in the example below. The script can still operate without JSON, but the example includes it because machine-readable output is useful in automation.
  • Read access to package metadata, which is normally available to non-root users for audit-only checks, though some environments restrict package cache access.

Permissions

  • Read-only audit mode: usually works as a standard user if local package metadata is readable.
  • Full package cache refresh: may require sudo if you choose to update package lists before the audit.
  • Do not run an audit script that changes package state unless you explicitly intend to patch.

If you are using this as a compliance or security evidence workflow, document which account ran the audit, the repository state at the time, and whether caches were refreshed immediately beforehand.

Bash script

The script below is a safe skeleton suitable for production hardening. It defaults to audit-only behavior and uses explicit parameters for anything that could alter system state.

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

# ubuntu-security-audit.sh
# Purpose: Audit a local Ubuntu host for pending security updates.
# Default behavior: read-only, no package changes.

SCRIPT_NAME="$(basename "$0")"
OUTPUT_FORMAT="text"
REFRESH_CACHE="false"
ONLY_SECURITY="true"
JSON_PRETTY="false"

usage() {
  cat <<'EOF'
Usage:
  ubuntu-security-audit.sh [--format text|json] [--refresh-cache] [--all-updates] [--pretty-json]

Options:
  --format text|json     Output format. Default: text
  --refresh-cache        Run apt-get update before auditing (requires appropriate permissions)
  --all-updates          Report all pending updates, not only security updates
  --pretty-json          Pretty-print JSON output when --format json is used
  -h, --help             Show this help message

Examples:
  ./ubuntu-security-audit.sh
  ./ubuntu-security-audit.sh --refresh-cache
  ./ubuntu-security-audit.sh --format json --pretty-json
EOF
}

log_err() {
  printf '%s: ERROR: %s\n' "$SCRIPT_NAME" "$*" >&2
}

log_info() {
  printf '%s: %s\n' "$SCRIPT_NAME" "$*"
}

require_cmd() {
  local cmd="$1"
  command -v "$cmd" >/dev/null 2>&1 || {
    log_err "Required command not found: $cmd"
    exit 2
  }
}

parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --format)
        [[ $# -ge 2 ]] || { log_err "--format requires a value"; exit 2; }
        OUTPUT_FORMAT="$2"
        shift 2
        ;;
      --refresh-cache)
        REFRESH_CACHE="true"
        shift
        ;;
      --all-updates)
        ONLY_SECURITY="false"
        shift
        ;;
      --pretty-json)
        JSON_PRETTY="true"
        shift
        ;;
      -h|--help)
        usage
        exit 0
        ;;
      *)
        log_err "Unknown argument: $1"
        usage >&2
        exit 2
        ;;
    esac
  done

  case "$OUTPUT_FORMAT" in
    text|json) ;;
    *) log_err "Invalid --format value: $OUTPUT_FORMAT"; exit 2 ;;
  esac
}

refresh_cache() {
  if [[ "$REFRESH_CACHE" == "true" ]]; then
    log_info "Refreshing package lists"
    sudo -n apt-get update
  fi
}

audit_updates() {
  # apt list --upgradable outputs a warning line and package entries.
  # We filter out non-package lines and optionally narrow to security sources.
  apt list --upgradable 2>/dev/null | awk 'NR>1 {print}'
}

filter_security_updates() {
  # This approach relies on repository naming conventions and metadata.
  # Validate in your environment before using it as compliance evidence.
  awk '
    BEGIN { FS="/" }
    {
      line = $0
      if (line ~ /security/ || line ~ /Ubuntu-Security/ || line ~ /security.ubuntu.com/) {
        print line
      }
    }
  '
}

collect_results() {
  local all_updates security_updates status count

  all_updates="$(audit_updates || true)"

  if [[ -n "$all_updates" ]]; then
    count=$(printf '%s\n' "$all_updates" | sed '/^$/d' | wc -l | tr -d ' ')
  else
    count=0
  fi

  if [[ "$ONLY_SECURITY" == "true" ]]; then
    security_updates="$(printf '%s\n' "$all_updates" | filter_security_updates || true)"
  else
    security_updates="$all_updates"
  fi

  if [[ -n "$security_updates" ]]; then
    status="updates_available"
  else
    status="clean"
  fi

  if [[ "$OUTPUT_FORMAT" == "json" ]]; then
    if [[ "$JSON_PRETTY" == "true" ]]; then
      python3 - "$status" "$count" <<'PY'
import json, sys
status = sys.argv[1]
count = int(sys.argv[2])
print(json.dumps({
    "status": status,
    "pending_update_count": count,
}, indent=2, sort_keys=True))
PY
    else
      python3 - "$status" "$count" <<'PY'
import json, sys
status = sys.argv[1]
count = int(sys.argv[2])
print(json.dumps({
    "status": status,
    "pending_update_count": count,
}, separators=(',', ':'), sort_keys=True))
PY
    fi
  else
    printf 'Status: %s\n' "$status"
    printf 'Pending updates: %s\n' "$count"
    if [[ -n "$security_updates" ]]; then
      printf '\nAffected packages:\n%s\n' "$security_updates"
    fi
  fi

  # Exit codes:
  # 0 = no pending security updates found
  # 1 = pending security updates found
  # 2 = script usage or runtime error
  if [[ "$status" == "updates_available" ]]; then
    return 1
  fi
  return 0
}

main() {
  parse_args "$@"
  require_cmd apt
  require_cmd apt-get
  require_cmd awk
  require_cmd sed
  require_cmd python3

  refresh_cache
  collect_results
}

main "$@"

How the script works

The script follows a simple operational flow:

  1. Parse and validate command-line arguments.
  2. Check that required commands exist.
  3. Optionally refresh package lists in a controlled way.
  4. Query pending upgrades from the local apt cache.
  5. Filter the output to security-relevant entries when requested.
  6. Return either text or JSON.
  7. Exit with a status code that tells automation whether updates are pending.

That structure is intentional. In security audit work, the biggest practical failure is not the query itself but the inability to tell whether the script completed cleanly, found a problem, or silently failed because a dependency was missing.

Expected input and output

Input

The script accepts only command-line flags. It does not read files or require an inventory feed.

Typical inputs:

  • No arguments: audit-only, text output.
  • --refresh-cache: refresh package metadata first.
  • --format json: return JSON for pipelines or log collectors.
  • --all-updates: report all pending updates instead of only security-related ones.

Example command

./ubuntu-security-audit.sh --format json --pretty-json

Example output

{
  "pending_update_count": 3,
  "status": "updates_available"
}

Text mode output might look like this:

Status: updates_available
Pending updates: 3

Affected packages:
openssl/security 3.0.2-0ubuntu1.14 amd64 [upgradable from: 3.0.2-0ubuntu1.12]
libssl3/security 3.0.2-0ubuntu1.14 amd64 [upgradable from: 3.0.2-0ubuntu1.12]
openssl-provider-legacy/security 3.0.2-0ubuntu1.14 amd64 [upgradable from: 3.0.2-0ubuntu1.12]

The exact package names, source labels, and formatting will vary by Ubuntu release and repository configuration.

What to change before production use

The script is deliberately conservative, but you should still harden it for your environment before using it as an audit control.

Verify the security filter

The sample filter uses repository text markers such as security, Ubuntu-Security, or security.ubuntu.com. That is a practical starting point, but you must verify that your mirrors and release naming conventions expose security updates in a way the filter can detect. If your environment uses internal mirrors or apt proxies, the source string may differ.

Decide whether to refresh caches

Refreshing the apt cache makes results more current, but it also depends on network reachability and may require elevated privileges. If your audit process runs from cron or a configuration management job, decide whether the cache refresh belongs in the script or in a separate, controlled precheck.

Align exit codes with automation

The example returns 1 when updates are found. That is useful for schedulers, but some monitoring systems interpret any non-zero exit as a hard failure. If that is your case, keep the status code logic and map it to an alerting rule that understands “updates available” versus “script error.”

Replace local-only assumptions if needed

If you need fleet reporting, do not bolt a server inventory layer into this script unless you are ready to handle authentication, transport security, and result normalization. Keep the script local and let your orchestration platform collect the output.

Safe execution behavior and dry-run defaults

This script is read-only by default. That is the right default for security audits because the purpose is evidence, not remediation.

Safe behavior in this script includes:

  • No package installation.
  • No service restarts.
  • No direct changes to system configuration.
  • No outbound API calls.
  • No ticket creation.
  • Optional cache refresh only when explicitly requested.

If you later extend this script to trigger notifications, write to an external system, or open a case, keep the safe default pattern: dry-run first, explicit opt-in for active actions, and strong input validation around every external dependency.

Validation and testing steps

Before production use, validate the script in at least three stages.

1. Syntax check

bash -n ./ubuntu-security-audit.sh

This confirms there are no parsing errors.

2. ShellCheck review

shellcheck ./ubuntu-security-audit.sh

ShellCheck will flag quoting issues, unreachable branches, and common Bash mistakes.

3. Controlled execution

Run it on a non-production Ubuntu system that matches the target release as closely as possible.

./ubuntu-security-audit.sh
./ubuntu-security-audit.sh --format json
./ubuntu-security-audit.sh --refresh-cache

Check for these outcomes:

  • The script exits 0 when no updates are pending.
  • The script exits 1 when pending security updates are found.
  • The script exits 2 for missing commands or invalid arguments.
  • The text and JSON formats both parse cleanly.
  • The output matches your repository setup and release expectations.

If you are using the audit as part of a broader host-hardening workflow, document the evidence in the same way you would for firewall controls or patch baselines. For example, a security review may also reference host exposure controls like those described in How to Configure UFW Firewall Rules on Ubuntu Server, but keep the patch audit focused on update status only.

Security and privacy notes

Patch audit output can reveal software versions, installed packages, and repository naming details. That is operationally useful, but it is still sensitive in many environments.

Keep the following in mind:

  • Restrict access to audit logs and exported JSON.
  • Avoid sending results to unsecured channels.
  • Treat package version data as inventory information.
  • Do not expose internal mirror hostnames unless necessary.
  • If you wrap the script in a centralized scheduler, ensure credentials and repository tokens are not echoed to logs.

If your audit process uses sudo -n apt-get update, confirm that passwordless privilege is limited to the intended command and account. Broad sudo rights undermine the control you are trying to prove.

Cleanup and rollback guidance

Because the script is read-only, rollback is usually simple: remove the file and any scheduler entry you created.

Cleanup

  • Delete the script from the host if it is no longer needed.
  • Remove any cron job, systemd timer, or CI schedule that invokes it.
  • Archive or rotate audit logs according to your retention policy.

Rollback

If a production validation reveals that the script’s security filter misclassifies updates, rollback means:

  • Disable the job.
  • Revert to manual auditing or a known-good version of the script.
  • Adjust the repository filter logic.
  • Re-run the audit in a controlled environment before re-enabling automation.

Because the script does not modify packages, there is no package-state rollback required. That is another reason to keep the audit and the remediation workflows separate.

Common mistakes

Mistake Why it matters Better approach
Treating every apt warning as a security finding Non-security noise can create false positives and desensitize responders Filter the output carefully and verify the repository markers in your environment
Running without validating dependencies Missing tools can produce partial output or a silent failure path Check commands up front with clear error messages
Refreshing package lists implicitly Hidden network dependency makes runs inconsistent and harder to troubleshoot Make cache refresh explicit with a flag
Using non-zero exit codes without documentation Monitoring tools may treat “updates available” as a job failure Document the exit-code contract and map it in the scheduler
Assuming JSON output is automatically valid Malformed output breaks log pipelines and downstream parsing Generate JSON through a proper encoder such as python3
Extending the script to patch systems directly Audit and remediation have different change-control and rollback requirements Keep the audit script read-only and separate the fix workflow

Final takeaway

A Bash-based Ubuntu security patch audit works well when it stays narrowly focused: verify the local package state, report pending security updates clearly, keep dry-run behavior by default, and make every dependency and exit code explicit before you trust it in production.

Use this guidance together with Windows 11 hardening checklist to connect the workflow with related operational context already available on the site.

Continue learning

Related content