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.
@@ -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
@@ -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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE)
26
+ [![PyPI release](https://img.shields.io/badge/pypi-not%20yet%20published-f59e0b)](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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE)
4
+ [![PyPI release](https://img.shields.io/badge/pypi-not%20yet%20published-f59e0b)](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"