Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
f07e1a0
feat(logging): add shared backend logging package
quarj0 Aug 23, 2026
f19b3f7
feat(logging): centralize sensitive log redaction
quarj0 Aug 23, 2026
720eb3a
feat(logging): expose safe logging to Django and workers
quarj0 Aug 23, 2026
1f8e8b9
feat(logging): expose safe logging to managed AI
quarj0 Aug 23, 2026
1b2fa85
feat(logging): install redaction at Django startup
quarj0 Aug 23, 2026
4cb315f
feat(logging): protect Celery startup logs
quarj0 Aug 23, 2026
0a0359f
feat(logging): install redaction before AI imports
quarj0 Aug 23, 2026
3e3e62d
build(logging): include shared redaction package in Django image
quarj0 Aug 23, 2026
f2e80eb
fix(build): preserve apt cleanup path
quarj0 Aug 23, 2026
fc7c457
build(logging): include shared redaction package in AI image
quarj0 Aug 23, 2026
bae85b3
test(logging): cover Django Celery and exception redaction
quarj0 Aug 23, 2026
f6d79a4
test(logging): cover managed AI redaction failures
quarj0 Aug 23, 2026
c8f851c
feat(logging): add frontend safe logging boundary
quarj0 Aug 23, 2026
be3f15c
test(logging): add frontend redaction test build config
quarj0 Aug 23, 2026
15c2edc
test(logging): add frontend adversarial redaction tests
quarj0 Aug 23, 2026
e6bbe33
test(logging): fail CI on unsafe frontend console logging
quarj0 Aug 23, 2026
273be68
feat(logging): export frontend safe logger
quarj0 Aug 23, 2026
6b1dbbf
test(logging): enforce safe frontend logging in lint
quarj0 Aug 23, 2026
878335c
docs(logging): document safe telemetry and redaction rules
quarj0 Aug 23, 2026
316a021
fix(logging): harden and optimize record sanitization
quarj0 Aug 23, 2026
9f7fb3d
test(logging): prove provider storage and exception boundaries
quarj0 Aug 23, 2026
dc9bcb8
fix(logging): cover correlated identifiers and multiword PII
quarj0 Aug 23, 2026
b1217a4
fix(logging): redact correlated frontend identifiers
quarj0 Aug 23, 2026
678cb3b
test(logging): cover correlated and multiword PII
quarj0 Aug 23, 2026
2cfaece
test(logging): cover correlated backend identifiers
quarj0 Aug 23, 2026
f3bb888
fix(logging): redact rendered messages without breaking format args
quarj0 Aug 23, 2026
6cae92f
fix(logging): contain malformed format strings safely
quarj0 Aug 23, 2026
23961ed
test(logging): contain malformed format strings
quarj0 Aug 23, 2026
e269172
fix(logging): keep Django safe logging exports aligned
quarj0 Aug 23, 2026
be97df1
fix(logging): keep AI safe logging exports aligned
quarj0 Aug 23, 2026
c72a71d
Fix managed AI redaction acceptance test
quarj0 Aug 23, 2026
6668efb
Route verification portal errors through safe logging
quarj0 Aug 23, 2026
24882cf
Make safe logging lint AST-aware
quarj0 Aug 23, 2026
e31f024
Fix structured log argument redaction before interpolation
quarj0 Sep 11, 2026
25309bf
Address cross-service redaction review findings
quarj0 Sep 11, 2026
80733db
Merge remote-tracking branch 'origin/main' into feat/ic-066-structure…
quarj0 Sep 11, 2026
89c3f7f
Close remaining credential and biometric logging gaps
quarj0 Sep 11, 2026
b2dfd71
Keep document promotion evidence keys out of warnings
quarj0 Sep 11, 2026
1e37258
Cover equivalent console calls and camel case secrets
quarj0 Sep 11, 2026
aacd38b
Close remaining safe logging bypasses
quarj0 Sep 11, 2026
ce6abb5
Preserve sanitized exception reports for email handlers
quarj0 Sep 12, 2026
dc845c2
Preserve sanitized Django request reports
quarj0 Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/ai-service/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ RUN uv export \
&& pip install --no-cache-dir -r /tmp/requirements-ai.txt \
&& pip install --no-cache-dir --force-reinstall opencv-contrib-python-headless==4.10.0.84

COPY backend/shared /app/backend/shared
COPY backend/ai-service /app/backend/ai-service

WORKDIR /app/backend/ai-service
Expand Down
30 changes: 30 additions & 0 deletions backend/ai-service/app/core/safe_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from __future__ import annotations

import sys
from pathlib import Path

_BACKEND_ROOT = Path(__file__).resolve().parents[3]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))

from shared.logging_redaction import ( # noqa: E402
LOG_FORMAT_ERROR,
REDACTED,
REDACTED_BINARY,
install_safe_logging,
is_sensitive_key,
redact_text,
redact_value,
sanitize_log_record,
)

__all__ = [
"LOG_FORMAT_ERROR",
"REDACTED",
"REDACTED_BINARY",
"install_safe_logging",
"is_sensitive_key",
"redact_text",
"redact_value",
"sanitize_log_record",
]
20 changes: 13 additions & 7 deletions backend/ai-service/app/main.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
from fastapi import FastAPI
from app.core.safe_logging import install_safe_logging

from app.core.auth import enforce_internal_token as _enforce_internal_token
from app.routers.health import healthcheck, readiness, router as health_router
from app.routers.processing import (
# Install before importing processing modules so initialization failures and
# exception paths cannot emit raw evidence or credentials.
install_safe_logging()

from fastapi import FastAPI # noqa: E402

from app.core.auth import enforce_internal_token as _enforce_internal_token # noqa: E402
from app.routers.health import healthcheck, readiness, router as health_router # noqa: E402
from app.routers.processing import ( # noqa: E402
document_classify,
document_ocr,
document_quality,
face_compare,
liveness_check,
router as processing_router,
)
from app.runtime import configure_runtime_environment
from app.schemas.processing import (
from app.runtime import configure_runtime_environment # noqa: E402
from app.schemas.processing import ( # noqa: E402
AIResultResponse,
DocumentClassificationRequest,
DocumentOCRRequest,
Expand All @@ -21,7 +27,7 @@
LivenessCheckRequest,
ReadinessResponse,
)
from app.settings import get_settings
from app.settings import get_settings # noqa: E402


settings = get_settings()
Expand Down
4 changes: 1 addition & 3 deletions backend/ai-service/app/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ class ProcessingError(RuntimeError):

class MediaAssetNotFoundError(RuntimeError):
def __init__(self, storage_key: str, bucket_name: str):
message = (
f"Media asset '{storage_key}' was not found in bucket '{bucket_name}'."
)
message = "Media asset was not found in configured storage (media_asset_not_found)."
super().__init__(message)
self.storage_key = storage_key
self.bucket_name = bucket_name
Expand Down
125 changes: 125 additions & 0 deletions backend/ai-service/tests/test_safe_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import io
import logging

from app.core.safe_logging import REDACTED, install_safe_logging, redact_value


def _capture(logger: logging.Logger, formatter: str = "%(message)s"):
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter(formatter))
logger.handlers = [handler]
logger.propagate = False
logger.setLevel(logging.INFO)
return stream


def test_managed_ai_uses_shared_nested_redaction_corpus():
install_safe_logging()
payload = {
"authorization": "Bearer ai-secret",
"request": {
"email": "subject@example.test",
"document_number": "P1234567",
"safe_operation": "face_compare",
},
"biometrics": {
"face_embedding": [0.12, 0.34],
"selfie_image": "base64-selfie",
},
}

redacted = redact_value(payload)

assert redacted["authorization"] == REDACTED
assert redacted["request"]["email"] == REDACTED
assert redacted["request"]["document_number"] == REDACTED
assert redacted["request"]["safe_operation"] == "face_compare"
# A container explicitly named "biometrics" is sensitive as a whole. The
# redactor intentionally fails closed instead of retaining its structure.
assert redacted["biometrics"] == REDACTED


def test_managed_ai_logger_redacts_structured_context_and_exception_text():
install_safe_logging()
logger = logging.getLogger("identitycore.ai.redaction-test")
stream = _capture(logger, "%(message)s context=%(context)s")

try:
raise ValueError(
"api_key=provider-secret email=subject@example.test "
"image_base64=raw-biometric"
)
except ValueError:
logger.exception(
"AI processing failed Authorization: Bearer %s",
"internal-shared-token",
extra={
"context": {
"document_storage_key": "tenant/evidence/document.jpg",
"ocr_text": "raw OCR contents",
"operation": "document_ocr",
}
},
)

output = stream.getvalue()
assert "provider-secret" not in output
assert "subject@example.test" not in output
assert "raw-biometric" not in output
assert "internal-shared-token" not in output
assert "tenant/evidence/document.jpg" not in output
assert "raw OCR contents" not in output
assert "document_ocr" in output
assert "Traceback" in output


def test_uvicorn_access_formatter_preserves_protocol_without_private_data():
from uvicorn.logging import AccessFormatter

install_safe_logging()
logger = logging.getLogger("uvicorn.access")
previous = (logger.handlers[:], logger.propagate, logger.level)
stream = _capture(logger)
logger.handlers[0].setFormatter(
AccessFormatter(
'%(client_addr)s - "%(request_line)s" %(status_code)s', use_colors=False
)
)
try:
logger.info(
'%s - "%s %s HTTP/%s" %d',
"192.0.2.1:1234",
"GET",
"/check?access_token=private-token",
"1.1",
200,
)
output = stream.getvalue()
assert "GET" in output
assert "200 OK" in output
assert "private-token" not in output
assert "192.0.2.1" not in output
assert "HTTP/1.1" in output
finally:
logger.handlers, logger.propagate, logger.level = previous


def test_numpy_buffers_do_not_render_pixels_or_embeddings():
import numpy as np
from app.core.safe_logging import REDACTED_BINARY

for value in (
np.array([17, 18], dtype=np.uint8),
np.array([0.125, 0.25], dtype=np.float32),
):
assert redact_value({"frame": value}) == {"frame": REDACTED_BINARY}


def test_missing_media_error_does_not_expose_evidence_location():
from app.pipeline import MediaAssetNotFoundError

error = MediaAssetNotFoundError("tenant/evidence/private.jpg", "private-bucket")
assert "tenant/evidence/private.jpg" not in str(error)
assert "private-bucket" not in str(error)
assert "media_asset_not_found" in str(error)
1 change: 1 addition & 0 deletions backend/django/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ RUN uv export \
--output-file /tmp/requirements-django.txt \
&& pip install --no-cache-dir -r /tmp/requirements-django.txt

COPY backend/shared /app/backend/shared
COPY backend/django /app/backend/django
COPY docs/openapi/identitycore-public-api.yaml /app/docs/openapi/identitycore-public-api.yaml

Expand Down
7 changes: 7 additions & 0 deletions backend/django/apps/core/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,10 @@ class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"
label = "core"

def ready(self) -> None:
# Install the redaction boundary after Django configures logging but before
# request/worker code can emit application records.
from common.safe_logging import install_safe_logging

install_safe_logging()
6 changes: 3 additions & 3 deletions backend/django/apps/identity_documents/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,10 +563,10 @@ def process_identity_document_task(identity_document_id: str) -> str:
promote_upload_to_media_by_storage_key(capture.storage_key)
except Exception as exc:
logger.warning(
"Failed to promote document upload %s for verification %s: %s",
capture.storage_key,
"Failed to promote document upload for verification %s (%s)",
verification.public_id,
exc,
type(exc).__name__,
extra={"storage_key": capture.storage_key},
)
record_audit_event(
tenant=verification.tenant,
Expand Down
5 changes: 4 additions & 1 deletion backend/django/apps/identity_documents/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,10 @@ def test_process_identity_document_task_keeps_processing_when_promotion_fails(
}
mock_promote.side_effect = RuntimeError("storage unavailable")

result = process_identity_document_task(self.identity_document.public_id)
with self.assertLogs("apps.identity_documents.tasks", level="WARNING") as captured:
result = process_identity_document_task(self.identity_document.public_id)
self.assertNotIn(self.upload.storage_key, " ".join(captured.output))
self.assertEqual(captured.records[0].storage_key, "[REDACTED]")

self.assertEqual(result, IdentityDocumentStatus.PROCESSED)
self.identity_document.refresh_from_db()
Expand Down
30 changes: 30 additions & 0 deletions backend/django/common/safe_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from __future__ import annotations

import sys
from pathlib import Path

_BACKEND_ROOT = Path(__file__).resolve().parents[2]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))

from shared.logging_redaction import ( # noqa: E402
LOG_FORMAT_ERROR,
REDACTED,
REDACTED_BINARY,
install_safe_logging,
is_sensitive_key,
redact_text,
redact_value,
sanitize_log_record,
)

__all__ = [
"LOG_FORMAT_ERROR",
"REDACTED",
"REDACTED_BINARY",
"install_safe_logging",
"is_sensitive_key",
"redact_text",
"redact_value",
"sanitize_log_record",
]
Loading
Loading