Files
DECNET/decnet/templates/ftp/server.py
anti abb4dd9fc0 feat(templates): migrate six cred emitters to native shape
Phase 2/3 of DEBT-039. Switches FTP, POP3, IMAP, SMTP, Redis, and
LDAP from the legacy `username=` + `password=` SD-block shape to the
universal credential shape (`principal=` + `secret_printable=` +
`secret_b64=`) the new Credential storage model expects.

Pattern is uniform across all six services:

    _log("auth_attempt", username=u, principal=u, **encode_secret(pw))

Each service emits the canonical SD keys. The ingester's native-shape
branch (introduced in 2f47f67) now writes their cred attempts
directly without going through the legacy adapter. Once Phase 3
removes the adapter the contract becomes single-shape.

Per-service notes:
- POP3 / IMAP — `status="success"|"failed"` renamed to
  `outcome="success"|"failure"` to match Credential.outcome's
  vocabulary; the ingester reads outcome directly.
- SMTP — AUTH path migrated; in addition the existing mail_from
  event now exposes a parsed `domain=` field alongside the original
  `value=` so future "what domains do attackers spoof from" analytics
  have an indexed field. Not stored in Credential — regular Log row.
- Redis — was silently dropped by the legacy adapter (no `username`
  field). Native branch handles `principal=None` correctly. BONUS
  FIX: the Redis 6+ ACL syntax `AUTH <user> <pw>` now captures the
  ACL username as principal (was previously discarded).
- LDAP — was silently dropped by the legacy adapter (no `password`
  recognition for the `bind` event). Now lands as
  `principal=<dn>`. BONUS FIX.

Tests (tests/services/test_cred_emitters.py, 9 cases):
- per-service native-shape ingest path produces correct Credential
  rows; outcome maps for POP3/IMAP; principal=None for legacy Redis
  AUTH; principal=dn for LDAP.
- mail_from event does NOT trigger a credential write (it's a
  Log-only observation, not auth).
- 0xff/NUL/ANSI bytes in passwords survive losslessly through
  secret_b64 even when secret_printable is sanitized.

Phase 3 deletes the legacy adapter once all migrations land — the
adapter has no live emitters to handle anymore.
2026-04-25 05:43:51 -04:00

145 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""
FTP server using Twisted's FTP server infrastructure.
Accepts any credentials, logs all commands and file requests,
forwards events as JSON to LOG_TARGET if set.
"""
import os
from pathlib import Path
from twisted.internet import defer, reactor
from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell
from twisted.python.filepath import FilePath
from twisted.python import log as twisted_log
import instance_seed as _seed
from syslog_bridge import (
encode_secret,
forward_syslog,
syslog_line,
write_syslog_file,
)
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
SERVICE_NAME = "ftp"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "21"))
# Per-instance daemon identity. Fleet-wide "vsFTPd 3.0.3" is an instant
# fingerprint of an unmaintained honeypot — real shops run a mix.
_FTP_BANNER_CHOICES = [
"220 (vsFTPd 3.0.3)",
"220 (vsFTPd 3.0.5)",
"220 ProFTPD 1.3.7a Server ready.",
"220 ProFTPD 1.3.6 Server ready.",
"220 Pure-FTPd Service ready.",
]
BANNER = os.environ.get("FTP_BANNER") or _seed.pick(_FTP_BANNER_CHOICES)
# Accept approximately this fraction of logins. Real anon-accessible
# servers succeed often; credential-harvesting scanners hitting every
# possible user/pass pair should still see plausible failures.
_LOGIN_SUCCESS_RATE = float(os.environ.get("FTP_LOGIN_SUCCESS_RATE", "0.9"))
# Optional override — if set to "never", ALL logins fail (realistic for a
# server with anonymous disabled). Handy for producing server diversity
# across the fleet.
_LOGIN_MODE = os.environ.get("FTP_LOGIN_MODE", "").strip().lower()
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
write_syslog_file(line)
forward_syslog(line, LOG_TARGET)
def _setup_bait_fs() -> str:
"""Generate a per-instance bait filesystem.
No shared paths across deckies (/tmp/ftp_bait was identical on every
host), no tell-tale 'super_secret_admin_pw' strings. Filenames, byte
counts, and inline values are all derived from the per-decky seed, so
two honeypots never serve byte-identical files yet each stays stable
across restarts."""
bait_dir = Path(f"/tmp/ftp-{_seed.instance_hex(6, 'ftp-bait-dir')}")
bait_dir.mkdir(parents=True, exist_ok=True)
company = _seed.pick(["acme", "contoso", "northwind", "initech", "globex", "hooli"])
env = _seed.pick(["prod", "stage", "backup", "archive"])
year = _seed.rng.randint(2022, 2024)
month = _seed.rng.randint(1, 12)
# Realistic-looking rotating backups. Sizes vary per instance.
for idx in range(_seed.rng.randint(2, 5)):
tag = f"{year}{month:02d}{_seed.rng.randint(1, 28):02d}"
size = _seed.rng.randint(2048, 32768)
(bait_dir / f"{company}-{env}-{tag}.tar.gz").write_bytes(
b"\x1f\x8b\x08\x00" + _seed.random_bytes(size - 4, f"tar-{idx}")
)
# A plausible README that looks like legacy ops notes, NOT a credential
# dump. No "password = ..." strings — those are a dead giveaway.
(bait_dir / "README.txt").write_text(
f"{company} {env} drop area\n"
f"Rotation: keep last 14, nightly rsync from db{_seed.rng.randint(1,9)}.{env}\n"
f"Contact: ops-{env}@{company}.internal\n"
)
(bait_dir / ".htaccess").write_text("Options -Indexes\n")
return str(bait_dir)
_BAIT_PATH = _setup_bait_fs()
class ServerFTP(FTP):
def connectionMade(self):
peer = self.transport.getPeer()
_log("connection", src_ip=peer.host, src_port=peer.port)
super().connectionMade()
def ftp_USER(self, username):
self._server_user = username
_log("user", username=username)
return super().ftp_USER(username)
def ftp_PASS(self, password):
_u = getattr(self, "_server_user", "?")
_log("auth_attempt", username=_u, principal=_u, **encode_secret(password))
# Decide whether this attempt succeeds. Unseeded randomness so
# scanners can't predict which creds will "work".
import random as _rand
if _LOGIN_MODE == "never":
accept = False
elif _LOGIN_MODE == "always":
accept = True
else:
accept = _rand.random() < _LOGIN_SUCCESS_RATE
if not accept:
return defer.succeed((530, "Login incorrect."))
self.state = self.AUTHED
self._user = getattr(self, "_server_user", "anonymous")
self.shell = FTPAnonymousShell(FilePath(_BAIT_PATH))
return defer.succeed((230, "Login successful."))
def ftp_RETR(self, path):
_log("download_attempt", path=path)
return super().ftp_RETR(path)
def connectionLost(self, reason):
peer = self.transport.getPeer()
_log("disconnect", src_ip=peer.host, src_port=peer.port)
super().connectionLost(reason)
class ServerFTPFactory(FTPFactory):
protocol = ServerFTP
welcomeMessage = BANNER
if __name__ == "__main__":
twisted_log.startLoggingWithObserver(lambda e: None, setStdout=False)
_log("startup", msg=f"FTP server starting as {NODE_NAME} on port {PORT}")
reactor.listenTCP(PORT, ServerFTPFactory())
reactor.run()