Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 1 addition & 3 deletions .clusterfuzzlite/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
FROM gcr.io/oss-fuzz-base/base-builder-python:v1
RUN python3 -m pip install --upgrade pip
RUN python3 -m pip install numpy==2.0.0
FROM gcr.io/oss-fuzz-base/base-builder-python:v1@sha256:6b2b3a7e4a2da50de47f94925cffbe34747e1aae4f33d7f07ba6c681dd648b23
COPY . $SRC/SignalGate
WORKDIR $SRC/SignalGate
COPY .clusterfuzzlite/build.sh $SRC/build.sh
11 changes: 7 additions & 4 deletions .clusterfuzzlite/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
# ClusterFuzzLite build script.
# Builds Python fuzzers using OSS-Fuzz helper.

python3 -m pip install --no-cache-dir atheris==2.3.0
python3 -m pip install \
--no-cache-dir \
--require-hashes \
-r .clusterfuzzlite/requirements.txt

# Install the project (best-effort). If the repo is not installable as a package,
# fallback to using source imports via PYTHONPATH in fuzzers.
python3 -m pip install --no-cache-dir -e . || true
# Make the project root visible to PyInstaller so the packaged fuzzer includes
# the production sanitizer module instead of relying on a runtime source path.
export PYTHONPATH="$PWD${PYTHONPATH:+:$PYTHONPATH}"

# Build fuzz targets.
for fuzzer in fuzz/fuzz_*.py; do
Expand Down
8 changes: 8 additions & 0 deletions .clusterfuzzlite/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
atheris==2.3.0 \
--hash=sha256:005bde4b5a70e998b7fa097e9aa195972dcc2e04092156a0149cff7aa0de970e \
--hash=sha256:6d4e83c8a518e7f7d1c82ee52453255bf6309b7b335657534e83747d4fdfa110 \
--hash=sha256:9877b6c2bd5386f9fbbb2989a939c717383d9639f5476411c06fe50fe2fe09a6 \
--hash=sha256:b91cb296d60915c3efa4f6db48f09c4678b574cddb7ca98035f1cb9d9fb96f64 \
--hash=sha256:c353952ee375bf851527e8b2ea57fdefd7ad16aadfe18143801998075485eccd \
--hash=sha256:cf1fdf5fa220a41a2f262b32363fc566549502b2cb0addf4e1baad5531c0e825 \
--hash=sha256:e4e43d1ee4760916a84ff73c9c6cf9ac6eee80fc030479bbed43fe0b8e994981
2 changes: 2 additions & 0 deletions .github/workflows/clusterfuzzlite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ jobs:
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1
with:
language: python
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Run fuzzers
uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1
with:
mode: code-change
fuzz-seconds: 60
github-token: ${{ secrets.GITHUB_TOKEN }}
13 changes: 7 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
FROM python:3.11-slim-bookworm AS runtime
FROM python:3.11-slim-bookworm@sha256:0bee7276f83efd4a1ee05bbbf4281d95ed28e079220a9457f25a93e3f1e3c31b AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
SIGNALGATE_CONFIG_PATH=/app/config/config.json

RUN addgroup --system signalgate \
&& adduser --system --ingroup signalgate --home /app signalgate

WORKDIR /app

COPY pyproject.toml README.md LICENSE ./
COPY docker/requirements.txt /app/requirements.txt
RUN python -m pip install --no-cache-dir --require-hashes -r /app/requirements.txt

COPY signalgate ./signalgate
COPY docs ./docs

RUN python -m pip install --no-cache-dir --upgrade pip \
&& python -m pip install --no-cache-dir . \
&& mkdir -p /app/config /app/data \
RUN mkdir -p /app/config /app/data \
&& chown -R signalgate:signalgate /app

USER signalgate
Expand All @@ -26,4 +27,4 @@ VOLUME ["/app/config", "/app/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/healthz', timeout=2).read()"

CMD ["signalgate"]
CMD ["python", "-m", "signalgate.cli"]
550 changes: 250 additions & 300 deletions docker/requirements.txt

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions fuzz/fuzz_sanitize_for_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# Ensure local imports work if the package install fails.
sys.path.insert(0, os.getcwd())

from signalgate.app import _sanitize_for_client # noqa: E402
from signalgate.client_sanitize import sanitize_for_client # noqa: E402


def TestOneInput(data: bytes) -> None:
Expand All @@ -17,7 +17,7 @@ def TestOneInput(data: bytes) -> None:
except Exception:
return

_sanitize_for_client(obj)
sanitize_for_client(obj)


def main() -> None:
Expand Down
54 changes: 3 additions & 51 deletions signalgate/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .budgets import BudgetManager
from .canary import CanaryConfig, is_canary_user
from .classifier import KNNTierClassifier
from .client_sanitize import sanitize_for_client
from .costing import compute_cost, savings_percent
from .embeddings import Embedder, build_embedder
from .errors import SGError, sg_bad_request, sg_payload_too_large, sg_queue_full, sg_unauthorized
Expand All @@ -39,55 +40,6 @@
logger = logging.getLogger("signalgate")


_STACKTRACE_MARKERS = (
'Traceback (most recent call last):',
' File "',
)

_BANNED_ERROR_KEYS = {
'trace',
'traceback',
'stack',
'stacktrace',
'stack_trace',
'exc',
'exception',
'exc_info',
'debug_trace',
'decision_trace',
}


def _sanitize_for_client(obj: Any) -> Any:
"""Best-effort scrubber to prevent leaking stack traces or internal debug fields."""

if obj is None:
return None

if isinstance(obj, str):
if any(m in obj for m in _STACKTRACE_MARKERS):
return 'redacted'
return obj

if isinstance(obj, (int, float, bool)):
return obj

if isinstance(obj, list):
return [_sanitize_for_client(x) for x in obj]

if isinstance(obj, dict):
out: dict[Any, Any] = {}
for k, v in obj.items():
ks = str(k).lower()
if ks in _BANNED_ERROR_KEYS:
continue
out[k] = _sanitize_for_client(v)
return out

# Fallback for unknown types
return str(obj)


def _percentile(values: list[int], pct: float) -> int | None:
if not values:
return None
Expand Down Expand Up @@ -426,7 +378,7 @@ async def _sg_error_handler(_req: Request, exc: SGError):
},
},
}
return JSONResponse(status_code=exc.status_code, content=_sanitize_for_client(body))
return JSONResponse(status_code=exc.status_code, content=sanitize_for_client(body))

@app.exception_handler(Exception)
async def _unhandled_exception_handler(_req: Request, exc: Exception):
Expand Down Expand Up @@ -461,7 +413,7 @@ async def _unhandled_exception_handler(_req: Request, exc: Exception):
},
},
}
return JSONResponse(status_code=500, content=_sanitize_for_client(body))
return JSONResponse(status_code=500, content=sanitize_for_client(body))

@app.get("/healthz")
async def healthz():
Expand Down
49 changes: 49 additions & 0 deletions signalgate/client_sanitize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Lightweight client-error sanitization safe for isolated fuzz packaging."""

from typing import Any

_STACKTRACE_MARKERS = (
'Traceback (most recent call last):',
' File "',
)

_BANNED_ERROR_KEYS = {
'trace',
'traceback',
'stack',
'stacktrace',
'stack_trace',
'exc',
'exception',
'exc_info',
'debug_trace',
'decision_trace',
}


def sanitize_for_client(obj: Any) -> Any:
"""Best-effort scrubber to prevent leaking stack traces or internal debug fields."""

if obj is None:
return None

if isinstance(obj, str):
if any(marker in obj for marker in _STACKTRACE_MARKERS):
return 'redacted'
return obj

if isinstance(obj, (int, float, bool)):
return obj

if isinstance(obj, list):
return [sanitize_for_client(item) for item in obj]

if isinstance(obj, dict):
out: dict[Any, Any] = {}
for key, value in obj.items():
if str(key).lower() in _BANNED_ERROR_KEYS:
continue
out[key] = sanitize_for_client(value)
return out

return str(obj)
18 changes: 18 additions & 0 deletions tests/unit/test_security_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest

from signalgate.client_sanitize import sanitize_for_client
from signalgate.errors import SGError
from signalgate.security import (
SecurityConfig,
Expand All @@ -11,6 +12,23 @@
)


def test_client_sanitizer_removes_nested_debug_data() -> None:
sanitized = sanitize_for_client(
{
"message": "safe",
"details": [
{"stack_trace": "private", "reason": "still safe"},
'Traceback (most recent call last): private',
],
}
)

assert sanitized == {
"message": "safe",
"details": [{"reason": "still safe"}, "redacted"],
}


def test_tokens_equal_accepts_only_exact_match() -> None:
assert tokens_equal("secret-token", "secret-token")
assert not tokens_equal("secret-token", "secret-token-x")
Expand Down
27 changes: 12 additions & 15 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.