#!/usr/lib64/linuxfabrik-monitoring-plugins/venv/bin/python
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import json
import os
import re
import sys

import lib.args
import lib.base
import lib.cache
import lib.disk
import lib.human
import lib.lftest
import lib.time
import lib.txt
from lib.globals import STATE_OK, STATE_UNKNOWN, STATE_WARN

__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026102502'

DESCRIPTION = """Evaluates the report of the last AIDE file integrity check, which a timer such
as aidecheck.timer runs on the host at regular intervals, and reports the number of added,
removed and changed files together with the first of them. The check itself does not run
AIDE, so it is fast, and it keeps no more than the summary and the first entries of the
report in memory, however large the report gets. Alerts when AIDE found differences
between its database and the file system, and when the report is missing, empty or older
than the maximum age, because then the regular check has stopped or failed. AIDE reports a
change hours after it happened, and most changes are planned ones, so by default only
WARNING is raised.
Requires root or sudo."""

DEFAULT_CRIT = ''
DEFAULT_MAX_AGE = 26
DEFAULT_WARN = '0'
# the online documentation of this check
DOCS_URL = f'{lib.args.DOCS_BASE_URL}/check-plugins/aide-logfile/'

# where the configurations that RHEL and Fedora ship, and the one of Debian's
# aide-common, keep the AIDE database
DATABASES = ['/var/lib/aide/aide.db.gz', '/var/lib/aide/aide.db']
# runs AIDE by hand; the lock keeps it from colliding with a check a timer starts at
# the same moment, which would end with exit code 21 (a timer that takes the same
# lock for every AIDE run is safe against that, flock creates it where nobody does)
AIDE = 'sudo flock /run/aide.lock aide --config=/etc/aide.conf'
# puts the database that `aide --init` or `aide --update` wrote into place
MOVE_DATABASE = f'sudo mv {DATABASES[0][:-3]}.new.gz {DATABASES[0]}'
# the check unit the CIS benchmarks name
UNITS = [
    '/etc/systemd/system/aidecheck.service',
    '/usr/lib/systemd/system/aidecheck.service',
]

# the parsed report of the last finished run, reported again while the next one runs
CACHE_FILE = 'linuxfabrik-monitoring-plugins-aide-logfile.db'
CACHE_KEY = 'aide-logfile-last-report'

# the location every AIDE package uses in its shipped configuration (RHEL, Debian's
# aide-common)
REPORT = '/var/log/aide/aide.log'
# Debian's aide-common hands the directory to its service account _aide, so the report
# is confined to it: a symlink planted there must not lead to any other file.
REPORT_DIR = os.path.dirname(REPORT)

# The report starts with the summary, the lists of added, removed and changed entries
# follow, and the details of every entry come last. A check that found thousands of
# entries writes megabytes, so the report is read line by line, only the first
# MAX_ENTRIES entries of each list are kept, and reading stops at the details, or
# after MAX_REPORT_BYTES in any case.
MAX_REPORT_BYTES = 16 * 1024 * 1024
MAX_ENTRIES = 10
# a line longer than this is no line of an AIDE report and is read in pieces
MAX_LINE_BYTES = 64 * 1024

# the lists of entries AIDE writes with report_grouped=yes, its default
GROUPS = {
    'Added entries': 'added',
    'Changed entries': 'changed',
    'Removed entries': 'removed',
}

# The change summary of AIDE in front of each entry (report_summarize_changes):
# get_summarize_changes_string() in src/report.c since aide 0.17, print_line() in
# src/compare_db.c of aide 0.16. The first 13 positions are the same in every version,
# the checksums show as `C` on aide 0.16 and as `H` later. The positions after them
# depend on what AIDE was built with, their letters are unique, `C` stands for the
# capabilities there (aide 0.17 and newer). Verified against the aide sources of
# 0.16.2, 0.17.4, 0.18, 0.19.2 and master.
# the file type AIDE puts first: `f` file, `d` directory, `l` symlink, `c`/`b`
# character/block device, `p` FIFO, `s` socket, `D` door, `P` event port, `!` type
# changed, `?` unknown
FILE_TYPES = set('!?DPbcdflps')
SUMMARY_POSITIONS = [
    'file type',
    'link target',
    'size',
    'block count',
    'permissions',
    'owner',
    'group',
    'access time',
    'modification time',
    'change time',
    'inode',
    'link count',
    'content',
]
# everything that can follow the file type in a change summary
SUMMARY_CHARS = set(' +-.:<=>ACEFHSXabcgilmnpu')
SUMMARY_LETTERS = {
    'A': 'ACL',
    'C': 'capabilities',
    'E': 'file attributes',
    'F': 'file system type',
    'S': 'SELinux context',
    'X': 'extended attributes',
}

# A section of the report starts with a headline, for example
#     ---------------------------------------------------
#     Added entries:
#     ---------------------------------------------------
# (report_top_format in src/compare_db.c of aide 0.16 and in src/report.c of aide
# 0.17, PLAIN_REPORT_HEADLINE_FMT in src/report_plain.c since aide 0.18).
# The layout of the plain report is the same from aide 0.16 to 0.19. Verified with
# aide 0.16 on Rocky 8, 0.17.4 on Ubuntu 22.04, 0.18.3 on Debian 12 and 0.19.1 on
# Debian 13, and against the aide sources up to 0.19.4. The distribution packages of
# Rocky 8/9/10, Debian 12/13 and Ubuntu 22.04/24.04/26.04 wrote about 1250 reports
# in containers, across every report_level (verbose on 0.16), grouped and ungrouped
# lists, with and without the change summary, report_quiet, report_append,
# report_detailed_init and report_format=json, for --init, --check, --update and
# --compare, which the check all evaluates as AIDE meant them.
DASHES_REGEX = re.compile(r'^-{20,}$')
# The end of an "Entry <file name> in databases has different attributes: <attributes>"
# line, which AIDE writes ahead of the start timestamp and the outline of a report
# (print_report_diff_attrs_entries_plain() in src/report_plain.c since aide 0.18,
# terse_report() in src/report.c of aide 0.17, add_file_to_tree() in src/gen_list.c
# of aide 0.16).
DIFF_ATTRS_REGEX = re.compile(
    r' in databases has different attributes: [^\n]*$', re.MULTILINE
)
OUTLINE_REGEX = re.compile(
    r'^AIDE (?:(successfully initialized database\.|initialized database at )'
    r'|found (NO )?differences between (?:database and filesystem|the two '
    r'databases))',
    re.MULTILINE,
)
START_REGEX = re.compile(
    r'^Start timestamp: (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d(?: [+-]\d{4})?)'
    r' \(AIDE ([^)]+)\)',
    re.MULTILINE,
)
# "Summary:" block of a run with differences, "Number of entries:" of one without.
# AIDE indents with spaces and tabs only. `\s` would also span line breaks, and with
# MULTILINE every line start would then scan all blank lines after it, which takes
# quadratic time on a report with many of them (10 s for 40000).
SUMMARY_REGEX = re.compile(
    r'^[ \t]*(Total number of entries|Number of entries|Added entries|Removed entries'
    r'|Changed entries):[ \t]*(\d+)[ \t]*$',
    re.MULTILINE,
)


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(
        description=DESCRIPTION,
        epilog=lib.args.epilog(__file__),
        formatter_class=lib.args.HelpFormatter,
    )

    parser.add_argument(
        '-V',
        '--version',
        action='version',
        version=f'%(prog)s: v{__version__} by {__author__}',
    )

    parser.add_argument(
        '--always-ok',
        help=lib.args.help('--always-ok'),
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    # Threshold parameters: use type=str (not int/float) to support Nagios
    # range expressions like "0", "10:", "~:50", "@10:20".
    # In main(), pass _operator='range' to lib.base.get_state().
    parser.add_argument(
        '-c',
        '--critical',
        help='CRIT threshold for the number of differences (added, removed and '
        'changed entries together) that the last AIDE check found. '
        'Supports Nagios ranges. '
        'Example: `--critical=0` to get a critical alert on the first difference. '
        'Default: %(default)s (no critical)',
        dest='CRIT',
        default=DEFAULT_CRIT,
    )

    parser.add_argument(
        '--max-age',
        help='Maximum age of the report in hours. An older report means that the '
        'regular AIDE check no longer runs. '
        'Default: %(default)s (0 disables the check)',
        dest='MAX_AGE',
        type=int,
        default=DEFAULT_MAX_AGE,
    )

    parser.add_argument(
        '--no-perfdata',
        help=lib.args.help('--no-perfdata'),
        dest='NO_PERFDATA',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    parser.add_argument(
        '-w',
        '--warning',
        help='WARN threshold for the number of differences (added, removed and '
        'changed entries together) that the last AIDE check found. '
        'Supports Nagios ranges. '
        'The default alerts on the first difference. '
        'Default: %(default)s',
        dest='WARN',
        default=DEFAULT_WARN,
    )

    args, _ = parser.parse_known_args()
    return args


def aide_running():
    """Tell whether an AIDE process is running right now. AIDE empties its report
    when it starts and writes the new one only when it has compared the whole file
    system, which takes minutes on a large host, so an empty report is normal while a
    check runs. Only a process of root counts: any user can start a process named
    `aide`, and one of them must not make an aborted run look like one in progress.
    Returns the PID of the first AIDE process found, or an empty string.
    """
    try:
        pids = [entry for entry in os.listdir('/proc') if entry.isdigit()]
    except OSError:
        return ''
    for pid in sorted(pids, key=int):
        info = lib.disk.stat(f'/proc/{pid}')
        if info is None or info.st_uid != 0:
            continue
        success, comm = lib.disk.read_file(f'/proc/{pid}/comm')
        if success and comm.strip() == 'aide':
            return pid
    return ''


def last_match(regex, text):
    """Return the last match of `regex` (a string is compiled as multiline) in
    `text`, or None.
    """
    if isinstance(regex, str):
        regex = re.compile(regex, re.MULTILINE)
    matches = list(regex.finditer(text))
    return matches[-1] if matches else None


def describe_entry(line, grouped):
    """Translate an entry of the lists into words, for example
    `f > ...   ..H.. . : /etc/hosts` into `f: /etc/hosts: grew, content`. The code in
    front of the file name is the change summary of AIDE (report_summarize_changes):
    the file type, then one character per attribute, a letter if it changed, `.` or
    `=` if not, ` ` if not checked, `+` or `-` if a rule started or stopped checking
    it, `:` if it changed but is ignored. A line without a summary (added:, removed:,
    changed:) is returned as it is.
    """
    match = re.match(r'^(.+?): (/.*)$', line)
    if not match:
        return line
    summary, path = match.group(1), match.group(2)
    if (
        len(summary) < 2
        or summary[0] not in FILE_TYPES
        or not set(summary[1:]) <= SUMMARY_CHARS
    ):
        return line
    # the file type stays AIDE's letter, the admin reads it in front of the path
    prefix = f'{summary[0]}: {path}'
    if set(summary[1:]) == {'+'}:
        return prefix if grouped else f'{prefix} (added)'
    if set(summary[1:]) == {'-'}:
        return prefix if grouped else f'{prefix} (removed)'
    changes = []
    for position, char in enumerate(summary):
        if position == 0:
            if char == '!':
                changes.append('file type changed')
            continue
        if char in ' .=:':
            continue
        # the first 13 positions are fixed, the rest depend on the build of AIDE
        # and are told apart by their letter
        if position < len(SUMMARY_POSITIONS):
            name = SUMMARY_POSITIONS[position]
        else:
            name = SUMMARY_LETTERS.get(char.upper() if char in '+-' else char, '')
            if char in '+-':
                name = ''
        if char == '+':
            changes.append(f'{name} now checked' if name else 'more attributes checked')
        elif char == '-':
            changes.append(
                f'{name} no longer checked' if name else 'fewer attributes checked'
            )
        elif position == 2:
            changes.append('grew' if char == '>' else 'shrank')
        elif position == 10:
            changes.append('replaced (new inode)')
        else:
            changes.append(name)
    if not changes:
        return line
    return f'{prefix}: {", ".join(changes)}'


def printable(text):
    """Replace control characters, which file names can carry, so they cannot
    garble the plugin output.
    """
    return re.sub(r'[\x00-\x1f\x7f]', '?', text)


def database_moved(path):
    """Tell whether the new database an `aide --update` run wrote is gone, which
    means that it was moved over the old one and its differences are accepted.

    The path comes from the report. Only an absolute path to an AIDE database is
    looked at, so the report cannot make the check probe for other files, and
    anything else counts as not moved.
    """
    if not path.startswith('/') or not os.path.basename(path).startswith('aide.db'):
        return False
    return not os.path.lexists(path)


def read_lines(handle):
    """Yield the lines of a binary file handle, each one cut at MAX_LINE_BYTES, so a
    file without line breaks cannot fill the memory.
    """
    while True:
        line = handle.readline(MAX_LINE_BYTES)
        if not line:
            return
        yield line


def parse_report(lines):
    """Parse a plain AIDE report (aide 0.16 to 0.19), given as an iterable of lines
    in bytes.

    Returns a dict with `action` (`check`, `init`, `update`, or None if the text is
    no AIDE report), `compare` (whether a check compared two databases instead of a
    database and the file system), `database_out` (the new database an update run
    wrote), `found` (whether the outline of a check or update reports differences),
    `start` (the start timestamp as written by AIDE, or None), `version`, the
    counts `added`, `changed`, `removed`, `total` (None where the report does not
    tell), `entries`, a list of (headline, lines) of the lists of
    added, removed and changed entries with at most MAX_ENTRIES lines each, `text`,
    everything above the first section, `truncated`, True if reading stopped at
    MAX_REPORT_BYTES before the end of the lists, and `appended`, True if a further
    report follows the first one (report_append=yes).
    """
    report = {
        'action': None,
        'added': None,
        'appended': False,
        'compare': False,
        'changed': None,
        'database_out': '',
        'entries': [],
        'found': False,
        'removed': None,
        'start': None,
        'text': '',
        'total': None,
        'truncated': False,
        'version': '',
    }
    head = []
    section = None
    # a headline spans three lines, so a line of dashes and the two after it are
    # held back until it is clear whether they form one
    pending = []
    size = 0
    # past the lists, the rest of the file is only searched for the outline of a
    # further report, which report_append=yes writes behind the first one
    past_lists = False
    for raw in lines:
        size += len(raw)
        if size > MAX_REPORT_BYTES:
            report['truncated'] = not past_lists
            break
        # file names are written as the file system has them, whatever their
        # encoding
        line = lib.txt.to_text(raw, errors='strict_or_latin1').rstrip('\r\n')
        if past_lists:
            if OUTLINE_REGEX.match(line):
                report['appended'] = True
                break
            continue
        pending.append(line)
        while pending and not past_lists:
            if DASHES_REGEX.match(pending[0]):
                if len(pending) < 3:
                    break
                if pending[1].endswith(':') and DASHES_REGEX.match(pending[2]):
                    title = pending[1][:-1]
                    pending = pending[3:]
                    if title.endswith('entries'):
                        report['entries'].append((title, []))
                    elif section is not None:
                        # the details and the attributes of the databases follow
                        # the lists, nothing below them is kept
                        past_lists = True
                    section = title
                    continue
            line = pending.pop(0)
            if section is None:
                head.append(line)
                continue
            if OUTLINE_REGEX.match(line):
                report['appended'] = True
                past_lists = True
                break
            if report['entries'] and section == report['entries'][-1][0]:
                entries = report['entries'][-1][1]
                if line.strip() and len(entries) < MAX_ENTRIES:
                    entries.append(printable(line.strip()))
        if report['appended']:
            break
    if section is None:
        head.extend(pending)
    text = '\n'.join(head)
    report['text'] = text

    # The outline of the report: gen_report() in src/compare_db.c of aide 0.16,
    # src/report.c of aide 0.17, get_summary_string() in src/report.c since aide
    # 0.18. It is anchored to the start of a line, since the daily check of
    # Debian's aide-common writes lines of its own around the report. `aide
    # --update` compares like a check and then writes a new database, and says so
    # in the next line, from report_level `summary` on. `aide --compare` compares
    # two databases instead of a database and the file system.
    # Before aide 0.19.2, AIDE wrote file names with their control characters
    # (CVE-2025-54389), and the "Entry ... in databases has different attributes"
    # lines, which carry file names, come ahead of the outline. A file name with a
    # line break could therefore forge a line there, so the last match counts.
    # Appended reports without any section end up in the head together. Only the
    # outlines behind the last of those lines are counted for that: every line a
    # file name forges there is followed by the real end of its own entry, so none
    # of them can come behind it, and a forged outline cannot pass the report off
    # as one of several runs. Verified against the aide sources 0.16 to 0.19.4.
    match = last_match(DIFF_ATTRS_REGEX, text)
    if len(OUTLINE_REGEX.findall(text[match.end() :] if match else text)) > 1:
        report['appended'] = True
    match = last_match(OUTLINE_REGEX, text)
    if match and match.group(1):
        report['action'] = 'init'
    elif match:
        report['action'] = 'check'
        report['compare'] = match.group(0).endswith('the two databases')
        report['found'] = not match.group(2)
        # AIDE writes this line right behind the outline (print_report_header()
        # in src/report.c of aide 0.17, print_report() since aide 0.18, verified
        # up to 0.19.4). One in front of it can only be forged by a file name, and
        # would make a check look like an accepted update.
        match = last_match(r'^New AIDE database written to (.+)$', text[match.end() :])
        if match:
            report['action'] = 'update'
            report['database_out'] = match.group(1).strip()

    match = last_match(START_REGEX, text)
    if match:
        report['start'] = match.group(1)
        report['version'] = match.group(2)

    for key, value in SUMMARY_REGEX.findall(text):
        if key in ('Total number of entries', 'Number of entries'):
            report['total'] = int(value)
        else:
            report[GROUPS[key]] = int(value)
    # a run without differences prints the number of entries only
    if report['action'] in ('check', 'update') and report['total'] is not None:
        for key in GROUPS.values():
            if report[key] is None:
                report[key] = 0

    return report


def get_entries_msg(report):
    """Return the lists of added, removed and changed entries, each cut after
    MAX_ENTRIES lines.
    """
    sections = []
    listed = []
    for title, lines in report['entries']:
        # the count per group comes from the summary; a list of all entries
        # (report_grouped=no) holds every difference
        key = GROUPS.get(title)
        count = report[key] if key else sum(report[key] or 0 for key in GROUPS.values())
        count = max(count or 0, len(lines))
        section = f'{title}:\n' + '\n'.join(
            f'* {describe_entry(line, key is not None)}' for line in lines
        )
        if count > len(lines):
            section += f'\n* ... and {count - len(lines)} more'
        sections.append(section)
        listed.append(title)
    # a list that lies beyond MAX_REPORT_BYTES, in the order AIDE writes them
    if report['truncated'] and all(title in GROUPS for title in listed):
        for title in ('Added entries', 'Removed entries', 'Changed entries'):
            if title not in listed and report[GROUPS[title]]:
                sections.append(
                    f'{title}:\n* {report[GROUPS[title]]} not listed, beyond the '
                    f'first {lib.human.bytes2human(MAX_REPORT_BYTES)} of the report'
                )
    return '\n\n' + '\n\n'.join(sections) if sections else ''


def main():
    """The main function. This is where the magic happens."""

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    # fetch data
    mtime = None
    if args.TEST is None:
        # AIDE keeps its log directory to root (mode 0700), so without root the
        # report cannot even be seen, which is not the same as a missing one
        try:
            os.lstat(REPORT)
        except FileNotFoundError:
            # Deliberately WARNING and not UNKNOWN: the regular check belongs on a
            # host that got this check, so a missing report is a gap the admin has
            # to close.
            lib.base.oao(
                f'No AIDE report found at {REPORT}, so the file integrity of this host '
                'is not being checked. Install AIDE, initialize its database and run '
                '`aide --check` regularly, for example with `aidecheck.timer`, with '
                f'`report_url=file:{REPORT}` in the AIDE configuration.',
                STATE_WARN,
                always_ok=args.ALWAYS_OK,
            )
        except OSError as e:
            lib.base.oao(
                f'Cannot access {REPORT} ({e.strerror}). The report is readable by '
                f'root only, so run the check via sudo, see {DOCS_URL}.',
                STATE_UNKNOWN,
                always_ok=args.ALWAYS_OK,
            )
        success, handle = lib.disk.open_file(
            REPORT, binary=True, allowed_roots=[REPORT_DIR], nofollow=True
        )
        if not success:
            msg = f'{handle.rstrip(".")}.'
            if os.geteuid() != 0:
                msg += (
                    ' The report is readable by root only, so run the check via sudo, '
                    f'see {DOCS_URL}.'
                )
            lib.base.oao(msg, STATE_UNKNOWN, always_ok=args.ALWAYS_OK)
        with handle:
            # the time the report was written, for a report without a start
            # timestamp
            mtime = os.fstat(handle.fileno()).st_mtime
            report = parse_report(read_lines(handle))
    else:
        # do not read the report, put in test data. A test never sees a running
        # AIDE process.
        stdout, _, _ = lib.lftest.test(args.TEST)
        report = parse_report(lib.txt.to_bytes(stdout).splitlines(keepends=True))

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''

    # analyze data
    # With report_append=yes, AIDE writes every run behind the previous ones. Only
    # the first report is parsed: taking a later one as the result would let a file
    # name with a line break, which AIDE wrote unescaped before 0.19.2
    # (CVE-2025-54389), forge a whole clean report further down. For the same
    # reason, the outline of a further report can be forged by a file name in the
    # lists or the details of the first one. So a further report never hides what
    # the first one found: if that one alerts, the check alerts with it (see the
    # end of main()), otherwise it cannot tell what the last run found.
    append_hint = (
        'Set `report_append=no` in the AIDE configuration, so that every run writes'
        ' a new report.'
    )
    # AIDE empties its report when it starts and writes the new one when it has
    # finished, which takes an hour or more on a large file server. Meanwhile, the
    # result of the previous run still stands, so it is reported again instead of
    # flapping to UNKNOWN or OK and back on every run.
    in_progress = ''
    if args.TEST is None and report['action'] is None:
        in_progress = aide_running()
        if in_progress:
            try:
                cached = json.loads(lib.cache.get(CACHE_KEY, filename=CACHE_FILE))
                # a report kept by an older version of the check may lack keys
                report = {**parse_report([]), **cached['report']}
                mtime = cached['mtime']
                if report['action'] not in ('check', 'init', 'update'):
                    raise ValueError
            # nothing cached yet (False), or not what this check wrote
            except (KeyError, TypeError, ValueError):
                lib.base.oao(
                    f'An AIDE run is in progress right now (PID {in_progress}), and '
                    'no result of an earlier run is known yet. Once AIDE has finished, '
                    'the next run of this monitoring plugin reads the new report.',
                    STATE_OK,
                    always_ok=args.ALWAYS_OK,
                )
    elif args.TEST is None:
        lib.cache.set(
            CACHE_KEY,
            json.dumps({'mtime': mtime, 'report': report}),
            filename=CACHE_FILE,
        )
    if report['action'] is None:
        text = report['text'].strip()
        # report_format=json and ndjson (aide 0.18 and newer)
        if text.startswith('{'):
            lib.base.oao(
                f'{REPORT} is not a plain AIDE report. Set `report_format=plain` in '
                'the AIDE configuration.',
                STATE_UNKNOWN,
                always_ok=args.ALWAYS_OK,
            )
        # AIDE empties the report when it starts (unless report_append=yes), so an
        # empty one belongs to a run that aborted before it wrote the report, for
        # example because the database is missing (exit code 18), or to a clean run
        # with report_quiet=yes, which writes nothing at all. aide 0.17 and newer
        # log the reason to stderr only, aide 0.16 also writes it into the report
        # ("Couldn't open file /var/lib/aide/aide.db.gz for reading"). Verified
        # with aide 0.16 on Rocky 8, 0.17.4 on Ubuntu 22.04, 0.18.3 on Debian 12
        # and 0.19.1 on Debian 13.
        msg += 'The last AIDE run aborted before it wrote its report'
        if text:
            msg += ': ' + printable(' '.join(text.splitlines()[:3])[:200])
        msg += '.'
        # The most common cause on a fresh installation: no database yet. The
        # locations are the ones of the shipped configurations (RHEL, Fedora;
        # Debian's aide-common). A test never looks at the host.
        if args.TEST is None and not any(os.path.lexists(db) for db in DATABASES):
            msg += (
                f' AIDE has no database yet at {DATABASES[0]}. Create it once the host '
                f'is set up completely, with `{AIDE} --init && {MOVE_DATABASE}`.'
            )
            lib.base.oao(msg, STATE_WARN, always_ok=args.ALWAYS_OK)
        if args.TEST is None and not any(os.path.lexists(unit) for unit in UNITS):
            msg += f' Run `{AIDE} --check` to see why.'
        else:
            msg += ' `sudo journalctl --unit aidecheck.service` shows why.'
        if not text:
            msg += (
                ' An empty report also results from `report_quiet=yes` in the AIDE '
                'configuration, or from a logrotate configuration with '
                f'`copytruncate`, see {DOCS_URL}.'
            )
        lib.base.oao(msg, STATE_WARN, always_ok=args.ALWAYS_OK)

    # AIDE writes the start of the run in the host's local time, together with the
    # UTC offset, and this check runs on the same host. A report level that leaves
    # the timestamp out falls back to the time the report was written.
    started = None
    if report['start']:
        # 'YYYY-MM-DD HH:MM:SS' plus ' +hhmm' where AIDE knows the offset
        pattern = '%Y-%m-%d %H:%M:%S'
        if len(report['start']) > 19:
            pattern += ' %z'
        try:
            started = lib.time.timestr2epoch(report['start'], pattern=pattern)
        except ValueError:
            started = None
    if started is None:
        started = mtime
    age_state = STATE_OK
    if started is not None:
        age = max(0, lib.time.now() - started)
        if args.MAX_AGE and age > args.MAX_AGE * 3600:
            age_state = STATE_WARN
    # `aide --update` writes the new database next to the old one, and only moving
    # it over the old one accepts the differences. An automated update typically
    # does that right after the run and then starts a check, which takes minutes on
    # a large host, so the report of the update run is what the check sees
    # meanwhile. Debian's aide-common runs its daily check as an update
    # by default and never moves the new database into place (COMMAND=update,
    # COPYNEWDB=no in /etc/default/aide), so there the differences still stand.
    # Verified with aide 0.16 on Rocky 8 and aide-common 0.19.1 on Debian 13.
    accepted = report['action'] == 'update' and database_moved(report['database_out'])
    compared = report['action'] == 'check' or (
        report['action'] == 'update' and not accepted
    )
    differences = sum(report[key] or 0 for key in GROUPS.values())
    # Below report_level `summary`, AIDE writes the outline only, so the report
    # tells that there are differences, but not how many.
    if compared and report['found'] and not differences:
        differences = None
    differences_state = STATE_OK
    if compared:
        differences_state = lib.base.get_state(
            1 if differences is None else differences,
            args.WARN,
            args.CRIT,
            _operator='range',
        )
    state = lib.base.get_worst(age_state, differences_state)

    # build the message
    ago = ''
    if started is not None:
        ago = (
            f' {lib.human.seconds2human(age)} ago'
            f'{lib.base.state2str(age_state, prefix=" ")}'
        )
    # a regular AIDE check needs no words, "in the check" would read as the check of
    # this monitoring plugin
    run = ''
    if report['action'] == 'update':
        run = ' in an update run'
    elif report['compare']:
        run = ' comparing two databases'
    if report['action'] == 'init':
        msg += f'AIDE initialized its database{ago}, no AIDE check has run since'
    elif accepted:
        msg += f'AIDE updated its database{ago}'
        if differences:
            msg += (
                f' and accepted {differences} '
                f'{lib.txt.pluralize("difference", differences)}'
                f' ({report["added"]} added, {report["removed"]} removed, '
                f'{report["changed"]} changed)'
            )
        msg += ', no AIDE check has run since'
    elif differences is None:
        msg += (
            f'AIDE found differences'
            f'{lib.base.state2str(differences_state, prefix=" ")}{run}{ago}. '
            'The report does not tell how many, set `report_level=summary` or '
            'higher in the AIDE configuration'
        )
    elif differences:
        msg += (
            f'AIDE found {differences} {lib.txt.pluralize("difference", differences)}'
            f' ({report["added"]} added, {report["removed"]} removed, '
            f'{report["changed"]} changed)'
            f'{lib.base.state2str(differences_state, prefix=" ")}{run}{ago}'
        )
    else:
        msg += f'AIDE found no differences{run}{ago}'
    if age_state != STATE_OK:
        msg += (
            f'\n\nThe last AIDE run started at {lib.time.epoch2iso(started)}, which '
            f'is more than {args.MAX_AGE}h ago. Check that the regular AIDE check '
            'still runs, for example with `systemctl list-timers aidecheck.timer`.'
        )
    if compared and differences != 0:
        msg += get_entries_msg(report)
        if report['action'] == 'update':
            msg += (
                f'\n\nThe details are in {REPORT}. The run wrote a new database '
                f'({printable(report["database_out"])}), which accepts the '
                'differences only '
                'once it replaces the old one. Then run a check, for example '
                '`sudo systemctl start aidecheck.service`, to confirm it.'
            )
        else:
            msg += (
                f'\n\nThe details are in {REPORT}. Once the changes are known to be '
                f'legitimate, accept them with `{AIDE} --update && {MOVE_DATABASE}`.'
            )
    if in_progress:
        first, sep, rest = msg.partition('\n')
        msg = f'{first}, a new AIDE run is in progress (PID {in_progress}){sep}{rest}'
    if compared and report['total'] is not None:
        perfdata += lib.base.get_perfdata('added', report['added'], uom=None, _min=0)
        perfdata += lib.base.get_perfdata(
            'changed', report['changed'], uom=None, _min=0
        )
        perfdata += lib.base.get_perfdata(
            'differences',
            differences,
            uom=None,
            warn=args.WARN,
            crit=args.CRIT,
            _min=0,
        )
    if report['total'] is not None:
        perfdata += lib.base.get_perfdata('entries', report['total'], uom=None, _min=0)
    if compared and report['total'] is not None:
        perfdata += lib.base.get_perfdata(
            'removed', report['removed'], uom=None, _min=0
        )

    if report['appended']:
        if state == STATE_OK:
            lib.base.oao(
                f'{REPORT} holds the reports of several AIDE runs. {append_hint}',
                STATE_UNKNOWN,
                always_ok=args.ALWAYS_OK,
            )
        # the first report alerts on its own, which a further one cannot undo; the
        # start of that one may as well be a forged file name
        msg += (
            f'\n\n{REPORT} seems to hold the reports of several AIDE runs.'
            f' {append_hint}'
        )

    # over and out
    lib.base.oao(
        msg, state, perfdata, always_ok=args.ALWAYS_OK, no_perfdata=args.NO_PERFDATA
    )


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
