Compare commits

...
88 Commits
Author SHA1 Message Date
funnywolfandCopilot be3e9b48a5 feat(playbooks): improve execution visibility
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-05 23:48:53 +08:00
funnywolf 575997a950 remove unused spec 2026-08-05 22:50:35 +08:00
funnywolfandCopilot 5191cb444f docs: update deployment requirements
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-05 22:49:57 +08:00
funnywolfandCopilot 5fb56d100e docs: update related cases guide
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 19:51:40 +08:00
funnywolfandCopilot 1b3a31c8eb fix(cases): refine relationship experience
Make Artifact suggestions explicit and bounded, align Related Cases with shared table layouts, and update the marketplace skills contract.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 19:08:46 +08:00
rookitandCopilot 0ca5cf5e8e feat(cases): add case relationships
Replace case merge with typed weak relationships and shared-artifact suggestions for analysts and agents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-04 16:28:33 +08:00
rookit f978adecb3 Delete finish spec 2026-08-04 10:01:51 +08:00
funnywolfandCopilot d9551180e7 feat(operations): add worker health monitoring
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 20:18:32 +08:00
funnywolf 3a0f048a84 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	docs/specs/v0.6.0/00-release-scope.md
#	docs/specs/v0.6.0/05-worker-health.md
#	docs/specs/v0.6.0/README.md
#	docs/specs/v0.6.0/TODO-remaining-domains.md
2026-08-03 20:09:14 +08:00
rookitandCopilot 101ebfa0b5 docs(spec): finalize v0.6.0 domains
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 13:23:19 +08:00
rookitandCopilot a5cb9f8c59 feat(custom): support typed variables
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 13:22:11 +08:00
rookitandCopilot 02c3ab138f fix(frontend): fill available table height
Ensure short datasets use the DataTable body's calculated viewport height instead of collapsing to their content height.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 11:06:45 +08:00
funnywolf 8d45860170 remove unused spec 2026-08-01 18:17:29 +08:00
funnywolfandCopilot be83c727ec feat(custom): add managed variables
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 18:12:53 +08:00
funnywolf 5f1660c2e4 remove unused spec 2026-08-01 15:10:32 +08:00
funnywolfandCopilot ddee6cb2f7 chore(deploy): preserve script permissions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 15:08:54 +08:00
funnywolfandCopilot 3d6eb240c8 feat(deploy): simplify compose lifecycle
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 15:08:31 +08:00
funnywolfandCopilot d314489cfb chore(backend): update dependencies
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 15:08:19 +08:00
rookit 24896701bc add TODO 2026-07-31 16:28:47 +08:00
rookitandCopilot 0a2880f266 feat(deploy): add managed compose upgrades
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-29 15:16:19 +08:00
rookitandCopilot 7465c5684d chore(release): prepare v0.5.2
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-28 10:57:54 +08:00
rookitandCopilot 7191f8cb74 feat(dashboard): cache overview in background
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-24 15:03:29 +08:00
rookitandCopilot 2db249e092 fix(agent-api): avoid exposing SIEM exception details
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 15:34:29 +08:00
rookitandCopilot 23350fa275 fix(deploy): install libmagic in backend image
Fixes #44

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 14:53:43 +08:00
rookit c3497ef84c Merge pull request #38 - Bump @ant-design/icons from 6.2.5 to 6.3.2
# Conflicts:
#	frontend/pnpm-lock.yaml
2026-07-22 16:38:27 +08:00
rookit 1b12e505ae Merge pull request #42 - Bump eslint from 10.4.1 to 10.7.0 in /frontend
# Conflicts:
#	frontend/pnpm-lock.yaml
2026-07-22 16:37:27 +08:00
rookit f860bf910f Merge pull request #39 - Bump typescript-eslint from 8.63.0 to 8.64.0 in /frontend 2026-07-22 16:36:37 +08:00
rookit dcc499bc06 Merge pull request #40 - Bump react-router-dom from 7.17.0 to 7.18.1 2026-07-22 16:36:19 +08:00
rookit f8573f6fcf Merge pull request #41 - Bump antd from 6.4.3 to 6.5.1 2026-07-22 16:36:07 +08:00
rookit fdb0ac1fcb Merge pull request #36 - Bump pnpm/action-setup from 4 to 6
# Conflicts:
#	.github/workflows/ci.yml
2026-07-22 16:35:21 +08:00
rookit 5c069f89df Merge pull request #33 - Bump actions/setup-node from 6 to 7 2026-07-22 16:34:28 +08:00
rookit af780af83f Merge pull request #37 - Bump astral-sh/setup-uv from 5 to 7 2026-07-22 16:34:25 +08:00
rookit 649e9d0449 Merge pull request #34 - Bump github/codeql-action from 3 to 4
# Conflicts:
#	.github/workflows/codeql.yml
2026-07-22 16:34:22 +08:00
rookit a08c67d8ec Merge pull request #35 from dependabot/github_actions/actions/checkout-7 2026-07-22 16:33:16 +08:00
SebastionandGitHub 6ef92fd1ec fix(siem): prevent Splunk index injection
Validate Splunk index names before interpolating them into SPL queries and return 400 for invalid SIEM requests.
2026-07-22 16:31:45 +08:00
rookitandCopilot 74ff007ec8 feat: add API documentation endpoints
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 15:09:34 +08:00
rookitandCopilot 511f0a340d chore: proxy Vite API requests to ASGI server
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 15:08:50 +08:00
rookitandCopilot aca59879cb feat: add table help affordance
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 15:08:50 +08:00
dependabot[bot]andGitHub 5d367cf8e5 Bump eslint from 10.4.1 to 10.7.0 in /frontend
Bumps [eslint](https://github.com/eslint/eslint) from 10.4.1 to 10.7.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.1...v10.7.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:34:26 +00:00
dependabot[bot]andGitHub 6534f24e2b Bump antd from 6.4.3 to 6.5.1 in /frontend
Bumps [antd](https://github.com/ant-design/ant-design) from 6.4.3 to 6.5.1.
- [Release notes](https://github.com/ant-design/ant-design/releases)
- [Changelog](https://github.com/ant-design/ant-design/blob/master/CHANGELOG.en-US.md)
- [Commits](https://github.com/ant-design/ant-design/compare/6.4.3...6.5.1)

---
updated-dependencies:
- dependency-name: antd
  dependency-version: 6.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:34:12 +00:00
dependabot[bot]andGitHub 9432270db5 Bump react-router-dom from 7.17.0 to 7.18.1 in /frontend
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.17.0 to 7.18.1.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@7.18.1/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.18.1/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.18.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:33:58 +00:00
dependabot[bot]andGitHub aa8d832377 Bump typescript-eslint from 8.63.0 to 8.64.0 in /frontend
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.63.0 to 8.64.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.64.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:33:43 +00:00
dependabot[bot]andGitHub 141acbb8d1 Bump @ant-design/icons from 6.2.5 to 6.3.2 in /frontend
Bumps [@ant-design/icons](https://github.com/ant-design/ant-design-icons) from 6.2.5 to 6.3.2.
- [Commits](https://github.com/ant-design/ant-design-icons/commits)

---
updated-dependencies:
- dependency-name: "@ant-design/icons"
  dependency-version: 6.3.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:33:26 +00:00
dependabot[bot]andGitHub 987edd6d65 Bump astral-sh/setup-uv from 5 to 7
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 5 to 7.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v5...v7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:32:46 +00:00
dependabot[bot]andGitHub 2be37f4fb6 Bump pnpm/action-setup from 4 to 6
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 4 to 6.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4...v6)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:32:42 +00:00
dependabot[bot]andGitHub affe53435f Bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:32:40 +00:00
dependabot[bot]andGitHub d02b3fe1a8 Bump github/codeql-action from 3 to 4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:32:36 +00:00
dependabot[bot]andGitHub 5b8b2a127b Bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:32:32 +00:00
funnywolfandCopilot 0c7bacea7f Pin frontend pnpm version
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 20:06:49 +08:00
funnywolfandCopilot b570858d0f Prepare v0.5.1 release
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:59:35 +08:00
rootkitGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>Copilot
af9a528b7d Potential fix for code scanning alert no. 31: Information exposure through an exception
Sanitize playbook failure remarks so exception details are logged server-side but not exposed through agent API responses.

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:52:11 +08:00
funnywolfandCopilot 7e95846db7 Fix code scanning exception exposure alerts
Sanitize API-facing exception details and replace OpenCTI pattern regex with linear parsing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:44:15 +08:00
funnywolfandCopilot 6dc0abf891 Bump docker/build-push-action from 6 to 7
Closes #22

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:31:03 +08:00
d71b99ca67 Bump @vitejs/plugin-react from 6.0.2 to 6.0.3 in /frontend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:30:30 +08:00
1622a325da Bump typescript-eslint from 8.61.0 to 8.63.0 in /frontend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:30:24 +08:00
6f18bd367a Bump softprops/action-gh-release from 2 to 3
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:30:14 +08:00
b9703a15df Bump actions/setup-node from 4 to 6
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:30:09 +08:00
527559ec5e Bump docker/setup-buildx-action from 3 to 4
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:30:02 +08:00
5786830f0d Bump lucide-react from 1.21.0 to 1.23.0 in /frontend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:26:17 +08:00
987fc929c9 Bump globals from 17.6.0 to 17.7.0 in /frontend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:26:11 +08:00
2a3fdd8c08 Bump vite from 8.0.16 to 8.1.3 in /frontend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:26:02 +08:00
0d05e06317 Bump actions/setup-python from 5 to 6
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:25:56 +08:00
d6e9c835e0 Bump nginx from 1.29-alpine to 1.31-alpine in /frontend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:25:50 +08:00
funnywolfandCopilot 9e794e637c Fix frontend CI investigation lint
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 19:15:40 +08:00
funnywolfandCopilot f01d725b3b Use database sequences for readable IDs
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 18:51:10 +08:00
funnywolfandCopilot 9e943602e7 Tune production deployment concurrency settings
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 18:20:13 +08:00
funnywolfandCopilot 44e9e51f10 Simplify table tag overflow rendering
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 09:31:33 +08:00
funnywolfandCopilot fd6abe585f Disable related count sorting
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 09:09:09 +08:00
funnywolf 17170e0d2c Revert "Optimize dashboard window summary queries"
This reverts commit 04ad30e97e.
2026-07-11 09:02:41 +08:00
funnywolfandCopilot 04ad30e97e Optimize dashboard window summary queries
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-11 08:58:02 +08:00
funnywolfandCopilot 90ac8385e0 Split web detail payloads from related data
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 22:35:55 +08:00
funnywolfandCopilot 5cc4992d08 Add time indexes for list queries
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 21:48:57 +08:00
funnywolfandCopilot aa75eed5e3 Optimize default case list metrics
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 21:38:30 +08:00
funnywolfandCopilot 4fc5f1047a Optimize default artifact count query
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 21:33:00 +08:00
funnywolfandCopilot bb775738a8 Avoid default alert artifact count aggregation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 21:26:31 +08:00
funnywolfandCopilot 4059d95498 Optimize dashboard active risk aggregation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 21:17:44 +08:00
funnywolfandCopilot 26c581cbca Optimize dashboard mean time aggregation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 21:16:11 +08:00
funnywolfandCopilot f9f13bd5b5 Optimize dashboard threat keyword aggregation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 21:13:04 +08:00
funnywolfandCopilot 9ce796d187 Optimize dashboard risk artifact query
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 21:10:11 +08:00
funnywolfandCopilot 1d14d53ac2 Add database performance tooling
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 21:07:18 +08:00
funnywolfandCopilot ac408b5b48 Add audit log management settings
Add an admin audit logs tab with searchable/filterable read-only table, reusable detail modal support, audit readable IDs, and CSV export for the current filter set.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 19:22:36 +08:00
funnywolfandCopilot cef99c4b11 Fix audit cascade delete logging
Cache relation parents before delete so audit relation events do not query parents already removed by cascade deletes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 17:55:07 +08:00
funnywolf 38e5782149 fix mock case TTD error 2026-07-06 17:43:15 +08:00
funnywolfandCopilot c647b5043b Register asp-marketplace submodule
Track asp-marketplace as a submodule so the marketplace plugin repository stays external while remaining discoverable from the main project.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 17:28:48 +08:00
funnywolfandCopilot 0258939037 Standardize release workflow configuration
Add manifest-driven release tooling, wire CI and release workflows to validate deterministic release surfaces, register asp-doc as a submodule, and rewrite the release runbook around the standardized flow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 17:27:36 +08:00
funnywolfandCopilot 44af8c15e3 Add operation timeouts for external requests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 16:11:01 +08:00
funnywolfandCopilot c11a5fca63 Improve production request logging
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 14:52:22 +08:00
funnywolfandCopilot fae594ccd7 Simplify component README files
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-05 17:29:22 +08:00
182 changed files with 11464 additions and 5362 deletions
+25 -8
View File
@@ -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
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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 }}
+23 -50
View 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
View File
@@ -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
+6
View File
@@ -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
+1
Submodule asp-marketplace added at 164bdcdd41
+2
View File
@@ -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
View File
@@ -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
+8 -7
View File
@@ -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": ""}
+16
View File
@@ -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,
+29
View File
@@ -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))
+4
View File
@@ -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"),
+91 -16
View File
@@ -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,
+15
View File
@@ -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:
+3 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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
+113 -9
View File
@@ -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'),
),
]
+9
View File
@@ -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)
+106 -24
View File
@@ -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",
)
+38 -7
View File
@@ -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'),
),
]
+3
View File
@@ -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)
+30 -15
View File
@@ -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",
)
+40 -5
View File
@@ -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
+14
View File
@@ -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"),
),
]
+1
View File
@@ -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"),
+33 -6
View File
@@ -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):
+40
View File
@@ -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
View File
@@ -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')],
},
),
]
+73
View File
@@ -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}"
+202 -16
View File
@@ -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
+236
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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)})
+3
View File
@@ -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
+11 -1
View File
@@ -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})
+1
View File
@@ -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),
]
+100
View File
@@ -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
+32
View File
@@ -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
+43 -8
View File
@@ -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):
+8
View File
@@ -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"))
+187
View File
@@ -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
+9
View File
@@ -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."))
+76
View File
@@ -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
View File
@@ -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'),
),
]
+3
View File
@@ -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'),
),
]
+3
View File
@@ -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'),
),
]
@@ -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')],
},
),
]
+35
View File
@@ -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",
),
]
+42 -3
View File
@@ -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
+22 -6
View File
@@ -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()
)
@@ -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',
),
]
@@ -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(),
),
]
+38 -3
View File
@@ -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
+5 -3
View File
@@ -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()
+183 -18
View File
@@ -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
+23 -20
View File
@@ -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": "",
}
-3
View File
@@ -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,
}
)
+9
View File
@@ -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
View File
@@ -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})
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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):
+18 -8
View File
@@ -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)
+5
View File
@@ -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)
+66
View File
@@ -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),
+4
View File
@@ -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")),
+7 -2
View File
@@ -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,
+6 -5
View File
@@ -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",
)
-1
View File
@@ -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"],
)
+25 -2
View File
@@ -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", {})
+6 -2
View File
@@ -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({
+26
View File
@@ -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