Skip to content

Commit

Permalink
PB-1351 Filter the logged headers
Browse files Browse the repository at this point in the history
Provide a white list that can be overwritten by the environment for which
headers we allow to go to the log
  • Loading branch information
schtibe committed Jan 28, 2025
1 parent 8c0d075 commit 5932f25
Show file tree
Hide file tree
Showing 4 changed files with 199 additions and 112 deletions.
113 changes: 1 addition & 112 deletions app/config/api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
import sys
from logging import getLogger
from typing import Any
from typing import List
from typing import Optional
from typing import TypedDict

from access.api import router as access_router
from botocore.exceptions import EndpointConnectionError
from distributions.api import router as distributions_router
Expand All @@ -21,111 +14,7 @@
from django.http import Http404
from django.http import HttpRequest
from django.http import HttpResponse

logger = getLogger(__name__)

LogExtra = TypedDict(
'LogExtra',
{
'http': {
'request': {
'method': str, 'header': dict[str, str]
},
'response': {
'status_code': int, 'header': dict[str, str]
}
},
'url': {
'path': str, 'scheme': str
}
}
)


def generate_log_extra(request: HttpRequest, response: HttpResponse) -> LogExtra:
"""Generate the extra dict for the logging calls
This will format the following extra fields to be sent to the logger:
request:
http:
request:
method: GET | POST | PUT | ...
header: LIST OF HEADERS
response:
header: LIST OF HEADERS
status_code: STATUS_CODE
url:
path: REQUEST_PATH
scheme: REQUEST_SCHEME
Args:
request (HttpRequest): Request object
response (HttpResponse): Response object
Returns:
dict: dict of extras
"""
return {
'http': {
'request': {
'method': request.method or 'UNKNOWN',
'header': {
k.lower(): v for k, v in request.headers.items()
}
},
'response': {
'status_code': response.status_code,
'header': {
k.lower(): v for k, v in response.headers.items()
},
}
},
'url': {
'path': request.path or 'UNKNOWN', 'scheme': request.scheme or 'UNKNOWN'
}
}


class LoggedNinjaAPI(NinjaAPI):
"""Extension for the NinjaAPI to log the requests to elastic
Overwriting the method that creates a response. The only thing done then
is that depending on the status, a log entry will be triggered.
"""

def create_response(
self,
request: HttpRequest,
data: Any,
*args: List[Any],
status: Optional[int] = None,
temporal_response: Optional[HttpResponse] = None,
) -> HttpResponse:
response = super().create_response(
request, data, *args, status=status, temporal_response=temporal_response
)

if response.status_code >= 200 and response.status_code < 400:
logger.info(
"Response %s on %s",
response.status_code, # parameter for %s
request.path, # parameter for %s
extra=generate_log_extra(request, response)
)
elif response.status_code >= 400 and response.status_code < 500:
logger.warning(
"Response %s on %s",
response.status_code, # parameter for %s
request.path, # parameter for %s
extra=generate_log_extra(request, response)
)
else:
logger.exception(repr(sys.exc_info()[1]), extra=generate_log_extra(request, response))

return response

from config.logging import LoggedNinjaAPI

api = LoggedNinjaAPI()

Expand Down
117 changes: 117 additions & 0 deletions app/config/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
from logging import getLogger
import sys

from typing import TypedDict
from typing import Any
from typing import List
from typing import Optional

from django.conf import settings
from django.http import HttpRequest, HttpResponse
from ninja import NinjaAPI

logger = getLogger(__name__)

LogExtra = TypedDict(
'LogExtra',
{
'http': {
'request': {
'method': str, 'header': dict[str, str]
},
'response': {
'status_code': int, 'header': dict[str, str]
}
},
'url': {
'path': str, 'scheme': str
}
}
)


def generate_log_extra(request: HttpRequest, response: HttpResponse) -> LogExtra:
"""Generate the extra dict for the logging calls
This will format the following extra fields to be sent to the logger:
request:
http:
request:
method: GET | POST | PUT | ...
header: LIST OF HEADERS
response:
header: LIST OF HEADERS
status_code: STATUS_CODE
url:
path: REQUEST_PATH
scheme: REQUEST_SCHEME
Args:
request (HttpRequest): Request object
response (HttpResponse): Response object
Returns:
dict: dict of extras
"""
return {
'http': {
'request': {
'method': request.method or 'UNKNOWN',
'header': {
k.lower(): v for k,
v in request.headers.items() if k.lower() in settings.LOG_ALLOWED_HEADERS
}
},
'response': {
'status_code': response.status_code,
'header': {
k.lower(): v for k,
v in response.headers.items() if k.lower() in settings.LOG_ALLOWED_HEADERS
},
}
},
'url': {
'path': request.path or 'UNKNOWN', 'scheme': request.scheme or 'UNKNOWN'
}
}


class LoggedNinjaAPI(NinjaAPI):
"""Extension for the NinjaAPI to log the requests to elastic
Overwriting the method that creates a response. The only thing done then
is that depending on the status, a log entry will be triggered.
"""

def create_response(
self,
request: HttpRequest,
data: Any,
*args: List[Any],
status: Optional[int] = None,
temporal_response: Optional[HttpResponse] = None,
) -> HttpResponse:
response = super().create_response(
request, data, *args, status=status, temporal_response=temporal_response
)

if response.status_code >= 200 and response.status_code < 400:
logger.info(
"Response %s on %s",
response.status_code, # parameter for %s
request.path, # parameter for %s
extra=generate_log_extra(request, response)
)
elif response.status_code >= 400 and response.status_code < 500:
logger.warning(
"Response %s on %s",
response.status_code, # parameter for %s
request.path, # parameter for %s
extra=generate_log_extra(request, response)
)
else:
logger.exception(repr(sys.exc_info()[1]), extra=generate_log_extra(request, response))

return response
48 changes: 48 additions & 0 deletions app/config/settings_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,51 @@ def get_logging_config() -> dict[str, object]:


LOGGING = get_logging_config()

# list of headers that are allowed to be logged
_DEFAULT_LOG_ALLOWED_HEADERS = [

# Standard headers
"accept",
"accept-encoding",
"accept-language",
"accept-ranges",
"cache-control",
"connection",
"content-length",
"content-security-policy",
"content-type",
"etag",
"host",
"if-match",
"if-none-match",
"origin",
"referer",
"referrer-policy",
"transfer-encoding",
"user-agent",
"vary",
"x-content-type-options",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",

# Cloudfront headers
"cloudfront-is-android-viewer",
"cloudfront-is-desktop-viewer",
"cloudfront-is-ios-viewer",
"cloudfront-is-mobile-viewer",
"cloudfront-is-smarttv-viewer",
"cloudfront-is-tablet-viewer",

# PPBGDI headers
"x-e2e-testing",
# API GW Headers
"geoadmin-authenticated"
"geoadmin-username",
"apigw-requestid"
]
LOG_ALLOWED_HEADERS = [
k.lower for k in env.list('LOG_ALLOWED_HEADERS', default=_DEFAULT_LOG_ALLOWED_HEADERS)
]
33 changes: 33 additions & 0 deletions app/tests/config/test_request_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,49 @@

import json

from django.http import HttpRequest, HttpResponse

import mock_api # pylint: disable=unused-import
import pytest
from ecs_logging import StdlibFormatter
from config.logging import generate_log_extra


@pytest.fixture(name='configure_logger')
def fixture_configure_logger(caplog):
caplog.handler.setFormatter(StdlibFormatter())


def test_generate_log_extra(settings):
settings.LOG_ALLOWED_HEADERS = ['Content-Type', 'Lebowski', 'x-apigw-id']

request = HttpRequest()
# overwrite the headers
request.headers = {
'opinion': 'just like yours',
'content-type': 'bowling',
'secret-header': 'remove this',
'Lebowski': 'Jeffrey'
}

response = HttpResponse()
response['Content-type'] = "Nihilism"
response['Set-Cookie'] = "Rug"

out = generate_log_extra(request, response)

request_headers = out['request']['header'].keys()
response_headers = out['response']['header'].keys()

assert 'content-type' in request_headers
assert 'lebowski' in request_headers
assert 'secret-header' not in request_headers
assert 'opinion' not in request_headers

assert 'content-type' in response_headers
assert 'set-cookie' not in response_headers


def test_404_logging(client, caplog, configure_logger):
path = '/api/v1/trigger-not-found'
client.get(path)
Expand Down

0 comments on commit 5932f25

Please sign in to comment.