aisoc-sdk 4.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aisoc_sdk-4.0.0/.gitignore +162 -0
- aisoc_sdk-4.0.0/PKG-INFO +109 -0
- aisoc_sdk-4.0.0/README.md +87 -0
- aisoc_sdk-4.0.0/pyproject.toml +52 -0
- aisoc_sdk-4.0.0/src/aisoc_sdk/__init__.py +52 -0
- aisoc_sdk-4.0.0/src/aisoc_sdk/client.py +292 -0
- aisoc_sdk-4.0.0/src/aisoc_sdk/models.py +208 -0
- aisoc_sdk-4.0.0/tests/__init__.py +1 -0
- aisoc_sdk-4.0.0/tests/test_client.py +186 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
.env
|
|
3
|
+
.env.local
|
|
4
|
+
.env.*.local
|
|
5
|
+
|
|
6
|
+
# Dependencies
|
|
7
|
+
node_modules/
|
|
8
|
+
.pnpm-store/
|
|
9
|
+
|
|
10
|
+
# Build outputs
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
.next/
|
|
14
|
+
out/
|
|
15
|
+
.turbo/
|
|
16
|
+
# T3.8 — generated Storybook static bundle. The source lives in
|
|
17
|
+
# apps/web/.storybook + apps/web/stories; the built bundle is uploaded
|
|
18
|
+
# as a CI artifact rather than committed.
|
|
19
|
+
apps/web/storybook-static/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
__pycache__/
|
|
22
|
+
*.pyc
|
|
23
|
+
*.pyo
|
|
24
|
+
|
|
25
|
+
# Go
|
|
26
|
+
*.exe
|
|
27
|
+
*.test
|
|
28
|
+
vendor/
|
|
29
|
+
bin/
|
|
30
|
+
|
|
31
|
+
# Terraform
|
|
32
|
+
.terraform/
|
|
33
|
+
*.tfstate
|
|
34
|
+
*.tfstate.backup
|
|
35
|
+
.terraform.lock.hcl
|
|
36
|
+
*.tfvars
|
|
37
|
+
!terraform.tfvars.example
|
|
38
|
+
|
|
39
|
+
# Python
|
|
40
|
+
.venv/
|
|
41
|
+
venv/
|
|
42
|
+
.eval-test-venv/
|
|
43
|
+
.mypy_cache/
|
|
44
|
+
.pytest_cache/
|
|
45
|
+
htmlcov/
|
|
46
|
+
.coverage
|
|
47
|
+
.coverage.*
|
|
48
|
+
coverage.xml
|
|
49
|
+
# Phase 2.2 — vitest v8 coverage outputs (apps/web/coverage/) and
|
|
50
|
+
# any other per-service coverage dump. Generated on every CI run,
|
|
51
|
+
# uploaded as artefacts — never committed.
|
|
52
|
+
coverage/
|
|
53
|
+
**/coverage/
|
|
54
|
+
# BUT keep app-source directories literally named `coverage` (the
|
|
55
|
+
# /tools/coverage route, and `components/coverage/` behind /coverage-advisor).
|
|
56
|
+
# The rules above are for generated test-coverage output; without these
|
|
57
|
+
# negations the coverage tool page silently drops from the build.
|
|
58
|
+
#
|
|
59
|
+
# `components/` was missing here, which is worse than it sounds: the component
|
|
60
|
+
# was tracked because it predates the rule, so nothing looked wrong, but every
|
|
61
|
+
# *new* file beside it — a test especially — was ignored on `git add -A` and
|
|
62
|
+
# never reached CI. A test that cannot be committed is indistinguishable from
|
|
63
|
+
# a test that passes.
|
|
64
|
+
!apps/web/src/app/**/coverage/
|
|
65
|
+
!apps/web/src/app/**/coverage/**
|
|
66
|
+
!apps/web/src/components/coverage/
|
|
67
|
+
!apps/web/src/components/coverage/**
|
|
68
|
+
*.lcov
|
|
69
|
+
|
|
70
|
+
# IDE
|
|
71
|
+
.idea/
|
|
72
|
+
.vscode/
|
|
73
|
+
.cursor/
|
|
74
|
+
.claude/
|
|
75
|
+
*.swp
|
|
76
|
+
*.swo
|
|
77
|
+
|
|
78
|
+
# Go test/build binaries inside plugins
|
|
79
|
+
plugins/**/*-build-test
|
|
80
|
+
plugins/**/*-build
|
|
81
|
+
|
|
82
|
+
# OS
|
|
83
|
+
.DS_Store
|
|
84
|
+
Thumbs.db
|
|
85
|
+
|
|
86
|
+
# Logs
|
|
87
|
+
logs/
|
|
88
|
+
*.log
|
|
89
|
+
# But: the AIT-LDS fidelity-benchmark fixture ships an Apache CLF
|
|
90
|
+
# access.log on purpose (T5.3 in v8.0). It is committed test data,
|
|
91
|
+
# not a runtime log, so unblock it explicitly.
|
|
92
|
+
!services/agents/tests/eval_data/**/*.log
|
|
93
|
+
|
|
94
|
+
# Docker
|
|
95
|
+
.docker/
|
|
96
|
+
|
|
97
|
+
# Secrets
|
|
98
|
+
*.pem
|
|
99
|
+
*.key
|
|
100
|
+
*.crt
|
|
101
|
+
secrets/
|
|
102
|
+
.gstack/
|
|
103
|
+
|
|
104
|
+
# Local eval / build artifacts
|
|
105
|
+
eval_report.json
|
|
106
|
+
eval_mitre_accuracy_report.json
|
|
107
|
+
.gocache/
|
|
108
|
+
*.tsbuildinfo
|
|
109
|
+
|
|
110
|
+
# PR body scratch files (local working copy, never committed)
|
|
111
|
+
.pr-body-*.md
|
|
112
|
+
|
|
113
|
+
# Local progress tracking (per AGENTS.md preference, not committed)
|
|
114
|
+
PROGRESS.md
|
|
115
|
+
PR_TRIAGE.md
|
|
116
|
+
|
|
117
|
+
# Acceptance harness ledger (.aisoc/acceptance-history.jsonl is per-machine and
|
|
118
|
+
# accumulates across runs — useful for "is this getting slower?" but not for
|
|
119
|
+
# version control. Same applies to any other harness state we drop in here.)
|
|
120
|
+
.aisoc/
|
|
121
|
+
|
|
122
|
+
# Detection-import upstream clones (populated by tools.detection_import)
|
|
123
|
+
.import-cache/
|
|
124
|
+
|
|
125
|
+
# Docusaurus generated cache (apps/docs)
|
|
126
|
+
apps/docs/.docusaurus/
|
|
127
|
+
apps/docs/build/
|
|
128
|
+
|
|
129
|
+
# Local QA artifacts (screenshots from manual UI checks)
|
|
130
|
+
apps/web/.qa-screenshots/
|
|
131
|
+
|
|
132
|
+
# Runtime playbook store. `PlaybookStore` keeps the bundled corpus read-only and
|
|
133
|
+
# writes every mutation to `index.json` in the same directory, so anything that
|
|
134
|
+
# exercises `POST /api/v1/playbooks` — a test, a local run, a reproduction of a
|
|
135
|
+
# bug — leaves a several-thousand-line file behind. Committing it ships whatever
|
|
136
|
+
# the last run happened to hold as if it were curated content, and the playbook
|
|
137
|
+
# schema lint counts it as a 65th file.
|
|
138
|
+
services/agents/data/playbooks/index.json
|
|
139
|
+
|
|
140
|
+
# Marketplace index staged into the API service build context at deploy time
|
|
141
|
+
# (see infra/fly/fly-demo-deploy.sh). The canonical source is marketplace/index.json
|
|
142
|
+
# at the repo root; the API Dockerfile only sees its own dir as build context, so
|
|
143
|
+
# the deploy script copies the index in just-in-time. We never commit the copy.
|
|
144
|
+
services/api/marketplace/
|
|
145
|
+
|
|
146
|
+
# Live-dumped graph schema produced by `scripts/export_graph_schema.py`
|
|
147
|
+
# (default mode). The source of truth is `schemas/graph-schema.yaml`; the
|
|
148
|
+
# `-current.yaml` file is a runtime artefact that varies by environment.
|
|
149
|
+
schemas/graph-schema-current.yaml
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Compiled Go binaries. `services/demo-producer/demo-producer` was committed as
|
|
153
|
+
# a 6.9 MB macOS arm64 executable in a repository whose Dockerfile builds a
|
|
154
|
+
# GOOS=linux one — nothing consumed it, nothing rebuilt it, and running the
|
|
155
|
+
# documented `go build ./...` silently overwrote it and dirtied the tree.
|
|
156
|
+
/services/demo-producer/demo-producer
|
|
157
|
+
/services/enrichment/enrichment
|
|
158
|
+
/services/ingest/ingest
|
|
159
|
+
|
|
160
|
+
# Generated by scripts/resolve_port_conflicts.py when a host port AiSOC
|
|
161
|
+
# publishes is already in use. Machine-specific by definition.
|
|
162
|
+
docker-compose.ports.yml
|
aisoc_sdk-4.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aisoc-sdk
|
|
3
|
+
Version: 4.0.0
|
|
4
|
+
Summary: Python client SDK for AiSOC — typed httpx client
|
|
5
|
+
Project-URL: Homepage, https://github.com/beenuar/AiSOC
|
|
6
|
+
Project-URL: Documentation, https://beenuar.github.io/AiSOC
|
|
7
|
+
Project-URL: Repository, https://github.com/beenuar/AiSOC
|
|
8
|
+
Project-URL: Issues, https://github.com/beenuar/AiSOC/issues
|
|
9
|
+
Author-email: AiSOC Contributors <oss@aisoc.io>
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: aisoc,client,sdk,security,soc
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: httpx>=0.27.0
|
|
14
|
+
Requires-Dist: pydantic>=2.0.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: mypy<3,>=2.3.1; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff<0.17,>=0.16.8; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# aisoc-sdk
|
|
24
|
+
|
|
25
|
+
[](../../LICENSE)
|
|
26
|
+
[](https://github.com/beenuar/AiSOC/blob/main/CHANGELOG.md)
|
|
27
|
+
|
|
28
|
+
Async Python client SDK for [AiSOC](https://github.com/beenuar/AiSOC).
|
|
29
|
+
|
|
30
|
+
> **Status — monorepo today, not yet on PyPI.** `pip install aisoc-sdk` does not resolve; install from the monorepo source path below. The import path (`aisoc_sdk`) and API surface stay identical once it ships.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Today (from this monorepo):
|
|
36
|
+
git clone https://github.com/beenuar/AiSOC.git
|
|
37
|
+
cd AiSOC && pip install -e packages/sdk-py
|
|
38
|
+
|
|
39
|
+
# Not yet on PyPI — the upload is blocked on registry credentials,
|
|
40
|
+
# which is an account action rather than a code change. Until then, install
|
|
41
|
+
# from source with the command above.
|
|
42
|
+
# pip install aisoc-sdk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
from aisoc_sdk import AiSOCClient
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def main():
|
|
53
|
+
async with AiSOCClient(
|
|
54
|
+
base_url="https://your-aisoc.example.com",
|
|
55
|
+
token="aisoc_...",
|
|
56
|
+
) as client:
|
|
57
|
+
# List critical open alerts
|
|
58
|
+
alerts = await client.alerts.list(severity="critical", status="open")
|
|
59
|
+
print(f"Found {alerts.total} critical alerts")
|
|
60
|
+
|
|
61
|
+
# Create a case
|
|
62
|
+
case = await client.cases.create(
|
|
63
|
+
title="Suspicious lateral movement",
|
|
64
|
+
priority="high",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Trigger a playbook
|
|
68
|
+
run = await client.playbooks.run(
|
|
69
|
+
"isolate-host",
|
|
70
|
+
trigger_data={"host_id": "srv-prod-42", "case_id": case.id},
|
|
71
|
+
)
|
|
72
|
+
print("Playbook run:", run.run_id)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
asyncio.run(main())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## GraphQL
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
async with AiSOCClient(base_url="...", token="...") as client:
|
|
82
|
+
result = await client.graphql("""
|
|
83
|
+
query {
|
|
84
|
+
alerts(pageSize: 10, status: "open") {
|
|
85
|
+
items { id title severity }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
""")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## API reference
|
|
92
|
+
|
|
93
|
+
All resource methods are `async` and return typed Pydantic models.
|
|
94
|
+
|
|
95
|
+
| Attribute | Methods |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `client.alerts` | `list(filters?)`, `get(id)`, `update(id, **data)` |
|
|
98
|
+
| `client.cases` | `list(filters?)`, `get(id)`, `create(**data)`, `update(id, **data)`, `delete(id)` |
|
|
99
|
+
| `client.detections` | `list(page, page_size)`, `get(id)` |
|
|
100
|
+
| `client.connectors` | `list(page, page_size)`, `get(id)` |
|
|
101
|
+
| `client.playbooks` | `list(page, page_size)`, `get(id)`, `create(**data)`, `update(id, **data)`, `delete(id)`, `run(id, trigger_data?)`, `get_run(run_id)` |
|
|
102
|
+
| `client.api_keys` | `list()`, `create(req)`, `revoke(id)` |
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install -e ".[dev]"
|
|
108
|
+
pytest
|
|
109
|
+
```
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# aisoc-sdk
|
|
2
|
+
|
|
3
|
+
[](../../LICENSE)
|
|
4
|
+
[](https://github.com/beenuar/AiSOC/blob/main/CHANGELOG.md)
|
|
5
|
+
|
|
6
|
+
Async Python client SDK for [AiSOC](https://github.com/beenuar/AiSOC).
|
|
7
|
+
|
|
8
|
+
> **Status — monorepo today, not yet on PyPI.** `pip install aisoc-sdk` does not resolve; install from the monorepo source path below. The import path (`aisoc_sdk`) and API surface stay identical once it ships.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# Today (from this monorepo):
|
|
14
|
+
git clone https://github.com/beenuar/AiSOC.git
|
|
15
|
+
cd AiSOC && pip install -e packages/sdk-py
|
|
16
|
+
|
|
17
|
+
# Not yet on PyPI — the upload is blocked on registry credentials,
|
|
18
|
+
# which is an account action rather than a code change. Until then, install
|
|
19
|
+
# from source with the command above.
|
|
20
|
+
# pip install aisoc-sdk
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import asyncio
|
|
27
|
+
from aisoc_sdk import AiSOCClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def main():
|
|
31
|
+
async with AiSOCClient(
|
|
32
|
+
base_url="https://your-aisoc.example.com",
|
|
33
|
+
token="aisoc_...",
|
|
34
|
+
) as client:
|
|
35
|
+
# List critical open alerts
|
|
36
|
+
alerts = await client.alerts.list(severity="critical", status="open")
|
|
37
|
+
print(f"Found {alerts.total} critical alerts")
|
|
38
|
+
|
|
39
|
+
# Create a case
|
|
40
|
+
case = await client.cases.create(
|
|
41
|
+
title="Suspicious lateral movement",
|
|
42
|
+
priority="high",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Trigger a playbook
|
|
46
|
+
run = await client.playbooks.run(
|
|
47
|
+
"isolate-host",
|
|
48
|
+
trigger_data={"host_id": "srv-prod-42", "case_id": case.id},
|
|
49
|
+
)
|
|
50
|
+
print("Playbook run:", run.run_id)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
asyncio.run(main())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## GraphQL
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
async with AiSOCClient(base_url="...", token="...") as client:
|
|
60
|
+
result = await client.graphql("""
|
|
61
|
+
query {
|
|
62
|
+
alerts(pageSize: 10, status: "open") {
|
|
63
|
+
items { id title severity }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
""")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## API reference
|
|
70
|
+
|
|
71
|
+
All resource methods are `async` and return typed Pydantic models.
|
|
72
|
+
|
|
73
|
+
| Attribute | Methods |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `client.alerts` | `list(filters?)`, `get(id)`, `update(id, **data)` |
|
|
76
|
+
| `client.cases` | `list(filters?)`, `get(id)`, `create(**data)`, `update(id, **data)`, `delete(id)` |
|
|
77
|
+
| `client.detections` | `list(page, page_size)`, `get(id)` |
|
|
78
|
+
| `client.connectors` | `list(page, page_size)`, `get(id)` |
|
|
79
|
+
| `client.playbooks` | `list(page, page_size)`, `get(id)`, `create(**data)`, `update(id, **data)`, `delete(id)`, `run(id, trigger_data?)`, `get_run(run_id)` |
|
|
80
|
+
| `client.api_keys` | `list()`, `create(req)`, `revoke(id)` |
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install -e ".[dev]"
|
|
86
|
+
pytest
|
|
87
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aisoc-sdk"
|
|
7
|
+
version = "4.0.0"
|
|
8
|
+
description = "Python client SDK for AiSOC — typed httpx client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["aisoc", "soc", "security", "client", "sdk"]
|
|
13
|
+
authors = [{ name = "AiSOC Contributors", email = "oss@aisoc.io" }]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"httpx>=0.27.0",
|
|
16
|
+
"pydantic>=2.0.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8.0",
|
|
22
|
+
"pytest-asyncio>=0.23",
|
|
23
|
+
"pytest-httpx>=0.30",
|
|
24
|
+
"mypy>=2.3.1,<3",
|
|
25
|
+
"ruff>=0.16.8,<0.17",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/beenuar/AiSOC"
|
|
30
|
+
Documentation = "https://beenuar.github.io/AiSOC"
|
|
31
|
+
Repository = "https://github.com/beenuar/AiSOC"
|
|
32
|
+
Issues = "https://github.com/beenuar/AiSOC/issues"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/aisoc_sdk"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
|
|
44
|
+
[tool.mypy]
|
|
45
|
+
strict = true
|
|
46
|
+
# Pinned, like the other five trees that declare [tool.mypy]. Without it
|
|
47
|
+
# mypy resolves the standard library for whatever interpreter it happens to
|
|
48
|
+
# run on, so this tree's share of the recorded baseline moved whenever CI's
|
|
49
|
+
# Python did — and the ratchet would red on a workflow change that touched
|
|
50
|
+
# none of this code. 3.11 is the interpreter every service image ships and
|
|
51
|
+
# the one CI now runs.
|
|
52
|
+
python_version = "3.11"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""aisoc-sdk — Python client for AiSOC.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from aisoc_sdk import AiSOCClient
|
|
6
|
+
|
|
7
|
+
async with AiSOCClient(base_url="https://soc.example.com", token="aisoc_...") as client:
|
|
8
|
+
alerts = await client.alerts.list(severity="critical")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import AiSOCClient, AiSOCError
|
|
12
|
+
from .models import (
|
|
13
|
+
Alert,
|
|
14
|
+
AlertFilters,
|
|
15
|
+
AlertSeverity,
|
|
16
|
+
AlertStatus,
|
|
17
|
+
ApiKey,
|
|
18
|
+
ApiKeyCreateRequest,
|
|
19
|
+
ApiKeyCreateResponse,
|
|
20
|
+
Case,
|
|
21
|
+
CaseFilters,
|
|
22
|
+
CasePriority,
|
|
23
|
+
CaseStatus,
|
|
24
|
+
Connector,
|
|
25
|
+
DetectionRule,
|
|
26
|
+
Page,
|
|
27
|
+
Playbook,
|
|
28
|
+
PlaybookRun,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AiSOCClient",
|
|
33
|
+
"AiSOCError",
|
|
34
|
+
"Alert",
|
|
35
|
+
"AlertFilters",
|
|
36
|
+
"AlertSeverity",
|
|
37
|
+
"AlertStatus",
|
|
38
|
+
"ApiKey",
|
|
39
|
+
"ApiKeyCreateRequest",
|
|
40
|
+
"ApiKeyCreateResponse",
|
|
41
|
+
"Case",
|
|
42
|
+
"CaseFilters",
|
|
43
|
+
"CasePriority",
|
|
44
|
+
"CaseStatus",
|
|
45
|
+
"Connector",
|
|
46
|
+
"DetectionRule",
|
|
47
|
+
"Page",
|
|
48
|
+
"Playbook",
|
|
49
|
+
"PlaybookRun",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
__version__ = "4.0.0"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""AiSOCClient — async httpx-based client for the AiSOC REST API.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
async with AiSOCClient(base_url="https://soc.example.com", token="aisoc_...") as c:
|
|
6
|
+
page = await c.alerts.list(severity="critical")
|
|
7
|
+
case = await c.cases.create(title="Incident", priority="high")
|
|
8
|
+
run = await c.playbooks.run("isolate-host", trigger_data={"host": "srv-42"})
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional, Type, TypeVar
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from pydantic import TypeAdapter
|
|
17
|
+
|
|
18
|
+
from .models import (
|
|
19
|
+
Alert,
|
|
20
|
+
AlertFilters,
|
|
21
|
+
ApiKey,
|
|
22
|
+
ApiKeyCreateRequest,
|
|
23
|
+
ApiKeyCreateResponse,
|
|
24
|
+
Case,
|
|
25
|
+
CaseFilters,
|
|
26
|
+
Connector,
|
|
27
|
+
DetectionRule,
|
|
28
|
+
Page,
|
|
29
|
+
Playbook,
|
|
30
|
+
PlaybookRun,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ─── Error ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AiSOCError(Exception):
|
|
40
|
+
"""Raised when the AiSOC API returns a non-2xx response."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
43
|
+
self.status_code = status_code
|
|
44
|
+
self.detail = detail
|
|
45
|
+
super().__init__(f"AiSOC API {status_code}: {detail}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ─── Base resource client ─────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _ResourceClient:
|
|
52
|
+
def __init__(self, http: httpx.AsyncClient) -> None:
|
|
53
|
+
self._http = http
|
|
54
|
+
|
|
55
|
+
async def _get(
|
|
56
|
+
self,
|
|
57
|
+
path: str,
|
|
58
|
+
params: Optional[dict[str, Any]] = None,
|
|
59
|
+
model: Optional[Type[T]] = None,
|
|
60
|
+
) -> Any:
|
|
61
|
+
r = await self._http.get(path, params=self._clean(params))
|
|
62
|
+
self._raise(r)
|
|
63
|
+
if model is not None:
|
|
64
|
+
return TypeAdapter(model).validate_python(r.json())
|
|
65
|
+
return r.json()
|
|
66
|
+
|
|
67
|
+
async def _post(self, path: str, body: Any, model: Optional[Type[T]] = None) -> Any:
|
|
68
|
+
r = await self._http.post(path, json=body)
|
|
69
|
+
self._raise(r)
|
|
70
|
+
if model is not None:
|
|
71
|
+
return TypeAdapter(model).validate_python(r.json())
|
|
72
|
+
return r.json()
|
|
73
|
+
|
|
74
|
+
async def _patch(self, path: str, body: Any, model: Optional[Type[T]] = None) -> Any:
|
|
75
|
+
r = await self._http.patch(path, json=body)
|
|
76
|
+
self._raise(r)
|
|
77
|
+
if model is not None:
|
|
78
|
+
return TypeAdapter(model).validate_python(r.json())
|
|
79
|
+
return r.json()
|
|
80
|
+
|
|
81
|
+
async def _put(self, path: str, body: Any, model: Optional[Type[T]] = None) -> Any:
|
|
82
|
+
r = await self._http.put(path, json=body)
|
|
83
|
+
self._raise(r)
|
|
84
|
+
if model is not None:
|
|
85
|
+
return TypeAdapter(model).validate_python(r.json())
|
|
86
|
+
return r.json()
|
|
87
|
+
|
|
88
|
+
async def _delete(self, path: str) -> None:
|
|
89
|
+
r = await self._http.delete(path)
|
|
90
|
+
self._raise(r)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _clean(params: Optional[dict[str, Any]]) -> dict[str, Any]:
|
|
94
|
+
if params is None:
|
|
95
|
+
return {}
|
|
96
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _raise(r: httpx.Response) -> None:
|
|
100
|
+
if not r.is_success:
|
|
101
|
+
try:
|
|
102
|
+
detail = r.json().get("detail", r.text)
|
|
103
|
+
except Exception:
|
|
104
|
+
detail = r.text
|
|
105
|
+
raise AiSOCError(r.status_code, detail)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ─── Resource sub-clients ─────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AlertsClient(_ResourceClient):
|
|
112
|
+
async def list(self, filters: Optional[AlertFilters] = None, **kwargs: Any) -> Page[Alert]:
|
|
113
|
+
params = filters.model_dump(exclude_none=True) if filters else self._clean(kwargs)
|
|
114
|
+
return await self._get("/api/v1/alerts", params, Page[Alert])
|
|
115
|
+
|
|
116
|
+
async def get(self, alert_id: str) -> Alert:
|
|
117
|
+
return await self._get(f"/api/v1/alerts/{alert_id}", model=Alert)
|
|
118
|
+
|
|
119
|
+
async def update(self, alert_id: str, **data: Any) -> Alert:
|
|
120
|
+
return await self._patch(f"/api/v1/alerts/{alert_id}", data, Alert)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class CasesClient(_ResourceClient):
|
|
124
|
+
async def list(self, filters: Optional[CaseFilters] = None, **kwargs: Any) -> Page[Case]:
|
|
125
|
+
params = filters.model_dump(exclude_none=True) if filters else self._clean(kwargs)
|
|
126
|
+
return await self._get("/api/v1/cases", params, Page[Case])
|
|
127
|
+
|
|
128
|
+
async def get(self, case_id: str) -> Case:
|
|
129
|
+
return await self._get(f"/api/v1/cases/{case_id}", model=Case)
|
|
130
|
+
|
|
131
|
+
async def create(self, **data: Any) -> Case:
|
|
132
|
+
return await self._post("/api/v1/cases", data, Case)
|
|
133
|
+
|
|
134
|
+
async def update(self, case_id: str, **data: Any) -> Case:
|
|
135
|
+
return await self._patch(f"/api/v1/cases/{case_id}", data, Case)
|
|
136
|
+
|
|
137
|
+
# There is no `delete`. The API serves no DELETE on a case — a case is
|
|
138
|
+
# closed by patching its status, and the method that used to be here
|
|
139
|
+
# called a route `services/api` has never declared.
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class DetectionsClient(_ResourceClient):
|
|
143
|
+
# The route is `/detection/rules`, singular, and this client asked for
|
|
144
|
+
# `/detections` — so every method here answered 404 against a real
|
|
145
|
+
# deployment while its mocked test passed.
|
|
146
|
+
async def list(self, page: int = 1, page_size: int = 20) -> Page[DetectionRule]:
|
|
147
|
+
return await self._get(
|
|
148
|
+
"/api/v1/detection/rules", {"page": page, "page_size": page_size}, Page[DetectionRule]
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def get(self, rule_id: str) -> DetectionRule:
|
|
152
|
+
return await self._get(f"/api/v1/detection/rules/{rule_id}", model=DetectionRule)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ConnectorsClient(_ResourceClient):
|
|
156
|
+
async def list(self, page: int = 1, page_size: int = 20) -> Page[Connector]:
|
|
157
|
+
return await self._get(
|
|
158
|
+
"/api/v1/connectors", {"page": page, "page_size": page_size}, Page[Connector]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def get(self, connector_id: str) -> Connector:
|
|
162
|
+
return await self._get(f"/api/v1/connectors/{connector_id}", model=Connector)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class PlaybooksClient(_ResourceClient):
|
|
166
|
+
async def list(self, page: int = 1, page_size: int = 20) -> Page[Playbook]:
|
|
167
|
+
return await self._get(
|
|
168
|
+
"/api/v1/playbooks", {"page": page, "page_size": page_size}, Page[Playbook]
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def get(self, playbook_id: str) -> Playbook:
|
|
172
|
+
return await self._get(f"/api/v1/playbooks/{playbook_id}", model=Playbook)
|
|
173
|
+
|
|
174
|
+
async def create(self, **data: Any) -> Playbook:
|
|
175
|
+
return await self._post("/api/v1/playbooks", data, Playbook)
|
|
176
|
+
|
|
177
|
+
# PUT, not PATCH: `playbooks.py` declares `@router.put("/{playbook_id}")`
|
|
178
|
+
# and no patch route, so the previous verb returned 405.
|
|
179
|
+
async def update(self, playbook_id: str, **data: Any) -> Playbook:
|
|
180
|
+
return await self._put(f"/api/v1/playbooks/{playbook_id}", data, Playbook)
|
|
181
|
+
|
|
182
|
+
async def delete(self, playbook_id: str) -> None:
|
|
183
|
+
return await self._delete(f"/api/v1/playbooks/{playbook_id}")
|
|
184
|
+
|
|
185
|
+
async def run(
|
|
186
|
+
self,
|
|
187
|
+
playbook_id: str,
|
|
188
|
+
trigger_data: Optional[dict[str, Any]] = None,
|
|
189
|
+
) -> PlaybookRun:
|
|
190
|
+
return await self._post(
|
|
191
|
+
f"/api/v1/playbooks/{playbook_id}/run",
|
|
192
|
+
{"trigger_data": trigger_data or {}},
|
|
193
|
+
PlaybookRun,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def get_run(self, run_id: str) -> PlaybookRun:
|
|
197
|
+
return await self._get(f"/api/v1/playbooks/runs/{run_id}", model=PlaybookRun)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ApiKeysClient(_ResourceClient):
|
|
201
|
+
async def list(self) -> Page[ApiKey]:
|
|
202
|
+
return await self._get("/api/v1/api-keys", model=Page[ApiKey])
|
|
203
|
+
|
|
204
|
+
async def create(self, req: ApiKeyCreateRequest) -> ApiKeyCreateResponse:
|
|
205
|
+
return await self._post(
|
|
206
|
+
"/api/v1/api-keys",
|
|
207
|
+
req.model_dump(exclude_none=True),
|
|
208
|
+
ApiKeyCreateResponse,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
async def revoke(self, key_id: str) -> None:
|
|
212
|
+
return await self._delete(f"/api/v1/api-keys/{key_id}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ─── Main client ─────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class AiSOCClient:
|
|
219
|
+
"""Async Python client for the AiSOC REST API.
|
|
220
|
+
|
|
221
|
+
Must be used as an async context manager::
|
|
222
|
+
|
|
223
|
+
async with AiSOCClient(base_url="...", token="...") as client:
|
|
224
|
+
alerts = await client.alerts.list()
|
|
225
|
+
|
|
226
|
+
Or manage the lifecycle manually::
|
|
227
|
+
|
|
228
|
+
client = AiSOCClient(base_url="...", token="...")
|
|
229
|
+
await client.__aenter__()
|
|
230
|
+
try:
|
|
231
|
+
...
|
|
232
|
+
finally:
|
|
233
|
+
await client.__aexit__(None, None, None)
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
base_url: str,
|
|
239
|
+
token: str,
|
|
240
|
+
*,
|
|
241
|
+
timeout: float = 30.0,
|
|
242
|
+
headers: Optional[dict[str, str]] = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
self._base_url = base_url.rstrip("/")
|
|
245
|
+
self._token = token
|
|
246
|
+
self._timeout = timeout
|
|
247
|
+
self._extra_headers = headers or {}
|
|
248
|
+
self._http: Optional[httpx.AsyncClient] = None
|
|
249
|
+
|
|
250
|
+
# Placeholders — initialised in __aenter__
|
|
251
|
+
self.alerts: AlertsClient
|
|
252
|
+
self.cases: CasesClient
|
|
253
|
+
self.detections: DetectionsClient
|
|
254
|
+
self.connectors: ConnectorsClient
|
|
255
|
+
self.playbooks: PlaybooksClient
|
|
256
|
+
self.api_keys: ApiKeysClient
|
|
257
|
+
|
|
258
|
+
async def __aenter__(self) -> "AiSOCClient":
|
|
259
|
+
self._http = httpx.AsyncClient(
|
|
260
|
+
base_url=self._base_url,
|
|
261
|
+
headers={
|
|
262
|
+
"Authorization": f"Bearer {self._token}",
|
|
263
|
+
"Content-Type": "application/json",
|
|
264
|
+
**self._extra_headers,
|
|
265
|
+
},
|
|
266
|
+
timeout=self._timeout,
|
|
267
|
+
)
|
|
268
|
+
self.alerts = AlertsClient(self._http)
|
|
269
|
+
self.cases = CasesClient(self._http)
|
|
270
|
+
self.detections = DetectionsClient(self._http)
|
|
271
|
+
self.connectors = ConnectorsClient(self._http)
|
|
272
|
+
self.playbooks = PlaybooksClient(self._http)
|
|
273
|
+
self.api_keys = ApiKeysClient(self._http)
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
277
|
+
if self._http is not None:
|
|
278
|
+
await self._http.aclose()
|
|
279
|
+
self._http = None
|
|
280
|
+
|
|
281
|
+
async def graphql(
|
|
282
|
+
self,
|
|
283
|
+
query: str,
|
|
284
|
+
variables: Optional[dict[str, Any]] = None,
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
"""Execute a GraphQL query against the /graphql endpoint."""
|
|
287
|
+
if self._http is None:
|
|
288
|
+
raise RuntimeError("Use AiSOCClient as an async context manager")
|
|
289
|
+
r = await self._http.post("/graphql", json={"query": query, "variables": variables})
|
|
290
|
+
if not r.is_success:
|
|
291
|
+
raise AiSOCError(r.status_code, r.text)
|
|
292
|
+
return r.json() # type: ignore[return-value]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Pydantic models mirroring the AiSOC OpenAPI schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Generic, List, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── Enums ─────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AlertSeverity(str, Enum):
|
|
16
|
+
CRITICAL = "critical"
|
|
17
|
+
HIGH = "high"
|
|
18
|
+
MEDIUM = "medium"
|
|
19
|
+
LOW = "low"
|
|
20
|
+
INFO = "info"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AlertStatus(str, Enum):
|
|
24
|
+
OPEN = "open"
|
|
25
|
+
IN_PROGRESS = "in_progress"
|
|
26
|
+
CLOSED = "closed"
|
|
27
|
+
FALSE_POSITIVE = "false_positive"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CasePriority(str, Enum):
|
|
31
|
+
CRITICAL = "critical"
|
|
32
|
+
HIGH = "high"
|
|
33
|
+
MEDIUM = "medium"
|
|
34
|
+
LOW = "low"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CaseStatus(str, Enum):
|
|
38
|
+
OPEN = "open"
|
|
39
|
+
INVESTIGATING = "investigating"
|
|
40
|
+
RESOLVED = "resolved"
|
|
41
|
+
CLOSED = "closed"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Core models ───────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
_M = ConfigDict(populate_by_name=True, from_attributes=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Alert(BaseModel):
|
|
50
|
+
model_config = _M
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
tenant_id: str
|
|
54
|
+
title: str
|
|
55
|
+
severity: AlertSeverity
|
|
56
|
+
status: AlertStatus
|
|
57
|
+
source: str
|
|
58
|
+
source_ref: Optional[str] = None
|
|
59
|
+
mitre_tactics: List[str] = []
|
|
60
|
+
ai_score: Optional[float] = None
|
|
61
|
+
case_id: Optional[str] = None
|
|
62
|
+
created_at: datetime
|
|
63
|
+
updated_at: datetime
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Case(BaseModel):
|
|
67
|
+
model_config = _M
|
|
68
|
+
|
|
69
|
+
id: str
|
|
70
|
+
tenant_id: str
|
|
71
|
+
case_number: str
|
|
72
|
+
title: str
|
|
73
|
+
status: CaseStatus
|
|
74
|
+
priority: CasePriority
|
|
75
|
+
assignee: Optional[str] = None
|
|
76
|
+
mitre_tactics: List[str] = []
|
|
77
|
+
alert_ids: List[str] = []
|
|
78
|
+
created_at: datetime
|
|
79
|
+
updated_at: datetime
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DetectionRule(BaseModel):
|
|
83
|
+
model_config = _M
|
|
84
|
+
|
|
85
|
+
id: str
|
|
86
|
+
tenant_id: str
|
|
87
|
+
name: str
|
|
88
|
+
description: Optional[str] = None
|
|
89
|
+
rule_language: str
|
|
90
|
+
severity: AlertSeverity
|
|
91
|
+
enabled: bool
|
|
92
|
+
created_at: datetime
|
|
93
|
+
updated_at: datetime
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Connector(BaseModel):
|
|
97
|
+
model_config = _M
|
|
98
|
+
|
|
99
|
+
id: str
|
|
100
|
+
tenant_id: str
|
|
101
|
+
name: str
|
|
102
|
+
connector_type: str
|
|
103
|
+
is_enabled: bool
|
|
104
|
+
health_status: str
|
|
105
|
+
events_ingested: int = 0
|
|
106
|
+
created_at: datetime
|
|
107
|
+
updated_at: datetime
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class PlaybookStep(BaseModel):
|
|
111
|
+
model_config = _M
|
|
112
|
+
|
|
113
|
+
id: str
|
|
114
|
+
name: str
|
|
115
|
+
type: str
|
|
116
|
+
action: Optional[str] = None
|
|
117
|
+
parameters: Optional[dict[str, Any]] = None
|
|
118
|
+
next_steps: List[str] = []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Playbook(BaseModel):
|
|
122
|
+
model_config = _M
|
|
123
|
+
|
|
124
|
+
id: str
|
|
125
|
+
name: str
|
|
126
|
+
description: Optional[str] = None
|
|
127
|
+
version: str
|
|
128
|
+
steps: List[PlaybookStep] = []
|
|
129
|
+
trigger_conditions: Optional[dict[str, Any]] = None
|
|
130
|
+
created_at: datetime
|
|
131
|
+
updated_at: datetime
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class PlaybookRun(BaseModel):
|
|
135
|
+
model_config = _M
|
|
136
|
+
|
|
137
|
+
run_id: str
|
|
138
|
+
playbook_id: str
|
|
139
|
+
status: str
|
|
140
|
+
started_at: datetime
|
|
141
|
+
completed_at: Optional[datetime] = None
|
|
142
|
+
trigger_data: Optional[dict[str, Any]] = None
|
|
143
|
+
step_results: Optional[dict[str, Any]] = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ApiKey(BaseModel):
|
|
147
|
+
model_config = _M
|
|
148
|
+
|
|
149
|
+
id: str
|
|
150
|
+
name: str
|
|
151
|
+
prefix: str
|
|
152
|
+
scopes: List[str]
|
|
153
|
+
expires_at: Optional[datetime] = None
|
|
154
|
+
last_used_at: Optional[datetime] = None
|
|
155
|
+
created_at: datetime
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── Pagination ────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
T = TypeVar("T")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Page(BaseModel, Generic[T]):
|
|
164
|
+
model_config = _M
|
|
165
|
+
|
|
166
|
+
items: List[T]
|
|
167
|
+
total: int
|
|
168
|
+
page: int
|
|
169
|
+
page_size: int
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── Request / response helpers ────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class AlertFilters(BaseModel):
|
|
176
|
+
model_config = _M
|
|
177
|
+
|
|
178
|
+
severity: Optional[AlertSeverity] = None
|
|
179
|
+
status: Optional[AlertStatus] = None
|
|
180
|
+
case_id: Optional[str] = None
|
|
181
|
+
search: Optional[str] = None
|
|
182
|
+
page: int = 1
|
|
183
|
+
page_size: int = 20
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class CaseFilters(BaseModel):
|
|
187
|
+
model_config = _M
|
|
188
|
+
|
|
189
|
+
status: Optional[CaseStatus] = None
|
|
190
|
+
priority: Optional[CasePriority] = None
|
|
191
|
+
assignee: Optional[str] = None
|
|
192
|
+
page: int = 1
|
|
193
|
+
page_size: int = 20
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ApiKeyCreateRequest(BaseModel):
|
|
197
|
+
model_config = _M
|
|
198
|
+
|
|
199
|
+
name: str
|
|
200
|
+
scopes: List[str]
|
|
201
|
+
expires_at: Optional[datetime] = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ApiKeyCreateResponse(BaseModel):
|
|
205
|
+
model_config = _M
|
|
206
|
+
|
|
207
|
+
key: ApiKey
|
|
208
|
+
raw_key: str
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Unit tests for the aisoc-sdk Python client.
|
|
2
|
+
|
|
3
|
+
Uses pytest-httpx to intercept outgoing requests — no real server needed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from pytest_httpx import HTTPXMock
|
|
10
|
+
|
|
11
|
+
from aisoc_sdk import AiSOCClient, AiSOCError
|
|
12
|
+
from aisoc_sdk.models import AlertSeverity, AlertStatus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BASE_URL = "https://aisoc.test"
|
|
16
|
+
TOKEN = "aisoc_test_token"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
async def client():
|
|
24
|
+
async with AiSOCClient(base_url=BASE_URL, token=TOKEN) as c:
|
|
25
|
+
yield c
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─── Alert tests ──────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_alerts_list(httpx_mock: HTTPXMock):
|
|
33
|
+
page_data = {
|
|
34
|
+
"items": [
|
|
35
|
+
{
|
|
36
|
+
"id": "a1",
|
|
37
|
+
"tenant_id": "t1",
|
|
38
|
+
"title": "Test Alert",
|
|
39
|
+
"severity": "critical",
|
|
40
|
+
"status": "open",
|
|
41
|
+
"source": "siem",
|
|
42
|
+
"mitre_tactics": [],
|
|
43
|
+
"created_at": "2024-01-01T00:00:00Z",
|
|
44
|
+
"updated_at": "2024-01-01T00:00:00Z",
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"total": 1,
|
|
48
|
+
"page": 1,
|
|
49
|
+
"page_size": 20,
|
|
50
|
+
}
|
|
51
|
+
httpx_mock.add_response(json=page_data)
|
|
52
|
+
|
|
53
|
+
async with AiSOCClient(base_url=BASE_URL, token=TOKEN) as client:
|
|
54
|
+
page = await client.alerts.list()
|
|
55
|
+
|
|
56
|
+
assert page.total == 1
|
|
57
|
+
assert page.items[0].id == "a1"
|
|
58
|
+
assert page.items[0].severity == AlertSeverity.CRITICAL
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_alerts_get(httpx_mock: HTTPXMock):
|
|
63
|
+
alert_data = {
|
|
64
|
+
"id": "a42",
|
|
65
|
+
"tenant_id": "t1",
|
|
66
|
+
"title": "Critical Alert",
|
|
67
|
+
"severity": "high",
|
|
68
|
+
"status": "in_progress",
|
|
69
|
+
"source": "edr",
|
|
70
|
+
"mitre_tactics": ["TA0001"],
|
|
71
|
+
"created_at": "2024-01-01T00:00:00Z",
|
|
72
|
+
"updated_at": "2024-01-01T00:00:00Z",
|
|
73
|
+
}
|
|
74
|
+
httpx_mock.add_response(json=alert_data)
|
|
75
|
+
|
|
76
|
+
async with AiSOCClient(base_url=BASE_URL, token=TOKEN) as client:
|
|
77
|
+
alert = await client.alerts.get("a42")
|
|
78
|
+
|
|
79
|
+
assert alert.id == "a42"
|
|
80
|
+
assert alert.status == AlertStatus.IN_PROGRESS
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ─── Case tests ───────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_cases_create(httpx_mock: HTTPXMock):
|
|
88
|
+
case_data = {
|
|
89
|
+
"id": "c1",
|
|
90
|
+
"tenant_id": "t1",
|
|
91
|
+
"case_number": "CASE-001",
|
|
92
|
+
"title": "Incident",
|
|
93
|
+
"status": "open",
|
|
94
|
+
"priority": "high",
|
|
95
|
+
"mitre_tactics": [],
|
|
96
|
+
"alert_ids": [],
|
|
97
|
+
"created_at": "2024-01-01T00:00:00Z",
|
|
98
|
+
"updated_at": "2024-01-01T00:00:00Z",
|
|
99
|
+
}
|
|
100
|
+
httpx_mock.add_response(status_code=201, json=case_data)
|
|
101
|
+
|
|
102
|
+
async with AiSOCClient(base_url=BASE_URL, token=TOKEN) as client:
|
|
103
|
+
case = await client.cases.create(title="Incident", priority="high")
|
|
104
|
+
|
|
105
|
+
assert case.id == "c1"
|
|
106
|
+
assert case.case_number == "CASE-001"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.asyncio
|
|
110
|
+
async def test_a_204_delete_resolves_to_none(httpx_mock: HTTPXMock):
|
|
111
|
+
"""This used to exercise ``cases.delete``.
|
|
112
|
+
|
|
113
|
+
That method called a DELETE route the API has never declared, and this
|
|
114
|
+
test passed anyway because ``httpx_mock`` answers whatever the client
|
|
115
|
+
asks. Repointed at ``playbooks.delete``, which is a real 204.
|
|
116
|
+
"""
|
|
117
|
+
httpx_mock.add_response(status_code=204)
|
|
118
|
+
|
|
119
|
+
async with AiSOCClient(base_url=BASE_URL, token=TOKEN) as client:
|
|
120
|
+
result = await client.playbooks.delete("p1")
|
|
121
|
+
|
|
122
|
+
assert result is None
|
|
123
|
+
assert httpx_mock.get_requests()[0].url.path == "/api/v1/playbooks/p1"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ─── Error handling ───────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.asyncio
|
|
130
|
+
async def test_raises_aisoc_error_on_404(httpx_mock: HTTPXMock):
|
|
131
|
+
httpx_mock.add_response(status_code=404, json={"detail": "Not found"})
|
|
132
|
+
|
|
133
|
+
async with AiSOCClient(base_url=BASE_URL, token=TOKEN) as client:
|
|
134
|
+
with pytest.raises(AiSOCError) as exc_info:
|
|
135
|
+
await client.alerts.get("missing")
|
|
136
|
+
|
|
137
|
+
assert exc_info.value.status_code == 404
|
|
138
|
+
assert "Not found" in exc_info.value.detail
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_raises_aisoc_error_on_403(httpx_mock: HTTPXMock):
|
|
143
|
+
httpx_mock.add_response(status_code=403, json={"detail": "Forbidden"})
|
|
144
|
+
|
|
145
|
+
async with AiSOCClient(base_url=BASE_URL, token=TOKEN) as client:
|
|
146
|
+
with pytest.raises(AiSOCError) as exc_info:
|
|
147
|
+
await client.cases.list()
|
|
148
|
+
|
|
149
|
+
assert exc_info.value.status_code == 403
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ─── Auth header ─────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio
|
|
156
|
+
async def test_bearer_token_is_sent(httpx_mock: HTTPXMock):
|
|
157
|
+
httpx_mock.add_response(json={"items": [], "total": 0, "page": 1, "page_size": 20})
|
|
158
|
+
|
|
159
|
+
async with AiSOCClient(base_url=BASE_URL, token="aisoc_my_secret") as client:
|
|
160
|
+
await client.alerts.list()
|
|
161
|
+
|
|
162
|
+
request = httpx_mock.get_requests()[0]
|
|
163
|
+
assert request.headers["Authorization"] == "Bearer aisoc_my_secret"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ─── Context manager ─────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_context_manager_required():
|
|
171
|
+
client = AiSOCClient(base_url=BASE_URL, token=TOKEN)
|
|
172
|
+
with pytest.raises(RuntimeError):
|
|
173
|
+
await client.graphql("{ __typename }")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ─── GraphQL ─────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_graphql_query(httpx_mock: HTTPXMock):
|
|
181
|
+
httpx_mock.add_response(json={"data": {"__typename": "Query"}})
|
|
182
|
+
|
|
183
|
+
async with AiSOCClient(base_url=BASE_URL, token=TOKEN) as client:
|
|
184
|
+
result = await client.graphql("{ __typename }")
|
|
185
|
+
|
|
186
|
+
assert result["data"]["__typename"] == "Query"
|