Files
team-tryouts/app/logging_config.py
T
GGThed 70db8a7491 feat(obs): nommer chaque requete, et rendre les pages d erreur audibles
OBS-005. Un 500 dans errors.log et les six lignes de app.log qui y menent
n etaient relies que par leur horodatage — ce qui n est pas une relation
des que le serveur traite plus d une requete a la fois. Et un utilisateur
qui dit « ca a plante quand j ai clique sur enregistrer » ne donnait a
personne de quoi chercher.

Chaque requete recoit un identifiant, porte par toutes les lignes de
journal qu elle produit, renvoye en X-Request-Id, et affiche sur la page
500 comme reference a citer.

Il est **genere**, jamais lu depuis un en-tete entrant. Accepter celui du
client serait pratique pour tracer a travers nginx, et permettrait aussi a
n importe qui d ecrire du texte arbitraire — retours a la ligne compris —
dans le fichier de journal. C est ainsi qu un journal cesse d etre une
preuve. Il n y a de toute facon aucun proxy de confiance tant qu OPS-002
est ouvert.

Le test correspondant assure sur l alphabet plutot qu en envoyant un
retour a la ligne : le client de test de Werkzeug refuse d emettre un tel
en-tete, donc l attaque ne peut meme pas etre construite par la, ce qui ne
prouverait rien sur l application.

**Defaut trouve en chemin, et repare.** Les cinq gabarits d erreur
remplissent le bloc `content`, qui n existait que dans la branche
authentifiee de la mise en page. Un visiteur deconnecte tombant sur une
erreur — donc typiquement sur la page de connexion — recevait le logo, le
selecteur de langue, et **aucun message**. Le code de statut etait bon, les
journaux etaient bons, la page etait vide. Le <title> disait quand meme
« 404 », ce qui explique en grande partie que personne ne l ait vu.

Le bloc est desormais rendu dans les deux branches via self.content(),
Jinja refusant deux blocs de meme nom. Un seul cote du if s execute, donc
jamais de double rendu — et c est assure, pas suppose.

550 tests.
2026-08-11 15:30:05 -04:00

263 lines
10 KiB
Python

"""Structured logging configuration for the Team Tryouts application.
This module configures rotating file handlers for application logs,
with separate files for errors, authentication events, and general logs.
Sensitive data (passwords, tokens) is automatically filtered out.
Usage:
from logging_config import configure_logging
configure_logging(app)
"""
import logging
import os
import re
from logging.handlers import RotatingFileHandler
#: Value used when a record is emitted outside a request — startup, the
#: Discord bot thread, the scheduler. Short and obviously not an id, so a
#: grep for one never matches it by accident.
NO_REQUEST = '-'
class RequestIdFilter(logging.Filter):
"""Stamp every record with the id of the request that produced it.
Without this, a 500 in errors.log and the six lines in app.log that led
to it are related only by their timestamps, which is not a relation when
the server is handling more than one request at a time (OBS-005).
The id is generated per request and never read from an inbound header.
Accepting one would be convenient for tracing across nginx, and it would
also let any caller write arbitrary text — newlines included — into the
log file, which is how a log gets forged rather than read. There is no
trusted proxy to take it from while OPS-002 is open.
"""
def filter(self, record):
record.request_id = NO_REQUEST
try:
from flask import g, has_request_context
if has_request_context():
record.request_id = g.get('request_id', NO_REQUEST)
except Exception: # noqa: BLE001 — logging must never be the thing that fails
pass
return True
class SensitiveDataFilter(logging.Filter):
"""Logging filter that redacts sensitive information from log messages.
Filters out: passwords, API keys, session tokens, and other secrets
that might accidentally be logged.
"""
# Patterns to redact
SENSITIVE_PATTERNS = [
(
re.compile(
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;)]+', re.IGNORECASE
),
'[REDACTED]',
),
(
re.compile(
r'(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*"[^"]*"', re.IGNORECASE
),
lambda m: m.group(0).split('=')[0] + '="[REDACTED]"',
),
(re.compile(r'Authorization[:\s]+[^\s]+', re.IGNORECASE), 'Authorization: [REDACTED]'),
(re.compile(r'Bearer\s+[^\s]+', re.IGNORECASE), 'Bearer [REDACTED]'),
]
def filter(self, record):
"""Apply redaction to the log record's fully rendered message.
The record is rendered first (msg % args) and the result stored back
as msg with args cleared. Redacting record.msg alone would miss almost
everything: this codebase logs with %s placeholders, so the sensitive
value lives in record.args while record.msg holds only the format
string.
Args:
record: The log record to filter.
Returns:
bool: Always True (never drops records, only redacts).
"""
try:
rendered = record.getMessage()
except Exception:
# A malformed format string must not lose the record entirely.
return True
for pattern, replacement in self.SENSITIVE_PATTERNS:
rendered = pattern.sub(replacement, rendered)
record.msg = rendered
record.args = ()
return True
def configure_logging(app):
"""Configure structured logging for the Flask application.
Sets up three rotating file handlers:
- errors.log: ERROR and CRITICAL level messages
- auth.log: Authentication-related events (INFO and above)
- app.log: All application logs (DEBUG and above, configurable)
Also configures console output for development.
Args:
app: The Flask application instance to configure logging for.
"""
log_dir = os.path.join(os.getcwd(), 'logs')
os.makedirs(log_dir, exist_ok=True)
# Remove default Flask handlers to avoid duplicate logging
app.logger.handlers.clear()
# Set base log level from environment (default: INFO)
log_level_name = os.getenv('LOG_LEVEL', 'INFO').upper()
log_level = getattr(logging, log_level_name, logging.INFO)
app.logger.setLevel(log_level)
# Create the sensitive data filter
sensitive_filter = SensitiveDataFilter()
request_id_filter = RequestIdFilter()
# Formatter with timestamp, level, module, request id, and message.
#
# request_id comes from RequestIdFilter, which is attached to every
# handler below. A handler that formats with this string and does not
# carry the filter raises on its first record — so if one is ever added,
# add the filter with it.
formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s [%(name)s:%(lineno)d] [%(request_id)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)
# -------------------------------------------------------------------------
# 1. Error Log Handler
# -------------------------------------------------------------------------
error_handler = RotatingFileHandler(
os.path.join(log_dir, 'errors.log'),
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10,
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter)
error_handler.addFilter(sensitive_filter)
error_handler.addFilter(request_id_filter)
app.logger.addHandler(error_handler)
# -------------------------------------------------------------------------
# 2. Authentication Log Handler
# -------------------------------------------------------------------------
auth_handler = RotatingFileHandler(
os.path.join(log_dir, 'auth.log'),
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
)
auth_handler.setLevel(logging.INFO)
auth_handler.setFormatter(formatter)
auth_handler.addFilter(sensitive_filter)
auth_handler.addFilter(request_id_filter)
# Create a named logger specifically for auth events
auth_logger = logging.getLogger('team_tryouts.auth')
auth_logger.setLevel(logging.INFO)
auth_logger.addHandler(auth_handler)
auth_logger.propagate = False # Don't double-log to root
# -------------------------------------------------------------------------
# 3. Application Log Handler (general)
# -------------------------------------------------------------------------
app_handler = RotatingFileHandler(
os.path.join(log_dir, 'app.log'),
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10,
)
app_handler.setLevel(log_level)
app_handler.setFormatter(formatter)
app_handler.addFilter(sensitive_filter)
app_handler.addFilter(request_id_filter)
app.logger.addHandler(app_handler)
# -------------------------------------------------------------------------
# 4. Console Handler (always on)
# -------------------------------------------------------------------------
# Previously gated on FLASK_DEBUG, which meant production emitted nothing
# on stdout — precisely where the Pterodactyl console looks. Keep the
# handler unconditional and vary the level instead.
debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG if debug_mode else log_level)
console_handler.setFormatter(formatter)
console_handler.addFilter(sensitive_filter)
console_handler.addFilter(request_id_filter)
app.logger.addHandler(console_handler)
# -------------------------------------------------------------------------
# 5. Package logger ('app.*') — notably app.discord_bot
# -------------------------------------------------------------------------
# Modules using logging.getLogger(__name__) resolve to 'app.<module>'.
# Without handlers here their INFO records were dropped entirely and
# WARNING+ fell through to Python's lastResort handler, unformatted.
package_logger = logging.getLogger('app')
package_logger.setLevel(log_level)
package_logger.propagate = False
for handler in (error_handler, app_handler, console_handler):
if handler not in package_logger.handlers:
package_logger.addHandler(handler)
# Log startup information
app.logger.info('Logging configured - Level: %s, Log directory: %s', log_level_name, log_dir)
app.logger.info('Application startup')
return app.logger
# Module-level auth logger factory
def get_auth_logger():
"""Get the authentication event logger.
Returns:
logging.Logger: Logger for authentication events.
"""
return logging.getLogger('team_tryouts.auth')
def log_auth_event(event, **fields):
"""Record a security-relevant event to auth.log.
The handler, its rotation and its redaction filter were configured from
the start, but get_auth_logger was never imported anywhere: auth.log was
created and stayed empty. No login, failure, lockout, role change or
account deletion left any trace.
Fields are emitted as `key=value` pairs, ordered, so the file stays
greppable without pulling in a JSON logging dependency.
Note on `ip`: it is taken from request.remote_addr, which reflects
X-Forwarded-For. As long as Waitress runs with trusted_proxy='*'
(SEC-WEB-002), that value is attacker-controlled and must be read as an
indication rather than as evidence.
Args:
event: Dotted event name, e.g. 'login.success'.
**fields: Additional context. Never pass a secret: values are
recorded verbatim apart from the redaction filter's patterns.
"""
from flask import has_request_context, request
parts = [f'event={event}']
if has_request_context():
parts.append(f'ip={request.remote_addr}')
parts.append(f'path={request.path}')
parts.extend(f'{key}={value}' for key, value in fields.items())
get_auth_logger().info(' '.join(parts))