Add authentication log audit tool

This commit is contained in:
Mateusz Suski
2026-05-11 17:04:48 +00:00
parent 452ff4fac1
commit 2da5e8b46c
5 changed files with 1064 additions and 0 deletions
@@ -0,0 +1,189 @@
# auth-log-audit
`auth-log-audit` is a read-only Python CLI for reviewing local Linux authentication logs. It summarizes suspicious SSH, sudo, su, and PAM authentication patterns that may require operator review during incident response, hardening checks, or access-control evidence gathering.
The tool analyzes collected log files only. It does not modify logs, query remote systems, or prove compromise.
## When To Use
- During incident response when `/var/log/auth.log`, `/var/log/secure`, or an exported authentication log needs a quick first-pass summary.
- During Linux hardening or access review when repeated failures, invalid users, root login attempts, or sudo failures need to be surfaced.
- Before attaching authentication evidence to an incident, security, problem, or compliance review ticket.
- When JSON output is useful for local automation or repeatable reporting.
## What It Does
- Reads one local authentication log supplied with `--file`.
- Detects common SSH, sudo, su, and PAM authentication events.
- Extracts usernames, source IPs, authentication methods, services, timestamps, and sample raw lines where practical.
- Aggregates failed login counts by source IP and username.
- Flags suspicious source IPs and usernames when failed attempts meet the configured threshold.
- Produces text, Markdown, or JSON output.
## What It Does Not Do
- It does not detect breaches or prove compromise.
- It does not read remote systems or live journal streams.
- It does not modify logs, accounts, SSH configuration, sudoers, or host state.
- It does not query SIEM, SOC tooling, ELK, Zabbix, identity providers, or ticketing systems.
- It does not replace host-specific incident response, access review, or forensic procedures.
- It does not classify every vendor-specific authentication message.
## Supported Input Types
- Debian/Ubuntu-style `/var/log/auth.log`.
- RHEL/Oracle Linux-style `/var/log/secure`.
- Exported authentication logs with similar syslog-style lines.
- UTF-8 text input is expected. Invalid byte sequences are replaced during read so review can continue.
Empty, missing, unreadable, or non-file paths are rejected with exit code `2`.
## Supported Event Categories
SSH-related:
- Failed SSH password login.
- Failed SSH publickey login.
- Successful SSH login.
- Invalid user attempts.
- Root login attempts.
- Refused or disallowed user attempts.
- Disconnects after failed authentication where detectable.
- Too many authentication failures where detectable.
sudo and su-related:
- sudo command usage.
- sudo authentication failure.
- su session opened.
- su authentication failure.
Generic authentication:
- authentication failure.
- `pam_unix` authentication failure.
- Account locked messages where detectable.
- User not known to the underlying authentication module.
## Timestamp Handling
The scanner attempts to parse:
- `May 11 10:15:30`
- `2026-05-11 10:15:30`
- `2026-05-11T10:15:30`
Timestamp parsing is best-effort. Lines with unparseable timestamps are still analyzed, and first seen / last seen values are reported as `UNKNOWN` when no parseable event timestamps are found. Syslog timestamps without a year use the current local year internally while preserving the original timestamp shape in text and Markdown output.
## Suspicious Activity Model
Default threshold:
```text
--threshold-failed 5
```
The report classifies findings conservatively:
- `OK` - no suspicious findings.
- `WARNING` - repeated failed logins, invalid users, root login attempts below the threshold, or sudo authentication failures.
- `CRITICAL` - root login attempts above threshold, high-volume brute-force indicators, or multiple suspicious source IPs above threshold.
This status is a triage signal. It identifies suspicious authentication patterns that require review; it does not confirm a breach.
## Usage
```bash
cd infra-run/scripts/python/auth-log-audit
python3 auth_log_audit.py --file examples/sample-auth.log
python3 auth_log_audit.py --file examples/sample-secure.log
python3 auth_log_audit.py --file examples/sample-auth.log --format markdown
python3 auth_log_audit.py --file examples/sample-auth.log --format markdown --output auth-report.md
python3 auth_log_audit.py --file examples/sample-auth.log --format json
python3 auth_log_audit.py --file examples/sample-auth.log --top 10
python3 auth_log_audit.py --file examples/sample-auth.log --threshold-failed 5
python3 auth_log_audit.py --file examples/sample-auth.log --ignore-users monitoring,backup,ansible
```
Ignored users are excluded from suspicious username threshold findings. Their events are still counted in totals and can still appear in top-user summaries so operational context is not silently hidden.
## Output Formats
- `text` - default terminal-oriented report.
- `markdown` - incident or security ticket attachment format.
- `json` - structured output for local automation.
Use `--output <path>` to write the rendered report to a separate file. Without `--output`, the report is printed to stdout. The tool rejects an output path that resolves to the input log file.
## Exit Codes
- `0` - OK, no suspicious findings.
- `1` - Suspicious findings detected.
- `2` - Invalid input, unreadable file, bad argument, output write failure, or runtime error.
## Example Text Output
```text
Auth Log Audit
==============
Overall status: WARNING
First seen: May 11 09:58:12
Last seen: May 11 10:07:48
Top Source IPs by Failed Attempts
---------------------------------
- 203.0.113.50: 7
- 198.51.100.23: 1
Suspicious Source IPs
---------------------
- 203.0.113.50: 7
Operational Summary
-------------------
Overall status: WARNING
Total lines scanned: 15
Authentication events detected: 15
Failed logins: 8
Successful logins: 1
Invalid user attempts: 1
Root login attempts: 2
Sudo usage events: 1
Sudo authentication failures: 1
Suspicious source IPs: 1
Suspicious usernames: 0
Threshold used: 5
Ignored users: None
```
## Markdown Workflow
Generate a Markdown report from a collected authentication log and attach it to the incident or security ticket as supporting evidence:
```bash
python3 auth_log_audit.py \
--file examples/sample-auth.log \
--format markdown \
--output auth-report.md
```
Review the report before attaching it. A `WARNING` or `CRITICAL` result should be reviewed with host access history, SSH configuration, sudo policy, user ownership, and any relevant monitoring evidence.
## Operational Limitations
- Pattern matching is intentionally simple and predictable.
- A single line may produce more than one event when PAM and service messages overlap.
- Syslog timestamps without a year are normalized internally with the current local year.
- Source IP extraction is IPv4-oriented.
- The tool compares counts, not rates, authentication windows, geolocation, or identity context.
- Large log files are read into memory; collect scoped extracts for very large incidents.
- Vendor-specific PAM modules or SSH daemon formats may need future patterns.
## Safety Notes
- The tool only reads the input log and optionally writes a separate report.
- It does not require elevated privileges unless the chosen log path requires them.
- Do not include secrets, customer data, private hostnames, or unsanitized production details in portfolio examples.
- Treat findings as prompts for operator review, not automated remediation instructions.
@@ -0,0 +1,734 @@
#!/usr/bin/env python3
"""Summarize suspicious authentication activity in local Linux auth logs."""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any
EXIT_OK = 0
EXIT_FINDINGS = 1
EXIT_INVALID = 2
UNKNOWN = "UNKNOWN"
ISO_TIMESTAMP_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})\b")
SYSLOG_TIMESTAMP_RE = re.compile(r"^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\b")
SERVICE_RE = re.compile(r"\s([A-Za-z0-9_.-]+)(?:\[\d+\])?:\s")
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
EVENT_PATTERNS = [
{
"event_type": "failed_ssh_password",
"category": "failed_login",
"method": "password",
"regex": re.compile(
r"sshd(?:\[\d+\])?: Failed password for (?:(invalid user) )?(\S+) from ((?:\d{1,3}\.){3}\d{1,3})"
),
},
{
"event_type": "failed_ssh_publickey",
"category": "failed_login",
"method": "publickey",
"regex": re.compile(
r"sshd(?:\[\d+\])?: Failed publickey for (?:(invalid user) )?(\S+) from ((?:\d{1,3}\.){3}\d{1,3})"
),
},
{
"event_type": "successful_ssh_login",
"category": "successful_login",
"method": None,
"regex": re.compile(
r"sshd(?:\[\d+\])?: Accepted (\S+) for (\S+) from ((?:\d{1,3}\.){3}\d{1,3})"
),
},
{
"event_type": "invalid_user_attempt",
"category": "invalid_user",
"method": None,
"regex": re.compile(
r"sshd(?:\[\d+\])?: Invalid user (\S+) from ((?:\d{1,3}\.){3}\d{1,3})"
),
},
{
"event_type": "refused_user_attempt",
"category": "refused_user",
"method": None,
"regex": re.compile(
r"sshd(?:\[\d+\])?: (?:User|Connection closed by invalid user) (\S+).*?from ((?:\d{1,3}\.){3}\d{1,3})"
),
},
{
"event_type": "disconnect_after_failed_auth",
"category": "disconnect_after_failed_auth",
"method": None,
"regex": re.compile(
r"sshd(?:\[\d+\])?: Disconnected from (?:authenticating user \S+ |invalid user \S+ )?((?:\d{1,3}\.){3}\d{1,3}).*(?:preauth|Too many authentication failures)"
),
},
{
"event_type": "too_many_auth_failures",
"category": "failed_login",
"method": None,
"regex": re.compile(
r"sshd(?:\[\d+\])?: .*(?:Too many authentication failures|maximum authentication attempts exceeded).*"
),
},
{
"event_type": "sudo_command",
"category": "sudo_usage",
"method": None,
"regex": re.compile(r"sudo(?:\[\d+\])?:\s+(\S+)\s+:\s+TTY=.*COMMAND=(.+)$"),
},
{
"event_type": "sudo_auth_failure",
"category": "sudo_failure",
"method": None,
"regex": re.compile(r"sudo(?:\[\d+\])?: pam_unix\(sudo:auth\): authentication failure;.*"),
},
{
"event_type": "su_session_opened",
"category": "su_event",
"method": None,
"regex": re.compile(r"su(?:\[\d+\])?: pam_unix\(su(?:-l)?:session\): session opened for user (\S+)"),
},
{
"event_type": "su_auth_failure",
"category": "su_event",
"method": None,
"regex": re.compile(r"su(?:\[\d+\])?: pam_unix\(su(?:-l)?:auth\): authentication failure;.*"),
},
{
"event_type": "pam_unix_auth_failure",
"category": "generic_auth_failure",
"method": None,
"regex": re.compile(r"pam_unix\([^)]*:auth\): authentication failure;.*"),
},
{
"event_type": "user_unknown",
"category": "generic_auth_failure",
"method": None,
"regex": re.compile(r"user (?:unknown|not known to the underlying authentication module)"),
},
{
"event_type": "account_locked",
"category": "generic_auth_failure",
"method": None,
"regex": re.compile(r"(?:account locked|authentication failure;.*account locked)", re.IGNORECASE),
},
]
FAILED_CATEGORIES = {"failed_login", "generic_auth_failure"}
SAMPLE_CATEGORIES = [
"failed_login",
"invalid_user",
"root_login_attempt",
"sudo_failure",
"suspicious_source_ip",
]
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Analyze local Linux authentication logs for suspicious patterns."
)
parser.add_argument("--file", required=True, help="Local auth.log or secure file to analyze.")
parser.add_argument(
"--format",
choices=("text", "markdown", "json"),
default="text",
help="Report format. Default: text.",
)
parser.add_argument("--output", help="Write report to this path instead of stdout.")
parser.add_argument(
"--top",
type=positive_int,
default=10,
help="Number of top IPs, usernames, and event types to display. Default: 10.",
)
parser.add_argument(
"--threshold-failed",
type=positive_int,
default=5,
help="Failed attempt threshold for suspicious IPs and usernames. Default: 5.",
)
parser.add_argument(
"--ignore-users",
default="",
help="Comma-separated usernames excluded from suspicious username thresholds.",
)
parser.add_argument(
"--max-samples",
type=non_negative_int,
default=3,
help="Maximum sample lines per finding category. Default: 3.",
)
return parser
def positive_int(value: str) -> int:
try:
number = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be a positive integer") from exc
if number <= 0:
raise argparse.ArgumentTypeError("must be a positive integer")
return number
def non_negative_int(value: str) -> int:
try:
number = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be zero or a positive integer") from exc
if number < 0:
raise argparse.ArgumentTypeError("must be zero or a positive integer")
return number
def parse_ignore_users(value: str) -> list[str]:
if not value.strip():
return []
users = []
for item in value.split(","):
user = item.strip()
if user:
users.append(user)
return sorted(set(users))
def read_log_file(path: Path) -> list[str]:
if not path.exists():
raise OSError(f"file does not exist: {path}")
if not path.is_file():
raise OSError(f"path is not a regular file: {path}")
try:
text = path.read_text(encoding="utf-8", errors="replace")
except PermissionError as exc:
raise OSError(f"file is not readable: {path}") from exc
except OSError as exc:
raise OSError(f"unable to read file {path}: {exc}") from exc
if text == "":
raise ValueError(f"file is empty: {path}")
return text.splitlines()
def parse_line_timestamp(line: str, syslog_year: int) -> tuple[datetime | None, str]:
iso_match = ISO_TIMESTAMP_RE.search(line)
if iso_match:
raw = f"{iso_match.group(1)} {iso_match.group(2)}"
try:
return datetime.strptime(raw, "%Y-%m-%d %H:%M:%S"), raw
except ValueError:
return None, UNKNOWN
syslog_match = SYSLOG_TIMESTAMP_RE.search(line)
if syslog_match:
raw = syslog_match.group(1)
normalized = f"{syslog_year} {raw}"
try:
parsed = datetime.strptime(normalized, "%Y %b %d %H:%M:%S")
except ValueError:
return None, UNKNOWN
return parsed, raw
return None, UNKNOWN
def render_seen(value: tuple[datetime, str] | None) -> str:
if value is None:
return UNKNOWN
return value[1] or value[0].strftime("%Y-%m-%d %H:%M:%S")
def extract_service(line: str) -> str:
match = SERVICE_RE.search(line)
if match:
return match.group(1)
return UNKNOWN
def extract_ip(line: str) -> str:
match = IP_RE.search(line)
if match:
return match.group(0)
return UNKNOWN
def extract_user_from_key_values(line: str) -> str:
for pattern in (
r"\buser=([A-Za-z0-9_.@-]+)",
r"\bruser=([A-Za-z0-9_.@-]+)",
r"\bUSER=([A-Za-z0-9_.@-]+)",
):
match = re.search(pattern, line)
if match and match.group(1):
return match.group(1)
return UNKNOWN
def event_from_match(line: str, pattern: dict[str, Any], match: re.Match[str]) -> dict[str, Any]:
event_type = pattern["event_type"]
username = UNKNOWN
source_ip = extract_ip(line)
method = pattern["method"] or UNKNOWN
if event_type in ("failed_ssh_password", "failed_ssh_publickey"):
username = match.group(2)
source_ip = match.group(3)
elif event_type == "successful_ssh_login":
method = match.group(1)
username = match.group(2)
source_ip = match.group(3)
elif event_type in ("invalid_user_attempt", "refused_user_attempt"):
username = match.group(1)
source_ip = match.group(2)
elif event_type == "sudo_command":
username = match.group(1)
elif event_type == "su_session_opened":
username = match.group(1).rstrip(")")
elif event_type in ("sudo_auth_failure", "su_auth_failure", "pam_unix_auth_failure"):
username = extract_user_from_key_values(line)
if username == "root" and event_type in (
"failed_ssh_password",
"failed_ssh_publickey",
"successful_ssh_login",
"invalid_user_attempt",
"refused_user_attempt",
):
event_type = "root_login_attempt"
return {
"event_type": event_type,
"category": pattern["category"],
"username": username or UNKNOWN,
"source_ip": source_ip or UNKNOWN,
"method": method,
"service": extract_service(line),
"raw": line,
}
def detect_events(line: str) -> list[dict[str, Any]]:
events = []
for pattern in EVENT_PATTERNS:
match = pattern["regex"].search(line)
if match:
events.append(event_from_match(line, pattern, match))
if any(event["event_type"] in ("sudo_auth_failure", "su_auth_failure") for event in events):
events = [
event for event in events if event["event_type"] != "pam_unix_auth_failure"
]
if "authentication failure" in line and not events:
events.append(
{
"event_type": "authentication_failure",
"category": "generic_auth_failure",
"username": extract_user_from_key_values(line),
"source_ip": extract_ip(line),
"method": UNKNOWN,
"service": extract_service(line),
"raw": line,
}
)
return dedupe_events(events)
def dedupe_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
deduped = []
seen = set()
for event in events:
key = (event["event_type"], event["username"], event["source_ip"], event["raw"])
if key in seen:
continue
seen.add(key)
deduped.append(event)
return deduped
def append_sample(samples: dict[str, list[str]], category: str, line: str, max_samples: int) -> None:
if max_samples == 0:
return
if len(samples[category]) < max_samples:
samples[category].append(line)
def update_seen(
first_seen: tuple[datetime, str] | None,
last_seen: tuple[datetime, str] | None,
parsed_at: datetime | None,
rendered_at: str,
) -> tuple[tuple[datetime, str] | None, tuple[datetime, str] | None]:
if parsed_at is None:
return first_seen, last_seen
if first_seen is None or parsed_at < first_seen[0]:
first_seen = (parsed_at, rendered_at)
if last_seen is None or parsed_at > last_seen[0]:
last_seen = (parsed_at, rendered_at)
return first_seen, last_seen
def analyze_log(
lines: list[str],
threshold_failed: int,
ignore_users: list[str],
top: int,
max_samples: int,
) -> dict[str, Any]:
syslog_year = datetime.now().year
events = []
samples: dict[str, list[str]] = defaultdict(list)
event_type_counts: Counter[str] = Counter()
failed_by_ip: Counter[str] = Counter()
failed_by_user: Counter[str] = Counter()
success_by_ip: Counter[str] = Counter()
success_by_user: Counter[str] = Counter()
first_seen: tuple[datetime, str] | None = None
last_seen: tuple[datetime, str] | None = None
for line in lines:
parsed_at, rendered_at = parse_line_timestamp(line, syslog_year)
line_events = detect_events(line)
if not line_events:
continue
first_seen, last_seen = update_seen(first_seen, last_seen, parsed_at, rendered_at)
for event in line_events:
event["timestamp"] = rendered_at
events.append(event)
event_type_counts[event["event_type"]] += 1
category = event["category"]
username = event["username"]
source_ip = event["source_ip"]
if event["event_type"] == "root_login_attempt":
append_sample(samples, "root_login_attempt", line, max_samples)
category = "failed_login"
if category in FAILED_CATEGORIES:
if source_ip != UNKNOWN:
failed_by_ip[source_ip] += 1
if username != UNKNOWN:
failed_by_user[username] += 1
append_sample(samples, "failed_login", line, max_samples)
if category == "successful_login":
if source_ip != UNKNOWN:
success_by_ip[source_ip] += 1
if username != UNKNOWN:
success_by_user[username] += 1
if category == "invalid_user":
append_sample(samples, "invalid_user", line, max_samples)
if category == "sudo_failure":
append_sample(samples, "sudo_failure", line, max_samples)
suspicious_ips = {
ip: count for ip, count in failed_by_ip.items() if count >= threshold_failed
}
suspicious_users = {
user: count
for user, count in failed_by_user.items()
if count >= threshold_failed and user not in ignore_users
}
for event in events:
if event["source_ip"] in suspicious_ips:
append_sample(samples, "suspicious_source_ip", event["raw"], max_samples)
summary = build_summary(
lines=lines,
events=events,
failed_by_ip=failed_by_ip,
failed_by_user=failed_by_user,
suspicious_ips=suspicious_ips,
suspicious_users=suspicious_users,
event_type_counts=event_type_counts,
threshold_failed=threshold_failed,
ignore_users=ignore_users,
first_seen=first_seen,
last_seen=last_seen,
)
return {
"summary": summary,
"top_source_ips_by_failed_attempts": top_items(failed_by_ip, top),
"top_usernames_by_failed_attempts": top_items(failed_by_user, top),
"top_source_ips_by_successful_logins": top_items(success_by_ip, top),
"top_usernames_by_successful_logins": top_items(success_by_user, top),
"top_event_types": top_items(event_type_counts, top),
"suspicious_source_ips": sorted_count_items(suspicious_ips),
"suspicious_usernames": sorted_count_items(suspicious_users),
"samples": {category: samples.get(category, []) for category in SAMPLE_CATEGORIES},
}
def build_summary(
lines: list[str],
events: list[dict[str, Any]],
failed_by_ip: Counter[str],
failed_by_user: Counter[str],
suspicious_ips: dict[str, int],
suspicious_users: dict[str, int],
event_type_counts: Counter[str],
threshold_failed: int,
ignore_users: list[str],
first_seen: tuple[datetime, str] | None,
last_seen: tuple[datetime, str] | None,
) -> dict[str, Any]:
root_attempts = event_type_counts["root_login_attempt"]
sudo_failures = event_type_counts["sudo_auth_failure"]
invalid_users = event_type_counts["invalid_user_attempt"]
high_volume_ips = sum(1 for count in suspicious_ips.values() if count >= threshold_failed * 2)
high_volume_users = sum(1 for count in suspicious_users.values() if count >= threshold_failed * 2)
if (
root_attempts >= threshold_failed
or high_volume_ips > 0
or high_volume_users > 0
or len(suspicious_ips) >= 2
):
status = "CRITICAL"
elif suspicious_ips or suspicious_users or invalid_users > 0 or sudo_failures > 0 or root_attempts > 0:
status = "WARNING"
else:
status = "OK"
return {
"overall_status": status,
"first_seen": render_seen(first_seen),
"last_seen": render_seen(last_seen),
"total_lines_scanned": len(lines),
"authentication_events_detected": len(events),
"failed_login_count": sum(failed_by_ip.values()),
"successful_login_count": event_type_counts["successful_ssh_login"],
"invalid_user_count": invalid_users,
"root_login_attempt_count": root_attempts,
"sudo_command_count": event_type_counts["sudo_command"],
"sudo_failure_count": sudo_failures,
"su_event_count": event_type_counts["su_session_opened"] + event_type_counts["su_auth_failure"],
"suspicious_source_ip_count": len(suspicious_ips),
"suspicious_username_count": len(suspicious_users),
"threshold_failed": threshold_failed,
"ignored_users": ignore_users,
}
def top_items(counter: Counter[str], limit: int) -> list[dict[str, Any]]:
return [{"value": value, "count": count} for value, count in counter.most_common(limit)]
def sorted_count_items(items: dict[str, int]) -> list[dict[str, Any]]:
return [
{"value": value, "count": count}
for value, count in sorted(items.items(), key=lambda item: (-item[1], item[0]))
]
def render_text(report: dict[str, Any]) -> str:
summary = report["summary"]
lines = [
"Auth Log Audit",
"==============",
"",
f"Overall status: {summary['overall_status']}",
f"First seen: {summary['first_seen']}",
f"Last seen: {summary['last_seen']}",
"",
]
lines.extend(render_text_table("Top Source IPs by Failed Attempts", report["top_source_ips_by_failed_attempts"]))
lines.extend(render_text_table("Top Usernames by Failed Attempts", report["top_usernames_by_failed_attempts"]))
lines.extend(render_text_table("Top Source IPs by Successful Logins", report["top_source_ips_by_successful_logins"]))
lines.extend(render_text_table("Top Usernames by Successful Logins", report["top_usernames_by_successful_logins"]))
lines.extend(render_text_table("Suspicious Source IPs", report["suspicious_source_ips"]))
lines.extend(render_text_table("Suspicious Usernames", report["suspicious_usernames"]))
lines.extend(render_text_table("Top Event Types", report["top_event_types"]))
lines.extend(render_text_samples(report["samples"]))
lines.extend(render_text_summary(summary))
return "\n".join(lines) + "\n"
def render_text_table(title: str, rows: list[dict[str, Any]]) -> list[str]:
lines = [title, "-" * len(title)]
if not rows:
lines.append("No entries detected.")
else:
for item in rows:
lines.append(f"- {item['value']}: {item['count']}")
lines.append("")
return lines
def render_text_samples(samples: dict[str, list[str]]) -> list[str]:
lines = ["Sample Log Lines", "----------------"]
for category in SAMPLE_CATEGORIES:
lines.append(f"{category}:")
if samples.get(category):
lines.extend(f" - {sample}" for sample in samples[category])
else:
lines.append(" - No samples retained")
lines.append("")
return lines
def render_text_summary(summary: dict[str, Any]) -> list[str]:
ignored = ", ".join(summary["ignored_users"]) if summary["ignored_users"] else "None"
return [
"Operational Summary",
"-------------------",
f"Overall status: {summary['overall_status']}",
f"Total lines scanned: {summary['total_lines_scanned']}",
f"Authentication events detected: {summary['authentication_events_detected']}",
f"Failed logins: {summary['failed_login_count']}",
f"Successful logins: {summary['successful_login_count']}",
f"Invalid user attempts: {summary['invalid_user_count']}",
f"Root login attempts: {summary['root_login_attempt_count']}",
f"Sudo usage events: {summary['sudo_command_count']}",
f"Sudo authentication failures: {summary['sudo_failure_count']}",
f"su events: {summary['su_event_count']}",
f"Suspicious source IPs: {summary['suspicious_source_ip_count']}",
f"Suspicious usernames: {summary['suspicious_username_count']}",
f"Threshold used: {summary['threshold_failed']}",
f"Ignored users: {ignored}",
]
def render_markdown(report: dict[str, Any]) -> str:
summary = report["summary"]
lines = [
"# Auth Log Audit",
"",
f"- Overall status: {summary['overall_status']}",
f"- First seen: {summary['first_seen']}",
f"- Last seen: {summary['last_seen']}",
"",
]
lines.extend(render_markdown_table("Top Source IPs by Failed Attempts", report["top_source_ips_by_failed_attempts"]))
lines.extend(render_markdown_table("Top Usernames by Failed Attempts", report["top_usernames_by_failed_attempts"]))
lines.extend(render_markdown_table("Top Source IPs by Successful Logins", report["top_source_ips_by_successful_logins"]))
lines.extend(render_markdown_table("Top Usernames by Successful Logins", report["top_usernames_by_successful_logins"]))
lines.extend(render_markdown_table("Suspicious Source IPs", report["suspicious_source_ips"]))
lines.extend(render_markdown_table("Suspicious Usernames", report["suspicious_usernames"]))
lines.extend(render_markdown_table("Top Event Types", report["top_event_types"]))
lines.extend(render_markdown_samples(report["samples"]))
ignored = ", ".join(summary["ignored_users"]) if summary["ignored_users"] else "None"
lines.extend(
[
"## Operational Summary",
"",
f"- Overall status: {summary['overall_status']}",
f"- Total lines scanned: {summary['total_lines_scanned']}",
f"- Authentication events detected: {summary['authentication_events_detected']}",
f"- Failed logins: {summary['failed_login_count']}",
f"- Successful logins: {summary['successful_login_count']}",
f"- Invalid user attempts: {summary['invalid_user_count']}",
f"- Root login attempts: {summary['root_login_attempt_count']}",
f"- Sudo usage events: {summary['sudo_command_count']}",
f"- Sudo authentication failures: {summary['sudo_failure_count']}",
f"- su events: {summary['su_event_count']}",
f"- Suspicious source IPs: {summary['suspicious_source_ip_count']}",
f"- Suspicious usernames: {summary['suspicious_username_count']}",
f"- Threshold used: {summary['threshold_failed']}",
f"- Ignored users: {ignored}",
"",
]
)
return "\n".join(lines)
def render_markdown_table(title: str, rows: list[dict[str, Any]]) -> list[str]:
lines = [f"## {title}", ""]
if not rows:
lines.extend(["No entries detected.", ""])
return lines
lines.extend(["| Value | Count |", "| --- | ---: |"])
lines.extend(f"| {item['value']} | {item['count']} |" for item in rows)
lines.append("")
return lines
def render_markdown_samples(samples: dict[str, list[str]]) -> list[str]:
lines = ["## Sample Log Lines", ""]
for category in SAMPLE_CATEGORIES:
lines.extend([f"### {category}", ""])
if samples.get(category):
lines.append("```text")
lines.extend(samples[category])
lines.append("```")
else:
lines.append("_No samples retained._")
lines.append("")
return lines
def render_json(report: dict[str, Any]) -> str:
return json.dumps(report, indent=2, sort_keys=True) + "\n"
def write_report(input_path: Path, output_path: str | None, content: str) -> None:
if output_path is None:
sys.stdout.write(content)
return
path = Path(output_path)
try:
if path.resolve() == input_path.resolve():
raise OSError("output path must not be the same as input file")
path.write_text(content, encoding="utf-8")
except OSError as exc:
raise OSError(f"unable to write output {path}: {exc}") from exc
def main() -> int:
parser = build_parser()
args = parser.parse_args()
input_path = Path(args.file)
ignore_users = parse_ignore_users(args.ignore_users)
try:
lines = read_log_file(input_path)
report = analyze_log(
lines=lines,
threshold_failed=args.threshold_failed,
ignore_users=ignore_users,
top=args.top,
max_samples=args.max_samples,
)
if args.format == "text":
content = render_text(report)
elif args.format == "markdown":
content = render_markdown(report)
else:
content = render_json(report)
write_report(input_path, args.output, content)
except (OSError, ValueError) as exc:
print(f"CRITICAL: {exc}", file=sys.stderr)
return EXIT_INVALID
except RuntimeError as exc:
print(f"CRITICAL: runtime error: {exc}", file=sys.stderr)
return EXIT_INVALID
if report["summary"]["overall_status"] == "OK":
return EXIT_OK
return EXIT_FINDINGS
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,112 @@
# Auth Log Audit
- Overall status: WARNING
- First seen: May 11 09:58:12
- Last seen: May 11 10:07:48
## Top Source IPs by Failed Attempts
| Value | Count |
| --- | ---: |
| 203.0.113.50 | 7 |
| 198.51.100.23 | 1 |
## Top Usernames by Failed Attempts
| Value | Count |
| --- | ---: |
| appuser | 3 |
| root | 2 |
| admin | 1 |
| backup | 1 |
## Top Source IPs by Successful Logins
| Value | Count |
| --- | ---: |
| 10.20.30.15 | 1 |
## Top Usernames by Successful Logins
| Value | Count |
| --- | ---: |
| deploy | 1 |
## Suspicious Source IPs
| Value | Count |
| --- | ---: |
| 203.0.113.50 | 7 |
## Suspicious Usernames
No entries detected.
## Top Event Types
| Value | Count |
| --- | ---: |
| failed_ssh_password | 4 |
| root_login_attempt | 2 |
| successful_ssh_login | 1 |
| sudo_command | 1 |
| invalid_user_attempt | 1 |
| disconnect_after_failed_auth | 1 |
| failed_ssh_publickey | 1 |
| sudo_auth_failure | 1 |
| su_session_opened | 1 |
| refused_user_attempt | 1 |
## Sample Log Lines
### failed_login
```text
May 11 10:01:44 web01 sshd[1220]: Failed password for invalid user admin from 203.0.113.50 port 45001 ssh2
May 11 10:02:03 web01 sshd[1224]: Failed password for root from 203.0.113.50 port 45012 ssh2
May 11 10:02:06 web01 sshd[1224]: Failed password for root from 203.0.113.50 port 45012 ssh2
```
### invalid_user
```text
May 11 10:01:46 web01 sshd[1220]: Invalid user admin from 203.0.113.50 port 45001
```
### root_login_attempt
```text
May 11 10:02:03 web01 sshd[1224]: Failed password for root from 203.0.113.50 port 45012 ssh2
May 11 10:02:06 web01 sshd[1224]: Failed password for root from 203.0.113.50 port 45012 ssh2
```
### sudo_failure
```text
May 11 10:04:20 web01 sudo: pam_unix(sudo:auth): authentication failure; logname=deploy uid=1001 euid=0 tty=/dev/pts/0 ruser=deploy rhost= user=deploy
```
### suspicious_source_ip
```text
May 11 10:01:44 web01 sshd[1220]: Failed password for invalid user admin from 203.0.113.50 port 45001 ssh2
May 11 10:01:46 web01 sshd[1220]: Invalid user admin from 203.0.113.50 port 45001
May 11 10:02:03 web01 sshd[1224]: Failed password for root from 203.0.113.50 port 45012 ssh2
```
## Operational Summary
- Overall status: WARNING
- Total lines scanned: 15
- Authentication events detected: 15
- Failed logins: 8
- Successful logins: 1
- Invalid user attempts: 1
- Root login attempts: 2
- Sudo usage events: 1
- Sudo authentication failures: 1
- su events: 1
- Suspicious source IPs: 1
- Suspicious usernames: 0
- Threshold used: 5
- Ignored users: None
@@ -0,0 +1,15 @@
May 11 09:58:12 web01 sshd[1201]: Accepted publickey for deploy from 10.20.30.15 port 52214 ssh2: ED25519 SHA256:samplekey
May 11 10:00:01 web01 sudo: deploy : TTY=pts/0 ; PWD=/srv/app ; USER=root ; COMMAND=/usr/bin/systemctl status nginx
May 11 10:01:44 web01 sshd[1220]: Failed password for invalid user admin from 203.0.113.50 port 45001 ssh2
May 11 10:01:46 web01 sshd[1220]: Invalid user admin from 203.0.113.50 port 45001
May 11 10:02:03 web01 sshd[1224]: Failed password for root from 203.0.113.50 port 45012 ssh2
May 11 10:02:06 web01 sshd[1224]: Failed password for root from 203.0.113.50 port 45012 ssh2
May 11 10:02:11 web01 sshd[1224]: Disconnected from authenticating user root 203.0.113.50 port 45012 [preauth]
May 11 10:03:10 web01 sshd[1231]: Failed password for appuser from 203.0.113.50 port 45101 ssh2
May 11 10:03:14 web01 sshd[1231]: Failed password for appuser from 203.0.113.50 port 45101 ssh2
May 11 10:03:18 web01 sshd[1231]: Failed password for appuser from 203.0.113.50 port 45101 ssh2
May 11 10:03:41 web01 sshd[1238]: Failed publickey for backup from 198.51.100.23 port 50222 ssh2
May 11 10:04:20 web01 sudo: pam_unix(sudo:auth): authentication failure; logname=deploy uid=1001 euid=0 tty=/dev/pts/0 ruser=deploy rhost= user=deploy
May 11 10:05:02 web01 su[1244]: pam_unix(su:session): session opened for user root by deploy(uid=1001)
May 11 10:06:31 web01 sshd[1250]: User testuser from 192.0.2.77 not allowed because not listed in AllowUsers
May 11 10:07:48 web01 sshd[1254]: error: maximum authentication attempts exceeded for invalid user oracle from 203.0.113.50 port 45200 ssh2 [preauth]
@@ -0,0 +1,14 @@
May 11 09:52:44 db01 sshd[2110]: Accepted publickey for admin from 10.40.10.25 port 60124 ssh2: RSA SHA256:samplekey
May 11 09:55:10 db01 sudo[2120]: admin : TTY=pts/1 ; PWD=/home/admin ; USER=root ; COMMAND=/usr/bin/systemctl restart auditd
May 11 09:55:10 db01 sudo[2120]: pam_unix(sudo:session): session opened for user root(uid=0) by admin(uid=1000)
May 11 10:00:01 db01 sshd[2130]: Failed password for invalid user postgres from 198.51.100.90 port 42101 ssh2
May 11 10:00:03 db01 sshd[2130]: Invalid user postgres from 198.51.100.90 port 42101
May 11 10:00:09 db01 sshd[2132]: Failed password for root from 198.51.100.90 port 42105 ssh2
May 11 10:00:13 db01 sshd[2132]: Failed password for root from 198.51.100.90 port 42105 ssh2
May 11 10:00:20 db01 sshd[2135]: Failed password for oracle from 198.51.100.90 port 42111 ssh2
May 11 10:00:25 db01 sshd[2135]: Failed password for oracle from 198.51.100.90 port 42111 ssh2
May 11 10:00:31 db01 sshd[2135]: Failed password for oracle from 198.51.100.90 port 42111 ssh2
May 11 10:01:12 db01 su[2142]: pam_unix(su:auth): authentication failure; logname=admin uid=1000 euid=0 tty=pts/1 ruser=admin rhost= user=root
May 11 10:01:45 db01 sshd[2149]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.77 user=monitoring
May 11 10:02:03 db01 sshd[2154]: error: PAM: User not known to the underlying authentication module for illegal user deploy from 203.0.113.77
May 11 10:02:36 db01 sshd[2159]: Disconnecting authenticating user oracle 198.51.100.90 port 42111: Too many authentication failures [preauth]