Перенесены секреты из .env в SM
This commit is contained in:
@@ -13,7 +13,8 @@ COPY --from=builder /build/dist/*.whl /tmp/
|
||||
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
|
||||
COPY alembic.ini ./
|
||||
COPY alembic ./alembic
|
||||
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
|
||||
USER 10001:10001
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["uvicorn"]
|
||||
CMD ["app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-proxy-headers"]
|
||||
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-proxy-headers"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
@@ -6,12 +7,11 @@ from sqlalchemy import pool
|
||||
from alembic import context
|
||||
from app.db import Base
|
||||
from app.postgres import create_postgres_engine
|
||||
from app.settings import get_settings
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name:
|
||||
fileConfig(config.config_file_name)
|
||||
database_url = get_settings().database_url
|
||||
database_url = os.environ["DATABASE_URL"]
|
||||
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
REDACTED = "[REDACTED]"
|
||||
_SENSITIVE_KEY = re.compile(
|
||||
r"(authorization|cookie|password|passwd|secret|token|api[_-]?key|"
|
||||
r"database[_-]?url|redis[_-]?url|dsn|callback[_-]?url)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_URI_USERINFO = re.compile(r"(?P<scheme>[a-z][a-z0-9+.-]*://)[^/@\s]+@", re.IGNORECASE)
|
||||
_QUERY_SECRET = re.compile(
|
||||
r"(?P<prefix>[?&](?:token|access_token|api_key|key|secret|password)=)[^&#\s]+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_AUTH_VALUE = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)
|
||||
|
||||
|
||||
def sanitize_text(value: str) -> str:
|
||||
value = _URI_USERINFO.sub(r"\g<scheme>[REDACTED]@", value)
|
||||
value = _QUERY_SECRET.sub(r"\g<prefix>[REDACTED]", value)
|
||||
return _AUTH_VALUE.sub(r"\1 [REDACTED]", value)
|
||||
|
||||
|
||||
def sanitize_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return sanitize_text(value)
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): REDACTED if _SENSITIVE_KEY.search(str(key)) else sanitize_value(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [sanitize_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(sanitize_value(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def redact_event(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return sanitize_value(event_dict)
|
||||
@@ -50,6 +50,7 @@ from app.integrations import (
|
||||
S3Client,
|
||||
SafetyClient,
|
||||
)
|
||||
from app.logging_security import redact_event
|
||||
from app.metrics import AUTH_BOOTSTRAP, HTTP_DURATION, HTTP_REQUESTS, RATE_LIMIT_DECISIONS
|
||||
from app.notification_routes import router as notification_router
|
||||
from app.notification_service import synchronize_source_tokens
|
||||
@@ -96,6 +97,7 @@ def configure_logging(level: str) -> None:
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
add_trace_context,
|
||||
redact_event,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.JSONRenderer(),
|
||||
@@ -342,7 +344,11 @@ async def http_error(request: Request, exc: StarletteHTTPException):
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_error(request: Request, exc: Exception):
|
||||
log.exception("request.failed", error_code="internal_error")
|
||||
log.error(
|
||||
"request.failed",
|
||||
error_code="internal_error",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return error_response(request, "internal_error", "Internal server error", 500)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
for name in ${HAN_SECRET_VARS:-}; do
|
||||
case "$name" in
|
||||
""|[0-9]*|*[!A-Z0-9_]*)
|
||||
echo "container secrets: invalid variable name" >&2
|
||||
exit 64
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
eval "file=\${${name}_FILE:-}"
|
||||
if [ -z "$file" ] || [ ! -r "$file" ]; then
|
||||
echo "container secrets: missing file for $name" >&2
|
||||
exit 66
|
||||
fi
|
||||
value=$(cat "$file")
|
||||
export "$name=$value"
|
||||
unset "${name}_FILE"
|
||||
done
|
||||
|
||||
unset HAN_SECRET_VARS
|
||||
exec "$@"
|
||||
@@ -5,39 +5,54 @@ services:
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
HAN_SECRET_VARS: >-
|
||||
DATABASE_URL REDIS_URL REDIS_REALTIME_URL MESSAGE_SAFETY_SERVICE_TOKEN
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN BITRIX_API_INBOX_TOKEN
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN SELECTEL_S3_ACCESS_KEY SELECTEL_S3_SECRET_KEY
|
||||
CURSOR_HMAC_SECRET
|
||||
DATABASE_URL_FILE: /run/secrets/api_database_url
|
||||
REDIS_URL_FILE: /run/secrets/api_redis_url
|
||||
REDIS_REALTIME_URL_FILE: /run/secrets/api_redis_realtime_url
|
||||
MESSAGE_SAFETY_SERVICE_TOKEN_FILE: /run/secrets/message_safety_service_token
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN_FILE: /run/secrets/bitrix_local_app_internal_token
|
||||
BITRIX_API_INBOX_TOKEN_FILE: /run/secrets/bitrix_api_inbox_token
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN_FILE: /run/secrets/keycloak_settings_bridge_token
|
||||
SELECTEL_S3_ACCESS_KEY_FILE: /run/secrets/selectel_s3_access_key
|
||||
SELECTEL_S3_SECRET_KEY_FILE: /run/secrets/selectel_s3_secret_key
|
||||
CURSOR_HMAC_SECRET_FILE: /run/secrets/cursor_hmac_secret
|
||||
APP_ENV: ${APP_ENV}
|
||||
API_PORT: ${API_PORT:-8000}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
REDIS_REALTIME_URL: ${REDIS_REALTIME_URL}
|
||||
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL}
|
||||
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM}
|
||||
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE}
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
|
||||
MESSAGE_SAFETY_SERVICE_TOKEN: ${MESSAGE_SAFETY_SERVICE_TOKEN}
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
|
||||
MESSAGE_SAFETY_TASK_POLL_MAX_SEC: ${MESSAGE_SAFETY_TASK_POLL_MAX_SEC:-300}
|
||||
MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD: ${MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD:-5}
|
||||
MESSAGE_SAFETY_CIRCUIT_OPEN_SEC: ${MESSAGE_SAFETY_CIRCUIT_OPEN_SEC:-30}
|
||||
BITRIX_LOCAL_APP_BASE_URL: ${BITRIX_LOCAL_APP_BASE_URL}
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN: ${BITRIX_LOCAL_APP_INTERNAL_TOKEN}
|
||||
BITRIX_API_INBOX_TOKEN: ${BITRIX_API_INBOX_TOKEN}
|
||||
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC: ${BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC:-20}
|
||||
BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD: ${BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD:-5}
|
||||
BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC: ${BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC:-30}
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
|
||||
SELECTEL_S3_ENDPOINT_URL: ${SELECTEL_S3_ENDPOINT_URL}
|
||||
SELECTEL_S3_BUCKET_DOCUMENTS: ${SELECTEL_S3_BUCKET_DOCUMENTS}
|
||||
SELECTEL_S3_BUCKET_ATTACHMENTS: ${SELECTEL_S3_BUCKET_ATTACHMENTS}
|
||||
SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE}
|
||||
SELECTEL_S3_ACCESS_KEY: ${SELECTEL_S3_ACCESS_KEY}
|
||||
SELECTEL_S3_SECRET_KEY: ${SELECTEL_S3_SECRET_KEY}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
|
||||
CURSOR_HMAC_SECRET: ${CURSOR_HMAC_SECRET}
|
||||
NOTIFICATIONS_TOKEN_PRODUCER_TEST: ${NOTIFICATIONS_TOKEN_PRODUCER_TEST}
|
||||
secrets:
|
||||
- api_database_url
|
||||
- api_redis_url
|
||||
- api_redis_realtime_url
|
||||
- message_safety_service_token
|
||||
- bitrix_local_app_internal_token
|
||||
- bitrix_api_inbox_token
|
||||
- keycloak_settings_bridge_token
|
||||
- selectel_s3_access_key
|
||||
- selectel_s3_secret_key
|
||||
- cursor_hmac_secret
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
@@ -48,6 +63,8 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
networks:
|
||||
- backend
|
||||
- observability
|
||||
@@ -55,3 +72,25 @@ services:
|
||||
networks:
|
||||
backend:
|
||||
observability:
|
||||
|
||||
secrets:
|
||||
api_database_url:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/DATABASE_URL
|
||||
api_redis_url:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/REDIS_URL
|
||||
api_redis_realtime_url:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/REDIS_REALTIME_URL
|
||||
message_safety_service_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/MESSAGE_SAFETY_SERVICE_TOKEN
|
||||
bitrix_local_app_internal_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_LOCAL_APP_INTERNAL_TOKEN
|
||||
bitrix_api_inbox_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_API_INBOX_TOKEN
|
||||
keycloak_settings_bridge_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_SETTINGS_BRIDGE_TOKEN
|
||||
selectel_s3_access_key:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/SELECTEL_S3_ACCESS_KEY
|
||||
selectel_s3_secret_key:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/SELECTEL_S3_SECRET_KEY
|
||||
cursor_hmac_secret:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/CURSOR_HMAC_SECRET
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from app.logging_security import REDACTED, redact_event, sanitize_text
|
||||
|
||||
|
||||
def test_redacts_sensitive_fields_recursively() -> None:
|
||||
event = {
|
||||
"authorization": "Bearer top-secret",
|
||||
"nested": {
|
||||
"database_url": "postgresql://user:password@db/app",
|
||||
"safe": "kept",
|
||||
},
|
||||
}
|
||||
|
||||
redacted = redact_event(None, "info", event)
|
||||
|
||||
assert redacted["authorization"] == REDACTED
|
||||
assert redacted["nested"]["database_url"] == REDACTED
|
||||
assert redacted["nested"]["safe"] == "kept"
|
||||
|
||||
|
||||
def test_redacts_credentials_embedded_in_text() -> None:
|
||||
value = (
|
||||
"POST https://callback-user:callback-password@example.test/cb"
|
||||
"?token=query-secret Authorization=Bearer header-secret"
|
||||
)
|
||||
|
||||
redacted = sanitize_text(value)
|
||||
|
||||
assert "callback-password" not in redacted
|
||||
assert "query-secret" not in redacted
|
||||
assert "header-secret" not in redacted
|
||||
assert redacted.count(REDACTED) == 3
|
||||
Reference in New Issue
Block a user