minimost.audit

minimost.audit

Security audit logging for MiniMost.

The Application Security and Development (ASD) STIG requires an application to generate audit records for security-relevant events, and that each record identify what happened, when (timestamp), where it came from (source address), who triggered it (user identity), and the outcome (success/failure). This module is the single source of truth for producing those records.

Design

  • Standard library only. Records are emitted through logging, so there is no new dependency and nothing newer than Python 3.6 is required.

  • Durable local trail. Records are appended to audit.log in the MiniMost data root (see minimost.paths.data_dir()). On a packaged install that is the systemd StateDirectory (e.g. /var/lib/minimost), so the file persists across restarts and can be shipped to a central aggregator/SIEM by the host’s log forwarder (journald, rsyslog, …).

  • One line per event, machine-parseable. Each record is a single key=value line prefixed with an ISO-8601 UTC timestamp, e.g.:

    2026-06-29T18:04:11Z event=login outcome=failure user=alice src=10.0.0.5
    
  • Append-only, multi-worker safe. The file handler is created lazily on first use after Gunicorn forks its workers, so every worker opens its own O_APPEND descriptor; on POSIX, single-line appends are atomic, so the workers never corrupt each other’s records.

  • Bounded by rotation. The handler rotates the log by size and/or age (see audit_log_max_size_mb / audit_log_max_age_days / audit_log_backups in settings.json), keeping a configurable number of timestamped archives. Rotation is coordinated across workers so they never clobber each other (see _RotatingAuditHandler). Pruning deletes the oldest archives, so a deployment with audit-retention requirements should off-load records to a central aggregator/SIEM before they age out.

  • Log-injection resistant. All interpolated values are stripped of control characters (notably CR/LF) before they reach the log, so an attacker cannot forge or split records by smuggling newlines through a username or path.

Module-level attributes

AUDIT_LOGstr

Absolute path to audit.log. Resolved once at import time from minimost.paths.data_dir(). Exposed as a module attribute (mirroring auth.AUTH_DB / presence.PRESENCE_DB) so the test suite can redirect it to a temp directory; changing it re-points the logger on the next event.

class minimost.audit._UTCFormatter(fmt=None, datefmt=None, style='%', validate=True, *, defaults=None)[source]

Bases: Formatter

Format timestamps as ISO-8601 UTC with a trailing Z.

The default logging.Formatter emits local time with millisecond precision; the STIG audit-content requirement is cleaner to satisfy with an unambiguous UTC instant, so the converter is pinned to time.gmtime().

Initialize the formatter with specified format strings.

Initialize the formatter either with the specified format string, or a default as described above. Allow for specialized date formatting with the optional datefmt argument. If datefmt is omitted, you get an ISO8601-like (or RFC 3339-like) format.

Use a style parameter of ‘%’, ‘{’ or ‘$’ to specify that you want to use one of %-formatting, str.format() ({}) formatting or string.Template formatting in your format string.

Changed in version 3.2: Added the style parameter.

converter()
gmtime([seconds]) -> (tm_year, tm_mon, tm_mday, tm_hour, tm_min,

tm_sec, tm_wday, tm_yday, tm_isdst)

Convert seconds since the Epoch to a time tuple expressing UTC (a.k.a. GMT). When ‘seconds’ is not passed in, convert the current time instead.

If the platform supports the tm_gmtoff and tm_zone, they are available as attributes only.

formatTime(record, datefmt=None)[source]

Return the creation time of the specified LogRecord as formatted text.

This method should be called from format() by a formatter which wants to make use of a formatted time. This method can be overridden in formatters to provide for any specific requirement, but the basic behaviour is as follows: if datefmt (a string) is specified, it is used with time.strftime() to format the creation time of the record. Otherwise, an ISO8601-like (or RFC 3339-like) format is used. The resulting string is returned. This function uses a user-configurable function to convert the creation time to a tuple. By default, time.localtime() is used; to change this for a particular formatter instance, set the ‘converter’ attribute to a function with the same signature as time.localtime() or time.gmtime(). To change it for all formatters, for example if you want all logging times to be shown in GMT, set the ‘converter’ attribute in the Formatter class.

minimost.audit._setting_num(value, default)[source]

Return value if it is a non-negative, non-bool number, else default.

minimost.audit._rotation_config()[source]

Return (max_bytes, max_age_seconds, backup_count) from settings.json.

Keys (all optional):

  • audit_log_max_size_mb — rotate once the live log reaches this size.

  • audit_log_max_age_days — rotate this long after the previous rotation.

  • audit_log_backups — number of rotated archives to keep; older ones are deleted. 0 keeps every archive.

A 0 (or absent) size/age disables that trigger; with both disabled the file is never rotated. A read/parse error falls back to the shipped defaults.

class minimost.audit._RotatingAuditHandler(filename, max_bytes, max_age_seconds, backup_count, encoding=None, delay=False)[source]

Bases: WatchedFileHandler

Append-only audit handler that rotates by size and/or age.

Rotation is safe across several Gunicorn workers, each of which holds its own handler. WatchedFileHandler reopens the log whenever its inode changes, so when one worker rotates the file (by renaming it) the others transparently switch to the freshly created one — no records are lost, and, unlike RotatingFileHandler, the workers never clobber each other’s rotation. The rename is guarded by an atomically-created <log>.lock file (O_CREAT | O_EXCL, which works on both POSIX and Windows) and re-checked under the lock, so exactly one worker rotates and a file already rotated by a peer is left alone. A lock left behind by a crashed rotation is treated as stale after a minute so it can never deadlock.

Age is measured from a companion <log>.rotated_at marker file (touched at each rotation), independent of write activity, so a quiet day does not reset the clock.

Open the specified file and use it as the stream for logging.

__init__(filename, max_bytes, max_age_seconds, backup_count, encoding=None, delay=False)[source]

Open the specified file and use it as the stream for logging.

_close_stream()[source]

Release this handler’s open file descriptor on the log.

Windows refuses to rename a file that is open in the process, so the log must be closed before it can be rotated (POSIX allows renaming an open file, so this is harmless there). The stream is left as None so the next emit() reopens a fresh file via the base handler.

_LOCK_STALE_SECONDS = 60
_clear_stale_lock(lockpath)[source]

Remove the lock file if it was abandoned by a crashed rotation.

emit(record)[source]

Emit a record.

If underlying file has changed, reopen the file before emitting the record to it.

minimost.audit._make_handler(path)[source]

Return a rotating audit handler for path, or None if unopenable.

Audit logging must never take the request down: if the log file cannot be created (read-only data dir, permissions, …) the failure is swallowed and the caller proceeds without a durable record rather than raising. The handler rotates by size and/or age per _rotation_config() so the trail does not grow without bound.

minimost.audit._get_logger()[source]

Return the configured audit logger, (re)attaching the file handler.

Idempotent: the handler is built only when missing or when AUDIT_LOG has changed since it was last built. propagate is disabled so audit records do not bleed into Gunicorn’s root/error logger.

minimost.audit._sanitize(value, limit=256)[source]

Neutralise a value for safe single-line logging.

Control characters are replaced with spaces (defeating log injection) and the result is length-capped so an oversized field cannot bloat the trail.

minimost.audit._client_ip()[source]

Return the request’s source IP, or None outside a request context.

When the app sits behind a reverse proxy the real client is the leftmost X-Forwarded-For entry; otherwise it is the peer address. XFF is attacker-controllable when the app is exposed directly, so deployments that must trust it should terminate at a proxy that overwrites the header.

minimost.audit.log_event(event, outcome, user=None, source=None, detail=None)[source]

Append one security audit record.

Parameters:
  • event – Short event type, e.g. "login" or "account_create".

  • outcome"success" or "failure".

  • user – The user identity the event concerns (None-).

  • source – Source address; resolved from the request when omitted.

  • detail – Optional free-text context (sanitised and quoted).

minimost.audit.login_success(user)[source]

Record a successful authentication.

minimost.audit.login_failure(user, detail=None)[source]

Record a failed authentication attempt.

minimost.audit.logout(user)[source]

Record a user-initiated logout.

minimost.audit.account_lockout(user)[source]

Record an account being locked after repeated failed logins.

minimost.audit.account_created(user)[source]

Record creation of a new account.

minimost.audit.account_deleted(user, delete_type)[source]

Record removal (soft/hard) of an account.

minimost.audit.password_changed(user, outcome='success')[source]

Record a password change by the logged-in user.

minimost.audit.password_reset(user, outcome='success')[source]

Record a password reset via a reset token.

minimost.audit.password_expired(user)[source]

Record a login refused because the password exceeded its maximum age.

minimost.audit.access_denied(user, resource)[source]

Record an access-control denial (forbidden resource / failed CSRF).