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
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,17 @@ jobs:
python-version: "3.13"

- name: Install contract dependencies
run: python -m pip install -r service/requirements.txt
run: python -m pip install -r service/requirements-dev.txt

- name: Validate shared detection contract
run: PYTHONPATH=service python -m unittest discover -s service/tests -p 'test_*.py' -v

- name: Audit Python dependencies
run: service/audit-dependencies.sh

- name: Static security analysis
run: bandit -q -r service/detlab

website:
runs-on: ubuntu-latest

Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
SHELL := /bin/bash

.PHONY: setup dev build start test service-test check
.PHONY: setup dev build start service-run test service-test check

setup:
cd web && npm install
Expand All @@ -14,6 +14,9 @@ build:
start:
cd web && npm run start

service-run:
PYTHONPATH=service uvicorn detlab.api:app --host 127.0.0.1 --port 8000

test:
cd web && npm test

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ What is confirmed in the repository today:
- side-by-side support for Sigma, Splunk SPL, Microsoft Sentinel KQL, Elastic EQL, and Elastic ES|QL
- repository content areas for detections, examples, knowledge, and reports
- a shared [DetLab Detection Content Specification v1](docs/schema/detection-content-spec-v1.md) adapter with source hashing and generated-artifact provenance
- an optional server-side pySigma conversion API with an explicit backend registry, input limits, timeouts, and structured errors
- workbench conversion controls with loading, error, provenance, and stale-output states
- Node-based tests for web copy/config behavior
- Python contract tests covering every authored detection YAML file

Expand Down Expand Up @@ -107,6 +109,7 @@ examples/ Example packs and sample artifacts
knowledge/ Supporting documentation / authored briefs
reports/ Generated or review-oriented outputs
scripts/ Supporting repo scripts
service/ Optional FastAPI + pySigma conversion service
web/ Next.js frontend and workbench
app/ Routes and pages
components/ UI components
Expand All @@ -122,6 +125,7 @@ web/ Next.js frontend and workbench
- Node built-in test runner (`node --test`)
- Markdown/YAML-based detection artifacts
- GitHub API-backed save flow in the workbench
- FastAPI and pinned pySigma backends for optional server-side conversion

## Quick start

Expand All @@ -135,6 +139,17 @@ npm run dev

Open `http://localhost:3000`.

### Optional conversion service

```bash
python3 -m venv .venv
. .venv/bin/activate
pip install -r service/requirements.txt
PYTHONPATH=service uvicorn detlab.api:app --host 127.0.0.1 --port 8000
```

Enter `http://localhost:8000` in the workbench conversion panel. See [`service/README.md`](service/README.md) before exposing the API beyond localhost/LAN.

### Repository-level helpers

```bash
Expand Down
69 changes: 69 additions & 0 deletions service/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# DetLab Sigma conversion service

This optional service executes pySigma conversion outside the static browser application. The workbench stays compatible with GitHub Pages and calls this API only when an operator configures an absolute HTTP(S) origin.

## Security model

- explicit backend registry; request data cannot import Python modules or select arbitrary classes
- safe YAML parsing with maximum depth 20, maximum 20 aliases, and a 10,000-node post-load structure limit
- 256 KiB source limit
- five-second response timeout by default, enforced in a disposable worker process that is terminated on timeout
- conversion results are fully serialized in the worker before publication, so partial generated output is never returned
- narrow CORS allowlist from `DETLAB_CORS_ORIGINS`
- versioned converter provenance and canonical-source SHA-256 in each response
- no credentials accepted in the browser's API-origin field

The service does not provide authentication or global rate limiting. Bind it to localhost/LAN by default and place authentication, TLS, request-rate controls, and stricter body limits at the reverse proxy before any broader exposure.

## Local setup

```bash
python3 -m venv .venv
. .venv/bin/activate
pip install -r service/requirements.txt
PYTHONPATH=service uvicorn detlab.api:app --host 127.0.0.1 --port 8000
```

Configure the workbench with `http://localhost:8000`, or set the build-time default:

```bash
NEXT_PUBLIC_DETLAB_CONVERSION_API=https://conversion.example.test npm run build
```

Set allowed browser origins on the service:

```bash
DETLAB_CORS_ORIGINS=https://detlab.example.test,http://localhost:3000 \
PYTHONPATH=service uvicorn detlab.api:app --host 127.0.0.1 --port 8000
```

## API

- `GET /healthz`
- `GET /v1/backends`
- `POST /v1/convert`

Request:

```json
{
"source": "title: ...",
"target": "splunk"
}
```

Registered targets:

- `splunk`
- `elastic-eql`
- `elastic-esql`
- `microsoft-kusto`

## Tests

```bash
PYTHONPATH=service python -m unittest discover -s service/tests -p 'test_*.py' -v
cd web && npm test && npm run build
```

No endpoint in this service deploys a query to a SIEM or claims live validation.
23 changes: 23 additions & 0 deletions service/SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Service dependency security

## DiskCache advisory (`PYSEC-2026-2447`)

`pySigma==1.4.0` declares `diskcache==5.6.3` as a dependency, although pySigma's
runtime code does not import it and DetLab does not create or read a DiskCache.
The advisory requires both attacker write access to a cache directory and a
subsequent application read that invokes pickle deserialization. DetLab has no
such cache path, so the vulnerable operation is unreachable.

The service's dependency audit ignores only `PYSEC-2026-2447` and must fail for
all other findings. `service/tests/test_dependency_security.py` also starts the
service with imports of `diskcache` blocked, proving the API and converter
registry do not depend on it. Do not add DiskCache use unless it uses a
non-pickle serializer, a service-private non-attacker-writable directory, and a
new security review removes this exception or updates its rationale.

Run the policy-enforcing audit with:

```bash
python3 -m pip install pip-audit
./service/audit-dependencies.sh
```
9 changes: 9 additions & 0 deletions service/audit-dependencies.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env sh
set -eu

# pySigma 1.4.0 declares DiskCache but does not import or use it in the
# conversion service. PYSEC-2026-2447 requires an application to deserialize
# an attacker-written cache entry; this service never creates or reads one.
python3 -m pip_audit \
--requirement "$(dirname "$0")/requirements.txt" \
--ignore-vuln PYSEC-2026-2447
137 changes: 137 additions & 0 deletions service/detlab/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""HTTP API for bounded server-side Sigma conversion."""
from __future__ import annotations

import asyncio
import json
import multiprocessing
import os
import queue
import time
from typing import Any

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from .converter import ConversionError, ConverterService

MAX_SOURCE_BYTES = 262_144


class ConversionWorkerError(RuntimeError):
"""Raised when an isolated conversion worker cannot return a complete result."""


def _conversion_worker(result_queue: Any, service: Any, source: str, target: str) -> None:
try:
# Serialize before publishing so callers can never observe partial output.
payload = json.dumps(service.convert(source, target), separators=(",", ":"))
result_queue.put(("ok", payload))
except ConversionError as exc:
result_queue.put(("conversion_error", str(exc)))
except Exception:
result_queue.put(("worker_error", "Conversion worker failed"))


def _convert_isolated(service: Any, source: str, target: str, timeout: float) -> dict[str, Any]:
context = multiprocessing.get_context("spawn")
result_queue = context.Queue(maxsize=1)
process = context.Process(
target=_conversion_worker,
args=(result_queue, service, source, target),
daemon=True,
)
process.start()
deadline = time.monotonic() + timeout
try:
try:
status, payload = result_queue.get(timeout=max(0.0, deadline - time.monotonic()))
except queue.Empty as exc:
if process.is_alive():
raise TimeoutError("conversion timed out") from exc
raise ConversionWorkerError("Conversion worker returned no result") from exc

process.join(max(0.0, deadline - time.monotonic()))
if process.is_alive():
raise TimeoutError("conversion timed out")
finally:
if process.is_alive():
process.terminate()
process.join(0.5)
if process.is_alive():
process.kill()
process.join()
result_queue.close()
result_queue.join_thread()
if status == "conversion_error":
raise ConversionError(payload)
if status != "ok":
raise ConversionWorkerError(payload)
try:
result = json.loads(payload)
except (TypeError, json.JSONDecodeError) as exc:
raise ConversionWorkerError("Conversion worker returned an invalid result") from exc
if not isinstance(result, dict):
raise ConversionWorkerError("Conversion worker returned an invalid result")
return result


class ConvertRequest(BaseModel):
source: str
target: str


def _error(status_code: int, code: str, message: str) -> HTTPException:
return HTTPException(status_code=status_code, detail={"code": code, "message": message})


def create_app(
*,
converter: Any | None = None,
conversion_timeout_seconds: float = 5.0,
) -> FastAPI:
service = converter or ConverterService()
app = FastAPI(title="DetLab Sigma Conversion API", version="1.0.0")
origins = [value.strip() for value in os.environ.get("DETLAB_CORS_ORIGINS", "http://localhost:3000").split(",") if value.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)

@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}

@app.get("/v1/backends")
async def backends() -> dict[str, Any]:
return {"backends": service.backends()}

@app.post("/v1/convert")
async def convert(request: ConvertRequest) -> dict[str, Any]:
if len(request.source.encode("utf-8")) > MAX_SOURCE_BYTES:
raise _error(413, "source_too_large", f"Sigma source exceeds {MAX_SOURCE_BYTES} bytes")
known_targets = {backend["id"] for backend in service.backends()}
if request.target not in known_targets:
raise _error(422, "unsupported_backend", "Requested conversion backend is not registered")
try:
return await asyncio.to_thread(
_convert_isolated,
service,
request.source,
request.target,
conversion_timeout_seconds,
)
except TimeoutError as exc:
raise _error(504, "conversion_timeout", "Conversion exceeded the configured timeout") from exc
except ConversionError as exc:
raise _error(422, "invalid_sigma", str(exc)) from exc
except ConversionWorkerError as exc:
raise _error(500, "conversion_failed", "Conversion worker failed safely") from exc

return app


app = create_app()
Loading