fastapi-observer 0.4.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {fastapi_observer-0.4.0/src/fastapi_observer.egg-info → fastapi_observer-0.4.1}/PKG-INFO +7 -4
  2. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/README.md +6 -3
  3. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1/src/fastapi_observer.egg-info}/PKG-INFO +7 -4
  4. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapi_observer.egg-info/SOURCES.txt +2 -0
  5. fastapi_observer-0.4.1/src/fastapiobserver/_version.py +1 -0
  6. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/logging/formatter.py +2 -2
  7. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/middleware/events.py +11 -2
  8. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/middleware/ip.py +17 -0
  9. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/security/policy.py +1 -0
  10. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/security/redaction.py +4 -0
  11. fastapi_observer-0.4.1/tests/test_dx_parity.py +79 -0
  12. fastapi_observer-0.4.1/tests/test_security_audit_fixes.py +161 -0
  13. fastapi_observer-0.4.0/src/fastapiobserver/_version.py +0 -1
  14. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/LICENSE +0 -0
  15. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/pyproject.toml +0 -0
  16. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/setup.cfg +0 -0
  17. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  18. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapi_observer.egg-info/requires.txt +0 -0
  19. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  20. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/__init__.py +0 -0
  21. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/config.py +0 -0
  22. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/control_plane.py +0 -0
  23. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/fastapi.py +0 -0
  24. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/integrations/__init__.py +0 -0
  25. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/integrations/strawberry.py +0 -0
  26. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/logging/__init__.py +0 -0
  27. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/logging/circuit_breaker.py +0 -0
  28. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/logging/filters.py +0 -0
  29. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/logging/queueing.py +0 -0
  30. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/logging/setup.py +0 -0
  31. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/logging/state.py +0 -0
  32. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/loguru.py +0 -0
  33. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/__init__.py +0 -0
  34. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/builder.py +0 -0
  35. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/contracts.py +0 -0
  36. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/endpoint.py +0 -0
  37. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/noop.py +0 -0
  38. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/pathing.py +0 -0
  39. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/prometheus/__init__.py +0 -0
  40. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/prometheus/backend.py +0 -0
  41. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/prometheus/client.py +0 -0
  42. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/prometheus/collector.py +0 -0
  43. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/prometheus/exemplars.py +0 -0
  44. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/prometheus/multiprocess.py +0 -0
  45. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/metrics/registry.py +0 -0
  46. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/middleware/__init__.py +0 -0
  47. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/middleware/body_capture.py +0 -0
  48. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/middleware/context.py +0 -0
  49. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/middleware/headers.py +0 -0
  50. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/middleware/metrics.py +0 -0
  51. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/middleware/request_logging.py +0 -0
  52. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/middleware/span_errors.py +0 -0
  53. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/otel/__init__.py +0 -0
  54. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/otel/lifecycle.py +0 -0
  55. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/otel/logs.py +0 -0
  56. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/otel/metrics.py +0 -0
  57. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/otel/resource.py +0 -0
  58. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/otel/settings.py +0 -0
  59. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/otel/tracing.py +0 -0
  60. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/otel/types.py +0 -0
  61. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/plugins.py +0 -0
  62. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/propagation.py +0 -0
  63. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/py.typed +0 -0
  64. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/request_context.py +0 -0
  65. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/security/__init__.py +0 -0
  66. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/security/normalize.py +0 -0
  67. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/security/proxies.py +0 -0
  68. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/__init__.py +0 -0
  69. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/builtin.py +0 -0
  70. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/discovery.py +0 -0
  71. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/factory.py +0 -0
  72. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  73. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
  74. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  75. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  76. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/protocol.py +0 -0
  77. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/registry.py +0 -0
  78. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/sinks/stats.py +0 -0
  79. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/src/fastapiobserver/utils.py +0 -0
  80. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_body_media_allowlist.py +0 -0
  81. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_config_settings.py +0 -0
  82. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_excluded_url_precedence.py +0 -0
  83. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_fastapi_install.py +0 -0
  84. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_graphql_integrations.py +0 -0
  85. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_log_queue_controls.py +0 -0
  86. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_logging_schema_contract.py +0 -0
  87. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_logtail_dlq.py +0 -0
  88. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_loguru_bridge.py +0 -0
  89. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_metrics_contracts.py +0 -0
  90. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_metrics_optional_dependency.py +0 -0
  91. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_middleware.py +0 -0
  92. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_negotiate_content_type.py +0 -0
  93. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_otel_integration.py +0 -0
  94. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_otel_lifecycle.py +0 -0
  95. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_otel_log_correlation.py +0 -0
  96. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_otel_logs_from_env.py +0 -0
  97. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_otel_logs_pipeline.py +0 -0
  98. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_otel_metrics_from_env.py +0 -0
  99. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_otlp_export_integration.py +0 -0
  100. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_plugin_isolation.py +0 -0
  101. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_propagation.py +0 -0
  102. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_redaction_presets.py +0 -0
  103. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_route_template_cardinality.py +0 -0
  104. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_runtime_control_auth.py +0 -0
  105. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_security_redaction.py +0 -0
  106. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_sink_circuit_breaker.py +0 -0
  107. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_sinks.py +0 -0
  108. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_trust_boundary.py +0 -0
  109. {fastapi_observer-0.4.0 → fastapi_observer-0.4.1}/tests/test_utils_refactors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-observer
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Zero-glue FastAPI observability with security presets and runtime controls
5
5
  Author-email: Vitaee <opensource@vitaee.dev>
6
6
  License-Expression: MIT
@@ -704,6 +704,9 @@ The library supports configuration from code and env vars. Below are the most re
704
704
  | `METRICS_EXEMPLARS_ENABLED` | `false` | Enable exemplars where supported |
705
705
  | `METRICS_FORMAT` | `negotiate` | `prometheus`, `openmetrics`, or `negotiate` |
706
706
 
707
+ > [!CAUTION]
708
+ > The `/metrics` endpoint is **unauthenticated by default**. In production it should be restricted to internal networks (e.g. behind a Kubernetes `NetworkPolicy`, VPC security group, or ingress rule that only allows your Prometheus scraper). Exposing it publicly leaks service topology, error rates, and request patterns.
709
+
707
710
  ### Security and trust boundary
708
711
 
709
712
  | Variable | Default | Description |
@@ -1041,8 +1044,8 @@ Repository integration tests include:
1041
1044
 
1042
1045
  Reproducible benchmark harness and methodology:
1043
1046
  - Guide: [`benchmarks.md`](benchmarks.md)
1044
- - Apps: `examples/benchmarks/plain_fastapi.py` and `examples/benchmarks/observer_fastapi.py`
1045
- - Runner: `examples/benchmarks/run_local_benchmark.sh`
1047
+ - Apps: `examples/benchmarks/app.py`
1048
+ - Runner: `examples/benchmarks/harness.py`
1046
1049
 
1047
1050
  ---
1048
1051
 
@@ -1053,7 +1056,7 @@ Reproducible benchmark harness and methodology:
1053
1056
  - `0.3.x`: package modularization, sink/metrics hardening, and performance reliability updates
1054
1057
  - `1.0.0`: dynamic runtime controls and plugin stability
1055
1058
 
1056
- Current release version: `0.4.0`
1059
+ Current release version: `0.4.1`
1057
1060
 
1058
1061
  ## Changelog Policy
1059
1062
 
@@ -634,6 +634,9 @@ The library supports configuration from code and env vars. Below are the most re
634
634
  | `METRICS_EXEMPLARS_ENABLED` | `false` | Enable exemplars where supported |
635
635
  | `METRICS_FORMAT` | `negotiate` | `prometheus`, `openmetrics`, or `negotiate` |
636
636
 
637
+ > [!CAUTION]
638
+ > The `/metrics` endpoint is **unauthenticated by default**. In production it should be restricted to internal networks (e.g. behind a Kubernetes `NetworkPolicy`, VPC security group, or ingress rule that only allows your Prometheus scraper). Exposing it publicly leaks service topology, error rates, and request patterns.
639
+
637
640
  ### Security and trust boundary
638
641
 
639
642
  | Variable | Default | Description |
@@ -971,8 +974,8 @@ Repository integration tests include:
971
974
 
972
975
  Reproducible benchmark harness and methodology:
973
976
  - Guide: [`benchmarks.md`](benchmarks.md)
974
- - Apps: `examples/benchmarks/plain_fastapi.py` and `examples/benchmarks/observer_fastapi.py`
975
- - Runner: `examples/benchmarks/run_local_benchmark.sh`
977
+ - Apps: `examples/benchmarks/app.py`
978
+ - Runner: `examples/benchmarks/harness.py`
976
979
 
977
980
  ---
978
981
 
@@ -983,7 +986,7 @@ Reproducible benchmark harness and methodology:
983
986
  - `0.3.x`: package modularization, sink/metrics hardening, and performance reliability updates
984
987
  - `1.0.0`: dynamic runtime controls and plugin stability
985
988
 
986
- Current release version: `0.4.0`
989
+ Current release version: `0.4.1`
987
990
 
988
991
  ## Changelog Policy
989
992
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-observer
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Zero-glue FastAPI observability with security presets and runtime controls
5
5
  Author-email: Vitaee <opensource@vitaee.dev>
6
6
  License-Expression: MIT
@@ -704,6 +704,9 @@ The library supports configuration from code and env vars. Below are the most re
704
704
  | `METRICS_EXEMPLARS_ENABLED` | `false` | Enable exemplars where supported |
705
705
  | `METRICS_FORMAT` | `negotiate` | `prometheus`, `openmetrics`, or `negotiate` |
706
706
 
707
+ > [!CAUTION]
708
+ > The `/metrics` endpoint is **unauthenticated by default**. In production it should be restricted to internal networks (e.g. behind a Kubernetes `NetworkPolicy`, VPC security group, or ingress rule that only allows your Prometheus scraper). Exposing it publicly leaks service topology, error rates, and request patterns.
709
+
707
710
  ### Security and trust boundary
708
711
 
709
712
  | Variable | Default | Description |
@@ -1041,8 +1044,8 @@ Repository integration tests include:
1041
1044
 
1042
1045
  Reproducible benchmark harness and methodology:
1043
1046
  - Guide: [`benchmarks.md`](benchmarks.md)
1044
- - Apps: `examples/benchmarks/plain_fastapi.py` and `examples/benchmarks/observer_fastapi.py`
1045
- - Runner: `examples/benchmarks/run_local_benchmark.sh`
1047
+ - Apps: `examples/benchmarks/app.py`
1048
+ - Runner: `examples/benchmarks/harness.py`
1046
1049
 
1047
1050
  ---
1048
1051
 
@@ -1053,7 +1056,7 @@ Reproducible benchmark harness and methodology:
1053
1056
  - `0.3.x`: package modularization, sink/metrics hardening, and performance reliability updates
1054
1057
  - `1.0.0`: dynamic runtime controls and plugin stability
1055
1058
 
1056
- Current release version: `0.4.0`
1059
+ Current release version: `0.4.1`
1057
1060
 
1058
1061
  ## Changelog Policy
1059
1062
 
@@ -74,6 +74,7 @@ src/fastapiobserver/sinks/logtail/handler.py
74
74
  src/fastapiobserver/sinks/logtail/sink.py
75
75
  tests/test_body_media_allowlist.py
76
76
  tests/test_config_settings.py
77
+ tests/test_dx_parity.py
77
78
  tests/test_excluded_url_precedence.py
78
79
  tests/test_fastapi_install.py
79
80
  tests/test_graphql_integrations.py
@@ -97,6 +98,7 @@ tests/test_propagation.py
97
98
  tests/test_redaction_presets.py
98
99
  tests/test_route_template_cardinality.py
99
100
  tests/test_runtime_control_auth.py
101
+ tests/test_security_audit_fixes.py
100
102
  tests/test_security_redaction.py
101
103
  tests/test_sink_circuit_breaker.py
102
104
  tests/test_sinks.py
@@ -0,0 +1 @@
1
+ __version__ = "0.4.1"
@@ -145,7 +145,7 @@ def _build_structured_error(
145
145
  def _generate_error_fingerprint(error_type: str, stacktrace: str) -> str:
146
146
  """Hash stack trace after stripping out environment-specific noise."""
147
147
  if not stacktrace:
148
- return hashlib.md5(error_type.encode("utf-8")).hexdigest()
148
+ return hashlib.sha256(error_type.encode("utf-8")).hexdigest()[:32]
149
149
 
150
150
  # Strip hexadecimal memory addresses
151
151
  sanitized = re.sub(r"0x[0-9a-fA-F]+", "0x<ptr>", stacktrace)
@@ -154,4 +154,4 @@ def _generate_error_fingerprint(error_type: str, stacktrace: str) -> str:
154
154
 
155
155
  # Combine error type and sanitized trace to ensure distinct groupings
156
156
  payload = f"{error_type}:{sanitized}"
157
- return hashlib.md5(payload.encode("utf-8")).hexdigest()
157
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
@@ -1,11 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from typing import Any
4
5
  from starlette.exceptions import HTTPException as StarletteHTTPException
5
6
  from starlette.types import Scope
6
7
 
7
8
  from ..security import SecurityPolicy, sanitize_event
8
9
 
10
+ _CREDENTIAL_RE = re.compile(r"://[^:]+:[^@]+@")
11
+
12
+
13
+ def _sanitize_exception_message(msg: str, *, max_length: int = 512) -> str:
14
+ """Strip credential patterns (e.g. ``://user:pass@``) from exception text."""
15
+ return _CREDENTIAL_RE.sub("://***:***@", msg)[:max_length]
16
+
9
17
  class _RequestEventBuilder:
10
18
  def __init__(self, policy: SecurityPolicy) -> None:
11
19
  self.policy = policy
@@ -41,7 +49,7 @@ class _RequestEventBuilder:
41
49
  event["response_body"] = response_body
42
50
  if exception is not None:
43
51
  event["exception_class"] = exception.__class__.__name__
44
- event["exception_message"] = str(exception)
52
+ event["exception_message"] = _sanitize_exception_message(str(exception))
45
53
  return sanitize_event(event, self.policy)
46
54
 
47
55
 
@@ -78,4 +86,5 @@ def _extract_route_template(scope: Scope, raw_path: str) -> str:
78
86
  return template
79
87
  return raw_path
80
88
 
81
- __all__ = ["_RequestEventBuilder", "_classify_error", "_extract_route_template"]
89
+ __all__ = ["_RequestEventBuilder", "_classify_error", "_extract_route_template",
90
+ "_sanitize_exception_message"]
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
4
+
3
5
  from starlette.types import Scope
4
6
 
5
7
  from ..security import (
@@ -8,6 +10,8 @@ from ..security import (
8
10
  resolve_client_ip,
9
11
  )
10
12
 
13
+ _LOGGER = logging.getLogger("fastapiobserver.security")
14
+
11
15
 
12
16
  def _extract_scope_client_ip(scope: Scope) -> str | None:
13
17
  client = scope.get("client")
@@ -22,6 +26,19 @@ def _extract_scope_client_ip(scope: Scope) -> str | None:
22
26
  class _IpResolver:
23
27
  def __init__(self, policy: TrustedProxyPolicy) -> None:
24
28
  self.policy = policy
29
+ if policy.honor_forwarded_headers:
30
+ _LOGGER.warning(
31
+ "security.forwarded_headers.enabled",
32
+ extra={
33
+ "event": {
34
+ "message": (
35
+ "X-Forwarded-For trust is enabled. "
36
+ "Ensure trusted_cidrs is locked to your proxy IPs only."
37
+ ),
38
+ },
39
+ "_skip_enrichers": True,
40
+ },
41
+ )
25
42
 
26
43
  def resolve(
27
44
  self,
@@ -57,6 +57,7 @@ GDPR_REDACTED_FIELDS = (
57
57
  "date-of-birth",
58
58
  "ssn",
59
59
  "ip-address",
60
+ "client-ip",
60
61
  "first-name",
61
62
  "last-name",
62
63
  "full-name",
@@ -4,6 +4,8 @@ from typing import Any, Mapping
4
4
  from .normalize import _normalize_key, _normalize_media_type
5
5
  from .policy import _DROP, SecurityPolicy
6
6
 
7
+ MAX_SANITIZE_DEPTH = 32
8
+
7
9
 
8
10
  def _redact_value(value: Any, policy: SecurityPolicy) -> Any:
9
11
  if policy.redaction_mode == "drop":
@@ -49,6 +51,8 @@ def _sanitize_mapping(
49
51
  header_allowlist: set[str] | None,
50
52
  event_key_allowlist: set[str] | None,
51
53
  ) -> dict[str, Any]:
54
+ if depth > MAX_SANITIZE_DEPTH:
55
+ return dict(data)
52
56
  out: dict[str, Any] = {}
53
57
  for key, value in data.items():
54
58
  normalized_key = _normalize_key(str(key))
@@ -0,0 +1,79 @@
1
+ import logging
2
+
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
+
6
+ from examples.time_saved.the_hard_way import app as hard_app
7
+ from examples.time_saved.the_easy_way import app as easy_app
8
+
9
+
10
+ @pytest.fixture
11
+ def hard_client() -> TestClient:
12
+ return TestClient(hard_app)
13
+
14
+
15
+ @pytest.fixture
16
+ def easy_client() -> TestClient:
17
+ return TestClient(easy_app)
18
+
19
+
20
+ def test_dx_parity_metrics_and_logs(
21
+ hard_client: TestClient, easy_client: TestClient, caplog: pytest.LogCaptureFixture
22
+ ) -> None:
23
+ caplog.set_level(logging.INFO)
24
+
25
+ # 1. Fire identical requests
26
+ hard_resp = hard_client.get("/hello/world")
27
+ easy_resp = easy_client.get("/hello/world")
28
+
29
+ assert hard_resp.status_code == 200
30
+ assert easy_resp.status_code == 200
31
+
32
+ # 2. Check JSON Logs
33
+ # Note: Because the 'hard' way uses a custom JSONFormatter that dumps
34
+ # strings, we need to inspect the formatted messages or the test log
35
+ # output. Fortunately, caplog captures records directly!
36
+ # Let's inspect the Raw capout
37
+ records = caplog.records
38
+ assert len(records) >= 4, "Both apps should have emitted log records"
39
+
40
+ # Check "hard way" request log
41
+ hard_request_logs = [r for r in records if getattr(r, "request_info", None)]
42
+ assert len(hard_request_logs) == 1
43
+
44
+ # Check "easy way" request log
45
+ easy_request_logs = [
46
+ r for r in records
47
+ if r.name == "fastapiobserver.middleware" and r.message == "request.completed"
48
+ ]
49
+ assert len(easy_request_logs) == 1
50
+
51
+ # In JSON rendering, these produce identical keys
52
+ # (trace_id, span_id, request_id, event/request...)
53
+ # The true test of parity is the presence of the contextual fields
54
+ assert hasattr(hard_request_logs[0], "request_info")
55
+ assert hasattr(easy_request_logs[0], "event")
56
+
57
+ # 3. Check Prometheus Metrics Parity
58
+ hard_metrics = hard_client.get("/metrics")
59
+ easy_metrics = easy_client.get("/metrics")
60
+
61
+ assert hard_metrics.status_code == 200
62
+ assert easy_metrics.status_code == 200
63
+
64
+ hard_text = hard_metrics.text
65
+ easy_text = easy_metrics.text
66
+
67
+ # Both should have successfully routed the GET /hello/{name}
68
+ # and prevented cardinality explosion via templating.
69
+ # The 'hard_way' hand-rolled labels looks like:
70
+ # `http_requests_total{method="GET",path="/hello/{name}",status="200"}`
71
+ # The 'easy_way' uses standard fastapiobserver prometheus conventions:
72
+ # `http_server_requests_total{...,path="/hello/{name}",status_code="200"}`
73
+
74
+ assert 'path="/hello/{name}"' in hard_text
75
+ assert 'path="/hello/{name}"' in easy_text
76
+
77
+ assert 'status="200"' in hard_text
78
+ assert 'status_code="200"' in easy_text
79
+
@@ -0,0 +1,161 @@
1
+ """Tests for security audit fixes.
2
+
3
+ Covers:
4
+ - GDPR preset redacts `client_ip` event key
5
+ - Exception message credential sanitization
6
+ - Recursion depth limit in sanitize_event
7
+ - Forwarded-headers startup warning log
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ import pytest
14
+
15
+ from fastapiobserver.middleware.events import (
16
+ _RequestEventBuilder,
17
+ _sanitize_exception_message,
18
+ )
19
+ from fastapiobserver.security import SecurityPolicy, sanitize_event
20
+ from fastapiobserver.security.redaction import MAX_SANITIZE_DEPTH
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # P0: GDPR preset redacts `client_ip`
25
+ # ---------------------------------------------------------------------------
26
+
27
+ def test_gdpr_preset_redacts_client_ip() -> None:
28
+ policy = SecurityPolicy.from_preset("gdpr")
29
+ event = {"client_ip": "203.0.113.42", "method": "GET"}
30
+ sanitized = sanitize_event(event, policy)
31
+ assert sanitized["client_ip"].startswith("sha256:")
32
+ assert sanitized["method"] == "GET"
33
+
34
+
35
+ def test_gdpr_preset_redacts_client_ip_inside_nested_dict() -> None:
36
+ policy = SecurityPolicy.from_preset("gdpr")
37
+ event = {"nested": {"client_ip": "10.0.0.1"}}
38
+ sanitized = sanitize_event(event, policy)
39
+ assert sanitized["nested"]["client_ip"].startswith("sha256:")
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # P0: Exception message credential sanitization
44
+ # ---------------------------------------------------------------------------
45
+
46
+ class TestSanitizeExceptionMessage:
47
+ def test_strips_basic_credential_url(self) -> None:
48
+ msg = "Cannot connect to postgres://admin:s3cret@db.example.com:5432/prod"
49
+ result = _sanitize_exception_message(msg)
50
+ assert "s3cret" not in result
51
+ assert "://***:***@" in result
52
+ assert "db.example.com:5432/prod" in result
53
+
54
+ def test_strips_redis_credential_url(self) -> None:
55
+ msg = "redis://default:my_password@redis.local:6379/0"
56
+ result = _sanitize_exception_message(msg)
57
+ assert "my_password" not in result
58
+ assert "://***:***@" in result
59
+
60
+ def test_strips_multiple_credentials(self) -> None:
61
+ msg = (
62
+ "DB: postgres://u1:p1@host1/db "
63
+ "Cache: redis://u2:p2@host2/0"
64
+ )
65
+ result = _sanitize_exception_message(msg)
66
+ assert "p1" not in result
67
+ assert "p2" not in result
68
+ assert result.count("://***:***@") == 2
69
+
70
+ def test_truncates_long_messages(self) -> None:
71
+ msg = "x" * 1000
72
+ result = _sanitize_exception_message(msg, max_length=256)
73
+ assert len(result) == 256
74
+
75
+ def test_preserves_safe_messages(self) -> None:
76
+ msg = "Connection refused on port 8080"
77
+ assert _sanitize_exception_message(msg) == msg
78
+
79
+ def test_event_builder_sanitizes_exception(self) -> None:
80
+ builder = _RequestEventBuilder(SecurityPolicy())
81
+ error = ConnectionError(
82
+ "Failed: postgres://admin:secret123@db.prod.internal:5432/mydb"
83
+ )
84
+ event = builder.build(
85
+ method="GET",
86
+ path="/test",
87
+ status_code=500,
88
+ duration_seconds=0.1,
89
+ client_ip=None,
90
+ request_body=None,
91
+ response_body=None,
92
+ error_type="server_error",
93
+ exception=error,
94
+ )
95
+ assert "secret123" not in event["exception_message"]
96
+ assert "://***:***@" in event["exception_message"]
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # P1: Recursion depth limit
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def test_sanitize_event_handles_deeply_nested_payload() -> None:
104
+ """Ensure sanitize_event doesn't RecursionError on extreme nesting."""
105
+ depth = MAX_SANITIZE_DEPTH + 20 # well beyond the limit
106
+ data: dict = {"leaf": "value"}
107
+ for _ in range(depth):
108
+ data = {"nested": data}
109
+ # Must not raise RecursionError
110
+ result = sanitize_event(data, SecurityPolicy())
111
+ assert isinstance(result, dict)
112
+
113
+
114
+ def test_sanitize_event_still_redacts_within_depth_limit() -> None:
115
+ """Normal nesting (within limit) still redacts correctly."""
116
+ data: dict = {"password": "secret"}
117
+ for _ in range(5):
118
+ data = {"nested": data}
119
+ result = sanitize_event(data, SecurityPolicy())
120
+ # Walk to the innermost level
121
+ inner = result
122
+ for _ in range(5):
123
+ inner = inner["nested"]
124
+ assert inner["password"] == "***"
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # P1: Forwarded-headers startup warning
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def test_ip_resolver_warns_when_forwarded_headers_enabled(caplog: pytest.LogCaptureFixture) -> None:
132
+ from fastapiobserver.middleware.ip import _IpResolver
133
+ from fastapiobserver.security import TrustedProxyPolicy
134
+
135
+ policy = TrustedProxyPolicy(
136
+ enabled=True,
137
+ honor_forwarded_headers=True,
138
+ )
139
+ with caplog.at_level(logging.WARNING, logger="fastapiobserver.security"):
140
+ _IpResolver(policy)
141
+
142
+ assert any(
143
+ "security.forwarded_headers.enabled" in record.message
144
+ for record in caplog.records
145
+ )
146
+
147
+
148
+ def test_ip_resolver_no_warning_when_forwarded_headers_disabled(
149
+ caplog: pytest.LogCaptureFixture
150
+ ) -> None:
151
+ from fastapiobserver.middleware.ip import _IpResolver
152
+ from fastapiobserver.security import TrustedProxyPolicy
153
+
154
+ policy = TrustedProxyPolicy(enabled=True, honor_forwarded_headers=False)
155
+ with caplog.at_level(logging.WARNING, logger="fastapiobserver.security"):
156
+ _IpResolver(policy)
157
+
158
+ assert not any(
159
+ "X-Forwarded-For trust is enabled" in record.message
160
+ for record in caplog.records
161
+ )
@@ -1 +0,0 @@
1
- __version__ = "0.4.0"