Repository navigation
refactor(security): harden auth.log parsing against untrusted input - #3
Conversation
krishsharma-code
left a comment
There was a problem hiding this comment.
Review: python_scripts/auth_log_parser.py
Scope reviewed: the file as it stands on main (53 lines), the full rewrite on this branch, the new tests/test_auth_log_parser.py (25 cases), and the repo conventions in CONTRIBUTING.md and the PR template.
What was reviewed
Security boundaries (input validation, injection, privilege handling), memory efficiency, edge-case and failure handling, and docstring/comment clarity.
What was found
- Untrusted token reached the terminal (injection). The old code took the token after
fromverbatim and printed it. An unauthenticated attacker chooses the username they attempt, so they influence that line; a value carrying ANSI control sequences would be echoed to the operator's terminal. No validation stood between the log line andprint(). - Address extraction was positional, not structural.
parts.index('from')returns the firstfrom, so a username containingfromyielded the wrong token. More seriously, PAM lines (authentication failure; ... rhost=1.2.3.4) were matched by the detector but carry nofromtoken at all, so everysudo/loginfailure was silently discarded — a false negative in a security report. - Unbounded memory keyed on attacker-controlled data.
failed_attemptsgrew one entry per distinct address with no cap; a log padded with spoofed sources was a memory-exhaustion path. Individual line length was unbounded too. - Failure handling.
os.path.exists()followed byopen()is a TOCTOU race and still raised on permission errors or a directory; the file was opened withoutencoding/errors, so one undecodable byte aborted the run withUnicodeDecodeError. - Destructive default.
__main__unconditionally wrotemock_auth.logwith mode'w'in the working directory, clobbering any real log of that name. - Standards. No module docstring, no function docstrings, no
argparse, no exit-code contract — all four are explicitCONTRIBUTING.mdrequirements.
What changed and why
- Parsing now uses two anchored regexes (OpenSSH and PAM) behind a single validation gate,
normalise_address(), which returns a canonical string only for a valid IP literal. Nothing unvalidated can reach the report — this closes finding 1 and the wrong-token half of finding 2. IPv4-mapped IPv6 peers are collapsed so one host is not counted twice. - The PAM format is now counted, closing the false-negative half of finding 2. Both defects are pinned by new tests.
MAX_TRACKED_ADDRESSESandMAX_LINE_CHARSbound memory (finding 3); attempts beyond the cap are tallied as overflow and surfaced in the report rather than dropped silently.exists()-then-open()is replaced by onetry/except OSError, and files open witherrors="replace"(finding 4). Missing file, directory-as-log and undecodable bytes each have a test.- The demo log is written only when absent (finding 5), with a test asserting an existing file is untouched.
argparseCLI with--log,--threshold,--top,--demo; exit 0 clean / 1 threshold reached / 2 unreadable; module and function docstrings throughout (finding 6).
Verification
py_compile clean; flake8 --select=E9,F63,F7,F82,F821,F823 --max-line-length=120 clean (full flake8 clean on both files too); pytest tests/ -q → 25 passed; --demo and --help exercised end to end; failure paths exercised.
Verdict
Submitted as COMMENT rather than APPROVE — GitHub does not permit approving one's own pull request. On the merits the change is ready to merge: both defects are fixed, the hardening is bounded and tested, and nothing was removed for well-formed logs. The only outstanding item is the stale README.md index description, noted on the PR and left as a follow-up.
| # --- Parsing helpers -------------------------------------------------------- | ||
|
|
||
|
|
||
| def normalise_address(token): |
There was a problem hiding this comment.
This is the security boundary the old version lacked. Everything downstream — the counts dict, the report, the terminal — only ever sees a value that came back from here, and this returns None for anything that is not a bare IPv4/IPv6 literal. That is what makes the terminal-escape injection impossible rather than merely unlikely.
One deliberate behaviour choice worth flagging: 192.168.1.50:22 is rejected. sshd logs the port as a separate port 22 token, so a colon-suffixed address means the line is not in the expected format — failing closed is the right call for a security tool.
| Raises: | ||
| OSError: If the file cannot be opened or read. | ||
| """ | ||
| with open(log_path, "r", encoding="utf-8", errors="replace") as handle: |
There was a problem hiding this comment.
errors="replace" matters more than it looks. Without it, a single non-UTF-8 byte in a rotated or partly-written log raises UnicodeDecodeError mid-iteration and the operator loses the whole report — exactly the moment they need it. Covered by test_undecodable_bytes_do_not_abort_the_run.
| continue | ||
| if address in counts: | ||
| counts[address] += 1 | ||
| elif len(counts) < max_addresses: |
There was a problem hiding this comment.
The cap is the fix for the memory-exhaustion path: the dict key is attacker-chosen, so its size must not be attacker-controlled. Overflow is counted rather than dropped so the report never implies "only N sources" when there were more. test_address_table_is_capped asserts 10 tracked + 40 overflow for a 50-address log with max_addresses=10.
Open question for a future run: is 10 000 the right ceiling for a busy internet-facing host? A /16 of sources would reach it.
| ] | ||
|
|
||
|
|
||
| def write_demo_log(log_path, lines=None): |
There was a problem hiding this comment.
The old __main__ wrote this file unconditionally with mode 'w' in the working directory. Refusing to overwrite turns a silent clobber of a real mock_auth.log into a message plus a parse of the existing file. test_demo_log_never_overwrites_an_existing_file pins it.
| return 2 | ||
|
|
||
| critical = sum(1 for count in counts.values() if count >= args.threshold) | ||
| return 1 if critical else 0 |
There was a problem hiding this comment.
Exit-code contract, matching the convention api_key_leak_scanner.py established in this repo: 0 clean, 1 threshold reached, 2 unreadable. This is what makes the script usable as a cron alert or a CI gate — the operator gets a signal without parsing stdout.
Summary
Daily code-quality audit of
python_scripts/auth_log_parser.py. The script counts failed SSH authentications per source address, but it treated log lines as trusted data and used a token split to find the address. This PR rebuilds the parser around an explicit validation gate (ipaddress), bounds the memory it can be made to consume, and adds a 25-case test suite.Linked Issue
Closes # — (scheduled audit run; no tracking issue raised)
Type of Change
What Changed
parts.index('from')→ anchored regexes for the OpenSSHFailed password … from <addr>format and the PAMauthentication failure; … rhost=<addr>format. The old split returned the wrong token wheneverfromalso appeared in the username, and it silently dropped every PAM-format line (they have nofromtoken at all).ipaddress.ip_address(); only a real IPv4/IPv6 literal is ever stored, counted or printed. IPv4-mapped IPv6 peers (::ffff:192.168.1.50) are collapsed to plain IPv4 so one host is not counted twice.MAX_TRACKED_ADDRESSES(10 000) with attempts beyond it tallied as overflow; lines are truncated atMAX_LINE_CHARS(8 192) before matching. A log padded with spoofed sources or one enormous line can no longer grow memory without limit.exists()-then-open()(a TOCTOU race) replaced with a singletry/except OSError; files open witherrors="replace"so undecodable bytes degrade instead of aborting the run.argparsewith--log,--threshold,--top,--demoand--help; exit 0 clean, 1 threshold reached, 2 unreadable. The demo log is only written when it does not already exist, so the old unconditionalopen(..., 'w')onmock_auth.logcan no longer clobber a real log.tests/test_auth_log_parser.py— 25 cases across detection, injection, memory bounds, failure handling, reporting and the exit-code contract.New or Modified Scripts
python_scripts/auth_log_parser.pytests/test_auth_log_parser.pySafety Review
--dry-run(n/a — read-only;--demowrites only to a non-existent file)Testing Checklist
python3 -m py_compile python_scripts/*.pypassesflake8 python_scripts --select=E9,F63,F7,F82,F821,F823 --max-line-length=120passesfor f in bash_scripts/*.sh; do bash -n "$f"; donepasses (unchanged by this PR)shellcheck bash_scripts/*.shreports no errors (advisory, unchanged)python3 -m pytest tests/ -q→ 25 passed--demo)--helpoutput--threshold)Documentation
README.mdin the correct numeric position — the index entry already exists at line 218; its description is now slightly stale ("from a mock auth.log"). The README edit was blocked by an API-side size limit on this run and is left as a follow-up.Code Quality Checklist
Additional Notes
Two genuine defects fixed (both behaviour-preserving in the sense that the new output is a superset of the old for well-formed logs, and strictly more correct for PAM lines):
authentication failurelines were matched by the detector but then dropped by the address splitter, sosudo/loginfailures never appeared in the report.fromcaused the wrong token to be taken as the address.Known limitation: addresses beyond the 10 000-entry cap are only counted in aggregate, not listed. That is deliberate — listing them is exactly the memory-exhaustion path being closed.