diff --git a/SCAN-TODO.md b/SCAN-TODO.md index 1fd95c7d0..06f442416 100644 --- a/SCAN-TODO.md +++ b/SCAN-TODO.md @@ -6,6 +6,7 @@ Rule: clone, scan, delete clone after. Keep disk under 90%. ## Priority 1 — Major infrastructure not yet scanned - [ ] Squid (C, HTTP proxy, huge install base) +- [x] Sendmail (C, MTA) — sendmail-0001 MOAD-0004 CWE-312 SASL client password logged verbatim at tTd(95,5) debug level in getauth(); MOADs 0001/0002/0003/0005 CLEAN - [x] PgBouncer (C, PostgreSQL connection pooler) — pgbouncer-0001 MOAD-0004 CWE-312 SCRAM verifier logged at slog_debug; pgbouncer-0002 MOAD-0001 CWE-407 find_database() O(D) linear scan per connection 100x at D=100; MOADs 0002/0003/0005 CLEAN (single-threaded libevent) - [x] Suricata (C, IDS/IPS) — suricata-0001 (CWE-407 threshold SID lookup O(T×S), CWE-312 auth header logging); suricata-0002 (CWE-407 EveHttpLogJSONHeaders O(H×F=53) per tx, 53x); MOADs 0003/0005 CLEAN - [x] ClamAV (C, antivirus engine) — clamav-0001 MOAD-0004 CWE-312 proxy password logged verbatim on curl failure; MOAD-0001/0002/0003/0005 CLEAN (AC trie, BM hash, mutex-protected cache) diff --git a/defects/sendmail-0001/TICKET.md b/defects/sendmail-0001/TICKET.md new file mode 100644 index 000000000..af8074ae5 --- /dev/null +++ b/defects/sendmail-0001/TICKET.md @@ -0,0 +1,79 @@ +# sendmail-0001 — MOAD-0004 (CWE-312) SASL client password logged verbatim at debug level + +**Target:** Sendmail 8.18.1 +**File:** `sendmail/usersmtp.c` +**Severity:** MEDIUM +**MOAD:** 0004 (Logged Secret / CWE-312) +**Benchmark:** Any debug-enabled deployment exposes plaintext password to syslog + +## Defect + +In `getauth()`, when trace flag `tTd(95, 5)` is active (a debug mode that operators +enable by adding `O LogLevel=95` or `-d95.5` to diagnose SASL auth failures), +our code logs each SASL auth field name and value verbatim to syslog: + +```c +/* sendmail/usersmtp.c, getauth(), ~line 1000 */ +if (tTd(95, 5)) + sm_syslog(LOG_DEBUG, NOQID, "getauth %s=%s", + sasl_info_name[r], (*sai)[r]); +``` + +`sasl_info_name[]` contains `{ "user id", "authentication id", "password", "realm", "mechlist" }`. +When `r == SASL_PASSWORD` (index 2), this emits: + +``` +sendmail[PID]: getauth password=s3cr3t_relay_pass +``` + +to syslog at LOG_DEBUG. Our syslog typically goes to `/var/log/mail.log` (world-readable +on many systems), to central syslog aggregators, and to SIEM platforms. Any +recipient of our log stream receives our relay authentication credential in plaintext. + +Our defect is that our credential denylist does not exist at our log serialization +layer — our raw value is passed directly to `sm_syslog()` without redaction. + +## Fix + +Redact our password field at our logging callsite. Our fix pattern is a simple +ternary that never suppresses our log line (preserving debug signal) but replaces +our secret value with `""`: + +```c +if (tTd(95, 5)) + sm_syslog(LOG_DEBUG, NOQID, "getauth %s=%s", + sasl_info_name[r], + (r == SASL_PASSWORD) ? "" : (*sai)[r]); +``` + +Our fix preserves: which field was loaded (name printed), that our load succeeded +(line still appears), and debug traceability (all non-secret fields unchanged). + +## MOAD 0001-0005 Scan Results + +**MOAD-0001 (CWE-407):** `sasl.c:intersect()` and `usersmtp.c:str_union()` scan +mechanism lists with `iteminlist()` inside a while loop — O(M*N). In practice +N ≤ 10 SASL mechanisms so no scalable defect. **CLEAN** for hotpath purposes. + +`recipient()` in `recipient.c` uses a sorted linked list with a sort function +for dedup — O(N) per insert with early exit, not O(N^2). **CLEAN.** + +`dochompheader()` in `headers.c` scans existing headers to delete defaults — +O(H) per header with H bounded by distinct header types (< 100). **CLEAN.** + +**MOAD-0002 (Intertangle):** sendmail uses `CurEnv` and `BlankEnvelope` as +process-global state, but our process-per-connection fork model means each +SMTP session has its own process address space. No shared mutable state +between concurrent connections. Architectural concern but not a defect +under our current threat model. **CLEAN.** + +**MOAD-0003 (Leaked Context):** sendmail is single-threaded per process +(fork model). No `pthread_key_t`, no `__thread`, no thread-local storage. +**CLEAN.** + +**MOAD-0004 (CWE-312):** `getauth()` at line 1001 logs SASL password verbatim. +**DEFECT — this ticket.** + +**MOAD-0005 (Thundering Herd):** MCI connection cache (`mci.c:mci_cache()`, +`mci_scan()`) is accessed only within a single forked process. No concurrent +goroutines or threads contend on our cache. **CLEAN.** diff --git a/defects/sendmail-0001/patch/sendmail-0001.patch b/defects/sendmail-0001/patch/sendmail-0001.patch new file mode 100644 index 000000000..ec324c56a --- /dev/null +++ b/defects/sendmail-0001/patch/sendmail-0001.patch @@ -0,0 +1,17 @@ +# UNDF: UNDF-2026-PENDING +--- a/sendmail/usersmtp.c ++++ b/sendmail/usersmtp.c +@@ -998,8 +998,10 @@ getauth(mci, e, sai) + goto fail; + got |= 1 << r; + } + else + goto fail; + if (tTd(95, 5)) +- sm_syslog(LOG_DEBUG, NOQID, "getauth %s=%s", +- sasl_info_name[r], (*sai)[r]); ++ sm_syslog(LOG_DEBUG, NOQID, "getauth %s=%s", ++ sasl_info_name[r], ++ (r == SASL_PASSWORD) ? "" : (*sai)[r]); + ++i; + } diff --git a/defects/sendmail-0001/test/test_sendmail_0001.py b/defects/sendmail-0001/test/test_sendmail_0001.py new file mode 100644 index 000000000..cb8d3b23a --- /dev/null +++ b/defects/sendmail-0001/test/test_sendmail_0001.py @@ -0,0 +1,171 @@ +""" +sendmail-0001 — MOAD-0004 (CWE-312) +SASL client password logged verbatim at debug level in getauth() + +Simulates our defect (logging a password field verbatim) and our fix +(redacting password field before logging). Asserts our fix never exposes +our plaintext password in our log output. +""" + +import sys +import time + +export_PYTHONUNBUFFERED = True # reminder: run with python3 -u or PYTHONUNBUFFERED=1 + +# --- Constants mirroring sendmail's sasl_info_name[] --- +SASL_USER = 0 +SASL_AUTHID = 1 +SASL_PASSWORD = 2 +SASL_DEFREALM = 3 +SASL_MECHLIST = 4 + +sasl_info_name = ["user id", "authentication id", "password", "realm", "mechlist"] + +# --- Simulate the SASL auth info array --- +sai = [ + "relay_user", # SASL_USER + "relay_authid", # SASL_AUTHID + "s3cr3t_relay_pass", # SASL_PASSWORD + "example.com", # SASL_DEFREALM + "PLAIN LOGIN", # SASL_MECHLIST +] + + +def getauth_defective(sai): + """ + Simulates our defective getauth() logging path from sendmail/usersmtp.c ~line 1001. + Logs all fields verbatim — password is exposed. + """ + log_lines = [] + for r in range(len(sai)): + # tTd(95, 5) debug path — logs name=value verbatim + line = "getauth {}={}".format(sasl_info_name[r], sai[r]) + log_lines.append(line) + return log_lines + + +def getauth_fixed(sai): + """ + Simulates our fixed getauth() logging path. + Redacts our password field before logging. + """ + log_lines = [] + for r in range(len(sai)): + value = "" if r == SASL_PASSWORD else sai[r] + line = "getauth {}={}".format(sasl_info_name[r], value) + log_lines.append(line) + return log_lines + + +def password_exposed_in_log(log_lines, password): + """Return True if our plaintext password appears anywhere in our log output.""" + return any(password in line for line in log_lines) + + +def password_field_logged(log_lines): + """Return True if our password field log line appears (field name present).""" + return any("password" in line for line in log_lines) + + +def run_tests(): + password = sai[SASL_PASSWORD] + passed = 0 + failed = 0 + + print("=" * 60) + print("sendmail-0001 — MOAD-0004 CWE-312 unit tests") + print("=" * 60) + + # --- Test 1: Defective version exposes password --- + defect_log = getauth_defective(sai) + exposed = password_exposed_in_log(defect_log, password) + if exposed: + print("PASS test1: defective getauth() exposes plaintext password in log") + passed += 1 + else: + print("FAIL test1: defective getauth() did not expose password (unexpected)") + failed += 1 + + # --- Test 2: Fixed version does NOT expose password --- + fixed_log = getauth_fixed(sai) + exposed_after_fix = password_exposed_in_log(fixed_log, password) + if not exposed_after_fix: + print("PASS test2: fixed getauth() does NOT expose plaintext password in log") + passed += 1 + else: + print("FAIL test2: fixed getauth() still exposes plaintext password") + failed += 1 + + # --- Test 3: Fixed version still logs our password field name (traceability) --- + field_present = password_field_logged(fixed_log) + if field_present: + print("PASS test3: fixed getauth() still logs password field name for traceability") + passed += 1 + else: + print("FAIL test3: fixed getauth() removed password field log line entirely") + failed += 1 + + # --- Test 4: Fixed version logs REDACTED marker --- + redacted_present = any("" in line for line in fixed_log) + if redacted_present: + print("PASS test4: fixed getauth() logs marker for password field") + passed += 1 + else: + print("FAIL test4: fixed getauth() missing marker") + failed += 1 + + # --- Test 5: Fixed version logs non-secret fields unchanged --- + username_logged = any("relay_user" in line for line in fixed_log) + if username_logged: + print("PASS test5: fixed getauth() logs non-secret fields (username) unchanged") + passed += 1 + else: + print("FAIL test5: fixed getauth() suppressed non-secret field logging") + failed += 1 + + # --- Test 6: Benchmark — simulate N=100 and N=1000 SASL auth attempts --- + # Our fix adds one integer comparison per field per auth attempt — negligible overhead. + # We assert our fix's overhead is < 10x relative to a plain strcmp (it's O(1)). + import timeit + + N = 100000 # auth attempts to benchmark + + def bench_defective(): + for _ in range(N): + getauth_defective(sai) + + def bench_fixed(): + for _ in range(N): + getauth_fixed(sai) + + t_defective = timeit.timeit(bench_defective, number=1) + t_fixed = timeit.timeit(bench_fixed, number=1) + + ratio = t_fixed / t_defective if t_defective > 0 else 1.0 + print() + print("Benchmark ({} iterations):".format(N)) + print(" Defective: {:.4f}s".format(t_defective)) + print(" Fixed: {:.4f}s".format(t_fixed)) + print(" Overhead: {:.2f}x (expected < 2x for ternary check)".format(ratio)) + + if ratio < 2.0: + print("PASS test6: fix overhead < 2x (single ternary, O(1))") + passed += 1 + else: + print("FAIL test6: fix overhead unexpectedly large ({:.2f}x)".format(ratio)) + failed += 1 + + print() + print("=" * 60) + total = passed + failed + print("Results: {}/{} PASS".format(passed, total)) + if failed == 0: + print("ALL PASS") + return 0 + else: + print("FAILURES: {}".format(failed)) + return 1 + + +if __name__ == "__main__": + sys.exit(run_tests())