mirror of
https://github.com/FunnyWolf/agentic-soc-platform.git
synced 2026-08-22 13:12:56 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be3e9b48a5 | ||
|
|
575997a950 | ||
|
|
5191cb444f | ||
|
|
5fb56d100e | ||
|
|
1b3a31c8eb | ||
|
|
0ca5cf5e8e | ||
|
|
f978adecb3 | ||
|
|
d9551180e7 | ||
|
|
3a0f048a84 | ||
|
|
101ebfa0b5 | ||
|
|
a5cb9f8c59 | ||
|
|
02c3ab138f | ||
|
|
8d45860170 | ||
|
|
be83c727ec | ||
|
|
5f1660c2e4 | ||
|
|
ddee6cb2f7 | ||
|
|
3d6eb240c8 | ||
|
|
d314489cfb | ||
|
|
24896701bc | ||
|
|
0a2880f266 | ||
|
|
7465c5684d | ||
|
|
7191f8cb74 | ||
|
|
2db249e092 | ||
|
|
23350fa275 | ||
|
|
c3497ef84c | ||
|
|
1b12e505ae | ||
|
|
f860bf910f | ||
|
|
dcc499bc06 | ||
|
|
f8573f6fcf | ||
|
|
fdb0ac1fcb | ||
|
|
5c069f89df | ||
|
|
af780af83f | ||
|
|
649e9d0449 | ||
|
|
a08c67d8ec | ||
|
|
6ef92fd1ec | ||
|
|
74ff007ec8 | ||
|
|
511f0a340d | ||
|
|
aca59879cb | ||
|
|
5d367cf8e5 | ||
|
|
6534f24e2b | ||
|
|
9432270db5 | ||
|
|
aa8d832377 | ||
|
|
141acbb8d1 | ||
|
|
987edd6d65 | ||
|
|
2be37f4fb6 | ||
|
|
affe53435f | ||
|
|
d02b3fe1a8 | ||
|
|
5b8b2a127b | ||
|
|
0c7bacea7f | ||
|
|
b570858d0f | ||
|
|
af9a528b7d | ||
|
|
7e95846db7 | ||
|
|
6dc0abf891 | ||
|
|
d71b99ca67 | ||
|
|
1622a325da | ||
|
|
6f18bd367a | ||
|
|
b9703a15df | ||
|
|
527559ec5e | ||
|
|
5786830f0d | ||
|
|
987fc929c9 | ||
|
|
2a3fdd8c08 | ||
|
|
0d05e06317 | ||
|
|
d6e9c835e0 | ||
|
|
9e794e637c | ||
|
|
f01d725b3b | ||
|
|
9e943602e7 | ||
|
|
44e9e51f10 | ||
|
|
fd6abe585f | ||
|
|
17170e0d2c | ||
|
|
04ad30e97e | ||
|
|
90ac8385e0 | ||
|
|
5cc4992d08 | ||
|
|
aa75eed5e3 | ||
|
|
4fc5f1047a | ||
|
|
bb775738a8 | ||
|
|
4059d95498 | ||
|
|
26c581cbca | ||
|
|
f9f13bd5b5 | ||
|
|
9ce796d187 | ||
|
|
1d14d53ac2 | ||
|
|
ac408b5b48 | ||
|
|
cef99c4b11 | ||
|
|
38e5782149 | ||
|
|
c647b5043b | ||
|
|
0258939037 | ||
|
|
44af8c15e3 | ||
|
|
c11a5fca63 | ||
|
|
fae594ccd7 |
@@ -11,6 +11,18 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-consistency:
|
||||
name: Release consistency
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: python deploy/release_tool.py check
|
||||
|
||||
backend:
|
||||
name: Backend
|
||||
runs-on: ubuntu-latest
|
||||
@@ -56,15 +68,16 @@ jobs:
|
||||
RUSTFS_SECRET_KEY: ci-rustfs-secret
|
||||
RUSTFS_BUCKET: asp
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- run: uv sync --frozen
|
||||
- run: uv run python manage.py check
|
||||
- run: uv run python manage.py spectacular --file /tmp/asp-openapi.yaml
|
||||
- run: uv run python manage.py test
|
||||
|
||||
frontend:
|
||||
@@ -74,12 +87,12 @@ jobs:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -93,7 +106,7 @@ jobs:
|
||||
name: Compose package
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Render Compose config
|
||||
working-directory: deploy/asp-compose
|
||||
run: |
|
||||
@@ -104,11 +117,15 @@ jobs:
|
||||
run: bash ./deploy/package-asp-compose.sh --version 0.0.0-ci --output-dir dist-ci
|
||||
- name: Validate package contents
|
||||
run: |
|
||||
archive_path="dist-ci/asp-compose-0.0.0-ci.tar.gz"
|
||||
archive_path="dist-ci/asp-compose.tar.gz"
|
||||
test -f "$archive_path"
|
||||
tar -tzf "$archive_path" >/dev/null
|
||||
rm -rf dist-ci/unpacked
|
||||
mkdir -p dist-ci/unpacked
|
||||
tar -xzf "$archive_path" -C dist-ci/unpacked
|
||||
test -x dist-ci/unpacked/asp-compose/scripts/upgrade.sh
|
||||
test -f dist-ci/unpacked/asp-compose/scripts/doctor.sh
|
||||
test ! -e dist-ci/unpacked/asp-compose/.env
|
||||
if find dist-ci/unpacked -type f | grep -E 'custom/(modules|playbooks)/.+\.py$|custom/data/(modules|siem|playbooks)/.+\.(json|ya?ml|md)$'; then
|
||||
echo "Release custom template contains development samples." >&2
|
||||
exit 1
|
||||
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
- python
|
||||
- javascript-typescript
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: github/codeql-action/init@v3
|
||||
- uses: actions/checkout@v7
|
||||
- uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- uses: github/codeql-action/analyze@v3
|
||||
- uses: github/codeql-action/analyze@v4
|
||||
|
||||
@@ -43,8 +43,8 @@ jobs:
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Resolve image tags
|
||||
id: tags
|
||||
shell: bash
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v6
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.file }}
|
||||
|
||||
@@ -15,47 +15,19 @@ jobs:
|
||||
name: Prepare release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
backend_image: ${{ steps.images.outputs.backend_image }}
|
||||
frontend_image: ${{ steps.images.outputs.frontend_image }}
|
||||
release_doc_url: ${{ steps.release_docs.outputs.release_doc_url }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
backend_image: ${{ steps.release.outputs.backend_image }}
|
||||
frontend_image: ${{ steps.release.outputs.frontend_image }}
|
||||
release_doc_url: ${{ steps.release.outputs.release_doc_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Resolve version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
if [ -z "$version" ] || [ "$version" = "$GITHUB_REF_NAME" ]; then
|
||||
echo "Tag must use v<version> format." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Resolve image names
|
||||
id: images
|
||||
shell: bash
|
||||
run: |
|
||||
owner="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
repo="${GITHUB_REPOSITORY#*/}"
|
||||
repo="${repo,,}"
|
||||
echo "backend_image=ghcr.io/${owner}/${repo}/asp-backend:${{ steps.version.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_image=ghcr.io/${owner}/${repo}/asp-frontend:${{ steps.version.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
- name: Resolve release docs URL
|
||||
id: release_docs
|
||||
run: |
|
||||
python - <<'PY' >> "$GITHUB_OUTPUT"
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
version = "${{ steps.version.outputs.version }}"
|
||||
config = json.loads(Path("deploy/release-docs.json").read_text(encoding="utf-8"))
|
||||
slug = config.get("releases", {}).get(version)
|
||||
if not slug:
|
||||
raise SystemExit(f"Missing asf-doc release mapping for version {version} in deploy/release-docs.json")
|
||||
base_url = str(config["base_url"]).rstrip("/")
|
||||
print(f"release_doc_url={base_url}/{slug}/")
|
||||
PY
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
- name: Check release manifest
|
||||
run: python deploy/release_tool.py check --tag "$GITHUB_REF_NAME"
|
||||
- name: Resolve release outputs
|
||||
id: release
|
||||
run: python deploy/release_tool.py show --format github-output >> "$GITHUB_OUTPUT"
|
||||
|
||||
images:
|
||||
name: Build and push images
|
||||
@@ -73,7 +45,7 @@ jobs:
|
||||
- prepare
|
||||
- images
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build Compose package
|
||||
run: |
|
||||
bash ./deploy/package-asp-compose.sh \
|
||||
@@ -83,12 +55,14 @@ jobs:
|
||||
--frontend-image "${{ needs.prepare.outputs.frontend_image }}"
|
||||
- name: Validate package
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
archive_path="dist/asp-compose-$version.tar.gz"
|
||||
archive_path="dist/asp-compose.tar.gz"
|
||||
test -f "$archive_path"
|
||||
tar -tzf "$archive_path" >/dev/null
|
||||
rm -rf dist/unpacked
|
||||
mkdir -p dist/unpacked
|
||||
tar -xzf "$archive_path" -C dist/unpacked
|
||||
test -x dist/unpacked/asp-compose/scripts/upgrade.sh
|
||||
test ! -e dist/unpacked/asp-compose/.env
|
||||
grep -F "${{ needs.prepare.outputs.backend_image }}" dist/unpacked/asp-compose/.env.example
|
||||
grep -F "${{ needs.prepare.outputs.frontend_image }}" dist/unpacked/asp-compose/.env.example
|
||||
if find dist/unpacked -type f | grep -E 'custom/(modules|playbooks)/.+\.py$|custom/data/(modules|siem|playbooks)/.+\.(json|ya?ml|md)$'; then
|
||||
@@ -97,7 +71,6 @@ jobs:
|
||||
fi
|
||||
- name: Write release body
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
cat > dist/release-body.md <<EOF
|
||||
## Release notes
|
||||
|
||||
@@ -105,7 +78,7 @@ jobs:
|
||||
|
||||
## Downloads
|
||||
|
||||
- asp-compose-$version.tar.gz
|
||||
- asp-compose.tar.gz
|
||||
|
||||
## Container images
|
||||
|
||||
@@ -113,10 +86,10 @@ jobs:
|
||||
- \`${{ needs.prepare.outputs.frontend_image }}\`
|
||||
EOF
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
body_path: dist/release-body.md
|
||||
files: dist/asp-compose-${{ needs.prepare.outputs.version }}.tar.gz
|
||||
files: dist/asp-compose.tar.gz
|
||||
|
||||
publish-cli:
|
||||
name: Publish CLI to PyPI
|
||||
@@ -134,11 +107,11 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Check CLI version matches release tag
|
||||
|
||||
+3
-1
@@ -13,10 +13,12 @@ build/
|
||||
*.sqlite3
|
||||
media/
|
||||
staticfiles/
|
||||
backend/perf-results/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
/deploy/asp-compose/compose.override.yaml
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -29,7 +31,7 @@ node_modules/
|
||||
|
||||
# Local git worktrees
|
||||
.worktrees/
|
||||
#/asf-doc/
|
||||
/asf-doc/
|
||||
#/asp-marketplace/
|
||||
|
||||
# OS
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "asp-doc"]
|
||||
path = asp-doc
|
||||
url = https://github.com/FunnyWolf/asp-doc.git
|
||||
[submodule "asp-marketplace"]
|
||||
path = asp-marketplace
|
||||
url = https://github.com/FunnyWolf/asp-marketplace.git
|
||||
Submodule
+1
Submodule asp-doc added at 996d0c7d32
Submodule
+1
Submodule asp-marketplace added at 164bdcdd41
@@ -10,6 +10,8 @@ POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_HOST=aspsirp.com
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_CONN_MAX_AGE=0
|
||||
POSTGRES_CONN_HEALTH_CHECKS=true
|
||||
|
||||
# Redis cache settings.
|
||||
REDIS_HOST=aspsirp.com
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& pip install --no-cache-dir uv
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ def ldap_authenticates(username, password, config=None):
|
||||
try:
|
||||
from ldap3 import SUBTREE, Connection, Server
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
except ImportError as exc:
|
||||
logger.warning("LDAP login failed for %s: ldap3 is not installed", username, exc_info=exc)
|
||||
except ImportError:
|
||||
logger.warning("LDAP login failed for %s: ldap3 is not installed", username, exc_info=True)
|
||||
return False
|
||||
|
||||
connections = []
|
||||
@@ -146,8 +146,9 @@ def test_ldap_config(config, *, test_username="", test_password=""):
|
||||
try:
|
||||
from ldap3 import Connection, Server
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
except ImportError as exc:
|
||||
return {"success": False, "detail": f"ldap3 is not installed: {exc}", "response_preview": ""}
|
||||
except ImportError:
|
||||
logger.warning("LDAP test failed: ldap3 is not installed", exc_info=True)
|
||||
return {"success": False, "detail": "LDAP support is not installed on the server.", "response_preview": ""}
|
||||
|
||||
try:
|
||||
server = Server(config["server_uri"])
|
||||
@@ -158,6 +159,6 @@ def test_ldap_config(config, *, test_username="", test_password=""):
|
||||
conn = Connection(server, auto_bind=True, **bind_kwargs)
|
||||
conn.unbind()
|
||||
return {"success": True, "detail": "LDAP bind succeeded.", "response_preview": ""}
|
||||
except LDAPException as exc:
|
||||
return {"success": False, "detail": f"LDAP bind failed: {exc}", "response_preview": ""}
|
||||
|
||||
except LDAPException:
|
||||
logger.warning("LDAP bind test failed", exc_info=True)
|
||||
return {"success": False, "detail": "LDAP bind failed.", "response_preview": ""}
|
||||
|
||||
@@ -30,9 +30,25 @@ def serialize_case(case, *, include_related=False):
|
||||
}
|
||||
if include_related:
|
||||
data["alerts"] = [serialize_alert(alert, include_related=False) for alert in case.alerts.all()[:50]]
|
||||
data["relationships"] = [
|
||||
serialize_case_relationship(relationship, case)
|
||||
for relationship in _case_relationships(case)[:50]
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
def _case_relationships(case):
|
||||
from apps.cases.services import relationships_for_case
|
||||
|
||||
return relationships_for_case(case)
|
||||
|
||||
|
||||
def serialize_case_relationship(relationship, case):
|
||||
from apps.cases.services import relationship_for_case_payload
|
||||
|
||||
return relationship_for_case_payload(relationship, case)
|
||||
|
||||
|
||||
def serialize_alert(alert, *, include_related=False):
|
||||
data = {
|
||||
"alert_id": alert.alert_id,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
class AgentSIEMValidationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(username="agent", password="password")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
def test_keyword_search_backend_value_error_returns_generic_bad_request(self):
|
||||
payload = {
|
||||
"keyword": "powershell",
|
||||
"index_name": 'main" | delete index=* | search index="x',
|
||||
"time_range_start": "2026-06-23T12:00:00Z",
|
||||
"time_range_end": "2026-06-23T13:00:00Z",
|
||||
}
|
||||
internal_detail = "Traceback in /opt/asp/custom/secrets.py: Invalid Splunk index name"
|
||||
|
||||
with patch("apps.agent_api.views.siem_service.keyword_search", side_effect=ValueError(internal_detail)):
|
||||
response = self.client.post("/api/agent/v1/siem/search/keyword/", payload, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["detail"], "Invalid SIEM request.")
|
||||
self.assertNotIn("Traceback", str(response.data))
|
||||
self.assertNotIn("/opt/asp/custom", str(response.data))
|
||||
@@ -9,6 +9,8 @@ from .views import (
|
||||
CaseAIAnalysisView,
|
||||
CaseDetailView,
|
||||
CaseListView,
|
||||
CaseRelationshipListView,
|
||||
CaseRelationshipSuggestionView,
|
||||
CommentListCreateView,
|
||||
EnrichmentCreateView,
|
||||
FileDetailView,
|
||||
@@ -37,6 +39,8 @@ urlpatterns = [
|
||||
path("cases/", CaseListView.as_view(), name="agent-api-case-list"),
|
||||
path("cases/<str:case_id>/", CaseDetailView.as_view(), name="agent-api-case-detail"),
|
||||
path("cases/<str:case_id>/ai-analysis/", CaseAIAnalysisView.as_view(), name="agent-api-case-ai-analysis"),
|
||||
path("cases/<str:case_id>/relationships/", CaseRelationshipListView.as_view(), name="agent-api-case-relationships"),
|
||||
path("cases/<str:case_id>/relationship-suggestions/", CaseRelationshipSuggestionView.as_view(), name="agent-api-case-relationship-suggestions"),
|
||||
path("alerts/", AlertListView.as_view(), name="agent-api-alert-list"),
|
||||
path("alerts/<str:alert_id>/", AlertDetailView.as_view(), name="agent-api-alert-detail"),
|
||||
path("artifacts/", ArtifactListView.as_view(), name="agent-api-artifact-list"),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
@@ -16,9 +17,15 @@ from apps.artifacts.models import Artifact
|
||||
from apps.attachments.models import Attachment
|
||||
from apps.audit.context import audit_actor
|
||||
from apps.cases.models import Case
|
||||
from apps.cases.services import (
|
||||
relationship_for_case_payload,
|
||||
relationships_for_case,
|
||||
suggest_related_cases,
|
||||
)
|
||||
from apps.comments.models import Comment
|
||||
from apps.comments.services import create_record_comment
|
||||
from apps.common.cursor_pagination import paginate_created_at_cursor
|
||||
from apps.common.operation_timeout import run_with_operation_timeout
|
||||
from apps.common.redis_stream import RedisStreamClient
|
||||
from apps.enrichments.models import Enrichment, EnrichmentProvider
|
||||
from apps.knowledge.models import Knowledge
|
||||
@@ -53,11 +60,14 @@ from .utils import bool_param, list_param, parse_tags, parse_timezone_aware_date
|
||||
API_VERSION = "v1"
|
||||
MIN_CLI_VERSION = "0.1.0"
|
||||
SERVER_VERSION = "0.5.0"
|
||||
logger = logging.getLogger(__name__)
|
||||
FOUNDATION_CAPABILITIES = [
|
||||
"agent.version",
|
||||
"case.list",
|
||||
"case.show",
|
||||
"case.update_ai",
|
||||
"case.relationships",
|
||||
"case.relationship_suggestions",
|
||||
"alert.list",
|
||||
"alert.show",
|
||||
"artifact.list",
|
||||
@@ -175,6 +185,36 @@ class CaseAIAnalysisView(APIView):
|
||||
return agent_response(request, operation="case.update_ai", data=serialize_case(case, include_related=True), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class CaseRelationshipListView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request, case_id):
|
||||
case = _find_case(case_id)
|
||||
page = paginate_created_at_cursor(relationships_for_case(case), request)
|
||||
data = [
|
||||
relationship_for_case_payload(relationship, case)
|
||||
for relationship in page.results
|
||||
]
|
||||
return agent_response(
|
||||
request,
|
||||
operation="case.relationships",
|
||||
data=data,
|
||||
pagination=pagination_meta(page),
|
||||
)
|
||||
|
||||
|
||||
class CaseRelationshipSuggestionView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request, case_id):
|
||||
case = _find_case(case_id)
|
||||
return agent_response(
|
||||
request,
|
||||
operation="case.relationship_suggestions",
|
||||
data=suggest_related_cases(case),
|
||||
)
|
||||
|
||||
|
||||
class AlertListView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@@ -447,7 +487,8 @@ class PlaybookRunView(APIView):
|
||||
user_input=request.data.get("user_input", ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValidationError({"detail": str(exc)}) from exc
|
||||
logger.info("Invalid agent playbook run request", exc_info=True)
|
||||
raise ValidationError({"detail": "Unknown playbook definition."}) from exc
|
||||
return agent_response(request, operation="playbook.run", data=serialize_playbook(playbook), status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@@ -463,7 +504,11 @@ class SIEMKeywordSearchView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
result = siem_service.keyword_search(KeywordSearchInput(**request.data))
|
||||
result = _run_siem_operation(
|
||||
"siem.search.keyword",
|
||||
siem_service.keyword_search,
|
||||
KeywordSearchInput(**request.data),
|
||||
)
|
||||
return agent_response(request, operation="siem.search.keyword", data=_dump(result))
|
||||
|
||||
|
||||
@@ -471,7 +516,11 @@ class SIEMAdaptiveQueryView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
result = siem_service.execute_adaptive_query(AdaptiveQueryInput(**request.data))
|
||||
result = _run_siem_operation(
|
||||
"siem.query.adaptive",
|
||||
siem_service.execute_adaptive_query,
|
||||
AdaptiveQueryInput(**request.data),
|
||||
)
|
||||
return agent_response(request, operation="siem.query.adaptive", data=_dump(result))
|
||||
|
||||
|
||||
@@ -479,7 +528,11 @@ class SIEMDiscoverFieldsView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
result = siem_service.discover_index_fields(DiscoverIndexFieldsInput(**request.data))
|
||||
result = _run_siem_operation(
|
||||
"siem.fields.discover",
|
||||
siem_service.discover_index_fields,
|
||||
DiscoverIndexFieldsInput(**request.data),
|
||||
)
|
||||
return agent_response(request, operation="siem.fields.discover", data=_dump(result))
|
||||
|
||||
|
||||
@@ -487,7 +540,8 @@ class SIEMSPLQueryView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
result = siem_service.execute_spl(SPLQueryInput(**request.data))
|
||||
input_data = SPLQueryInput(**request.data)
|
||||
result = run_with_operation_timeout("siem.query.spl", siem_service.execute_spl, input_data)
|
||||
return agent_response(request, operation="siem.query.spl", data=_dump(result))
|
||||
|
||||
|
||||
@@ -495,7 +549,8 @@ class SIEMESQLQueryView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
result = siem_service.execute_esql(ESQLQueryInput(**request.data))
|
||||
input_data = ESQLQueryInput(**request.data)
|
||||
result = run_with_operation_timeout("siem.query.esql", siem_service.execute_esql, input_data)
|
||||
return agent_response(request, operation="siem.query.esql", data=_dump(result))
|
||||
|
||||
|
||||
@@ -503,14 +558,20 @@ class ThreatIntelQueryView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
indicator = request.data.get("indicator")
|
||||
artifact_type = request.data.get("artifact_type", "Unknown")
|
||||
provider = request.data.get("provider")
|
||||
try:
|
||||
result = query_indicator(
|
||||
request.data.get("indicator"),
|
||||
artifact_type=request.data.get("artifact_type", "Unknown"),
|
||||
provider=request.data.get("provider"),
|
||||
result = run_with_operation_timeout(
|
||||
"threat_intel.query",
|
||||
query_indicator,
|
||||
indicator,
|
||||
artifact_type=artifact_type,
|
||||
provider=provider,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValidationError({"detail": str(exc)}) from exc
|
||||
logger.info("Invalid agent threat intelligence query", exc_info=True)
|
||||
raise ValidationError({"detail": "Invalid threat intelligence query."}) from exc
|
||||
return agent_response(request, operation="ti.query", data=_dump(result))
|
||||
|
||||
|
||||
@@ -518,14 +579,20 @@ class CMDBLookupView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
artifact_type = request.data.get("artifact_type")
|
||||
artifact_value = request.data.get("artifact_value")
|
||||
provider = request.data.get("provider")
|
||||
try:
|
||||
result = lookup_artifact_context(
|
||||
request.data.get("artifact_type"),
|
||||
request.data.get("artifact_value"),
|
||||
provider=request.data.get("provider"),
|
||||
result = run_with_operation_timeout(
|
||||
"cmdb.lookup",
|
||||
lookup_artifact_context,
|
||||
artifact_type,
|
||||
artifact_value,
|
||||
provider=provider,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise ValidationError({"detail": str(exc)}) from exc
|
||||
logger.info("Invalid agent CMDB lookup request", exc_info=True)
|
||||
raise ValidationError({"detail": "Invalid CMDB lookup request."}) from exc
|
||||
return agent_response(request, operation="cmdb.lookup", data=_dump(result))
|
||||
|
||||
|
||||
@@ -748,3 +815,11 @@ def _dump(value):
|
||||
if hasattr(value, "model_dump"):
|
||||
return value.model_dump()
|
||||
return value
|
||||
|
||||
|
||||
def _run_siem_operation(operation, func, input_data):
|
||||
try:
|
||||
return run_with_operation_timeout(operation, func, input_data)
|
||||
except ValueError as exc:
|
||||
logger.info("Invalid agent SIEM request", exc_info=True)
|
||||
raise ValidationError({"detail": "Invalid SIEM request."}) from exc
|
||||
|
||||
@@ -17,6 +17,7 @@ class Command(BaseCommand):
|
||||
self,
|
||||
options=options,
|
||||
worker_name="agentic case analysis",
|
||||
worker_type="case-analysis",
|
||||
run_once=run_case_analysis_once,
|
||||
default_interval=DEFAULT_INTERVAL_SECONDS,
|
||||
sleep_policy=SLEEP_WHEN_IDLE,
|
||||
|
||||
@@ -17,6 +17,7 @@ class Command(BaseCommand):
|
||||
self,
|
||||
options=options,
|
||||
worker_name="agentic module",
|
||||
worker_type="agentic-module",
|
||||
run_once=run_all_modules_once,
|
||||
default_interval=DEFAULT_INTERVAL_SECONDS,
|
||||
sleep_policy=SLEEP_WHEN_IDLE,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.agentic.runtime.monitor import run_playbook_once
|
||||
from apps.agentic.services.playbooks import recover_orphaned_playbook_runs
|
||||
from apps.common.worker_runner import SLEEP_WHEN_IDLE, add_worker_arguments, run_worker
|
||||
|
||||
DEFAULT_INTERVAL_SECONDS = 3.0
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -13,10 +17,14 @@ class Command(BaseCommand):
|
||||
add_worker_arguments(parser, interval_help="Seconds to sleep when no playbook run is pending.")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
recovered = recover_orphaned_playbook_runs()
|
||||
if recovered:
|
||||
logger.warning("Recovered %d orphaned playbook run(s)", recovered)
|
||||
run_worker(
|
||||
self,
|
||||
options=options,
|
||||
worker_name="agentic playbook",
|
||||
worker_type="playbook",
|
||||
run_once=run_playbook_once,
|
||||
default_interval=DEFAULT_INTERVAL_SECONDS,
|
||||
sleep_policy=SLEEP_WHEN_IDLE,
|
||||
|
||||
@@ -7,12 +7,14 @@ from django.conf import settings
|
||||
from django.utils import timezone as django_timezone
|
||||
|
||||
from apps.settings.runtime_config import get_prompt_language
|
||||
from apps.settings.custom_variables import get_custom_variable
|
||||
|
||||
|
||||
class BasePlaybook:
|
||||
NAME = ""
|
||||
DESC = ""
|
||||
TAGS = []
|
||||
RISK_LEVEL = "Low"
|
||||
PROMPT_SLUG = ""
|
||||
SCRIPT_PATH = None
|
||||
|
||||
@@ -24,6 +26,13 @@ class BasePlaybook:
|
||||
def run(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def add_run_message(self, message):
|
||||
if self.playbook_run is None:
|
||||
raise ValueError("Run messages require an active playbook run.")
|
||||
from apps.agentic.services.playbooks import add_playbook_run_message
|
||||
|
||||
add_playbook_run_message(self.playbook_run, message)
|
||||
|
||||
@classmethod
|
||||
def prompt_slug(cls):
|
||||
if cls.PROMPT_SLUG:
|
||||
@@ -43,6 +52,9 @@ class BasePlaybook:
|
||||
raise FileNotFoundError(f"Custom playbook prompt not found: {path}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
def get_variable(self, key):
|
||||
return get_custom_variable(key)
|
||||
|
||||
|
||||
class BaseModule:
|
||||
NAME = ""
|
||||
@@ -53,6 +65,9 @@ class BaseModule:
|
||||
def run(self, message):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_variable(self, key):
|
||||
return get_custom_variable(key)
|
||||
|
||||
|
||||
def parse_event_time(value, default=None):
|
||||
if not value:
|
||||
|
||||
@@ -66,8 +66,9 @@ def scan_module_definitions(*, scripts_dir=None, scripts_dirs=None):
|
||||
for path in paths:
|
||||
try:
|
||||
definition = _definition_from_script(path)
|
||||
except Exception as exc:
|
||||
errors.append({"path": str(path), "error": f"{type(exc).__name__}: {exc}"})
|
||||
except Exception:
|
||||
logger.exception("Failed to load module definition from %s", path)
|
||||
errors.append({"path": str(path), "error": "Failed to load module definition."})
|
||||
continue
|
||||
if definition is not None:
|
||||
definitions.append(definition)
|
||||
|
||||
@@ -23,11 +23,11 @@ def run_playbook_once(*, scripts_dir=None):
|
||||
try:
|
||||
playbook_class = find_playbook_class(playbook_run.name, scripts_dir=scripts_dir)
|
||||
result = playbook_class(playbook_run=playbook_run).run()
|
||||
mark_playbook_success(playbook_run, str(result))
|
||||
except Exception as exc:
|
||||
mark_playbook_failed(playbook_run, exc)
|
||||
return True
|
||||
|
||||
mark_playbook_success(playbook_run, str(result))
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ def _playbook_record(definition):
|
||||
"name": definition.name,
|
||||
"description": getattr(definition.script_class, "DESC", ""),
|
||||
"tags": tags,
|
||||
"risk_level": getattr(definition.script_class, "RISK_LEVEL", "Low"),
|
||||
"path": str(definition.path),
|
||||
"source": _source_for_path(definition.path),
|
||||
}
|
||||
@@ -101,14 +102,15 @@ def _module_record_with_stream_health(definition, *, redis_client=None):
|
||||
record = _module_record(definition)
|
||||
try:
|
||||
record["stream_health"] = _stream_health(definition.stream_name, redis_client=redis_client)
|
||||
except redis.RedisError as exc:
|
||||
except redis.RedisError:
|
||||
logger.exception("Failed to read module stream health for %s", definition.stream_name)
|
||||
record["stream_health"] = {
|
||||
"available": False,
|
||||
"length": 0,
|
||||
"first_id": "",
|
||||
"last_id": "",
|
||||
"groups": [],
|
||||
"warning": f"{type(exc).__name__}: {exc}",
|
||||
"warning": "Stream health is unavailable.",
|
||||
}
|
||||
return record
|
||||
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.agentic.runtime.base import BasePlaybook
|
||||
from apps.agentic.runtime.loader import discover_script_class, iter_overlaid_python_scripts
|
||||
from apps.audit.context import suppress_audit
|
||||
from apps.inbox.notifications import notify_playbook_completion
|
||||
from apps.playbooks.models import Playbook, PlaybookJobStatus
|
||||
from apps.playbooks.models import Playbook, PlaybookJobStatus, PlaybookRunMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLAYBOOK_RISK_LEVELS = {"Low", "Medium", "High", "Critical"}
|
||||
MAX_RUN_MESSAGE_LENGTH = 1000
|
||||
MAX_RUN_REMARK_LENGTH = 2000
|
||||
ORPHANED_RUN_REMARK = "Playbook worker stopped before completion."
|
||||
_AUTHORIZATION_RE = re.compile(r"(?i)\bauthorization\b(\s*[:=]\s*)[^\r\n]+")
|
||||
_SENSITIVE_ASSIGNMENT_RE = re.compile(
|
||||
r"""(?ix)
|
||||
\b(password|token|api[_-]?key|secret)\b
|
||||
(\s*[:=]\s*)
|
||||
("[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_visible_text(value, *, max_length):
|
||||
text = str(value or "").strip()
|
||||
text = _AUTHORIZATION_RE.sub(lambda match: f"authorization{match.group(1)}***", text)
|
||||
text = _SENSITIVE_ASSIGNMENT_RE.sub(
|
||||
lambda match: f"{match.group(1)}{match.group(2)}***",
|
||||
text,
|
||||
)
|
||||
return text[:max_length]
|
||||
|
||||
|
||||
def default_playbook_scripts_dir():
|
||||
@@ -57,10 +87,23 @@ def scan_playbook_definitions(*, scripts_dir=None, scripts_dirs=None):
|
||||
class_name="Playbook",
|
||||
base_class=BasePlaybook,
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.append({"path": str(path), "error": f"{type(exc).__name__}: {exc}"})
|
||||
except Exception:
|
||||
logger.exception("Failed to load playbook definition from %s", path)
|
||||
errors.append({"path": str(path), "error": "Failed to load playbook definition."})
|
||||
continue
|
||||
if definition is not None:
|
||||
risk_level = getattr(definition.script_class, "RISK_LEVEL", "Low")
|
||||
if risk_level not in PLAYBOOK_RISK_LEVELS:
|
||||
logger.error(
|
||||
"Invalid playbook risk level: path=%s risk_level=%r",
|
||||
path,
|
||||
risk_level,
|
||||
)
|
||||
errors.append({
|
||||
"path": str(path),
|
||||
"error": f"RISK_LEVEL must be one of: {', '.join(sorted(PLAYBOOK_RISK_LEVELS))}.",
|
||||
})
|
||||
continue
|
||||
definitions.append(definition)
|
||||
return definitions, errors
|
||||
|
||||
@@ -77,6 +120,7 @@ def list_playbook_definitions(*, include_path=False, scripts_dir=None):
|
||||
"name": item.name,
|
||||
"description": getattr(item.script_class, "DESC", ""),
|
||||
"tags": _normalize_tags(getattr(item.script_class, "TAGS", [])),
|
||||
"risk_level": item.script_class.RISK_LEVEL,
|
||||
}
|
||||
if include_path:
|
||||
data["path"] = str(item.path)
|
||||
@@ -115,7 +159,7 @@ def claim_pending_playbook_run():
|
||||
Playbook.objects
|
||||
.select_for_update()
|
||||
.filter(job_status=PlaybookJobStatus.PENDING)
|
||||
.order_by("created_at")
|
||||
.order_by("created_at", "id")
|
||||
.first()
|
||||
)
|
||||
if playbook is None:
|
||||
@@ -123,19 +167,55 @@ def claim_pending_playbook_run():
|
||||
|
||||
playbook.job_status = PlaybookJobStatus.RUNNING
|
||||
playbook.job_id = str(uuid.uuid4())
|
||||
playbook.started_at = timezone.now()
|
||||
playbook.finished_at = None
|
||||
playbook.remark = ""
|
||||
playbook.save(update_fields=["job_status", "job_id", "remark", "updated_at"])
|
||||
with suppress_audit():
|
||||
playbook.save(update_fields=[
|
||||
"job_status",
|
||||
"job_id",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"remark",
|
||||
"updated_at",
|
||||
])
|
||||
return playbook
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def add_playbook_run_message(playbook, message):
|
||||
if not isinstance(message, str):
|
||||
raise TypeError("Run message must be a string.")
|
||||
sanitized = _sanitize_visible_text(message, max_length=MAX_RUN_MESSAGE_LENGTH)
|
||||
if not sanitized:
|
||||
raise ValueError("Run message must not be empty.")
|
||||
|
||||
locked = Playbook.objects.select_for_update().get(pk=playbook.pk)
|
||||
if locked.job_status != PlaybookJobStatus.RUNNING:
|
||||
raise ValueError("Run messages can only be added while the playbook is Running.")
|
||||
last_sequence = (
|
||||
PlaybookRunMessage.objects
|
||||
.filter(playbook_run=locked)
|
||||
.aggregate(value=Max("sequence"))["value"]
|
||||
or 0
|
||||
)
|
||||
return PlaybookRunMessage.objects.create(
|
||||
playbook_run=locked,
|
||||
sequence=last_sequence + 1,
|
||||
message=sanitized,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def mark_playbook_success(playbook, remark):
|
||||
locked = Playbook.objects.select_for_update().get(pk=playbook.pk)
|
||||
if locked.job_status != PlaybookJobStatus.RUNNING:
|
||||
raise ValueError(f"Playbook must be Running before success, got {locked.job_status}")
|
||||
locked.job_status = PlaybookJobStatus.SUCCESS
|
||||
locked.remark = remark
|
||||
locked.save(update_fields=["job_status", "remark", "updated_at"])
|
||||
locked.finished_at = timezone.now()
|
||||
locked.remark = _sanitize_visible_text(remark, max_length=MAX_RUN_REMARK_LENGTH)
|
||||
with suppress_audit():
|
||||
locked.save(update_fields=["job_status", "finished_at", "remark", "updated_at"])
|
||||
notify_playbook_completion(locked)
|
||||
return locked
|
||||
|
||||
@@ -146,7 +226,31 @@ def mark_playbook_failed(playbook, error):
|
||||
if locked.job_status != PlaybookJobStatus.RUNNING:
|
||||
raise ValueError(f"Playbook must be Running before failure, got {locked.job_status}")
|
||||
locked.job_status = PlaybookJobStatus.FAILED
|
||||
locked.remark = f"{type(error).__name__}: {error}"
|
||||
locked.save(update_fields=["job_status", "remark", "updated_at"])
|
||||
locked.finished_at = timezone.now()
|
||||
logger.exception("Playbook execution failed", exc_info=error)
|
||||
locked.remark = "Playbook execution failed."
|
||||
with suppress_audit():
|
||||
locked.save(update_fields=["job_status", "finished_at", "remark", "updated_at"])
|
||||
notify_playbook_completion(locked)
|
||||
return locked
|
||||
|
||||
|
||||
def recover_orphaned_playbook_runs():
|
||||
with transaction.atomic():
|
||||
orphaned_runs = list(
|
||||
Playbook.objects
|
||||
.select_for_update()
|
||||
.select_related("user", "case")
|
||||
.filter(job_status=PlaybookJobStatus.RUNNING)
|
||||
)
|
||||
finished_at = timezone.now()
|
||||
for playbook in orphaned_runs:
|
||||
playbook.job_status = PlaybookJobStatus.FAILED
|
||||
playbook.finished_at = finished_at
|
||||
playbook.remark = ORPHANED_RUN_REMARK
|
||||
with suppress_audit():
|
||||
playbook.save(update_fields=["job_status", "finished_at", "remark", "updated_at"])
|
||||
|
||||
for playbook in orphaned_runs:
|
||||
notify_playbook_completion(playbook)
|
||||
return len(orphaned_runs)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-10 13:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0001_initial'),
|
||||
('artifacts', '0002_artifact_artifact_created_id_idx'),
|
||||
('cases', '0002_case_case_created_id_idx'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='alert',
|
||||
index=models.Index(fields=['-created_at', '-id'], name='alert_created_id_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='alert',
|
||||
index=models.Index(fields=['-first_seen_time', '-id'], name='alert_first_seen_id_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-24 01:20
|
||||
|
||||
import django.db.models.functions.comparison
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0002_alert_alert_created_id_idx_and_more'),
|
||||
('artifacts', '0002_artifact_artifact_created_id_idx'),
|
||||
('cases', '0003_case_case_status_severity_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name='alert',
|
||||
index=models.Index(django.db.models.functions.comparison.Coalesce('last_seen_time', 'first_seen_time', 'created_at'), name='alert_event_time_idx'),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.db import models
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from apps.common.models import BaseModel
|
||||
from apps.common.readable_ids import save_with_readable_id
|
||||
@@ -222,6 +223,14 @@ class Alert(BaseModel):
|
||||
class Meta:
|
||||
db_table = "alerts"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["-created_at", "-id"], name="alert_created_id_idx"),
|
||||
models.Index(fields=["-first_seen_time", "-id"], name="alert_first_seen_id_idx"),
|
||||
models.Index(
|
||||
Coalesce("last_seen_time", "first_seen_time", "created_at"),
|
||||
name="alert_event_time_idx",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title or str(self.id)
|
||||
|
||||
@@ -1,38 +1,120 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
|
||||
from apps.enrichments.models import Enrichment
|
||||
from .models import Alert
|
||||
|
||||
|
||||
class AlertSerializer(serializers.ModelSerializer):
|
||||
artifact_count = serializers.SerializerMethodField()
|
||||
enrichment_count = serializers.SerializerMethodField()
|
||||
class AlertDetailSerializer(serializers.ModelSerializer):
|
||||
case_id = serializers.CharField(source="case.id", read_only=True)
|
||||
case_readable_id = serializers.CharField(source="case.case_id", read_only=True)
|
||||
case_title = serializers.CharField(source="case.title", read_only=True)
|
||||
case_status = serializers.CharField(source="case.status", read_only=True)
|
||||
case_category = serializers.CharField(source="case.category", read_only=True)
|
||||
|
||||
def get_artifact_count(self, obj):
|
||||
request = self.context.get("request")
|
||||
if request is not None and request.method not in SAFE_METHODS:
|
||||
return obj.artifacts.count()
|
||||
|
||||
prefetched_artifacts = getattr(obj, "_prefetched_objects_cache", {}).get("artifacts")
|
||||
if prefetched_artifacts is not None:
|
||||
return len(prefetched_artifacts)
|
||||
|
||||
annotated_value = getattr(obj, "artifact_count", None)
|
||||
if annotated_value is not None:
|
||||
return annotated_value
|
||||
|
||||
return obj.artifacts.count()
|
||||
|
||||
def get_enrichment_count(self, obj):
|
||||
return Enrichment.objects.filter(alert=obj).count()
|
||||
|
||||
class Meta:
|
||||
model = Alert
|
||||
fields = "__all__"
|
||||
fields = (
|
||||
"id",
|
||||
"alert_id",
|
||||
"case",
|
||||
"case_id",
|
||||
"case_readable_id",
|
||||
"case_title",
|
||||
"case_status",
|
||||
"case_category",
|
||||
"title",
|
||||
"severity",
|
||||
"confidence",
|
||||
"impact",
|
||||
"disposition",
|
||||
"action",
|
||||
"labels",
|
||||
"desc",
|
||||
"first_seen_time",
|
||||
"last_seen_time",
|
||||
"rule_id",
|
||||
"rule_name",
|
||||
"correlation_uid",
|
||||
"src_url",
|
||||
"source_uid",
|
||||
"data_sources",
|
||||
"analytic_name",
|
||||
"analytic_type",
|
||||
"analytic_state",
|
||||
"analytic_desc",
|
||||
"tactic",
|
||||
"technique",
|
||||
"sub_technique",
|
||||
"mitigation",
|
||||
"product_category",
|
||||
"product_vendor",
|
||||
"product_name",
|
||||
"product_feature",
|
||||
"policy_name",
|
||||
"policy_type",
|
||||
"policy_desc",
|
||||
"risk_level",
|
||||
"status",
|
||||
"status_detail",
|
||||
"remediation",
|
||||
"unmapped",
|
||||
"raw_data",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("id", "alert_id", "created_at", "updated_at")
|
||||
|
||||
|
||||
class AlertListSerializer(AlertDetailSerializer):
|
||||
artifact_count = serializers.IntegerField(read_only=True, default=0)
|
||||
enrichment_count = serializers.IntegerField(read_only=True, default=0)
|
||||
|
||||
class Meta(AlertDetailSerializer.Meta):
|
||||
fields = (
|
||||
"id",
|
||||
"alert_id",
|
||||
"case",
|
||||
"case_id",
|
||||
"case_readable_id",
|
||||
"case_title",
|
||||
"case_status",
|
||||
"case_category",
|
||||
"title",
|
||||
"severity",
|
||||
"confidence",
|
||||
"impact",
|
||||
"disposition",
|
||||
"action",
|
||||
"labels",
|
||||
"desc",
|
||||
"first_seen_time",
|
||||
"last_seen_time",
|
||||
"rule_id",
|
||||
"rule_name",
|
||||
"correlation_uid",
|
||||
"src_url",
|
||||
"source_uid",
|
||||
"data_sources",
|
||||
"analytic_name",
|
||||
"analytic_type",
|
||||
"analytic_state",
|
||||
"analytic_desc",
|
||||
"tactic",
|
||||
"technique",
|
||||
"sub_technique",
|
||||
"mitigation",
|
||||
"product_category",
|
||||
"product_vendor",
|
||||
"product_name",
|
||||
"product_feature",
|
||||
"policy_name",
|
||||
"policy_type",
|
||||
"policy_desc",
|
||||
"risk_level",
|
||||
"status",
|
||||
"status_detail",
|
||||
"remediation",
|
||||
"artifact_count",
|
||||
"enrichment_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import viewsets, permissions
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
@@ -6,15 +7,14 @@ from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from apps.accounts.permissions import IsBusinessWriterOrReadOnly
|
||||
from apps.audit.mixins import AuditActorMixin
|
||||
from apps.common.advanced_filters import AdvancedFilterBackend
|
||||
from apps.enrichments.models import Enrichment
|
||||
from .models import Alert
|
||||
from .serializers import AlertSerializer
|
||||
from .serializers import AlertDetailSerializer, AlertListSerializer
|
||||
|
||||
|
||||
class AlertViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
queryset = Alert.objects.select_related("case").prefetch_related("artifacts").annotate(
|
||||
artifact_count=Count("artifacts", distinct=True)
|
||||
).order_by("-created_at")
|
||||
serializer_class = AlertSerializer
|
||||
queryset = Alert.objects.select_related("case").order_by("-created_at")
|
||||
serializer_class = AlertDetailSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly]
|
||||
lookup_field = "id"
|
||||
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
|
||||
@@ -37,7 +37,6 @@ class AlertViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
ordering_fields = (
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"artifact_count",
|
||||
"severity",
|
||||
"confidence",
|
||||
"impact",
|
||||
@@ -83,9 +82,41 @@ class AlertViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
"created_at": "date",
|
||||
}
|
||||
|
||||
def annotate_list_counts(self, queryset):
|
||||
artifact_count = (
|
||||
Alert.artifacts.through.objects
|
||||
.filter(alert_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("alert_id")
|
||||
.annotate(count=Count("artifact_id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
queryset = queryset.annotate(
|
||||
artifact_count=Coalesce(Subquery(artifact_count, output_field=IntegerField()), Value(0))
|
||||
)
|
||||
|
||||
enrichment_count = (
|
||||
Enrichment.objects
|
||||
.filter(alert_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("alert_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
return queryset.annotate(
|
||||
enrichment_count=Coalesce(Subquery(enrichment_count, output_field=IntegerField()), Value(0))
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.action == "list":
|
||||
queryset = self.annotate_list_counts(queryset)
|
||||
artifact_id = self.request.query_params.get("artifacts")
|
||||
if artifact_id:
|
||||
queryset = queryset.filter(artifacts__id=artifact_id)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return AlertListSerializer
|
||||
return AlertDetailSerializer
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-10 13:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('artifacts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='artifact',
|
||||
index=models.Index(fields=['-created_at', '-id'], name='artifact_created_id_idx'),
|
||||
),
|
||||
]
|
||||
@@ -305,6 +305,9 @@ class Artifact(BaseModel):
|
||||
class Meta:
|
||||
db_table = "artifacts"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["-created_at", "-id"], name="artifact_created_id_idx"),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
return save_with_readable_id(self, "artifact_id", "artifact", *args, **kwargs)
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.enrichments.models import Enrichment
|
||||
from .models import Artifact
|
||||
|
||||
|
||||
class ArtifactSerializer(serializers.ModelSerializer):
|
||||
alert_count = serializers.SerializerMethodField()
|
||||
enrichment_count = serializers.SerializerMethodField()
|
||||
|
||||
def get_alert_count(self, obj):
|
||||
annotated_value = getattr(obj, "alert_count", None)
|
||||
if annotated_value is not None:
|
||||
return annotated_value
|
||||
return obj.alerts.count()
|
||||
|
||||
def get_enrichment_count(self, obj):
|
||||
return Enrichment.objects.filter(artifact=obj).count()
|
||||
|
||||
class ArtifactDetailSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Artifact
|
||||
fields = "__all__"
|
||||
fields = (
|
||||
"id",
|
||||
"artifact_id",
|
||||
"name",
|
||||
"type",
|
||||
"role",
|
||||
"value",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("id", "artifact_id", "created_at", "updated_at")
|
||||
|
||||
|
||||
class ArtifactListSerializer(ArtifactDetailSerializer):
|
||||
alert_count = serializers.IntegerField(read_only=True, default=0)
|
||||
enrichment_count = serializers.IntegerField(read_only=True, default=0)
|
||||
|
||||
class Meta(ArtifactDetailSerializer.Meta):
|
||||
fields = (
|
||||
"id",
|
||||
"artifact_id",
|
||||
"name",
|
||||
"type",
|
||||
"role",
|
||||
"value",
|
||||
"alert_count",
|
||||
"enrichment_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import viewsets, permissions
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
@@ -6,18 +7,19 @@ from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from apps.accounts.permissions import IsBusinessWriterOrReadOnly
|
||||
from apps.audit.mixins import AuditActorMixin
|
||||
from apps.common.advanced_filters import AdvancedFilterBackend
|
||||
from apps.enrichments.models import Enrichment
|
||||
from .models import Artifact
|
||||
from .serializers import ArtifactSerializer
|
||||
from .serializers import ArtifactDetailSerializer, ArtifactListSerializer
|
||||
|
||||
|
||||
class ArtifactViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
queryset = Artifact.objects.annotate(alert_count=Count("alerts", distinct=True)).order_by("-created_at")
|
||||
serializer_class = ArtifactSerializer
|
||||
queryset = Artifact.objects.order_by("-created_at")
|
||||
serializer_class = ArtifactDetailSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly]
|
||||
lookup_field = "id"
|
||||
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
|
||||
search_fields = ("artifact_id", "value", "name", "type", "role")
|
||||
ordering_fields = ("created_at", "updated_at", "type", "role", "alert_count")
|
||||
ordering_fields = ("created_at", "updated_at", "type", "role")
|
||||
filterset_fields = ("type", "role", "alerts__id")
|
||||
advanced_filter_fields = {
|
||||
"artifact_id": "text",
|
||||
@@ -29,9 +31,42 @@ class ArtifactViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
"updated_at": "date",
|
||||
}
|
||||
|
||||
def annotate_alert_count(self, queryset):
|
||||
alert_count = (
|
||||
Artifact.alerts.through.objects
|
||||
.filter(artifact_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("artifact_id")
|
||||
.annotate(count=Count("alert_id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
return queryset.annotate(
|
||||
alert_count=Coalesce(Subquery(alert_count, output_field=IntegerField()), Value(0))
|
||||
)
|
||||
|
||||
def annotate_enrichment_count(self, queryset):
|
||||
enrichment_count = (
|
||||
Enrichment.objects
|
||||
.filter(artifact_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("artifact_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
return queryset.annotate(
|
||||
enrichment_count=Coalesce(Subquery(enrichment_count, output_field=IntegerField()), Value(0))
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.action == "list":
|
||||
queryset = self.annotate_enrichment_count(self.annotate_alert_count(queryset))
|
||||
alert_id = self.request.query_params.get("alerts")
|
||||
if alert_id:
|
||||
queryset = queryset.filter(alerts__id=alert_id)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return ArtifactListSerializer
|
||||
return ArtifactDetailSerializer
|
||||
|
||||
@@ -9,6 +9,10 @@ def get_current_actor():
|
||||
return getattr(_state, "actor", None)
|
||||
|
||||
|
||||
def audit_is_suppressed():
|
||||
return getattr(_state, "suppressed", False)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def audit_actor(actor):
|
||||
previous = get_current_actor()
|
||||
@@ -17,3 +21,13 @@ def audit_actor(actor):
|
||||
yield
|
||||
finally:
|
||||
_state.actor = previous
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suppress_audit():
|
||||
previous = audit_is_suppressed()
|
||||
_state.suppressed = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_state.suppressed = previous
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by GitHub Copilot on 2026-07-06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("audit", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="auditlog",
|
||||
index=models.Index(fields=["-created_at", "-id"], name="audit_time_idx"),
|
||||
),
|
||||
]
|
||||
@@ -22,6 +22,7 @@ class AuditLog(models.Model):
|
||||
db_table = "audit_logs"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["-created_at", "-id"], name="audit_time_idx"),
|
||||
models.Index(fields=["content_type", "object_id", "-created_at", "-id"], name="audit_obj_time_idx"),
|
||||
models.Index(fields=["actor", "-created_at", "-id"], name="audit_actor_time_idx"),
|
||||
models.Index(fields=["action", "-created_at", "-id"], name="audit_action_time_idx"),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models.signals import m2m_changed, post_save, post_delete, pre_save
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from apps.common.models import BaseModel
|
||||
from .context import get_current_actor
|
||||
from .context import audit_is_suppressed, get_current_actor
|
||||
from .helpers import readable_label, write_relation_event
|
||||
from .models import AuditLog
|
||||
|
||||
@@ -11,6 +12,10 @@ RELATION_FK_FIELDS = {
|
||||
"alert": {"case": "alerts"},
|
||||
"playbook": {"case": "playbooks"},
|
||||
"enrichment": {"case": "enrichments", "alert": "enrichments", "artifact": "enrichments"},
|
||||
"caserelationship": {
|
||||
"source_case": "case_relationships",
|
||||
"target_case": "case_relationships",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +35,13 @@ def relation_fields(sender):
|
||||
return RELATION_FK_FIELDS.get(sender._meta.model_name, {})
|
||||
|
||||
|
||||
def relation_parent(instance, field_name):
|
||||
try:
|
||||
return getattr(instance, field_name, None)
|
||||
except ObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def changed_fields(sender, before, after):
|
||||
changes = {}
|
||||
ignored_fields = {"created_at", "updated_at"}
|
||||
@@ -56,22 +68,35 @@ def write_fk_relation_events(sender, before, after, created=False):
|
||||
|
||||
|
||||
def write_delete_relation_events(sender, instance):
|
||||
parents = getattr(instance, "_audit_delete_relation_parents", {})
|
||||
for field_name, relation in relation_fields(sender).items():
|
||||
parent = getattr(instance, field_name, None)
|
||||
parent = parents.get(field_name) or relation_parent(instance, field_name)
|
||||
if parent:
|
||||
write_relation_event(parent, "deleted", relation, instance)
|
||||
|
||||
|
||||
@receiver(pre_save)
|
||||
def capture_previous_state(sender, instance, **kwargs):
|
||||
if not audit_model(sender) or not instance.pk:
|
||||
if audit_is_suppressed() or not audit_model(sender) or not instance.pk:
|
||||
instance._audit_previous = None
|
||||
return
|
||||
instance._audit_previous = sender.objects.filter(pk=instance.pk).first()
|
||||
|
||||
|
||||
@receiver(pre_delete)
|
||||
def capture_delete_relation_parents(sender, instance, **kwargs):
|
||||
if audit_is_suppressed() or not audit_model(sender):
|
||||
return
|
||||
parents = {}
|
||||
for field_name in relation_fields(sender):
|
||||
parent = relation_parent(instance, field_name)
|
||||
if parent is not None:
|
||||
parents[field_name] = parent
|
||||
instance._audit_delete_relation_parents = parents
|
||||
|
||||
@receiver(post_save)
|
||||
def log_save(sender, instance, created, **kwargs):
|
||||
if not audit_model(sender):
|
||||
if audit_is_suppressed() or not audit_model(sender):
|
||||
return
|
||||
action = "create" if created else "update"
|
||||
previous = getattr(instance, "_audit_previous", None)
|
||||
@@ -89,7 +114,7 @@ def log_save(sender, instance, created, **kwargs):
|
||||
|
||||
@receiver(post_delete)
|
||||
def log_delete(sender, instance, **kwargs):
|
||||
if not audit_model(sender):
|
||||
if audit_is_suppressed() or not audit_model(sender):
|
||||
return
|
||||
AuditLog.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(sender),
|
||||
@@ -103,6 +128,8 @@ def log_delete(sender, instance, **kwargs):
|
||||
|
||||
@receiver(m2m_changed)
|
||||
def log_many_to_many_change(sender, instance, action, reverse, model, pk_set, **kwargs):
|
||||
if audit_is_suppressed():
|
||||
return
|
||||
if action not in {"post_add", "post_remove", "post_clear"}:
|
||||
return
|
||||
if not isinstance(instance, BaseModel):
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.artifacts.models import Artifact, ArtifactName, ArtifactRole, ArtifactType
|
||||
from apps.enrichments.models import Enrichment, EnrichmentProvider, EnrichmentType
|
||||
from .models import AuditLog
|
||||
|
||||
|
||||
class AuditDeleteCascadeTests(TestCase):
|
||||
def test_deleting_parent_with_fk_relation_audit_child_does_not_query_deleted_parent(self):
|
||||
artifact = Artifact.objects.create(
|
||||
name=ArtifactName.HOSTNAME,
|
||||
type=ArtifactType.HOSTNAME,
|
||||
role=ArtifactRole.RELATED,
|
||||
value="audit-delete-repro.example",
|
||||
)
|
||||
enrichment = Enrichment.objects.create(
|
||||
artifact=artifact,
|
||||
name="audit delete repro enrichment",
|
||||
type=EnrichmentType.OBSERVATION,
|
||||
provider=EnrichmentProvider.INTERNAL,
|
||||
uid="audit-delete-repro",
|
||||
value="audit-delete-repro.example",
|
||||
)
|
||||
|
||||
artifact_id = artifact.id
|
||||
enrichment_id = enrichment.id
|
||||
|
||||
artifact.delete()
|
||||
|
||||
artifact_content_type = ContentType.objects.get_for_model(Artifact)
|
||||
self.assertTrue(
|
||||
AuditLog.objects.filter(
|
||||
content_type=artifact_content_type,
|
||||
object_id=str(artifact_id),
|
||||
action="deleted",
|
||||
metadata__relation="enrichments",
|
||||
metadata__related_id=str(enrichment_id),
|
||||
).exists()
|
||||
)
|
||||
+467
-1
@@ -1,15 +1,63 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db.models import Q
|
||||
from django.db.models import CharField, Q, Value
|
||||
from django.db.models.functions import Cast, Coalesce, Concat
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from rest_framework import viewsets, permissions
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.accounts.permissions import IsAdmin
|
||||
from apps.common.cursor_pagination import cursor_response_payload, paginate_created_at_cursor
|
||||
from apps.common.readable_ids import format_readable_id, parse_readable_id_number
|
||||
from .helpers import readable_label
|
||||
from .models import AuditLog
|
||||
EXPORT_MAX_ROWS = 50000
|
||||
EXPORT_MAX_ROWS = 50000
|
||||
|
||||
RESOURCE_MODEL_TO_KEY = {
|
||||
"case": "cases",
|
||||
"alert": "alerts",
|
||||
"artifact": "artifacts",
|
||||
"enrichment": "enrichments",
|
||||
"playbook": "playbooks",
|
||||
"knowledge": "knowledge",
|
||||
"user": "users",
|
||||
"llmproviderconfig": "llm-providers",
|
||||
"threatintelalienvaultotxconfig": "threat-intel-otx",
|
||||
"threatintelopencticonfig": "threat-intel-opencti",
|
||||
"siemsplunkconfig": "siem-splunk",
|
||||
"siemelkconfig": "siem-elk",
|
||||
"ldapconfig": "ldap",
|
||||
"runtimeconfig": "runtime",
|
||||
"customvariable": "custom-variables",
|
||||
}
|
||||
|
||||
RESOURCE_LABELS = {
|
||||
"case": "Case",
|
||||
"alert": "Alert",
|
||||
"artifact": "Artifact",
|
||||
"enrichment": "Enrichment",
|
||||
"playbook": "Playbook",
|
||||
"knowledge": "Knowledge",
|
||||
"user": "User",
|
||||
"llmproviderconfig": "LLM Provider",
|
||||
"threatintelalienvaultotxconfig": "AlienVault OTX Settings",
|
||||
"threatintelopencticonfig": "OpenCTI Settings",
|
||||
"siemsplunkconfig": "Splunk Settings",
|
||||
"siemelkconfig": "ELK Settings",
|
||||
"ldapconfig": "LDAP Settings",
|
||||
"runtimeconfig": "Runtime Settings",
|
||||
"customvariable": "Custom Variable",
|
||||
}
|
||||
|
||||
|
||||
def foreign_key_field(model, field_name):
|
||||
@@ -76,6 +124,277 @@ def datetime_param(params, name):
|
||||
return value
|
||||
|
||||
|
||||
def _json_text(value):
|
||||
return json.dumps(value or {}, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
def _readable_id(log):
|
||||
return format_readable_id("audit", log.id)
|
||||
|
||||
|
||||
def _actor_name(log):
|
||||
if not log.actor:
|
||||
return ""
|
||||
return log.actor.get_full_name() or log.actor.username
|
||||
|
||||
|
||||
def _resource_label(content_type):
|
||||
return RESOURCE_LABELS.get(content_type.model, content_type.model.replace("_", " ").title())
|
||||
|
||||
|
||||
def _resource_key(content_type):
|
||||
return RESOURCE_MODEL_TO_KEY.get(content_type.model, f"{content_type.model}s")
|
||||
|
||||
|
||||
def _changed_field_names(log):
|
||||
return list((log.changes or {}).keys())
|
||||
|
||||
|
||||
def _field_summary(log):
|
||||
metadata = log.metadata or {}
|
||||
relation = metadata.get("relation")
|
||||
if relation:
|
||||
return str(relation)
|
||||
field_names = _changed_field_names(log)
|
||||
if not field_names:
|
||||
return ""
|
||||
if len(field_names) == 1:
|
||||
return field_names[0]
|
||||
return f"{len(field_names)} fields"
|
||||
|
||||
|
||||
def _log_summary(log):
|
||||
actor = _actor_name(log) if log.actor else "system"
|
||||
resource = _resource_label(log.content_type)
|
||||
field = _field_summary(log)
|
||||
if field:
|
||||
return f"{actor} {log.action} {resource} {log.object_id} ({field})"
|
||||
return f"{actor} {log.action} {resource} {log.object_id}"
|
||||
|
||||
|
||||
def audit_log_payload(log):
|
||||
metadata = log.metadata or {}
|
||||
return {
|
||||
"id": log.id,
|
||||
"readable_id": _readable_id(log),
|
||||
"title": _readable_id(log),
|
||||
"action": log.action,
|
||||
"actor": log.actor.username if log.actor else None,
|
||||
"actor_id": log.actor_id,
|
||||
"actor_name": _actor_name(log),
|
||||
"content_type": log.content_type.model,
|
||||
"content_type_model": log.content_type.model,
|
||||
"content_type_app": log.content_type.app_label,
|
||||
"resource_type": log.content_type.model,
|
||||
"resource_key": _resource_key(log.content_type),
|
||||
"resource_label": _resource_label(log.content_type),
|
||||
"object_id": log.object_id,
|
||||
"field_summary": _field_summary(log),
|
||||
"summary": _log_summary(log),
|
||||
"related_resource": metadata.get("related_resource") or "",
|
||||
"related_id": metadata.get("related_id") or "",
|
||||
"related_label": metadata.get("related_label") or "",
|
||||
"relation": metadata.get("relation") or "",
|
||||
"changes": log.changes,
|
||||
"display_changes": display_changes(log),
|
||||
"metadata": metadata,
|
||||
"changes_json": _json_text(log.changes),
|
||||
"metadata_json": _json_text(metadata),
|
||||
"created_at": log.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _values(value):
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value if str(item)]
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def _text_q(field, operator, value):
|
||||
values = _values(value)
|
||||
if operator == "is_empty":
|
||||
return Q(**{field: ""}) | Q(**{f"{field}__isnull": True})
|
||||
if operator == "is_not_empty":
|
||||
return ~(Q(**{field: ""}) | Q(**{f"{field}__isnull": True}))
|
||||
if not values:
|
||||
raise ValidationError("Filter value is required.")
|
||||
if operator == "eq":
|
||||
return Q(**{field: values[0]})
|
||||
if operator == "neq":
|
||||
return ~Q(**{field: values[0]})
|
||||
if operator == "contains":
|
||||
return Q(**{f"{field}__icontains": values[0]})
|
||||
if operator == "not_contains":
|
||||
return ~Q(**{f"{field}__icontains": values[0]})
|
||||
if operator == "contains_all":
|
||||
query = Q()
|
||||
for item in values:
|
||||
query &= Q(**{f"{field}__icontains": item})
|
||||
return query
|
||||
raise ValidationError(f"Unsupported filter operator: {operator}")
|
||||
|
||||
|
||||
def _select_q(field, operator, value):
|
||||
values = _values(value)
|
||||
if operator == "is_empty":
|
||||
return Q(**{field: ""}) | Q(**{f"{field}__isnull": True})
|
||||
if operator == "is_not_empty":
|
||||
return ~(Q(**{field: ""}) | Q(**{f"{field}__isnull": True}))
|
||||
if not values:
|
||||
raise ValidationError("Filter value is required.")
|
||||
if operator == "is":
|
||||
return Q(**{field: values[0]})
|
||||
if operator == "is_not":
|
||||
return ~Q(**{field: values[0]})
|
||||
if operator == "is_one_of":
|
||||
return Q(**{f"{field}__in": values})
|
||||
if operator == "is_not_any_of":
|
||||
return ~Q(**{f"{field}__in": values})
|
||||
raise ValidationError(f"Unsupported filter operator: {operator}")
|
||||
|
||||
|
||||
def _date_q(field, operator, value):
|
||||
values = _values(value)
|
||||
if operator == "is_empty":
|
||||
return Q(**{f"{field}__isnull": True})
|
||||
if operator == "is_not_empty":
|
||||
return Q(**{f"{field}__isnull": False})
|
||||
if not values:
|
||||
raise ValidationError("Filter value is required.")
|
||||
if operator in {"between", "not_between"}:
|
||||
if len(values) != 2:
|
||||
raise ValidationError("Range filters require two values.")
|
||||
query = Q(**{f"{field}__gte": values[0], f"{field}__lte": values[1]})
|
||||
return ~query if operator == "not_between" else query
|
||||
lookup = {
|
||||
"eq": "",
|
||||
"neq": "",
|
||||
"lt": "__lt",
|
||||
"gt": "__gt",
|
||||
"lte": "__lte",
|
||||
"gte": "__gte",
|
||||
}.get(operator)
|
||||
if lookup is None:
|
||||
raise ValidationError(f"Unsupported filter operator: {operator}")
|
||||
query = Q(**{f"{field}{lookup}": values[0]})
|
||||
return ~query if operator == "neq" else query
|
||||
|
||||
|
||||
def _field_filter_q(operator, value):
|
||||
values = _values(value)
|
||||
if operator == "is_empty":
|
||||
return Q(changes={}) & (Q(metadata__relation="") | Q(metadata__relation__isnull=True))
|
||||
if operator == "is_not_empty":
|
||||
return ~_field_filter_q("is_empty", None)
|
||||
if not values:
|
||||
raise ValidationError("Filter value is required.")
|
||||
if operator in {"is", "eq"}:
|
||||
return Q(changes__has_key=values[0]) | Q(metadata__relation=values[0])
|
||||
if operator in {"is_not", "neq"}:
|
||||
return ~(Q(changes__has_key=values[0]) | Q(metadata__relation=values[0]))
|
||||
if operator == "contains":
|
||||
return Q(changes_text__icontains=values[0]) | Q(metadata__relation__icontains=values[0])
|
||||
if operator == "not_contains":
|
||||
return ~(Q(changes_text__icontains=values[0]) | Q(metadata__relation__icontains=values[0]))
|
||||
if operator == "is_one_of":
|
||||
query = Q()
|
||||
for item in values:
|
||||
query |= Q(changes__has_key=item) | Q(metadata__relation=item)
|
||||
return query
|
||||
if operator == "is_not_any_of":
|
||||
query = Q()
|
||||
for item in values:
|
||||
query |= Q(changes__has_key=item) | Q(metadata__relation=item)
|
||||
return ~query
|
||||
raise ValidationError(f"Unsupported filter operator: {operator}")
|
||||
|
||||
|
||||
def _actor_filter_q(operator, value):
|
||||
values = _values(value)
|
||||
if operator == "is_empty":
|
||||
return Q(actor__isnull=True)
|
||||
if operator == "is_not_empty":
|
||||
return Q(actor__isnull=False)
|
||||
if not values:
|
||||
raise ValidationError("Filter value is required.")
|
||||
|
||||
include_system = "system" in values
|
||||
user_values = [item for item in values if item != "system"]
|
||||
query = Q()
|
||||
if include_system:
|
||||
query |= Q(actor__isnull=True)
|
||||
if user_values:
|
||||
query |= Q(actor_id__in=user_values)
|
||||
|
||||
if operator in {"is", "is_one_of"}:
|
||||
return query
|
||||
if operator in {"is_not", "is_not_any_of"}:
|
||||
return ~query
|
||||
raise ValidationError(f"Unsupported filter operator: {operator}")
|
||||
|
||||
|
||||
def _advanced_filter_condition(item):
|
||||
field = str(item.get("field") or "")
|
||||
operator = str(item.get("operator") or "")
|
||||
value = item.get("value")
|
||||
if field == "field":
|
||||
return _field_filter_q(operator, value)
|
||||
if field == "resource_type":
|
||||
return _select_q("content_type__model", operator, value)
|
||||
if field == "actor":
|
||||
return _actor_filter_q(operator, value)
|
||||
field_map = {
|
||||
"action": ("select", "action"),
|
||||
"object_id": ("text", "object_id"),
|
||||
"related_resource": ("text", "metadata__related_resource"),
|
||||
"related_id": ("text", "metadata__related_id"),
|
||||
"related_label": ("text", "metadata__related_label"),
|
||||
"relation": ("text", "metadata__relation"),
|
||||
"changes": ("text", "changes_text"),
|
||||
"metadata": ("text", "metadata_text"),
|
||||
"created_at": ("date", "created_at"),
|
||||
}
|
||||
field_config = field_map.get(field)
|
||||
if not field_config:
|
||||
raise ValidationError(f"Unsupported filter field: {field}")
|
||||
value_type, lookup_field = field_config
|
||||
if value_type == "select":
|
||||
return _select_q(lookup_field, operator, value)
|
||||
if value_type == "date":
|
||||
return _date_q(lookup_field, operator, value)
|
||||
return _text_q(lookup_field, operator, value)
|
||||
|
||||
|
||||
def _apply_advanced_filters(queryset, raw_filters):
|
||||
if not raw_filters:
|
||||
return queryset
|
||||
try:
|
||||
filters = json.loads(raw_filters)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValidationError("advanced_filters must be valid JSON.") from exc
|
||||
if not isinstance(filters, list):
|
||||
raise ValidationError("advanced_filters must be a list.")
|
||||
|
||||
combined = Q()
|
||||
has_condition = False
|
||||
for item in filters:
|
||||
if not isinstance(item, dict):
|
||||
raise ValidationError("Each advanced filter must be an object.")
|
||||
condition = _advanced_filter_condition(item)
|
||||
if not has_condition:
|
||||
combined = condition
|
||||
has_condition = True
|
||||
elif item.get("connector") == "or":
|
||||
combined |= condition
|
||||
else:
|
||||
combined &= condition
|
||||
return queryset.filter(combined).distinct() if has_condition else queryset
|
||||
|
||||
|
||||
class AuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@@ -132,3 +451,150 @@ class AuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
for log in page.results
|
||||
]
|
||||
return Response(cursor_response_payload(page, data))
|
||||
|
||||
|
||||
class AdminAuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdmin]
|
||||
pagination_class = None
|
||||
ordering_fields = (
|
||||
"created_at",
|
||||
"action",
|
||||
"object_id",
|
||||
"content_type__model",
|
||||
"actor__username",
|
||||
)
|
||||
|
||||
def base_queryset(self):
|
||||
return AuditLog.objects.select_related("actor", "content_type").annotate(
|
||||
changes_text=Cast("changes", output_field=CharField()),
|
||||
metadata_text=Cast("metadata", output_field=CharField()),
|
||||
actor_display=Concat(
|
||||
Coalesce("actor__first_name", Value("")),
|
||||
Value(" "),
|
||||
Coalesce("actor__last_name", Value("")),
|
||||
output_field=CharField(),
|
||||
),
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.base_queryset()
|
||||
params = self.request.query_params
|
||||
|
||||
action_value = params.get("action")
|
||||
if action_value:
|
||||
queryset = queryset.filter(action=action_value)
|
||||
|
||||
actor = params.get("actor")
|
||||
if actor == "system":
|
||||
queryset = queryset.filter(actor__isnull=True)
|
||||
elif actor:
|
||||
queryset = queryset.filter(actor_id=actor)
|
||||
|
||||
resource_type = params.get("resource_type")
|
||||
if resource_type:
|
||||
queryset = queryset.filter(content_type__model__in=_values(resource_type))
|
||||
|
||||
created_after = datetime_param(params, "created_after")
|
||||
if created_after:
|
||||
queryset = queryset.filter(created_at__gte=created_after)
|
||||
|
||||
created_before = datetime_param(params, "created_before")
|
||||
if created_before:
|
||||
queryset = queryset.filter(created_at__lte=created_before)
|
||||
|
||||
search = (params.get("search") or "").strip()
|
||||
if search:
|
||||
search_query = (
|
||||
Q(action__icontains=search)
|
||||
| Q(object_id__icontains=search)
|
||||
| Q(content_type__model__icontains=search)
|
||||
| Q(content_type__app_label__icontains=search)
|
||||
| Q(actor__username__icontains=search)
|
||||
| Q(actor__email__icontains=search)
|
||||
| Q(actor_display__icontains=search)
|
||||
| Q(changes_text__icontains=search)
|
||||
| Q(metadata_text__icontains=search)
|
||||
)
|
||||
readable_id_number = parse_readable_id_number(search.lower(), "audit")
|
||||
if readable_id_number:
|
||||
search_query |= Q(id=readable_id_number)
|
||||
queryset = queryset.filter(search_query)
|
||||
|
||||
queryset = _apply_advanced_filters(queryset, params.get("advanced_filters"))
|
||||
|
||||
ordering = params.get("ordering")
|
||||
if ordering:
|
||||
field_name = ordering[1:] if ordering.startswith("-") else ordering
|
||||
if field_name not in self.ordering_fields:
|
||||
raise ValidationError({"ordering": "Unsupported ordering field."})
|
||||
return queryset.order_by(ordering, "-id")
|
||||
return queryset.order_by("-created_at", "-id")
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.get_queryset()
|
||||
page_number = request.query_params.get("page")
|
||||
page_size = request.query_params.get("page_size")
|
||||
if page_number is not None or page_size is not None:
|
||||
from apps.common.pagination import StandardResultsSetPagination
|
||||
|
||||
paginator = StandardResultsSetPagination()
|
||||
page = paginator.paginate_queryset(queryset, request, view=self)
|
||||
data = [audit_log_payload(log) for log in page]
|
||||
return paginator.get_paginated_response(data)
|
||||
|
||||
data = [audit_log_payload(log) for log in queryset[:100]]
|
||||
return Response({"count": queryset.count(), "results": data})
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
log = get_object_or_404(self.base_queryset(), pk=kwargs.get("pk"))
|
||||
return Response(audit_log_payload(log))
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="export")
|
||||
def export(self, request):
|
||||
queryset = self.get_queryset()
|
||||
|
||||
total = queryset.count()
|
||||
if total > EXPORT_MAX_ROWS:
|
||||
raise ValidationError({
|
||||
"detail": f"Export matches {total} rows. Narrow the filters to {EXPORT_MAX_ROWS} rows or fewer."
|
||||
})
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
"audit_id",
|
||||
"created_at",
|
||||
"actor",
|
||||
"actor_id",
|
||||
"action",
|
||||
"resource_type",
|
||||
"object_id",
|
||||
"field_or_relation",
|
||||
"related_resource",
|
||||
"related_id",
|
||||
"related_label",
|
||||
"changes_json",
|
||||
"metadata_json",
|
||||
])
|
||||
for log in queryset[:EXPORT_MAX_ROWS]:
|
||||
metadata = log.metadata or {}
|
||||
writer.writerow([
|
||||
_readable_id(log),
|
||||
log.created_at.isoformat(),
|
||||
log.actor.username if log.actor else "system",
|
||||
log.actor_id or "",
|
||||
log.action,
|
||||
log.content_type.model,
|
||||
log.object_id,
|
||||
_field_summary(log),
|
||||
metadata.get("related_resource") or "",
|
||||
metadata.get("related_id") or "",
|
||||
metadata.get("related_label") or "",
|
||||
_json_text(log.changes),
|
||||
_json_text(metadata),
|
||||
])
|
||||
|
||||
filename = timezone.now().strftime("audit-logs-%Y%m%d-%H%M%S.csv")
|
||||
response = HttpResponse(output.getvalue(), content_type="text/csv; charset=utf-8")
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-10 13:45
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cases', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='case',
|
||||
index=models.Index(fields=['-created_at', '-id'], name='case_created_id_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-24 01:20
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('cases', '0002_case_case_created_id_idx'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name='case',
|
||||
index=models.Index(fields=['status', 'severity'], name='case_status_severity_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='case',
|
||||
index=models.Index(fields=['updated_at'], name='case_updated_at_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='case',
|
||||
index=models.Index(fields=['acknowledged_time'], name='case_ack_time_idx'),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name='case',
|
||||
index=models.Index(fields=['closed_time'], name='case_closed_time_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 6.0.6 on 2026-08-04 08:10
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cases', '0003_case_case_status_severity_idx_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CaseRelationship',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('relationship_type', models.CharField(choices=[('Related', 'Related'), ('Duplicate of', 'Duplicate of'), ('Parent of', 'Parent of')], max_length=20)),
|
||||
('note', models.TextField(blank=True, default='', validators=[django.core.validators.MaxLengthValidator(500)])),
|
||||
('pair_key', models.CharField(editable=False, max_length=73, unique=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_case_relationships', to=settings.AUTH_USER_MODEL)),
|
||||
('source_case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='outgoing_relationships', to='cases.case')),
|
||||
('target_case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incoming_relationships', to='cases.case')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'case_relationships',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['source_case', 'relationship_type'], name='case_rel_source_type_idx'), models.Index(fields=['target_case', 'relationship_type'], name='case_rel_target_type_idx')],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('source_case', models.F('target_case')), _negated=True), name='case_rel_no_self'), models.UniqueConstraint(condition=models.Q(('relationship_type', 'Parent of')), fields=('target_case',), name='case_rel_one_parent'), models.UniqueConstraint(condition=models.Q(('relationship_type', 'Duplicate of')), fields=('source_case',), name='case_rel_one_duplicate_target')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.core.validators import MaxLengthValidator
|
||||
from django.db import models
|
||||
|
||||
from apps.common.models import BaseModel
|
||||
@@ -60,6 +61,12 @@ class CaseVerdict(models.TextChoices):
|
||||
OTHER = "Other"
|
||||
|
||||
|
||||
class CaseRelationshipType(models.TextChoices):
|
||||
RELATED = "Related"
|
||||
DUPLICATE_OF = "Duplicate of", "Duplicate of"
|
||||
PARENT_OF = "Parent of", "Parent of"
|
||||
|
||||
|
||||
class CaseCategory(models.TextChoices):
|
||||
DLP = "DLP", "DLP"
|
||||
EMAIL = "Email"
|
||||
@@ -115,6 +122,72 @@ class Case(BaseModel):
|
||||
class Meta:
|
||||
db_table = "cases"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["-created_at", "-id"], name="case_created_id_idx"),
|
||||
models.Index(fields=["status", "severity"], name="case_status_severity_idx"),
|
||||
models.Index(fields=["updated_at"], name="case_updated_at_idx"),
|
||||
models.Index(fields=["acknowledged_time"], name="case_ack_time_idx"),
|
||||
models.Index(fields=["closed_time"], name="case_closed_time_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title or str(self.id)
|
||||
|
||||
|
||||
class CaseRelationship(BaseModel):
|
||||
source_case = models.ForeignKey(
|
||||
Case,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="outgoing_relationships",
|
||||
)
|
||||
target_case = models.ForeignKey(
|
||||
Case,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="incoming_relationships",
|
||||
)
|
||||
relationship_type = models.CharField(max_length=20, choices=CaseRelationshipType)
|
||||
note = models.TextField(blank=True, default="", validators=[MaxLengthValidator(500)])
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_case_relationships",
|
||||
)
|
||||
pair_key = models.CharField(max_length=73, unique=True, editable=False)
|
||||
|
||||
@staticmethod
|
||||
def build_pair_key(source_case_id, target_case_id):
|
||||
first, second = sorted((str(source_case_id), str(target_case_id)))
|
||||
return f"{first}:{second}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pair_key = self.build_pair_key(self.source_case_id, self.target_case_id)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
db_table = "case_relationships"
|
||||
ordering = ["-created_at"]
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=~models.Q(source_case=models.F("target_case")),
|
||||
name="case_rel_no_self",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["target_case"],
|
||||
condition=models.Q(relationship_type=CaseRelationshipType.PARENT_OF),
|
||||
name="case_rel_one_parent",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["source_case"],
|
||||
condition=models.Q(relationship_type=CaseRelationshipType.DUPLICATE_OF),
|
||||
name="case_rel_one_duplicate_target",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["source_case", "relationship_type"], name="case_rel_source_type_idx"),
|
||||
models.Index(fields=["target_case", "relationship_type"], name="case_rel_target_type_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.source_case.case_id} {self.relationship_type} {self.target_case.case_id}"
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.alerts.serializers import AlertSerializer
|
||||
from apps.enrichments.models import Enrichment
|
||||
from apps.inbox.notifications import notify_case_assignment
|
||||
from .models import Case, CaseStatus
|
||||
from .models import Case, CaseRelationship, CaseRelationshipType, CaseStatus
|
||||
from .services import case_summary, validate_relationship
|
||||
|
||||
|
||||
class CaseSerializer(serializers.ModelSerializer):
|
||||
alerts = AlertSerializer(many=True, read_only=True)
|
||||
alert_count = serializers.IntegerField(read_only=True, default=0)
|
||||
playbook_count = serializers.IntegerField(read_only=True, default=0)
|
||||
enrichment_count = serializers.SerializerMethodField()
|
||||
class CaseDetailSerializer(serializers.ModelSerializer):
|
||||
assignee_name = serializers.SerializerMethodField()
|
||||
first_alert_seen_time = serializers.SerializerMethodField()
|
||||
detection_time_seconds = serializers.SerializerMethodField()
|
||||
acknowledgement_time_seconds = serializers.SerializerMethodField()
|
||||
response_time_seconds = serializers.SerializerMethodField()
|
||||
relationship_count = serializers.IntegerField(read_only=True, default=0)
|
||||
|
||||
def _get_user_name(self, user):
|
||||
if not user:
|
||||
@@ -26,9 +23,6 @@ class CaseSerializer(serializers.ModelSerializer):
|
||||
def get_assignee_name(self, obj):
|
||||
return self._get_user_name(obj.assignee)
|
||||
|
||||
def get_enrichment_count(self, obj):
|
||||
return Enrichment.objects.filter(case=obj).count()
|
||||
|
||||
def _duration_seconds(self, start, end):
|
||||
if not start or not end:
|
||||
return None
|
||||
@@ -87,11 +81,203 @@ class CaseSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Case
|
||||
fields = "__all__"
|
||||
fields = (
|
||||
"id",
|
||||
"case_id",
|
||||
"title",
|
||||
"severity",
|
||||
"impact",
|
||||
"priority",
|
||||
"confidence",
|
||||
"description",
|
||||
"category",
|
||||
"tags",
|
||||
"status",
|
||||
"verdict",
|
||||
"summary",
|
||||
"assignee",
|
||||
"assignee_name",
|
||||
"acknowledged_time",
|
||||
"closed_time",
|
||||
"correlation_uid",
|
||||
"severity_ai",
|
||||
"confidence_ai",
|
||||
"impact_ai",
|
||||
"priority_ai",
|
||||
"verdict_ai",
|
||||
"first_alert_seen_time",
|
||||
"detection_time_seconds",
|
||||
"acknowledgement_time_seconds",
|
||||
"response_time_seconds",
|
||||
"relationship_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("id", "case_id", "created_at", "updated_at")
|
||||
|
||||
|
||||
class CaseListSerializer(CaseSerializer):
|
||||
class Meta(CaseSerializer.Meta):
|
||||
exclude = ("investigation_report_ai_json",)
|
||||
fields = None
|
||||
class CaseListSerializer(CaseDetailSerializer):
|
||||
alert_count = serializers.IntegerField(read_only=True, default=0)
|
||||
playbook_count = serializers.IntegerField(read_only=True, default=0)
|
||||
enrichment_count = serializers.IntegerField(read_only=True, default=0)
|
||||
|
||||
class Meta(CaseDetailSerializer.Meta):
|
||||
fields = (
|
||||
"id",
|
||||
"case_id",
|
||||
"title",
|
||||
"severity",
|
||||
"impact",
|
||||
"priority",
|
||||
"confidence",
|
||||
"description",
|
||||
"category",
|
||||
"tags",
|
||||
"status",
|
||||
"verdict",
|
||||
"summary",
|
||||
"assignee",
|
||||
"assignee_name",
|
||||
"acknowledged_time",
|
||||
"closed_time",
|
||||
"correlation_uid",
|
||||
"severity_ai",
|
||||
"confidence_ai",
|
||||
"impact_ai",
|
||||
"priority_ai",
|
||||
"verdict_ai",
|
||||
"alert_count",
|
||||
"playbook_count",
|
||||
"enrichment_count",
|
||||
"first_alert_seen_time",
|
||||
"detection_time_seconds",
|
||||
"acknowledgement_time_seconds",
|
||||
"response_time_seconds",
|
||||
"relationship_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
class CaseRelationshipSerializer(serializers.ModelSerializer):
|
||||
source_case_id = serializers.PrimaryKeyRelatedField(
|
||||
source="source_case",
|
||||
queryset=Case.objects.all(),
|
||||
write_only=True,
|
||||
)
|
||||
target_case_id = serializers.PrimaryKeyRelatedField(
|
||||
source="target_case",
|
||||
queryset=Case.objects.all(),
|
||||
write_only=True,
|
||||
)
|
||||
source_case = serializers.SerializerMethodField()
|
||||
target_case = serializers.SerializerMethodField()
|
||||
created_by = serializers.CharField(source="created_by.username", read_only=True, default="")
|
||||
|
||||
class Meta:
|
||||
model = CaseRelationship
|
||||
fields = (
|
||||
"id",
|
||||
"source_case_id",
|
||||
"target_case_id",
|
||||
"source_case",
|
||||
"target_case",
|
||||
"relationship_type",
|
||||
"note",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("id", "created_at", "updated_at")
|
||||
extra_kwargs = {
|
||||
"note": {"allow_blank": True, "max_length": 500},
|
||||
}
|
||||
|
||||
def get_source_case(self, obj):
|
||||
return case_summary(obj.source_case)
|
||||
|
||||
def get_target_case(self, obj):
|
||||
return case_summary(obj.target_case)
|
||||
|
||||
def validate(self, attrs):
|
||||
source_case = attrs.get("source_case", getattr(self.instance, "source_case", None))
|
||||
target_case = attrs.get("target_case", getattr(self.instance, "target_case", None))
|
||||
relationship_type = attrs.get(
|
||||
"relationship_type",
|
||||
getattr(self.instance, "relationship_type", None),
|
||||
)
|
||||
if source_case is None:
|
||||
raise serializers.ValidationError({"source_case_id": ["This field is required."]})
|
||||
if target_case is None:
|
||||
raise serializers.ValidationError({"target_case_id": ["This field is required."]})
|
||||
if self.instance and {
|
||||
source_case.id,
|
||||
target_case.id,
|
||||
} != {
|
||||
self.instance.source_case_id,
|
||||
self.instance.target_case_id,
|
||||
}:
|
||||
raise serializers.ValidationError(
|
||||
{"target_case_id": ["The related Case cannot be changed."]}
|
||||
)
|
||||
|
||||
if relationship_type == CaseRelationshipType.RELATED and str(source_case.id) > str(target_case.id):
|
||||
source_case, target_case = target_case, source_case
|
||||
attrs["source_case"] = source_case
|
||||
attrs["target_case"] = target_case
|
||||
|
||||
validate_relationship(
|
||||
source_case,
|
||||
target_case,
|
||||
relationship_type,
|
||||
getattr(self.instance, "id", None),
|
||||
)
|
||||
return attrs
|
||||
|
||||
def _locked_cases(self, source_case, target_case):
|
||||
case_ids = sorted((source_case.id, target_case.id), key=str)
|
||||
locked = {
|
||||
case.id: case
|
||||
for case in Case.objects.select_for_update().filter(pk__in=case_ids)
|
||||
}
|
||||
return locked[source_case.id], locked[target_case.id]
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
source_case, target_case = self._locked_cases(
|
||||
validated_data["source_case"],
|
||||
validated_data["target_case"],
|
||||
)
|
||||
validate_relationship(
|
||||
source_case,
|
||||
target_case,
|
||||
validated_data["relationship_type"],
|
||||
)
|
||||
request = self.context.get("request")
|
||||
validated_data["source_case"] = source_case
|
||||
validated_data["target_case"] = target_case
|
||||
validated_data["created_by"] = (
|
||||
request.user if request and request.user.is_authenticated else None
|
||||
)
|
||||
try:
|
||||
return super().create(validated_data)
|
||||
except IntegrityError as exc:
|
||||
raise serializers.ValidationError(
|
||||
{"detail": ["The relationship conflicts with an existing relationship."]}
|
||||
) from exc
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
source_case = validated_data.get("source_case", instance.source_case)
|
||||
target_case = validated_data.get("target_case", instance.target_case)
|
||||
source_case, target_case = self._locked_cases(source_case, target_case)
|
||||
relationship_type = validated_data.get("relationship_type", instance.relationship_type)
|
||||
validate_relationship(source_case, target_case, relationship_type, instance.id)
|
||||
validated_data["source_case"] = source_case
|
||||
validated_data["target_case"] = target_case
|
||||
try:
|
||||
return super().update(instance, validated_data)
|
||||
except IntegrityError as exc:
|
||||
raise serializers.ValidationError(
|
||||
{"detail": ["The relationship conflicts with an existing relationship."]}
|
||||
) from exc
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db import OperationalError, connection, transaction
|
||||
from django.db.models import Count, Max, Q, Subquery
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import APIException, ValidationError
|
||||
|
||||
from apps.alerts.models import Alert
|
||||
from apps.artifacts.models import Artifact
|
||||
|
||||
from .models import Case, CaseRelationship, CaseRelationshipType
|
||||
|
||||
SUGGESTION_ARTIFACT_LIMIT = 20
|
||||
SUGGESTION_QUERY_TIMEOUT_MS = 3000
|
||||
|
||||
|
||||
class SuggestionQueryTimeout(APIException):
|
||||
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
default_detail = "Case relationship suggestions timed out because the dataset is too large."
|
||||
default_code = "suggestion_query_timeout"
|
||||
|
||||
|
||||
def case_summary(case):
|
||||
return {
|
||||
"id": str(case.id),
|
||||
"case_id": case.case_id,
|
||||
"title": case.title,
|
||||
"status": case.status,
|
||||
"severity": case.severity,
|
||||
"verdict": case.verdict,
|
||||
"assignee_id": case.assignee_id,
|
||||
"assignee_name": (
|
||||
case.assignee.get_full_name() or case.assignee.username
|
||||
if case.assignee_id
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def relationships_for_case(case):
|
||||
return (
|
||||
CaseRelationship.objects
|
||||
.filter(Q(source_case=case) | Q(target_case=case))
|
||||
.select_related("source_case__assignee", "target_case__assignee", "created_by")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
|
||||
def relationship_for_case_payload(relationship, case):
|
||||
is_source = relationship.source_case_id == case.id
|
||||
related_case = relationship.target_case if is_source else relationship.source_case
|
||||
if relationship.relationship_type == CaseRelationshipType.RELATED:
|
||||
relation = CaseRelationshipType.RELATED
|
||||
elif relationship.relationship_type == CaseRelationshipType.DUPLICATE_OF:
|
||||
relation = "Duplicate of" if is_source else "Has duplicate"
|
||||
else:
|
||||
relation = "Parent of" if is_source else "Child of"
|
||||
return {
|
||||
"id": str(relationship.id),
|
||||
"relationship_type": relationship.relationship_type,
|
||||
"relation": relation,
|
||||
"related_case": case_summary(related_case),
|
||||
"note": relationship.note,
|
||||
"created_by": relationship.created_by.username if relationship.created_by else "",
|
||||
"created_at": relationship.created_at.isoformat(),
|
||||
"updated_at": relationship.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _excluding(queryset, relationship_id):
|
||||
if relationship_id:
|
||||
return queryset.exclude(pk=relationship_id)
|
||||
return queryset
|
||||
|
||||
|
||||
def _validate_parent_relationship(source_case, target_case, relationship_id):
|
||||
existing_parent = _excluding(
|
||||
CaseRelationship.objects.filter(
|
||||
relationship_type=CaseRelationshipType.PARENT_OF,
|
||||
target_case=target_case,
|
||||
),
|
||||
relationship_id,
|
||||
)
|
||||
if existing_parent.exists():
|
||||
raise ValidationError({"target_case_id": ["This Case already has a parent."]})
|
||||
|
||||
current_id = source_case.id
|
||||
visited = set()
|
||||
while current_id and current_id not in visited:
|
||||
if current_id == target_case.id:
|
||||
raise ValidationError({"target_case_id": ["Parent relationships cannot form a cycle."]})
|
||||
visited.add(current_id)
|
||||
parent_id = (
|
||||
_excluding(
|
||||
CaseRelationship.objects.filter(
|
||||
relationship_type=CaseRelationshipType.PARENT_OF,
|
||||
target_case_id=current_id,
|
||||
),
|
||||
relationship_id,
|
||||
)
|
||||
.values_list("source_case_id", flat=True)
|
||||
.first()
|
||||
)
|
||||
current_id = parent_id
|
||||
|
||||
|
||||
def _validate_duplicate_relationship(source_case, target_case, relationship_id):
|
||||
source_duplicates = _excluding(
|
||||
CaseRelationship.objects.filter(
|
||||
relationship_type=CaseRelationshipType.DUPLICATE_OF,
|
||||
source_case=source_case,
|
||||
),
|
||||
relationship_id,
|
||||
)
|
||||
if source_duplicates.exists():
|
||||
raise ValidationError({"source_case_id": ["This Case already has a canonical Case."]})
|
||||
|
||||
target_is_duplicate = _excluding(
|
||||
CaseRelationship.objects.filter(
|
||||
relationship_type=CaseRelationshipType.DUPLICATE_OF,
|
||||
source_case=target_case,
|
||||
),
|
||||
relationship_id,
|
||||
)
|
||||
if target_is_duplicate.exists():
|
||||
raise ValidationError({"target_case_id": ["The canonical Case cannot itself be a duplicate."]})
|
||||
|
||||
source_is_canonical = _excluding(
|
||||
CaseRelationship.objects.filter(
|
||||
relationship_type=CaseRelationshipType.DUPLICATE_OF,
|
||||
target_case=source_case,
|
||||
),
|
||||
relationship_id,
|
||||
)
|
||||
if source_is_canonical.exists():
|
||||
raise ValidationError({"source_case_id": ["A canonical Case cannot become a duplicate."]})
|
||||
|
||||
|
||||
def validate_relationship(source_case, target_case, relationship_type, relationship_id=None):
|
||||
if source_case.id == target_case.id:
|
||||
raise ValidationError({"target_case_id": ["A Case cannot be related to itself."]})
|
||||
|
||||
pair_key = CaseRelationship.build_pair_key(source_case.id, target_case.id)
|
||||
same_pair = _excluding(
|
||||
CaseRelationship.objects.filter(pair_key=pair_key),
|
||||
relationship_id,
|
||||
)
|
||||
if same_pair.exists():
|
||||
raise ValidationError({"target_case_id": ["These Cases already have a relationship."]})
|
||||
|
||||
if relationship_type == CaseRelationshipType.PARENT_OF:
|
||||
_validate_parent_relationship(source_case, target_case, relationship_id)
|
||||
elif relationship_type == CaseRelationshipType.DUPLICATE_OF:
|
||||
_validate_duplicate_relationship(source_case, target_case, relationship_id)
|
||||
|
||||
|
||||
def _suggest_related_cases(case, limit):
|
||||
source_artifact_ids = (
|
||||
Alert.artifacts.through.objects
|
||||
.filter(alert__case=case)
|
||||
.values("artifact_id")
|
||||
.annotate(last_link_id=Max("id"))
|
||||
.order_by("-last_link_id")
|
||||
.values("artifact_id")[:SUGGESTION_ARTIFACT_LIMIT]
|
||||
)
|
||||
related_case_ids = set()
|
||||
for source_case_id, target_case_id in (
|
||||
CaseRelationship.objects
|
||||
.filter(Q(source_case=case) | Q(target_case=case))
|
||||
.values_list("source_case_id", "target_case_id")
|
||||
):
|
||||
related_case_ids.add(source_case_id)
|
||||
related_case_ids.add(target_case_id)
|
||||
|
||||
candidates = list(
|
||||
Case.objects
|
||||
.select_related("assignee")
|
||||
.exclude(pk__in=related_case_ids | {case.id})
|
||||
.filter(alerts__artifacts__id__in=Subquery(source_artifact_ids))
|
||||
.annotate(
|
||||
shared_artifact_count=Count(
|
||||
"alerts__artifacts",
|
||||
filter=Q(alerts__artifacts__id__in=Subquery(source_artifact_ids)),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.order_by("-shared_artifact_count", "-updated_at", "id")[:limit]
|
||||
)
|
||||
|
||||
evidence_by_case = defaultdict(list)
|
||||
for candidate in candidates:
|
||||
evidence_by_case[candidate.id] = list(
|
||||
Artifact.objects
|
||||
.filter(
|
||||
id__in=Subquery(source_artifact_ids),
|
||||
alerts__case=candidate,
|
||||
)
|
||||
.order_by("type", "value", "id")
|
||||
.values("id", "type", "value")
|
||||
.distinct()[:3]
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"case": case_summary(candidate),
|
||||
"shared_artifact_count": candidate.shared_artifact_count,
|
||||
"shared_artifacts": [
|
||||
{
|
||||
"id": str(artifact["id"]),
|
||||
"type": artifact["type"],
|
||||
"value": artifact["value"],
|
||||
}
|
||||
for artifact in evidence_by_case[candidate.id]
|
||||
],
|
||||
}
|
||||
for candidate in candidates
|
||||
]
|
||||
|
||||
|
||||
def suggest_related_cases(case, limit=10):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT set_config('statement_timeout', %s, true)",
|
||||
[f"{SUGGESTION_QUERY_TIMEOUT_MS}ms"],
|
||||
)
|
||||
return _suggest_related_cases(case, limit)
|
||||
except OperationalError as exc:
|
||||
cause = exc.__cause__
|
||||
if (
|
||||
getattr(cause, "sqlstate", None) == "57014"
|
||||
or getattr(cause, "pgcode", None) == "57014"
|
||||
):
|
||||
raise SuggestionQueryTimeout() from exc
|
||||
raise
|
||||
@@ -1,9 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import CaseViewSet
|
||||
from .views import CaseRelationshipViewSet, CaseViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("cases", CaseViewSet, basename="case")
|
||||
router.register("case-relationships", CaseRelationshipViewSet, basename="case-relationship")
|
||||
|
||||
urlpatterns = [path("", include(router.urls))]
|
||||
|
||||
+167
-15
@@ -1,22 +1,29 @@
|
||||
from django.db.models import Count, Min
|
||||
from uuid import UUID
|
||||
|
||||
from django.db.models import Count, DateTimeField, IntegerField, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import viewsets, permissions
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.accounts.permissions import IsBusinessWriterOrReadOnly
|
||||
from apps.alerts.models import Alert
|
||||
from apps.audit.context import audit_actor
|
||||
from apps.audit.mixins import AuditActorMixin
|
||||
from apps.common.advanced_filters import AdvancedFilterBackend
|
||||
from .models import Case
|
||||
from .serializers import CaseListSerializer, CaseSerializer
|
||||
from apps.enrichments.models import Enrichment
|
||||
from apps.playbooks.models import Playbook
|
||||
from .models import Case, CaseRelationship
|
||||
from .serializers import CaseDetailSerializer, CaseListSerializer, CaseRelationshipSerializer
|
||||
from .services import suggest_related_cases
|
||||
|
||||
|
||||
class CaseViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
queryset = Case.objects.select_related("assignee").annotate(
|
||||
alert_count=Count("alerts", distinct=True),
|
||||
playbook_count=Count("playbooks", distinct=True),
|
||||
first_alert_seen_time=Min("alerts__first_seen_time"),
|
||||
).order_by("-created_at")
|
||||
serializer_class = CaseSerializer
|
||||
queryset = Case.objects.select_related("assignee").order_by("-created_at")
|
||||
serializer_class = CaseDetailSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly]
|
||||
lookup_field = "id"
|
||||
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
|
||||
@@ -26,8 +33,6 @@ class CaseViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
"updated_at",
|
||||
"acknowledged_time",
|
||||
"closed_time",
|
||||
"alert_count",
|
||||
"playbook_count",
|
||||
"severity",
|
||||
"severity_ai",
|
||||
"priority",
|
||||
@@ -71,13 +76,160 @@ class CaseViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
"correlation_uid": "text",
|
||||
}
|
||||
|
||||
def annotate_list_metrics(self, queryset):
|
||||
alert_count = (
|
||||
Alert.objects
|
||||
.filter(case_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("case_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
playbook_count = (
|
||||
Playbook.objects
|
||||
.filter(case_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("case_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
first_alert_seen_time = (
|
||||
Alert.objects
|
||||
.filter(case_id=OuterRef("pk"), first_seen_time__isnull=False)
|
||||
.order_by("first_seen_time")
|
||||
.values("first_seen_time")[:1]
|
||||
)
|
||||
enrichment_count = (
|
||||
Enrichment.objects
|
||||
.filter(case_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("case_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
outgoing_relationship_count = (
|
||||
CaseRelationship.objects
|
||||
.filter(source_case_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("source_case_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
incoming_relationship_count = (
|
||||
CaseRelationship.objects
|
||||
.filter(target_case_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("target_case_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
return queryset.annotate(
|
||||
alert_count=Coalesce(Subquery(alert_count, output_field=IntegerField()), Value(0)),
|
||||
playbook_count=Coalesce(Subquery(playbook_count, output_field=IntegerField()), Value(0)),
|
||||
enrichment_count=Coalesce(Subquery(enrichment_count, output_field=IntegerField()), Value(0)),
|
||||
first_alert_seen_time=Subquery(first_alert_seen_time, output_field=DateTimeField()),
|
||||
relationship_count=(
|
||||
Coalesce(Subquery(outgoing_relationship_count, output_field=IntegerField()), Value(0))
|
||||
+ Coalesce(Subquery(incoming_relationship_count, output_field=IntegerField()), Value(0))
|
||||
),
|
||||
)
|
||||
|
||||
def annotate_detail_metrics(self, queryset):
|
||||
first_alert_seen_time = (
|
||||
Alert.objects
|
||||
.filter(case_id=OuterRef("pk"), first_seen_time__isnull=False)
|
||||
.order_by("first_seen_time")
|
||||
.values("first_seen_time")[:1]
|
||||
)
|
||||
outgoing_relationship_count = (
|
||||
CaseRelationship.objects
|
||||
.filter(source_case_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("source_case_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
incoming_relationship_count = (
|
||||
CaseRelationship.objects
|
||||
.filter(target_case_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("target_case_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
return queryset.annotate(
|
||||
first_alert_seen_time=Subquery(first_alert_seen_time, output_field=DateTimeField()),
|
||||
relationship_count=(
|
||||
Coalesce(Subquery(outgoing_relationship_count, output_field=IntegerField()), Value(0))
|
||||
+ Coalesce(Subquery(incoming_relationship_count, output_field=IntegerField()), Value(0))
|
||||
),
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.action == "list":
|
||||
return queryset.defer("investigation_report_ai_json")
|
||||
return queryset
|
||||
return self.annotate_list_metrics(super().get_queryset()).defer("investigation_report_ai_json")
|
||||
if self.action in {"retrieve", "update", "partial_update"}:
|
||||
return self.annotate_detail_metrics(super().get_queryset()).defer("investigation_report_ai_json")
|
||||
return super().get_queryset()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return CaseListSerializer
|
||||
return CaseSerializer
|
||||
return CaseDetailSerializer
|
||||
|
||||
@action(detail=True, methods=["get", "patch"], url_path="investigation")
|
||||
def investigation(self, request, *args, **kwargs):
|
||||
case = self.get_object()
|
||||
if request.method == "PATCH":
|
||||
value = request.data.get("investigation_report_ai_json", "")
|
||||
with audit_actor(request.user):
|
||||
case.investigation_report_ai_json = value
|
||||
case.save(update_fields=["investigation_report_ai_json", "updated_at"])
|
||||
return Response({
|
||||
"id": str(case.id),
|
||||
"case_id": case.case_id,
|
||||
"investigation_report_ai_json": case.investigation_report_ai_json,
|
||||
})
|
||||
|
||||
|
||||
class CaseRelationshipViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
queryset = CaseRelationship.objects.select_related(
|
||||
"source_case__assignee",
|
||||
"target_case__assignee",
|
||||
"created_by",
|
||||
).order_by("-created_at")
|
||||
serializer_class = CaseRelationshipSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly]
|
||||
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter)
|
||||
filterset_fields = ("relationship_type",)
|
||||
search_fields = (
|
||||
"source_case__case_id",
|
||||
"source_case__title",
|
||||
"target_case__case_id",
|
||||
"target_case__title",
|
||||
"note",
|
||||
"created_by__username",
|
||||
)
|
||||
ordering_fields = ("relationship_type", "created_at", "updated_at")
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
case_id = self.request.query_params.get("case")
|
||||
if case_id:
|
||||
try:
|
||||
case_id = UUID(case_id)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError({"case": ["Invalid Case ID."]})
|
||||
queryset = queryset.filter(Q(source_case_id=case_id) | Q(target_case_id=case_id))
|
||||
return queryset
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="suggestions")
|
||||
def suggestions(self, request):
|
||||
case_id = request.query_params.get("case")
|
||||
if not case_id:
|
||||
raise ValidationError({"case": ["This query parameter is required."]})
|
||||
try:
|
||||
case = Case.objects.get(pk=UUID(case_id))
|
||||
except (Case.DoesNotExist, TypeError, ValueError):
|
||||
raise ValidationError({"case": ["Case not found."]})
|
||||
return Response({"results": suggest_related_cases(case)})
|
||||
|
||||
@@ -4,3 +4,6 @@ from django.apps import AppConfig
|
||||
class CommonConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.common"
|
||||
|
||||
def ready(self):
|
||||
from . import openapi # noqa: F401
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from rest_framework.views import exception_handler
|
||||
|
||||
from apps.common.operation_timeout import OperationTimeoutError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
@@ -50,6 +53,10 @@ def _query_params(request):
|
||||
return _redact({key: values if len(values) != 1 else values[0] for key, values in request.GET.lists()})
|
||||
|
||||
|
||||
def _request_id(request):
|
||||
return request.headers.get("X-Request-ID") or f"req_{uuid4().hex}"
|
||||
|
||||
|
||||
def _user_context(request):
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
@@ -71,6 +78,7 @@ def _event(exc, context, response=None):
|
||||
request = context.get("request")
|
||||
response_data = _serializable(getattr(response, "data", None)) if response is not None else None
|
||||
return {
|
||||
"request_id": _request_id(request) if request is not None else "",
|
||||
"method": getattr(request, "method", ""),
|
||||
"path": getattr(request, "path", ""),
|
||||
"query_params": _query_params(request) if request is not None else {},
|
||||
@@ -92,7 +100,9 @@ def custom_exception_handler(exc, context):
|
||||
logger.exception("Unhandled API exception: %s", event_json, extra={"api_error": event})
|
||||
return None
|
||||
|
||||
if response.status_code >= 500:
|
||||
if isinstance(exc, OperationTimeoutError):
|
||||
logger.warning("API request timed out: %s", event_json, extra={"api_error": event})
|
||||
elif response.status_code >= 500:
|
||||
logger.exception("API server error: %s", event_json, extra={"api_error": event})
|
||||
elif response.status_code >= 400:
|
||||
logger.warning("API request failed: %s", event_json, extra={"api_error": event})
|
||||
|
||||
@@ -16,6 +16,7 @@ LOG_ROLE_FILES = {
|
||||
"agentic-case-analysis-worker": "agentic-case-analysis-worker.log",
|
||||
"agentic-module-worker": "agentic-module-worker.log",
|
||||
"elk-action-worker": "elk-action-worker.log",
|
||||
"dashboard-cache-worker": "dashboard-cache-worker.log",
|
||||
}
|
||||
ROOT_PROCESS_FILE_LOGGERS = [""]
|
||||
SERVER_PROCESS_FILE_LOGGERS = {
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import connection, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.models import (
|
||||
Alert,
|
||||
AlertAction,
|
||||
AlertAnalyticState,
|
||||
AlertAnalyticType,
|
||||
AlertPolicyType,
|
||||
AlertRiskLevel,
|
||||
AlertStatus,
|
||||
AlertTactic,
|
||||
Confidence,
|
||||
Disposition,
|
||||
Impact,
|
||||
ProductCategory,
|
||||
Severity,
|
||||
)
|
||||
from apps.artifacts.models import Artifact, ArtifactName, ArtifactRole, ArtifactType
|
||||
from apps.audit.models import AuditLog
|
||||
from apps.cases.models import (
|
||||
Case,
|
||||
CaseCategory,
|
||||
CaseConfidence,
|
||||
CaseImpact,
|
||||
CasePriority,
|
||||
CaseSeverity,
|
||||
CaseStatus,
|
||||
CaseVerdict,
|
||||
)
|
||||
from apps.common.readable_ids import sync_readable_id_sequence
|
||||
from apps.enrichments.models import Enrichment, EnrichmentProvider, EnrichmentType
|
||||
from apps.knowledge.models import Knowledge, KnowledgeSource
|
||||
from apps.playbooks.models import Playbook, PlaybookJobStatus
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scale:
|
||||
cases: int
|
||||
alerts: int
|
||||
artifacts: int
|
||||
alert_artifact_links: int
|
||||
enrichments: int
|
||||
playbooks: int
|
||||
knowledge: int
|
||||
audit_logs: int
|
||||
|
||||
|
||||
SCALES = {
|
||||
"tiny": Scale(
|
||||
cases=20,
|
||||
alerts=200,
|
||||
artifacts=100,
|
||||
alert_artifact_links=600,
|
||||
enrichments=80,
|
||||
playbooks=40,
|
||||
knowledge=8,
|
||||
audit_logs=200,
|
||||
),
|
||||
"medium": Scale(
|
||||
cases=10_000,
|
||||
alerts=100_000,
|
||||
artifacts=50_000,
|
||||
alert_artifact_links=300_000,
|
||||
enrichments=30_000,
|
||||
playbooks=10_000,
|
||||
knowledge=2_000,
|
||||
audit_logs=100_000,
|
||||
),
|
||||
"large": Scale(
|
||||
cases=100_000,
|
||||
alerts=1_000_000,
|
||||
artifacts=500_000,
|
||||
alert_artifact_links=3_000_000,
|
||||
enrichments=250_000,
|
||||
playbooks=100_000,
|
||||
knowledge=20_000,
|
||||
audit_logs=1_000_000,
|
||||
),
|
||||
"extreme": Scale(
|
||||
cases=1_000_000,
|
||||
alerts=10_000_000,
|
||||
artifacts=5_000_000,
|
||||
alert_artifact_links=30_000_000,
|
||||
enrichments=2_500_000,
|
||||
playbooks=1_000_000,
|
||||
knowledge=200_000,
|
||||
audit_logs=10_000_000,
|
||||
),
|
||||
}
|
||||
|
||||
HOT_SEARCH_TOKEN = "perf-hot-auth"
|
||||
MID_SEARCH_TOKEN = "perf-mid-cloud"
|
||||
RARE_SEARCH_TOKEN = "perf-rare-000001"
|
||||
PERF_USER_COUNT = 20
|
||||
|
||||
|
||||
def stable_seed(value):
|
||||
return int(hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:16], 16)
|
||||
|
||||
|
||||
def run_slug(run_id):
|
||||
value = re.sub(r"[^a-z0-9]+", "-", run_id.lower()).strip("-")
|
||||
return value[:40] or "perf"
|
||||
|
||||
|
||||
def weighted_choice(rng, choices):
|
||||
total = sum(weight for _, weight in choices)
|
||||
marker = rng.uniform(0, total)
|
||||
upto = 0
|
||||
for value, weight in choices:
|
||||
upto += weight
|
||||
if upto >= marker:
|
||||
return value
|
||||
return choices[-1][0]
|
||||
|
||||
|
||||
def batched(iterable, size):
|
||||
batch = []
|
||||
for item in iterable:
|
||||
batch.append(item)
|
||||
if len(batch) >= size:
|
||||
yield batch
|
||||
batch = []
|
||||
if batch:
|
||||
yield batch
|
||||
|
||||
|
||||
def format_readable_id(prefix, number):
|
||||
return f"{prefix}_{number:06d}"
|
||||
|
||||
|
||||
def readable_start(model, field_name, prefix):
|
||||
marker = f"{prefix}_"
|
||||
max_number = 0
|
||||
for value in model.objects.exclude(**{field_name: ""}).values_list(field_name, flat=True).iterator(chunk_size=10_000):
|
||||
if not value or not value.startswith(marker):
|
||||
continue
|
||||
suffix = value[len(marker):]
|
||||
if suffix.isdigit():
|
||||
max_number = max(max_number, int(suffix))
|
||||
return max_number + 1
|
||||
|
||||
|
||||
def database_label():
|
||||
config = connection.settings_dict
|
||||
return (
|
||||
f"{config.get('ENGINE')} "
|
||||
f"host={config.get('HOST') or 'default'} "
|
||||
f"port={config.get('PORT') or 'default'} "
|
||||
f"name={config.get('NAME')} "
|
||||
f"user={config.get('USER')}"
|
||||
)
|
||||
|
||||
|
||||
def random_time_in_last_90_days(rng, now):
|
||||
marker = rng.random()
|
||||
if marker < 0.05:
|
||||
seconds = rng.randint(0, 24 * 60 * 60)
|
||||
elif marker < 0.20:
|
||||
seconds = rng.randint(24 * 60 * 60, 7 * 24 * 60 * 60)
|
||||
elif marker < 0.60:
|
||||
seconds = rng.randint(7 * 24 * 60 * 60, 30 * 24 * 60 * 60)
|
||||
else:
|
||||
seconds = rng.randint(30 * 24 * 60 * 60, 90 * 24 * 60 * 60)
|
||||
return now - timedelta(seconds=seconds)
|
||||
|
||||
|
||||
def token_for_index(index):
|
||||
if index == 0:
|
||||
return RARE_SEARCH_TOKEN
|
||||
if index % 17 == 0:
|
||||
return MID_SEARCH_TOKEN
|
||||
if index % 3 == 0:
|
||||
return HOT_SEARCH_TOKEN
|
||||
return "perf-normal"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate large deterministic performance-test data for local dedicated PostgreSQL databases."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--scale", choices=sorted(SCALES), default="tiny")
|
||||
parser.add_argument("--seed", default="20260710")
|
||||
parser.add_argument("--run-id", default="")
|
||||
parser.add_argument("--batch-size", type=int, default=5_000)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--reset-perf-data", action="store_true")
|
||||
parser.add_argument("--confirm-reset", action="store_true")
|
||||
parser.add_argument("--delete-run", default="")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
batch_size = options["batch_size"]
|
||||
if batch_size < 1:
|
||||
raise CommandError("--batch-size must be greater than zero.")
|
||||
|
||||
scale = SCALES[options["scale"]]
|
||||
run_id = options["run_id"] or timezone.now().strftime("perf-%Y%m%d%H%M%S")
|
||||
rng = random.Random(stable_seed(options["seed"]))
|
||||
|
||||
self.stdout.write(f"Database: {database_label()}")
|
||||
self.stdout.write(f"Scale: {options['scale']} {scale}")
|
||||
self.stdout.write(f"Run ID: {run_id}")
|
||||
|
||||
if options["delete_run"]:
|
||||
self.delete_run(options["delete_run"], dry_run=options["dry_run"])
|
||||
return
|
||||
|
||||
if options["reset_perf_data"]:
|
||||
if not options["confirm_reset"]:
|
||||
raise CommandError("--reset-perf-data requires --confirm-reset.")
|
||||
self.reset_perf_data(dry_run=options["dry_run"])
|
||||
|
||||
if options["dry_run"]:
|
||||
self.stdout.write(self.style.WARNING("Dry run only. No performance data generated."))
|
||||
return
|
||||
|
||||
self.generate(scale=scale, rng=rng, run_id=run_id, batch_size=batch_size)
|
||||
|
||||
def reset_perf_data(self, *, dry_run):
|
||||
tables = [
|
||||
"audit_logs",
|
||||
"enrichments",
|
||||
"playbooks",
|
||||
"knowledge",
|
||||
"alerts",
|
||||
"artifacts",
|
||||
"cases",
|
||||
]
|
||||
statement = f"TRUNCATE TABLE {', '.join(tables)} RESTART IDENTITY CASCADE"
|
||||
if dry_run:
|
||||
self.stdout.write(f"Would execute: {statement}")
|
||||
return
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(statement)
|
||||
self.stdout.write(self.style.WARNING("Reset performance data tables with TRUNCATE ... CASCADE."))
|
||||
|
||||
def delete_run(self, run_id, *, dry_run):
|
||||
slug = run_slug(run_id)
|
||||
run_tag = f"perf-run:{run_id}"
|
||||
artifact_value_pattern = f"%perf-{slug}-%"
|
||||
alert_filter = "labels @> %s::jsonb"
|
||||
artifact_filter = "value LIKE %s"
|
||||
statements = [
|
||||
("audit_logs", "DELETE FROM audit_logs WHERE metadata ->> 'run_id' = %s", [run_id]),
|
||||
("enrichments", "DELETE FROM enrichments WHERE data ->> 'run_id' = %s", [run_id]),
|
||||
("playbooks", "DELETE FROM playbooks WHERE job_id LIKE %s", [f"perf-{slug}-%"]),
|
||||
("knowledge", "DELETE FROM knowledge WHERE tags @> %s::jsonb", [json.dumps([run_tag])]),
|
||||
(
|
||||
"alerts_artifacts",
|
||||
(
|
||||
"DELETE FROM alerts_artifacts WHERE alert_id IN "
|
||||
f"(SELECT id FROM alerts WHERE {alert_filter}) "
|
||||
"OR artifact_id IN "
|
||||
f"(SELECT id FROM artifacts WHERE {artifact_filter})"
|
||||
),
|
||||
[json.dumps([run_tag]), artifact_value_pattern],
|
||||
),
|
||||
("alerts", "DELETE FROM alerts WHERE labels @> %s::jsonb", [json.dumps([run_tag])]),
|
||||
("cases", "DELETE FROM cases WHERE tags @> %s::jsonb", [json.dumps([run_tag])]),
|
||||
("artifacts", "DELETE FROM artifacts WHERE value LIKE %s", [artifact_value_pattern]),
|
||||
]
|
||||
if dry_run:
|
||||
for name, statement, params in statements:
|
||||
self.stdout.write(f"Would delete from {name}: {statement} {params}")
|
||||
return
|
||||
|
||||
with connection.cursor() as cursor, transaction.atomic():
|
||||
for name, statement, params in statements:
|
||||
cursor.execute(statement, params)
|
||||
self.stdout.write(f"Deleted {cursor.rowcount} rows from {name}.")
|
||||
|
||||
def generate(self, *, scale, rng, run_id, batch_size):
|
||||
now = timezone.now()
|
||||
slug = run_slug(run_id)
|
||||
run_tag = f"perf-run:{run_id}"
|
||||
users = self.ensure_perf_users(now)
|
||||
user_ids = [user.id for user in users]
|
||||
|
||||
readable_offsets = {
|
||||
"case": readable_start(Case, "case_id", "case"),
|
||||
"alert": readable_start(Alert, "alert_id", "alert"),
|
||||
"artifact": readable_start(Artifact, "artifact_id", "artifact"),
|
||||
"enrichment": readable_start(Enrichment, "enrichment_id", "enrichment"),
|
||||
"knowledge": readable_start(Knowledge, "knowledge_id", "knowledge"),
|
||||
"playbook": readable_start(Playbook, "playbook_id", "playbook"),
|
||||
}
|
||||
|
||||
case_ids, case_times = self.create_cases(
|
||||
scale=scale,
|
||||
rng=rng,
|
||||
now=now,
|
||||
run_id=run_id,
|
||||
run_tag=run_tag,
|
||||
readable_offset=readable_offsets["case"],
|
||||
user_ids=user_ids,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
artifact_ids = self.create_artifacts(
|
||||
scale=scale,
|
||||
slug=slug,
|
||||
run_tag=run_tag,
|
||||
readable_offset=readable_offsets["artifact"],
|
||||
batch_size=batch_size,
|
||||
)
|
||||
alert_ids = self.create_alerts(
|
||||
scale=scale,
|
||||
rng=rng,
|
||||
now=now,
|
||||
run_id=run_id,
|
||||
run_tag=run_tag,
|
||||
case_ids=case_ids,
|
||||
case_times=case_times,
|
||||
readable_offset=readable_offsets["alert"],
|
||||
batch_size=batch_size,
|
||||
)
|
||||
self.create_alert_artifact_links(
|
||||
scale=scale,
|
||||
rng=rng,
|
||||
alert_ids=alert_ids,
|
||||
artifact_ids=artifact_ids,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
self.create_enrichments(
|
||||
scale=scale,
|
||||
rng=rng,
|
||||
now=now,
|
||||
run_id=run_id,
|
||||
case_ids=case_ids,
|
||||
alert_ids=alert_ids,
|
||||
artifact_ids=artifact_ids,
|
||||
readable_offset=readable_offsets["enrichment"],
|
||||
batch_size=batch_size,
|
||||
)
|
||||
self.create_playbooks(
|
||||
scale=scale,
|
||||
rng=rng,
|
||||
now=now,
|
||||
slug=slug,
|
||||
case_ids=case_ids,
|
||||
user_ids=user_ids,
|
||||
readable_offset=readable_offsets["playbook"],
|
||||
batch_size=batch_size,
|
||||
)
|
||||
self.create_knowledge(
|
||||
scale=scale,
|
||||
now=now,
|
||||
run_id=run_id,
|
||||
run_tag=run_tag,
|
||||
case_ids=case_ids,
|
||||
readable_offset=readable_offsets["knowledge"],
|
||||
batch_size=batch_size,
|
||||
)
|
||||
self.create_audit_logs(
|
||||
scale=scale,
|
||||
rng=rng,
|
||||
now=now,
|
||||
run_id=run_id,
|
||||
case_ids=case_ids,
|
||||
alert_ids=alert_ids,
|
||||
artifact_ids=artifact_ids,
|
||||
user_ids=user_ids,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
self.sync_readable_id_sequences(readable_offsets, scale)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Generated performance data for run {run_id}."))
|
||||
|
||||
def sync_readable_id_sequences(self, readable_offsets, scale):
|
||||
sequence_targets = {
|
||||
"case": scale.cases,
|
||||
"alert": scale.alerts,
|
||||
"artifact": scale.artifacts,
|
||||
"enrichment": scale.enrichments,
|
||||
"knowledge": min(scale.knowledge, scale.cases),
|
||||
"playbook": scale.playbooks,
|
||||
}
|
||||
for prefix, count in sequence_targets.items():
|
||||
if count <= 0:
|
||||
continue
|
||||
sync_readable_id_sequence(prefix, readable_offsets[prefix] + count - 1)
|
||||
self.stdout.write("Synchronized readable ID sequences.")
|
||||
|
||||
def ensure_perf_users(self, now):
|
||||
User = get_user_model()
|
||||
users = []
|
||||
for index in range(PERF_USER_COUNT):
|
||||
username = f"perf_user_{index + 1:03d}"
|
||||
user, created = User.objects.get_or_create(
|
||||
username=username,
|
||||
defaults={
|
||||
"email": f"{username}@perf.local",
|
||||
"first_name": "Perf",
|
||||
"last_name": f"User {index + 1:03d}",
|
||||
"is_active": True,
|
||||
"date_joined": now,
|
||||
"password": "!",
|
||||
},
|
||||
)
|
||||
if created:
|
||||
self.stdout.write(f"Created reusable user {username}.")
|
||||
users.append(user)
|
||||
return users
|
||||
|
||||
def bulk_create(self, model, objects, *, batch_size, label):
|
||||
total = 0
|
||||
for batch in batched(objects, batch_size):
|
||||
model.objects.bulk_create(batch, batch_size=batch_size)
|
||||
total += len(batch)
|
||||
self.stdout.write(f"Created {total} {label}.")
|
||||
|
||||
def create_cases(self, *, scale, rng, now, run_id, run_tag, readable_offset, user_ids, batch_size):
|
||||
case_ids = [uuid.uuid4() for _ in range(scale.cases)]
|
||||
case_times = [random_time_in_last_90_days(rng, now) for _ in range(scale.cases)]
|
||||
statuses = [
|
||||
(CaseStatus.NEW, 30),
|
||||
(CaseStatus.IN_PROGRESS, 28),
|
||||
(CaseStatus.ON_HOLD, 10),
|
||||
(CaseStatus.RESOLVED, 20),
|
||||
(CaseStatus.CLOSED, 12),
|
||||
]
|
||||
severities = [
|
||||
(CaseSeverity.CRITICAL, 4),
|
||||
(CaseSeverity.HIGH, 16),
|
||||
(CaseSeverity.MEDIUM, 45),
|
||||
(CaseSeverity.LOW, 25),
|
||||
(CaseSeverity.INFORMATIONAL, 8),
|
||||
(CaseSeverity.UNKNOWN, 2),
|
||||
]
|
||||
categories = [
|
||||
CaseCategory.IAM,
|
||||
CaseCategory.EDR,
|
||||
CaseCategory.NDR,
|
||||
CaseCategory.CLOUD,
|
||||
CaseCategory.EMAIL,
|
||||
CaseCategory.WAF,
|
||||
CaseCategory.DLP,
|
||||
CaseCategory.SIEM,
|
||||
]
|
||||
|
||||
def objects():
|
||||
for index, case_id in enumerate(case_ids):
|
||||
created_at = case_times[index]
|
||||
status = weighted_choice(rng, statuses)
|
||||
acknowledged_time = None
|
||||
closed_time = None
|
||||
if status not in {CaseStatus.NEW}:
|
||||
acknowledged_time = created_at + timedelta(minutes=rng.randint(5, 240))
|
||||
if status in {CaseStatus.RESOLVED, CaseStatus.CLOSED}:
|
||||
closed_time = (acknowledged_time or created_at) + timedelta(hours=rng.randint(1, 72))
|
||||
severity = weighted_choice(rng, severities)
|
||||
token = token_for_index(index)
|
||||
category = categories[index % len(categories)]
|
||||
yield Case(
|
||||
id=case_id,
|
||||
case_id=format_readable_id("case", readable_offset + index),
|
||||
title=f"{token} performance case {index:08d}",
|
||||
severity=severity,
|
||||
impact=weighted_choice(rng, [(CaseImpact.CRITICAL, 5), (CaseImpact.HIGH, 20), (CaseImpact.MEDIUM, 45), (CaseImpact.LOW, 25), (CaseImpact.UNKNOWN, 5)]),
|
||||
priority=weighted_choice(rng, [(CasePriority.CRITICAL, 5), (CasePriority.HIGH, 20), (CasePriority.MEDIUM, 45), (CasePriority.LOW, 25), (CasePriority.UNKNOWN, 5)]),
|
||||
confidence=weighted_choice(rng, [(CaseConfidence.HIGH, 30), (CaseConfidence.MEDIUM, 50), (CaseConfidence.LOW, 15), (CaseConfidence.UNKNOWN, 5)]),
|
||||
description=f"{token} generated database performance case for {run_id}. Scenario {category}.",
|
||||
category=category,
|
||||
tags=[run_tag, token, category.lower()],
|
||||
status=status,
|
||||
verdict=weighted_choice(rng, [(CaseVerdict.UNKNOWN, 35), (CaseVerdict.TRUE_POSITIVE, 30), (CaseVerdict.SUSPICIOUS, 20), (CaseVerdict.FALSE_POSITIVE, 10), (CaseVerdict.SECURITY_RISK, 5)]),
|
||||
summary=f"Performance baseline summary {index:08d}",
|
||||
assignee_id=user_ids[index % len(user_ids)] if user_ids else None,
|
||||
acknowledged_time=acknowledged_time,
|
||||
closed_time=closed_time,
|
||||
correlation_uid=f"perf-corr-{run_id}-{index:08d}",
|
||||
severity_ai=severity,
|
||||
confidence_ai=CaseConfidence.HIGH if index % 4 == 0 else CaseConfidence.MEDIUM,
|
||||
impact_ai=CaseImpact.HIGH if severity in {CaseSeverity.CRITICAL, CaseSeverity.HIGH} else CaseImpact.MEDIUM,
|
||||
priority_ai=CasePriority.HIGH if severity in {CaseSeverity.CRITICAL, CaseSeverity.HIGH} else CasePriority.MEDIUM,
|
||||
verdict_ai=CaseVerdict.SUSPICIOUS if index % 5 == 0 else CaseVerdict.UNKNOWN,
|
||||
investigation_report_ai_json=json.dumps({"run_id": run_id, "index": index, "token": token}),
|
||||
created_at=created_at,
|
||||
updated_at=created_at + timedelta(minutes=rng.randint(0, 120)),
|
||||
)
|
||||
|
||||
self.bulk_create(Case, objects(), batch_size=batch_size, label="cases")
|
||||
return case_ids, case_times
|
||||
|
||||
def create_artifacts(self, *, scale, slug, run_tag, readable_offset, batch_size):
|
||||
artifact_ids = [uuid.uuid4() for _ in range(scale.artifacts)]
|
||||
artifact_types = [
|
||||
ArtifactType.HOSTNAME,
|
||||
ArtifactType.USER_NAME,
|
||||
ArtifactType.EMAIL_ADDRESS,
|
||||
ArtifactType.URL_STRING,
|
||||
ArtifactType.HASH,
|
||||
ArtifactType.PROCESS_NAME,
|
||||
ArtifactType.RESOURCE_UID,
|
||||
]
|
||||
artifact_names = [
|
||||
ArtifactName.SOURCE,
|
||||
ArtifactName.DESTINATION,
|
||||
ArtifactName.ACTOR,
|
||||
ArtifactName.TARGET,
|
||||
ArtifactName.AFFECTED,
|
||||
ArtifactName.RELATED,
|
||||
]
|
||||
artifact_roles = [ArtifactRole.ACTOR, ArtifactRole.TARGET, ArtifactRole.AFFECTED, ArtifactRole.RELATED]
|
||||
now = timezone.now()
|
||||
|
||||
def value_for(index, type_):
|
||||
token = token_for_index(index)
|
||||
prefix = f"perf-{slug}-{token}-{index:08d}"
|
||||
if type_ == ArtifactType.HOSTNAME:
|
||||
return f"{prefix}.corp.local"
|
||||
if type_ == ArtifactType.USER_NAME:
|
||||
return f"{prefix}-user"
|
||||
if type_ == ArtifactType.EMAIL_ADDRESS:
|
||||
return f"{prefix}@example.local"
|
||||
if type_ == ArtifactType.URL_STRING:
|
||||
return f"https://{prefix}.example.local/path"
|
||||
if type_ == ArtifactType.HASH:
|
||||
return f"{prefix}-{hashlib.sha256(prefix.encode('utf-8')).hexdigest()}"
|
||||
if type_ == ArtifactType.PROCESS_NAME:
|
||||
return f"{prefix}.exe"
|
||||
return f"{prefix}-resource"
|
||||
|
||||
def objects():
|
||||
for index, artifact_id in enumerate(artifact_ids):
|
||||
type_ = artifact_types[index % len(artifact_types)]
|
||||
yield Artifact(
|
||||
id=artifact_id,
|
||||
artifact_id=format_readable_id("artifact", readable_offset + index),
|
||||
name=artifact_names[index % len(artifact_names)],
|
||||
type=type_,
|
||||
role=artifact_roles[index % len(artifact_roles)],
|
||||
value=value_for(index, type_),
|
||||
created_at=now - timedelta(minutes=index % 100_000),
|
||||
updated_at=now - timedelta(minutes=index % 50_000),
|
||||
)
|
||||
|
||||
self.bulk_create(Artifact, objects(), batch_size=batch_size, label=f"artifacts tagged {run_tag}")
|
||||
return artifact_ids
|
||||
|
||||
def create_alerts(self, *, scale, rng, now, run_id, run_tag, case_ids, case_times, readable_offset, batch_size):
|
||||
alert_ids = [uuid.uuid4() for _ in range(scale.alerts)]
|
||||
hot_case_count = max(1, scale.cases // 1_000)
|
||||
statuses = [
|
||||
(AlertStatus.NEW, 35),
|
||||
(AlertStatus.IN_PROGRESS, 25),
|
||||
(AlertStatus.RESOLVED, 25),
|
||||
(AlertStatus.SUPPRESSED, 8),
|
||||
(AlertStatus.ARCHIVED, 7),
|
||||
]
|
||||
severities = [
|
||||
(Severity.CRITICAL, 3),
|
||||
(Severity.HIGH, 17),
|
||||
(Severity.MEDIUM, 45),
|
||||
(Severity.LOW, 27),
|
||||
(Severity.INFORMATIONAL, 6),
|
||||
(Severity.UNKNOWN, 2),
|
||||
]
|
||||
categories = list(ProductCategory)
|
||||
tactics = list(AlertTactic)
|
||||
|
||||
def objects():
|
||||
for index, alert_id in enumerate(alert_ids):
|
||||
if rng.random() < 0.15:
|
||||
case_index = rng.randrange(hot_case_count)
|
||||
else:
|
||||
case_index = rng.randrange(scale.cases)
|
||||
case_created_at = case_times[case_index]
|
||||
first_seen = case_created_at - timedelta(minutes=rng.randint(1, 360))
|
||||
last_seen = first_seen + timedelta(minutes=rng.randint(0, 240))
|
||||
created_at = case_created_at + timedelta(minutes=rng.randint(0, 30))
|
||||
severity = weighted_choice(rng, severities)
|
||||
category = categories[index % len(categories)]
|
||||
tactic = tactics[index % len(tactics)]
|
||||
token = token_for_index(index)
|
||||
yield Alert(
|
||||
id=alert_id,
|
||||
alert_id=format_readable_id("alert", readable_offset + index),
|
||||
case_id=case_ids[case_index],
|
||||
title=f"{token} generated alert {index:08d}",
|
||||
severity=severity,
|
||||
confidence=weighted_choice(rng, [(Confidence.HIGH, 30), (Confidence.MEDIUM, 50), (Confidence.LOW, 15), (Confidence.UNKNOWN, 5)]),
|
||||
impact=Impact.HIGH if severity in {Severity.CRITICAL, Severity.HIGH} else Impact.MEDIUM,
|
||||
disposition=weighted_choice(rng, [(Disposition.DETECTED, 40), (Disposition.BLOCKED, 25), (Disposition.ALLOWED, 20), (Disposition.QUARANTINED, 10), (Disposition.UNKNOWN, 5)]),
|
||||
action=weighted_choice(rng, [(AlertAction.OBSERVED, 45), (AlertAction.DENIED, 25), (AlertAction.ALLOWED, 20), (AlertAction.MODIFIED, 5), (AlertAction.UNKNOWN, 5)]),
|
||||
labels=[run_tag, token, category.lower(), tactic.lower().replace(" ", "-")],
|
||||
desc=f"{token} alert description for database read baseline run {run_id}.",
|
||||
first_seen_time=first_seen,
|
||||
last_seen_time=last_seen,
|
||||
rule_id=f"perf-rule-{index % 2_000:04d}",
|
||||
rule_name=f"{token} detection rule {index % 2_000:04d}",
|
||||
correlation_uid=f"perf-corr-{run_id}-{case_index:08d}",
|
||||
src_url=f"https://siem.local/alerts/{alert_id}",
|
||||
source_uid=f"perf-source-{run_id}-{index:08d}",
|
||||
data_sources=[category.lower(), "perf.telemetry"],
|
||||
analytic_name=f"perf analytic {index % 500:03d}",
|
||||
analytic_type=AlertAnalyticType.RULE,
|
||||
analytic_state=AlertAnalyticState.ACTIVE,
|
||||
analytic_desc=f"{token} analytic generated for performance test.",
|
||||
tactic=tactic,
|
||||
technique=f"T{1000 + (index % 500):04d}",
|
||||
sub_technique=f"T{1000 + (index % 500):04d}.{index % 10:03d}",
|
||||
mitigation="Generated mitigation guidance for performance testing.",
|
||||
product_category=category,
|
||||
product_vendor="PerfVendor",
|
||||
product_name=f"PerfProduct-{index % 12}",
|
||||
product_feature=f"Feature-{index % 20}",
|
||||
policy_name=f"Perf policy {index % 100}",
|
||||
policy_type=AlertPolicyType.ACCESS_CONTROL_POLICY,
|
||||
policy_desc="Generated policy text for database performance testing.",
|
||||
risk_level=AlertRiskLevel.CRITICAL if severity == Severity.CRITICAL else AlertRiskLevel.HIGH if severity == Severity.HIGH else AlertRiskLevel.MEDIUM,
|
||||
status=weighted_choice(rng, statuses),
|
||||
status_detail=f"{token} status detail",
|
||||
remediation="Review generated alert and close after benchmark.",
|
||||
unmapped={"run_id": run_id, "token": token, "case_index": case_index, "index": index},
|
||||
raw_data={
|
||||
"run_id": run_id,
|
||||
"event": {"id": str(alert_id), "index": index, "token": token},
|
||||
"network": {"src": f"10.{index % 255}.{(index // 255) % 255}.{index % 254 + 1}", "dst": f"172.16.{index % 255}.{index % 254 + 1}"},
|
||||
"process": {"name": f"perf-process-{index % 200}.exe", "pid": index % 65535},
|
||||
"message": f"{token} generated raw payload for database read performance baseline.",
|
||||
},
|
||||
created_at=created_at,
|
||||
updated_at=created_at + timedelta(minutes=rng.randint(0, 180)),
|
||||
)
|
||||
|
||||
self.bulk_create(Alert, objects(), batch_size=batch_size, label="alerts")
|
||||
return alert_ids
|
||||
|
||||
def create_alert_artifact_links(self, *, scale, rng, alert_ids, artifact_ids, batch_size):
|
||||
through = Alert.artifacts.through
|
||||
hot_artifact_count = max(1, scale.artifacts // 5_000)
|
||||
|
||||
def objects():
|
||||
for alert_index, alert_id in enumerate(alert_ids):
|
||||
link_count = 1 + (alert_index % 5)
|
||||
used = set()
|
||||
for link_index in range(link_count):
|
||||
if rng.random() < 0.20:
|
||||
artifact_index = rng.randrange(hot_artifact_count)
|
||||
else:
|
||||
artifact_index = rng.randrange(scale.artifacts)
|
||||
while artifact_index in used:
|
||||
artifact_index = (artifact_index + 1) % scale.artifacts
|
||||
used.add(artifact_index)
|
||||
yield through(alert_id=alert_id, artifact_id=artifact_ids[artifact_index])
|
||||
|
||||
self.bulk_create(through, objects(), batch_size=batch_size, label="alert-artifact links")
|
||||
|
||||
def create_enrichments(self, *, scale, rng, now, run_id, case_ids, alert_ids, artifact_ids, readable_offset, batch_size):
|
||||
enrichment_types = [
|
||||
EnrichmentType.THREAT_INTELLIGENCE,
|
||||
EnrichmentType.CMDB,
|
||||
EnrichmentType.REPUTATION,
|
||||
EnrichmentType.IDENTITY,
|
||||
EnrichmentType.BEHAVIOR,
|
||||
]
|
||||
providers = [
|
||||
EnrichmentProvider.MOCK,
|
||||
EnrichmentProvider.INTERNAL_CMDB,
|
||||
EnrichmentProvider.ASP,
|
||||
EnrichmentProvider.SPLUNK,
|
||||
EnrichmentProvider.ELASTIC,
|
||||
]
|
||||
|
||||
def objects():
|
||||
for index in range(scale.enrichments):
|
||||
target_type = index % 3
|
||||
case_id = case_ids[rng.randrange(scale.cases)] if target_type == 0 else None
|
||||
alert_id = alert_ids[rng.randrange(scale.alerts)] if target_type == 1 else None
|
||||
artifact_id = artifact_ids[rng.randrange(scale.artifacts)] if target_type == 2 else None
|
||||
token = token_for_index(index)
|
||||
created_at = random_time_in_last_90_days(rng, now)
|
||||
yield Enrichment(
|
||||
id=uuid.uuid4(),
|
||||
enrichment_id=format_readable_id("enrichment", readable_offset + index),
|
||||
name=f"{token} enrichment {index:08d}",
|
||||
type=enrichment_types[index % len(enrichment_types)],
|
||||
provider=providers[index % len(providers)],
|
||||
uid=f"perf:{run_id}:{index:08d}",
|
||||
value=f"{token}:value:{index:08d}",
|
||||
desc=f"{token} generated enrichment for performance benchmark.",
|
||||
data={"run_id": run_id, "token": token, "score": index % 100, "source": "perf-generator"},
|
||||
case_id=case_id,
|
||||
alert_id=alert_id,
|
||||
artifact_id=artifact_id,
|
||||
created_at=created_at,
|
||||
updated_at=created_at + timedelta(minutes=index % 240),
|
||||
)
|
||||
|
||||
self.bulk_create(Enrichment, objects(), batch_size=batch_size, label="enrichments")
|
||||
|
||||
def create_playbooks(self, *, scale, rng, now, slug, case_ids, user_ids, readable_offset, batch_size):
|
||||
names = ["Investigation", "Knowledge Extraction", "Threat Intelligence Enrichment", "CMDB Enrichment"]
|
||||
statuses = [
|
||||
(PlaybookJobStatus.SUCCESS, 55),
|
||||
(PlaybookJobStatus.FAILED, 10),
|
||||
(PlaybookJobStatus.PENDING, 20),
|
||||
(PlaybookJobStatus.RUNNING, 15),
|
||||
]
|
||||
|
||||
def objects():
|
||||
for index in range(scale.playbooks):
|
||||
created_at = random_time_in_last_90_days(rng, now)
|
||||
token = token_for_index(index)
|
||||
yield Playbook(
|
||||
id=uuid.uuid4(),
|
||||
playbook_id=format_readable_id("playbook", readable_offset + index),
|
||||
case_id=case_ids[rng.randrange(scale.cases)],
|
||||
name=names[index % len(names)],
|
||||
user_input=f"{token} generated playbook input",
|
||||
user_id=user_ids[index % len(user_ids)] if user_ids else None,
|
||||
job_status=weighted_choice(rng, statuses),
|
||||
job_id=f"perf-{slug}-{index:08d}",
|
||||
remark=f"{token} generated playbook remark",
|
||||
created_at=created_at,
|
||||
updated_at=created_at + timedelta(minutes=index % 180),
|
||||
)
|
||||
|
||||
self.bulk_create(Playbook, objects(), batch_size=batch_size, label="playbooks")
|
||||
|
||||
def create_knowledge(self, *, scale, now, run_id, run_tag, case_ids, readable_offset, batch_size):
|
||||
linked_count = min(scale.knowledge, len(case_ids))
|
||||
|
||||
def objects():
|
||||
for index in range(linked_count):
|
||||
created_at = now - timedelta(hours=index % (90 * 24))
|
||||
token = token_for_index(index)
|
||||
yield Knowledge(
|
||||
id=uuid.uuid4(),
|
||||
knowledge_id=format_readable_id("knowledge", readable_offset + index),
|
||||
title=f"{token} knowledge {index:08d}",
|
||||
body=f"{token} generated knowledge body for database performance run {run_id}.",
|
||||
expires_at=None if index % 5 else now + timedelta(days=30),
|
||||
source=KnowledgeSource.CASE,
|
||||
tags=[run_tag, token, "perf-knowledge"],
|
||||
case_id=case_ids[index],
|
||||
created_at=created_at,
|
||||
updated_at=created_at + timedelta(minutes=index % 120),
|
||||
)
|
||||
|
||||
self.bulk_create(Knowledge, objects(), batch_size=batch_size, label="knowledge records")
|
||||
|
||||
def create_audit_logs(self, *, scale, rng, now, run_id, case_ids, alert_ids, artifact_ids, user_ids, batch_size):
|
||||
content_types = {
|
||||
"case": ContentType.objects.get_for_model(Case).id,
|
||||
"alert": ContentType.objects.get_for_model(Alert).id,
|
||||
"artifact": ContentType.objects.get_for_model(Artifact).id,
|
||||
}
|
||||
resources = [
|
||||
("case", case_ids),
|
||||
("alert", alert_ids),
|
||||
("artifact", artifact_ids),
|
||||
]
|
||||
actions = [("create", 50), ("update", 40), ("delete", 10)]
|
||||
|
||||
def objects():
|
||||
for index in range(scale.audit_logs):
|
||||
resource_name, resource_ids = resources[index % len(resources)]
|
||||
object_id = resource_ids[rng.randrange(len(resource_ids))]
|
||||
token = token_for_index(index)
|
||||
created_at = random_time_in_last_90_days(rng, now)
|
||||
yield AuditLog(
|
||||
content_type_id=content_types[resource_name],
|
||||
object_id=str(object_id),
|
||||
action=weighted_choice(rng, actions),
|
||||
actor_id=user_ids[index % len(user_ids)] if user_ids and index % 7 else None,
|
||||
changes={"status": {"from": "New", "to": "In Progress"}, "token": token},
|
||||
metadata={"run_id": run_id, "perf": True, "resource": resource_name, "token": token},
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
self.bulk_create(AuditLog, objects(), batch_size=batch_size, label="audit logs")
|
||||
@@ -0,0 +1,261 @@
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import connection, reset_queries
|
||||
from django.db.models import CharField, Count, DateTimeField, IntegerField, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Cast, Coalesce, Concat
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.models import Alert
|
||||
from apps.artifacts.models import Artifact
|
||||
from apps.audit.models import AuditLog
|
||||
from apps.cases.models import Case
|
||||
from apps.common.cursor_pagination import paginate_created_at_cursor
|
||||
from apps.dashboard.views import build_dashboard_overview
|
||||
from apps.playbooks.models import Playbook
|
||||
|
||||
|
||||
HOT_SEARCH_TOKEN = "perf-hot-auth"
|
||||
MID_SEARCH_TOKEN = "perf-mid-cloud"
|
||||
RARE_SEARCH_TOKEN = "perf-rare-000001"
|
||||
|
||||
|
||||
def percentile(values, pct):
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
index = max(0, min(len(ordered) - 1, math.ceil((pct / 100) * len(ordered)) - 1))
|
||||
return ordered[index]
|
||||
|
||||
|
||||
def duration_stats(values):
|
||||
if not values:
|
||||
return {"min_ms": None, "avg_ms": None, "p95_ms": None, "max_ms": None}
|
||||
return {
|
||||
"min_ms": round(min(values), 2),
|
||||
"avg_ms": round(sum(values) / len(values), 2),
|
||||
"p95_ms": round(percentile(values, 95), 2),
|
||||
"max_ms": round(max(values), 2),
|
||||
}
|
||||
|
||||
|
||||
def database_label():
|
||||
config = connection.settings_dict
|
||||
return {
|
||||
"engine": config.get("ENGINE"),
|
||||
"host": config.get("HOST") or "default",
|
||||
"port": config.get("PORT") or "default",
|
||||
"name": str(config.get("NAME")),
|
||||
"user": str(config.get("USER")),
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run ORM-level smoke benchmarks against database read paths."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--iterations", type=int, default=3)
|
||||
parser.add_argument("--warmup", type=int, default=1)
|
||||
parser.add_argument("--page-size", type=int, default=100)
|
||||
parser.add_argument("--deep-offset", type=int, default=10_000)
|
||||
parser.add_argument("--run-id", default="")
|
||||
parser.add_argument("--output-dir", default="")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options["iterations"] < 1:
|
||||
raise CommandError("--iterations must be greater than zero.")
|
||||
if options["warmup"] < 0:
|
||||
raise CommandError("--warmup cannot be negative.")
|
||||
if options["page_size"] < 1:
|
||||
raise CommandError("--page-size must be greater than zero.")
|
||||
if options["deep_offset"] < 0:
|
||||
raise CommandError("--deep-offset cannot be negative.")
|
||||
|
||||
started_at = timezone.now()
|
||||
scenarios = self.scenarios(page_size=options["page_size"], deep_offset=options["deep_offset"])
|
||||
results = []
|
||||
|
||||
self.stdout.write(f"Database: {database_label()}")
|
||||
self.stdout.write(f"Running {len(scenarios)} scenarios, iterations={options['iterations']}, warmup={options['warmup']}")
|
||||
|
||||
for name, func in scenarios:
|
||||
result = self.measure(
|
||||
name,
|
||||
func,
|
||||
iterations=options["iterations"],
|
||||
warmup=options["warmup"],
|
||||
)
|
||||
results.append(result)
|
||||
if result.get("error"):
|
||||
self.stdout.write(self.style.ERROR(f"{name}: ERROR {result['error']}"))
|
||||
else:
|
||||
stats = result["duration"]
|
||||
self.stdout.write(
|
||||
f"{name}: avg={stats['avg_ms']}ms p95={stats['p95_ms']}ms "
|
||||
f"queries={result['query_count_avg']} rows={result['result_count']}"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"run_id": options["run_id"] or "",
|
||||
"started_at": started_at.isoformat(),
|
||||
"finished_at": timezone.now().isoformat(),
|
||||
"database": database_label(),
|
||||
"options": {
|
||||
"iterations": options["iterations"],
|
||||
"warmup": options["warmup"],
|
||||
"page_size": options["page_size"],
|
||||
"deep_offset": options["deep_offset"],
|
||||
},
|
||||
"results": results,
|
||||
}
|
||||
output_path = self.write_results(payload, output_dir=options["output_dir"], run_id=options["run_id"])
|
||||
self.stdout.write(self.style.SUCCESS(f"Wrote benchmark results to {output_path}"))
|
||||
|
||||
def measure(self, name, func, *, iterations, warmup):
|
||||
try:
|
||||
for _ in range(warmup):
|
||||
func()
|
||||
|
||||
durations = []
|
||||
query_counts = []
|
||||
result_count = None
|
||||
old_force_debug = connection.force_debug_cursor
|
||||
connection.force_debug_cursor = True
|
||||
try:
|
||||
for _ in range(iterations):
|
||||
reset_queries()
|
||||
started = time.perf_counter()
|
||||
result_count = func()
|
||||
durations.append((time.perf_counter() - started) * 1000)
|
||||
query_counts.append(len(connection.queries))
|
||||
finally:
|
||||
connection.force_debug_cursor = old_force_debug
|
||||
reset_queries()
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"duration": duration_stats(durations),
|
||||
"query_count_min": min(query_counts) if query_counts else None,
|
||||
"query_count_avg": round(sum(query_counts) / len(query_counts), 2) if query_counts else None,
|
||||
"query_count_max": max(query_counts) if query_counts else None,
|
||||
"result_count": result_count,
|
||||
"error": "",
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001 - benchmark records scenario failures and continues.
|
||||
return {
|
||||
"name": name,
|
||||
"duration": duration_stats([]),
|
||||
"query_count_min": None,
|
||||
"query_count_avg": None,
|
||||
"query_count_max": None,
|
||||
"result_count": None,
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
|
||||
def scenarios(self, *, page_size, deep_offset):
|
||||
def case_queryset():
|
||||
alert_count = (
|
||||
Alert.objects
|
||||
.filter(case_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("case_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
playbook_count = (
|
||||
Playbook.objects
|
||||
.filter(case_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("case_id")
|
||||
.annotate(count=Count("id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
first_alert_seen_time = (
|
||||
Alert.objects
|
||||
.filter(case_id=OuterRef("pk"), first_seen_time__isnull=False)
|
||||
.order_by("first_seen_time")
|
||||
.values("first_seen_time")[:1]
|
||||
)
|
||||
return Case.objects.select_related("assignee").annotate(
|
||||
alert_count=Coalesce(Subquery(alert_count, output_field=IntegerField()), Value(0)),
|
||||
playbook_count=Coalesce(Subquery(playbook_count, output_field=IntegerField()), Value(0)),
|
||||
first_alert_seen_time=Subquery(first_alert_seen_time, output_field=DateTimeField()),
|
||||
).order_by("-created_at")
|
||||
|
||||
def alert_queryset():
|
||||
return Alert.objects.select_related("case").prefetch_related("artifacts").order_by("-created_at")
|
||||
|
||||
def artifact_queryset():
|
||||
alert_count = (
|
||||
Artifact.alerts.through.objects
|
||||
.filter(artifact_id=OuterRef("pk"))
|
||||
.order_by()
|
||||
.values("artifact_id")
|
||||
.annotate(count=Count("alert_id"))
|
||||
.values("count")[:1]
|
||||
)
|
||||
return Artifact.objects.annotate(
|
||||
alert_count=Coalesce(Subquery(alert_count, output_field=IntegerField()), Value(0))
|
||||
).order_by("-created_at")
|
||||
|
||||
def admin_audit_queryset():
|
||||
return AuditLog.objects.select_related("actor", "content_type").annotate(
|
||||
changes_text=Cast("changes", output_field=CharField()),
|
||||
metadata_text=Cast("metadata", output_field=CharField()),
|
||||
actor_display=Concat(
|
||||
Coalesce("actor__first_name", Value("")),
|
||||
Value(" "),
|
||||
Coalesce("actor__last_name", Value("")),
|
||||
output_field=CharField(),
|
||||
),
|
||||
).order_by("-created_at", "-id")
|
||||
|
||||
def list_count(queryset):
|
||||
return len(list(queryset[:page_size]))
|
||||
|
||||
def cursor_count(queryset):
|
||||
request = SimpleNamespace(query_params={"page_size": str(min(page_size, 100))})
|
||||
page = paginate_created_at_cursor(queryset, request)
|
||||
return len(page.results)
|
||||
|
||||
return [
|
||||
("cases.default_page", lambda: list_count(case_queryset())),
|
||||
("cases.deep_page", lambda: list_count(case_queryset()[deep_offset:deep_offset + page_size])),
|
||||
("cases.filter_status_severity", lambda: list_count(case_queryset().filter(status__in=["New", "In Progress"], severity__in=["High", "Critical"]))),
|
||||
("cases.search_hot", lambda: list_count(case_queryset().filter(Q(case_id__icontains=HOT_SEARCH_TOKEN) | Q(title__icontains=HOT_SEARCH_TOKEN) | Q(description__icontains=HOT_SEARCH_TOKEN) | Q(summary__icontains=HOT_SEARCH_TOKEN) | Q(correlation_uid__icontains=HOT_SEARCH_TOKEN)))),
|
||||
("cases.search_rare", lambda: list_count(case_queryset().filter(Q(title__icontains=RARE_SEARCH_TOKEN) | Q(description__icontains=RARE_SEARCH_TOKEN)))),
|
||||
("alerts.default_page", lambda: list_count(alert_queryset())),
|
||||
("alerts.filter_status_severity", lambda: list_count(alert_queryset().filter(status__in=["New", "In Progress"], severity__in=["High", "Critical"]))),
|
||||
("alerts.filter_product_risk", lambda: list_count(alert_queryset().filter(product_category="IAM", risk_level__in=["High", "Critical"]))),
|
||||
("alerts.order_first_seen", lambda: list_count(alert_queryset().order_by("-first_seen_time", "-id"))),
|
||||
("alerts.search_hot", lambda: list_count(alert_queryset().filter(Q(alert_id__icontains=HOT_SEARCH_TOKEN) | Q(title__icontains=HOT_SEARCH_TOKEN) | Q(desc__icontains=HOT_SEARCH_TOKEN) | Q(rule_name__icontains=HOT_SEARCH_TOKEN) | Q(source_uid__icontains=HOT_SEARCH_TOKEN)))),
|
||||
("alerts.search_rare", lambda: list_count(alert_queryset().filter(Q(title__icontains=RARE_SEARCH_TOKEN) | Q(rule_name__icontains=RARE_SEARCH_TOKEN)))),
|
||||
("artifacts.default_page", lambda: list_count(artifact_queryset())),
|
||||
("artifacts.filter_type_role", lambda: list_count(artifact_queryset().filter(type="Hostname", role__in=["Actor", "Target"]))),
|
||||
("artifacts.search_hot", lambda: list_count(artifact_queryset().filter(Q(artifact_id__icontains=HOT_SEARCH_TOKEN) | Q(value__icontains=HOT_SEARCH_TOKEN) | Q(name__icontains=HOT_SEARCH_TOKEN) | Q(type__icontains=HOT_SEARCH_TOKEN) | Q(role__icontains=HOT_SEARCH_TOKEN)))),
|
||||
("artifacts.search_rare", lambda: list_count(artifact_queryset().filter(value__icontains=RARE_SEARCH_TOKEN))),
|
||||
("dashboard.24h", lambda: len(build_dashboard_overview("24h"))),
|
||||
("dashboard.7d", lambda: len(build_dashboard_overview("7d"))),
|
||||
("dashboard.30d", lambda: len(build_dashboard_overview("30d"))),
|
||||
("audit.default_page", lambda: list_count(admin_audit_queryset())),
|
||||
("audit.filter_action_actor", lambda: list_count(admin_audit_queryset().filter(action="update", actor__isnull=False))),
|
||||
("audit.search_hot", lambda: list_count(admin_audit_queryset().filter(Q(action__icontains=HOT_SEARCH_TOKEN) | Q(object_id__icontains=HOT_SEARCH_TOKEN) | Q(content_type__model__icontains=HOT_SEARCH_TOKEN) | Q(actor__username__icontains=HOT_SEARCH_TOKEN) | Q(changes_text__icontains=HOT_SEARCH_TOKEN) | Q(metadata_text__icontains=HOT_SEARCH_TOKEN)))),
|
||||
("cursor.cases", lambda: cursor_count(Case.objects.all())),
|
||||
("cursor.alerts", lambda: cursor_count(Alert.objects.select_related("case"))),
|
||||
("cursor.artifacts", lambda: cursor_count(Artifact.objects.all())),
|
||||
]
|
||||
|
||||
def write_results(self, payload, *, output_dir, run_id):
|
||||
base_dir = Path(output_dir) if output_dir else Path(settings.BASE_DIR) / "perf-results"
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
|
||||
run_part = re.sub(r"[^A-Za-z0-9_.-]+", "-", run_id).strip("-") if run_id else "benchmark"
|
||||
path = base_dir / f"{timestamp}-{run_part}.json"
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
@@ -0,0 +1,52 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
READABLE_ID_TARGETS = (
|
||||
("case", "cases", "case_id", "readable_id_case_seq"),
|
||||
("alert", "alerts", "alert_id", "readable_id_alert_seq"),
|
||||
("artifact", "artifacts", "artifact_id", "readable_id_artifact_seq"),
|
||||
("enrichment", "enrichments", "enrichment_id", "readable_id_enrichment_seq"),
|
||||
("playbook", "playbooks", "playbook_id", "readable_id_playbook_seq"),
|
||||
("knowledge", "knowledge", "knowledge_id", "readable_id_knowledge_seq"),
|
||||
)
|
||||
|
||||
|
||||
def create_and_sync_sequences(apps, schema_editor):
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
for prefix, table_name, field_name, sequence_name in READABLE_ID_TARGETS:
|
||||
cursor.execute(f"CREATE SEQUENCE IF NOT EXISTS {sequence_name}")
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT COALESCE(MAX(substring({field_name} FROM %s)::bigint), 0)
|
||||
FROM {table_name}
|
||||
WHERE {field_name} ~ %s
|
||||
""",
|
||||
[f"^{prefix}_([0-9]+)$", f"^{prefix}_[0-9]+$"],
|
||||
)
|
||||
max_number = cursor.fetchone()[0]
|
||||
if max_number:
|
||||
cursor.execute("SELECT setval(%s::regclass, %s, true)", [sequence_name, max_number])
|
||||
else:
|
||||
cursor.execute("SELECT setval(%s::regclass, 1, false)", [sequence_name])
|
||||
|
||||
|
||||
def drop_sequences(apps, schema_editor):
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
for _prefix, _table_name, _field_name, sequence_name in reversed(READABLE_ID_TARGETS):
|
||||
cursor.execute(f"DROP SEQUENCE IF EXISTS {sequence_name}")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0002_case_case_created_id_idx"),
|
||||
("alerts", "0002_alert_alert_created_id_idx_and_more"),
|
||||
("artifacts", "0002_artifact_artifact_created_id_idx"),
|
||||
("enrichments", "0002_remove_mcp_provider_choice"),
|
||||
("playbooks", "0001_initial"),
|
||||
("knowledge", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_and_sync_sequences, reverse_code=drop_sequences),
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from drf_spectacular.openapi import AutoSchema
|
||||
from drf_spectacular.plumbing import build_serializer_context
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
class AspAutoSchema(AutoSchema):
|
||||
def _get_serializer(self):
|
||||
view = self.view
|
||||
context = build_serializer_context(view)
|
||||
try:
|
||||
if isinstance(view, GenericAPIView):
|
||||
if view.__class__.get_serializer == GenericAPIView.get_serializer:
|
||||
return view.get_serializer_class()(context=context)
|
||||
return view.get_serializer(context=context)
|
||||
if isinstance(view, APIView):
|
||||
if callable(getattr(view, "get_serializer", None)):
|
||||
return view.get_serializer(context=context)
|
||||
if callable(getattr(view, "get_serializer_class", None)):
|
||||
return view.get_serializer_class()(context=context)
|
||||
if hasattr(view, "serializer_class"):
|
||||
return view.serializer_class
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_request_serializer(self):
|
||||
serializer = self._get_serializer()
|
||||
if serializer is None and self.method in ("POST", "PUT", "PATCH"):
|
||||
return OpenApiTypes.OBJECT
|
||||
return serializer
|
||||
|
||||
def get_response_serializers(self):
|
||||
return self._get_serializer() or OpenApiTypes.OBJECT
|
||||
|
||||
|
||||
class BearerAuthenticationScheme(OpenApiAuthenticationExtension):
|
||||
target_class = "rest_framework_simplejwt.authentication.JWTAuthentication"
|
||||
name = "bearerAuth"
|
||||
priority = 1
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
return {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "Use the format: Bearer <access_token>",
|
||||
}
|
||||
|
||||
|
||||
class ApiKeyAuthenticationScheme(OpenApiAuthenticationExtension):
|
||||
target_class = "apps.accounts.authentication.ApiKeyAuthentication"
|
||||
name = "apiKeyAuth"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
return {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "Authorization",
|
||||
"description": "Use the format: Api-Key <key>",
|
||||
}
|
||||
|
||||
|
||||
BUSINESS_TAG_PREFIXES = (
|
||||
("/api/agent/v1/", "Agent API"),
|
||||
("/api/auth/api-keys", "API Keys"),
|
||||
("/api/auth/users", "Users"),
|
||||
("/api/auth/", "Auth"),
|
||||
("/api/alerts", "Alerts"),
|
||||
("/api/artifacts", "Artifacts"),
|
||||
("/api/attachments", "Attachments"),
|
||||
("/api/audit-logs", "Audit"),
|
||||
("/api/cases", "Cases"),
|
||||
("/api/comments", "Comments"),
|
||||
("/api/custom/", "Custom"),
|
||||
("/api/dashboard", "Dashboard"),
|
||||
("/api/enrichments", "Enrichments"),
|
||||
("/api/health", "System"),
|
||||
("/api/inbox", "Inbox"),
|
||||
("/api/knowledge", "Knowledge"),
|
||||
("/api/metadata", "Metadata"),
|
||||
("/api/playbooks", "Playbooks"),
|
||||
("/api/saved-table-filters", "Preferences"),
|
||||
("/api/settings", "Settings"),
|
||||
("/api/user-table-preferences", "Preferences"),
|
||||
("/api/webhook", "Webhooks"),
|
||||
)
|
||||
|
||||
|
||||
def postprocess_business_tags(result, generator, request, public):
|
||||
for path, methods in result.get("paths", {}).items():
|
||||
tag = next((candidate for prefix, candidate in BUSINESS_TAG_PREFIXES if path.startswith(prefix)), None)
|
||||
if tag is None:
|
||||
continue
|
||||
for operation in methods.values():
|
||||
if isinstance(operation, dict):
|
||||
operation["tags"] = [tag]
|
||||
return result
|
||||
@@ -0,0 +1,32 @@
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import close_old_connections
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
|
||||
class OperationTimeoutError(APIException):
|
||||
status_code = 504
|
||||
default_detail = "Operation timed out."
|
||||
default_code = "operation_timeout"
|
||||
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=16, thread_name_prefix="asp-operation")
|
||||
|
||||
|
||||
def _run_with_db_cleanup(func, args, kwargs):
|
||||
close_old_connections()
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
|
||||
def run_with_operation_timeout(operation: str, func, *args, timeout_seconds: float | None = None, **kwargs):
|
||||
timeout = float(timeout_seconds if timeout_seconds is not None else settings.SYNC_OPERATION_TIMEOUT_SECONDS)
|
||||
future = _executor.submit(_run_with_db_cleanup, func, args, kwargs)
|
||||
try:
|
||||
return future.result(timeout=timeout)
|
||||
except FutureTimeoutError as exc:
|
||||
future.cancel()
|
||||
raise OperationTimeoutError(f"{operation} timed out after {timeout:g} seconds.") from exc
|
||||
@@ -1,8 +1,16 @@
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db import IntegrityError, connection, transaction
|
||||
|
||||
|
||||
READABLE_ID_WIDTH = 6
|
||||
READABLE_ID_RETRIES = 3
|
||||
READABLE_ID_SEQUENCES = {
|
||||
"case": "readable_id_case_seq",
|
||||
"alert": "readable_id_alert_seq",
|
||||
"artifact": "readable_id_artifact_seq",
|
||||
"enrichment": "readable_id_enrichment_seq",
|
||||
"playbook": "readable_id_playbook_seq",
|
||||
"knowledge": "readable_id_knowledge_seq",
|
||||
}
|
||||
|
||||
|
||||
def format_readable_id(prefix: str, number: int) -> str:
|
||||
@@ -19,18 +27,45 @@ def parse_readable_id_number(value: str | None, prefix: str) -> int:
|
||||
return int(suffix) if suffix.isdigit() else 0
|
||||
|
||||
|
||||
def next_readable_id(model_class, field_name: str, prefix: str) -> str:
|
||||
values = model_class.objects.exclude(**{field_name: ""}).values_list(field_name, flat=True)
|
||||
max_number = 0
|
||||
for value in values:
|
||||
max_number = max(max_number, parse_readable_id_number(value, prefix))
|
||||
return format_readable_id(prefix, max_number + 1)
|
||||
def readable_id_sequence_name(prefix: str) -> str:
|
||||
try:
|
||||
return READABLE_ID_SEQUENCES[prefix]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unsupported readable ID prefix: {prefix}") from exc
|
||||
|
||||
|
||||
def next_readable_id(prefix: str) -> str:
|
||||
sequence_name = readable_id_sequence_name(prefix)
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT nextval(%s::regclass)", [sequence_name])
|
||||
number = cursor.fetchone()[0]
|
||||
return format_readable_id(prefix, number)
|
||||
|
||||
|
||||
def sync_readable_id_sequence(prefix: str, minimum_value: int) -> None:
|
||||
if minimum_value < 1:
|
||||
return
|
||||
sequence_name = readable_id_sequence_name(prefix)
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT last_value
|
||||
FROM pg_sequences
|
||||
WHERE schemaname = current_schema()
|
||||
AND sequencename = %s
|
||||
""",
|
||||
[sequence_name],
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
current_value = row[0] if row and row[0] is not None else 0
|
||||
if current_value < minimum_value:
|
||||
cursor.execute("SELECT setval(%s::regclass, %s, true)", [sequence_name, minimum_value])
|
||||
|
||||
|
||||
def assign_readable_id(instance, field_name: str, prefix: str) -> None:
|
||||
if getattr(instance, field_name):
|
||||
return
|
||||
setattr(instance, field_name, next_readable_id(type(instance), field_name, prefix))
|
||||
setattr(instance, field_name, next_readable_id(prefix))
|
||||
|
||||
|
||||
def save_with_readable_id(instance, field_name: str, prefix: str, *args, **kwargs):
|
||||
|
||||
@@ -2,6 +2,7 @@ from unittest.mock import patch
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import SimpleTestCase
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -35,3 +36,10 @@ class CursorPaginationTests(SimpleTestCase):
|
||||
|
||||
self.assertEqual(decoded_created_at, created_at)
|
||||
self.assertEqual(decoded_id, str(record_id))
|
||||
|
||||
|
||||
class BackendImageDependencyTests(SimpleTestCase):
|
||||
def test_backend_image_installs_libmagic_for_python_magic(self):
|
||||
dockerfile = settings.BASE_DIR / "Dockerfile"
|
||||
|
||||
self.assertIn("libmagic1", dockerfile.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django_redis import get_redis_connection
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HEARTBEAT_INTERVAL_SECONDS = 10
|
||||
HEARTBEAT_STALE_SECONDS = 30
|
||||
WORKER_HEALTH_KEY = "worker-health:v1:{worker_type}"
|
||||
EXPECTED_WORKERS = (
|
||||
("agentic-module", "Agentic Module Worker", "agentic-module-worker"),
|
||||
("case-analysis", "Case Analysis Worker", "agentic-case-analysis-worker"),
|
||||
("playbook", "Playbook Worker", "agentic-playbook-worker"),
|
||||
("elk-action", "ELK Action Worker", "elk-action-worker"),
|
||||
("dashboard-cache", "Dashboard Cache Worker", "dashboard-cache-worker"),
|
||||
)
|
||||
|
||||
|
||||
def _timestamp():
|
||||
return timezone.now().isoformat()
|
||||
|
||||
|
||||
def _decode_hash(values):
|
||||
return {
|
||||
key.decode() if isinstance(key, bytes) else str(key): value.decode() if isinstance(value, bytes) else str(value)
|
||||
for key, value in values.items()
|
||||
}
|
||||
|
||||
|
||||
class WorkerHealthReporter:
|
||||
def __init__(self, worker_type, *, redis_client=None, heartbeat_interval=HEARTBEAT_INTERVAL_SECONDS):
|
||||
self.worker_type = worker_type
|
||||
self.redis = redis_client or get_redis_connection("default")
|
||||
self.heartbeat_interval = heartbeat_interval
|
||||
self.stop_event = threading.Event()
|
||||
self.thread = None
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
return WORKER_HEALTH_KEY.format(worker_type=self.worker_type)
|
||||
|
||||
def _write(self, **fields):
|
||||
try:
|
||||
self.redis.hset(self.key, mapping={key: str(value) for key, value in fields.items()})
|
||||
except RedisError as exc:
|
||||
logger.warning(
|
||||
"Worker health update failed: worker_type=%s error_type=%s",
|
||||
self.worker_type,
|
||||
type(exc).__name__,
|
||||
)
|
||||
|
||||
def start(self):
|
||||
now = _timestamp()
|
||||
self._write(
|
||||
worker_type=self.worker_type,
|
||||
state="Starting",
|
||||
reason="",
|
||||
started_at=now,
|
||||
heartbeat_at=now,
|
||||
iteration_started_at="",
|
||||
last_iteration_success_at="",
|
||||
last_processed_at="",
|
||||
last_failure_at="",
|
||||
last_duration_ms="",
|
||||
last_message="",
|
||||
last_error="",
|
||||
)
|
||||
self.thread = threading.Thread(
|
||||
target=self._heartbeat_loop,
|
||||
name=f"{self.worker_type}-heartbeat",
|
||||
daemon=True,
|
||||
)
|
||||
self.thread.start()
|
||||
|
||||
def _heartbeat_loop(self):
|
||||
while not self.stop_event.wait(self.heartbeat_interval):
|
||||
self._write(heartbeat_at=_timestamp())
|
||||
|
||||
def iteration_started(self):
|
||||
now = _timestamp()
|
||||
self._write(
|
||||
state="Running",
|
||||
reason="",
|
||||
heartbeat_at=now,
|
||||
iteration_started_at=now,
|
||||
)
|
||||
|
||||
def iteration_succeeded(self, result, duration_ms):
|
||||
now = _timestamp()
|
||||
fields = {
|
||||
"state": "Idle",
|
||||
"reason": "",
|
||||
"heartbeat_at": now,
|
||||
"iteration_started_at": "",
|
||||
"last_iteration_success_at": now,
|
||||
"last_duration_ms": max(0, int(duration_ms)),
|
||||
"last_message": result.message,
|
||||
}
|
||||
if result.processed:
|
||||
fields["last_processed_at"] = now
|
||||
self._write(**fields)
|
||||
|
||||
def iteration_failed(self, exc, duration_ms):
|
||||
now = _timestamp()
|
||||
self._write(
|
||||
state="Degraded",
|
||||
reason="iteration_failed",
|
||||
heartbeat_at=now,
|
||||
iteration_started_at="",
|
||||
last_failure_at=now,
|
||||
last_duration_ms=max(0, int(duration_ms)),
|
||||
last_message="",
|
||||
last_error=json.dumps({
|
||||
"type": type(exc).__name__,
|
||||
"message": "Worker iteration failed.",
|
||||
}),
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
self.stop_event.set()
|
||||
if self.thread is not None:
|
||||
self.thread.join(timeout=1)
|
||||
self._write(
|
||||
state="Down",
|
||||
reason="graceful",
|
||||
heartbeat_at=_timestamp(),
|
||||
iteration_started_at="",
|
||||
)
|
||||
|
||||
|
||||
def get_worker_health_states(*, redis_client=None, now=None):
|
||||
client = redis_client or get_redis_connection("default")
|
||||
current_time = now or timezone.now()
|
||||
stale_before = current_time - timedelta(seconds=HEARTBEAT_STALE_SECONDS)
|
||||
results = []
|
||||
|
||||
for worker_type, display_name, log_role in EXPECTED_WORKERS:
|
||||
values = _decode_hash(client.hgetall(WORKER_HEALTH_KEY.format(worker_type=worker_type)))
|
||||
if not values:
|
||||
values = {
|
||||
"worker_type": worker_type,
|
||||
"state": "Down",
|
||||
"reason": "never_reported",
|
||||
}
|
||||
|
||||
heartbeat_at = parse_datetime(values.get("heartbeat_at", ""))
|
||||
if values.get("state") != "Down" and (heartbeat_at is None or heartbeat_at < stale_before):
|
||||
values["state"] = "Down"
|
||||
values["reason"] = "heartbeat_expired"
|
||||
|
||||
last_error = values.get("last_error", "")
|
||||
try:
|
||||
parsed_error = json.loads(last_error) if last_error else None
|
||||
except (TypeError, ValueError):
|
||||
parsed_error = {"type": "UnknownError", "message": "Worker iteration failed."}
|
||||
|
||||
iteration_started_at = parse_datetime(values.get("iteration_started_at", ""))
|
||||
running_duration_seconds = None
|
||||
if values.get("state") == "Running" and iteration_started_at is not None:
|
||||
running_duration_seconds = max(0, int((current_time - iteration_started_at).total_seconds()))
|
||||
|
||||
results.append({
|
||||
"worker_type": worker_type,
|
||||
"display_name": display_name,
|
||||
"state": values.get("state", "Down"),
|
||||
"reason": values.get("reason", ""),
|
||||
"started_at": values.get("started_at") or None,
|
||||
"heartbeat_at": values.get("heartbeat_at") or None,
|
||||
"iteration_started_at": values.get("iteration_started_at") or None,
|
||||
"last_iteration_success_at": values.get("last_iteration_success_at") or None,
|
||||
"last_processed_at": values.get("last_processed_at") or None,
|
||||
"last_failure_at": values.get("last_failure_at") or None,
|
||||
"last_duration_ms": int(values["last_duration_ms"]) if values.get("last_duration_ms") else None,
|
||||
"running_duration_seconds": running_duration_seconds,
|
||||
"last_message": values.get("last_message", ""),
|
||||
"last_error": parsed_error,
|
||||
"log_role": log_role,
|
||||
})
|
||||
|
||||
return results
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
from apps.common.logging import configure_process_file_logging
|
||||
from apps.common.worker_health import WorkerHealthReporter
|
||||
|
||||
SLEEP_ALWAYS = "always"
|
||||
SLEEP_WHEN_IDLE = "when_idle"
|
||||
@@ -78,6 +79,7 @@ def run_worker(
|
||||
*,
|
||||
options,
|
||||
worker_name,
|
||||
worker_type,
|
||||
run_once,
|
||||
default_interval,
|
||||
sleep_policy=SLEEP_WHEN_IDLE,
|
||||
@@ -99,10 +101,15 @@ def run_worker(
|
||||
return
|
||||
|
||||
command.stdout.write(_styled(command, "SUCCESS", started_message or f"{worker_label} started"))
|
||||
health = WorkerHealthReporter(worker_type)
|
||||
health.start()
|
||||
try:
|
||||
while True:
|
||||
iteration_started = time.perf_counter()
|
||||
health.iteration_started()
|
||||
try:
|
||||
result = _run_once_or_raise(worker_label, run_once)
|
||||
health.iteration_succeeded(result, (time.perf_counter() - iteration_started) * 1000)
|
||||
if result.message:
|
||||
command.stdout.write(result.message)
|
||||
if _should_sleep(result, sleep_policy):
|
||||
@@ -111,8 +118,10 @@ def run_worker(
|
||||
raise CommandError("worker sleep interval must be greater than 0.")
|
||||
time.sleep(current_sleep_seconds)
|
||||
except Exception as exc:
|
||||
health.iteration_failed(exc.__cause__ or exc, (time.perf_counter() - iteration_started) * 1000)
|
||||
logger.exception("%s iteration failed", worker_label)
|
||||
command.stderr.write(_styled(command, "ERROR", f"{worker_label} failed: {type(exc).__name__}: {exc}"))
|
||||
time.sleep(interval)
|
||||
except KeyboardInterrupt:
|
||||
health.stop()
|
||||
command.stdout.write(_styled(command, "WARNING", stopped_message or f"{worker_label} stopped."))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import logging
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django_redis import get_redis_connection
|
||||
from redis.exceptions import LockNotOwnedError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DASHBOARD_CACHE_KEY = "dashboard:overview:v1:{window}"
|
||||
DASHBOARD_REFRESH_LOCK_KEY = "dashboard:overview:refresh:v1:{window}"
|
||||
DASHBOARD_REFRESH_LOCK_TIMEOUT_SECONDS = 600
|
||||
DASHBOARD_STALE_WARNING_INTERVALS = 3
|
||||
|
||||
|
||||
def _cache_key(window):
|
||||
return DASHBOARD_CACHE_KEY.format(window=window)
|
||||
|
||||
|
||||
def set_cached_dashboard_overview(window, overview, refresh_interval_seconds):
|
||||
cache.set(
|
||||
_cache_key(window),
|
||||
{
|
||||
"overview": overview,
|
||||
"refreshed_at": timezone.now().isoformat(),
|
||||
"refresh_interval_seconds": int(refresh_interval_seconds),
|
||||
},
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
|
||||
def get_cached_dashboard_overview(window):
|
||||
snapshot = cache.get(_cache_key(window))
|
||||
if snapshot is None:
|
||||
return None
|
||||
|
||||
refreshed_at = parse_datetime(snapshot["refreshed_at"])
|
||||
if refreshed_at is None:
|
||||
raise ValueError(f"Invalid dashboard cache timestamp for window {window}.")
|
||||
|
||||
refresh_interval_seconds = int(snapshot["refresh_interval_seconds"])
|
||||
age_seconds = max(0, int((timezone.now() - refreshed_at).total_seconds()))
|
||||
overview = dict(snapshot["overview"])
|
||||
overview["cache"] = {
|
||||
"generated_at": overview["generated_at"],
|
||||
"refreshed_at": refreshed_at.isoformat(),
|
||||
"refresh_interval_seconds": refresh_interval_seconds,
|
||||
"age_seconds": age_seconds,
|
||||
"stale_warning": age_seconds > refresh_interval_seconds * DASHBOARD_STALE_WARNING_INTERVALS,
|
||||
}
|
||||
return overview
|
||||
|
||||
|
||||
def refresh_cached_dashboard_overview(window, refresh_interval_seconds):
|
||||
connection = get_redis_connection("default")
|
||||
lock = connection.lock(
|
||||
DASHBOARD_REFRESH_LOCK_KEY.format(window=window),
|
||||
timeout=DASHBOARD_REFRESH_LOCK_TIMEOUT_SECONDS,
|
||||
blocking_timeout=0,
|
||||
)
|
||||
if not lock.acquire(blocking=False):
|
||||
return False
|
||||
|
||||
try:
|
||||
from .views import build_dashboard_overview
|
||||
|
||||
overview = build_dashboard_overview(window)
|
||||
set_cached_dashboard_overview(window, overview, refresh_interval_seconds)
|
||||
return True
|
||||
finally:
|
||||
try:
|
||||
lock.release()
|
||||
except LockNotOwnedError:
|
||||
logger.warning("Dashboard refresh lock expired before release: window=%s", window)
|
||||
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.common.worker_runner import SLEEP_ALWAYS, WorkerIterationResult, add_worker_arguments, run_worker
|
||||
from apps.dashboard.cache import refresh_cached_dashboard_overview
|
||||
from apps.dashboard.views import WINDOW_DELTAS
|
||||
from apps.settings.runtime_config import get_dashboard_refresh_interval_seconds
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DASHBOARD_REFRESH_WARNING_SECONDS = 60
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Refresh the Redis-backed dashboard overview cache."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
add_worker_arguments(
|
||||
parser,
|
||||
interval_help="Seconds between refreshes. Defaults to the Runtime Settings value.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
next_sleep_seconds = None
|
||||
|
||||
def effective_interval():
|
||||
return options["interval"] or get_dashboard_refresh_interval_seconds()
|
||||
|
||||
def refresh_once():
|
||||
nonlocal next_sleep_seconds
|
||||
interval = effective_interval()
|
||||
started = time.perf_counter()
|
||||
refreshed = []
|
||||
skipped = []
|
||||
failed = []
|
||||
|
||||
for window in WINDOW_DELTAS:
|
||||
window_started = time.perf_counter()
|
||||
try:
|
||||
if refresh_cached_dashboard_overview(window, interval):
|
||||
refreshed.append(window)
|
||||
logger.info(
|
||||
"Dashboard cache refreshed: window=%s duration_ms=%.2f",
|
||||
window,
|
||||
(time.perf_counter() - window_started) * 1000,
|
||||
)
|
||||
else:
|
||||
skipped.append(window)
|
||||
logger.info("Dashboard cache refresh skipped because lock is held: window=%s", window)
|
||||
except Exception as exc: # noqa: BLE001 - each window refresh must fail independently.
|
||||
failed.append(window)
|
||||
logger.exception("Dashboard cache refresh failed: window=%s error=%s", window, exc)
|
||||
|
||||
duration_seconds = time.perf_counter() - started
|
||||
if duration_seconds > DASHBOARD_REFRESH_WARNING_SECONDS:
|
||||
logger.warning(
|
||||
"Dashboard cache refresh exceeded target: duration_seconds=%.2f target_seconds=%s",
|
||||
duration_seconds,
|
||||
DASHBOARD_REFRESH_WARNING_SECONDS,
|
||||
)
|
||||
next_sleep_seconds = min(60, interval) if failed else interval
|
||||
if failed:
|
||||
raise RuntimeError("Dashboard cache refresh failed.")
|
||||
|
||||
return WorkerIterationResult(
|
||||
processed=bool(refreshed),
|
||||
message=(
|
||||
f"Dashboard cache refresh completed in {duration_seconds:.2f}s; "
|
||||
f"refreshed={','.join(refreshed) or 'none'}; "
|
||||
f"skipped={','.join(skipped) or 'none'}; "
|
||||
f"failed={','.join(failed) or 'none'}."
|
||||
),
|
||||
)
|
||||
|
||||
run_worker(
|
||||
self,
|
||||
options=options,
|
||||
worker_name="dashboard cache",
|
||||
worker_type="dashboard-cache",
|
||||
run_once=refresh_once,
|
||||
default_interval=get_dashboard_refresh_interval_seconds,
|
||||
sleep_policy=SLEEP_ALWAYS,
|
||||
sleep_seconds=lambda: next_sleep_seconds or effective_interval(),
|
||||
log_role="dashboard-cache-worker",
|
||||
)
|
||||
+194
-92
@@ -1,10 +1,14 @@
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import Count, DateTimeField, Min, Q
|
||||
from django.db import connection
|
||||
from django.db.models import Case as DbCase, Count, DateTimeField, FloatField, Min, Q, Sum, Value, When
|
||||
from django.db.models.functions import Coalesce, TruncDay, TruncHour
|
||||
from django.utils import timezone
|
||||
from django_redis.exceptions import ConnectionInterrupted
|
||||
from redis.exceptions import RedisError
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
@@ -15,8 +19,11 @@ from apps.cases.models import Case, CaseStatus
|
||||
from apps.enrichments.models import Enrichment
|
||||
from apps.knowledge.models import Knowledge, KnowledgeSource
|
||||
from apps.playbooks.models import Playbook, PlaybookJobStatus
|
||||
from .cache import get_cached_dashboard_overview
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WINDOW_DELTAS = {
|
||||
"24h": timedelta(hours=24),
|
||||
"7d": timedelta(days=7),
|
||||
@@ -55,6 +62,7 @@ KEYWORD_STOP_WORDS = {
|
||||
"mock",
|
||||
"unknown",
|
||||
}
|
||||
KEYWORD_AGGREGATION_LIMIT = 120
|
||||
|
||||
|
||||
def iso_datetime(value):
|
||||
@@ -71,20 +79,11 @@ def severity_weight(value):
|
||||
return SEVERITY_WEIGHTS.get(value or "", 0)
|
||||
|
||||
|
||||
def non_negative_duration_seconds(start, end):
|
||||
if not start or not end:
|
||||
return None
|
||||
seconds = int((end - start).total_seconds())
|
||||
return seconds if seconds >= 0 else None
|
||||
|
||||
|
||||
def mean_duration(values):
|
||||
valid_values = [value for value in values if value is not None]
|
||||
if not valid_values:
|
||||
return {"seconds": None, "sample_count": 0}
|
||||
def mean_duration_result(row):
|
||||
seconds, sample_count = row or (None, 0)
|
||||
return {
|
||||
"seconds": round(sum(valid_values) / len(valid_values)),
|
||||
"sample_count": len(valid_values),
|
||||
"seconds": int(seconds) if seconds is not None and sample_count else None,
|
||||
"sample_count": sample_count or 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -152,31 +151,101 @@ def add_keyword(counter, value, weight=1, split=False):
|
||||
counter[text] += weight
|
||||
|
||||
|
||||
def keyword_weight(value):
|
||||
return max(1, int(severity_weight(value) or 1))
|
||||
|
||||
|
||||
def category_keyword_weight(value):
|
||||
return max(1, keyword_weight(value) // 2)
|
||||
|
||||
|
||||
def weighted_severity_sum(queryset, multiplier=1):
|
||||
severity_score = DbCase(
|
||||
*[
|
||||
When(severity=severity, then=Value(float(weight) * multiplier))
|
||||
for severity, weight in SEVERITY_WEIGHTS.items()
|
||||
],
|
||||
default=Value(0.0),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
return queryset.aggregate(total=Sum(severity_score))["total"] or 0
|
||||
|
||||
|
||||
def add_grouped_keywords(counter, queryset, field, weight_function):
|
||||
for row in queryset.exclude(**{field: ""}).values(field, "severity").annotate(count=Count("id")).order_by():
|
||||
add_keyword(counter, row[field], weight=row["count"] * weight_function(row["severity"]))
|
||||
|
||||
|
||||
def add_title_tokens(counter, queryset, field):
|
||||
sql, params = queryset.order_by().values(field).query.sql_with_params()
|
||||
stop_words = list(KEYWORD_STOP_WORDS)
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT token, COUNT(*) AS value
|
||||
FROM (
|
||||
SELECT lower(trim(both '._-' FROM raw_token.value)) AS token
|
||||
FROM ({sql}) AS source
|
||||
CROSS JOIN LATERAL regexp_split_to_table(source.{field}, '[^A-Za-z0-9+._-]+') AS raw_token(value)
|
||||
) AS tokens
|
||||
WHERE length(token) >= 3
|
||||
AND token ~ '^[a-z][a-z0-9+._-]*$'
|
||||
AND NOT (token = ANY(%s))
|
||||
GROUP BY token
|
||||
ORDER BY value DESC, token
|
||||
LIMIT %s
|
||||
""",
|
||||
[*params, stop_words, KEYWORD_AGGREGATION_LIMIT],
|
||||
)
|
||||
for token, value in cursor.fetchall():
|
||||
counter[token] += value
|
||||
|
||||
|
||||
def add_json_array_keywords(counter, queryset, field):
|
||||
sql, params = queryset.order_by().values(field, "severity").query.sql_with_params()
|
||||
severity_cases = " ".join(
|
||||
"WHEN severity = %s THEN %s"
|
||||
for _severity, _weight in SEVERITY_WEIGHTS.items()
|
||||
)
|
||||
severity_params = [
|
||||
item
|
||||
for severity, weight in SEVERITY_WEIGHTS.items()
|
||||
for item in (severity, keyword_weight(severity))
|
||||
]
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT value, SUM(weight) AS score
|
||||
FROM (
|
||||
SELECT
|
||||
jsonb_array_elements_text(source.{field}) AS value,
|
||||
CASE {severity_cases} ELSE 1 END AS weight
|
||||
FROM ({sql}) AS source
|
||||
) AS tokens
|
||||
WHERE value <> ''
|
||||
GROUP BY value
|
||||
ORDER BY score DESC, value
|
||||
LIMIT %s
|
||||
""",
|
||||
[*severity_params, *params, KEYWORD_AGGREGATION_LIMIT],
|
||||
)
|
||||
for value, score in cursor.fetchall():
|
||||
add_keyword(counter, value, weight=score)
|
||||
|
||||
|
||||
def build_threat_keywords(window_cases, window_alerts):
|
||||
counter = Counter()
|
||||
|
||||
for alert in window_alerts.values(
|
||||
"title",
|
||||
"severity",
|
||||
"labels",
|
||||
"tactic",
|
||||
"technique",
|
||||
"product_category",
|
||||
"product_name",
|
||||
):
|
||||
weight = max(1, int(severity_weight(alert["severity"]) or 1))
|
||||
add_keyword(counter, alert["title"], weight=1, split=True)
|
||||
add_keyword(counter, alert["labels"], weight=weight)
|
||||
add_keyword(counter, alert["tactic"], weight=weight)
|
||||
add_keyword(counter, alert["technique"], weight=weight)
|
||||
add_keyword(counter, alert["product_category"], weight=max(1, weight // 2))
|
||||
add_keyword(counter, alert["product_name"], weight=1)
|
||||
add_title_tokens(counter, window_alerts, "title")
|
||||
add_json_array_keywords(counter, window_alerts, "labels")
|
||||
add_grouped_keywords(counter, window_alerts, "tactic", keyword_weight)
|
||||
add_grouped_keywords(counter, window_alerts, "technique", keyword_weight)
|
||||
add_grouped_keywords(counter, window_alerts, "product_category", category_keyword_weight)
|
||||
add_grouped_keywords(counter, window_alerts, "product_name", lambda _severity: 1)
|
||||
|
||||
for case in window_cases.values("title", "severity", "category", "tags"):
|
||||
weight = max(1, int(severity_weight(case["severity"]) or 1))
|
||||
add_keyword(counter, case["title"], weight=1, split=True)
|
||||
add_keyword(counter, case["category"], weight=max(1, weight // 2))
|
||||
add_keyword(counter, case["tags"], weight=weight)
|
||||
add_title_tokens(counter, window_cases, "title")
|
||||
add_json_array_keywords(counter, window_cases, "tags")
|
||||
add_grouped_keywords(counter, window_cases, "category", category_keyword_weight)
|
||||
|
||||
return [
|
||||
{"text": text, "value": value}
|
||||
@@ -240,45 +309,59 @@ def build_alert_trend(window, start, generated_at):
|
||||
|
||||
|
||||
def build_mean_times(start):
|
||||
mttd_values = []
|
||||
cases_for_detection = Case.objects.filter(created_at__gte=start).annotate(
|
||||
first_alert_seen_time=Min("alerts__first_seen_time")
|
||||
).values("created_at", "first_alert_seen_time")
|
||||
for case in cases_for_detection:
|
||||
mttd_values.append(non_negative_duration_seconds(case["first_alert_seen_time"], case["created_at"]))
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ROUND(EXTRACT(EPOCH FROM AVG(created_at - first_alert_seen_time)))::int, COUNT(*)::int
|
||||
FROM (
|
||||
SELECT cases.id, cases.created_at, MIN(alerts.first_seen_time) AS first_alert_seen_time
|
||||
FROM cases
|
||||
LEFT JOIN alerts ON alerts.case_id = cases.id
|
||||
WHERE cases.created_at >= %s
|
||||
GROUP BY cases.id, cases.created_at
|
||||
) AS detected_cases
|
||||
WHERE first_alert_seen_time IS NOT NULL
|
||||
AND created_at >= first_alert_seen_time
|
||||
""",
|
||||
[start],
|
||||
)
|
||||
mttd = mean_duration_result(cursor.fetchone())
|
||||
|
||||
mtta_values = []
|
||||
cases_for_acknowledgement = Case.objects.filter(
|
||||
acknowledged_time__gte=start,
|
||||
acknowledged_time__isnull=False,
|
||||
).values("created_at", "acknowledged_time")
|
||||
for case in cases_for_acknowledgement:
|
||||
mtta_values.append(non_negative_duration_seconds(case["created_at"], case["acknowledged_time"]))
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ROUND(EXTRACT(EPOCH FROM AVG(acknowledged_time - created_at)))::int, COUNT(*)::int
|
||||
FROM cases
|
||||
WHERE acknowledged_time >= %s
|
||||
AND acknowledged_time IS NOT NULL
|
||||
AND acknowledged_time >= created_at
|
||||
""",
|
||||
[start],
|
||||
)
|
||||
mtta = mean_duration_result(cursor.fetchone())
|
||||
|
||||
mttr_values = []
|
||||
cases_for_resolution = Case.objects.filter(
|
||||
closed_time__gte=start,
|
||||
closed_time__isnull=False,
|
||||
).values("acknowledged_time", "closed_time")
|
||||
for case in cases_for_resolution:
|
||||
mttr_values.append(non_negative_duration_seconds(case["acknowledged_time"], case["closed_time"]))
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ROUND(EXTRACT(EPOCH FROM AVG(closed_time - acknowledged_time)))::int, COUNT(*)::int
|
||||
FROM cases
|
||||
WHERE closed_time >= %s
|
||||
AND closed_time IS NOT NULL
|
||||
AND acknowledged_time IS NOT NULL
|
||||
AND closed_time >= acknowledged_time
|
||||
""",
|
||||
[start],
|
||||
)
|
||||
mttr = mean_duration_result(cursor.fetchone())
|
||||
|
||||
return {
|
||||
"mttd": mean_duration(mttd_values),
|
||||
"mtta": mean_duration(mtta_values),
|
||||
"mttr": mean_duration(mttr_values),
|
||||
"mttd": mttd,
|
||||
"mtta": mtta,
|
||||
"mttr": mttr,
|
||||
}
|
||||
|
||||
|
||||
def build_active_risk_index(window_cases, window_alerts, window_playbooks):
|
||||
case_score = sum(
|
||||
severity_weight(severity) * 2
|
||||
for severity in window_cases.filter(status__in=OPEN_CASE_STATUSES).values_list("severity", flat=True)
|
||||
)
|
||||
alert_score = sum(
|
||||
severity_weight(severity)
|
||||
for severity in window_alerts.filter(status__in=ACTIVE_ALERT_STATUSES).values_list("severity", flat=True)
|
||||
)
|
||||
case_score = weighted_severity_sum(window_cases.filter(status__in=OPEN_CASE_STATUSES), multiplier=2)
|
||||
alert_score = weighted_severity_sum(window_alerts.filter(status__in=ACTIVE_ALERT_STATUSES))
|
||||
playbook_score = (
|
||||
window_playbooks.filter(job_status=PlaybookJobStatus.FAILED).count() * 4
|
||||
+ window_playbooks.filter(job_status=PlaybookJobStatus.RUNNING).count()
|
||||
@@ -287,35 +370,41 @@ def build_active_risk_index(window_cases, window_alerts, window_playbooks):
|
||||
|
||||
|
||||
def build_top_risk_artifacts(window_alerts):
|
||||
artifact_scores = {}
|
||||
alerts = window_alerts.prefetch_related("artifacts")
|
||||
for alert in alerts:
|
||||
weight = severity_weight(alert.severity)
|
||||
for artifact in alert.artifacts.all():
|
||||
key = str(artifact.id)
|
||||
entry = artifact_scores.setdefault(key, {
|
||||
"id": key,
|
||||
"name": artifact.name,
|
||||
"type": artifact.type,
|
||||
"role": artifact.role,
|
||||
"value": artifact.value,
|
||||
"risk_score": 0,
|
||||
"alert_count": 0,
|
||||
})
|
||||
entry["risk_score"] += weight
|
||||
entry["alert_count"] += 1
|
||||
|
||||
ranked = sorted(
|
||||
artifact_scores.values(),
|
||||
key=lambda item: (item["risk_score"], item["alert_count"], item["value"]),
|
||||
reverse=True,
|
||||
severity_score = DbCase(
|
||||
*[
|
||||
When(alert__severity=severity, then=Value(float(weight)))
|
||||
for severity, weight in SEVERITY_WEIGHTS.items()
|
||||
],
|
||||
default=Value(0.0),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
rows = (
|
||||
Alert.artifacts.through.objects
|
||||
.filter(alert_id__in=window_alerts.order_by().values("id"))
|
||||
.values(
|
||||
"artifact_id",
|
||||
"artifact__name",
|
||||
"artifact__type",
|
||||
"artifact__role",
|
||||
"artifact__value",
|
||||
)
|
||||
.annotate(
|
||||
risk_score=Sum(severity_score),
|
||||
alert_count=Count("alert_id"),
|
||||
)
|
||||
.order_by("-risk_score", "-alert_count", "-artifact__value")[:8]
|
||||
)
|
||||
return [
|
||||
{
|
||||
**item,
|
||||
"risk_score": round(item["risk_score"], 1),
|
||||
"id": str(row["artifact_id"]),
|
||||
"name": row["artifact__name"],
|
||||
"type": row["artifact__type"],
|
||||
"role": row["artifact__role"],
|
||||
"value": row["artifact__value"],
|
||||
"risk_score": round(row["risk_score"] or 0, 1),
|
||||
"alert_count": row["alert_count"],
|
||||
}
|
||||
for item in ranked[:8]
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@@ -440,4 +529,17 @@ class DashboardOverviewView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response(build_dashboard_overview(window))
|
||||
try:
|
||||
overview = get_cached_dashboard_overview(window)
|
||||
except (ConnectionInterrupted, RedisError, KeyError, TypeError, ValueError):
|
||||
logger.exception("Dashboard cache read failed: window=%s", window)
|
||||
return Response(
|
||||
{"detail": "Dashboard cache is temporarily unavailable."},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
if overview is None:
|
||||
return Response(
|
||||
{"detail": "Dashboard cache is not ready. Wait for the background refresh worker."},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
return Response(overview)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-24 01:20
|
||||
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0003_alert_alert_event_time_idx'),
|
||||
('artifacts', '0002_artifact_artifact_created_id_idx'),
|
||||
('cases', '0003_case_case_status_severity_idx_and_more'),
|
||||
('enrichments', '0002_remove_mcp_provider_choice'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name='enrichment',
|
||||
index=models.Index(fields=['created_at'], name='enrichment_created_idx'),
|
||||
),
|
||||
]
|
||||
@@ -185,6 +185,9 @@ class Enrichment(BaseModel):
|
||||
class Meta:
|
||||
db_table = "enrichments"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["created_at"], name="enrichment_created_idx"),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
return save_with_readable_id(self, "enrichment_id", "enrichment", *args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-24 01:20
|
||||
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('cases', '0003_case_case_status_severity_idx_and_more'),
|
||||
('knowledge', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name='knowledge',
|
||||
index=models.Index(fields=['created_at', 'source'], name='knowledge_created_src_idx'),
|
||||
),
|
||||
]
|
||||
@@ -29,6 +29,9 @@ class Knowledge(BaseModel):
|
||||
class Meta:
|
||||
db_table = "knowledge"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["created_at", "source"], name="knowledge_created_src_idx"),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
return save_with_readable_id(self, "knowledge_id", "knowledge", *args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-24 01:20
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('cases', '0003_case_case_status_severity_idx_and_more'),
|
||||
('playbooks', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name='playbook',
|
||||
index=models.Index(fields=['created_at', 'job_status'], name='playbook_created_job_idx'),
|
||||
),
|
||||
]
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 6.0.7 on 2026-08-05 15:21
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('playbooks', '0002_playbook_playbook_created_job_idx'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='playbook',
|
||||
name='finished_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Execution finish time (执行结束时间)', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='playbook',
|
||||
name='started_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Execution start time (执行开始时间)', null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PlaybookRunMessage',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('sequence', models.PositiveBigIntegerField()),
|
||||
('message', models.TextField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('playbook_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='run_messages', to='playbooks.playbook')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'playbook_run_messages',
|
||||
'ordering': ['sequence'],
|
||||
'indexes': [models.Index(fields=['playbook_run', 'sequence'], name='playbook_msg_run_seq_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('playbook_run', 'sequence'), name='playbook_msg_run_seq_uniq')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
@@ -27,14 +29,47 @@ class Playbook(BaseModel):
|
||||
help_text="Background job status (后台任务状态)",
|
||||
)
|
||||
job_id = models.CharField(max_length=255, blank=True, default="", help_text="Background job ID (后台任务 ID)")
|
||||
started_at = models.DateTimeField(null=True, blank=True, help_text="Execution start time (执行开始时间)")
|
||||
finished_at = models.DateTimeField(null=True, blank=True, help_text="Execution finish time (执行结束时间)")
|
||||
remark = models.TextField(blank=True, default="", help_text="Execution remark (执行备注)")
|
||||
|
||||
class Meta:
|
||||
db_table = "playbooks"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["created_at", "job_status"], name="playbook_created_job_idx"),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
return save_with_readable_id(self, "playbook_id", "playbook", *args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name or str(self.id)
|
||||
|
||||
|
||||
class PlaybookRunMessage(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
playbook_run = models.ForeignKey(
|
||||
Playbook,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="run_messages",
|
||||
)
|
||||
sequence = models.PositiveBigIntegerField()
|
||||
message = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "playbook_run_messages"
|
||||
ordering = ["sequence"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["playbook_run", "sequence"],
|
||||
name="playbook_msg_run_seq_uniq",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["playbook_run", "sequence"],
|
||||
name="playbook_msg_run_seq_idx",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Playbook
|
||||
from .models import Playbook, PlaybookJobStatus, PlaybookRunMessage
|
||||
|
||||
|
||||
class PlaybookSerializer(serializers.ModelSerializer):
|
||||
@@ -8,8 +9,46 @@ class PlaybookSerializer(serializers.ModelSerializer):
|
||||
case_title = serializers.CharField(source="case.title", read_only=True)
|
||||
case_id = serializers.UUIDField(source="case.id", read_only=True)
|
||||
case_readable_id = serializers.CharField(source="case.case_id", read_only=True)
|
||||
duration_seconds = serializers.SerializerMethodField()
|
||||
|
||||
def get_duration_seconds(self, obj):
|
||||
if obj.started_at is None:
|
||||
return None
|
||||
if obj.finished_at is not None:
|
||||
end = obj.finished_at
|
||||
elif obj.job_status == PlaybookJobStatus.RUNNING:
|
||||
end = timezone.now()
|
||||
else:
|
||||
return None
|
||||
return max(0, int((end - obj.started_at).total_seconds()))
|
||||
|
||||
class Meta:
|
||||
model = Playbook
|
||||
fields = "__all__"
|
||||
read_only_fields = ("id", "playbook_id", "created_at", "updated_at")
|
||||
fields = (
|
||||
"id",
|
||||
"playbook_id",
|
||||
"case",
|
||||
"case_id",
|
||||
"case_readable_id",
|
||||
"case_title",
|
||||
"name",
|
||||
"user_input",
|
||||
"user",
|
||||
"user_username",
|
||||
"job_status",
|
||||
"job_id",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"duration_seconds",
|
||||
"remark",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class PlaybookRunMessageSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PlaybookRunMessage
|
||||
fields = ("id", "sequence", "message", "created_at")
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import viewsets, permissions, status
|
||||
@@ -8,21 +10,22 @@ from rest_framework.response import Response
|
||||
from apps.accounts.permissions import IsBusinessWriterOrReadOnly
|
||||
from apps.agentic.services.playbooks import create_pending_playbook_run, list_playbook_definitions
|
||||
from apps.audit.context import audit_actor
|
||||
from apps.audit.mixins import AuditActorMixin
|
||||
from apps.cases.models import Case
|
||||
from apps.common.advanced_filters import AdvancedFilterBackend
|
||||
from .models import Playbook
|
||||
from .serializers import PlaybookSerializer
|
||||
from .serializers import PlaybookRunMessageSerializer, PlaybookSerializer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlaybookViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
class PlaybookViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Playbook.objects.select_related("user", "case")
|
||||
serializer_class = PlaybookSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly]
|
||||
lookup_field = "id"
|
||||
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
|
||||
search_fields = ("playbook_id", "name", "job_id", "user_input", "remark")
|
||||
ordering_fields = ("created_at", "updated_at", "job_status")
|
||||
ordering_fields = ("created_at", "updated_at", "job_status", "started_at", "finished_at")
|
||||
filterset_fields = ("job_status", "case__id")
|
||||
advanced_filter_fields = {
|
||||
"playbook_id": "text",
|
||||
@@ -33,12 +36,24 @@ class PlaybookViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
"remark": "text",
|
||||
"created_at": "date",
|
||||
"updated_at": "date",
|
||||
"started_at": "date",
|
||||
"finished_at": "date",
|
||||
}
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="definitions")
|
||||
def definitions(self, request):
|
||||
return Response(list_playbook_definitions())
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="messages")
|
||||
def messages(self, request, id=None):
|
||||
playbook = self.get_object()
|
||||
queryset = playbook.run_messages.order_by("sequence")
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = PlaybookRunMessageSerializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
return Response(PlaybookRunMessageSerializer(queryset, many=True).data)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="run")
|
||||
def run(self, request):
|
||||
name = request.data.get("name")
|
||||
@@ -63,7 +78,8 @@ class PlaybookViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
user=request.user,
|
||||
user_input=user_input,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except ValueError:
|
||||
logger.info("Invalid playbook run request", exc_info=True)
|
||||
return Response({"detail": "Unknown playbook definition."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(self.get_serializer(playbook).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from .models import CustomVariable
|
||||
|
||||
|
||||
def get_custom_variable(key):
|
||||
return (
|
||||
CustomVariable.objects.filter(key=key, enabled=True)
|
||||
.values_list("value", flat=True)
|
||||
.first()
|
||||
)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-06 07:48
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('settings', '0002_threatintelopencticonfig'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='siemelkconfig',
|
||||
name='request_timeout_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='threatintelalienvaultotxconfig',
|
||||
name='timeout_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='threatintelopencticonfig',
|
||||
name='timeout_seconds',
|
||||
),
|
||||
]
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-07-24 01:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('settings', '0003_remove_siemelkconfig_request_timeout_seconds_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='runtimeconfig',
|
||||
name='dashboard_refresh_interval_seconds',
|
||||
field=models.PositiveIntegerField(default=300),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 6.0.7 on 2026-08-01 09:55
|
||||
|
||||
import django.core.validators
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('settings', '0004_runtimeconfig_dashboard_refresh_interval_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomVariable',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('key', models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator(message='Key must start with an uppercase letter and contain only uppercase letters, numbers, and underscores.', regex='^[A-Z][A-Z0-9_]{0,127}$')])),
|
||||
('value', models.TextField()),
|
||||
('is_secret', models.BooleanField(default=False)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'setting_custom_variables',
|
||||
'ordering': ['key'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
# Generated by Django 6.0.7 on 2026-08-03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_string_values(apps, schema_editor):
|
||||
CustomVariable = apps.get_model("settings", "CustomVariable")
|
||||
for variable in CustomVariable.objects.only("id", "value").iterator():
|
||||
variable.typed_value = variable.value
|
||||
variable.save(update_fields=["typed_value"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("settings", "0005_customvariable"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="customvariable",
|
||||
name="value_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("string", "String"),
|
||||
("integer", "Integer"),
|
||||
("float", "Float"),
|
||||
("boolean", "Boolean"),
|
||||
("list", "List"),
|
||||
("dictionary", "Dictionary"),
|
||||
],
|
||||
default="string",
|
||||
max_length=16,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="customvariable",
|
||||
name="typed_value",
|
||||
field=models.JSONField(null=True),
|
||||
),
|
||||
migrations.RunPython(copy_string_values),
|
||||
migrations.RemoveField(
|
||||
model_name="customvariable",
|
||||
name="value",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="customvariable",
|
||||
old_name="typed_value",
|
||||
new_name="value",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="customvariable",
|
||||
name="value",
|
||||
field=models.JSONField(),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
@@ -30,7 +31,6 @@ class ThreatIntelAlienVaultOTXConfig(models.Model):
|
||||
api_key = models.TextField(blank=True, default="")
|
||||
base_url = models.URLField(max_length=500, default="https://otx.alienvault.com/api/v1")
|
||||
proxy = models.CharField(max_length=500, blank=True, default="")
|
||||
timeout_seconds = models.FloatField(default=10)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -53,7 +53,6 @@ class ThreatIntelOpenCTIConfig(models.Model):
|
||||
token = models.TextField(blank=True, default="")
|
||||
ssl_verify = models.BooleanField(default=False)
|
||||
proxy = models.CharField(max_length=500, blank=True, default="")
|
||||
timeout_seconds = models.FloatField(default=30)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -97,7 +96,6 @@ class SiemElkConfig(models.Model):
|
||||
host = models.URLField(max_length=500, blank=True, default="")
|
||||
api_key = models.TextField(blank=True, default="")
|
||||
verify_certs = models.BooleanField(default=False)
|
||||
request_timeout_seconds = models.PositiveIntegerField(default=30)
|
||||
process_alert_from_index_enabled = models.BooleanField(default=False)
|
||||
action_index = models.CharField(max_length=255, blank=True, default="siem-alert")
|
||||
action_poll_interval_seconds = models.PositiveIntegerField(default=60)
|
||||
@@ -145,6 +143,7 @@ class RuntimeConfig(models.Model):
|
||||
singleton_id = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
|
||||
prompt_language = models.CharField(max_length=10, default="en")
|
||||
stream_maxlen = models.PositiveIntegerField(default=10000)
|
||||
dashboard_refresh_interval_seconds = models.PositiveIntegerField(default=300)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -158,3 +157,39 @@ class RuntimeConfig(models.Model):
|
||||
def get_current(cls):
|
||||
instance, _ = cls.objects.get_or_create(singleton_id=1)
|
||||
return instance
|
||||
|
||||
|
||||
class CustomVariable(models.Model):
|
||||
class ValueType(models.TextChoices):
|
||||
STRING = "string", "String"
|
||||
INTEGER = "integer", "Integer"
|
||||
FLOAT = "float", "Float"
|
||||
BOOLEAN = "boolean", "Boolean"
|
||||
LIST = "list", "List"
|
||||
DICTIONARY = "dictionary", "Dictionary"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
key = models.CharField(
|
||||
max_length=128,
|
||||
unique=True,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r"^[A-Z][A-Z0-9_]{0,127}$",
|
||||
message="Key must start with an uppercase letter and contain only uppercase letters, numbers, and underscores.",
|
||||
)
|
||||
],
|
||||
)
|
||||
value_type = models.CharField(max_length=16, choices=ValueType.choices)
|
||||
value = models.JSONField()
|
||||
is_secret = models.BooleanField(default=False)
|
||||
description = models.TextField(blank=True, default="")
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "setting_custom_variables"
|
||||
ordering = ["key"]
|
||||
|
||||
def __str__(self):
|
||||
return self.key
|
||||
|
||||
@@ -28,7 +28,6 @@ def get_otx_config():
|
||||
"api_key": config.api_key,
|
||||
"base_url": config.base_url.rstrip("/"),
|
||||
"proxy": config.proxy,
|
||||
"timeout_seconds": config.timeout_seconds,
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +42,6 @@ def get_opencti_config():
|
||||
"token": config.token,
|
||||
"ssl_verify": config.ssl_verify,
|
||||
"proxy": config.proxy,
|
||||
"timeout_seconds": config.timeout_seconds,
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +69,6 @@ def get_elk_config():
|
||||
"host": config.host.rstrip("/"),
|
||||
"api_key": config.api_key,
|
||||
"verify_certs": config.verify_certs,
|
||||
"request_timeout_seconds": config.request_timeout_seconds,
|
||||
"process_alert_from_index_enabled": config.process_alert_from_index_enabled,
|
||||
"action_index": config.action_index,
|
||||
"action_poll_interval_seconds": config.action_poll_interval_seconds,
|
||||
@@ -103,6 +100,7 @@ def get_runtime_config():
|
||||
return {
|
||||
"prompt_language": config.prompt_language,
|
||||
"stream_maxlen": config.stream_maxlen,
|
||||
"dashboard_refresh_interval_seconds": config.dashboard_refresh_interval_seconds,
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +117,10 @@ def get_stream_maxlen():
|
||||
raise
|
||||
|
||||
|
||||
def get_dashboard_refresh_interval_seconds():
|
||||
return get_runtime_config()["dashboard_refresh_interval_seconds"]
|
||||
|
||||
|
||||
def invalidate(group=None):
|
||||
if group in {None, "llm"}:
|
||||
get_llm_configs.cache_clear()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import json
|
||||
import math
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import (
|
||||
CustomVariable,
|
||||
LdapConfig,
|
||||
LLMProviderConfig,
|
||||
RuntimeConfig,
|
||||
@@ -11,6 +15,177 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
MAX_CUSTOM_VARIABLE_VALUE_BYTES = 65_536
|
||||
MAX_CUSTOM_VARIABLE_DEPTH = 20
|
||||
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
||||
|
||||
|
||||
def _validate_structured_custom_variable(value, depth=0):
|
||||
if isinstance(value, list):
|
||||
if depth > MAX_CUSTOM_VARIABLE_DEPTH:
|
||||
raise serializers.ValidationError(
|
||||
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_DEPTH} levels of nesting."
|
||||
)
|
||||
for item in value:
|
||||
_validate_structured_custom_variable(item, depth + 1)
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
if depth > MAX_CUSTOM_VARIABLE_DEPTH:
|
||||
raise serializers.ValidationError(
|
||||
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_DEPTH} levels of nesting."
|
||||
)
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise serializers.ValidationError("Dictionary keys must be strings.")
|
||||
for item in value.values():
|
||||
_validate_structured_custom_variable(item, depth + 1)
|
||||
return
|
||||
if value is None or type(value) in {str, int, float, bool}:
|
||||
return
|
||||
raise serializers.ValidationError("Value must contain valid JSON values.")
|
||||
|
||||
|
||||
def _validate_custom_variable_value(value_type, value):
|
||||
if value_type == CustomVariable.ValueType.STRING:
|
||||
if not isinstance(value, str):
|
||||
raise serializers.ValidationError("Value must be a string.")
|
||||
if value == "":
|
||||
raise serializers.ValidationError("Value cannot be empty.")
|
||||
encoded_value = value.encode("utf-8")
|
||||
elif value_type == CustomVariable.ValueType.INTEGER:
|
||||
if type(value) is not int:
|
||||
raise serializers.ValidationError("Value must be an integer.")
|
||||
if not -MAX_SAFE_INTEGER <= value <= MAX_SAFE_INTEGER:
|
||||
raise serializers.ValidationError(
|
||||
f"Value must be between {-MAX_SAFE_INTEGER:,} and {MAX_SAFE_INTEGER:,}."
|
||||
)
|
||||
encoded_value = json.dumps(value).encode("utf-8")
|
||||
elif value_type == CustomVariable.ValueType.FLOAT:
|
||||
if type(value) not in {int, float}:
|
||||
raise serializers.ValidationError("Value must be a number.")
|
||||
value = float(value)
|
||||
if not math.isfinite(value):
|
||||
raise serializers.ValidationError("Value must be a finite number.")
|
||||
encoded_value = json.dumps(value).encode("utf-8")
|
||||
elif value_type == CustomVariable.ValueType.BOOLEAN:
|
||||
if type(value) is not bool:
|
||||
raise serializers.ValidationError("Value must be a boolean.")
|
||||
encoded_value = json.dumps(value).encode("utf-8")
|
||||
elif value_type == CustomVariable.ValueType.LIST:
|
||||
if not isinstance(value, list):
|
||||
raise serializers.ValidationError("Value must be a list.")
|
||||
encoded_value = _encode_structured_custom_variable(value)
|
||||
elif value_type == CustomVariable.ValueType.DICTIONARY:
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Value must be a dictionary.")
|
||||
encoded_value = _encode_structured_custom_variable(value)
|
||||
else:
|
||||
raise serializers.ValidationError("Unsupported value type.")
|
||||
|
||||
if len(encoded_value) > MAX_CUSTOM_VARIABLE_VALUE_BYTES:
|
||||
raise serializers.ValidationError(
|
||||
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_VALUE_BYTES:,} UTF-8 bytes."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _encode_structured_custom_variable(value):
|
||||
_validate_structured_custom_variable(value, depth=1)
|
||||
try:
|
||||
serialized = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise serializers.ValidationError("Value must contain valid JSON values.") from exc
|
||||
return serialized.encode("utf-8")
|
||||
|
||||
|
||||
class CustomVariableSerializer(serializers.ModelSerializer):
|
||||
value = serializers.JSONField(required=False)
|
||||
value_configured = serializers.SerializerMethodField()
|
||||
confirm_secret_exposure = serializers.BooleanField(write_only=True, required=False, default=False)
|
||||
|
||||
class Meta:
|
||||
model = CustomVariable
|
||||
fields = (
|
||||
"id",
|
||||
"key",
|
||||
"value_type",
|
||||
"value",
|
||||
"value_configured",
|
||||
"is_secret",
|
||||
"description",
|
||||
"enabled",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"confirm_secret_exposure",
|
||||
)
|
||||
read_only_fields = ("id", "value_configured", "created_at", "updated_at")
|
||||
extra_kwargs = {
|
||||
"description": {"required": False, "allow_blank": True},
|
||||
}
|
||||
|
||||
def get_value_configured(self, obj):
|
||||
return obj.value is not None
|
||||
|
||||
def validate_key(self, value):
|
||||
if self.instance is not None and value != self.instance.key:
|
||||
raise serializers.ValidationError("Key cannot be changed.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
confirmation = attrs.pop("confirm_secret_exposure", False)
|
||||
|
||||
if self.instance is None:
|
||||
if "value_type" not in attrs:
|
||||
raise serializers.ValidationError({"value_type": "Value type is required."})
|
||||
if "value" not in attrs:
|
||||
raise serializers.ValidationError({"value": "Value is required."})
|
||||
elif attrs.get("value_type", self.instance.value_type) != self.instance.value_type:
|
||||
if "value" not in attrs:
|
||||
raise serializers.ValidationError({
|
||||
"value": "Value is required when changing the value type."
|
||||
})
|
||||
|
||||
value_type = attrs.get(
|
||||
"value_type",
|
||||
self.instance.value_type if self.instance else None,
|
||||
)
|
||||
next_is_secret = attrs.get(
|
||||
"is_secret",
|
||||
self.instance.is_secret if self.instance else False,
|
||||
)
|
||||
if next_is_secret and value_type != CustomVariable.ValueType.STRING:
|
||||
raise serializers.ValidationError({
|
||||
"is_secret": "Only String variables can be secret."
|
||||
})
|
||||
if (
|
||||
self.instance is not None
|
||||
and self.instance.is_secret
|
||||
and not next_is_secret
|
||||
and not confirmation
|
||||
):
|
||||
raise serializers.ValidationError({
|
||||
"confirm_secret_exposure": "Confirm that this secret value may be exposed."
|
||||
})
|
||||
|
||||
if "value" in attrs:
|
||||
try:
|
||||
attrs["value"] = _validate_custom_variable_value(value_type, attrs["value"])
|
||||
except serializers.ValidationError as exc:
|
||||
raise serializers.ValidationError({"value": exc.detail}) from exc
|
||||
return attrs
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
if instance.is_secret:
|
||||
data["value"] = None
|
||||
return data
|
||||
|
||||
|
||||
class LLMProviderConfigSerializer(serializers.ModelSerializer):
|
||||
api_key_configured = serializers.SerializerMethodField()
|
||||
|
||||
@@ -86,7 +261,6 @@ class ThreatIntelAlienVaultOTXConfigSerializer(serializers.ModelSerializer):
|
||||
"api_key_configured",
|
||||
"base_url",
|
||||
"proxy",
|
||||
"timeout_seconds",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("api_key_configured", "updated_at")
|
||||
@@ -104,11 +278,6 @@ class ThreatIntelAlienVaultOTXConfigSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError("Proxy must start with http://, https://, socks4://, or socks5://.")
|
||||
return proxy
|
||||
|
||||
def validate_timeout_seconds(self, value):
|
||||
if value <= 0:
|
||||
raise serializers.ValidationError("Timeout must be greater than 0.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
api_key = attrs.get("api_key")
|
||||
@@ -137,7 +306,6 @@ class ThreatIntelOpenCTIConfigSerializer(serializers.ModelSerializer):
|
||||
"token_configured",
|
||||
"ssl_verify",
|
||||
"proxy",
|
||||
"timeout_seconds",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("token_configured", "updated_at")
|
||||
@@ -156,11 +324,6 @@ class ThreatIntelOpenCTIConfigSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError("Proxy must start with http://, https://, socks4://, or socks5://.")
|
||||
return proxy
|
||||
|
||||
def validate_timeout_seconds(self, value):
|
||||
if value <= 0:
|
||||
raise serializers.ValidationError("Timeout must be greater than 0.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
token = attrs.get("token")
|
||||
@@ -240,7 +403,6 @@ class SiemElkConfigSerializer(serializers.ModelSerializer):
|
||||
"api_key",
|
||||
"api_key_configured",
|
||||
"verify_certs",
|
||||
"request_timeout_seconds",
|
||||
"process_alert_from_index_enabled",
|
||||
"action_index",
|
||||
"action_poll_interval_seconds",
|
||||
@@ -259,11 +421,6 @@ class SiemElkConfigSerializer(serializers.ModelSerializer):
|
||||
def get_api_key_configured(self, obj):
|
||||
return bool(obj.api_key)
|
||||
|
||||
def validate_request_timeout_seconds(self, value):
|
||||
if value <= 0:
|
||||
raise serializers.ValidationError("Request timeout must be greater than 0.")
|
||||
return value
|
||||
|
||||
def validate_action_poll_interval_seconds(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
@@ -399,11 +556,14 @@ class LdapConfigSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class RuntimeConfigSerializer(serializers.ModelSerializer):
|
||||
DASHBOARD_REFRESH_INTERVALS = {300, 900, 1800, 3600}
|
||||
|
||||
class Meta:
|
||||
model = RuntimeConfig
|
||||
fields = (
|
||||
"prompt_language",
|
||||
"stream_maxlen",
|
||||
"dashboard_refresh_interval_seconds",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("updated_at",)
|
||||
@@ -418,3 +578,8 @@ class RuntimeConfigSerializer(serializers.ModelSerializer):
|
||||
if value <= 0:
|
||||
raise serializers.ValidationError("Stream maxlen must be greater than 0.")
|
||||
return value
|
||||
|
||||
def validate_dashboard_refresh_interval_seconds(self, value):
|
||||
if value not in self.DASHBOARD_REFRESH_INTERVALS:
|
||||
raise serializers.ValidationError("Dashboard refresh interval must be 300, 900, 1800, or 3600 seconds.")
|
||||
return value
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from pycti import OpenCTIApiClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _chat_completions_url(base_url):
|
||||
normalized = base_url.rstrip("/")
|
||||
@@ -33,10 +37,9 @@ def test_llm_provider(config):
|
||||
"temperature": 0,
|
||||
"max_tokens": 8,
|
||||
}
|
||||
client_kwargs = {"timeout": 20, "trust_env": False}
|
||||
client_kwargs = {"trust_env": False}
|
||||
if proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
|
||||
try:
|
||||
with httpx.Client(**client_kwargs) as client:
|
||||
response = client.post(_chat_completions_url(base_url), headers=headers, json=payload)
|
||||
@@ -58,10 +61,11 @@ def test_llm_provider(config):
|
||||
"detail": f"LLM provider test failed with HTTP {response.status_code}.",
|
||||
"response_preview": _redact(response.text, [api_key])[:500],
|
||||
}
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
logger.exception("LLM provider test failed")
|
||||
return {
|
||||
"success": False,
|
||||
"detail": _redact(exc, [api_key]),
|
||||
"detail": "LLM provider test failed due to a connection error.",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
@@ -70,7 +74,6 @@ def test_alienvault_otx_config(config):
|
||||
api_key = (config.get("api_key") or "").strip()
|
||||
base_url = (config.get("base_url") or "").strip().rstrip("/")
|
||||
proxy = (config.get("proxy") or "").strip()
|
||||
timeout = float(config.get("timeout_seconds") or 10)
|
||||
|
||||
if not api_key:
|
||||
return {
|
||||
@@ -79,14 +82,13 @@ def test_alienvault_otx_config(config):
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
client_kwargs = {"timeout": timeout, "trust_env": False}
|
||||
if proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
|
||||
headers = {
|
||||
"accept": "application/json",
|
||||
"X-OTX-API-KEY": api_key,
|
||||
}
|
||||
client_kwargs = {"trust_env": False}
|
||||
if proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
try:
|
||||
with httpx.Client(**client_kwargs) as client:
|
||||
response = client.get(f"{base_url}/user/me", headers=headers)
|
||||
@@ -101,10 +103,11 @@ def test_alienvault_otx_config(config):
|
||||
"detail": f"AlienVault OTX test failed with HTTP {response.status_code}.",
|
||||
"response_preview": _redact(response.text, [api_key])[:500],
|
||||
}
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
logger.exception("AlienVault OTX configuration test failed")
|
||||
return {
|
||||
"success": False,
|
||||
"detail": _redact(exc, [api_key]),
|
||||
"detail": "AlienVault OTX test failed due to a connection error.",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
@@ -113,7 +116,6 @@ def test_opencti_config(config):
|
||||
token = (config.get("token") or "").strip()
|
||||
url = (config.get("url") or "").strip().rstrip("/")
|
||||
proxy = (config.get("proxy") or "").strip()
|
||||
timeout = int(float(config.get("timeout_seconds") or 30))
|
||||
ssl_verify = bool(config.get("ssl_verify"))
|
||||
|
||||
if not url:
|
||||
@@ -138,7 +140,6 @@ def test_opencti_config(config):
|
||||
ssl_verify=ssl_verify,
|
||||
proxies=proxies,
|
||||
perform_health_check=True,
|
||||
requests_timeout=timeout,
|
||||
provider="AspOpenCTITest/1.0",
|
||||
)
|
||||
indicators = client.indicator.list(first=1)
|
||||
@@ -164,10 +165,11 @@ def test_opencti_config(config):
|
||||
"detail": "OpenCTI responded successfully.",
|
||||
"response_preview": str(preview)[:500],
|
||||
}
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
logger.exception("OpenCTI configuration test failed")
|
||||
return {
|
||||
"success": False,
|
||||
"detail": _redact(exc, [token]),
|
||||
"detail": "OpenCTI test failed due to a connection error.",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
@@ -191,10 +193,11 @@ def test_splunk_config(config):
|
||||
"detail": "Splunk responded successfully.",
|
||||
"response_preview": str({key: info.get(key) for key in ("serverName", "version", "guid")})[:500],
|
||||
}
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
logger.exception("Splunk configuration test failed")
|
||||
return {
|
||||
"success": False,
|
||||
"detail": _redact(exc, [password]),
|
||||
"detail": "Splunk test failed due to a connection error.",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
@@ -208,7 +211,6 @@ def test_elk_config(config):
|
||||
(config.get("host") or "").rstrip("/"),
|
||||
api_key=api_key,
|
||||
verify_certs=bool(config.get("verify_certs")),
|
||||
request_timeout=int(config.get("request_timeout_seconds") or 30),
|
||||
)
|
||||
info = client.info()
|
||||
return {
|
||||
@@ -219,9 +221,10 @@ def test_elk_config(config):
|
||||
"version": (info.get("version") or {}).get("number") if isinstance(info.get("version"), dict) else "",
|
||||
})[:500],
|
||||
}
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
logger.exception("ELK configuration test failed")
|
||||
return {
|
||||
"success": False,
|
||||
"detail": _redact(exc, [api_key]),
|
||||
"detail": "ELK test failed due to a connection error.",
|
||||
"response_preview": "",
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ class ThreatIntelAlienVaultOTXServiceTests(SimpleTestCase):
|
||||
"api_key": "wrong-key",
|
||||
"base_url": "https://otx.alienvault.com/api/v1",
|
||||
"proxy": "",
|
||||
"timeout_seconds": 10,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -90,7 +89,6 @@ class ThreatIntelOpenCTIServiceTests(SimpleTestCase):
|
||||
"token": "secret-token",
|
||||
"ssl_verify": False,
|
||||
"proxy": "",
|
||||
"timeout_seconds": 30,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -106,7 +104,6 @@ class ThreatIntelOpenCTIServiceTests(SimpleTestCase):
|
||||
"token": "",
|
||||
"ssl_verify": False,
|
||||
"proxy": "",
|
||||
"timeout_seconds": 30,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.audit.views import AdminAuditLogViewSet
|
||||
from .custom_views import (
|
||||
CustomDefinitionsModuleView,
|
||||
CustomDefinitionsPlaybookView,
|
||||
@@ -9,6 +10,7 @@ from .custom_views import (
|
||||
CustomModuleStreamMessagesView,
|
||||
)
|
||||
from .views import (
|
||||
CustomVariableViewSet,
|
||||
LLMProviderConfigViewSet,
|
||||
LdapConfigView,
|
||||
LdapTestView,
|
||||
@@ -21,11 +23,16 @@ from .views import (
|
||||
ThreatIntelAlienVaultOTXTestView,
|
||||
ThreatIntelOpenCTIConfigView,
|
||||
ThreatIntelOpenCTITestView,
|
||||
WorkerHealthView,
|
||||
)
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("llm-providers", LLMProviderConfigViewSet, basename="llm-provider")
|
||||
router.register("audit-logs", AdminAuditLogViewSet, basename="settings-audit-log")
|
||||
|
||||
custom_router = DefaultRouter()
|
||||
custom_router.register("variables", CustomVariableViewSet, basename="custom-variable")
|
||||
|
||||
urlpatterns = [
|
||||
path("settings/threat-intel/otx/", ThreatIntelAlienVaultOTXConfigView.as_view(), name="threat-intel-otx-config"),
|
||||
@@ -39,10 +46,12 @@ urlpatterns = [
|
||||
path("settings/ldap/", LdapConfigView.as_view(), name="ldap-config"),
|
||||
path("settings/ldap/test/", LdapTestView.as_view(), name="ldap-test"),
|
||||
path("settings/runtime/", RuntimeConfigView.as_view(), name="runtime-config"),
|
||||
path("settings/workers/", WorkerHealthView.as_view(), name="worker-health"),
|
||||
path("custom/modules/", CustomDefinitionsModuleView.as_view(), name="custom-definitions-modules"),
|
||||
path("custom/modules/stream/messages/", CustomModuleStreamMessagesView.as_view(), name="custom-module-stream-messages"),
|
||||
path("custom/modules/stream/message/", CustomModuleStreamMessageView.as_view(), name="custom-module-stream-message"),
|
||||
path("custom/playbooks/", CustomDefinitionsPlaybookView.as_view(), name="custom-definitions-playbooks"),
|
||||
path("custom/siem/", CustomDefinitionsSiemView.as_view(), name="custom-definitions-siem"),
|
||||
path("custom/", include(custom_router.urls)),
|
||||
path("settings/", include(router.urls)),
|
||||
]
|
||||
|
||||
+170
-12
@@ -1,3 +1,6 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
@@ -6,11 +9,15 @@ from rest_framework import permissions, status, views, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.response import Response
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from apps.accounts.permissions import IsAdmin
|
||||
from apps.audit.models import AuditLog
|
||||
from apps.common.advanced_filters import AdvancedFilterBackend
|
||||
from apps.common.operation_timeout import OperationTimeoutError, run_with_operation_timeout
|
||||
from apps.common.worker_health import get_worker_health_states
|
||||
from .models import (
|
||||
CustomVariable,
|
||||
LdapConfig,
|
||||
LLMProviderConfig,
|
||||
RuntimeConfig,
|
||||
@@ -21,6 +28,7 @@ from .models import (
|
||||
)
|
||||
from .runtime_config import invalidate
|
||||
from .serializers import (
|
||||
CustomVariableSerializer,
|
||||
LLMProviderConfigSerializer,
|
||||
LdapConfigSerializer,
|
||||
SiemElkConfigSerializer,
|
||||
@@ -31,15 +39,16 @@ from .serializers import (
|
||||
)
|
||||
from .services import test_alienvault_otx_config, test_elk_config, test_llm_provider, test_opencti_config, test_splunk_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LLM_AUDIT_FIELDS = ("name", "base_url", "model", "proxy", "tags", "enabled", "priority", "api_key")
|
||||
OTX_AUDIT_FIELDS = ("enabled", "api_key", "base_url", "proxy", "timeout_seconds")
|
||||
OPENCTI_AUDIT_FIELDS = ("enabled", "url", "token", "ssl_verify", "proxy", "timeout_seconds")
|
||||
OTX_AUDIT_FIELDS = ("enabled", "api_key", "base_url", "proxy")
|
||||
OPENCTI_AUDIT_FIELDS = ("enabled", "url", "token", "ssl_verify", "proxy")
|
||||
SPLUNK_AUDIT_FIELDS = ("host", "port", "username", "password", "scheme", "verify")
|
||||
ELK_AUDIT_FIELDS = (
|
||||
"host",
|
||||
"api_key",
|
||||
"verify_certs",
|
||||
"request_timeout_seconds",
|
||||
"process_alert_from_index_enabled",
|
||||
"action_index",
|
||||
"action_poll_interval_seconds",
|
||||
@@ -57,6 +66,15 @@ LDAP_AUDIT_FIELDS = (
|
||||
RUNTIME_AUDIT_FIELDS = (
|
||||
"prompt_language",
|
||||
"stream_maxlen",
|
||||
"dashboard_refresh_interval_seconds",
|
||||
)
|
||||
CUSTOM_VARIABLE_AUDIT_FIELDS = (
|
||||
"key",
|
||||
"value_type",
|
||||
"value",
|
||||
"is_secret",
|
||||
"description",
|
||||
"enabled",
|
||||
)
|
||||
|
||||
|
||||
@@ -89,6 +107,19 @@ def _write_audit(instance, action, actor, *, changes=None, metadata=None):
|
||||
)
|
||||
|
||||
|
||||
def _run_config_test(operation, func, config):
|
||||
try:
|
||||
return run_with_operation_timeout(
|
||||
operation,
|
||||
func,
|
||||
config,
|
||||
timeout_seconds=settings.CONFIG_TEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
except OperationTimeoutError:
|
||||
logger.warning("Configuration test timed out: %s", operation, exc_info=True)
|
||||
return {"success": False, "detail": "Configuration test timed out.", "response_preview": ""}
|
||||
|
||||
|
||||
def _config_from_instance(instance, values):
|
||||
config = _snapshot(instance, LLM_AUDIT_FIELDS) if instance else {}
|
||||
config.update(values)
|
||||
@@ -163,7 +194,7 @@ class LLMProviderConfigViewSet(viewsets.ModelViewSet):
|
||||
def test_unsaved(self, request):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = test_llm_provider(serializer.validated_data)
|
||||
result = _run_config_test("settings.llm.test", test_llm_provider, serializer.validated_data)
|
||||
AuditLog.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(LLMProviderConfig),
|
||||
object_id="unsaved",
|
||||
@@ -178,11 +209,110 @@ class LLMProviderConfigViewSet(viewsets.ModelViewSet):
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance, data=request.data or {}, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = test_llm_provider(_config_from_instance(instance, serializer.validated_data))
|
||||
result = _run_config_test("settings.llm.test", test_llm_provider, _config_from_instance(instance, serializer.validated_data))
|
||||
_write_audit(instance, "test", request.user, metadata={"success": result["success"]})
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class CustomVariableViewSet(viewsets.ModelViewSet):
|
||||
queryset = CustomVariable.objects.all()
|
||||
serializer_class = CustomVariableSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdmin]
|
||||
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
|
||||
search_fields = ("key", "description")
|
||||
filterset_fields = ("value_type", "is_secret", "enabled")
|
||||
ordering_fields = (
|
||||
"key",
|
||||
"value_type",
|
||||
"is_secret",
|
||||
"enabled",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
advanced_filter_fields = {
|
||||
"key": "text",
|
||||
"description": "text",
|
||||
"value_type": "select",
|
||||
"is_secret": "select",
|
||||
"enabled": "select",
|
||||
"created_at": "date",
|
||||
"updated_at": "date",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _safe_changes(before, after):
|
||||
changes = {}
|
||||
for field in CUSTOM_VARIABLE_AUDIT_FIELDS:
|
||||
old_value = before.get(field) if before else None
|
||||
new_value = after.get(field) if after else None
|
||||
if old_value == new_value:
|
||||
continue
|
||||
if field == "value":
|
||||
changes[field] = {"from": "***", "to": "***"}
|
||||
else:
|
||||
changes[field] = {"from": old_value, "to": new_value}
|
||||
return changes
|
||||
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
after = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
|
||||
_write_audit(
|
||||
instance,
|
||||
"create",
|
||||
self.request.user,
|
||||
changes=self._safe_changes(None, after),
|
||||
metadata={"key": instance.key, "value_changed": True},
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def perform_update(self, serializer):
|
||||
instance = self.get_object()
|
||||
before = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
|
||||
instance = serializer.save()
|
||||
after = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
|
||||
changes = self._safe_changes(before, after)
|
||||
if changes:
|
||||
_write_audit(
|
||||
instance,
|
||||
"update",
|
||||
self.request.user,
|
||||
changes=changes,
|
||||
metadata={"key": instance.key, "value_changed": before["value"] != after["value"]},
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def perform_destroy(self, instance):
|
||||
before = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
|
||||
_write_audit(
|
||||
instance,
|
||||
"delete",
|
||||
self.request.user,
|
||||
changes=self._safe_changes(before, None),
|
||||
metadata={
|
||||
"key": instance.key,
|
||||
"description": instance.description,
|
||||
"is_secret": instance.is_secret,
|
||||
"enabled": instance.enabled,
|
||||
},
|
||||
)
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def reveal(self, request, pk=None):
|
||||
instance = self.get_object()
|
||||
if not instance.is_secret:
|
||||
return Response(
|
||||
{"detail": "Only secret variables can be revealed."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
_write_audit(instance, "reveal", request.user, metadata={"key": instance.key})
|
||||
response = Response({"value": instance.value})
|
||||
response["Cache-Control"] = "no-store"
|
||||
response["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
def _otx_config_from_instance(instance, values):
|
||||
config = _snapshot(instance, OTX_AUDIT_FIELDS)
|
||||
config.update(values)
|
||||
@@ -238,7 +368,11 @@ class ThreatIntelAlienVaultOTXTestView(views.APIView):
|
||||
instance = ThreatIntelAlienVaultOTXConfig.get_current()
|
||||
serializer = ThreatIntelAlienVaultOTXConfigSerializer(instance, data=request.data or {}, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = test_alienvault_otx_config(_otx_config_from_instance(instance, serializer.validated_data))
|
||||
result = _run_config_test(
|
||||
"settings.threat_intel.otx.test",
|
||||
test_alienvault_otx_config,
|
||||
_otx_config_from_instance(instance, serializer.validated_data),
|
||||
)
|
||||
_write_audit(instance, "test", request.user, metadata={"success": result["success"]})
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -279,7 +413,11 @@ class ThreatIntelOpenCTITestView(views.APIView):
|
||||
instance = ThreatIntelOpenCTIConfig.get_current()
|
||||
serializer = ThreatIntelOpenCTIConfigSerializer(instance, data=request.data or {}, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = test_opencti_config(_opencti_config_from_instance(instance, serializer.validated_data))
|
||||
result = _run_config_test(
|
||||
"settings.threat_intel.opencti.test",
|
||||
test_opencti_config,
|
||||
_opencti_config_from_instance(instance, serializer.validated_data),
|
||||
)
|
||||
_write_audit(instance, "test", request.user, metadata={"success": result["success"]})
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -333,7 +471,7 @@ class SiemSplunkTestView(views.APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
config = _snapshot(instance, SPLUNK_AUDIT_FIELDS)
|
||||
config.update(serializer.validated_data)
|
||||
result = test_splunk_config(config)
|
||||
result = _run_config_test("settings.siem.splunk.test", test_splunk_config, config)
|
||||
_write_audit(instance, "test", request.user, metadata={"success": result["success"]})
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -369,7 +507,7 @@ class SiemElkTestView(views.APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
config = _snapshot(instance, ELK_AUDIT_FIELDS)
|
||||
config.update(serializer.validated_data)
|
||||
result = test_elk_config(config)
|
||||
result = _run_config_test("settings.siem.elk.test", test_elk_config, config)
|
||||
_write_audit(instance, "test", request.user, metadata={"success": result["success"]})
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -409,10 +547,16 @@ class LdapTestView(views.APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
config = _snapshot(instance, LDAP_AUDIT_FIELDS)
|
||||
config.update(serializer.validated_data)
|
||||
result = test_ldap_config(
|
||||
test_username = str(request.data.get("test_username") or "")
|
||||
test_password = str(request.data.get("test_password") or "")
|
||||
result = _run_config_test(
|
||||
"settings.ldap.test",
|
||||
lambda data: test_ldap_config(
|
||||
data,
|
||||
test_username=test_username,
|
||||
test_password=test_password,
|
||||
),
|
||||
config,
|
||||
test_username=str(request.data.get("test_username") or ""),
|
||||
test_password=str(request.data.get("test_password") or ""),
|
||||
)
|
||||
_write_audit(instance, "test", request.user, metadata={"success": result["success"]})
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
@@ -437,3 +581,17 @@ class RuntimeConfigView(views.APIView):
|
||||
_write_audit(instance, "update", request.user, changes=changes)
|
||||
transaction.on_commit(lambda: invalidate("runtime"))
|
||||
return Response(RuntimeConfigSerializer(instance).data)
|
||||
|
||||
|
||||
class WorkerHealthView(views.APIView):
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdmin]
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
results = get_worker_health_states()
|
||||
except RedisError:
|
||||
return Response(
|
||||
{"detail": "Worker health monitoring is unavailable."},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
return Response({"results": results})
|
||||
|
||||
@@ -11,7 +11,7 @@ from integrations.siem.clients import get_elk_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ELK_CLIENT_CONFIG_FIELDS = ("host", "api_key", "verify_certs", "request_timeout_seconds")
|
||||
ELK_CLIENT_CONFIG_FIELDS = ("host", "api_key", "verify_certs")
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -48,6 +48,7 @@ class Command(BaseCommand):
|
||||
self,
|
||||
options=options,
|
||||
worker_name="ELK action",
|
||||
worker_type="elk-action",
|
||||
run_once=process_once,
|
||||
default_interval=lambda: processor.interval_seconds,
|
||||
sleep_policy=SLEEP_ALWAYS,
|
||||
|
||||
@@ -198,7 +198,7 @@ class WebhookAPITests(SimpleTestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.json()["detail"], "Failed to write Redis stream search: RuntimeError")
|
||||
self.assertEqual(response.json()["detail"], "Webhook stream service is unavailable.")
|
||||
|
||||
def test_old_agentic_forwarder_route_is_removed(self):
|
||||
with self.assertRaises(Resolver404):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from pydantic import ValidationError
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.response import Response
|
||||
@@ -9,6 +11,10 @@ from apps.webhook.service import (
|
||||
handle_splunk_webhook,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
INVALID_WEBHOOK_PAYLOAD_DETAIL = "Invalid webhook payload."
|
||||
WEBHOOK_STREAM_UNAVAILABLE_DETAIL = "Webhook stream service is unavailable."
|
||||
|
||||
|
||||
class SplunkWebhookView(APIView):
|
||||
authentication_classes = []
|
||||
@@ -17,10 +23,12 @@ class SplunkWebhookView(APIView):
|
||||
def post(self, request):
|
||||
try:
|
||||
result = handle_splunk_webhook(request.data)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except WebhookRedisError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
except (ValidationError, ValueError):
|
||||
logger.info("Invalid Splunk webhook payload", exc_info=True)
|
||||
return Response({"detail": INVALID_WEBHOOK_PAYLOAD_DETAIL}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except WebhookRedisError:
|
||||
logger.exception("Failed to process Splunk webhook")
|
||||
return Response({"detail": WEBHOOK_STREAM_UNAVAILABLE_DETAIL}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
return Response(result.model_dump(), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -31,8 +39,10 @@ class KibanaWebhookView(APIView):
|
||||
def post(self, request):
|
||||
try:
|
||||
result = handle_kibana_webhook(request.data)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except WebhookRedisError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
except (ValidationError, ValueError):
|
||||
logger.info("Invalid Kibana webhook payload", exc_info=True)
|
||||
return Response({"detail": INVALID_WEBHOOK_PAYLOAD_DETAIL}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except WebhookRedisError:
|
||||
logger.exception("Failed to process Kibana webhook")
|
||||
return Response({"detail": WEBHOOK_STREAM_UNAVAILABLE_DETAIL}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
return Response(result.model_dump(), status=status.HTTP_200_OK)
|
||||
|
||||
@@ -4,7 +4,9 @@ ASGI config for asp project.
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.asgi import get_asgi_application
|
||||
from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
|
||||
from apps.common.logging import configure_process_file_logging
|
||||
@@ -22,3 +24,6 @@ application = ProtocolTypeRouter({
|
||||
"http": django_application,
|
||||
"websocket": JWTAuthMiddleware(URLRouter(websocket_urlpatterns)),
|
||||
})
|
||||
|
||||
if settings.DEBUG:
|
||||
application = ASGIStaticFilesHandler(application)
|
||||
|
||||
@@ -14,9 +14,28 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
CUSTOM_DIR = BASE_DIR / "custom"
|
||||
|
||||
|
||||
def _env_int(name, default, *, minimum=1):
|
||||
try:
|
||||
value = int(os.environ.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
return max(minimum, value)
|
||||
|
||||
|
||||
def _env_bool(name, default=False):
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-secret-key-change-in-prod-32-byte-minimum")
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
|
||||
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",")
|
||||
ASP_WEB_TIMEOUT = _env_int("ASP_WEB_TIMEOUT", 210)
|
||||
SYNC_OPERATION_TIMEOUT_SECONDS = max(1, ASP_WEB_TIMEOUT - 30)
|
||||
CONFIG_TEST_TIMEOUT_SECONDS = 10
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
@@ -27,6 +46,8 @@ INSTALLED_APPS = [
|
||||
# Third party
|
||||
"rest_framework",
|
||||
"rest_framework_simplejwt",
|
||||
"drf_spectacular",
|
||||
"drf_spectacular_sidecar",
|
||||
"corsheaders",
|
||||
"django_filters",
|
||||
"storages",
|
||||
@@ -93,6 +114,8 @@ DATABASES = {
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
|
||||
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
|
||||
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
|
||||
"CONN_MAX_AGE": _env_int("POSTGRES_CONN_MAX_AGE", 0, minimum=0),
|
||||
"CONN_HEALTH_CHECKS": _env_bool("POSTGRES_CONN_HEALTH_CHECKS", True),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +166,52 @@ REST_FRAMEWORK = {
|
||||
"rest_framework.filters.SearchFilter",
|
||||
"rest_framework.filters.OrderingFilter",
|
||||
],
|
||||
"DEFAULT_SCHEMA_CLASS": "apps.common.openapi.AspAutoSchema",
|
||||
"EXCEPTION_HANDLER": "apps.common.exceptions.custom_exception_handler",
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Agentic SOC Platform API",
|
||||
"DESCRIPTION": "HTTP API for Agentic SOC Platform. External automation integrations should prefer API keys.",
|
||||
"VERSION": "0.5.2",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"COMPONENT_SPLIT_REQUEST": True,
|
||||
"SWAGGER_UI_DIST": "SIDECAR",
|
||||
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
|
||||
"REDOC_DIST": "SIDECAR",
|
||||
"SWAGGER_UI_SETTINGS": {
|
||||
"deepLinking": True,
|
||||
"persistAuthorization": True,
|
||||
},
|
||||
"POSTPROCESSING_HOOKS": [
|
||||
"drf_spectacular.hooks.postprocess_schema_enums",
|
||||
"apps.common.openapi.postprocess_business_tags",
|
||||
],
|
||||
"TAGS": [
|
||||
{"name": "Auth", "description": "Login, refresh token, profile, and current user operations."},
|
||||
{"name": "Users", "description": "User administration APIs."},
|
||||
{"name": "API Keys", "description": "Personal API key management APIs."},
|
||||
{"name": "Cases", "description": "Case investigation records."},
|
||||
{"name": "Alerts", "description": "Security alert records."},
|
||||
{"name": "Artifacts", "description": "Indicators, assets, and other related artifacts."},
|
||||
{"name": "Enrichments", "description": "Enrichment records and creation APIs."},
|
||||
{"name": "Playbooks", "description": "Playbook records and execution APIs."},
|
||||
{"name": "Knowledge", "description": "Knowledge base records."},
|
||||
{"name": "Comments", "description": "Record comment APIs."},
|
||||
{"name": "Attachments", "description": "Attachment upload, metadata, and download APIs."},
|
||||
{"name": "Audit", "description": "Audit log query APIs."},
|
||||
{"name": "Inbox", "description": "User inbox message APIs."},
|
||||
{"name": "Preferences", "description": "User table preference APIs."},
|
||||
{"name": "Settings", "description": "System configuration APIs."},
|
||||
{"name": "Custom", "description": "Custom module, playbook, and SIEM definition APIs."},
|
||||
{"name": "Dashboard", "description": "Dashboard summary APIs."},
|
||||
{"name": "Metadata", "description": "Resource metadata APIs."},
|
||||
{"name": "Webhooks", "description": "Inbound alert webhook APIs."},
|
||||
{"name": "Agent API", "description": "Versioned APIs for agent and CLI integrations."},
|
||||
{"name": "System", "description": "System health and utility APIs."},
|
||||
],
|
||||
}
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(hours=8),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from django.urls import path, include
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||
|
||||
urlpatterns = [
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
||||
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
||||
path("api/", include("apps.accounts.urls")),
|
||||
path("api/", include("apps.settings.urls")),
|
||||
path("api/", include("apps.common.urls")),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import logging
|
||||
|
||||
from integrations.cmdb.models import CMDBQueryOutput
|
||||
from integrations.cmdb.providers import get_providers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def list_providers():
|
||||
return list(get_providers().keys())
|
||||
@@ -25,8 +29,9 @@ def lookup_artifact_context(artifact_type, artifact_value, provider=None):
|
||||
results.append(result)
|
||||
if result.error:
|
||||
errors.append(f"[{provider_name}] {result.error}")
|
||||
except Exception as exc:
|
||||
errors.append(f"[{provider_name}] {type(exc).__name__}: {exc}")
|
||||
except Exception:
|
||||
logger.exception("CMDB provider lookup failed: %s", provider_name)
|
||||
errors.append(f"[{provider_name}] Provider lookup failed.")
|
||||
|
||||
return CMDBQueryOutput(
|
||||
artifact_type=artifact_type,
|
||||
|
||||
@@ -31,6 +31,7 @@ from integrations.siem.query_builders import (
|
||||
build_safe_aggs,
|
||||
build_splunk_keyword_clause,
|
||||
build_time_range_clause,
|
||||
format_splunk_index,
|
||||
parse_time_range,
|
||||
)
|
||||
from integrations.siem.registry import get_default_agg_fields
|
||||
@@ -131,7 +132,7 @@ class ELKQueryBackend:
|
||||
|
||||
all_fields = [f for f in field_types if not f.startswith("_")]
|
||||
query: dict = {"bool": {"must": [build_time_range_clause("@timestamp", time_start, time_end)]}}
|
||||
response = cls._search(index_name, query, size=doc_limit, request_timeout=60)
|
||||
response = cls._search(index_name, query, size=doc_limit)
|
||||
hits = response.get("hits", {}).get("hits", [])
|
||||
|
||||
field_values: dict[str, list] = {}
|
||||
@@ -203,7 +204,7 @@ class SplunkQueryBackend:
|
||||
|
||||
@classmethod
|
||||
def execute_structured_query(cls, input_data: AdaptiveQueryInput) -> BackendQueryResult:
|
||||
search_query = f"search index=\"{input_data.index_name}\""
|
||||
search_query = f"search index=\"{format_splunk_index(input_data.index_name)}\""
|
||||
for field, value in input_data.filters.items():
|
||||
if isinstance(value, list):
|
||||
search_query += f" ({' OR '.join(f'{field}=\"{v}\"' for v in value)})"
|
||||
@@ -216,7 +217,7 @@ class SplunkQueryBackend:
|
||||
|
||||
@classmethod
|
||||
def execute_keyword_query(cls, input_data: KeywordSearchInput) -> BackendQueryResult:
|
||||
effective_index = input_data.index_name or "*"
|
||||
effective_index = format_splunk_index(input_data.index_name or "*")
|
||||
search_query = f"search index=\"{effective_index}\" ({build_splunk_keyword_clause(input_data.keyword)})"
|
||||
aggregation_fields = get_default_agg_fields(input_data.index_name) if input_data.index_name else []
|
||||
|
||||
@@ -266,7 +267,7 @@ class SplunkQueryBackend:
|
||||
return []
|
||||
service = get_splunk_service()
|
||||
start_time, end_time = parse_time_range(input_data.time_range_start, input_data.time_range_end)
|
||||
index_clause = " OR ".join(f'index="{i}"' for i in indices)
|
||||
index_clause = " OR ".join(f'index="{format_splunk_index(i)}"' for i in indices)
|
||||
search_query = f"search ({index_clause}) ({build_splunk_keyword_clause(input_data.keyword)}) | stats count by index"
|
||||
|
||||
oneshot = service.jobs.oneshot(search_query, earliest_time=start_time, latest_time=end_time, output_mode="json")
|
||||
@@ -282,7 +283,7 @@ class SplunkQueryBackend:
|
||||
service = get_splunk_service()
|
||||
start_time, end_time = parse_time_range(time_start, time_end)
|
||||
oneshot = service.jobs.oneshot(
|
||||
f'search index="{index_name}" | head {doc_limit} | fieldsummary maxvals={max_samples}',
|
||||
f'search index="{format_splunk_index(index_name)}" | head {doc_limit} | fieldsummary maxvals={max_samples}',
|
||||
earliest_time=start_time, latest_time=end_time, output_mode="json",
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ def get_elk_client():
|
||||
_require_setting("ELK host", config["host"]),
|
||||
api_key=_require_setting("ELK API key", config["api_key"]),
|
||||
verify_certs=config["verify_certs"],
|
||||
request_timeout=config["request_timeout_seconds"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,29 @@ def build_splunk_keyword_clause(keyword_input: str | list[str]) -> str:
|
||||
return " AND ".join(format_splunk_keyword(keyword) for keyword in normalize_keywords(keyword_input))
|
||||
|
||||
|
||||
# Splunk index names are restricted to lowercase letters, digits, underscores,
|
||||
# and hyphens (Splunk enforces a max length of 80). We also allow the bare
|
||||
# wildcard "*" as a sentinel used by keyword_search when no index is provided.
|
||||
# Rejecting anything else prevents SPL injection through the `search index="..."`
|
||||
# clause, e.g. an index_name like `main" | delete index=* | search index="x`.
|
||||
_SPLUNK_INDEX_RE = re.compile(r"[a-zA-Z0-9_.:-]{1,80}")
|
||||
|
||||
|
||||
def format_splunk_index(index_name: str) -> str:
|
||||
"""Return ``index_name`` if it is a safe Splunk index token.
|
||||
|
||||
Raises ``ValueError`` for values that could break out of the surrounding
|
||||
``search index="..."`` clause. The allow-list matches the character set
|
||||
Splunk permits for real index names plus the ``*`` wildcard sentinel used
|
||||
internally when the caller intentionally targets all indices.
|
||||
"""
|
||||
if index_name == "*":
|
||||
return "*"
|
||||
if not isinstance(index_name, str) or not _SPLUNK_INDEX_RE.fullmatch(index_name):
|
||||
raise ValueError(f"Invalid Splunk index name: {index_name!r}")
|
||||
return index_name
|
||||
|
||||
|
||||
def extract_field_types(properties: dict[str, Any], prefix: str, result: dict[str, str]) -> None:
|
||||
for field_name, field_info in properties.items():
|
||||
full_name = f"{prefix}{field_name}" if prefix else field_name
|
||||
@@ -72,9 +95,9 @@ def get_elk_field_types(index_name: str) -> dict[str, str]:
|
||||
field_types: dict[str, str] = {}
|
||||
try:
|
||||
mapping_resp = client.indices.get_mapping(index=index_name)
|
||||
except Exception as E:
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to get ELK field types for {index_name}")
|
||||
logger.exception(E)
|
||||
logger.exception(exc)
|
||||
return field_types
|
||||
for _, index_mapping in mapping_resp.items():
|
||||
properties = index_mapping.get("mappings", {}).get("properties", {})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
@@ -6,6 +7,8 @@ import yaml
|
||||
from integrations.siem.models import IndexInfo, SchemaFieldInfo
|
||||
from asp import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CUSTOM_REGISTRY_DIR = Path(settings.CUSTOM_DIR) / "data" / "siem"
|
||||
|
||||
|
||||
@@ -54,8 +57,9 @@ def scan_registry_configs():
|
||||
for yaml_file in _iter_overlaid_yaml_files(*default_registry_dirs()):
|
||||
try:
|
||||
index_info = _load_yaml_file(yaml_file)
|
||||
except Exception as exc:
|
||||
errors.append({"path": str(yaml_file), "error": f"{type(exc).__name__}: {exc}"})
|
||||
except Exception:
|
||||
logger.exception("Failed to load SIEM registry config from %s", yaml_file)
|
||||
errors.append({"path": str(yaml_file), "error": "Failed to load SIEM registry config."})
|
||||
continue
|
||||
fields = [field.model_dump() for field in index_info.fields]
|
||||
indices.append({
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from integrations.siem.query_builders import format_splunk_index
|
||||
|
||||
|
||||
class SplunkIndexFormattingTests(SimpleTestCase):
|
||||
def test_allows_valid_index_names_and_wildcard_sentinel(self):
|
||||
for index_name in ("main", "wineventlog", "linux_secure", "os:linux", "prod-web.1", "*"):
|
||||
with self.subTest(index_name=index_name):
|
||||
self.assertEqual(format_splunk_index(index_name), index_name)
|
||||
|
||||
def test_rejects_values_that_can_escape_splunk_index_clause(self):
|
||||
malicious_values = (
|
||||
'main" | delete index=* | search index="x',
|
||||
"main | stats count",
|
||||
"main; delete",
|
||||
"main search",
|
||||
"",
|
||||
"a" * 81,
|
||||
123,
|
||||
)
|
||||
|
||||
for index_name in malicious_values:
|
||||
with self.subTest(index_name=index_name):
|
||||
with self.assertRaisesMessage(ValueError, "Invalid Splunk index name"):
|
||||
format_splunk_index(index_name)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user