From 5383be756be4b9d347a076179a7540d9b8e48b37 Mon Sep 17 00:00:00 2001 From: Xore Date: Sun, 27 Sep 2026 02:48:08 +0200 Subject: [PATCH 1/6] feat(backend-service): publish the OpenAPI contract for /api and fuzz it weekly (#3325) Adds a machine-readable OpenAPI 3.1 contract for backend-service's /api surface -- 130 registered paths, 139 operations -- and the two gates that keep it from drifting, plus the weekly fuzz job the issue asked for. The contract is hand-maintained in a new src/openapi.rs rather than derived with utoipa. Almost every handler returns Json, and the property the issue wants pinned lives in the route table and the extractor signatures, not the response bodies, so a #[utoipa::path] on 138 handlers would learn only that GET /api/v1/events takes a Query and answers 401 without a service token. Three gates, failing in different directions on purpose: - contract_covers_every_router_route reads main.rs and fails if the router and the contract disagree about which (path, method) pairs exist. A route added without a contract row is the direction that rots, since it drops a fuzz target silently. - checked_in_contract_is_current fails if the committed openapi.json is not what the module renders. - quality.yml runs the same generator as a diff -u in both backend jobs, because a test failure says "stale" while the diff says what changed. Response bodies stay free-form on purpose: 138 transcribed shapes would be 138 chances to assert something the code does not enforce. The document pins the auth tier, the request parameters (including the enums the handlers actually validate), the declared status codes, and every response's media type. weekly-schemathesis.yml runs Mondays 04:23 UTC and on workflow_dispatch, booting the service against a single-node Elasticsearch service container -- without a cluster every ES-backed route answers 502 and the run measures nothing. Only the auth gate can fail the job; both schemathesis passes are advisory, because this API validates aggressively enough that a hard gate would be red on its first run and train everyone to ignore it. That gate is scripts/check-api-auth-tier.py, not schemathesis's ignored_auth. ignored_auth skips any operation the contract declares public, so demoting a live route in the document turns the check green while the route keeps serving anonymous callers -- reproduced on this service, where marking /api/v1/events public gave a passing run and one warning. The script reads the same document for expected answers but never trusts it about which routes are secured; that direction is the Rust test, which asserts the public set is exactly /healthz and /metrics. One row does not follow the file's pattern and is worth flagging in review: GET /api/v1/webhook-delivery, added by #3373 on main during this branch's rebase, is the only ES-backed read that declares no 502. Its handler catches the cluster error and answers 200 with `available: false` and the reason in the body, so a stated empty card beats a page that fails to render. Every neighbouring diagnostics row 502s because its extractor turns a dead cluster into a status code; copying the pattern here would have been a guess the source does not support. Running the job before shipping it found eight real omissions in the first draft of the contract, all now fixed: 415 and 422 from axum's Json rejection on all 25 body routes, 400 from its Query rejection, four services routes answering JSON errors declared as text/plain, a text/plain export declared as JSON, 404 from the correlations and reporter-stats lookups, and 422/501 from the report renderer's own error mapping. None of them are reachable from the router's source -- nothing in main.rs mentions 415 -- so the builders now add the extractor-level statuses instead of leaving each row to remember. The passes report those two finding classes by name so a regression in the document is visible even though it cannot fail the job. Against a booted service the document is now clean: 136 secured operations refuse an anonymous caller, and the authenticated pass finds nothing against the contract itself. Refs #3325 --- .github/workflows/quality.yml | 13 + .github/workflows/weekly-schemathesis.yml | 286 + .gitignore | 6 + .../backend-service/openapi.json | 10660 ++++++++++++++++ .../backend-service/src/bin/openapi.rs | 24 + .../backend-service/src/lib.rs | 14 + .../backend-service/src/openapi.rs | 1811 +++ docs/CI-CD.md | 61 + scripts/check-api-auth-tier.py | 211 + 9 files changed, 13086 insertions(+) create mode 100644 .github/workflows/weekly-schemathesis.yml create mode 100644 arcane/home/honeypot-dashboard/backend-service/openapi.json create mode 100644 arcane/home/honeypot-dashboard/backend-service/src/bin/openapi.rs create mode 100644 arcane/home/honeypot-dashboard/backend-service/src/lib.rs create mode 100644 arcane/home/honeypot-dashboard/backend-service/src/openapi.rs create mode 100644 scripts/check-api-auth-tier.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 3766299b..67b5502b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -980,6 +980,14 @@ jobs: if-no-files-found: warn retention-days: 7 - run: PATH="$HOME/.cargo/bin:$PATH" cargo clippy --all-targets -- -D warnings + # #3325: the committed contract has to be what the generator + # renders. `cargo test` already fails on a stale openapi.json, but + # only the *tests* fail, and only once someone reads which of the + # four assertions went red -- `diff` states the actual change and + # is the step a reviewer reads when they wonder why the file moved. + # Regenerate locally with `cargo run --bin openapi > openapi.json`. + - name: OpenAPI contract is not stale (#3325) + run: PATH="$HOME/.cargo/bin:$PATH" cargo run --quiet --bin openapi | diff -u openapi.json - backend-service-cloud: name: Dashboard backend-service (Rust) (GitHub-hosted) @@ -1027,6 +1035,11 @@ jobs: if-no-files-found: warn retention-days: 7 - run: cargo clippy --all-targets -- -D warnings + # #3325: same drift gate as the homeserver lane -- see the note + # there. The two lanes both build this contract, so the contract + # cannot pass on one toolchain and fail on the other. + - name: OpenAPI contract is not stale (#3325) + run: cargo run --quiet --bin openapi | diff -u openapi.json - vendored-theme: name: Vendored Xore/theme is in sync diff --git a/.github/workflows/weekly-schemathesis.yml b/.github/workflows/weekly-schemathesis.yml new file mode 100644 index 00000000..8496d760 --- /dev/null +++ b/.github/workflows/weekly-schemathesis.yml @@ -0,0 +1,286 @@ +name: Weekly schemathesis fuzz of the /api contract + +# #3325's second half. The contract in +# arcane/home/honeypot-dashboard/backend-service/openapi.json is checked in +# and gated against the router by quality.yml, so it cannot drift from the +# route table -- but "the routes are all listed" is not "the routes behave +# the way the document says". This job boots the real service and holds it +# to its own published contract. +# +# Three steps do three different kinds of work, and only the first can fail +# the job: +# +# 1. scripts/check-api-auth-tier.py -- the auth tier, checked against the +# running service. Every secured operation must answer 401/403 with no +# token, and the two public ones must not. This is the property #3325 +# was filed for, and it is a hard gate. +# 2. schemathesis, unauthenticated -- the same ground from the other +# side, using generated requests rather than one probe per route. +# 3. schemathesis, authenticated -- a fixture service token, so the +# fuzzer reaches the handlers and checks the status codes and media +# types they actually answer. +# +# The auth gate is a script and not a schemathesis check on purpose. +# `ignored_auth` skips any operation the contract declares public, so +# demoting a live /api/v1 route in the document turns the check green +# while the route keeps serving unauthenticated callers -- reproduced on +# this service, where marking /api/v1/events public gave a passing run and +# one warning. A gate you can switch off by editing the document it is +# meant to police is not a gate. The script reads the same document for +# the expected answers but never trusts it about which routes are secured; +# that direction is openapi.rs's own test, which asserts the public set is +# exactly /healthz and /metrics. +# +# ADVISORY for everything schemathesis finds. A generated-but-invalid +# request is not automatically a defect: this API validates aggressively +# and returns 400 for inputs no schema can distinguish from nonsense, so a +# hard gate would be red on its first run and train everyone to ignore it. +# The value is the standing record, so a change in that record is visible. +# The contract defects this job found on its first run -- 415/422 from +# axum's Json rejection, 400 from Query, four routes answering JSON +# errors declared as text/plain, a text/plain export declared as JSON -- +# were fixed in the document, not by suppressing the output. That is why +# pass 3 reports the contract-level finding classes by name even though it +# cannot fail: a regression there is a regression in the thing we ship. +# +# The ES service container is not optional. Every ES-backed route answers +# 502 when Elasticsearch is unreachable, and 502 is a documented status -- +# so without a cluster this job measures nothing but its own fixtures and +# buries the real signal under ~85 identical 5xx findings. A plain +# single-node cluster with security off is the smallest thing that makes +# the read paths answer, and there are no fixtures to seed: es.rs searches +# with ignore_unavailable(true), so a bare cluster already returns 200s +# with empty hits. That is what makes this hermetic in #3316's sense -- it +# needs a live ES and nothing else, no honeypot, no dashboard BFF, no +# operator data. +on: + schedule: + # 04:23 UTC Mondays. Off the hour on purpose: this repo's other + # watchers all sit on :00, and a fleet of cron jobs waking together + # on the same minute is how a shared runner starts timing out. + - cron: "23 4 * * 1" + workflow_dispatch: + inputs: + max_examples: + description: "Fuzzing examples per operation (the weekly run uses 5)" + required: false + default: "5" + base_url: + description: "Base URL of an already-running backend-service (skips the local boot)" + required: false + default: "" + +permissions: + contents: read + +concurrency: + group: weekly-schemathesis + cancel-in-progress: false + +jobs: + fuzz: + name: schemathesis against a booted backend-service + runs-on: ubuntu-latest + timeout-minutes: 45 + defaults: + run: + # The contract and the crate both live here, so every step that + # names either path gets it for free. + working-directory: arcane/home/honeypot-dashboard/backend-service + services: + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:9.5.3@sha256:f456578fc2a620a8a4f4c21d070fff1f6070345adb2be5e5626b65be72aea350 + # Same pin as arcane/home/honeypot-elk/compose.yml and + # arcane/home/honeypot-init/compose.yml. #2315 is why a fuzz run + # must not quietly measure a different cluster than the one that + # ships. + env: + discovery.type: single-node + xpack.security.enabled: "false" + ES_JAVA_OPTS: -Xms1g -Xmx1g + ports: + - 9200:9200 + # A fresh cluster takes 40-60s to accept connections. Without + # this, the first routes the fuzzer hits answer 502 and every run + # reports the same 5xx wall. + options: >- + --health-cmd "curl -fsS http://localhost:9200/_cluster/health" + --health-interval 5s + --health-timeout 5s + --health-retries 40 + env: + SCHEMATHESIS_VERSION: "4.28.0" + SERVICE_TOKEN: schemathesis-fixture-token + LISTEN_ADDR: 127.0.0.1:8081 + ELASTICSEARCH_URL: http://127.0.0.1:9200 + BASE_URL: http://127.0.0.1:8081 + steps: + - uses: actions/checkout@v7 + + - name: Install the pinned schemathesis + run: | + set -euo pipefail + python3 -m pip install --quiet "schemathesis==${SCHEMATHESIS_VERSION}" + schemathesis --version + # The advisory record only means something against the version + # it was written for: a major bump changes which checks exist + # and which findings they name, so reading a diff across that is + # guesswork. Bumping this line is the deliberate act. + schemathesis run --help | grep -q -- "--exclude-path" \ + || { echo "the pinned schemathesis no longer has --exclude-path"; exit 1; } + + - name: Refuse to fuzz a stale contract + # The advisory steps must not be the thing that first learns the + # contract is stale: their output is a wall of findings, none of + # which would say so. quality.yml already gates this; failing + # loudly keeps a manual dispatch from quietly fuzzing last week's + # document. + run: cargo run --quiet --bin openapi | diff -u openapi.json - + + - name: Build and boot backend-service + if: ${{ inputs.base_url == '' }} + run: | + set -euo pipefail + cargo build --quiet --bin apiary-backend + # The two state files default to /state/..., which does not + # exist on a GitHub runner, and main.rs opens the audit log at + # boot -- so an unwritable path is a crash loop before the + # fuzzer sends a single request. WORKER_LOOPS stays unset so + # the service serves without its background loops mutating + # state underneath the fuzzer. + mkdir -p "${RUNNER_TEMP}/state" + DASHBOARD_AUDIT_FILE="${RUNNER_TEMP}/state/audit.jsonl" \ + DASHBOARD_CONFIG_HISTORY_FILE="${RUNNER_TEMP}/state/config-history.jsonl" \ + ./target/debug/apiary-backend > "${RUNNER_TEMP}/backend.log" 2>&1 & + echo "$!" > "${RUNNER_TEMP}/backend.pid" + for _ in $(seq 1 60); do + if curl -fsS "${BASE_URL}/healthz" >/dev/null 2>&1; then + echo "backend-service is listening" + exit 0 + fi + sleep 1 + done + echo "backend-service never became reachable:" + cat "${RUNNER_TEMP}/backend.log" || true + exit 1 + + - name: Wait for an ES-backed read + if: ${{ inputs.base_url == '' }} + run: | + set -euo pipefail + # Belt and braces. /healthz answers 200 even when Elasticsearch + # is unreachable -- it reports reachability in its body rather + # than in the status -- so a green healthz is not proof the + # read paths work. A 200 from a real query is. + for _ in $(seq 1 30); do + if curl -fsS -H "X-Service-Token: ${SERVICE_TOKEN}" \ + "${BASE_URL}/api/v1/events?size=1" >/dev/null 2>&1; then + echo "an ES-backed read answered 200" + exit 0 + fi + sleep 2 + done + echo "no ES-backed read succeeded; the passes below would only measure 502s" + cat "${RUNNER_TEMP}/backend.log" || true + exit 1 + + - name: Auth tier - every secured route must refuse an anonymous caller + # The one step that can fail this job. See the header comment for + # why this is a script and not schemathesis's ignored_auth. + run: | + set -uo pipefail + python3 "${{ github.workspace }}/scripts/check-api-auth-tier.py" \ + --base-url "${BASE_URL}" + + - name: Unauthenticated fuzz pass (advisory) + # No token at all. Kept alongside the gate above because it asks + # the same question with generated requests and a wider net, and + # because its `ignored_auth` run is what surfaces the tier on any + # route the gate's single probe per operation happened to miss. + continue-on-error: true + run: | + set -uo pipefail + schemathesis run openapi.json \ + --url "${BASE_URL}" \ + --phases coverage,fuzzing \ + --max-examples "${{ inputs.max_examples }}" \ + --exclude-path '/api/v1/live' \ + --checks all \ + --report junit --report-dir "${RUNNER_TEMP}/report-unauthenticated" \ + 2>&1 | tee "${RUNNER_TEMP}/unauthenticated.txt" + + - name: Authenticated fuzz pass (advisory) + # X-Actor-Username comes along because the Workbench's + # require_actor rejects a valid token with no forwarded actor, + # which would otherwise read as "this route refuses everything". + continue-on-error: true + run: | + set -uo pipefail + schemathesis run openapi.json \ + --url "${BASE_URL}" \ + -H "X-Service-Token: ${SERVICE_TOKEN}" \ + -H "X-Actor-Username: schemathesis" \ + --phases coverage,fuzzing \ + --max-examples "${{ inputs.max_examples }}" \ + --exclude-path '/api/v1/live' \ + --exclude-path '/metrics' \ + --report junit --report-dir "${RUNNER_TEMP}/report-authenticated" \ + 2>&1 | tee "${RUNNER_TEMP}/authenticated.txt" + # Count the finding classes that indict the *document* rather + # than the service. This cannot fail the job, but it is the + # number worth watching week to week: it is the contract's own + # accuracy, and it is the class that regressed every time the + # passes ran for real. + python3 - "${RUNNER_TEMP}/report-authenticated" <<'PY' + import collections, glob, sys, xml.etree.ElementTree as ET + reports = glob.glob(f"{sys.argv[1]}/*.xml") + if not reports: + print("no JUnit report written") + sys.exit(0) + kinds = collections.Counter() + for case in ET.parse(sorted(reports)[-1]).getroot().iter("testcase"): + failure = case.find("failure") + if failure is None: + continue + for line in (failure.text or "").splitlines(): + line = line.strip() + if line.startswith("- "): + kinds[line[2:]] += 1 + print("\nfinding classes:") + for kind, count in kinds.most_common(): + print(f" {count:5d} {kind}") + against = (kinds.get("Undocumented HTTP status code", 0) + + kinds.get("Undocumented Content-Type", 0)) + if against: + print(f"\n{against} finding(s) say the contract is wrong about this service.") + print("That is a defect in the document, not the service: add the status or") + print("media type to the row in operations() (src/openapi.rs), then run") + print("`cargo run --bin openapi > openapi.json`.") + else: + print("\nNothing found against the contract itself: every status code and") + print("media type the service returned is one the document declares.") + PY + + - name: Upload the fuzz reports + # Advisory without a retrievable artifact is a red line in a log + # nobody reads. These JUnit reports are what a follow-up issue + # quotes. upload-artifact resolves paths against the workspace + # root, not this job's working-directory. + if: always() + uses: actions/upload-artifact@v6 + with: + name: schemathesis-reports + path: | + ${{ runner.temp }}/report-unauthenticated/ + ${{ runner.temp }}/report-authenticated/ + ${{ runner.temp }}/unauthenticated.txt + ${{ runner.temp }}/authenticated.txt + retention-days: 30 + if-no-files-found: ignore + + - name: Stop backend-service + if: ${{ always() && inputs.base_url == '' }} + run: | + set -uo pipefail + [ -f "${RUNNER_TEMP}/backend.pid" ] || exit 0 + kill "$(cat "${RUNNER_TEMP}/backend.pid")" 2>/dev/null || true diff --git a/.gitignore b/.gitignore index 51e7109b..ae126013 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ ml-worker/benchmarks/data/ ml-worker/benchmarks/beth-data/ round7/ 1947full/ + +# schemathesis's local run cache (case corpus, crash dumps). The weekly job +# writes its reports to $RUNNER_TEMP instead, so this only appears when a +# developer runs `schemathesis run` against the crate by hand -- and it is +# per-machine derived data, not something to review. +.schemathesis/ diff --git a/arcane/home/honeypot-dashboard/backend-service/openapi.json b/arcane/home/honeypot-dashboard/backend-service/openapi.json new file mode 100644 index 00000000..76da710d --- /dev/null +++ b/arcane/home/honeypot-dashboard/backend-service/openapi.json @@ -0,0 +1,10660 @@ +{ + "components": { + "securitySchemes": { + "serviceToken": { + "description": "The shared secret the Nitro BFF presents on every /api/v1 call (SERVICE_TOKEN; main.rs's require_service_token, #2183). Compared in constant time, and a wrong or missing value is a 401 before the route is even resolved. Browsers never reach this service directly.", + "in": "header", + "name": "X-Service-Token", + "type": "apiKey" + } + } + }, + "info": { + "description": "The /api surface of `backend-service/` (`apiary-backend`), the Rust service tier of the APIARY dashboard's modernization port (#1608). It is not a public API: the Nitro BFF in `frontend-next/` is the only intended caller and presents a shared service token on every /api/v1 route. /healthz and /metrics are the two exceptions, open on purpose for the container healthcheck and the #1972 metrics scrape.\n\nResponse bodies are deliberately left unconstrained here. The handlers return `Json` or a per-surface struct that nothing in this crate enforces, so restating 138 shapes would be 138 chances to publish something the code does not promise -- and a contract that lies about a response is worse than one that admits the gap. What this document does pin is the part that has been wrong before: the auth tier, the request parameters (including the enum-valued ones the handlers actually validate), the declared status codes, and the media type of every response. That is what `.github/workflows/weekly-schemathesis.yml` checks.\n\n`arcane/home/honeypot-dashboard/backend-service/src/openapi.rs` is the source of truth. Regenerate the committed copy with `cargo run --bin openapi > openapi.json`; the drift tests in that module and a diff step in `quality.yml` both fail if you forget.", + "summary": "The dashboard's Rust service tier: Elasticsearch-backed read APIs over the honeypot event corpus.", + "title": "APIARY dashboard backend-service", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/alerts": { + "get": { + "operationId": "get_api_v1_alerts", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Free-text Lucene query string.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Narrow to one source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "`sources` adds the payload-inventory source buckets; anything else is ignored.", + "in": "query", + "name": "aggs", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Alert-state store.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/alerts/{key}/ack": { + "post": { + "operationId": "post_api_v1_alerts__key__ack", + "parameters": [ + { + "description": "Alert-state document key (the hashified signature triple).", + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `AckBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Acknowledge one alert.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/artifacts/{kind}/{key}": { + "get": { + "operationId": "get_api_v1_artifacts__kind___key_", + "parameters": [ + { + "description": "Artifact family.", + "in": "path", + "name": "kind", + "required": true, + "schema": { + "enum": [ + "ghidra", + "sandbox" + ], + "type": "string" + } + }, + { + "description": "Run id the artifacts belong to (a sha256 for ghidra, a job id for sandbox).", + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Artifacts a run produced, one row per filename.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/artifacts/{kind}/{key}/{filename}": { + "get": { + "operationId": "get_api_v1_artifacts__kind___key___filename_", + "parameters": [ + { + "description": "Artifact family.", + "in": "path", + "name": "kind", + "required": true, + "schema": { + "enum": [ + "ghidra", + "sandbox" + ], + "type": "string" + } + }, + { + "description": "Run id the artifacts belong to.", + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Exact stored filename; the handler refuses a path separator or a name outside this key.", + "in": "path", + "name": "filename", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": {} + } + }, + "description": "The stored artifact bytes." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "413": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The stored artifact is larger than this endpoint will serve." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + }, + "503": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Download one artifact of a run.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/attack-vectors": { + "get": { + "operationId": "get_api_v1_attack_vectors", + "parameters": [ + { + "description": "A specific sensor. Empty, suricata and portbridge are all rejected: those ship to their own index families.", + "in": "query", + "name": "sensor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Attack vectors for one sensor.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/attackers": { + "get": { + "operationId": "get_api_v1_attackers", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Free-text Lucene query string.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Narrow to one source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "`sources` adds the payload-inventory source buckets; anything else is ignored.", + "in": "query", + "name": "aggs", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Attacker-entity store.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/attackers-graph": { + "get": { + "operationId": "get_api_v1_attackers_graph", + "parameters": [ + { + "description": "Attacker entity id.", + "in": "query", + "name": "id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The node/edge graph around one attacker entity.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/attackers/{id}/events": { + "get": { + "operationId": "get_api_v1_attackers__id__events", + "parameters": [ + { + "description": "Attacker entity id from /api/v1/attackers.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "500": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The handler failed in a way it does not model as a 4xx." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The raw evidence behind one attacker entity.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/audit": { + "get": { + "operationId": "get_api_v1_audit", + "parameters": [ + { + "description": "How many entries; clamped to [1, 500] by the handler, default 100.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 500, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Only entries with this action.", + "in": "query", + "name": "action", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The audit trail, newest first.", + "tags": [ + "configuration" + ] + } + }, + "/api/v1/campaigns": { + "get": { + "operationId": "get_api_v1_campaigns", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Free-text Lucene query string.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Narrow to one source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "`sources` adds the payload-inventory source buckets; anything else is ignored.", + "in": "query", + "name": "aggs", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Campaign store.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/canarytokens": { + "get": { + "operationId": "get_api_v1_canarytokens", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Minted canarytokens, with their management token redacted.", + "tags": [ + "operations" + ] + }, + "post": { + "operationId": "post_api_v1_canarytokens", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `CreateBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "500": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The handler failed in a way it does not model as a 4xx." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + }, + "503": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Mint a canarytoken.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/canarytokens/types": { + "get": { + "operationId": "get_api_v1_canarytokens_types", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The canarytoken types this build can mint.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/canarytokens/{id}/download": { + "get": { + "operationId": "get_api_v1_canarytokens__id__download", + "parameters": [ + { + "description": "Canarytoken id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Redirect to the token's landing URL." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "500": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The handler failed in a way it does not model as a 4xx." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + }, + "503": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The canarytoken's landing URL, as a redirect.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/cape/{sha}": { + "get": { + "operationId": "get_api_v1_cape__sha_", + "parameters": [ + { + "description": "Payload/analysis subject id, lower-case hex.", + "in": "path", + "name": "sha", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{8,64}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One CAPE analysis run.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/cape/{sha}/raw": { + "get": { + "operationId": "get_api_v1_cape__sha__raw", + "parameters": [ + { + "description": "Payload/analysis subject id, lower-case hex.", + "in": "path", + "name": "sha", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{8,64}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "The stored report, verbatim." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The raw CAPE report JSON for one run.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/charts/anomaly-trend": { + "get": { + "operationId": "get_api_v1_charts_anomaly_trend", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Anomaly counts over time.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/attacker-fusion": { + "get": { + "operationId": "get_api_v1_charts_attacker_fusion", + "parameters": [ + { + "description": "Attacker entity id.", + "in": "query", + "name": "id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "How one attacker's signals fuse across sources.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/attck-coverage": { + "get": { + "operationId": "get_api_v1_charts_attck_coverage", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "ATT&CK technique coverage as a grid.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/campaign-timeline": { + "get": { + "operationId": "get_api_v1_charts_campaign_timeline", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Campaigns over time.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/decoy-client-fingerprints": { + "get": { + "operationId": "get_api_v1_charts_decoy_client_fingerprints", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Decoy requests joined against ClientHello fingerprints.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/decoy-requests": { + "get": { + "operationId": "get_api_v1_charts_decoy_requests", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Requests per decoy.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/dionaea-cves": { + "get": { + "operationId": "get_api_v1_charts_dionaea_cves", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Dionaea exploit attempts by CVE.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/endlessh-held-histogram": { + "get": { + "operationId": "get_api_v1_charts_endlessh_held_histogram", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "How long endlessh held each connection.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/ics-functions": { + "get": { + "operationId": "get_api_v1_charts_ics_functions", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "ICS function codes seen.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/ja4h-fingerprints": { + "get": { + "operationId": "get_api_v1_charts_ja4h_fingerprints", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "JA4H fingerprint distribution.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/ja4l-fingerprints": { + "get": { + "operationId": "get_api_v1_charts_ja4l_fingerprints", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "JA4L fingerprint distribution.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/ja4x-fingerprints": { + "get": { + "operationId": "get_api_v1_charts_ja4x_fingerprints", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "JA4X fingerprint distribution.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/kill-chain-sankey": { + "get": { + "operationId": "get_api_v1_charts_kill_chain_sankey", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Kill-chain stages as a sankey.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/ml-anomaly-scores": { + "get": { + "operationId": "get_api_v1_charts_ml_anomaly_scores", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "ML anomaly scores over time.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/ml-backlog": { + "get": { + "operationId": "get_api_v1_charts_ml_backlog", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "ML anomaly backlog over time.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/netflow-bytes": { + "get": { + "operationId": "get_api_v1_charts_netflow_bytes", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Netflow bytes over time.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/netflow-packets": { + "get": { + "operationId": "get_api_v1_charts_netflow_packets", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Netflow packets over time.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/os-distribution": { + "get": { + "operationId": "get_api_v1_charts_os_distribution", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Fingerprint-derived OS distribution.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/ssh-fingerprints": { + "get": { + "operationId": "get_api_v1_charts_ssh_fingerprints", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "SSH fingerprint distribution.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/tcp-stack-clusters": { + "get": { + "operationId": "get_api_v1_charts_tcp_stack_clusters", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "JA4T stack clusters.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/charts/tls-fingerprints": { + "get": { + "operationId": "get_api_v1_charts_tls_fingerprints", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "TLS fingerprint distribution.", + "tags": [ + "charts" + ] + } + }, + "/api/v1/clusters": { + "get": { + "operationId": "get_api_v1_clusters", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Free-text Lucene query string.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Narrow to one source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "`sources` adds the payload-inventory source buckets; anything else is ignored.", + "in": "query", + "name": "aggs", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Attacker-cluster store.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/config": { + "get": { + "operationId": "get_api_v1_config", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The whole operator-authored dashboard configuration.", + "tags": [ + "configuration" + ] + } + }, + "/api/v1/config/history": { + "get": { + "operationId": "get_api_v1_config_history", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The revision history the rollback picker reads (payloads excluded).", + "tags": [ + "configuration" + ] + } + }, + "/api/v1/config/presentation": { + "put": { + "operationId": "put_api_v1_config_presentation", + "parameters": [ + { + "description": "OIDC subject recorded on the audit/history entry.", + "in": "query", + "name": "actor_subject", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Operator name recorded on the audit/history entry.", + "in": "query", + "name": "actor_username", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional optimistic-concurrency revision, as a weak ETag (`W/\"7\"`). A mismatch answers 409.", + "in": "header", + "name": "If-Match", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `serde_json::Value`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "description": "The stored presentation block and its new revision." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "409": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The record changed since the revision the caller presented." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Replace the presentation block (branding, theme, landing copy).", + "tags": [ + "configuration" + ] + } + }, + "/api/v1/config/rollback": { + "post": { + "operationId": "post_api_v1_config_rollback", + "parameters": [ + { + "description": "Optional optimistic-concurrency revision, as a weak ETag (`W/\"7\"`). A mismatch answers 409.", + "in": "header", + "name": "If-Match", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `RollbackBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "description": "The restored configuration and its new revision." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "409": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The record changed since the revision the caller presented." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Restore a past configuration revision.", + "tags": [ + "configuration" + ] + } + }, + "/api/v1/config/validate": { + "post": { + "operationId": "post_api_v1_config_validate", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `serde_json::Value`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Check a candidate configuration without storing it.", + "tags": [ + "configuration" + ] + } + }, + "/api/v1/config/{section}": { + "put": { + "operationId": "put_api_v1_config__section_", + "parameters": [ + { + "description": "Settings section to replace.", + "in": "path", + "name": "section", + "required": true, + "schema": { + "enum": [ + "honeypot", + "behavior", + "report-presets" + ], + "type": "string" + } + }, + { + "description": "OIDC subject recorded on the audit/history entry.", + "in": "query", + "name": "actor_subject", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Operator name recorded on the audit/history entry.", + "in": "query", + "name": "actor_username", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional optimistic-concurrency revision, as a weak ETag (`W/\"7\"`). A mismatch answers 409.", + "in": "header", + "name": "If-Match", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `serde_json::Value`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "description": "The stored section and its new revision." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "409": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The record changed since the revision the caller presented." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Replace one settings section.", + "tags": [ + "configuration" + ] + } + }, + "/api/v1/connections/{community_id}": { + "get": { + "operationId": "get_api_v1_connections__community_id_", + "parameters": [ + { + "description": "network.community_id flow hash, as computed independently by each sensor.", + "in": "path", + "name": "community_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Every record that shares one community_id flow hash.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/cred-reuse": { + "get": { + "operationId": "get_api_v1_cred_reuse", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Credential pairs reused across more than one address.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/credentials": { + "get": { + "operationId": "get_api_v1_credentials", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "HoneyFS implant credentials, with secrets redacted.", + "tags": [ + "operations" + ] + }, + "post": { + "operationId": "post_api_v1_credentials", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `CreateBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "500": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The handler failed in a way it does not model as a 4xx." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + }, + "503": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Provision a honeyfs-implant credential.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/credentials/{id}/link-token": { + "post": { + "operationId": "post_api_v1_credentials__id__link_token", + "parameters": [ + { + "description": "HoneyFS implant credential id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `LinkTokenBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "500": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The handler failed in a way it does not model as a 4xx." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Mint a link token for a honeyfs-implant credential.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/credentials/{id}/rotate": { + "post": { + "operationId": "post_api_v1_credentials__id__rotate", + "parameters": [ + { + "description": "HoneyFS implant credential id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `RotateBody`. The shape is left open here on purpose -- see the module doc.", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "500": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The handler failed in a way it does not model as a 4xx." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + }, + "503": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Rotate a honeyfs-implant credential's secret.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/event/{id}": { + "get": { + "operationId": "get_api_v1_event__id_", + "parameters": [ + { + "description": "Event document id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One event, with the pivot groups its detail pane needs.", + "tags": [ + "events" + ] + } + }, + "/api/v1/event/{id}/connections": { + "get": { + "operationId": "get_api_v1_event__id__connections", + "parameters": [ + { + "description": "Event document id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The same-flow summary and re-used-wordlist edges for one event.", + "tags": [ + "events" + ] + } + }, + "/api/v1/events": { + "get": { + "operationId": "get_api_v1_events", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size, clamped to 100 by the handler.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Single source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated source addresses.", + "in": "query", + "name": "ips", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Sensor name (honeypot.dionaea, suricata, ...).", + "in": "query", + "name": "sensor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "ISO country code.", + "in": "query", + "name": "country", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "City name, as bucketed on the overview map.", + "in": "query", + "name": "city", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Destination port.", + "in": "query", + "name": "port", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Transport protocol.", + "in": "query", + "name": "proto", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "honeypot.event kind (command, login, ...).", + "in": "query", + "name": "kind", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Captured-payload hash.", + "in": "query", + "name": "shasum", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "One flow across every sensor that saw it.", + "in": "query", + "name": "community_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Free-text query_string, passed to Elasticsearch as-is.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Go-style relative window (24h, 7d).", + "in": "query", + "name": "since", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy persona id.", + "in": "query", + "name": "persona", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy site id.", + "in": "query", + "name": "site", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy asset id.", + "in": "query", + "name": "asset", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Client fingerprint, matched across every field sensors record one in.", + "in": "query", + "name": "fingerprint", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Exact command text.", + "in": "query", + "name": "cmd", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "\"user / pass\" pair.", + "in": "query", + "name": "cred", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Request path.", + "in": "query", + "name": "path", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Session id.", + "in": "query", + "name": "session", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Source AS number.", + "in": "query", + "name": "asn", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Source network organization.", + "in": "query", + "name": "org", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Provider class.", + "in": "query", + "name": "provider", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "IDS alert signature.", + "in": "query", + "name": "sig", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Detection category (Suricata alert category or honeypot.category).", + "in": "query", + "name": "cat", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Event explorer page: the shared filter set, windowed.", + "tags": [ + "events" + ] + } + }, + "/api/v1/export/campaigns.csv": { + "get": { + "operationId": "get_api_v1_export_campaigns_csv", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": {} + } + }, + "description": "CSV of campaigns." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Campaigns as CSV.", + "tags": [ + "export" + ] + } + }, + "/api/v1/export/clusters.csv": { + "get": { + "operationId": "get_api_v1_export_clusters_csv", + "parameters": [ + { + "description": "Cluster kind to export.", + "in": "query", + "name": "kind", + "required": false, + "schema": { + "enum": [ + "fingerprint", + "payload", + "asn", + "provider" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": {} + } + }, + "description": "CSV of attacker clusters." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Attacker clusters as CSV, by cluster kind.", + "tags": [ + "export" + ] + } + }, + "/api/v1/export/commands.csv": { + "get": { + "operationId": "get_api_v1_export_commands_csv", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size, clamped to 100 by the handler.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Single source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated source addresses.", + "in": "query", + "name": "ips", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Sensor name (honeypot.dionaea, suricata, ...).", + "in": "query", + "name": "sensor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "ISO country code.", + "in": "query", + "name": "country", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "City name, as bucketed on the overview map.", + "in": "query", + "name": "city", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Destination port.", + "in": "query", + "name": "port", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Transport protocol.", + "in": "query", + "name": "proto", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "honeypot.event kind (command, login, ...).", + "in": "query", + "name": "kind", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Captured-payload hash.", + "in": "query", + "name": "shasum", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "One flow across every sensor that saw it.", + "in": "query", + "name": "community_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Free-text query_string, passed to Elasticsearch as-is.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Go-style relative window (24h, 7d).", + "in": "query", + "name": "since", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy persona id.", + "in": "query", + "name": "persona", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy site id.", + "in": "query", + "name": "site", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy asset id.", + "in": "query", + "name": "asset", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Client fingerprint, matched across every field sensors record one in.", + "in": "query", + "name": "fingerprint", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Exact command text.", + "in": "query", + "name": "cmd", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "\"user / pass\" pair.", + "in": "query", + "name": "cred", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Request path.", + "in": "query", + "name": "path", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Session id.", + "in": "query", + "name": "session", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Source AS number.", + "in": "query", + "name": "asn", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Source network organization.", + "in": "query", + "name": "org", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Provider class.", + "in": "query", + "name": "provider", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "IDS alert signature.", + "in": "query", + "name": "sig", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Detection category (Suricata alert category or honeypot.category).", + "in": "query", + "name": "cat", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": {} + } + }, + "description": "CSV of matching commands." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Matching commands as CSV.", + "tags": [ + "export" + ] + } + }, + "/api/v1/export/events.csv": { + "get": { + "operationId": "get_api_v1_export_events_csv", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size, clamped to 100 by the handler.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Single source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated source addresses.", + "in": "query", + "name": "ips", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Sensor name (honeypot.dionaea, suricata, ...).", + "in": "query", + "name": "sensor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "ISO country code.", + "in": "query", + "name": "country", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "City name, as bucketed on the overview map.", + "in": "query", + "name": "city", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Destination port.", + "in": "query", + "name": "port", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Transport protocol.", + "in": "query", + "name": "proto", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "honeypot.event kind (command, login, ...).", + "in": "query", + "name": "kind", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Captured-payload hash.", + "in": "query", + "name": "shasum", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "One flow across every sensor that saw it.", + "in": "query", + "name": "community_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Free-text query_string, passed to Elasticsearch as-is.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Go-style relative window (24h, 7d).", + "in": "query", + "name": "since", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy persona id.", + "in": "query", + "name": "persona", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy site id.", + "in": "query", + "name": "site", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy asset id.", + "in": "query", + "name": "asset", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Client fingerprint, matched across every field sensors record one in.", + "in": "query", + "name": "fingerprint", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Exact command text.", + "in": "query", + "name": "cmd", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "\"user / pass\" pair.", + "in": "query", + "name": "cred", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Request path.", + "in": "query", + "name": "path", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Session id.", + "in": "query", + "name": "session", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Source AS number.", + "in": "query", + "name": "asn", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Source network organization.", + "in": "query", + "name": "org", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Provider class.", + "in": "query", + "name": "provider", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "IDS alert signature.", + "in": "query", + "name": "sig", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Detection category (Suricata alert category or honeypot.category).", + "in": "query", + "name": "cat", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": {} + } + }, + "description": "CSV of the matching events." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The event explorer as CSV, same filters as /events.", + "tags": [ + "export" + ] + } + }, + "/api/v1/export/history.json": { + "get": { + "operationId": "get_api_v1_export_history_json", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size, clamped to 100 by the handler.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Single source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated source addresses.", + "in": "query", + "name": "ips", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Sensor name (honeypot.dionaea, suricata, ...).", + "in": "query", + "name": "sensor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "ISO country code.", + "in": "query", + "name": "country", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "City name, as bucketed on the overview map.", + "in": "query", + "name": "city", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Destination port.", + "in": "query", + "name": "port", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Transport protocol.", + "in": "query", + "name": "proto", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "honeypot.event kind (command, login, ...).", + "in": "query", + "name": "kind", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Captured-payload hash.", + "in": "query", + "name": "shasum", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "One flow across every sensor that saw it.", + "in": "query", + "name": "community_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Free-text query_string, passed to Elasticsearch as-is.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Go-style relative window (24h, 7d).", + "in": "query", + "name": "since", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy persona id.", + "in": "query", + "name": "persona", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy site id.", + "in": "query", + "name": "site", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Decoy asset id.", + "in": "query", + "name": "asset", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Client fingerprint, matched across every field sensors record one in.", + "in": "query", + "name": "fingerprint", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Exact command text.", + "in": "query", + "name": "cmd", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "\"user / pass\" pair.", + "in": "query", + "name": "cred", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Request path.", + "in": "query", + "name": "path", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Session id.", + "in": "query", + "name": "session", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Source AS number.", + "in": "query", + "name": "asn", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Source network organization.", + "in": "query", + "name": "org", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Provider class.", + "in": "query", + "name": "provider", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "IDS alert signature.", + "in": "query", + "name": "sig", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Detection category (Suricata alert category or honeypot.category).", + "in": "query", + "name": "cat", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Behaviour-search rows as JSON." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The behaviour-search slice as JSON.", + "tags": [ + "export" + ] + } + }, + "/api/v1/export/ips.csv": { + "get": { + "operationId": "get_api_v1_export_ips_csv", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/csv": { + "schema": {} + } + }, + "description": "CSV of source addresses." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Every source address in the window as CSV.", + "tags": [ + "export" + ] + } + }, + "/api/v1/filter-values": { + "get": { + "operationId": "get_api_v1_filter_values", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Distinct values behind every explorer filter dropdown.", + "tags": [ + "platform" + ] + } + }, + "/api/v1/ghidra-callgraph/{sha}": { + "get": { + "operationId": "get_api_v1_ghidra_callgraph__sha_", + "parameters": [ + { + "description": "Payload/analysis subject id, lower-case hex.", + "in": "path", + "name": "sha", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{8,64}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The call graph one Ghidra run produced.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/ghidra/submit": { + "post": { + "operationId": "post_api_v1_ghidra_submit", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `SubmitBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "503": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Queue a Ghidra analysis.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/ghidra/{sha}": { + "get": { + "operationId": "get_api_v1_ghidra__sha_", + "parameters": [ + { + "description": "Payload/analysis subject id, lower-case hex.", + "in": "path", + "name": "sha", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{8,64}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One Ghidra analysis run.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/github-analysis/submit": { + "post": { + "operationId": "post_api_v1_github_analysis_submit", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `SubmitBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "503": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Queue a GitHub analysis.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/github-analysis/{sha}": { + "get": { + "operationId": "get_api_v1_github_analysis__sha_", + "parameters": [ + { + "description": "Payload/analysis subject id, lower-case hex.", + "in": "path", + "name": "sha", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{8,64}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One GitHub analysis run.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/gpu-queue": { + "get": { + "operationId": "get_api_v1_gpu_queue", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The GPU analysis queue as it stands.", + "tags": [ + "platform" + ] + } + }, + "/api/v1/gpu-queue/{job_id}/abort": { + "post": { + "operationId": "post_api_v1_gpu_queue__job_id__abort", + "parameters": [ + { + "description": "GPU job to abort.", + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Abort a queued or running GPU job.", + "tags": [ + "platform" + ] + } + }, + "/api/v1/investigate/cidr/{cidr}": { + "get": { + "operationId": "get_api_v1_investigate_cidr__cidr_", + "parameters": [ + { + "description": "CIDR block to correlate. A malformed block is a 400.", + "in": "path", + "name": "cidr", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Correlation across one CIDR block.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/investigate/cluster": { + "get": { + "operationId": "get_api_v1_investigate_cluster", + "parameters": [ + { + "description": "Cluster kind.", + "in": "query", + "name": "kind", + "required": false, + "schema": { + "enum": [ + "fingerprint", + "payload", + "asn", + "provider" + ], + "type": "string" + } + }, + { + "description": "The cluster's value, as /api/v1/clusters reports it.", + "in": "query", + "name": "value", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The members of one attacker cluster.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/investigate/ip/{ip}": { + "get": { + "operationId": "get_api_v1_investigate_ip__ip_", + "parameters": [ + { + "description": "Source address to profile. A non-address is a 400.", + "in": "path", + "name": "ip", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Everything one source address did, across sensors.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/ip-block": { + "post": { + "operationId": "post_api_v1_ip_block", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `BlockBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Block or unblock an address.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/ip-block-export": { + "get": { + "operationId": "get_api_v1_ip_block_export", + "parameters": [], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": {} + } + }, + "description": "The blocked addresses, one per line." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The whole block list, for backup or review.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/ip-block/{ip}": { + "get": { + "operationId": "get_api_v1_ip_block__ip_", + "parameters": [ + { + "description": "Address whose block state is wanted.", + "in": "path", + "name": "ip", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One address's block state.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/live": { + "get": { + "operationId": "get_api_v1_live", + "parameters": [], + "responses": { + "200": { + "content": { + "text/event-stream": { + "schema": {} + } + }, + "description": "An endless text/event-stream of event documents. Never terminates, which is why the fuzz job excludes this path." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Server-sent event source: the explorer tailing contract.", + "tags": [ + "events" + ], + "x-endless-stream": true + } + }, + "/api/v1/llm-search": { + "get": { + "operationId": "get_api_v1_llm_search", + "parameters": [ + { + "description": "The question.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "How many hits to summarise.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "minimum": 1, + "type": "integer" + } + }, + { + "description": "\"session\", \"vault\" or \"vault-note\"; an unknown value falls back to session.", + "in": "query", + "name": "source", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Natural-language search over the corpus, answered by the local model.", + "tags": [ + "ai" + ] + } + }, + "/api/v1/mail/{session_id}": { + "get": { + "operationId": "get_api_v1_mail__session_id_", + "parameters": [ + { + "description": "Session whose captured mail is wanted.", + "in": "path", + "name": "session_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Mail the SMTP honeypot captured for one session.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/ml-anomalies/ack": { + "post": { + "operationId": "post_api_v1_ml_anomalies_ack", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `MlAckBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Acknowledge one ML anomaly.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/ml-anomalies/ack-all": { + "post": { + "operationId": "post_api_v1_ml_anomalies_ack_all", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `MlAckAllBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Acknowledge every open ML anomaly.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/ml-anomalies/acks": { + "get": { + "operationId": "get_api_v1_ml_anomalies_acks", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The ack ledger.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/ml-anomalies/disposition": { + "post": { + "operationId": "post_api_v1_ml_anomalies_disposition", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `MlDispositionBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Record an analyst disposition for anomalies.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/ml-anomalies/stats": { + "get": { + "operationId": "get_api_v1_ml_anomalies_stats", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Ack statistics.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/ml-health": { + "get": { + "operationId": "get_api_v1_ml_health", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Per-model ml-worker health.", + "tags": [ + "platform" + ] + } + }, + "/api/v1/overview/dashboard": { + "get": { + "operationId": "get_api_v1_overview_dashboard", + "parameters": [ + { + "description": "Comma-separated subset of slice names; absent or empty means every slice.", + "in": "query", + "name": "parts", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The one aggregation the overview page renders, sliced by ?parts=.", + "tags": [ + "platform" + ] + } + }, + "/api/v1/overview/kpis": { + "get": { + "operationId": "get_api_v1_overview_kpis", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "KPI counters behind the overview tiles.", + "tags": [ + "platform" + ] + } + }, + "/api/v1/payloads": { + "get": { + "operationId": "get_api_v1_payloads", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Free-text Lucene query string.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Narrow to one source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "`sources` adds the payload-inventory source buckets; anything else is ignored.", + "in": "query", + "name": "aggs", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Captured-payload store.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/payloads/{hash}": { + "get": { + "operationId": "get_api_v1_payloads__hash_", + "parameters": [ + { + "description": "Payload id: 32 or 64 lower-case hex characters.", + "in": "path", + "name": "hash", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{32}([0-9a-fA-F]{32})?$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One captured payload and its analysis.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/payloads/{hash}/raw": { + "get": { + "operationId": "get_api_v1_payloads__hash__raw", + "parameters": [ + { + "description": "Payload id: 32 or 64 lower-case hex characters.", + "in": "path", + "name": "hash", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{32}([0-9a-fA-F]{32})?$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": {} + } + }, + "description": "The payload bytes." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "413": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The stored artifact is larger than this endpoint will serve." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One captured payload's bytes.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/payloads/{hash}/report": { + "post": { + "operationId": "post_api_v1_payloads__hash__report", + "parameters": [ + { + "description": "Payload id: 32 or 64 lower-case hex characters.", + "in": "path", + "name": "hash", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{32}([0-9a-fA-F]{32})?$", + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "The queued report run." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "501": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The saved definition's template is not implemented by the renderer yet." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One-click payload PDF into the generated store.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/preferences": { + "get": { + "operationId": "get_api_v1_preferences", + "parameters": [ + { + "description": "OIDC subject. Required -- an empty value is a 400.", + "in": "query", + "name": "subject", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Operator name.", + "in": "query", + "name": "username", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Operator role.", + "in": "query", + "name": "role", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One user's saved preferences.", + "tags": [ + "configuration" + ] + }, + "put": { + "operationId": "put_api_v1_preferences", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `PreferencesWriteBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Replace one user's saved preferences.", + "tags": [ + "configuration" + ] + } + }, + "/api/v1/preferences/reset": { + "post": { + "operationId": "post_api_v1_preferences_reset", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `PreferencesResetBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Drop one user's saved preferences back to the defaults.", + "tags": [ + "configuration" + ] + } + }, + "/api/v1/problem-reports": { + "post": { + "operationId": "post_api_v1_problem_reports", + "parameters": [ + { + "description": "OIDC subject recorded on the audit/history entry.", + "in": "query", + "name": "actor_subject", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Operator name recorded on the audit/history entry.", + "in": "query", + "name": "actor_username", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `Submission`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "201": { + "description": "The stored report." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "409": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The record changed since the revision the caller presented." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "File a problem report from the dashboard UI.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/problem-reports/{id}": { + "patch": { + "operationId": "patch_api_v1_problem_reports__id_", + "parameters": [ + { + "description": "Problem-report id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `StatusPatch`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "204": { + "description": "No content; the status was stored." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "409": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The record changed since the revision the caller presented." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Move a problem report through open/triaged/closed.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/recordings": { + "get": { + "operationId": "get_api_v1_recordings", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Free-text Lucene query string.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Narrow to one source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "`sources` adds the payload-inventory source buckets; anything else is ignored.", + "in": "query", + "name": "aggs", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "TTY recording store.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/recordings/{shasum}": { + "get": { + "operationId": "get_api_v1_recordings__shasum_", + "parameters": [ + { + "description": "Recording shasum to replay.", + "in": "path", + "name": "shasum", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One TTY recording.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/recordings/{shasum}/cast": { + "get": { + "operationId": "get_api_v1_recordings__shasum__cast", + "parameters": [ + { + "description": "Recording shasum to replay.", + "in": "path", + "name": "shasum", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": {} + } + }, + "description": "The asciicast body." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One TTY recording as asciicast.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/recordings/{shasum}/raw": { + "get": { + "operationId": "get_api_v1_recordings__shasum__raw", + "parameters": [ + { + "description": "Recording shasum to replay.", + "in": "path", + "name": "shasum", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": {} + } + }, + "description": "The raw log bytes." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "413": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The stored artifact is larger than this endpoint will serve." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One TTY recording as raw bytes.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/reporter-stats": { + "get": { + "operationId": "get_api_v1_reporter_stats", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "500": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The handler failed in a way it does not model as a 4xx." + }, + "502": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "What the reporting loops produced and when.", + "tags": [ + "platform" + ] + } + }, + "/api/v1/reports/definitions": { + "get": { + "operationId": "get_api_v1_reports_definitions", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Saved report definitions.", + "tags": [ + "reports" + ] + }, + "post": { + "operationId": "post_api_v1_reports_definitions", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `ReportDefinition`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "201": { + "description": "The stored definition." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "409": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The record changed since the revision the caller presented." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Save a new report definition.", + "tags": [ + "reports" + ] + } + }, + "/api/v1/reports/definitions/{id}": { + "delete": { + "operationId": "delete_api_v1_reports_definitions__id_", + "parameters": [ + { + "description": "Saved report-definition id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Delete one saved report definition.", + "tags": [ + "reports" + ] + }, + "get": { + "operationId": "get_api_v1_reports_definitions__id_", + "parameters": [ + { + "description": "Saved report-definition id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One saved report definition.", + "tags": [ + "reports" + ] + }, + "put": { + "operationId": "put_api_v1_reports_definitions__id_", + "parameters": [ + { + "description": "Saved report-definition id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `ReportDefinition`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "409": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The record changed since the revision the caller presented." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Replace one saved report definition.", + "tags": [ + "reports" + ] + } + }, + "/api/v1/reports/definitions/{id}/generate": { + "post": { + "operationId": "post_api_v1_reports_definitions__id__generate", + "parameters": [ + { + "description": "Saved report-definition id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `GenerateBody`. The shape is left open here on purpose -- see the module doc.", + "required": false + }, + "responses": { + "201": { + "description": "The queued run." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "409": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The record changed since the revision the caller presented." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Run a saved definition now and store the result.", + "tags": [ + "reports" + ] + } + }, + "/api/v1/reports/generated/{id}": { + "delete": { + "operationId": "delete_api_v1_reports_generated__id_", + "parameters": [ + { + "description": "Generated report id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Delete one generated report.", + "tags": [ + "reports" + ] + } + }, + "/api/v1/reports/templates": { + "get": { + "operationId": "get_api_v1_reports_templates", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The report template and element catalog.", + "tags": [ + "reports" + ] + } + }, + "/api/v1/reports/{id}/pdf": { + "get": { + "operationId": "get_api_v1_reports__id__pdf", + "parameters": [ + { + "description": "Generated report id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/pdf": { + "schema": {} + } + }, + "description": "The rendered PDF." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One generated report, rendered to PDF.", + "tags": [ + "reports" + ] + } + }, + "/api/v1/revdeck/{sha}": { + "get": { + "operationId": "get_api_v1_revdeck__sha_", + "parameters": [ + { + "description": "Payload/analysis subject id, lower-case hex.", + "in": "path", + "name": "sha", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{8,64}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One RevDeck analysis run.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/sandbox/golden-image-status": { + "get": { + "operationId": "get_api_v1_sandbox_golden_image_status", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Whether the sandbox golden image is built.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/sandbox/submit": { + "post": { + "operationId": "post_api_v1_sandbox_submit", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `SubmitBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "503": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Queue a sandbox detonation.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/sandbox/vnc": { + "get": { + "operationId": "get_api_v1_sandbox_vnc", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The VNC port the sandbox advertises, if any.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/sandbox/{job}": { + "get": { + "operationId": "get_api_v1_sandbox__job_", + "parameters": [ + { + "description": "Sandbox job id.", + "in": "path", + "name": "job", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One sandbox run.", + "tags": [ + "analysis" + ] + } + }, + "/api/v1/search": { + "get": { + "operationId": "get_api_v1_search", + "parameters": [ + { + "description": "What to search for.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Cross-surface search for the omnibox.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/sensors": { + "get": { + "operationId": "get_api_v1_sensors", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Per-sensor counts, last-seen, and state.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/sensors/catalog": { + "get": { + "operationId": "get_api_v1_sensors_catalog", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The sensor catalog the setup pages read.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/sensors/{sensor}/events": { + "get": { + "operationId": "get_api_v1_sensors__sensor__events", + "parameters": [ + { + "description": "Sensor name; the handler rejects an empty value or one over 128 characters.", + "in": "path", + "name": "sensor", + "required": true, + "schema": { + "maxLength": 128, + "type": "string" + } + }, + { + "description": "How many events to return; clamped by the handler.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Recent events from one sensor.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/sensors/{sensor}/overview": { + "get": { + "operationId": "get_api_v1_sensors__sensor__overview", + "parameters": [ + { + "description": "Sensor name; the handler rejects an empty value or one over 128 characters.", + "in": "path", + "name": "sensor", + "required": true, + "schema": { + "maxLength": 128, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Protocols, ports and fingerprints for one sensor.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/services": { + "get": { + "operationId": "get_api_v1_services", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "503": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "The compose services the operator can act on.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/services/{name}/logs": { + "get": { + "operationId": "get_api_v1_services__name__logs", + "parameters": [ + { + "description": "compose service name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "How many lines; the handler defaults to 200.", + "in": "query", + "name": "lines", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "application/json": { + "schema": {} + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "503": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Recent log lines for one service, via the services adapter.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/services/{name}/{action}": { + "post": { + "operationId": "post_api_v1_services__name___action_", + "parameters": [ + { + "description": "compose service name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Lifecycle action the services adapter accepts.", + "in": "path", + "name": "action", + "required": true, + "schema": { + "enum": [ + "start", + "stop", + "restart" + ], + "type": "string" + } + }, + { + "description": "OIDC subject recorded on the audit/history entry.", + "in": "query", + "name": "actor_subject", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Operator name recorded on the audit/history entry.", + "in": "query", + "name": "actor_username", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "application/json": { + "schema": {} + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "503": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "A dependency this route needs is not configured or not reachable." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Start, stop or restart one service.", + "tags": [ + "operations" + ] + } + }, + "/api/v1/sessions/{id}": { + "get": { + "operationId": "get_api_v1_sessions__id_", + "parameters": [ + { + "description": "Session id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One session: its events, commands and credentials.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/settings/storage": { + "get": { + "operationId": "get_api_v1_settings_storage", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Index sizes and document counts.", + "tags": [ + "platform" + ] + } + }, + "/api/v1/source-health": { + "get": { + "operationId": "get_api_v1_source_health", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Per-source ingestion health, the page behind \"Source & pipeline health\".", + "tags": [ + "platform" + ] + } + }, + "/api/v1/sources": { + "get": { + "operationId": "get_api_v1_sources", + "parameters": [ + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Known source addresses with their event counts.", + "tags": [ + "platform" + ] + } + }, + "/api/v1/store/{name}": { + "delete": { + "operationId": "delete_api_v1_store__name_", + "parameters": [ + { + "description": "Allowlisted generic store. Anything else is a 404 -- this route is not an arbitrary index read.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "enum": [ + "agent-campaigns", + "auth-events", + "canarytokens", + "cape", + "dead-letters", + "generated-reports", + "ghidra-runs", + "github-analysis", + "intelligence", + "llm-analysis", + "ml-anomalies", + "problem-reports", + "report-definitions", + "revdeck", + "sandbox-runs", + "static-analysis", + "workbench-runs", + "yara" + ], + "type": "string" + } + }, + { + "description": "Lucene query string; absent or empty purges every retained dead letter.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "405": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The store exists but exposes no delete side (only dead-letters does)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Purge dead letters matching ?q= (dead-letters only).", + "tags": [ + "stores" + ] + }, + "get": { + "operationId": "get_api_v1_store__name_", + "parameters": [ + { + "description": "Allowlisted generic store. Anything else is a 404 -- this route is not an arbitrary index read.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "enum": [ + "agent-campaigns", + "auth-events", + "canarytokens", + "cape", + "dead-letters", + "generated-reports", + "ghidra-runs", + "github-analysis", + "intelligence", + "llm-analysis", + "ml-anomalies", + "problem-reports", + "report-definitions", + "revdeck", + "sandbox-runs", + "static-analysis", + "workbench-runs", + "yara" + ], + "type": "string" + } + }, + { + "description": "Result window start.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Page size.", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Free-text Lucene query string.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Narrow to one source address.", + "in": "query", + "name": "ip", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "`sources` adds the payload-inventory source buckets; anything else is ignored.", + "in": "query", + "name": "aggs", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One allowlisted store, through the generic passthrough.", + "tags": [ + "stores" + ] + } + }, + "/api/v1/topology": { + "get": { + "operationId": "get_api_v1_topology", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Decoy topology graph.", + "tags": [ + "investigate" + ] + } + }, + "/api/v1/users": { + "get": { + "operationId": "get_api_v1_users", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Dashboard users, as the ES-side user store reports them.", + "tags": [ + "configuration" + ] + } + }, + "/api/v1/vault-rag": { + "get": { + "operationId": "get_api_v1_vault_rag", + "parameters": [ + { + "description": "The question.", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Answer a question from the Vault corpus through the local model.", + "tags": [ + "ai" + ] + } + }, + "/api/v1/webhook-delivery": { + "get": { + "operationId": "get_api_v1_webhook_delivery", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Delivery outcomes for the configured alert webhook.", + "tags": [ + "platform" + ] + } + }, + "/api/v1/workbench/analyzers": { + "get": { + "operationId": "get_api_v1_workbench_analyzers", + "parameters": [ + { + "description": "Payload id.", + "in": "query", + "name": "hash", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "application/json": { + "schema": {} + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "No such record, store, or route for the values given." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Analyzers available for one payload.", + "tags": [ + "reports" + ] + } + }, + "/api/v1/workbench/recipes": { + "get": { + "operationId": "get_api_v1_workbench_recipes", + "parameters": [ + { + "description": "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401.", + "in": "header", + "name": "X-Actor-Username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "application/json": { + "schema": {} + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Saved Workbench recipes owned by the calling operator.", + "tags": [ + "reports" + ] + }, + "post": { + "operationId": "post_api_v1_workbench_recipes", + "parameters": [ + { + "description": "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401.", + "in": "header", + "name": "X-Actor-Username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `SaveRecipeBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "application/json": { + "schema": {} + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "No such record, store, or route for the values given." + }, + "409": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "The record changed since the revision the caller presented." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Save a Workbench recipe.", + "tags": [ + "reports" + ] + } + }, + "/api/v1/workbench/runs": { + "get": { + "operationId": "get_api_v1_workbench_runs", + "parameters": [ + { + "description": "Narrow to one payload id.", + "in": "query", + "name": "hash", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "How many runs to return.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401.", + "in": "header", + "name": "X-Actor-Username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "application/json": { + "schema": {} + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "502": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Payload Workbench runs owned by the calling operator.", + "tags": [ + "reports" + ] + }, + "post": { + "operationId": "post_api_v1_workbench_runs", + "parameters": [ + { + "description": "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401.", + "in": "header", + "name": "X-Actor-Username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Deserialized by the handler into `CreateRunBody`. The shape is left open here on purpose -- see the module doc.", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "application/json": { + "schema": {} + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "No such record, store, or route for the values given." + }, + "415": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran." + }, + "422": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure)." + }, + "502": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Start a Workbench run over one payload.", + "tags": [ + "reports" + ] + } + }, + "/api/v1/workbench/runs/{id}": { + "get": { + "operationId": "get_api_v1_workbench_runs__id_", + "parameters": [ + { + "description": "Workbench run id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401.", + "in": "header", + "name": "X-Actor-Username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "401": { + "content": { + "application/json": { + "schema": {} + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "One Workbench run, with its children.", + "tags": [ + "reports" + ] + } + }, + "/api/v1/workbench/runs/{id}/children/{analyzer_id}/{action}": { + "post": { + "operationId": "post_api_v1_workbench_runs__id__children__analyzer_id___action_", + "parameters": [ + { + "description": "Workbench run id.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Analyzer entry on this run; the orchestrator looks the child up by it.", + "in": "path", + "name": "analyzer_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Child lifecycle action.", + "in": "path", + "name": "action", + "required": true, + "schema": { + "enum": [ + "cancel", + "retry" + ], + "type": "string" + } + }, + { + "description": "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401.", + "in": "header", + "name": "X-Actor-Username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "400": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Rejected: the request was understood but its input is not acceptable." + }, + "401": { + "content": { + "application/json": { + "schema": {} + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "No valid service token (or, on the Workbench, no actor identity)." + }, + "404": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "No such record, store, or route for the values given." + }, + "502": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "security": [ + { + "serviceToken": [] + } + ], + "summary": "Cancel or retry one child of a run.", + "tags": [ + "reports" + ] + } + }, + "/healthz": { + "get": { + "operationId": "get_healthz", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + }, + "502": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "Elasticsearch (or a sibling it proxies) refused or failed the query." + } + }, + "summary": "Liveness plus an Elasticsearch reachability flag. Public on purpose: the container healthcheck is the caller.", + "tags": [ + "platform" + ] + } + }, + "/metrics": { + "get": { + "operationId": "get_metrics", + "parameters": [], + "responses": { + "200": { + "content": { + "text/plain": { + "schema": {} + } + }, + "description": "Prometheus text exposition format." + } + }, + "summary": "Prometheus exposition for the #1972 request metrics. Public on purpose, like /healthz.", + "tags": [ + "platform" + ] + } + } + }, + "servers": [ + { + "description": "The default LISTEN_ADDR. In compose this service is reachable only over the internal honeynet, and only by the dashboard BFF.", + "url": "http://127.0.0.1:8081" + } + ], + "tags": [ + { + "description": "Model-backed triage: LLM search over the corpus and the vault-backed RAG endpoint.", + "name": "ai" + }, + { + "description": "Submission and polling for the out-of-band analysers (sandbox, Ghidra, CAPE, RevDeck).", + "name": "analysis" + }, + { + "description": "Precomputed chart series, one route per widget.", + "name": "charts" + }, + { + "description": "Dashboard configuration, user preferences, and the audit trail.", + "name": "configuration" + }, + { + "description": "The event corpus itself: browse, search, and the live event stream.", + "name": "events" + }, + { + "description": "Bulk CSV/JSON exports of the views an operator hands to someone else.", + "name": "export" + }, + { + "description": "Read paths that turn one event or sensor into something a human reads.", + "name": "investigate" + }, + { + "description": "Operator housekeeping: block lists, service accounts, canary tokens, problem reports.", + "name": "operations" + }, + { + "description": "The dashboard's own view of the service: landing overview and KPIs, health, source and pipeline health, and platform settings.", + "name": "platform" + }, + { + "description": "Saved report definitions, generated reports, and the Payload Workbench.", + "name": "reports" + }, + { + "description": "The generated Elasticsearch stores the alert, campaign and identity views are built from.", + "name": "stores" + } + ] +} diff --git a/arcane/home/honeypot-dashboard/backend-service/src/bin/openapi.rs b/arcane/home/honeypot-dashboard/backend-service/src/bin/openapi.rs new file mode 100644 index 00000000..f8a21a18 --- /dev/null +++ b/arcane/home/honeypot-dashboard/backend-service/src/bin/openapi.rs @@ -0,0 +1,24 @@ +//! Prints the /api contract to stdout, so the committed copy is a build +//! artifact rather than something edited by hand: +//! +//! ```text +//! cargo run --bin openapi > openapi.json +//! ``` +//! +//! #3325's drift tests (`src/openapi.rs`) fail when `openapi.json` and +//! this output disagree, and `quality.yml` runs the same command as a +//! `diff`, so the file cannot fall behind the router. The write is +//! deliberately *not* done here -- a generator that rewrites its own +//! tracked file in passing is one `cargo run` away from silently +//! accepting a contract nobody reviewed. + +fn main() { + let document = apiary_backend::openapi::document(); + match serde_json::to_string_pretty(&document) { + Ok(rendered) => println!("{rendered}"), + // A `serde_json::Value` this module built in-process always + // serializes; failing here would mean the module is broken, and + // a panic says that louder than an io error would. + Err(error) => panic!("the contract did not serialize: {error}"), + } +} diff --git a/arcane/home/honeypot-dashboard/backend-service/src/lib.rs b/arcane/home/honeypot-dashboard/backend-service/src/lib.rs new file mode 100644 index 00000000..8104ab89 --- /dev/null +++ b/arcane/home/honeypot-dashboard/backend-service/src/lib.rs @@ -0,0 +1,14 @@ +//! The library half of the crate. +//! +//! The binary (`src/main.rs`) is the service: 80-odd handler modules, the +//! router, and the #2183 boot gate. None of that needs to be a library, +//! and making it one would be a large refactor with no payoff. +//! +//! What *does* need to be reachable from a second target is +//! [`openapi`] -- the machine-readable /api contract (#3325) and the +//! drift tests that keep it honest. Those run from `cargo test` and from +//! the `openapi` generator binary (`src/bin/openapi.rs`), which is why +//! the module lives here rather than in `main.rs`: a second binary cannot +//! see a first binary's modules. + +pub mod openapi; diff --git a/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs b/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs new file mode 100644 index 00000000..4fc8cc95 --- /dev/null +++ b/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs @@ -0,0 +1,1811 @@ +//! The machine-readable contract for this service's HTTP surface (#3325). +//! +//! # Why this is hand-maintained rather than derived +//! +//! #3325 offered two ways to get an OpenAPI document: derive it from the +//! Axum handlers with `utoipa`, or hand-maintain one. This is the second +//! one, deliberately. The handlers almost all return `Json` or a +//! typed struct with a field per surface concern, and the one thing the +//! issue wants the document to pin -- the auth tier and the request shape +//! -- lives in the *route table* and the *extractor signatures*, not in +//! the response bodies. Deriving would mean adding `#[utoipa::path]` to +//! 138 handlers to learn that `GET /api/v1/events` takes a `Query` +//! and answers 401 without a service token; a table plus the two drift +//! tests below states the same thing in one readable place. +//! +//! # Why it cannot drift +//! +//! Two tests in `tests` below, both of which run inside `cargo test` (so in +//! both of quality.yml's backend-service twins): +//! +//! 1. `checked_in_contract_is_current` -- the committed `openapi.json` must +//! equal what this module renders. Regenerate with +//! `cargo run --bin openapi > openapi.json` after any edit here. +//! 2. `contract_covers_every_router_route` -- the (path, method) set this +//! module publishes must equal the (path, method) set `main.rs` +//! registers. A route added to the router without a row here fails the +//! build, which is the direction that actually rots. +//! +//! # What the document deliberately does NOT claim +//! +//! **Every** success body is free-form (an empty schema, which accepts any +//! JSON value) -- including `/healthz`'s two-field struct. Hand-transcribing +//! 138 response shapes would be 138 chances to assert something the code +//! does not enforce, and a contract that lies about a response is worse +//! than one that admits it is open. The empty schema is the truthful +//! statement: it is also what keeps `response_schema_conformance` from +//! failing the fuzz job on correct behaviour, which is why this document +//! reports service findings like "API accepted schema-violating request" +//! and treats them as a standing record rather than as a verdict. +//! +//! What the document *does* pin is the part that has been wrong before: +//! the auth tier per operation, the request parameters (including the +//! enum-shaped ones the handlers actually validate), the declared status +//! codes, and the media type of every response. That is what +//! `.github/workflows/weekly-schemathesis.yml` checks, and the auth tier +//! is the property #3325 was filed for. +//! +//! # Statuses that no row has to remember +//! +//! Three come from axum's extractors, before any handler runs, and the +//! builders add them so a row cannot forget: `body()` adds 415 and 422 for +//! `Json`, and `with_query()` adds 400 for `Query`. Running the fuzz +//! job against a booted service is what found all three -- they are +//! unreachable from the router's source, since nothing in `main.rs` +//! mentions them, and every one of them was reported "undocumented" on +//! routes whose rows looked complete. +//! +//! One status carries two media types on the routes where the extractor's +//! answer and the handler's own answer share a code: the Workbench's 400 +//! is `text/plain` when `Query` refuses the query string and +//! `application/json` when the handler rejects the value. `render_operation` +//! merges content types for a repeated status rather than overwriting. + +use serde_json::{json, Map, Value}; + +/// Which gate an operation sits behind. The names say what the *caller* +/// must present, because that is the only part of the auth model an +/// operator has to get right (the BFF is the only legitimate caller -- +/// see the crate doc in main.rs). +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Tier { + /// No service token: `/healthz` for the container healthcheck and + /// `/metrics` for the #1972 scrape. Both stay open on purpose and are + /// the only two routes in the binary that do. + Public, + /// `require_service_token` (main.rs). 401 is `text/plain`: the + /// middleware returns a bare `(StatusCode, String)`. + ServiceToken, + /// `require_service_token` *plus* the Workbench's own + /// `require_actor` (workbench_api.rs), which wants a forwarded + /// `X-Actor-Username` and answers a `Json` 401. Both media + /// types are declared on the one 401, because both are reachable: + /// no token gets the middleware's, a token without an actor gets the + /// Workbench's. + ServiceTokenAndActor, +} + +/// A query or header parameter. `in` is filled in by the renderer from +/// where the entry sits in the table, so a row cannot put a query +/// parameter in the header block. +pub struct Param { + pub name: &'static str, + pub description: &'static str, + pub schema: Value, + pub required: bool, +} + +/// A path parameter. Kept apart from [`Param`] because `in: path` is +/// mandatory and always required -- a mistake there is a spec error, and +/// mixing the two makes it easy to write. +pub struct PathParam { + pub name: &'static str, + pub description: &'static str, + pub schema: Value, +} + +pub struct Response { + pub status: u16, + pub description: &'static str, + /// One entry per media type under `content`; empty for a bodiless + /// status. A list rather than a single media type because the + /// Workbench's one 401 is reachable as both `text/plain` and + /// `application/json` (see [`Tier::ServiceTokenAndActor`]). + pub schemas: Vec<(&'static str, Value)>, +} + +pub struct Op { + pub method: &'static str, + pub path: &'static str, + pub summary: &'static str, + pub tier: Tier, + pub query: Vec, + pub headers: Vec, + pub path_params: Vec, + pub body: Option, + pub responses: Vec, + /// Not free-form: for SSE (`/api/v1/live`) the success body is an + /// endless stream, so the media type is the whole contract. + pub success_media: Option<&'static str>, +} + +/// A JSON request body. The shape is left open -- see the module doc -- +/// but the description names the handler struct, so a reader can jump +/// straight to the fields the service actually deserializes. +pub struct Body { + pub handler_struct: &'static str, + pub required: bool, +} + +// --------------------------------------------------------------------- +// Small constructors. Every response body the service produces is JSON +// unless the table says otherwise, so `json` is the default and the +// error helpers are the ones that carry a media type. +// --------------------------------------------------------------------- + +fn free_form() -> Value { + // `type` is deliberately omitted rather than "object": most handlers + // answer an object, but several (sources, ml-health, gpu-queue, the + // chart family) answer a bare array, and a wrong `type` here would + // make schemathesis's response_schema_conformance fail on correct + // behaviour. An empty schema accepts any JSON value, which is the + // truthful statement. + json!({}) +} + +fn str_schema() -> Value { + json!({"type": "string"}) +} + +fn u64_schema() -> Value { + json!({"type": "integer", "format": "int64", "minimum": 0}) +} + +fn query(name: &'static str, description: &'static str, schema: Value) -> Param { + Param { name, description, schema, required: false } +} + +fn opt_query( + name: &'static str, + description: &'static str, + schema: Value, +) -> Param { + query(name, description, schema) +} + +fn path_param(name: &'static str, description: &'static str, schema: Value) -> PathParam { + PathParam { name, description, schema } +} + +fn op(method: &'static str, path: &'static str, summary: &'static str) -> Op { + Op { + method, + path, + summary, + tier: Tier::ServiceToken, + query: Vec::new(), + headers: Vec::new(), + path_params: Vec::new(), + body: None, + responses: Vec::new(), + success_media: None, + } +} + +impl Op { + fn public(mut self) -> Self { + self.tier = Tier::Public; + self + } + + /// Marks the route as also requiring the BFF's forwarded actor + /// identity, with a required `X-Actor-Username` header so the fuzzer + /// exercises the route the way the BFF actually calls it. + fn actor(mut self) -> Self { + self.tier = Tier::ServiceTokenAndActor; + self.headers.push(Param { + name: "X-Actor-Username", + description: "Operator identity the BFF forwards; workbench_api.rs's \ + require_actor rejects a missing or blank value with a \ + JSON 401.", + schema: str_schema(), + required: true, + }); + self + } + + /// A 200 whose body is `application/json` of unconstrained shape. + fn ok(mut self) -> Self { + self.responses.push(Response { + status: 200, + description: "Success.", + schemas: vec![("application/json", free_form())], + }); + self + } + + /// A 200 with a specific media type -- CSV, PDF, octet-stream, an + /// SSE stream, Prometheus text. + fn ok_media(mut self, media: &'static str, description: &'static str) -> Self { + self.success_media = Some(media); + self.responses.push(Response { + status: 200, + description, + schemas: vec![(media, free_form())], + }); + self + } + + /// A bodiless success (`204 No Content`, and the `200` some submit + /// routes answer with an empty object). + fn ok_status(mut self, status: u16, description: &'static str) -> Self { + self.responses.push(Response { + status, + description, + schemas: Vec::new(), + }); + self + } + + /// A `text/plain` error status -- the shape of every + /// `Err((StatusCode, String))` in the crate, which is most of them. + fn err(mut self, status: u16) -> Self { + self.responses.push(Response { + status, + description: error_description(status), + schemas: vec![("text/plain", str_schema())], + }); + self + } + + /// A `application/json` error status -- the Workbench's + /// `Err((StatusCode, Json))` family. + fn err_json(mut self, status: u16) -> Self { + self.responses.push(Response { + status, + description: error_description(status), + schemas: vec![("application/json", free_form())], + }); + self + } + + /// Declares this operation's query parameters, and the 400 that + /// axum's `Query` extractor raises when the query string will not + /// deserialize -- a required field missing, or a value of the wrong + /// type (`?limit=false` on a `limit: usize`). It is `text/plain` and + /// it happens before the handler runs. + /// + /// Added here rather than per row because forgetting it is invisible: + /// an operation that lists query parameters and declares no 400 looks + /// complete, and only a fuzzer sending `?limit=false` finds out. On + /// a row that already declares a `text/plain` 400 this is a no-op + /// (the render merges same-key content), and on one whose handler + /// answers 400 in JSON the status ends up carrying both media types, + /// which is the truth. + fn with_query(mut self, params: Vec) -> Self { + self.query = params; + self = self.err(400); + self + } + + fn with_path(mut self, params: Vec) -> Self { + self.path_params = params; + self + } + + /// A JSON request body the handler deserializes into `handler_struct` + /// (axum's `Json` extractor). `required` is false for the + /// `Option>` handlers, which accept a missing body. + fn body(mut self, handler_struct: &'static str, required: bool) -> Self { + self.body = Some(Body { handler_struct, required }); + // Two rejections happen in the extractor, before the handler is + // entered, so no row can be forgotten and no handler's own error + // list has to remember them: axum's `Json` answers 415 for a + // wrong `Content-Type` and 422 for a body that does not + // deserialize into `handler_struct`. Both are `text/plain`. + // Found by running the weekly fuzz job against a booted + // service, which called them undocumented on all 25 body routes + // -- #3325's job earning its keep on the contract it ships with. + // `err` takes self by value, so hand it back and keep going. + self = self.err(415).err(422); + self + } + + /// An optional `If-Match` carrying the revision the caller believes it + /// is editing (config.rs's `expected_revision`, a weak ETag whose + /// numeric body is the revision). Optional because a missing header + /// means "no expectation", not "reject". + fn if_match(mut self) -> Self { + self.headers.push(Param { + name: "If-Match", + description: "Optional optimistic-concurrency revision, as a weak ETag \ + (`W/\"7\"`). A mismatch answers 409.", + schema: str_schema(), + required: false, + }); + self + } +} + +fn error_description(status: u16) -> &'static str { + match status { + 400 => "Rejected: the request was understood but its input is not acceptable.", + 401 => "No valid service token (or, on the Workbench, no actor identity).", + 404 => "No such record, store, or route for the values given.", + 405 => "The store exists but exposes no delete side (only dead-letters does).", + 409 => "The record changed since the revision the caller presented.", + 413 => "The stored artifact is larger than this endpoint will serve.", + 415 => "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", + 422 => "Well-formed but unprocessable. Two causes, both text/plain: the Json \ + extractor refused the body before the handler ran, or the route's own domain \ + check rejected the reference it was asked to resolve (the reports store \ + answers this for an unresolvable scope or an unexpected storage failure).", + 500 => "The handler failed in a way it does not model as a 4xx.", + 501 => "The saved definition's template is not implemented by the renderer yet.", + 502 => "Elasticsearch (or a sibling it proxies) refused or failed the query.", + 503 => "A dependency this route needs is not configured or not reachable.", + _ => "Error.", + } +} + +// --------------------------------------------------------------------- +// Shared query shapes, transcribed from the handler structs they mirror. +// Each `fn` names the Rust type it tracks so a reader can check it +// against that struct in one jump; a drift test does not cover these +// field-by-field (nothing in the crate can), which is why each carries +// the type name. +// --------------------------------------------------------------------- + +/// `events::EventsQuery` -- the filter set /events and four of the CSV +/// exports share. +fn events_query() -> Vec { + let filters: [(&'static str, &'static str); 24] = [ + ("ip", "Single source address."), + ("ips", "Comma-separated source addresses."), + ("sensor", "Sensor name (honeypot.dionaea, suricata, ...)."), + ("country", "ISO country code."), + ("city", "City name, as bucketed on the overview map."), + ("port", "Destination port."), + ("proto", "Transport protocol."), + ("kind", "honeypot.event kind (command, login, ...)."), + ("shasum", "Captured-payload hash."), + ("community_id", "One flow across every sensor that saw it."), + ("q", "Free-text query_string, passed to Elasticsearch as-is."), + ("since", "Go-style relative window (24h, 7d)."), + ("persona", "Decoy persona id."), + ("site", "Decoy site id."), + ("asset", "Decoy asset id."), + ("fingerprint", "Client fingerprint, matched across every field sensors record one in."), + ("cmd", "Exact command text."), + ("cred", "\"user / pass\" pair."), + ("path", "Request path."), + ("session", "Session id."), + ("asn", "Source AS number."), + ("org", "Source network organization."), + ("provider", "Provider class."), + ("sig", "IDS alert signature."), + ]; + let mut params = vec![ + query("offset", "Result window start.", u64_schema()), + opt_query("size", "Page size, clamped to 100 by the handler.", json!({"type": "integer", "format": "int64", "minimum": 1, "maximum": 100})), + ]; + params.extend(filters.iter().map(|(name, description)| opt_query(name, description, str_schema()))); + params.push(opt_query("cat", "Detection category (Suricata alert category or honeypot.category).", str_schema())); + params +} + +/// `stores::StoreQuery` -- the generic store family's paging, shared by +/// the fixed store endpoints and `/api/v1/store/{name}`. +fn store_query() -> Vec { + vec![ + query("offset", "Result window start.", u64_schema()), + opt_query("size", "Page size.", json!({"type": "integer", "format": "int64", "minimum": 1})), + opt_query("q", "Free-text Lucene query string.", str_schema()), + opt_query("ip", "Narrow to one source address.", str_schema()), + opt_query("aggs", "`sources` adds the payload-inventory source buckets; anything else is ignored.", str_schema()), + ] +} + +/// `aggregates::PageQuery`. +fn page_query() -> Vec { + vec![ + query("offset", "Result window start.", u64_schema()), + opt_query("size", "Page size.", json!({"type": "integer", "format": "int64", "minimum": 1})), + ] +} + +/// `config::ActorQuery` -- the audit attribution the write paths take. +fn actor_query() -> Vec { + vec![ + opt_query("actor_subject", "OIDC subject recorded on the audit/history entry.", str_schema()), + opt_query("actor_username", "Operator name recorded on the audit/history entry.", str_schema()), + ] +} + +fn q(name: &'static str, description: &'static str) -> Param { + opt_query(name, description, str_schema()) +} + +fn enum_schema(values: &[&str]) -> Value { + json!({"type": "string", "enum": values}) +} + +// --------------------------------------------------------------------- +// The surface itself. Grouped and ordered the way main.rs registers it, +// so the two read side by side. +// --------------------------------------------------------------------- + +fn operations() -> Vec { + vec![ + // The /api surface, in main.rs's registration order. Every row is + // a route the router really registers -- `contract_covers_every_router_route` + // below fails the build if these two lists stop being the same list. + op("GET", "/api/v1/overview/kpis", "KPI counters behind the overview tiles.") + .ok() + .err(502), + op("GET", "/api/v1/overview/dashboard", "The one aggregation the overview page renders, sliced by ?parts=.") + .ok() + .err(502) + .with_query(vec![q("parts", "Comma-separated subset of slice names; absent or empty means every slice.")]), + op("GET", "/api/v1/events", "Event explorer page: the shared filter set, windowed.") + .ok() + .err(502) + .with_query(events_query()), + op("GET", "/api/v1/export/events.csv", "The event explorer as CSV, same filters as /events.") + .ok_media("text/csv", "CSV of the matching events.") + .err(502) + .with_query(events_query()), + op("GET", "/api/v1/export/commands.csv", "Matching commands as CSV.") + .ok_media("text/csv", "CSV of matching commands.") + .err(502) + .with_query(events_query()), + op("GET", "/api/v1/export/ips.csv", "Every source address in the window as CSV.") + .ok_media("text/csv", "CSV of source addresses.") + .err(502) + .with_query(page_query()), + op("GET", "/api/v1/export/campaigns.csv", "Campaigns as CSV.") + .ok_media("text/csv", "CSV of campaigns.") + .err(502) + .with_query(page_query()), + op("GET", "/api/v1/export/clusters.csv", "Attacker clusters as CSV, by cluster kind.") + .ok_media("text/csv", "CSV of attacker clusters.") + .err(502) + .with_query(vec![opt_query("kind", "Cluster kind to export.", enum_schema(&["fingerprint", "payload", "asn", "provider"]))]), + op("GET", "/api/v1/export/history.json", "The behaviour-search slice as JSON.") + .ok_media("application/json", "Behaviour-search rows as JSON.") + .err(502) + .with_query(events_query()), + op("GET", "/api/v1/live", "Server-sent event source: the explorer tailing contract.") + .ok_media("text/event-stream", "An endless text/event-stream of event documents. Never terminates, which is why the fuzz job excludes this path."), + op("GET", "/api/v1/mail/{session_id}", "Mail the SMTP honeypot captured for one session.") + .with_path(vec![path_param("session_id", "Session whose captured mail is wanted.", str_schema())]) + .ok() + .err(400) + .err(404) + .err(502), + op("GET", "/api/v1/ml-health", "Per-model ml-worker health.") + .ok() + .err(502), + op("GET", "/api/v1/gpu-queue", "The GPU analysis queue as it stands.") + .ok() + .err(502), + op("POST", "/api/v1/gpu-queue/{job_id}/abort", "Abort a queued or running GPU job.") + .with_path(vec![path_param("job_id", "GPU job to abort.", str_schema())]) + .ok() + .err(502), + op("GET", "/api/v1/sources", "Known source addresses with their event counts.") + .ok() + .err(502) + .with_query(page_query()), + op("GET", "/api/v1/filter-values", "Distinct values behind every explorer filter dropdown.") + .ok() + .err(502), + op("GET", "/api/v1/investigate/ip/{ip}", "Everything one source address did, across sensors.") + .with_path(vec![path_param("ip", "Source address to profile. A non-address is a 400.", json!({"type": "string"}))]) + .ok() + .err(400) + .err(404) + .err(502), + op("GET", "/api/v1/investigate/cidr/{cidr}", "Correlation across one CIDR block.") + .with_path(vec![path_param("cidr", "CIDR block to correlate. A malformed block is a 400.", str_schema())]) + .ok() + .err(400) + .err(502), + op("GET", "/api/v1/investigate/cluster", "The members of one attacker cluster.") + .ok() + .err(400) + .err(404) + .err(502) + .with_query(vec![opt_query("kind", "Cluster kind.", enum_schema(&["fingerprint", "payload", "asn", "provider"])), q("value", "The cluster's value, as /api/v1/clusters reports it.")]), + op("GET", "/api/v1/source-health", "Per-source ingestion health, the page behind \"Source & pipeline health\".") + .ok() + .err(502), + // The one ES-backed read in the API that does *not* 502. Every + // other row with a `.err(502)` gets there because the extractor + // turns a dead cluster into a status code; this handler catches + // the error and answers 200 with `available: false` and the + // reason in the body, because a delivery card that states why it + // has nothing is worth more than an operations page that fails to + // render. Declaring 502 here would be the exact kind of guess the + // rest of this file refuses to make. + op("GET", "/api/v1/webhook-delivery", "Delivery outcomes for the configured alert webhook.") + .ok(), + op("GET", "/api/v1/event/{id}", "One event, with the pivot groups its detail pane needs.") + .with_path(vec![path_param("id", "Event document id.", str_schema())]) + .ok() + .err(400) + .err(404) + .err(502), + op("GET", "/api/v1/event/{id}/connections", "The same-flow summary and re-used-wordlist edges for one event.") + .with_path(vec![path_param("id", "Event document id.", str_schema())]) + .ok() + .err(400) + .err(404) + .err(502), + op("GET", "/api/v1/connections/{community_id}", "Every record that shares one community_id flow hash.") + .with_path(vec![path_param("community_id", "network.community_id flow hash, as computed independently by each sensor.", str_schema())]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/cred-reuse", "Credential pairs reused across more than one address.") + .ok() + .err(502), + op("GET", "/api/v1/sensors", "Per-sensor counts, last-seen, and state.") + .ok() + .err(502), + op("GET", "/api/v1/sensors/catalog", "The sensor catalog the setup pages read.") + .ok() + .err(502), + op("GET", "/api/v1/sensors/{sensor}/events", "Recent events from one sensor.") + .with_path(vec![path_param("sensor", "Sensor name; the handler rejects an empty value or one over 128 characters.", json!({"type": "string", "maxLength": 128}))]) + .ok() + .err(400) + .err(502) + .with_query(vec![q("limit", "How many events to return; clamped by the handler.")]), + op("GET", "/api/v1/sensors/{sensor}/overview", "Protocols, ports and fingerprints for one sensor.") + .with_path(vec![path_param("sensor", "Sensor name; the handler rejects an empty value or one over 128 characters.", json!({"type": "string", "maxLength": 128}))]) + .ok() + .err(400) + .err(502), + op("GET", "/api/v1/sessions/{id}", "One session: its events, commands and credentials.") + .with_path(vec![path_param("id", "Session id.", str_schema())]) + .ok() + .err(400) + .err(404) + .err(502), + op("GET", "/api/v1/search", "Cross-surface search for the omnibox.") + .ok() + .err(502) + .with_query(vec![q("q", "What to search for.")]), + op("GET", "/api/v1/topology", "Decoy topology graph.") + .ok(), + op("GET", "/api/v1/settings/storage", "Index sizes and document counts.") + .ok() + .err(502), + op("GET", "/api/v1/config", "The whole operator-authored dashboard configuration.") + .ok() + .err(502), + op("PUT", "/api/v1/config/presentation", "Replace the presentation block (branding, theme, landing copy).") + .ok_status(200, "The stored presentation block and its new revision.") + .err(400) + .err(404) + .err(409) + .err(502) + .body("serde_json::Value", true) + .if_match() + .with_query(actor_query()), + op("PUT", "/api/v1/config/{section}", "Replace one settings section.") + .with_path(vec![path_param("section", "Settings section to replace.", enum_schema(&["honeypot", "behavior", "report-presets"]))]) + .ok_status(200, "The stored section and its new revision.") + .err(400) + .err(404) + .err(409) + .err(502) + .body("serde_json::Value", true) + .if_match() + .with_query(actor_query()), + op("GET", "/api/v1/config/history", "The revision history the rollback picker reads (payloads excluded).") + .ok(), + op("POST", "/api/v1/config/rollback", "Restore a past configuration revision.") + .ok_status(200, "The restored configuration and its new revision.") + .err(400) + .err(404) + .err(409) + .err(502) + .body("RollbackBody", true) + .if_match(), + op("POST", "/api/v1/config/validate", "Check a candidate configuration without storing it.") + .ok() + .err(400) + .body("serde_json::Value", true), + op("GET", "/api/v1/users", "Dashboard users, as the ES-side user store reports them.") + .ok() + .err(502), + op("GET", "/api/v1/audit", "The audit trail, newest first.") + .ok() + .with_query(vec![opt_query("limit", "How many entries; clamped to [1, 500] by the handler, default 100.", json!({"type": "integer", "minimum": 1, "maximum": 500})), q("action", "Only entries with this action.")]), + op("GET", "/api/v1/preferences", "One user's saved preferences.") + .ok() + .err(400) + .err(502) + .with_query(vec![q("subject", "OIDC subject. Required -- an empty value is a 400."), q("username", "Operator name."), q("role", "Operator role.")]), + op("PUT", "/api/v1/preferences", "Replace one user's saved preferences.") + .ok() + .err(400) + .err(404) + .err(502) + .body("PreferencesWriteBody", true), + op("POST", "/api/v1/preferences/reset", "Drop one user's saved preferences back to the defaults.") + .ok() + .err(400) + .err(404) + .err(502) + .body("PreferencesResetBody", true), + op("GET", "/api/v1/reporter-stats", "What the reporting loops produced and when.") + .ok() + .err(500) + .err_json(502), + op("GET", "/api/v1/services", "The compose services the operator can act on.") + .ok() + .err_json(503), + op("GET", "/api/v1/services/{name}/logs", "Recent log lines for one service, via the services adapter.") + .with_path(vec![path_param("name", "compose service name.", str_schema())]) + .ok() + .err_json(400) + .err_json(503) + .with_query(vec![opt_query("lines", "How many lines; the handler defaults to 200.", json!({"type": "integer", "format": "int32", "minimum": 1}))]), + op("POST", "/api/v1/services/{name}/{action}", "Start, stop or restart one service.") + .with_path(vec![path_param("name", "compose service name.", str_schema()), path_param("action", "Lifecycle action the services adapter accepts.", enum_schema(&["start", "stop", "restart"]))]) + .ok() + .err_json(400) + .err_json(503) + .with_query(actor_query()), + op("GET", "/api/v1/llm-search", "Natural-language search over the corpus, answered by the local model.") + .ok() + .with_query(vec![q("q", "The question."), opt_query("limit", "How many hits to summarise.", json!({"type": "integer", "minimum": 1})), q("source", "\"session\", \"vault\" or \"vault-note\"; an unknown value falls back to session.")]), + op("GET", "/api/v1/vault-rag", "Answer a question from the Vault corpus through the local model.") + .ok() + .with_query(vec![q("q", "The question.")]), + op("POST", "/api/v1/ip-block", "Block or unblock an address.") + .ok() + .err(400) + .err(502) + .body("BlockBody", true), + op("GET", "/api/v1/ip-block/{ip}", "One address's block state.") + .with_path(vec![path_param("ip", "Address whose block state is wanted.", str_schema())]) + .ok() + .err(400) + .err(502), + op("GET", "/api/v1/ip-block-export", "The whole block list, for backup or review.") + // Not JSON: ip_block::export answers the newline-joined IP + // list as text/plain, which is what makes it paste-able into a + // block list. The fuzzer called the declared application/json + // undocumented. + .ok_media("text/plain", "The blocked addresses, one per line.") + .err(502), + op("GET", "/api/v1/sandbox/{job}", "One sandbox run.") + .with_path(vec![path_param("job", "Sandbox job id.", str_schema())]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/ghidra/{sha}", "One Ghidra analysis run.") + .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/ghidra-callgraph/{sha}", "The call graph one Ghidra run produced.") + .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/revdeck/{sha}", "One RevDeck analysis run.") + .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/cape/{sha}", "One CAPE analysis run.") + .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/cape/{sha}/raw", "The raw CAPE report JSON for one run.") + .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) + .ok_media("application/json", "The stored report, verbatim.") + .err(404) + .err(502), + op("GET", "/api/v1/github-analysis/{sha}", "One GitHub analysis run.") + .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/attackers-graph", "The node/edge graph around one attacker entity.") + .ok() + .err(400) + .err(404) + .err(502) + .with_query(vec![q("id", "Attacker entity id.")]), + op("GET", "/api/v1/attack-vectors", "Attack vectors for one sensor.") + .ok() + .err(400) + .err(502) + .with_query(vec![q("sensor", "A specific sensor. Empty, suricata and portbridge are all rejected: those ship to their own index families.")]), + op("POST", "/api/v1/ml-anomalies/ack", "Acknowledge one ML anomaly.") + .ok() + .err(400) + .err(502) + .body("MlAckBody", true), + op("POST", "/api/v1/ml-anomalies/ack-all", "Acknowledge every open ML anomaly.") + .ok() + .err(502) + .body("MlAckAllBody", true), + op("GET", "/api/v1/ml-anomalies/acks", "The ack ledger.") + .ok() + .err(502), + op("GET", "/api/v1/ml-anomalies/stats", "Ack statistics.") + .ok() + .err(502), + op("POST", "/api/v1/ml-anomalies/disposition", "Record an analyst disposition for anomalies.") + .ok() + .err(400) + .err(502) + .body("MlDispositionBody", true), + op("GET", "/api/v1/reports/{id}/pdf", "One generated report, rendered to PDF.") + .with_path(vec![path_param("id", "Generated report id.", str_schema())]) + .ok_media("application/pdf", "The rendered PDF.") + .err(404) + .err(502), + op("GET", "/api/v1/reports/templates", "The report template and element catalog.") + .ok(), + op("GET", "/api/v1/reports/definitions", "Saved report definitions.") + .ok() + .err(502), + op("POST", "/api/v1/reports/definitions", "Save a new report definition.") + .ok_status(201, "The stored definition.") + .err(400) + .err(409) + .err(422) + .err(502) + .body("ReportDefinition", true), + op("GET", "/api/v1/reports/definitions/{id}", "One saved report definition.") + .with_path(vec![path_param("id", "Saved report-definition id.", str_schema())]) + .ok() + .err(404) + .err(502), + op("PUT", "/api/v1/reports/definitions/{id}", "Replace one saved report definition.") + .with_path(vec![path_param("id", "Saved report-definition id.", str_schema())]) + .ok() + .err(400) + .err(404) + .err(409) + .err(422) + .err(502) + .body("ReportDefinition", true), + op("DELETE", "/api/v1/reports/definitions/{id}", "Delete one saved report definition.") + .with_path(vec![path_param("id", "Saved report-definition id.", str_schema())]) + .ok() + .err(404) + .err(422) + .err(502), + op("POST", "/api/v1/reports/definitions/{id}/generate", "Run a saved definition now and store the result.") + .with_path(vec![path_param("id", "Saved report-definition id.", str_schema())]) + .ok_status(201, "The queued run.") + .err(400) + .err(404) + .err(409) + .err(502) + .body("GenerateBody", false), + op("DELETE", "/api/v1/reports/generated/{id}", "Delete one generated report.") + .with_path(vec![path_param("id", "Generated report id.", str_schema())]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/artifacts/{kind}/{key}", "Artifacts a run produced, one row per filename.") + .with_path(vec![path_param("kind", "Artifact family.", enum_schema(&["ghidra", "sandbox"])), path_param("key", "Run id the artifacts belong to (a sha256 for ghidra, a job id for sandbox).", str_schema())]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/artifacts/{kind}/{key}/{filename}", "Download one artifact of a run.") + .with_path(vec![path_param("kind", "Artifact family.", enum_schema(&["ghidra", "sandbox"])), path_param("key", "Run id the artifacts belong to.", str_schema()), path_param("filename", "Exact stored filename; the handler refuses a path separator or a name outside this key.", str_schema())]) + .ok_media("application/octet-stream", "The stored artifact bytes.") + .err(400) + .err(404) + .err(413) + .err(502) + .err(503), + op("GET", "/api/v1/charts/kill-chain-sankey", "Kill-chain stages as a sankey.") + .ok() + .err(502), + op("GET", "/api/v1/charts/attck-coverage", "ATT&CK technique coverage as a grid.") + .ok() + .err(502), + op("GET", "/api/v1/charts/campaign-timeline", "Campaigns over time.") + .ok() + .err(502), + op("GET", "/api/v1/charts/ml-backlog", "ML anomaly backlog over time.") + .ok() + .err(502), + op("GET", "/api/v1/charts/netflow-bytes", "Netflow bytes over time.") + .ok() + .err(502), + op("GET", "/api/v1/charts/netflow-packets", "Netflow packets over time.") + .ok() + .err(502), + op("GET", "/api/v1/charts/anomaly-trend", "Anomaly counts over time.") + .ok() + .err(502), + op("GET", "/api/v1/charts/dionaea-cves", "Dionaea exploit attempts by CVE.") + .ok() + .err(502), + op("GET", "/api/v1/charts/os-distribution", "Fingerprint-derived OS distribution.") + .ok() + .err(502), + op("GET", "/api/v1/charts/tcp-stack-clusters", "JA4T stack clusters.") + .ok() + .err(502), + op("GET", "/api/v1/charts/ics-functions", "ICS function codes seen.") + .ok() + .err(502), + op("GET", "/api/v1/charts/decoy-requests", "Requests per decoy.") + .ok() + .err(502), + op("GET", "/api/v1/charts/decoy-client-fingerprints", "Decoy requests joined against ClientHello fingerprints.") + .ok() + .err(502), + op("GET", "/api/v1/charts/ja4h-fingerprints", "JA4H fingerprint distribution.") + .ok() + .err(502), + op("GET", "/api/v1/charts/ja4x-fingerprints", "JA4X fingerprint distribution.") + .ok() + .err(502), + op("GET", "/api/v1/charts/ja4l-fingerprints", "JA4L fingerprint distribution.") + .ok() + .err(502), + op("GET", "/api/v1/charts/tls-fingerprints", "TLS fingerprint distribution.") + .ok() + .err(502), + op("GET", "/api/v1/charts/ssh-fingerprints", "SSH fingerprint distribution.") + .ok() + .err(502), + op("GET", "/api/v1/charts/endlessh-held-histogram", "How long endlessh held each connection.") + .ok() + .err(502), + op("GET", "/api/v1/charts/ml-anomaly-scores", "ML anomaly scores over time.") + .ok() + .err(502), + op("GET", "/api/v1/charts/attacker-fusion", "How one attacker's signals fuse across sources.") + .ok() + .err(400) + .err(404) + .err(502) + .with_query(vec![q("id", "Attacker entity id.")]), + op("GET", "/api/v1/campaigns", "Campaign store.") + .ok() + .err(502) + .with_query(store_query()), + op("GET", "/api/v1/clusters", "Attacker-cluster store.") + .ok() + .err(502) + .with_query(store_query()), + op("GET", "/api/v1/attackers", "Attacker-entity store.") + .ok() + .err(502) + .with_query(store_query()), + op("GET", "/api/v1/attackers/{id}/events", "The raw evidence behind one attacker entity.") + .with_path(vec![path_param("id", "Attacker entity id from /api/v1/attackers.", str_schema())]) + .ok() + .err(404) + .err(500) + .err(502) + .with_query(page_query()), + op("GET", "/api/v1/recordings", "TTY recording store.") + .ok() + .err(502) + .with_query(store_query()), + op("GET", "/api/v1/recordings/{shasum}", "One TTY recording.") + .with_path(vec![path_param("shasum", "Recording shasum to replay.", str_schema())]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/recordings/{shasum}/cast", "One TTY recording as asciicast.") + .with_path(vec![path_param("shasum", "Recording shasum to replay.", str_schema())]) + .ok_media("text/plain", "The asciicast body.") + .err(404) + .err(502), + op("GET", "/api/v1/recordings/{shasum}/raw", "One TTY recording as raw bytes.") + .with_path(vec![path_param("shasum", "Recording shasum to replay.", str_schema())]) + .ok_media("application/octet-stream", "The raw log bytes.") + .err(404) + .err(413) + .err(502), + op("GET", "/api/v1/alerts", "Alert-state store.") + .ok() + .err(502) + .with_query(store_query()), + op("POST", "/api/v1/alerts/{key}/ack", "Acknowledge one alert.") + .with_path(vec![path_param("key", "Alert-state document key (the hashified signature triple).", str_schema())]) + .ok() + .err(502) + .body("AckBody", true), + op("GET", "/api/v1/canarytokens/types", "The canarytoken types this build can mint.") + .ok(), + op("GET", "/api/v1/canarytokens", "Minted canarytokens, with their management token redacted.") + .ok() + .err(502), + op("POST", "/api/v1/canarytokens", "Mint a canarytoken.") + .ok() + .err(400) + .err(500) + .err(502) + .err(503) + .body("CreateBody", true), + op("GET", "/api/v1/canarytokens/{id}/download", "The canarytoken's landing URL, as a redirect.") + .with_path(vec![path_param("id", "Canarytoken id.", str_schema())]) + .ok_status(302, "Redirect to the token's landing URL.") + .err(400) + .err(404) + .err(500) + .err(502) + .err(503), + op("GET", "/api/v1/credentials", "HoneyFS implant credentials, with secrets redacted.") + .ok(), + op("POST", "/api/v1/credentials", "Provision a honeyfs-implant credential.") + .ok() + .err(400) + .err(500) + .err(502) + .err(503) + .body("CreateBody", true), + op("POST", "/api/v1/credentials/{id}/rotate", "Rotate a honeyfs-implant credential's secret.") + .with_path(vec![path_param("id", "HoneyFS implant credential id.", str_schema())]) + .ok() + .err(404) + .err(500) + .err(502) + .err(503) + .body("RotateBody", false), + op("POST", "/api/v1/credentials/{id}/link-token", "Mint a link token for a honeyfs-implant credential.") + .with_path(vec![path_param("id", "HoneyFS implant credential id.", str_schema())]) + .ok() + .err(400) + .err(404) + .err(500) + .body("LinkTokenBody", true), + op("GET", "/api/v1/payloads", "Captured-payload store.") + .ok() + .err(502) + .with_query(store_query()), + op("GET", "/api/v1/payloads/{hash}", "One captured payload and its analysis.") + .with_path(vec![path_param("hash", "Payload id: 32 or 64 lower-case hex characters.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{32}([0-9a-fA-F]{32})?$"}))]) + .ok() + .err(404) + .err(502), + op("GET", "/api/v1/payloads/{hash}/raw", "One captured payload's bytes.") + .with_path(vec![path_param("hash", "Payload id: 32 or 64 lower-case hex characters.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{32}([0-9a-fA-F]{32})?$"}))]) + .ok_media("application/octet-stream", "The payload bytes.") + .err(400) + .err(404) + .err(413) + .err(502), + op("POST", "/api/v1/payloads/{hash}/report", "One-click payload PDF into the generated store.") + .with_path(vec![path_param("hash", "Payload id: 32 or 64 lower-case hex characters.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{32}([0-9a-fA-F]{32})?$"}))]) + .ok_status(201, "The queued report run.") + .err(400) + .err(404) + .err(422) + .err(501) + .err(502), + op("GET", "/api/v1/store/{name}", "One allowlisted store, through the generic passthrough.") + .with_path(vec![path_param("name", "Allowlisted generic store. Anything else is a 404 -- this route is not an arbitrary index read.", enum_schema(STORE_NAMES))]) + .ok() + .err(404) + .err(502) + .with_query(store_query()), + op("DELETE", "/api/v1/store/{name}", "Purge dead letters matching ?q= (dead-letters only).") + .with_path(vec![path_param("name", "Allowlisted generic store. Anything else is a 404 -- this route is not an arbitrary index read.", enum_schema(STORE_NAMES))]) + .ok() + .err(405) + .err(502) + .with_query(vec![q("q", "Lucene query string; absent or empty purges every retained dead letter.")]), + op("POST", "/api/v1/problem-reports", "File a problem report from the dashboard UI.") + .ok_status(201, "The stored report.") + .err(400) + .err(404) + .err(409) + .err(502) + .body("Submission", true) + .with_query(actor_query()), + op("PATCH", "/api/v1/problem-reports/{id}", "Move a problem report through open/triaged/closed.") + .with_path(vec![path_param("id", "Problem-report id.", str_schema())]) + .ok_status(204, "No content; the status was stored.") + .err(400) + .err(404) + .err(409) + .err(502) + .body("StatusPatch", true), + op("POST", "/api/v1/sandbox/submit", "Queue a sandbox detonation.") + .ok() + .err(400) + .err(404) + .err(503) + .body("SubmitBody", true), + op("GET", "/api/v1/sandbox/golden-image-status", "Whether the sandbox golden image is built.") + .ok(), + op("GET", "/api/v1/sandbox/vnc", "The VNC port the sandbox advertises, if any.") + .ok() + .err(404), + op("POST", "/api/v1/ghidra/submit", "Queue a Ghidra analysis.") + .ok() + .err(400) + .err(404) + .err(503) + .body("SubmitBody", true), + op("POST", "/api/v1/github-analysis/submit", "Queue a GitHub analysis.") + .ok() + .err(400) + .err(404) + .err(503) + .body("SubmitBody", true), + op("GET", "/api/v1/workbench/analyzers", "Analyzers available for one payload.") + .ok() + .err_json(400) + .err_json(404) + .with_query(vec![q("hash", "Payload id.")]), + op("GET", "/api/v1/workbench/runs", "Payload Workbench runs owned by the calling operator.") + .ok() + .err_json(502) + .actor() + .with_query(vec![q("hash", "Narrow to one payload id."), opt_query("limit", "How many runs to return.", json!({"type": "integer", "minimum": 1}))]), + op("POST", "/api/v1/workbench/runs", "Start a Workbench run over one payload.") + .ok() + .err_json(400) + .err_json(404) + .err_json(502) + .actor() + .body("CreateRunBody", true), + op("GET", "/api/v1/workbench/runs/{id}", "One Workbench run, with its children.") + .with_path(vec![path_param("id", "Workbench run id.", str_schema())]) + .ok() + .err_json(404) + .err_json(502) + .actor(), + op("POST", "/api/v1/workbench/runs/{id}/children/{analyzer_id}/{action}", "Cancel or retry one child of a run.") + .with_path(vec![path_param("id", "Workbench run id.", str_schema()), path_param("analyzer_id", "Analyzer entry on this run; the orchestrator looks the child up by it.", str_schema()), path_param("action", "Child lifecycle action.", enum_schema(&["cancel", "retry"]))]) + .ok() + .err_json(400) + .err_json(404) + .err_json(502) + .actor(), + op("GET", "/api/v1/workbench/recipes", "Saved Workbench recipes owned by the calling operator.") + .ok() + .err_json(502) + .actor(), + op("POST", "/api/v1/workbench/recipes", "Save a Workbench recipe.") + .ok() + .err_json(400) + .err_json(404) + .err_json(409) + .err_json(502) + .actor() + .body("SaveRecipeBody", true), + op("GET", "/healthz", "Liveness plus an Elasticsearch reachability flag. Public on purpose: the container healthcheck is the caller.") + .public() + .ok() + .err(502), + op("GET", "/metrics", "Prometheus exposition for the #1972 request metrics. Public on purpose, like /healthz.") + .public() + .ok_media("text/plain", "Prometheus text exposition format."), + ] +} + +/// The tag an operation is filed under. Derived from the path so the +/// grouping is reviewable in one place rather than repeated 138 times. +fn tag_for(path: &str) -> &'static str { + let rest = path.strip_prefix("/api/v1/").unwrap_or(path); + // Group by the *first* segment, not the whole remainder. Matching + // the remainder reads `workbench/runs` as an unknown route and + // dumps every multi-segment path into `other` -- which is most of + // the API, and a tag that covers most of the API is not a tag. The + // leading `/` has to come off first, or the first segment of + // `/healthz` is the empty string and the two public routes read as + // ungrouped. + let head = rest.trim_start_matches('/'); + match head.split('/').next().unwrap_or(head) { + "healthz" | "metrics" | "sources" | "filter-values" | "source-health" | "settings" + | "reporter-stats" | "ml-health" | "gpu-queue" | "overview" | "webhook-delivery" => "platform", + "events" | "live" | "event" => "events", + "mail" | "sessions" | "search" | "topology" | "attackers-graph" | "attack-vectors" + | "cred-reuse" | "connections" | "investigate" | "sensors" => "investigate", + "llm-search" | "vault-rag" => "ai", + "ip-block" | "ip-block-export" | "services" | "problem-reports" | "canarytokens" + | "credentials" => "operations", + "config" | "preferences" | "users" | "audit" => "configuration", + "attackers" | "campaigns" | "clusters" | "alerts" | "recordings" | "payloads" + | "ml-anomalies" | "store" => "stores", + "reports" | "workbench" => "reports", + "sandbox" | "ghidra" | "ghidra-callgraph" | "revdeck" | "cape" | "github-analysis" + | "artifacts" => "analysis", + "export" => "export", + "charts" => "charts", + _ => "other", + } +} + +/// What a tag covers, for the document's top-level `tags` list. OpenAPI +/// wants a Tag Object per entry rather than a bare string, and a reader +/// landing on a 500-operation group deserves to know what is in it. +fn tag_description(tag: &str) -> &'static str { + match tag { + "platform" => "The dashboard's own view of the service: landing overview and KPIs, health, source and pipeline health, and platform settings.", + "events" => "The event corpus itself: browse, search, and the live event stream.", + "investigate" => "Read paths that turn one event or sensor into something a human reads.", + "ai" => "Model-backed triage: LLM search over the corpus and the vault-backed RAG endpoint.", + "operations" => "Operator housekeeping: block lists, service accounts, canary tokens, problem reports.", + "configuration" => "Dashboard configuration, user preferences, and the audit trail.", + "stores" => "The generated Elasticsearch stores the alert, campaign and identity views are built from.", + "reports" => "Saved report definitions, generated reports, and the Payload Workbench.", + "analysis" => "Submission and polling for the out-of-band analysers (sandbox, Ghidra, CAPE, RevDeck).", + "export" => "Bulk CSV/JSON exports of the views an operator hands to someone else.", + "charts" => "Precomputed chart series, one route per widget.", + _ => "Routes this service exposes that do not fit the groups above; grouped so nothing is unrouted.", + } +} + +/// The allowlisted store names, mirroring `stores.rs`'s `store_config` +/// match. A copy rather than a reference because the contract is a +/// document, not a view of the binary -- but it is a copy with teeth: +/// anything outside this list is a 404 at runtime, so widening the +/// contract's enum without widening the allowlist would advertise a +/// route that cannot work. +const STORE_NAMES: &[&str] = &[ + "agent-campaigns", + "auth-events", + "canarytokens", + "cape", + "dead-letters", + "generated-reports", + "ghidra-runs", + "github-analysis", + "intelligence", + "llm-analysis", + "ml-anomalies", + "problem-reports", + "report-definitions", + "revdeck", + "sandbox-runs", + "static-analysis", + "workbench-runs", + "yara", +]; + +/// Renders the OpenAPI 3.1 document. Deterministic: operations are +/// emitted in sorted (path, method) order and every map is built with +/// sorted keys, so regenerating an unchanged table produces a +/// byte-identical file and a reviewer can read a real diff. +pub fn document() -> Value { + let mut ops = operations(); + ops.sort_by(|a, b| (a.path, a.method).cmp(&(b.path, b.method))); + + let mut paths: Map = Map::new(); + for op in &ops { + let item = paths.entry(op.path.to_string()).or_insert_with(|| json!({})); + let object = item + .as_object_mut() + .expect("path items are always built as objects"); + assert!( + !object.contains_key(op.method), + "two operations claim {} {}", + op.method, + op.path, + ); + // A Path Item's method fields are lowercase in OpenAPI + // (`get:`, not `GET:`) -- the table above keeps the uppercase + // spelling because that is how the methods read in the source + // and in the drift test, so the case is folded here rather than + // in all 138 rows. + object.insert(op.method.to_ascii_lowercase(), render_operation(op)); + } + + let mut tag_names: Vec<&str> = ops.iter().map(|op| tag_for(op.path)).collect(); + tag_names.sort_unstable(); + tag_names.dedup(); + let tags: Vec = tag_names + .iter() + .map(|tag| json!({"name": tag, "description": tag_description(tag)})) + .collect(); + + json!({ + "openapi": "3.1.0", + "info": { + "title": "APIARY dashboard backend-service", + "version": env!("CARGO_PKG_VERSION"), + "summary": "The dashboard's Rust service tier: Elasticsearch-backed \ + read APIs over the honeypot event corpus.", + "description": INFO_DESCRIPTION + }, + "servers": [ + {"url": "http://127.0.0.1:8081", "description": + "The default LISTEN_ADDR. In compose this service is reachable \ + only over the internal honeynet, and only by the dashboard BFF."} + ], + "tags": Value::Array(tags), + "paths": Value::Object(paths), + "components": { + "securitySchemes": { + "serviceToken": { + "type": "apiKey", + "in": "header", + "name": "X-Service-Token", + "description": "The shared secret the Nitro BFF presents on \ + every /api/v1 call (SERVICE_TOKEN; main.rs's \ + require_service_token, #2183). Compared in \ + constant time, and a wrong or missing value \ + is a 401 before the route is even resolved. \ + Browsers never reach this service directly." + } + } + } + }) +} + +const INFO_DESCRIPTION: &str = "\ +The /api surface of `backend-service/` (`apiary-backend`), the Rust service \ +tier of the APIARY dashboard's modernization port (#1608). It is not a \ +public API: the Nitro BFF in `frontend-next/` is the only intended caller \ +and presents a shared service token on every /api/v1 route. /healthz and \ +/metrics are the two exceptions, open on purpose for the container \ +healthcheck and the #1972 metrics scrape. + +Response bodies are deliberately left unconstrained here. The handlers \ +return `Json` or a per-surface struct that nothing in this crate \ +enforces, so restating 138 shapes would be 138 chances to publish \ +something the code does not promise -- and a contract that lies about a \ +response is worse than one that admits the gap. What this document does \ +pin is the part that has been wrong before: the auth tier, the request \ +parameters (including the enum-valued ones the handlers actually \ +validate), the declared status codes, and the media type of every \ +response. That is what `.github/workflows/weekly-schemathesis.yml` \ +checks. + +`arcane/home/honeypot-dashboard/backend-service/src/openapi.rs` is the \ +source of truth. Regenerate the committed copy with \ +`cargo run --bin openapi > openapi.json`; the drift tests in that module \ +and a diff step in `quality.yml` both fail if you forget."; + +fn render_operation(op: &Op) -> Value { + let mut parameters: Vec = Vec::new(); + for param in &op.path_params { + parameters.push(json!({ + "name": param.name, + "in": "path", + "required": true, + "description": param.description, + "schema": param.schema, + })); + } + for param in &op.query { + parameters.push(json!({ + "name": param.name, + "in": "query", + "required": false, + "description": param.description, + "schema": param.schema, + })); + } + for param in &op.headers { + parameters.push(json!({ + "name": param.name, + "in": "header", + "required": param.required, + "description": param.description, + "schema": param.schema, + })); + } + + let mut responses: Map = Map::new(); + for response in &op.responses { + let mut content = Map::new(); + for (name, schema) in &response.schemas { + content.insert((*name).to_string(), json!({"schema": schema})); + } + let key = response.status.to_string(); + // Merge, do not overwrite. One status can be reachable with two + // media types on this API -- the Workbench's 400 is text/plain + // when axum's `Query` extractor refuses the query string and + // application/json when the handler rejects the value itself -- + // and an overwrite here would quietly publish only whichever was + // declared last, which is how the fuzzer ends up reporting a + // documented-but-unreachable media type. + match responses.get_mut(&key) { + Some(existing) => { + let existing = existing + .as_object_mut() + .expect("a rendered response is always an object"); + if let Some(existing_content) = existing.get_mut("content").and_then(Value::as_object_mut) + { + for (name, schema) in content { + existing_content.insert(name, schema); + } + } else { + existing.insert("content".to_string(), Value::Object(content)); + } + } + None => { + let mut rendered = Map::new(); + rendered.insert("description".to_string(), json!(response.description)); + if !content.is_empty() { + rendered.insert("content".to_string(), Value::Object(content)); + } + responses.insert(key, Value::Object(rendered)); + } + } + } + // The auth tier belongs in every operation, not in a note above the + // table: it is the property the fuzzer is pointed at, and an + // operation that quietly lost its `security` block would otherwise + // still validate. + match op.tier { + Tier::Public => {} + Tier::ServiceToken => { + responses.insert( + "401".to_string(), + json!({ + "description": error_description(401), + "content": {"text/plain": {"schema": str_schema()}} + }), + ); + } + Tier::ServiceTokenAndActor => { + // Both media types are reachable on one status: no token gets + // the middleware's text/plain 401, a valid token without a + // forwarded actor gets the Workbench's JSON one. + responses.insert( + "401".to_string(), + json!({ + "description": error_description(401), + "content": { + "text/plain": {"schema": str_schema()}, + "application/json": {"schema": free_form()} + } + }), + ); + } + } + + let mut rendered = Map::new(); + rendered.insert("tags".to_string(), json!([tag_for(op.path)])); + rendered.insert("summary".to_string(), json!(op.summary)); + rendered.insert("operationId".to_string(), json!(operation_id(op))); + rendered.insert("parameters".to_string(), Value::Array(parameters)); + if op.method != "GET" { + if let Some(body) = &op.body { + rendered.insert( + "requestBody".to_string(), + json!({ + "required": body.required, + "description": format!( + "Deserialized by the handler into `{}`. The shape is \ + left open here on purpose -- see the module doc.", + body.handler_struct + ), + "content": {"application/json": {"schema": free_form()}} + }), + ); + } + } + rendered.insert("responses".to_string(), Value::Object(responses)); + if op.tier != Tier::Public { + rendered.insert("security".to_string(), json!([{"serviceToken": []}])); + } + if op.success_media == Some("text/event-stream") { + // Not part of OpenAPI, and present on exactly one operation: a + // fuzzer that reads the document has to be able to find the + // route whose body never ends without hardcoding a path. The + // other non-JSON routes (CSV, PDF, octet-stream) terminate and + // need no marker -- a response too big to assert on is a + // finding, not a hang. + rendered.insert("x-endless-stream".to_string(), json!(true)); + } + Value::Object(rendered) +} + +/// A stable, greppable operation id: the method, then the path with `/` +/// and `{}` folded away. Unique by construction, because `document()` +/// has already refused a repeated (path, method). +fn operation_id(op: &Op) -> String { + let path: String = op + .path + .trim_start_matches('/') + .chars() + .map(|c| match c { + '{' | '}' | '.' | '-' | '/' => '_', + other => other, + }) + .collect(); + format!("{}_{}", op.method.to_lowercase(), path) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + use std::path::PathBuf; + + fn crate_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + } + + /// #3325's drift gate, direction one: the committed document has to + /// be what this module renders. Compared parsed rather than + /// byte-wise, so a reformat of the JSON is not a red build while a + /// changed status code, parameter or tag is. + #[test] + fn checked_in_contract_is_current() { + let path = crate_dir().join("openapi.json"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{} unreadable: {error}", path.display())); + let committed: Value = serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("{} is not valid JSON: {error}", path.display())); + let generated = document(); + assert!( + committed == generated, + "openapi.json is stale -- regenerate with\n \ + cargo run --bin openapi > openapi.json\n\nfirst difference: {}", + first_difference(&committed, &generated), + ); + } + + /// #3325's drift gate, direction two: every `.route(...)` main.rs + /// registers is in the contract, and nothing in the contract is + /// missing from the router. The direction that matters is + /// router -> contract -- a new route with no contract row is how a + /// fuzz target quietly stops covering the thing it was added for. + /// The other direction catches a contract path that no longer routes, + /// which is how a fuzzer ends up measuring a 404. + #[test] + fn contract_covers_every_router_route() { + let main_rs = + std::fs::read_to_string(crate_dir().join("src/main.rs")).expect("src/main.rs is readable"); + let registered: BTreeSet<(String, String)> = router_routes(&main_rs) + .into_iter() + .collect(); + assert!( + registered.len() > 100, + "the route scan found only {} registrations -- the parser is broken, \ + not the router", + registered.len(), + ); + + let published: BTreeSet<(String, String)> = operations() + .iter() + .map(|op| (op.path.to_string(), op.method.to_string())) + .collect(); + + let missing: Vec<_> = registered.difference(&published).collect(); + let extra: Vec<_> = published.difference(®istered).collect(); + assert!( + missing.is_empty() && extra.is_empty(), + "the contract and src/main.rs disagree about the /api surface (#3325).\n\ + registered but not in openapi.json: {missing:#?}\n\ + in openapi.json but not registered: {extra:#?}\n\ + add the row to operations() in src/openapi.rs, then run \ + `cargo run --bin openapi > openapi.json`.", + ); + } + + /// The document has to be loadable, not merely equal to itself. A + /// path template whose parameter was never declared, an operation + /// with no responses, or a secured operation that forgot its 401 all + /// pass the two gates above -- and then leave a fuzzer doing nothing + /// useful against it. + #[test] + fn document_is_well_formed() { + let document = document(); + assert_eq!(document["openapi"], json!("3.1.0")); + assert!(document["info"]["title"].is_string()); + assert!(document["info"]["version"].is_string()); + assert_eq!( + document["components"]["securitySchemes"]["serviceToken"]["name"], + json!("X-Service-Token"), + ); + + let paths = document["paths"].as_object().expect("paths is an object"); + assert!(paths.len() > 100, "only {} paths", paths.len()); + + let mut operations_seen = 0usize; + for (path, item) in paths { + let captures = capture_names(path); + for (method, operation) in item.as_object().expect("path item is an object") { + operations_seen += 1; + assert!( + operation["summary"].is_string(), + "{method} {path} has no summary" + ); + + let responses = operation["responses"] + .as_object() + .unwrap_or_else(|| panic!("{method} {path} declares no responses")); + assert!(!responses.is_empty(), "{method} {path} declares no response"); + + let secured = operation.get("security").is_some(); + assert_eq!( + secured, + responses.contains_key("401"), + "{method} {path}: a secured operation must declare its 401, or \ + the auth tier is not actually in the contract" + ); + assert_eq!( + secured, + !(path.starts_with("/healthz") || path.starts_with("/metrics")), + "{method} {path}: /healthz and /metrics are the only routes outside \ + the service-token tier (main.rs)" + ); + + // Every `Json` body can be refused by the extractor + // before the handler runs, and the fuzzer sends exactly + // the bodies that trip it. `body()` adds these, so this + // only fires if a row starts declaring a body some other + // way. + if operation.get("requestBody").is_some() { + for status in ["415", "422"] { + assert!( + responses.contains_key(status), + "{method} {path} takes a JSON body but does not declare {status} \ + (axum's Json rejection)" + ); + } + } + + // Same for a `Query` extractor, which answers 400 as + // text/plain. `with_query` adds it. One direction only: + // a row may declare 400 for its handler's own reasons + // with no query parameters at all (the artifact download + // refuses a path separator that way). + let takes_query = operation + .get("parameters") + .and_then(Value::as_array) + .is_some_and(|parameters| { + parameters + .iter() + .any(|parameter| parameter["in"] == json!("query")) + }); + assert!( + !takes_query || responses.contains_key("400"), + "{method} {path}: an operation with query parameters must declare the \ + 400 that axum's Query rejection raises" + ); + + let parameters = operation["parameters"] + .as_array() + .cloned() + .unwrap_or_default(); + let declared: BTreeSet = parameters + .iter() + .filter(|p| p["in"] == json!("path")) + .map(|p| p["name"].as_str().unwrap_or_default().to_string()) + .collect(); + assert_eq!( + declared, captures, + "{method} {path}: the declared path parameters must match the \ + template's {{captures}} exactly" + ); + for parameter in ¶meters { + assert!( + matches!( + parameter["in"].as_str(), + Some("query") | Some("path") | Some("header") + ), + "{method} {path}: parameter {} has no location", + parameter["name"], + ); + } + + if method != "get" { + if let Some(body) = operation.get("requestBody") { + assert!( + body["content"]["application/json"]["schema"].is_object(), + "{method} {path}: its request body has no schema" + ); + } + } + } + } + assert!(operations_seen > 130, "only {operations_seen} operations"); + } + + /// The enum the contract advertises for `/api/v1/store/{name}` has + /// to be the allowlist the handler enforces, or the fuzzer is being + /// told a route exists that answers 404 for every value it generates. + /// The list is copied from `stores.rs`, so this test is what keeps + /// the copy honest. + #[test] + fn the_store_enum_matches_the_handside_allowlist() { + let stores_rs = + std::fs::read_to_string(crate_dir().join("src/stores.rs")).expect("src/stores.rs"); + let allowlist_start = stores_rs + .find("fn store_config") + .expect("store_config is still the allowlist"); + let allowlist_end = allowlist_start + + stores_rs[allowlist_start..] + .find("\n}\n") + .expect("store_config has a closing brace"); + let allowlist = &stores_rs[allowlist_start..allowlist_end]; + + let mut from_code: Vec<&str> = allowlist + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + let rest = trimmed.strip_prefix('"')?; + let end = rest.find('"')?; + let name = &rest[..end]; + (trimmed.contains("=>") + && name.chars().all(|c| c.is_ascii_lowercase() || c == '-')) + .then_some(name) + }) + .collect(); + from_code.sort_unstable(); + + let mut from_contract: Vec<&str> = STORE_NAMES.to_vec(); + from_contract.sort_unstable(); + assert_eq!( + from_contract, from_code, + "the contract's store enum has drifted from stores.rs's store_config \ + allowlist (#3325)" + ); + } + + fn capture_names(path: &str) -> BTreeSet { + let mut names = BTreeSet::new(); + let mut rest = path; + while let Some(open) = rest.find('{') { + let after = &rest[open + 1..]; + let Some(close) = after.find('}') else { break }; + names.insert(after[..close].to_string()); + rest = &after[close + 1..]; + } + names + } + + /// The `(path, method)` pairs main.rs registers, read out of the + /// source rather than a hand-kept list. A third list would be a + /// third thing to update, and the point of the gate is that the + /// router stays the thing that decides what exists. + /// + /// The scan tracks paren depth, so a method name is only read where + /// it sits in the route's own argument list. That is what keeps + /// `get(preferences::get).put(preferences::put)` from reading as + /// four methods instead of two, and `axum::routing::put(...)` -- + /// where the name arrives after `::` -- from reading as none. + fn router_routes(source: &str) -> Vec<(String, String)> { + const METHODS: [&str; 5] = ["get", "post", "put", "delete", "patch"]; + let bytes = source.as_bytes(); + let mut found: Vec<(String, String)> = Vec::new(); + let mut cursor = 0usize; + + while let Some(offset) = source[cursor..].find(".route(") { + let open = cursor + offset + ".route(".len(); + let mut depth = 1i32; + let mut end = open; + while depth > 0 { + match bytes[end] { + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + end += 1; + } + let body = &source[open..end - 1]; + cursor = end; + + let Some(first_quote) = body.find('"') else { continue }; + let after = &body[first_quote + 1..]; + let Some(closing) = after.find('"') else { continue }; + let path = &after[..closing]; + + for method in methods_in(&after[closing + 1..], &METHODS) { + // axum spells these lowercase (`get(handler)`); the + // contract spells them the way OpenAPI does (`GET`). + // Normalize here so the comparison below is two + // vocabularies rather than a wall of case mismatches. + found.push((path.to_string(), method.to_ascii_uppercase())); + } + } + found + } + + fn methods_in<'a>(arguments: &str, methods: &'a [&'a str]) -> Vec<&'a str> { + let bytes = arguments.as_bytes(); + let mut found: Vec<&'a str> = Vec::new(); + let mut depth = 0i32; + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + if depth == 0 { + for method in methods.iter().copied() { + if !arguments[index..].starts_with(method) { + continue; + } + // A method name is a whole token: the character in + // front of it is not part of a longer identifier. + let before = if index == 0 { b' ' } else { bytes[index - 1] }; + if before.is_ascii_alphanumeric() || before == b'_' { + continue; + } + let after = &arguments[index + method.len()..]; + let call = after.trim_start(); + if call.starts_with('(') { + found.push(method); + // Step over the call so its arguments -- and any + // identifier inside them that merely ends in a + // method name -- are not rescanned. `call` is a + // slice of `arguments` offset by the leading + // whitespace, so the `(` is this far in; the + // balance starts at zero and the `(` itself is + // counted, which stops the walk exactly after + // this method's own closing paren. That is what + // leaves a chained `.post(...)` to be read. + let open = index + method.len() + (after.len() - call.len()); + let mut inner = 0i32; + let mut scan = open; + // `open` is the `(`, so the balance only returns + // to zero once this method's own call is + // closed -- leaving a chained `.post(...)` at + // depth zero for the outer loop to read. An + // unbalanced tail stops on the end of the input + // rather than looping forever. + while scan < bytes.len() { + match bytes[scan] { + b'(' => inner += 1, + b')' => inner -= 1, + _ => {} + } + scan += 1; + if inner == 0 { + break; + } + } + index = scan; + } + break; + } + } + index += 1; + } + found + } + + /// "Here is where they stop matching", so a stale contract names the + /// operation that moved instead of dumping both documents. + fn first_difference(left: &Value, right: &Value) -> String { + fn walk(left: &Value, right: &Value, at: &str) -> Option { + if left == right { + return None; + } + match (left, right) { + (Value::Object(l), Value::Object(r)) => { + for (key, value) in l { + match r.get(key) { + None => return Some(format!("{at}/{key}: only in openapi.json")), + Some(other) => { + if let Some(found) = walk(value, other, &format!("{at}/{key}")) { + return Some(found); + } + } + } + } + for key in r.keys() { + if !l.contains_key(key) { + return Some(format!("{at}/{key}: missing from openapi.json")); + } + } + None + } + _ => Some(format!("{at}: openapi.json has {left}, the generator has {right}")), + } + } + walk(left, right, "").unwrap_or_else(|| "no obvious difference".to_string()) + } +} diff --git a/docs/CI-CD.md b/docs/CI-CD.md index 942f4a7f..988baeba 100644 --- a/docs/CI-CD.md +++ b/docs/CI-CD.md @@ -326,6 +326,67 @@ the gate itself already encodes. The script is covered by `scripts/tests/test_ci_lane_summary.py`, which runs in the `scripts-and-compose` matrix's `scripts/tests suite` row. +## The `/api` contract and its weekly fuzz job (#3325) + +`backend-service` publishes an OpenAPI 3.1 contract at +`arcane/home/honeypot-dashboard/backend-service/openapi.json`, covering all +129 registered `/api` paths (138 operations). It is generated, not +hand-edited: the source of truth is the operation table in +`arcane/home/honeypot-dashboard/backend-service/src/openapi.rs`, rendered by + +```sh +cd arcane/home/honeypot-dashboard/backend-service +cargo run --bin openapi > openapi.json +``` + +**Three gates keep it honest, and they fail in different directions on +purpose:** + +- **`cargo test`** runs two drift tests in `src/openapi.rs`. + `contract_covers_every_router_route` reads `src/main.rs` and fails if the + router and the contract disagree about which `(path, method)` pairs exist + — a route added without a contract row is the direction that rots, since + it silently drops a fuzz target. `checked_in_contract_is_current` fails + if the committed `openapi.json` is not what the module renders. +- **`OpenAPI contract is not stale (#3325)`** in both `quality.yml` backend + jobs runs the same generator as a `diff -u`, because a test failure says + "stale" while the diff says what changed. +- **`weekly-schemathesis.yml`** runs Mondays at 04:23 UTC and on + `workflow_dispatch` (`max_examples` and `base_url` are both inputs). It + boots the service against a single-node Elasticsearch service container — + without a cluster every ES-backed route answers 502 and the run measures + nothing — and makes three passes: + + - `scripts/check-api-auth-tier.py` is the **only** step that can fail the + job. Every operation the contract secures must answer 401/403 with no + token, and `/healthz` and `/metrics` must not. It is a script and not + schemathesis's `ignored_auth` check because `ignored_auth` skips any + operation the contract declares public: demoting a live route in the + document turns the check green while the route keeps serving + anonymous callers. Run it against any deployment with + `python3 scripts/check-api-auth-tier.py --base-url http://host:8081`. + - two `schemathesis run` passes, unauthenticated and with a fixture + service token, both `continue-on-error: true` and both reporting into + uploaded JUnit artifacts. These are **advisory**: this API validates + aggressively and returns 400 for inputs no schema can distinguish from + nonsense, so a hard gate would be red on its first run and train + everyone to ignore it. The value is the standing record, so a change in + the record is visible. + + The pass reports its finding classes by name, and the two that indict the + *document* rather than the service — `Undocumented HTTP status code` and + `Undocumented Content-Type` — are the number worth watching. Each of + those was a real omission in the first version of the contract: `415` and + `422` from axum's `Json` rejection on all 25 body routes, `400` from + its `Query` rejection, four `services` routes answering JSON errors + declared as `text/plain`, and a `text/plain` export declared as JSON. + Fix those by editing the row in `operations()` and regenerating — never + by suppressing the output. + +`/api/v1/live` is excluded from the fuzz passes and skipped by the auth +script, and the contract marks it `x-endless-stream: true`. Its body never +ends, so probing it on a service with auth open would hang the run instead +of reporting the leak. ## Pull request workflow diff --git a/scripts/check-api-auth-tier.py b/scripts/check-api-auth-tier.py new file mode 100644 index 00000000..1db018d0 --- /dev/null +++ b/scripts/check-api-auth-tier.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Fail when a running backend-service does not enforce the auth tier its +OpenAPI contract claims (#3325). + +The contract publishes a `security` block on every operation outside the +service-token tier, and openapi.rs's own test asserts the public set is +exactly /healthz and /metrics. That proves the *document* is internally +consistent -- it says nothing about whether the middleware agrees. This +asks the running service, over HTTP, with no token at all: every secured +operation must answer 401/403, and every public operation must not. + +That second half is the reason this exists as a script and not as a +schemathesis check. `ignored_auth` skips any operation the contract +declares public, so marking a live /api/v1 route public makes the check +silently pass while the route keeps serving unauthenticated callers -- +verified on this very service, where demoting /api/v1/events produced a +green run and one warning. A gate that can be switched off by editing the +document it is supposed to police is not a gate. Deriving the expected +answer from the document but never trusting it about *which* operations +are secured is the whole design: a leak shows up as a 200 here, and a +route wrongly demoted in the document trips openapi.rs's test instead. + +Stdlib only, matching the other checkers in this directory. + +Usage: + python scripts/check-api-auth-tier.py --base-url http://127.0.0.1:8081 + python scripts/check-api-auth-tier.py --base-url ... \\ + --contract arcane/home/honeypot-dashboard/backend-service/openapi.json + +Exits non-zero on any mismatch, naming the operation and the status it +answered instead. +""" +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CONTRACT = ROOT / "arcane/home/honeypot-dashboard/backend-service/openapi.json" +TIMEOUT = 15 +WORKERS = 8 + + +def parameters_of(item: dict, operation: dict) -> list[dict]: + """Every parameter that applies to one operation. + + OpenAPI allows `parameters` on the Path Item as well as on the + Operation, and an operation's own entry wins where a name repeats. + This document puts them all on the operation, but reading only the + path item is a silent no-op: every path parameter would go out as a + literal `{id}`, which the router does not match, and the probe would + report a 404 as an auth failure. + """ + merged: dict[tuple[str, str], dict] = {} + for source in (item.get("parameters") or [], operation.get("parameters") or []): + if isinstance(source, dict): # a $ref is not resolvable offline + continue + for parameter in source: + if isinstance(parameter, dict) and "name" in parameter: + merged[(parameter.get("in", ""), parameter["name"])] = parameter + return list(merged.values()) + + +def sample_path(path: str, parameters: list[dict]) -> str: + """Fill every `{param}` in a path template with something the router + will match. + + The value is irrelevant to the verdict -- require_service_token is a + Router-wide layer, so it answers before the handler, before extractors, + and even for a path that matches no route at all (verified: an unknown + /api/v1 path is 401, not 404). It is built from the parameter's own + declared schema anyway so this script keeps working if the middleware + is ever moved inward to a per-route layer, where a bad substitution + would answer 404 and read as a leak. + """ + for parameter in parameters: + name = parameter.get("name") + if not name or parameter.get("in") != "path" or name not in path: + continue + schema = parameter.get("schema") or {} + if "enum" in schema and schema["enum"]: + value = str(schema["enum"][0]) + elif schema.get("type") in ("integer", "number"): + value = "1" + else: + value = "check-api-auth-tier" + path = path.replace("{" + name + "}", value) + return path + + +def request(base_url: str, method: str, path: str, headers: dict[str, str]): + """(status, body) for one call. A connection error is a status of 0: + the service not being up is a failure of the run, not a pass.""" + url = base_url.rstrip("/") + path + req = urllib.request.Request(url, method=method) + for name, value in headers.items(): + req.add_header(name, value) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as response: + return response.status, response.read(2048) + except urllib.error.HTTPError as error: + return error.code, error.read(2048) + except urllib.error.URLError as error: + return 0, str(error.reason).encode() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", required=True, help="a running backend-service") + parser.add_argument("--contract", type=Path, default=DEFAULT_CONTRACT) + args = parser.parse_args() + + contract = json.loads(args.contract.read_text()) + secured, public, skipped = [], [], [] + for path, item in contract.get("paths", {}).items(): + for method, operation in item.items(): + if method in ("parameters", "summary", "description"): + continue + # Every required header the contract declares, so the probe + # measures the *token* tier and nothing else. The Workbench's + # six actor-gated routes answer their own JSON 401 when + # X-Actor-Username is absent, which is indistinguishable from + # the middleware refusing a valid caller -- so without this, + # a service with auth wide open still passes on exactly the + # routes an operator would most want checked. Only required + # headers: an optional one (If-Match) is the caller's choice, + # and sending it would move the probe onto a different status. + headers = { + parameter["name"]: "check-api-auth-tier" + for parameter in parameters_of(item, operation) + if parameter.get("in") == "header" and parameter.get("required") + } + if operation.get("x-endless-stream"): + # /api/v1/live is a text/event-stream that never ends. On a + # service enforcing auth it answers 401 before a byte is + # written, but on the very service this script exists to + # catch -- one serving unauthenticated callers -- it answers + # 200 and then holds the socket open forever, so probing it + # would hang the run instead of reporting the leak. The + # contract marks it for exactly this; the fuzz job excludes + # it for the same reason. + skipped.append((method.upper(), path)) + continue + (secured if operation.get("security") else public).append( + ( + method.upper(), + sample_path(path, parameters_of(item, operation)), + path, + headers, + ) + ) + + if not secured: + print(f"{args.contract} declares no secured operations -- refusing to pass", file=sys.stderr) + return 1 + print(f"{len(secured)} secured, {len(public)} public, per {args.contract.name}") + for _, path in skipped: + print(f"skipping {path}: marked x-endless-stream (the body never ends)") + + def probe(entry): + method, concrete, template, headers = entry + return entry, request(args.base_url, method, concrete, headers) + + failures = [] + with ThreadPoolExecutor(max_workers=WORKERS) as pool: + for (_, _, template, _), (status, body) in pool.map(probe, secured): + if status not in (401, 403): + failures.append( + f" {template}\n served an unauthenticated request: " + f"HTTP {status} {body[:120]!r}" + ) + + # The other direction. A route the contract calls public that + # answers 401 is not an auth leak, but it means the document and + # the service disagree about who may call what, which is the same + # defect one tier over -- and a public route is the one an + # operator is most likely to hardcode into a health probe. + for (_, _, template, _), (status, body) in pool.map(probe, public): + if status in (401, 403): + failures.append( + f" {template}\n is published as public but answered " + f"HTTP {status} {body[:120]!r}" + ) + + if failures: + unreachable = sum(1 for failure in failures if "HTTP 0 " in failure) + if unreachable == len(failures): + print( + f"\n{unreachable} probes could not reach {args.base_url} at all " + "(connection refused or timed out)." + ) + print("That is the service being down, not an auth finding -- the run") + print("never tested anything. Start backend-service and try again.") + return 1 + print("\nauth tier does not match the contract:") + print("\n".join(failures)) + return 1 + print( + f"every secured operation refused the unauthenticated request, and all " + f"{len(public)} public operations answered without a token" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f7807c5a0a10e0cc6cff987adfa0a94a74e41591 Mon Sep 17 00:00:00 2001 From: Xore Date: Sun, 27 Sep 2026 11:16:08 +0200 Subject: [PATCH 2/6] fix(contract): cover /livez and /readyz, and satisfy the zizmor gate (#3325) The rebase onto #3388 pulled in main's /livez and /readyz, which the contract test caught as registered-but-undocumented. Documented both, and widened the public-route invariant in the same test to name all four public routes instead of prefix-matching two. Zizmor: this branch introduces weekly-schemathesis.yml, which #3388 never saw, so it kept floating action tags and expanded a workflow_dispatch input inline into a run block. Pin both actions and route max_examples through env like every other value in that run block. --- .github/workflows/weekly-schemathesis.yml | 12 +++-- .../backend-service/openapi.json | 44 +++++++++++++++++++ .../backend-service/src/openapi.rs | 15 +++++-- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/.github/workflows/weekly-schemathesis.yml b/.github/workflows/weekly-schemathesis.yml index 8496d760..7c0bdee6 100644 --- a/.github/workflows/weekly-schemathesis.yml +++ b/.github/workflows/weekly-schemathesis.yml @@ -114,8 +114,12 @@ jobs: LISTEN_ADDR: 127.0.0.1:8081 ELASTICSEARCH_URL: http://127.0.0.1:9200 BASE_URL: http://127.0.0.1:8081 + # Through env, not ${{ inputs.* }} inline: a workflow_dispatch input is + # attacker-influenced text and zizmor is right that expanding it into a + # run block is code injection (audit: template-injection). + MAX_EXAMPLES: ${{ inputs.max_examples }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install the pinned schemathesis run: | @@ -203,7 +207,7 @@ jobs: schemathesis run openapi.json \ --url "${BASE_URL}" \ --phases coverage,fuzzing \ - --max-examples "${{ inputs.max_examples }}" \ + --max-examples "${MAX_EXAMPLES}" \ --exclude-path '/api/v1/live' \ --checks all \ --report junit --report-dir "${RUNNER_TEMP}/report-unauthenticated" \ @@ -221,7 +225,7 @@ jobs: -H "X-Service-Token: ${SERVICE_TOKEN}" \ -H "X-Actor-Username: schemathesis" \ --phases coverage,fuzzing \ - --max-examples "${{ inputs.max_examples }}" \ + --max-examples "${MAX_EXAMPLES}" \ --exclude-path '/api/v1/live' \ --exclude-path '/metrics' \ --report junit --report-dir "${RUNNER_TEMP}/report-authenticated" \ @@ -267,7 +271,7 @@ jobs: # quotes. upload-artifact resolves paths against the workspace # root, not this job's working-directory. if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v6 with: name: schemathesis-reports path: | diff --git a/arcane/home/honeypot-dashboard/backend-service/openapi.json b/arcane/home/honeypot-dashboard/backend-service/openapi.json index 76da710d..15fd55f0 100644 --- a/arcane/home/honeypot-dashboard/backend-service/openapi.json +++ b/arcane/home/honeypot-dashboard/backend-service/openapi.json @@ -10584,6 +10584,26 @@ ] } }, + "/livez": { + "get": { + "operationId": "get_livez", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + } + }, + "summary": "Liveness. The same handler as /healthz under a second name (main.rs); public like it.", + "tags": [ + "other" + ] + } + }, "/metrics": { "get": { "operationId": "get_metrics", @@ -10603,6 +10623,26 @@ "platform" ] } + }, + "/readyz": { + "get": { + "operationId": "get_readyz", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Success." + } + }, + "summary": "Readiness: Elasticsearch reachable and this tier's write targets checked. Public like /healthz.", + "tags": [ + "other" + ] + } } }, "servers": [ @@ -10644,6 +10684,10 @@ "description": "Operator housekeeping: block lists, service accounts, canary tokens, problem reports.", "name": "operations" }, + { + "description": "Routes this service exposes that do not fit the groups above; grouped so nothing is unrouted.", + "name": "other" + }, { "description": "The dashboard's own view of the service: landing overview and KPIs, health, source and pipeline health, and platform settings.", "name": "platform" diff --git a/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs b/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs index 4fc8cc95..739238ed 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs @@ -1091,6 +1091,12 @@ fn operations() -> Vec { .public() .ok() .err(502), + op("GET", "/livez", "Liveness. The same handler as /healthz under a second name (main.rs); public like it.") + .public() + .ok(), + op("GET", "/readyz", "Readiness: Elasticsearch reachable and this tier's write targets checked. Public like /healthz.") + .public() + .ok(), op("GET", "/metrics", "Prometheus exposition for the #1972 request metrics. Public on purpose, like /healthz.") .public() .ok_media("text/plain", "Prometheus text exposition format."), @@ -1535,9 +1541,12 @@ mod tests { ); assert_eq!( secured, - !(path.starts_with("/healthz") || path.starts_with("/metrics")), - "{method} {path}: /healthz and /metrics are the only routes outside \ - the service-token tier (main.rs)" + !matches!( + path.as_str(), + "/healthz" | "/livez" | "/readyz" | "/metrics" + ), + "{method} {path}: /healthz, /livez, /readyz and /metrics are the only \ + routes outside the service-token tier (main.rs)" ); // Every `Json` body can be refused by the extractor From d135c23daba75355679e6aed7e1a469f38066e0f Mon Sep 17 00:00:00 2001 From: Xore Date: Sun, 27 Sep 2026 12:41:08 +0200 Subject: [PATCH 3/6] refactor(backend-service): move the handler modules and route table into the library being transcribed by hand, and that needs a shape the crate did not have: `#[utoipa::path]` annotations live on the handlers, and the second binary that prints the document cannot see a first binary's modules. So the modules move into `src/lib.rs`, which already existed for exactly this reason, and the route table moves with them so the document is generated from the same `Router` the process serves. The move is mechanical on purpose. No module, type or function is renamed or split, and the ~320 `crate::` references the handlers make resolve to the same items as before, because `AppState` and the probe handlers landed in the same place the crate root used to be. `main.rs` keeps what is genuinely a process: the environment, the #2183 boot gate, state construction, the listener. `allow_unauth_dev_from_env` becomes `pub` because `main.rs` now reaches it through the library rather than declaring it in the crate root. One test changes, and only in what it reads: `contract_covers_every_router_route` parsed `.route(...)` out of `src/main.rs`, which no longer holds the route table. It reads `src/lib.rs` now. Every assertion is unchanged -- including the `registered.len() > 100` guard that would have caught a stale path -- so the gate is exactly as strong, pointed at the file the code moved to. 544 passed, 0 failed, 1 ignored: same as before the move. `openapi.json` is byte-identical, which the checked_in_contract_is_current gate asserts. --- .../backend-service/src/lib.rs | 973 +++++++++++++++++- .../backend-service/src/main.rs | 928 +---------------- .../backend-service/src/openapi.rs | 17 +- 3 files changed, 985 insertions(+), 933 deletions(-) diff --git a/arcane/home/honeypot-dashboard/backend-service/src/lib.rs b/arcane/home/honeypot-dashboard/backend-service/src/lib.rs index 8104ab89..707f6f89 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/lib.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/lib.rs @@ -1,14 +1,967 @@ -//! The library half of the crate. +//! The library half of the crate: the handler modules, the shared state, +//! the /api route table, and the machine-readable /api contract (#3325). //! -//! The binary (`src/main.rs`) is the service: 80-odd handler modules, the -//! router, and the #2183 boot gate. None of that needs to be a library, -//! and making it one would be a large refactor with no payoff. +//! # Why the service is a library //! -//! What *does* need to be reachable from a second target is -//! [`openapi`] -- the machine-readable /api contract (#3325) and the -//! drift tests that keep it honest. Those run from `cargo test` and from -//! the `openapi` generator binary (`src/bin/openapi.rs`), which is why -//! the module lives here rather than in `main.rs`: a second binary cannot -//! see a first binary's modules. +//! This used to be the other way round. Every handler module was declared +//! from `main.rs`, on the reasoning that a service does not need to be a +//! library and making it one would be a large refactor with no payoff. The +//! refactor arrived anyway, for a reason the earlier note did not foresee: +//! to generate the #3325 contract with `utoipa`/`utoipa-axum` the +//! annotations have to sit on the handlers, and a second binary -- the +//! `openapi` generator -- cannot see a first binary's modules. So the +//! modules moved here, and the move is what turned the contract from a +//! hand-transcribed parallel table into a description of the router that +//! actually serves. +//! +//! The move is deliberately mechanical: no module was renamed, split or +//! reorganized, and the 300-odd `crate::` references the handlers make +//! resolve to exactly the same items they did when the crate root was +//! `main.rs`. What `main.rs` keeps is the part that is genuinely a +//! process: reading the environment, refusing to boot without a service +//! token (#2183), constructing the state, and binding the listener. + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{delete, get, patch, post}, + Json, Router, +}; +use serde::Serialize; +use std::sync::Arc; +pub mod aggregates; +pub mod artifacts; +pub mod attacker_identity; +pub mod audit; +pub mod canarytokens; +pub mod charts; +pub mod config; +pub mod config_history; +pub mod agent_intrusion; +pub mod campaign_correlator; +pub mod correlator; +pub mod correlations; +pub mod credentials; +pub mod criticality_rules; +pub mod dashboard; +pub mod decode_correlate; +pub mod detail; +pub mod es; +pub mod event_detail; +pub mod obs; +pub mod event_page; +pub mod es_importer; +pub mod events; +pub mod exports; +pub mod fusion; +pub mod ghidra_submit; +pub mod github_analysis_submit; +pub mod gpu_queue; +pub mod health; +pub mod isolate; +pub mod honeyfs_implant; +pub mod investigate; +pub mod ip_block; +pub mod ip_enrichment; +pub mod kill_chain; +pub mod live; +pub mod llm_search; +pub mod mail; +pub mod ml_health; +pub mod overview; +pub mod payload_bytes; +pub mod payload_detail; +pub mod payload_inventory; +pub mod payload_kind; +pub mod payload_paths; +pub mod payload_static_analysis; +pub mod preferences; +pub mod problem_reports; +pub mod replay; +pub mod report_pdf; +pub mod reports; +pub mod reports_api; +pub mod reports_data; +pub mod reports_store; +pub mod reporter_stats; +pub mod rollups; +pub mod sandbox_submit; +pub mod sensors; +pub mod search; +pub mod services_control; +pub mod session; +pub mod stores; +pub mod ics_severity; +pub mod ioc_correlation; +pub mod threat_intel; +pub mod topology; +pub mod webhook_delivery; +pub mod zeek_proxy_attribution; +pub mod worker; +pub mod vault_rag; +pub mod workbench_api; +pub mod workbench_domain; +pub mod workbench_es; +pub mod workbench_orchestrator; pub mod openapi; + +#[derive(Clone)] +pub struct AppState { + pub es: Arc, + pub service_token: Arc>, + pub audit: Arc, + pub config_history: Arc, + /// #1972: request metrics + where durable JSONL request lines land + /// (empty = durable shipping disabled; stdout tracing unaffected). + pub observability: Arc, +} + +/// /livez — the process is up and its HTTP stack is answering. Says nothing +/// about Elasticsearch, and must never ask it. +/// +/// This is the endpoint the container HEALTHCHECK curls on an interval, and +/// the reason it stays dependency-free is the whole point of #3317: a probe +/// that can block on Elasticsearch turns that dependency's outage into a +/// restart loop of a container which was never the thing that broke. The +/// old /healthz answered `{"ok": true, "es": }` from inside this +/// handler, so an ES outage showed up here as a slow or failed probe +/// instead of as an ES outage. +#[derive(Serialize)] +struct Liveness { + live: bool, + /// build.rs's compile stamp, so a probe can answer "is the running + /// binary newer than the merge" without a second round trip. Same + /// field main logs at boot. + built: String, + /// #3315: which revision of the repository this binary was compiled from, + /// or "unknown". Unauthenticated and deliberately so — this is the field + /// that turns "is the running binary newer than the merge?" from a manual + /// inference into a curl, and /livez is already the one open probe on this + /// service (the token middleware covers /api/v1 only, see + /// require_service_token). A git revision names no secret: it is the same + /// string the image carries in org.opencontainers.image.revision and that + /// ghcr shows on the tag. + /// + /// This is the *answer* where `built` is the *inference*: `built` can only + /// be compared against a time, and a rebuilt-from-old-commit image passes + /// that comparison while running month-old code. `revision` is an object + /// name, so it can be looked up — which is what + /// scripts/verify-deploy.sh does, and why it reads this field rather than + /// `built`. Both are here because both have a consumer, and neither is + /// derivable from the other after the fact. + revision: String, +} + +/// /readyz — Elasticsearch is reachable and this tier's own write targets +/// are not write-blocked, i.e. the backend can actually do its job rather +/// than merely be running. 503 plus a `reason` when it cannot. +/// +/// Unlike liveness this endpoint is allowed to fail, so it is the one +/// diagnostics and the #3315 deploy verifier probe: "the process is up" is +/// the wrong question during an ingest outage, and it is the only question +/// the old endpoint could ask. +#[derive(Serialize)] +struct Readiness { + ready: bool, + /// Present exactly when `ready` is false, and specific enough to act + /// on — "Elasticsearch is unreachable" and "these four indices are + /// write-blocked" send an operator to different pages. + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + /// green / yellow / red / unreachable. Reported even when ready, since + /// yellow is the ordinary shape of a replicated cluster and a probe + /// that only ever printed green would be no better than the constant + /// it replaces. + cluster: String, + /// The write-blocked members of `es::WRITE_TARGET_FAMILIES`. Always + /// present so a consumer can read one shape; named rather than counted, + /// because the point is to be able to act on which ones. + write_blocked: Vec, +} + +/// /readyz's own deadline, independent of the shared client's. +/// +/// es::connect gives the transport 30s, sized for real multi-second queries +/// rather than for a probe — and es.rs's own comment on that budget records +/// a /healthz that stopped responding because a worker loop's aggregation +/// saturated the search queue. A readiness answer somebody is waiting on +/// should arrive in seconds, and a probe that blocks for 30 is +/// indistinguishable from the outage it exists to report. +const READINESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// The one place readiness is decided, kept pure so its truth table is +/// testable without an Elasticsearch to ask — the same discipline as +/// `resolve_service_token` below, and for the same reason: the interesting +/// cases are the ones where two independent probes disagree about how bad +/// things are, and a test that needs a live cluster to reach them is a test +/// that does not get run. +/// +/// `cluster` and `write_blocked` are two Results rather than one tuple of +/// plain values so a partial failure is a case this function has to answer +/// for, instead of one a caller has to. +fn readiness_verdict( + cluster: anyhow::Result, + write_blocked: anyhow::Result>, +) -> Readiness { + // Unreachable outranks everything else. A write-block reading against a + // cluster we could not reach is not a fact, it is the absence of one, + // and reporting it as the cause would be a guess. + let cluster = match cluster { + Ok(status) => status, + Err(error) => { + return Readiness { + ready: false, + reason: Some(format!("elasticsearch is unreachable: {error}")), + cluster: "unreachable".to_string(), + write_blocked: Vec::new(), + } + } + }; + let blocked = match write_blocked { + Ok(blocked) => blocked, + Err(error) => { + return Readiness { + ready: false, + reason: Some(format!("elasticsearch refused the readiness probe: {error}")), + cluster, + write_blocked: Vec::new(), + } + } + }; + // Red means unassigned primaries, against which both reads and writes + // fail. Yellow means unassigned *replicas*, which is the ordinary shape + // of a replicated cluster during a rolling restart and costs this tier + // nothing — a red-only gate would go not-ready on every deploy. + if cluster == "red" { + return Readiness { + ready: false, + reason: Some("elasticsearch cluster health is red (unassigned primaries)".to_string()), + cluster, + write_blocked: blocked, + }; + } + if !blocked.is_empty() { + return Readiness { + ready: false, + reason: Some(format!( + "elasticsearch has index.blocks.write set on: {}. The flood-stage disk \ + watermark sets this on every index at once, and so does an operator's \ + `PUT //_block/write`; this endpoint cannot tell those apart, so \ + check _cat/allocation free space before concluding which one it is.", + blocked.join(", ") + )), + cluster, + write_blocked: blocked, + }; + } + Readiness { ready: true, reason: None, cluster, write_blocked: blocked } +} + +/// GET /livez, and GET /healthz — the same handler under two names. See +/// `Liveness` for why the response carries no Elasticsearch signal. +async fn livez() -> Json { + Json(Liveness { live: true, built: build_stamp(), revision: git_revision() }) +} + +/// `/healthz` is the name the image's HEALTHCHECK, the port-test harness +/// and the ops scripts already use, so it stays as an alias rather than +/// being broken (killing a 2024-era name in a health-probe rename is how a +/// stack ends up reporting permanently unhealthy with nothing wrong). It +/// used to answer `{"ok": true, "es": }` where `ok` was the constant +/// #3317 is about; the `es` half of that answer now lives on /readyz, which +/// can say no. +async fn healthz() -> Json { + livez().await +} + +/// GET /readyz — see `Readiness`. Unauthenticated exactly like the liveness +/// probes and /metrics, because the callers are infrastructure: the deploy +/// verifier, diagnostics, and an operator on a jump host. Authentication +/// would not make it safer here, only less answerable. +async fn readyz(State(state): State) -> (StatusCode, Json) { + // Both probes in flight together: they are independent round trips and + // the endpoint's whole value is being quick to answer. The async block + // is what makes the pair a single future the deadline can wrap -- + // `join!` on its own expands to the values, not to something awaitable. + let probes = tokio::time::timeout(READINESS_TIMEOUT, async { + tokio::join!( + state.es.cluster_health_status(), + state.es.write_blocked(es::WRITE_TARGET_FAMILIES), + ) + }) + .await; + let (cluster, write_blocked) = match probes { + Ok(probes) => probes, + Err(_elapsed) => { + // Both halves report the deadline, because a probe pair that + // timed out established nothing about either question. The + // verdict resolves the cluster half as unreachable; this one + // exists so the pair stays a pair of Results rather than a + // Result of a pair, and its text is never what gets reported. + let expired = || anyhow::anyhow!("no answer within {}s", READINESS_TIMEOUT.as_secs()); + (Err(expired()), Err(expired())) + } + }; + let readiness = readiness_verdict(cluster, write_blocked); + let status = if readiness.ready { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + tracing::debug!(ready = readiness.ready, cluster = %readiness.cluster, "readyz"); + (status, Json(readiness)) +} + +/// A boot refusal carries the code the cutover doc and dashboards grep +/// for, plus the exact remedy — the whole point of #2183 is that a +/// misconfigured instance explains itself instead of silently opening +/// every route. `std::fmt::Display` rather than deriving Debug on an enum: +/// anyhow prints this through `Error: {}` at exit, one line, no nesting. +#[derive(Debug)] +pub struct ServiceTokenRefusal { + message: String, +} + +impl ServiceTokenRefusal { + fn new() -> Self { + Self { + message: concat!( + "[E-SERVICE-TOKEN] refusing to start: SERVICE_TOKEN is unset or empty, ", + "which would leave every /api/v1 route open to unauthenticated requests ", + "(the BFF proxy tier mirrors this check). ", + "Set SERVICE_TOKEN to a shared secret — see docs/DASHBOARD-CUTOVER.md step 2 — ", + "or, for local development only, set APIARY_ALLOW_UNAUTH_DEV=1 explicitly (#2183).", + ) + .to_string(), + } + } +} + +impl std::fmt::Display for ServiceTokenRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ServiceTokenRefusal {} + +/// The single decision behind #2183's boot gate, kept pure so tests can pin +/// its truth table without env-var races between parallel test threads. +/// +/// - Ok(Some(token)) — a real token is configured; require_service_token +/// enforces it below. +/// - Ok(None) — SERVICE_TOKEN unset/empty AND APIARY_ALLOW_UNAUTH_DEV=1: +/// an explicitly opted-in unauthenticated dev instance, announced loudly. +/// - Err — otherwise: main refuses before binding, replacing #2044's +/// warn-only posture (a warning sat next to a listen socket that silently +/// accepted everything). +/// +/// The override never weakens a configured token: with both set, the token +/// wins and the middleware enforces it as usual. +pub fn resolve_service_token( + service_token: Option<&str>, + allow_unauth_dev: bool, +) -> Result, ServiceTokenRefusal> { + match service_token.filter(|token| !token.is_empty()) { + Some(token) => Ok(Some(token)), + None if allow_unauth_dev => Ok(None), + None => Err(ServiceTokenRefusal::new()), + } +} + +/// Exactly "1" enables the override — no truthiness zoo where someone's +/// `APIARY_ALLOW_UNAUTH_DEV=0` or `=false` quietly reads as consent. +pub fn allow_unauth_dev_from_env(raw: Option<&str>) -> bool { + raw == Some("1") +} + +/// Every /api/v1 route requires the BFF's service token (constant-time +/// comparison; header X-Service-Token). /healthz stays open for the +/// container healthcheck, same as the Go dashboard's -healthcheck probe. +async fn require_service_token( + State(state): State, + headers: HeaderMap, + request: axum::extract::Request, + next: Next, +) -> Response { + if let Some(expected) = state.service_token.as_ref() { + let presented = headers + .get("x-service-token") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let expected = expected.as_bytes(); + let presented = presented.as_bytes(); + let mut diff = expected.len() ^ presented.len(); + for i in 0..expected.len().min(presented.len()) { + diff |= (expected[i] ^ presented[i]) as usize; + } + if diff != 0 { + return (StatusCode::UNAUTHORIZED, "service token required").into_response(); + } + } + next.run(request).await +} + + +/// When this binary was compiled, as RFC 3339, or "unknown". +/// +/// Set by build.rs. `option_env!` rather than `env!` on purpose: the first +/// attempt at this used `env!` and broke the image build outright, because +/// the Dockerfile copies Cargo.toml and src but did not copy build.rs, so +/// cargo never ran it. That is fixed, but the failure mode should not be a +/// dead build for a diagnostic field -- and it must not be a lie either, so +/// an absent stamp reads as "unknown" rather than as a plausible time. +pub fn build_stamp() -> String { + let Some(raw) = option_env!("APIARY_BUILD_EPOCH") else { + return "unknown".to_string(); + }; + match raw.parse::() { + Ok(epoch) => chrono::DateTime::from_timestamp(epoch, 0) + .map(|when| when.to_rfc3339()) + .unwrap_or_else(|| raw.to_string()), + Err(_) => raw.to_string(), + } +} + +/// The honest answer when no revision was baked in. Same word build_stamp +/// uses, deliberately: a value that reads as a plausible time or a plausible +/// object name is worse than one that says nothing. +pub const REVISION_UNKNOWN: &str = "unknown"; + +/// A git object name is 7-64 hex characters, optionally `sha256:`-prefixed +/// (git's own object-format naming) — anything else is not a revision. +/// +/// This is a filter, not a format preference. `APIARY_GIT_SHA` arrives from a +/// `docker build --build-arg`, and a value that is not an object name is +/// either a mistake or something injected; either way it must not be echoed +/// back out of /healthz verbatim and read as "this is the deployed commit". +/// Case is normalized because GitHub, `git rev-parse` and the OCI label +/// convention each spell it differently, and a spelling difference must not +/// read as a deployed-revision mismatch. +pub fn normalize_revision(raw: &str) -> String { + let candidate = raw.strip_prefix("sha256:").unwrap_or(raw); + if (7..=64).contains(&candidate.len()) && candidate.bytes().all(|b| b.is_ascii_hexdigit()) { + candidate.to_ascii_lowercase() + } else { + REVISION_UNKNOWN.to_string() + } +} + +/// The revision this binary was compiled from, as the image build supplied it. +/// +/// `option_env!` for the same reason build_stamp() uses it: a missing stamp is +/// a diagnostic field, not a reason to fail a build. It is set unconditionally +/// by build.rs (an absent GIT_SHA becomes the empty string), so the None arm +/// only fires for a crate built by some path that skipped build.rs entirely — +/// which must read as "unknown", never as a guess. +pub fn git_revision() -> String { + match option_env!("APIARY_GIT_SHA") { + Some(raw) => normalize_revision(raw), + None => REVISION_UNKNOWN.to_string(), + } +} + +// The route table lives here rather than in `main.rs` for the same reason +// the modules do: `utoipa-axum` derives the OpenAPI document from the very +// same `Router` the service serves, so a second binary cannot reach a +// route table that only `main.rs` can see. `main.rs` still owns state +// construction and the listener. + +/// The /api/v1 surface: every route behind the BFF's service token. +/// +/// `state` is not a parameter because nothing here needs it: the token +/// middleware is layered on by [`router`] below, so the same table serves +/// the process and the contract generator. +pub fn api_router() -> Router { + Router::new() + .route("/api/v1/overview/kpis", get(overview::kpis)) + .route("/api/v1/overview/dashboard", get(dashboard::dashboard)) + .route("/api/v1/events", get(events::list)) + .route("/api/v1/export/events.csv", get(exports::events_csv)) + .route("/api/v1/export/commands.csv", get(exports::commands_csv)) + .route("/api/v1/export/ips.csv", get(exports::ips_csv)) + .route("/api/v1/export/campaigns.csv", get(exports::campaigns_csv)) + .route("/api/v1/export/clusters.csv", get(exports::clusters_csv)) + .route("/api/v1/export/history.json", get(exports::history_json)) + .route("/api/v1/live", get(live::stream)) + .route("/api/v1/mail/{session_id}", get(mail::get)) + .route("/api/v1/ml-health", get(ml_health::list)) + .route("/api/v1/gpu-queue", get(gpu_queue::list)) + .route("/api/v1/gpu-queue/{job_id}/abort", post(gpu_queue::abort)) + .route("/api/v1/sources", get(aggregates::sources)) + .route("/api/v1/filter-values", get(aggregates::filter_values)) + .route("/api/v1/investigate/ip/{ip}", get(investigate::ip)) + .route("/api/v1/investigate/cidr/{cidr}", get(investigate::cidr)) + .route("/api/v1/investigate/cluster", get(investigate::cluster)) + .route("/api/v1/source-health", get(health::source_health)) + // #3330: the alert fan-out's own delivery outcomes. Also a field on + // /api/v1/source-health; this route is what the Settings card + // reads, so the operations page's one-snapshot design is not the + // only way to get at it. + .route("/api/v1/webhook-delivery", get(webhook_delivery::health)) + .route("/api/v1/event/{id}", get(event_page::get)) + // #2047: materialized cross-sensor correlations — the event page's + // same-flow summary and the re-used-wordlist edges. + .route("/api/v1/event/{id}/connections", get(correlations::event_connections)) + .route("/api/v1/connections/{community_id}", get(correlations::flow_by_id)) + .route("/api/v1/cred-reuse", get(correlations::cred_reuse)) + .route("/api/v1/sensors", get(sensors::detail)) + // #1856: /catalog is registered before /{sensor} so the literal + // segment is not swallowed by the capture. A sensor genuinely + // named "catalog" would be shadowed; none is, and the alternative + // is a query parameter that reads worse for the common case. + .route("/api/v1/sensors/catalog", get(sensors::catalog)) + .route("/api/v1/sensors/{sensor}/events", get(sensors::events)) + .route("/api/v1/sensors/{sensor}/overview", get(sensors::overview)) + .route("/api/v1/sessions/{id}", get(session::detail)) + .route("/api/v1/search", get(search::search)) + .route("/api/v1/topology", get(topology::topology)) + .route("/api/v1/settings/storage", get(health::storage)) + .route("/api/v1/config", get(config::get_config)) + .route( + "/api/v1/config/presentation", + axum::routing::put(config::put_presentation), + ) + .route( + "/api/v1/config/{section}", + axum::routing::put(config::put_config_section), + ) + .route("/api/v1/config/history", get(config::history)) + .route("/api/v1/config/rollback", post(config::rollback)) + .route("/api/v1/config/validate", post(config::validate)) + .route("/api/v1/users", get(config::users)) + .route("/api/v1/audit", get(audit::list)) + .route( + "/api/v1/preferences", + get(preferences::get).put(preferences::put), + ) + .route("/api/v1/preferences/reset", post(preferences::reset)) + .route("/api/v1/reporter-stats", get(reporter_stats::stats)) + .route("/api/v1/services", get(services_control::list)) + .route("/api/v1/services/{name}/logs", get(services_control::logs)) + .route("/api/v1/services/{name}/{action}", post(services_control::action)) + .route("/api/v1/llm-search", get(llm_search::search)) + .route("/api/v1/vault-rag", get(vault_rag::ask)) + .route("/api/v1/ip-block", post(ip_block::set_block)) + .route("/api/v1/ip-block/{ip}", get(ip_block::get_block)) + .route("/api/v1/ip-block-export", get(ip_block::export)) + .route("/api/v1/sandbox/{job}", get(detail::sandbox_run)) + .route("/api/v1/ghidra/{sha}", get(detail::ghidra_run)) + .route("/api/v1/ghidra-callgraph/{sha}", get(detail::ghidra_callgraph)) + .route("/api/v1/revdeck/{sha}", get(detail::revdeck_run)) + .route("/api/v1/cape/{sha}", get(detail::cape_run)) + .route("/api/v1/cape/{sha}/raw", get(detail::cape_raw)) + .route("/api/v1/github-analysis/{sha}", get(detail::github_analysis_run)) + .route("/api/v1/attackers-graph", get(detail::attackers_graph)) + .route("/api/v1/attack-vectors", get(detail::attack_vectors)) + .route("/api/v1/ml-anomalies/ack", post(detail::ml_anomaly_ack)) + .route("/api/v1/ml-anomalies/ack-all", post(detail::ml_anomaly_ack_all)) + .route("/api/v1/ml-anomalies/acks", get(detail::ml_anomaly_acks)) + .route("/api/v1/ml-anomalies/stats", get(detail::ml_anomaly_stats)) + .route("/api/v1/ml-anomalies/disposition", post(detail::ml_anomaly_disposition)) + .route("/api/v1/reports/{id}/pdf", get(reports::pdf)) + // #1612 phase 4: Reports studio — template/element catalog, + // definitions CRUD, and on-demand generate. See reports_store.rs's + // module doc comment for the sandbox/payload/ghidra scope decision. + .route("/api/v1/reports/templates", get(reports_api::templates)) + .route( + "/api/v1/reports/definitions", + get(reports_api::list_definitions).post(reports_api::create_definition), + ) + .route( + "/api/v1/reports/definitions/{id}", + get(reports_api::get_definition) + .put(reports_api::replace_definition) + .delete(reports_api::delete_definition), + ) + .route("/api/v1/reports/definitions/{id}/generate", post(reports_api::generate)) + .route("/api/v1/reports/generated/{id}", delete(reports_api::delete_generated)) + .route("/api/v1/artifacts/{kind}/{key}", get(artifacts::list)) + .route("/api/v1/artifacts/{kind}/{key}/{filename}", get(artifacts::download)) + .route("/api/v1/charts/kill-chain-sankey", get(kill_chain::sankey)) + .route("/api/v1/charts/attck-coverage", get(kill_chain::attck_coverage)) + .route("/api/v1/charts/campaign-timeline", get(kill_chain::campaign_timeline)) + .route("/api/v1/charts/ml-backlog", get(charts::ml_backlog)) + .route("/api/v1/charts/netflow-bytes", get(charts::netflow_bytes)) + .route("/api/v1/charts/netflow-packets", get(charts::netflow_packets)) + .route("/api/v1/charts/anomaly-trend", get(charts::anomaly_trend)) + .route("/api/v1/charts/dionaea-cves", get(charts::dionaea_cves)) + .route("/api/v1/charts/os-distribution", get(charts::os_distribution)) + // #1727 §7: JA4T stack clusters, the successor to the p0f OS chart above. + .route("/api/v1/charts/tcp-stack-clusters", get(charts::tcp_stack_clusters)) + // #1736/#1739: two surfaces for data that currently has no view at all. + .route("/api/v1/charts/ics-functions", get(charts::ics_functions)) + .route("/api/v1/charts/decoy-requests", get(charts::decoy_requests)) + // #1765: the wire-tuple join in use -- Traefik requests meeting the + // ClientHello fingerprints only the passive sniffer can see. + .route("/api/v1/charts/decoy-client-fingerprints", get(charts::decoy_client_fingerprints)) + // #1729: the rest of the JA4+ family Zeek produces. + .route("/api/v1/charts/ja4h-fingerprints", get(charts::ja4h_fingerprints)) + .route("/api/v1/charts/ja4x-fingerprints", get(charts::ja4x_fingerprints)) + .route("/api/v1/charts/ja4l-fingerprints", get(charts::ja4l_fingerprints)) + .route("/api/v1/charts/tls-fingerprints", get(charts::tls_fingerprints)) + .route("/api/v1/charts/ssh-fingerprints", get(charts::ssh_fingerprints)) + .route("/api/v1/charts/endlessh-held-histogram", get(charts::endlessh_histogram)) + .route("/api/v1/charts/ml-anomaly-scores", get(charts::ml_anomaly_scores)) + .route("/api/v1/charts/attacker-fusion", get(fusion::fusion)) + .route("/api/v1/campaigns", get(stores::campaigns)) + .route("/api/v1/clusters", get(stores::clusters)) + .route("/api/v1/attackers", get(stores::attackers)) + // #2045: the raw evidence behind an attacker entity. + .route("/api/v1/attackers/{id}/events", get(attacker_identity::entity_events)) + .route("/api/v1/recordings", get(stores::recordings)) + .route("/api/v1/recordings/{shasum}", get(replay::replay)) + // #1711: the two download forms the Go tier served at + // /tty/.cast and .raw, which the port dropped. + .route("/api/v1/recordings/{shasum}/cast", get(replay::replay_cast)) + .route("/api/v1/recordings/{shasum}/raw", get(replay::replay_raw)) + .route("/api/v1/alerts", get(stores::alerts)) + .route("/api/v1/alerts/{key}/ack", post(stores::acknowledge)) + .route("/api/v1/canarytokens/types", get(canarytokens::types)) + .route("/api/v1/canarytokens", get(canarytokens::list).post(canarytokens::create)) + .route("/api/v1/canarytokens/{id}/download", get(canarytokens::download)) + // #1612 misc write paths: honeyfs-implant credential provisioning/ + // rotation (credentials_manager.go/credentials_api.go). Plain HTTP + // to a WireGuard-reachable URL, no host mount — same tier as + // canarytokens.rs above, not the mounted-worker-role service. + .route( + "/api/v1/credentials", + get(credentials::list).post(credentials::create), + ) + .route("/api/v1/credentials/{id}/rotate", post(credentials::rotate)) + .route("/api/v1/credentials/{id}/link-token", post(credentials::link_token)) + .route("/api/v1/payloads", get(stores::payloads)) + .route("/api/v1/payloads/{hash}", get(payload_detail::detail)) + .route("/api/v1/payloads/{hash}/raw", get(payload_detail::raw)) + // #474 one-click payload PDF (hp-payload-report.js): ephemeral + // payload-scoped report into the generated store, no saved + // definition. See reports_api::generate_payload_report. + .route("/api/v1/payloads/{hash}/report", post(reports_api::generate_payload_report)) + .route("/api/v1/store/{name}", get(stores::generic).delete(stores::generic_delete)) + .route("/api/v1/problem-reports", post(problem_reports::submit)) + .route("/api/v1/problem-reports/{id}", patch(problem_reports::patch_status)) + // #1612 mounted worker role (phase 3a): sandbox/ghidra/github- + // analysis submission + golden-image status. Registered in the + // same shared route table as everything else — which container + // these are actually reachable/useful on depends entirely on + // which compose service has the spool-dir mounts (backend-service- + // mounted), not on route registration here. + .route("/api/v1/sandbox/submit", post(sandbox_submit::submit)) + .route("/api/v1/sandbox/golden-image-status", get(sandbox_submit::golden_image_status)) + .route("/api/v1/sandbox/vnc", get(sandbox_submit::vnc_status)) + .route("/api/v1/ghidra/submit", post(ghidra_submit::submit)) + .route("/api/v1/github-analysis/submit", post(github_analysis_submit::submit)) + // #1612 phase 3b: Payload Workbench orchestrator (recipes, run + // creation/reconciliation, child cancel/retry). Same + // shared-route-table posture as phase 3a — only useful on + // backend-service-mounted, which has the write-capable spool mounts. + .route("/api/v1/workbench/analyzers", get(workbench_api::analyzers)) + .route( + "/api/v1/workbench/runs", + get(workbench_api::list_runs).post(workbench_api::create_run), + ) + .route("/api/v1/workbench/runs/{id}", get(workbench_api::get_run)) + .route( + "/api/v1/workbench/runs/{id}/children/{analyzer_id}/{action}", + post(workbench_api::child_action), + ) + .route( + "/api/v1/workbench/recipes", + get(workbench_api::list_recipes).post(workbench_api::save_recipe), + ) +} + +/// The unauthenticated routes: the two liveness names, readiness, and the +/// #1972 metrics scrape. They are a separate builder from [`api_router`] +/// because they are the only routes the token middleware does not cover. +pub fn public_router() -> Router { + Router::new() + .route("/livez", get(livez)) + .route("/healthz", get(healthz)) + .route("/readyz", get(readyz)) + // #1972: same listener, same internal-network posture as /healthz. + .route("/metrics", get(obs::metrics_route)) +} + +/// The whole service surface: the public routes, the token-gated /api +/// table, and the observability wrapper. This is what `main.rs` serves, +/// and its shape is unchanged from the one binary had before the move. +pub fn router(state: AppState) -> Router { + let api = api_router() + .layer(middleware::from_fn_with_state(state.clone(), require_service_token)); + + let app = Router::new() + // The public routes are declared first, exactly as `main.rs` + // declared them before the move; the token-gated /api table is + // merged in behind them. + .merge(public_router()) + .merge(api) + // #1972 observability wraps EVERYTHING above it — health probe, + // metrics scrape, and every /api/v1 route get a request id echoed + // in x-request-id, metrics recorded per family/status/latency, and + // one durable JSONL line when DASHBOARD_LOG_FILE is set. observe() + // reads its Obs handle via State, so this must be the + // _with_state form (plain from_fn builds FromFn<(), ..> whose + // Service bound never matches a state-taking extractor). + .layer(middleware::from_fn_with_state(state.clone(), obs::observe)) + .layer(tower_http::trace::TraceLayer::new_for_http()) + .with_state(state); + app +} + +#[cfg(test)] +mod build_stamp_tests { + use super::{build_stamp, git_revision, normalize_revision, REVISION_UNKNOWN}; + + #[test] + fn build_stamp_is_a_real_recent_timestamp() { + // The stamp exists to answer "is the running binary newer than the + // merge", so a value that does not parse, or that sits in 1970, + // would be worse than none: it reads as an answer. + let stamp = build_stamp(); + // "unknown" is an acceptable answer -- it is the honest one when the + // build script did not run. Anything else has to be a real time. + if stamp == "unknown" { + return; + } + let parsed = chrono::DateTime::parse_from_rfc3339(&stamp) + .unwrap_or_else(|error| panic!("build stamp {stamp:?} is not RFC 3339: {error}")); + + let now = chrono::Utc::now(); + let age = now.signed_duration_since(parsed.with_timezone(&chrono::Utc)); + assert!( + age.num_days() < 3650 && age.num_seconds() > -3600, + "build stamp {stamp} is not a plausible build time (age {age})", + ); + } + + // ---- #3315: the revision /healthz reports (lib.rs's `revision` field) ---- + + #[test] + fn a_full_object_name_survives_verbatim() { + assert_eq!(normalize_revision("3dca4457f1b2c0d4e5a69788796a5b4c3d2e1f0ab"), + "3dca4457f1b2c0d4e5a69788796a5b4c3d2e1f0ab"); + } + + /// The table is a file rather than a literal list so that the other + /// implementation of this rule -- dashboard-next's normalizeRevision, in + /// JavaScript, in a different CI lane -- can be driven from exactly the + /// same cases. `scripts/tests/test_3315_image_revision.py` runs both. + /// Written inline, the two lists would drift the first time either gained + /// a case, and the failure would be a deploy disagreement that is not one: + /// two tiers stamping different strings for the same build, which + /// scripts/verify-deploy.sh would report as a mismatch. + #[test] + fn the_shared_corpus_normalizes_as_the_other_tier_does() { + let corpus: serde_json::Value = + serde_json::from_str(include_str!("revision-corpus.json")).expect("corpus is valid JSON"); + let unknown = corpus["unknown"].as_str().expect("corpus names its unknown value"); + let cases = corpus["cases"].as_array().expect("corpus carries a cases array"); + assert!(!cases.is_empty(), "the corpus is empty, so this test proves nothing"); + for case in cases { + let pair = case.as_array().expect("each case is a [input, expected] pair"); + let (raw, expected) = ( + pair[0].as_str().expect("case input is a string"), + pair[1].as_str().expect("case expectation is a string"), + ); + assert_eq!( + &normalize_revision(raw), + expected, + "normalize_revision({raw:?}) disagrees with the shared corpus" + ); + } + // The one value the whole design turns on: a build with no revision + // says so rather than reporting something that reads as an answer. + assert_eq!(normalize_revision(""), unknown); + } + + #[test] + fn this_test_binary_reports_a_revision_or_says_unknown() { + // A `cargo test` run has GIT_SHA unset unless the caller exported it, + // so the honest value here is "unknown" -- and both are acceptable. + // What must never happen is a third thing: a revision that does not + // look like an object name coming out of the real accessor. + let revision = git_revision(); + assert!(!revision.is_empty(), "the revision field must never be empty"); + if revision != REVISION_UNKNOWN { + assert_eq!( + revision, + normalize_revision(&revision), + "git_revision() returned {revision:?}, which its own normalizer would not accept" + ); + } + } +} + +#[cfg(test)] +mod service_token_tests { + use super::{allow_unauth_dev_from_env, resolve_service_token}; + + #[test] + fn unset_token_without_override_refuses() { + // #2183's whole point: the default posture for a copied/partial + // compose or a bare `cargo run` used to be "open, quietly". It must + // be a refusal carrying the code and the remedy instead. + let err = resolve_service_token(None, false).expect_err("unset token must refuse"); + assert!(err.to_string().contains("E-SERVICE-TOKEN"), "{err}"); + assert!(err.to_string().contains("SERVICE_TOKEN"), "{err}"); + assert!( + err.to_string().contains("APIARY_ALLOW_UNAUTH_DEV=1"), + "{err}" + ); + } + + #[test] + fn empty_token_counts_as_unset() { + // compose ships `${DASHBOARD_SERVICE_TOKEN:-}`; a copied/partial + // env produces exactly this empty string, not an absent variable. + // The filter lives inside the decision, so that state can't slip + // past it if main()'s own pre-filter ever moves. + let err = resolve_service_token(Some(""), false).expect_err("empty token must refuse"); + assert!(err.to_string().contains("E-SERVICE-TOKEN"), "{err}"); + } + + #[test] + fn override_with_unset_token_is_sanctioned_dev() { + let resolved = resolve_service_token(None, true).expect("override must boot"); + assert_eq!(resolved, None); + } + + #[test] + fn override_accepts_the_bare_literal_one_only() { + // "1", not a truthiness zoo: a future `APIARY_ALLOW_UNAUTH_DEV=0` + // must read as refusal, and `=true` as a typo to fix, not consent. + assert!(!allow_unauth_dev_from_env(Some(""))); + assert!(!allow_unauth_dev_from_env(Some("0"))); + assert!(!allow_unauth_dev_from_env(Some("true"))); + assert!(!allow_unauth_dev_from_env(Some("yes"))); + assert!(allow_unauth_dev_from_env(Some("1"))); + } + + #[test] + fn present_token_boots_without_override() { + let resolved = resolve_service_token(Some("s3cret"), false).expect("token must boot"); + assert_eq!(resolved, Some("s3cret")); + } + + #[test] + fn override_never_weakens_a_present_token() { + // Both set: the middleware enforces the real token. The override + // exists only to sanction the token's absence, never to disable + // enforcement alongside it. + let resolved = resolve_service_token(Some("s3cret"), true).expect("must boot"); + assert_eq!(resolved, Some("s3cret")); + } +} + +#[cfg(test)] +mod readiness_tests { + use super::readiness_verdict; + + fn blocked(names: &[&str]) -> Vec { + names.iter().map(|name| name.to_string()).collect() + } + + #[test] + fn a_reachable_unblocked_cluster_is_ready() { + let readiness = readiness_verdict(Ok("green".into()), Ok(vec![])); + assert!(readiness.ready); + assert_eq!(readiness.reason, None, "a ready verdict carries no reason"); + // Still reported when ready: yellow is the ordinary shape of a + // replicated cluster, and a probe that only ever printed green + // would be no better than the constant #3317 replaced. + assert_eq!(readiness.cluster, "green"); + assert!(readiness.write_blocked.is_empty()); + } + + #[test] + fn yellow_stays_ready() { + // Yellow is unassigned *replicas*, which costs this tier nothing. + // Gating on it would take the backend not-ready on every rolling + // restart and every replica relocation. + assert!(readiness_verdict(Ok("yellow".into()), Ok(vec![])).ready); + } + + #[test] + fn an_unreachable_cluster_is_not_ready_and_says_why() { + // The bug in #3317's report, in the shape it took there: a backend + // that cannot reach Elasticsearch still answering healthy. + let readiness = readiness_verdict(Err(anyhow::anyhow!("connection refused")), Ok(vec![])); + assert!(!readiness.ready); + assert_eq!(readiness.cluster, "unreachable"); + let reason = readiness.reason.expect("not-ready must carry a reason"); + assert!(reason.contains("unreachable"), "{reason}"); + assert!(reason.contains("connection refused"), "{reason}"); + } + + #[test] + fn unreachable_outranks_a_write_block_report() { + // A block reading taken against a cluster we could not reach is not + // a fact about that cluster. Reporting it as the cause would be a + // guess, and it would be the wrong one to send an operator after. + let readiness = readiness_verdict( + Err(anyhow::anyhow!("no route to host")), + Ok(blocked(&["dashboard-config-v1"])), + ); + assert!(!readiness.ready); + assert!( + readiness.write_blocked.is_empty(), + "an unreachable cluster has no block state to report, got {:?}", + readiness.write_blocked + ); + assert!(readiness.reason.unwrap().contains("unreachable")); + } + + #[test] + fn a_write_block_is_not_ready_and_names_the_indices() { + // The disk-flood-stage / operator-block case: ES answers health + // fine and the block is the only evidence there is. Names rather + // than counts, because the point is to be actionable. + let readiness = readiness_verdict( + Ok("green".into()), + Ok(blocked(&["dashboard-config-v1", "dashboard-users-v1"])), + ); + assert!(!readiness.ready, "a green cluster is not the same as a writable one"); + assert_eq!(readiness.cluster, "green"); + assert_eq!(readiness.write_blocked, blocked(&["dashboard-config-v1", "dashboard-users-v1"])); + let reason = readiness.reason.expect("not-ready must carry a reason"); + assert!(reason.contains("dashboard-config-v1"), "{reason}"); + assert!(reason.contains("dashboard-users-v1"), "{reason}"); + assert!( + reason.contains("_cat/allocation"), + "the reason has to point at the two causes it cannot tell apart: {reason}" + ); + } + + #[test] + fn red_is_not_ready_even_with_nothing_blocked() { + let readiness = readiness_verdict(Ok("red".into()), Ok(vec![])); + assert!(!readiness.ready); + assert!(readiness.reason.unwrap().contains("red")); + } + + #[test] + fn a_probe_refused_itself_is_not_ready() { + // Reachable enough to answer health, but the settings call itself + // failed. "Ready" would be an answer this endpoint has no basis + // for -- it asked whether writes are permitted and was not told. + let readiness = + readiness_verdict(Ok("green".into()), Err(anyhow::anyhow!("403 forbidden"))); + assert!(!readiness.ready); + assert!(readiness.reason.unwrap().contains("refused the readiness probe")); + } + + #[test] + fn an_unknown_color_is_not_treated_as_red_or_green() { + // Neither branch claims it. The verdict is ready because no + // condition was met -- but `cluster` carries the honest "unknown" + // for the reader, rather than the endpoint inventing a color it + // was not told. + let readiness = readiness_verdict(Ok("unknown".into()), Ok(vec![])); + assert!(readiness.ready); + assert_eq!(readiness.cluster, "unknown"); + } +} diff --git a/arcane/home/honeypot-dashboard/backend-service/src/main.rs b/arcane/home/honeypot-dashboard/backend-service/src/main.rs index 6a18d344..844651d1 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/main.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/main.rs @@ -6,450 +6,14 @@ //! service token (SERVICE_TOKEN env), mirroring how the Go dashboard //! introspects today. Browsers never reach this service directly. An unset //! SERVICE_TOKEN refuses to boot (#2183) unless APIARY_ALLOW_UNAUTH_DEV=1 -//! says otherwise — see resolve_service_token below. +//! says otherwise — see the library's `resolve_service_token`. -use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - middleware::{self, Next}, - response::{IntoResponse, Response}, - routing::{delete, get, patch, post}, - Json, Router, +use apiary_backend::{ + allow_unauth_dev_from_env, audit, build_stamp, config_history, es, git_revision, obs, + resolve_service_token, worker, AppState, }; -use serde::Serialize; use std::{net::SocketAddr, sync::Arc}; -mod aggregates; -mod artifacts; -mod attacker_identity; -mod audit; -mod canarytokens; -mod charts; -mod config; -mod config_history; -mod agent_intrusion; -mod campaign_correlator; -mod correlator; -mod correlations; -mod credentials; -mod criticality_rules; -mod dashboard; -mod decode_correlate; -mod detail; -mod es; -mod event_detail; -mod obs; -mod event_page; -mod es_importer; -mod events; -mod exports; -mod fusion; -mod ghidra_submit; -mod github_analysis_submit; -mod gpu_queue; -mod health; -mod isolate; -mod honeyfs_implant; -mod investigate; -mod ip_block; -mod ip_enrichment; -mod kill_chain; -mod live; -mod llm_search; -mod mail; -mod ml_health; -mod overview; -mod payload_bytes; -mod payload_detail; -mod payload_inventory; -mod payload_kind; -mod payload_paths; -mod payload_static_analysis; -mod preferences; -mod problem_reports; -mod replay; -mod report_pdf; -mod reports; -mod reports_api; -mod reports_data; -mod reports_store; -mod reporter_stats; -mod rollups; -mod sandbox_submit; -mod sensors; -mod search; -mod services_control; -mod session; -mod stores; -mod ics_severity; -mod ioc_correlation; -mod threat_intel; -mod topology; -mod webhook_delivery; -mod zeek_proxy_attribution; -mod worker; -mod vault_rag; -mod workbench_api; -mod workbench_domain; -mod workbench_es; -mod workbench_orchestrator; - -#[derive(Clone)] -pub struct AppState { - pub es: Arc, - pub service_token: Arc>, - pub audit: Arc, - pub config_history: Arc, - /// #1972: request metrics + where durable JSONL request lines land - /// (empty = durable shipping disabled; stdout tracing unaffected). - pub observability: Arc, -} - -/// /livez — the process is up and its HTTP stack is answering. Says nothing -/// about Elasticsearch, and must never ask it. -/// -/// This is the endpoint the container HEALTHCHECK curls on an interval, and -/// the reason it stays dependency-free is the whole point of #3317: a probe -/// that can block on Elasticsearch turns that dependency's outage into a -/// restart loop of a container which was never the thing that broke. The -/// old /healthz answered `{"ok": true, "es": }` from inside this -/// handler, so an ES outage showed up here as a slow or failed probe -/// instead of as an ES outage. -#[derive(Serialize)] -struct Liveness { - live: bool, - /// build.rs's compile stamp, so a probe can answer "is the running - /// binary newer than the merge" without a second round trip. Same - /// field main logs at boot. - built: String, - /// #3315: which revision of the repository this binary was compiled from, - /// or "unknown". Unauthenticated and deliberately so — this is the field - /// that turns "is the running binary newer than the merge?" from a manual - /// inference into a curl, and /livez is already the one open probe on this - /// service (the token middleware covers /api/v1 only, see - /// require_service_token). A git revision names no secret: it is the same - /// string the image carries in org.opencontainers.image.revision and that - /// ghcr shows on the tag. - /// - /// This is the *answer* where `built` is the *inference*: `built` can only - /// be compared against a time, and a rebuilt-from-old-commit image passes - /// that comparison while running month-old code. `revision` is an object - /// name, so it can be looked up — which is what - /// scripts/verify-deploy.sh does, and why it reads this field rather than - /// `built`. Both are here because both have a consumer, and neither is - /// derivable from the other after the fact. - revision: String, -} - -/// /readyz — Elasticsearch is reachable and this tier's own write targets -/// are not write-blocked, i.e. the backend can actually do its job rather -/// than merely be running. 503 plus a `reason` when it cannot. -/// -/// Unlike liveness this endpoint is allowed to fail, so it is the one -/// diagnostics and the #3315 deploy verifier probe: "the process is up" is -/// the wrong question during an ingest outage, and it is the only question -/// the old endpoint could ask. -#[derive(Serialize)] -struct Readiness { - ready: bool, - /// Present exactly when `ready` is false, and specific enough to act - /// on — "Elasticsearch is unreachable" and "these four indices are - /// write-blocked" send an operator to different pages. - #[serde(skip_serializing_if = "Option::is_none")] - reason: Option, - /// green / yellow / red / unreachable. Reported even when ready, since - /// yellow is the ordinary shape of a replicated cluster and a probe - /// that only ever printed green would be no better than the constant - /// it replaces. - cluster: String, - /// The write-blocked members of `es::WRITE_TARGET_FAMILIES`. Always - /// present so a consumer can read one shape; named rather than counted, - /// because the point is to be able to act on which ones. - write_blocked: Vec, -} - -/// /readyz's own deadline, independent of the shared client's. -/// -/// es::connect gives the transport 30s, sized for real multi-second queries -/// rather than for a probe — and es.rs's own comment on that budget records -/// a /healthz that stopped responding because a worker loop's aggregation -/// saturated the search queue. A readiness answer somebody is waiting on -/// should arrive in seconds, and a probe that blocks for 30 is -/// indistinguishable from the outage it exists to report. -const READINESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - -/// The one place readiness is decided, kept pure so its truth table is -/// testable without an Elasticsearch to ask — the same discipline as -/// `resolve_service_token` below, and for the same reason: the interesting -/// cases are the ones where two independent probes disagree about how bad -/// things are, and a test that needs a live cluster to reach them is a test -/// that does not get run. -/// -/// `cluster` and `write_blocked` are two Results rather than one tuple of -/// plain values so a partial failure is a case this function has to answer -/// for, instead of one a caller has to. -fn readiness_verdict( - cluster: anyhow::Result, - write_blocked: anyhow::Result>, -) -> Readiness { - // Unreachable outranks everything else. A write-block reading against a - // cluster we could not reach is not a fact, it is the absence of one, - // and reporting it as the cause would be a guess. - let cluster = match cluster { - Ok(status) => status, - Err(error) => { - return Readiness { - ready: false, - reason: Some(format!("elasticsearch is unreachable: {error}")), - cluster: "unreachable".to_string(), - write_blocked: Vec::new(), - } - } - }; - let blocked = match write_blocked { - Ok(blocked) => blocked, - Err(error) => { - return Readiness { - ready: false, - reason: Some(format!("elasticsearch refused the readiness probe: {error}")), - cluster, - write_blocked: Vec::new(), - } - } - }; - // Red means unassigned primaries, against which both reads and writes - // fail. Yellow means unassigned *replicas*, which is the ordinary shape - // of a replicated cluster during a rolling restart and costs this tier - // nothing — a red-only gate would go not-ready on every deploy. - if cluster == "red" { - return Readiness { - ready: false, - reason: Some("elasticsearch cluster health is red (unassigned primaries)".to_string()), - cluster, - write_blocked: blocked, - }; - } - if !blocked.is_empty() { - return Readiness { - ready: false, - reason: Some(format!( - "elasticsearch has index.blocks.write set on: {}. The flood-stage disk \ - watermark sets this on every index at once, and so does an operator's \ - `PUT //_block/write`; this endpoint cannot tell those apart, so \ - check _cat/allocation free space before concluding which one it is.", - blocked.join(", ") - )), - cluster, - write_blocked: blocked, - }; - } - Readiness { ready: true, reason: None, cluster, write_blocked: blocked } -} - -/// GET /livez, and GET /healthz — the same handler under two names. See -/// `Liveness` for why the response carries no Elasticsearch signal. -async fn livez() -> Json { - Json(Liveness { live: true, built: build_stamp(), revision: git_revision() }) -} - -/// `/healthz` is the name the image's HEALTHCHECK, the port-test harness -/// and the ops scripts already use, so it stays as an alias rather than -/// being broken (killing a 2024-era name in a health-probe rename is how a -/// stack ends up reporting permanently unhealthy with nothing wrong). It -/// used to answer `{"ok": true, "es": }` where `ok` was the constant -/// #3317 is about; the `es` half of that answer now lives on /readyz, which -/// can say no. -async fn healthz() -> Json { - livez().await -} - -/// GET /readyz — see `Readiness`. Unauthenticated exactly like the liveness -/// probes and /metrics, because the callers are infrastructure: the deploy -/// verifier, diagnostics, and an operator on a jump host. Authentication -/// would not make it safer here, only less answerable. -async fn readyz(State(state): State) -> (StatusCode, Json) { - // Both probes in flight together: they are independent round trips and - // the endpoint's whole value is being quick to answer. The async block - // is what makes the pair a single future the deadline can wrap -- - // `join!` on its own expands to the values, not to something awaitable. - let probes = tokio::time::timeout(READINESS_TIMEOUT, async { - tokio::join!( - state.es.cluster_health_status(), - state.es.write_blocked(es::WRITE_TARGET_FAMILIES), - ) - }) - .await; - let (cluster, write_blocked) = match probes { - Ok(probes) => probes, - Err(_elapsed) => { - // Both halves report the deadline, because a probe pair that - // timed out established nothing about either question. The - // verdict resolves the cluster half as unreachable; this one - // exists so the pair stays a pair of Results rather than a - // Result of a pair, and its text is never what gets reported. - let expired = || anyhow::anyhow!("no answer within {}s", READINESS_TIMEOUT.as_secs()); - (Err(expired()), Err(expired())) - } - }; - let readiness = readiness_verdict(cluster, write_blocked); - let status = if readiness.ready { - StatusCode::OK - } else { - StatusCode::SERVICE_UNAVAILABLE - }; - tracing::debug!(ready = readiness.ready, cluster = %readiness.cluster, "readyz"); - (status, Json(readiness)) -} - -/// A boot refusal carries the code the cutover doc and dashboards grep -/// for, plus the exact remedy — the whole point of #2183 is that a -/// misconfigured instance explains itself instead of silently opening -/// every route. `std::fmt::Display` rather than deriving Debug on an enum: -/// anyhow prints this through `Error: {}` at exit, one line, no nesting. -#[derive(Debug)] -pub struct ServiceTokenRefusal { - message: String, -} - -impl ServiceTokenRefusal { - fn new() -> Self { - Self { - message: concat!( - "[E-SERVICE-TOKEN] refusing to start: SERVICE_TOKEN is unset or empty, ", - "which would leave every /api/v1 route open to unauthenticated requests ", - "(the BFF proxy tier mirrors this check). ", - "Set SERVICE_TOKEN to a shared secret — see docs/DASHBOARD-CUTOVER.md step 2 — ", - "or, for local development only, set APIARY_ALLOW_UNAUTH_DEV=1 explicitly (#2183).", - ) - .to_string(), - } - } -} - -impl std::fmt::Display for ServiceTokenRefusal { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } -} - -impl std::error::Error for ServiceTokenRefusal {} - -/// The single decision behind #2183's boot gate, kept pure so tests can pin -/// its truth table without env-var races between parallel test threads. -/// -/// - Ok(Some(token)) — a real token is configured; require_service_token -/// enforces it below. -/// - Ok(None) — SERVICE_TOKEN unset/empty AND APIARY_ALLOW_UNAUTH_DEV=1: -/// an explicitly opted-in unauthenticated dev instance, announced loudly. -/// - Err — otherwise: main refuses before binding, replacing #2044's -/// warn-only posture (a warning sat next to a listen socket that silently -/// accepted everything). -/// -/// The override never weakens a configured token: with both set, the token -/// wins and the middleware enforces it as usual. -pub fn resolve_service_token( - service_token: Option<&str>, - allow_unauth_dev: bool, -) -> Result, ServiceTokenRefusal> { - match service_token.filter(|token| !token.is_empty()) { - Some(token) => Ok(Some(token)), - None if allow_unauth_dev => Ok(None), - None => Err(ServiceTokenRefusal::new()), - } -} - -/// Exactly "1" enables the override — no truthiness zoo where someone's -/// `APIARY_ALLOW_UNAUTH_DEV=0` or `=false` quietly reads as consent. -fn allow_unauth_dev_from_env(raw: Option<&str>) -> bool { - raw == Some("1") -} - -/// Every /api/v1 route requires the BFF's service token (constant-time -/// comparison; header X-Service-Token). /healthz stays open for the -/// container healthcheck, same as the Go dashboard's -healthcheck probe. -async fn require_service_token( - State(state): State, - headers: HeaderMap, - request: axum::extract::Request, - next: Next, -) -> Response { - if let Some(expected) = state.service_token.as_ref() { - let presented = headers - .get("x-service-token") - .and_then(|value| value.to_str().ok()) - .unwrap_or(""); - let expected = expected.as_bytes(); - let presented = presented.as_bytes(); - let mut diff = expected.len() ^ presented.len(); - for i in 0..expected.len().min(presented.len()) { - diff |= (expected[i] ^ presented[i]) as usize; - } - if diff != 0 { - return (StatusCode::UNAUTHORIZED, "service token required").into_response(); - } - } - next.run(request).await -} - - -/// When this binary was compiled, as RFC 3339, or "unknown". -/// -/// Set by build.rs. `option_env!` rather than `env!` on purpose: the first -/// attempt at this used `env!` and broke the image build outright, because -/// the Dockerfile copies Cargo.toml and src but did not copy build.rs, so -/// cargo never ran it. That is fixed, but the failure mode should not be a -/// dead build for a diagnostic field -- and it must not be a lie either, so -/// an absent stamp reads as "unknown" rather than as a plausible time. -pub fn build_stamp() -> String { - let Some(raw) = option_env!("APIARY_BUILD_EPOCH") else { - return "unknown".to_string(); - }; - match raw.parse::() { - Ok(epoch) => chrono::DateTime::from_timestamp(epoch, 0) - .map(|when| when.to_rfc3339()) - .unwrap_or_else(|| raw.to_string()), - Err(_) => raw.to_string(), - } -} - -/// The honest answer when no revision was baked in. Same word build_stamp -/// uses, deliberately: a value that reads as a plausible time or a plausible -/// object name is worse than one that says nothing. -pub const REVISION_UNKNOWN: &str = "unknown"; - -/// A git object name is 7-64 hex characters, optionally `sha256:`-prefixed -/// (git's own object-format naming) — anything else is not a revision. -/// -/// This is a filter, not a format preference. `APIARY_GIT_SHA` arrives from a -/// `docker build --build-arg`, and a value that is not an object name is -/// either a mistake or something injected; either way it must not be echoed -/// back out of /healthz verbatim and read as "this is the deployed commit". -/// Case is normalized because GitHub, `git rev-parse` and the OCI label -/// convention each spell it differently, and a spelling difference must not -/// read as a deployed-revision mismatch. -pub fn normalize_revision(raw: &str) -> String { - let candidate = raw.strip_prefix("sha256:").unwrap_or(raw); - if (7..=64).contains(&candidate.len()) && candidate.bytes().all(|b| b.is_ascii_hexdigit()) { - candidate.to_ascii_lowercase() - } else { - REVISION_UNKNOWN.to_string() - } -} - -/// The revision this binary was compiled from, as the image build supplied it. -/// -/// `option_env!` for the same reason build_stamp() uses it: a missing stamp is -/// a diagnostic field, not a reason to fail a build. It is set unconditionally -/// by build.rs (an absent GIT_SHA becomes the empty string), so the None arm -/// only fires for a crate built by some path that skipped build.rs entirely — -/// which must read as "unknown", never as a guess. -pub fn git_revision() -> String { - match option_env!("APIARY_GIT_SHA") { - Some(raw) => normalize_revision(raw), - None => REVISION_UNKNOWN.to_string(), - } -} - #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -500,229 +64,10 @@ async fn main() -> anyhow::Result<()> { // Worker loops (#1610): same image, role by WORKER_LOOPS env. worker::spawn_enabled(state.clone()); - let api = Router::new() - .route("/api/v1/overview/kpis", get(overview::kpis)) - .route("/api/v1/overview/dashboard", get(dashboard::dashboard)) - .route("/api/v1/events", get(events::list)) - .route("/api/v1/export/events.csv", get(exports::events_csv)) - .route("/api/v1/export/commands.csv", get(exports::commands_csv)) - .route("/api/v1/export/ips.csv", get(exports::ips_csv)) - .route("/api/v1/export/campaigns.csv", get(exports::campaigns_csv)) - .route("/api/v1/export/clusters.csv", get(exports::clusters_csv)) - .route("/api/v1/export/history.json", get(exports::history_json)) - .route("/api/v1/live", get(live::stream)) - .route("/api/v1/mail/{session_id}", get(mail::get)) - .route("/api/v1/ml-health", get(ml_health::list)) - .route("/api/v1/gpu-queue", get(gpu_queue::list)) - .route("/api/v1/gpu-queue/{job_id}/abort", post(gpu_queue::abort)) - .route("/api/v1/sources", get(aggregates::sources)) - .route("/api/v1/filter-values", get(aggregates::filter_values)) - .route("/api/v1/investigate/ip/{ip}", get(investigate::ip)) - .route("/api/v1/investigate/cidr/{cidr}", get(investigate::cidr)) - .route("/api/v1/investigate/cluster", get(investigate::cluster)) - .route("/api/v1/source-health", get(health::source_health)) - // #3330: the alert fan-out's own delivery outcomes. Also a field on - // /api/v1/source-health; this route is what the Settings card - // reads, so the operations page's one-snapshot design is not the - // only way to get at it. - .route("/api/v1/webhook-delivery", get(webhook_delivery::health)) - .route("/api/v1/event/{id}", get(event_page::get)) - // #2047: materialized cross-sensor correlations — the event page's - // same-flow summary and the re-used-wordlist edges. - .route("/api/v1/event/{id}/connections", get(correlations::event_connections)) - .route("/api/v1/connections/{community_id}", get(correlations::flow_by_id)) - .route("/api/v1/cred-reuse", get(correlations::cred_reuse)) - .route("/api/v1/sensors", get(sensors::detail)) - // #1856: /catalog is registered before /{sensor} so the literal - // segment is not swallowed by the capture. A sensor genuinely - // named "catalog" would be shadowed; none is, and the alternative - // is a query parameter that reads worse for the common case. - .route("/api/v1/sensors/catalog", get(sensors::catalog)) - .route("/api/v1/sensors/{sensor}/events", get(sensors::events)) - .route("/api/v1/sensors/{sensor}/overview", get(sensors::overview)) - .route("/api/v1/sessions/{id}", get(session::detail)) - .route("/api/v1/search", get(search::search)) - .route("/api/v1/topology", get(topology::topology)) - .route("/api/v1/settings/storage", get(health::storage)) - .route("/api/v1/config", get(config::get_config)) - .route( - "/api/v1/config/presentation", - axum::routing::put(config::put_presentation), - ) - .route( - "/api/v1/config/{section}", - axum::routing::put(config::put_config_section), - ) - .route("/api/v1/config/history", get(config::history)) - .route("/api/v1/config/rollback", post(config::rollback)) - .route("/api/v1/config/validate", post(config::validate)) - .route("/api/v1/users", get(config::users)) - .route("/api/v1/audit", get(audit::list)) - .route( - "/api/v1/preferences", - get(preferences::get).put(preferences::put), - ) - .route("/api/v1/preferences/reset", post(preferences::reset)) - .route("/api/v1/reporter-stats", get(reporter_stats::stats)) - .route("/api/v1/services", get(services_control::list)) - .route("/api/v1/services/{name}/logs", get(services_control::logs)) - .route("/api/v1/services/{name}/{action}", post(services_control::action)) - .route("/api/v1/llm-search", get(llm_search::search)) - .route("/api/v1/vault-rag", get(vault_rag::ask)) - .route("/api/v1/ip-block", post(ip_block::set_block)) - .route("/api/v1/ip-block/{ip}", get(ip_block::get_block)) - .route("/api/v1/ip-block-export", get(ip_block::export)) - .route("/api/v1/sandbox/{job}", get(detail::sandbox_run)) - .route("/api/v1/ghidra/{sha}", get(detail::ghidra_run)) - .route("/api/v1/ghidra-callgraph/{sha}", get(detail::ghidra_callgraph)) - .route("/api/v1/revdeck/{sha}", get(detail::revdeck_run)) - .route("/api/v1/cape/{sha}", get(detail::cape_run)) - .route("/api/v1/cape/{sha}/raw", get(detail::cape_raw)) - .route("/api/v1/github-analysis/{sha}", get(detail::github_analysis_run)) - .route("/api/v1/attackers-graph", get(detail::attackers_graph)) - .route("/api/v1/attack-vectors", get(detail::attack_vectors)) - .route("/api/v1/ml-anomalies/ack", post(detail::ml_anomaly_ack)) - .route("/api/v1/ml-anomalies/ack-all", post(detail::ml_anomaly_ack_all)) - .route("/api/v1/ml-anomalies/acks", get(detail::ml_anomaly_acks)) - .route("/api/v1/ml-anomalies/stats", get(detail::ml_anomaly_stats)) - .route("/api/v1/ml-anomalies/disposition", post(detail::ml_anomaly_disposition)) - .route("/api/v1/reports/{id}/pdf", get(reports::pdf)) - // #1612 phase 4: Reports studio — template/element catalog, - // definitions CRUD, and on-demand generate. See reports_store.rs's - // module doc comment for the sandbox/payload/ghidra scope decision. - .route("/api/v1/reports/templates", get(reports_api::templates)) - .route( - "/api/v1/reports/definitions", - get(reports_api::list_definitions).post(reports_api::create_definition), - ) - .route( - "/api/v1/reports/definitions/{id}", - get(reports_api::get_definition) - .put(reports_api::replace_definition) - .delete(reports_api::delete_definition), - ) - .route("/api/v1/reports/definitions/{id}/generate", post(reports_api::generate)) - .route("/api/v1/reports/generated/{id}", delete(reports_api::delete_generated)) - .route("/api/v1/artifacts/{kind}/{key}", get(artifacts::list)) - .route("/api/v1/artifacts/{kind}/{key}/{filename}", get(artifacts::download)) - .route("/api/v1/charts/kill-chain-sankey", get(kill_chain::sankey)) - .route("/api/v1/charts/attck-coverage", get(kill_chain::attck_coverage)) - .route("/api/v1/charts/campaign-timeline", get(kill_chain::campaign_timeline)) - .route("/api/v1/charts/ml-backlog", get(charts::ml_backlog)) - .route("/api/v1/charts/netflow-bytes", get(charts::netflow_bytes)) - .route("/api/v1/charts/netflow-packets", get(charts::netflow_packets)) - .route("/api/v1/charts/anomaly-trend", get(charts::anomaly_trend)) - .route("/api/v1/charts/dionaea-cves", get(charts::dionaea_cves)) - .route("/api/v1/charts/os-distribution", get(charts::os_distribution)) - // #1727 §7: JA4T stack clusters, the successor to the p0f OS chart above. - .route("/api/v1/charts/tcp-stack-clusters", get(charts::tcp_stack_clusters)) - // #1736/#1739: two surfaces for data that currently has no view at all. - .route("/api/v1/charts/ics-functions", get(charts::ics_functions)) - .route("/api/v1/charts/decoy-requests", get(charts::decoy_requests)) - // #1765: the wire-tuple join in use -- Traefik requests meeting the - // ClientHello fingerprints only the passive sniffer can see. - .route("/api/v1/charts/decoy-client-fingerprints", get(charts::decoy_client_fingerprints)) - // #1729: the rest of the JA4+ family Zeek produces. - .route("/api/v1/charts/ja4h-fingerprints", get(charts::ja4h_fingerprints)) - .route("/api/v1/charts/ja4x-fingerprints", get(charts::ja4x_fingerprints)) - .route("/api/v1/charts/ja4l-fingerprints", get(charts::ja4l_fingerprints)) - .route("/api/v1/charts/tls-fingerprints", get(charts::tls_fingerprints)) - .route("/api/v1/charts/ssh-fingerprints", get(charts::ssh_fingerprints)) - .route("/api/v1/charts/endlessh-held-histogram", get(charts::endlessh_histogram)) - .route("/api/v1/charts/ml-anomaly-scores", get(charts::ml_anomaly_scores)) - .route("/api/v1/charts/attacker-fusion", get(fusion::fusion)) - .route("/api/v1/campaigns", get(stores::campaigns)) - .route("/api/v1/clusters", get(stores::clusters)) - .route("/api/v1/attackers", get(stores::attackers)) - // #2045: the raw evidence behind an attacker entity. - .route("/api/v1/attackers/{id}/events", get(attacker_identity::entity_events)) - .route("/api/v1/recordings", get(stores::recordings)) - .route("/api/v1/recordings/{shasum}", get(replay::replay)) - // #1711: the two download forms the Go tier served at - // /tty/.cast and .raw, which the port dropped. - .route("/api/v1/recordings/{shasum}/cast", get(replay::replay_cast)) - .route("/api/v1/recordings/{shasum}/raw", get(replay::replay_raw)) - .route("/api/v1/alerts", get(stores::alerts)) - .route("/api/v1/alerts/{key}/ack", post(stores::acknowledge)) - .route("/api/v1/canarytokens/types", get(canarytokens::types)) - .route("/api/v1/canarytokens", get(canarytokens::list).post(canarytokens::create)) - .route("/api/v1/canarytokens/{id}/download", get(canarytokens::download)) - // #1612 misc write paths: honeyfs-implant credential provisioning/ - // rotation (credentials_manager.go/credentials_api.go). Plain HTTP - // to a WireGuard-reachable URL, no host mount — same tier as - // canarytokens.rs above, not the mounted-worker-role service. - .route( - "/api/v1/credentials", - get(credentials::list).post(credentials::create), - ) - .route("/api/v1/credentials/{id}/rotate", post(credentials::rotate)) - .route("/api/v1/credentials/{id}/link-token", post(credentials::link_token)) - .route("/api/v1/payloads", get(stores::payloads)) - .route("/api/v1/payloads/{hash}", get(payload_detail::detail)) - .route("/api/v1/payloads/{hash}/raw", get(payload_detail::raw)) - // #474 one-click payload PDF (hp-payload-report.js): ephemeral - // payload-scoped report into the generated store, no saved - // definition. See reports_api::generate_payload_report. - .route("/api/v1/payloads/{hash}/report", post(reports_api::generate_payload_report)) - .route("/api/v1/store/{name}", get(stores::generic).delete(stores::generic_delete)) - .route("/api/v1/problem-reports", post(problem_reports::submit)) - .route("/api/v1/problem-reports/{id}", patch(problem_reports::patch_status)) - // #1612 mounted worker role (phase 3a): sandbox/ghidra/github- - // analysis submission + golden-image status. Registered in the - // same shared route table as everything else — which container - // these are actually reachable/useful on depends entirely on - // which compose service has the spool-dir mounts (backend-service- - // mounted), not on route registration here. - .route("/api/v1/sandbox/submit", post(sandbox_submit::submit)) - .route("/api/v1/sandbox/golden-image-status", get(sandbox_submit::golden_image_status)) - .route("/api/v1/sandbox/vnc", get(sandbox_submit::vnc_status)) - .route("/api/v1/ghidra/submit", post(ghidra_submit::submit)) - .route("/api/v1/github-analysis/submit", post(github_analysis_submit::submit)) - // #1612 phase 3b: Payload Workbench orchestrator (recipes, run - // creation/reconciliation, child cancel/retry). Same - // shared-route-table posture as phase 3a — only useful on - // backend-service-mounted, which has the write-capable spool mounts. - .route("/api/v1/workbench/analyzers", get(workbench_api::analyzers)) - .route( - "/api/v1/workbench/runs", - get(workbench_api::list_runs).post(workbench_api::create_run), - ) - .route("/api/v1/workbench/runs/{id}", get(workbench_api::get_run)) - .route( - "/api/v1/workbench/runs/{id}/children/{analyzer_id}/{action}", - post(workbench_api::child_action), - ) - .route( - "/api/v1/workbench/recipes", - get(workbench_api::list_recipes).post(workbench_api::save_recipe), - ) - .layer(middleware::from_fn_with_state(state.clone(), require_service_token)); - - let app = Router::new() - // #3317: liveness and readiness are different questions with - // different consequences, and conflating them is what let a backend - // that could not reach Elasticsearch keep answering `ok: true` to - // its own healthcheck. /livez (and its /healthz alias) is "the - // process is up" and never touches Elasticsearch, so an ES outage - // cannot restart-loop the container; /readyz is "this can do its - // job" and answers 503 with a reason when it cannot. All three are - // unauthenticated, like the /healthz they grew out of. - .route("/livez", get(livez)) - .route("/healthz", get(healthz)) - .route("/readyz", get(readyz)) - // #1972: same listener, same internal-network posture as /healthz. - .route("/metrics", get(obs::metrics_route)) - .merge(api) - // #1972 observability wraps EVERYTHING above it — health probe, - // metrics scrape, and every /api/v1 route get a request id echoed - // in x-request-id, metrics recorded per family/status/latency, and - // one durable JSONL line when DASHBOARD_LOG_FILE is set. observe() - // reads its Obs handle via State, so this must be the - // _with_state form (plain from_fn builds FromFn<(), ..> whose - // Service bound never matches a state-taking extractor). - .layer(middleware::from_fn_with_state(state.clone(), obs::observe)) - .layer(tower_http::trace::TraceLayer::new_for_http()) - .with_state(state); + // The route table itself is the library's, so that the #3325 + // contract is generated from the same `Router` this process serves + // rather than from a second hand-kept copy of it. + let app = apiary_backend::router(state); let addr: SocketAddr = listen.parse()?; // `built` is the one thing that makes a deploy verifiable from outside. @@ -740,259 +85,4 @@ async fn main() -> anyhow::Result<()> { ); let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve(listener, app).await?; - Ok(()) -} - -#[cfg(test)] -mod build_stamp_tests { - use super::{build_stamp, git_revision, normalize_revision, REVISION_UNKNOWN}; - - #[test] - fn build_stamp_is_a_real_recent_timestamp() { - // The stamp exists to answer "is the running binary newer than the - // merge", so a value that does not parse, or that sits in 1970, - // would be worse than none: it reads as an answer. - let stamp = build_stamp(); - // "unknown" is an acceptable answer -- it is the honest one when the - // build script did not run. Anything else has to be a real time. - if stamp == "unknown" { - return; - } - let parsed = chrono::DateTime::parse_from_rfc3339(&stamp) - .unwrap_or_else(|error| panic!("build stamp {stamp:?} is not RFC 3339: {error}")); - - let now = chrono::Utc::now(); - let age = now.signed_duration_since(parsed.with_timezone(&chrono::Utc)); - assert!( - age.num_days() < 3650 && age.num_seconds() > -3600, - "build stamp {stamp} is not a plausible build time (age {age})", - ); - } - - // ---- #3315: the revision /healthz reports (main.rs's `revision` field) ---- - - #[test] - fn a_full_object_name_survives_verbatim() { - assert_eq!(normalize_revision("3dca4457f1b2c0d4e5a69788796a5b4c3d2e1f0ab"), - "3dca4457f1b2c0d4e5a69788796a5b4c3d2e1f0ab"); - } - - /// The table is a file rather than a literal list so that the other - /// implementation of this rule -- dashboard-next's normalizeRevision, in - /// JavaScript, in a different CI lane -- can be driven from exactly the - /// same cases. `scripts/tests/test_3315_image_revision.py` runs both. - /// Written inline, the two lists would drift the first time either gained - /// a case, and the failure would be a deploy disagreement that is not one: - /// two tiers stamping different strings for the same build, which - /// scripts/verify-deploy.sh would report as a mismatch. - #[test] - fn the_shared_corpus_normalizes_as_the_other_tier_does() { - let corpus: serde_json::Value = - serde_json::from_str(include_str!("revision-corpus.json")).expect("corpus is valid JSON"); - let unknown = corpus["unknown"].as_str().expect("corpus names its unknown value"); - let cases = corpus["cases"].as_array().expect("corpus carries a cases array"); - assert!(!cases.is_empty(), "the corpus is empty, so this test proves nothing"); - for case in cases { - let pair = case.as_array().expect("each case is a [input, expected] pair"); - let (raw, expected) = ( - pair[0].as_str().expect("case input is a string"), - pair[1].as_str().expect("case expectation is a string"), - ); - assert_eq!( - &normalize_revision(raw), - expected, - "normalize_revision({raw:?}) disagrees with the shared corpus" - ); - } - // The one value the whole design turns on: a build with no revision - // says so rather than reporting something that reads as an answer. - assert_eq!(normalize_revision(""), unknown); - } - - #[test] - fn this_test_binary_reports_a_revision_or_says_unknown() { - // A `cargo test` run has GIT_SHA unset unless the caller exported it, - // so the honest value here is "unknown" -- and both are acceptable. - // What must never happen is a third thing: a revision that does not - // look like an object name coming out of the real accessor. - let revision = git_revision(); - assert!(!revision.is_empty(), "the revision field must never be empty"); - if revision != REVISION_UNKNOWN { - assert_eq!( - revision, - normalize_revision(&revision), - "git_revision() returned {revision:?}, which its own normalizer would not accept" - ); - } - } -} - -#[cfg(test)] -mod service_token_tests { - use super::{allow_unauth_dev_from_env, resolve_service_token}; - - #[test] - fn unset_token_without_override_refuses() { - // #2183's whole point: the default posture for a copied/partial - // compose or a bare `cargo run` used to be "open, quietly". It must - // be a refusal carrying the code and the remedy instead. - let err = resolve_service_token(None, false).expect_err("unset token must refuse"); - assert!(err.to_string().contains("E-SERVICE-TOKEN"), "{err}"); - assert!(err.to_string().contains("SERVICE_TOKEN"), "{err}"); - assert!( - err.to_string().contains("APIARY_ALLOW_UNAUTH_DEV=1"), - "{err}" - ); - } - - #[test] - fn empty_token_counts_as_unset() { - // compose ships `${DASHBOARD_SERVICE_TOKEN:-}`; a copied/partial - // env produces exactly this empty string, not an absent variable. - // The filter lives inside the decision, so that state can't slip - // past it if main()'s own pre-filter ever moves. - let err = resolve_service_token(Some(""), false).expect_err("empty token must refuse"); - assert!(err.to_string().contains("E-SERVICE-TOKEN"), "{err}"); - } - - #[test] - fn override_with_unset_token_is_sanctioned_dev() { - let resolved = resolve_service_token(None, true).expect("override must boot"); - assert_eq!(resolved, None); - } - - #[test] - fn override_accepts_the_bare_literal_one_only() { - // "1", not a truthiness zoo: a future `APIARY_ALLOW_UNAUTH_DEV=0` - // must read as refusal, and `=true` as a typo to fix, not consent. - assert!(!allow_unauth_dev_from_env(Some(""))); - assert!(!allow_unauth_dev_from_env(Some("0"))); - assert!(!allow_unauth_dev_from_env(Some("true"))); - assert!(!allow_unauth_dev_from_env(Some("yes"))); - assert!(allow_unauth_dev_from_env(Some("1"))); - } - - #[test] - fn present_token_boots_without_override() { - let resolved = resolve_service_token(Some("s3cret"), false).expect("token must boot"); - assert_eq!(resolved, Some("s3cret")); - } - - #[test] - fn override_never_weakens_a_present_token() { - // Both set: the middleware enforces the real token. The override - // exists only to sanction the token's absence, never to disable - // enforcement alongside it. - let resolved = resolve_service_token(Some("s3cret"), true).expect("must boot"); - assert_eq!(resolved, Some("s3cret")); - } -} - -#[cfg(test)] -mod readiness_tests { - use super::readiness_verdict; - - fn blocked(names: &[&str]) -> Vec { - names.iter().map(|name| name.to_string()).collect() - } - - #[test] - fn a_reachable_unblocked_cluster_is_ready() { - let readiness = readiness_verdict(Ok("green".into()), Ok(vec![])); - assert!(readiness.ready); - assert_eq!(readiness.reason, None, "a ready verdict carries no reason"); - // Still reported when ready: yellow is the ordinary shape of a - // replicated cluster, and a probe that only ever printed green - // would be no better than the constant #3317 replaced. - assert_eq!(readiness.cluster, "green"); - assert!(readiness.write_blocked.is_empty()); - } - - #[test] - fn yellow_stays_ready() { - // Yellow is unassigned *replicas*, which costs this tier nothing. - // Gating on it would take the backend not-ready on every rolling - // restart and every replica relocation. - assert!(readiness_verdict(Ok("yellow".into()), Ok(vec![])).ready); - } - - #[test] - fn an_unreachable_cluster_is_not_ready_and_says_why() { - // The bug in #3317's report, in the shape it took there: a backend - // that cannot reach Elasticsearch still answering healthy. - let readiness = readiness_verdict(Err(anyhow::anyhow!("connection refused")), Ok(vec![])); - assert!(!readiness.ready); - assert_eq!(readiness.cluster, "unreachable"); - let reason = readiness.reason.expect("not-ready must carry a reason"); - assert!(reason.contains("unreachable"), "{reason}"); - assert!(reason.contains("connection refused"), "{reason}"); - } - - #[test] - fn unreachable_outranks_a_write_block_report() { - // A block reading taken against a cluster we could not reach is not - // a fact about that cluster. Reporting it as the cause would be a - // guess, and it would be the wrong one to send an operator after. - let readiness = readiness_verdict( - Err(anyhow::anyhow!("no route to host")), - Ok(blocked(&["dashboard-config-v1"])), - ); - assert!(!readiness.ready); - assert!( - readiness.write_blocked.is_empty(), - "an unreachable cluster has no block state to report, got {:?}", - readiness.write_blocked - ); - assert!(readiness.reason.unwrap().contains("unreachable")); - } - - #[test] - fn a_write_block_is_not_ready_and_names_the_indices() { - // The disk-flood-stage / operator-block case: ES answers health - // fine and the block is the only evidence there is. Names rather - // than counts, because the point is to be actionable. - let readiness = readiness_verdict( - Ok("green".into()), - Ok(blocked(&["dashboard-config-v1", "dashboard-users-v1"])), - ); - assert!(!readiness.ready, "a green cluster is not the same as a writable one"); - assert_eq!(readiness.cluster, "green"); - assert_eq!(readiness.write_blocked, blocked(&["dashboard-config-v1", "dashboard-users-v1"])); - let reason = readiness.reason.expect("not-ready must carry a reason"); - assert!(reason.contains("dashboard-config-v1"), "{reason}"); - assert!(reason.contains("dashboard-users-v1"), "{reason}"); - assert!( - reason.contains("_cat/allocation"), - "the reason has to point at the two causes it cannot tell apart: {reason}" - ); - } - - #[test] - fn red_is_not_ready_even_with_nothing_blocked() { - let readiness = readiness_verdict(Ok("red".into()), Ok(vec![])); - assert!(!readiness.ready); - assert!(readiness.reason.unwrap().contains("red")); - } - - #[test] - fn a_probe_refused_itself_is_not_ready() { - // Reachable enough to answer health, but the settings call itself - // failed. "Ready" would be an answer this endpoint has no basis - // for -- it asked whether writes are permitted and was not told. - let readiness = - readiness_verdict(Ok("green".into()), Err(anyhow::anyhow!("403 forbidden"))); - assert!(!readiness.ready); - assert!(readiness.reason.unwrap().contains("refused the readiness probe")); - } - - #[test] - fn an_unknown_color_is_not_treated_as_red_or_green() { - // Neither branch claims it. The verdict is ready because no - // condition was met -- but `cluster` carries the honest "unknown" - // for the reader, rather than the endpoint inventing a color it - // was not told. - let readiness = readiness_verdict(Ok("unknown".into()), Ok(vec![])); - assert!(readiness.ready); - assert_eq!(readiness.cluster, "unknown"); - } -} + Ok(())} diff --git a/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs b/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs index 739238ed..a4966e5d 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs @@ -1460,17 +1460,25 @@ mod tests { ); } - /// #3325's drift gate, direction two: every `.route(...)` main.rs + /// #3325's drift gate, direction two: every `.route(...)` the service /// registers is in the contract, and nothing in the contract is /// missing from the router. The direction that matters is /// router -> contract -- a new route with no contract row is how a /// fuzz target quietly stops covering the thing it was added for. /// The other direction catches a contract path that no longer routes, /// which is how a fuzzer ends up measuring a 404. + /// + /// The route table was read out of `src/main.rs` while the binary + /// still owned it. It is read out of `src/lib.rs` now, because the + /// table moved there with the handler modules (#3325's utoipa + /// migration): the contract is generated from the same builder the + /// process serves, and a second binary cannot see a first binary's + /// modules. Every assertion below is unchanged -- only the file the + /// scan reads moved, because the code it scans moved. #[test] fn contract_covers_every_router_route() { let main_rs = - std::fs::read_to_string(crate_dir().join("src/main.rs")).expect("src/main.rs is readable"); + std::fs::read_to_string(crate_dir().join("src/lib.rs")).expect("src/lib.rs is readable"); let registered: BTreeSet<(String, String)> = router_routes(&main_rs) .into_iter() .collect(); @@ -1490,7 +1498,8 @@ mod tests { let extra: Vec<_> = published.difference(®istered).collect(); assert!( missing.is_empty() && extra.is_empty(), - "the contract and src/main.rs disagree about the /api surface (#3325).\n\ + "the contract and the service's route table disagree about the /api \ + surface (#3325).\n\ registered but not in openapi.json: {missing:#?}\n\ in openapi.json but not registered: {extra:#?}\n\ add the row to operations() in src/openapi.rs, then run \ @@ -1674,7 +1683,7 @@ mod tests { names } - /// The `(path, method)` pairs main.rs registers, read out of the + /// The `(path, method)` pairs the service registers, read out of the /// source rather than a hand-kept list. A third list would be a /// third thing to update, and the point of the gate is that the /// router stays the thing that decides what exists. From f3ce0b48a7b78b66c74f8a922eea8367f8c8aa5f Mon Sep 17 00:00:00 2001 From: Xore Date: Sun, 27 Sep 2026 13:37:36 +0200 Subject: [PATCH 4/6] refactor(backend-service): generate the OpenAPI contract with utoipa (#3325) Replaces the 1820-line hand-written table in src/openapi.rs with utoipa/utoipa-axum, so the contract is derived from the annotations and the route table rather than transcribed alongside them. The 128 /api paths and 4 health probes come out byte-identical, which the the_document_is_byte_stable gate asserts against the committed openapi.json. The route table is now an index, not a second copy. Every entry moved from `.route(path, method(handler))` to `.routes(utoipa_axum::routes!(handler))`, in the original order, one handler per call: `routes!` panics if two handlers in a single call share an HTTP method, and the table has methods that repeat across adjacent entries. Paths and methods now come from `#[utoipa::path]`, so they cannot disagree with the annotations the document is built from. `#[derive(OpenApi)]` is deliberately not used. It tags every operation with a module-path tag; utoipa-axum's OpenApiRouter does not, and the committed document's tag list is per-area, not per-module. `OpenApiRouter::default()` rather than `new()`, because `new()` fills info.contact and info.license from utoipa's own Cargo.toml. The `axum_extras` feature stays off so the annotations are authoritative rather than inferred from extractor types. 141 handlers annotated, `inline(...)` throughout -- no components/schemas and no $refs anywhere in the document. The two `Option>` bodies and the `Option` parameters cannot express `pattern`/`maxLength`/`minimum`/ `maximum` or inline enums at container level through utoipa's derive, so 16 named parameter-shape types live in a new src/contract.rs behind a `contract_schema!` macro; STORE_NAMES moved there with them and is re-exported from openapi.rs for the handside allowlist test. A post-generation transform in render() reconciles the document with the committed one. Six steps, each documented at the point of implementation and counted in the module doc: merge the middleware 401, derive `operationId` and `tags` as pure functions of the path, read `Option` as an absence rather than a null (this is also what turns `x-optional-body` into `required: false`), strip the descriptions utoipa derives from doc comments, pin the document-level literals, and emit `parameters: []` on operations that take none. contract_covers_every_router_route is rewritten, not just repointed. It parsed `.route(...)` calls out of the source, and that form no longer exists. The two doors that remain open are now the two it closes: the BYPASSING_ROUTES list (`.route(`, `.route_service(`, `.nest_service(`) must not appear in src/lib.rs, because OpenApiRouter inherits all three as pass-throughs that serve a route with no OpenAPI operation; and every `#[utoipa::path]` in src/**/*.rs must be routed. Both directions were exercised rather than assumed -- injecting a bare `.route("/api/v1/sneaky")` and removing an existing `routes!` entry each fail with the intended message. One intentional line of difference in openapi.json. The serviceToken security-scheme description reads "lib.rs's require_service_token" where it used to read "main.rs's", because Stage 1 moved that middleware out of the crate root. Publishing the old string would state something untrue about where the check lives. That is the only byte that changed, in 296852. 546 passed, 0 failed, 1 ignored: the 544 baseline plus the endless-stream invariant and the new byte-stability test. `cargo build --release` succeeds (the Dockerfile copies src/ wholesale), and clippy --lib --all-targets is clean. --- .../backend-service/Cargo.lock | 44 + .../backend-service/Cargo.toml | 15 + .../backend-service/openapi.json | 2 +- .../backend-service/src/aggregates.rs | 26 + .../backend-service/src/artifacts.rs | 35 + .../backend-service/src/attacker_identity.rs | 19 + .../backend-service/src/audit.rs | 15 + .../backend-service/src/canarytokens.rs | 52 + .../backend-service/src/charts.rs | 170 ++ .../backend-service/src/config.rs | 105 + .../backend-service/src/contract.rs | 183 ++ .../backend-service/src/correlations.rs | 39 + .../backend-service/src/credentials.rs | 62 + .../backend-service/src/dashboard.rs | 14 + .../backend-service/src/detail.rs | 189 ++ .../backend-service/src/event_page.rs | 15 + .../backend-service/src/events.rs | 41 + .../backend-service/src/exports.rs | 165 ++ .../backend-service/src/fusion.rs | 15 + .../backend-service/src/ghidra_submit.rs | 15 + .../src/github_analysis_submit.rs | 15 + .../backend-service/src/gpu_queue.rs | 23 + .../backend-service/src/health.rs | 20 + .../backend-service/src/investigate.rs | 46 + .../backend-service/src/ip_block.rs | 38 + .../backend-service/src/kill_chain.rs | 30 + .../backend-service/src/lib.rs | 362 +-- .../backend-service/src/live.rs | 10 + .../backend-service/src/llm_search.rs | 16 + .../backend-service/src/mail.rs | 15 + .../backend-service/src/ml_health.rs | 10 + .../backend-service/src/obs.rs | 8 + .../backend-service/src/openapi.rs | 2033 +++++------------ .../backend-service/src/overview.rs | 10 + .../backend-service/src/payload_detail.rs | 31 + .../backend-service/src/preferences.rs | 46 + .../backend-service/src/problem_reports.rs | 39 + .../backend-service/src/replay.rs | 43 + .../backend-service/src/reporter_stats.rs | 11 + .../backend-service/src/reports.rs | 14 + .../backend-service/src/reports_api.rs | 133 ++ .../backend-service/src/sandbox_submit.rs | 34 + .../backend-service/src/search.rs | 14 + .../backend-service/src/sensors.rs | 50 + .../backend-service/src/services_control.rs | 43 + .../backend-service/src/session.rs | 15 + .../backend-service/src/stores.rs | 161 ++ .../backend-service/src/topology.rs | 9 + .../backend-service/src/vault_rag.rs | 13 + .../backend-service/src/webhook_delivery.rs | 9 + .../backend-service/src/workbench_api.rs | 120 + 51 files changed, 3011 insertions(+), 1631 deletions(-) create mode 100644 arcane/home/honeypot-dashboard/backend-service/src/contract.rs diff --git a/arcane/home/honeypot-dashboard/backend-service/Cargo.lock b/arcane/home/honeypot-dashboard/backend-service/Cargo.lock index 74e3df62..dad5c2f2 100644 --- a/arcane/home/honeypot-dashboard/backend-service/Cargo.lock +++ b/arcane/home/honeypot-dashboard/backend-service/Cargo.lock @@ -114,6 +114,8 @@ dependencies = [ "tower-http 0.7.1", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-axum", ] [[package]] @@ -1379,6 +1381,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -2413,6 +2421,42 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8765fe27aeff71012a3f90fa474cf1df863a7d0dd81ed25de4e63fff2544994f" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-axum" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865319162dc3d0032e1e40ed11bd49d5b78e8ef576740589fd6823e2e9977423" +dependencies = [ + "axum", + "pastey", + "tower-layer", + "tower-service", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d935f1c83fdf8b88f09bbe050d0cea78ea4afee6d7b0317403228c1200b2c8ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/arcane/home/honeypot-dashboard/backend-service/Cargo.toml b/arcane/home/honeypot-dashboard/backend-service/Cargo.toml index 0478e665..d79ca59d 100644 --- a/arcane/home/honeypot-dashboard/backend-service/Cargo.toml +++ b/arcane/home/honeypot-dashboard/backend-service/Cargo.toml @@ -48,6 +48,21 @@ flate2 = "1" # (the campaign's own DNS-transport exfil shape uses base32, not base64). data-encoding = "2" mail-parser = "0.11.6" +# #3325: the /api contract, generated from the routes rather than +# transcribed beside them. `utoipa-axum` collects the `#[utoipa::path]` +# annotations off the handlers into the same `Router` the process serves, +# so the document and the router cannot describe different APIs. +# +# The `axum_extras` feature is deliberately *not* enabled. It would make +# `#[utoipa::path]` derive parameters and request bodies from each +# handler's extractor signature; every route here answers `Json` or +# a per-surface struct that nothing in the crate enforces, so the derived +# shapes would be a second, silently-different opinion about the same +# routes. With it off, the `params(...)` and `request_body(...)` in each +# annotation are the whole story -- see src/contract.rs for the parameter +# shapes they name. +utoipa = { version = "6", features = ["macros"] } +utoipa-axum = "0.3" [profile.release] lto = "thin" diff --git a/arcane/home/honeypot-dashboard/backend-service/openapi.json b/arcane/home/honeypot-dashboard/backend-service/openapi.json index 15fd55f0..9224dfcc 100644 --- a/arcane/home/honeypot-dashboard/backend-service/openapi.json +++ b/arcane/home/honeypot-dashboard/backend-service/openapi.json @@ -2,7 +2,7 @@ "components": { "securitySchemes": { "serviceToken": { - "description": "The shared secret the Nitro BFF presents on every /api/v1 call (SERVICE_TOKEN; main.rs's require_service_token, #2183). Compared in constant time, and a wrong or missing value is a 401 before the route is even resolved. Browsers never reach this service directly.", + "description": "The shared secret the Nitro BFF presents on every /api/v1 call (SERVICE_TOKEN; lib.rs's require_service_token, #2183). Compared in constant time, and a wrong or missing value is a 401 before the route is even resolved. Browsers never reach this service directly.", "in": "header", "name": "X-Service-Token", "type": "apiKey" diff --git a/arcane/home/honeypot-dashboard/backend-service/src/aggregates.rs b/arcane/home/honeypot-dashboard/backend-service/src/aggregates.rs index 0b59b27b..b456dc4a 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/aggregates.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/aggregates.rs @@ -4,6 +4,7 @@ //! passes through to the routes. // (filter_values below also lives here — small shared aggregation helpers.) +use crate::contract; use axum::{ extract::{Query, State}, http::StatusCode, @@ -53,6 +54,21 @@ pub struct SourcesPage { pub rows: Vec, } +#[utoipa::path( + get, + path = "/api/v1/sources", + summary = "Known source addresses with their event counts.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn sources( State(state): State, Query(q): Query, @@ -140,6 +156,16 @@ pub struct FilterValues { pub kinds: Vec, } +#[utoipa::path( + get, + path = "/api/v1/filter-values", + summary = "Distinct values behind every explorer filter dropdown.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/filter-values — the filter bar's autocomplete vocabularies, /// mirroring the Go tier's /api/filter-values (live terms over the event /// window). diff --git a/arcane/home/honeypot-dashboard/backend-service/src/artifacts.rs b/arcane/home/honeypot-dashboard/backend-service/src/artifacts.rs index d779bea7..9934efac 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/artifacts.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/artifacts.rs @@ -3,6 +3,7 @@ //! by chunk_index). List endpoints exclude the data; download endpoints //! stream the decoded bytes with the stored content type. +use crate::contract; use axum::{ extract::{Path, State}, http::{header, StatusCode}, @@ -23,6 +24,21 @@ fn store_for(kind: &str) -> Option<(&'static str, &'static str)> { } } +#[utoipa::path( + get, + path = "/api/v1/artifacts/{kind}/{key}", + summary = "Artifacts a run produced, one row per filename.", + params( + ("kind" = inline(contract::ArtifactKind), Path, description = "Artifact family."), + ("key" = inline(String), Path, description = "Run id the artifacts belong to (a sha256 for ghidra, a job id for sandbox)."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn list( State(state): State, Path((kind, key)): Path<(String, String)>, @@ -59,6 +75,25 @@ pub async fn list( Ok(Json(json!({"rows": rows}))) } +#[utoipa::path( + get, + path = "/api/v1/artifacts/{kind}/{key}/{filename}", + summary = "Download one artifact of a run.", + params( + ("kind" = inline(contract::ArtifactKind), Path, description = "Artifact family."), + ("key" = inline(String), Path, description = "Run id the artifacts belong to."), + ("filename" = inline(String), Path, description = "Exact stored filename; the handler refuses a path separator or a name outside this key."), + ), + responses( + (status = 200, description = "The stored artifact bytes.", body = inline(serde_json::Value), content_type = "application/octet-stream"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 413, description = "The stored artifact is larger than this endpoint will serve.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn download( State(state): State, Path((kind, key, filename)): Path<(String, String, String)>, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/attacker_identity.rs b/arcane/home/honeypot-dashboard/backend-service/src/attacker_identity.rs index e2919dcf..4e5cc0d4 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/attacker_identity.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/attacker_identity.rs @@ -15,6 +15,7 @@ //! mounts, no local state -- runs as the `attacker-identity` WORKER_LOOPS //! entry on the existing (stateless-by-design) backend-worker service. +use crate::contract; use axum::{ extract::{Path, Query, State}, http::StatusCode, @@ -1519,6 +1520,24 @@ fn default_page_size() -> u64 { 25 } +#[utoipa::path( + get, + path = "/api/v1/attackers/{id}/events", + summary = "The raw evidence behind one attacker entity.", + params( + ("id" = inline(String), Path, description = "Attacker entity id from /api/v1/attackers."), + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 500, description = "The handler failed in a way it does not model as a 4xx.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// The raw evidence behind an entity: resolves the persisted evidence /// pointers (the newest-first event document ids the identity cycle /// records on `attackers-v1`) against `honeypot-v2-*` -- the same family diff --git a/arcane/home/honeypot-dashboard/backend-service/src/audit.rs b/arcane/home/honeypot-dashboard/backend-service/src/audit.rs index 770c4f9f..b5024059 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/audit.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/audit.rs @@ -4,6 +4,7 @@ //! rotated generation (older events are not retained past one rotation — //! matches the Go tier exactly). +use crate::contract; use axum::extract::{Query, State}; use axum::Json; use serde::{Deserialize, Serialize}; @@ -111,6 +112,20 @@ pub struct AuditQuery { action: Option, } +#[utoipa::path( + get, + path = "/api/v1/audit", + summary = "The audit trail, newest first.", + params( + ("limit" = inline(Option), Query, description = "How many entries; clamped to [1, 500] by the handler, default 100."), + ("action" = inline(Option), Query, description = "Only entries with this action."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/audit?limit=&action= — newest first, optional action /// filter, limit clamped to [1, 500] (default 100), ported from /// serveSettingsAudit. diff --git a/arcane/home/honeypot-dashboard/backend-service/src/canarytokens.rs b/arcane/home/honeypot-dashboard/backend-service/src/canarytokens.rs index e82f8381..43f4d8c2 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/canarytokens.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/canarytokens.rs @@ -83,6 +83,15 @@ fn type_info(token_type: &str) -> Option<&'static TokenType> { TYPES.iter().find(|entry| entry.id == token_type) } +#[utoipa::path( + get, + path = "/api/v1/canarytokens/types", + summary = "The canarytoken types this build can mint.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn types() -> Json { Json(json!(TYPES .iter() @@ -126,6 +135,22 @@ pub struct CreatedToken { pub created_at: String, } +#[utoipa::path( + post, + path = "/api/v1/canarytokens", + summary = "Mint a canarytoken.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `CreateBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 500, description = "The handler failed in a way it does not model as a 4xx.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn create( State(state): State, Json(body): Json, @@ -252,6 +277,16 @@ fn redact_record(mut record: Value) -> Value { record } +#[utoipa::path( + get, + path = "/api/v1/canarytokens", + summary = "Minted canarytokens, with their management token redacted.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/canarytokens — every created token's history record, newest /// first, for the Settings pane's history table and credentials' /// link-token id validation. auth_token is the platform's own management @@ -289,6 +324,23 @@ pub async fn list(State(state): State) -> Result, (StatusC Ok(Json(json!({"tokens": records}))) } +#[utoipa::path( + get, + path = "/api/v1/canarytokens/{id}/download", + summary = "The canarytoken's landing URL, as a redirect.", + params( + ("id" = inline(String), Path, description = "Canarytoken id."), + ), + responses( + (status = 302, description = "Redirect to the token's landing URL."), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 500, description = "The handler failed in a way it does not model as a 4xx.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/canarytokens/{id}/download — proxy the token's artifact. /// web_image never goes through /download (fetching the trigger URL /// server-side would itself fire the token). diff --git a/arcane/home/honeypot-dashboard/backend-service/src/charts.rs b/arcane/home/honeypot-dashboard/backend-service/src/charts.rs index a6dac129..7ae210eb 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/charts.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/charts.rs @@ -56,6 +56,16 @@ fn bad_gateway(error: anyhow::Error) -> (StatusCode, String) { (StatusCode::BAD_GATEWAY, error.to_string()) } +#[utoipa::path( + get, + path = "/api/v1/charts/ml-backlog", + summary = "ML anomaly backlog over time.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/charts/ml-backlog — hourly average queue depth per source /// index from ml-worker-metrics' backlog gauge documents. pub async fn ml_backlog(State(state): State) -> Result>, (StatusCode, String)> { @@ -288,6 +298,16 @@ async fn traffic_from_rollup(state: &AppState, hours: usize, name: &str) -> Opti }) } +#[utoipa::path( + get, + path = "/api/v1/charts/netflow-bytes", + summary = "Netflow bytes over time.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn netflow_bytes(State(state): State) -> Result>, (StatusCode, String)> { traffic_sum( &state, @@ -300,6 +320,16 @@ pub async fn netflow_bytes(State(state): State) -> Result) -> Result>, (StatusCode, String)> { traffic_sum( &state, @@ -312,6 +342,16 @@ pub async fn netflow_packets(State(state): State) -> Result) -> Result) -> Result, (S Ok(Json(bar)) } +#[utoipa::path( + get, + path = "/api/v1/charts/os-distribution", + summary = "Fingerprint-derived OS distribution.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/charts/os-distribution — unique attacker IPs per p0f OS guess /// (portbridge-v2-*, #241/#1277). pub async fn os_distribution(State(state): State) -> Result>, (StatusCode, String)> { @@ -427,6 +487,16 @@ pub async fn os_distribution(State(state): State) -> Result) -> Result, ( Ok(Json(bar)) } +#[utoipa::path( + get, + path = "/api/v1/charts/decoy-requests", + summary = "Requests per decoy.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/charts/decoy-requests — what was requested from the TLS-terminated /// decoys (`traefik-v1-*`, #1739). /// @@ -557,6 +647,16 @@ pub async fn decoy_requests(State(state): State) -> Result, fingerprint_bar(&state, &["traefik-v1-*"], body, "paths").await.map(Json).map_err(bad_gateway) } +#[utoipa::path( + get, + path = "/api/v1/charts/decoy-client-fingerprints", + summary = "Decoy requests joined against ClientHello fingerprints.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/charts/decoy-client-fingerprints — the JA4 of clients that actually /// reached a TLS-terminated decoy (#1765). /// @@ -611,6 +711,16 @@ pub async fn decoy_client_fingerprints( fingerprint_bar(&state, &["huginn-v1-*"], body, "ja4").await.map(Json).map_err(bad_gateway) } +#[utoipa::path( + get, + path = "/api/v1/charts/ja4h-fingerprints", + summary = "JA4H fingerprint distribution.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/charts/ja4h-fingerprints — HTTP client fingerprints (`http.log`). /// /// The HTTP counterpart to the TLS JA4 chart below: it fingerprints the @@ -625,6 +735,16 @@ pub async fn ja4h_fingerprints(State(state): State) -> Result) -> Result) -> Result) -> Result, (StatusCode, String)> { @@ -672,6 +812,16 @@ pub async fn tls_fingerprints(State(state): State) -> Result fingerprint_bar(&state, &["suricata-v2-tls-*"], body, "ja4").await.map(Json).map_err(bad_gateway) } +#[utoipa::path( + get, + path = "/api/v1/charts/ssh-fingerprints", + summary = "SSH fingerprint distribution.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/charts/ssh-fingerprints — SSH client software counts. pub async fn ssh_fingerprints(State(state): State) -> Result, (StatusCode, String)> { let body = json!({ @@ -682,6 +832,16 @@ pub async fn ssh_fingerprints(State(state): State) -> Result fingerprint_bar(&state, &["suricata-v2-ssh-*"], body, "software").await.map(Json).map_err(bad_gateway) } +#[utoipa::path( + get, + path = "/api/v1/charts/ml-anomaly-scores", + summary = "ML anomaly scores over time.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/charts/ml-anomaly-scores — one scatter series per detector /// model plus the composite (#1284), reshaped from ml-anomalies docs. pub async fn ml_anomaly_scores(State(state): State) -> Result>, (StatusCode, String)> { @@ -769,6 +929,16 @@ fn held_ranges() -> Vec { .collect() } +#[utoipa::path( + get, + path = "/api/v1/charts/endlessh-held-histogram", + summary = "How long endlessh held each connection.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn endlessh_histogram(State(state): State) -> Result, (StatusCode, String)> { let ranges = held_ranges(); let body = json!({ diff --git a/arcane/home/honeypot-dashboard/backend-service/src/config.rs b/arcane/home/honeypot-dashboard/backend-service/src/config.rs index 3fc6b4a5..3621f51a 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/config.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/config.rs @@ -29,6 +29,7 @@ //! - GET /api/v1/users — the known-operators roster (subjects, roles, //! seen timestamps; per-user preference blobs stay out of the list). +use crate::contract; use axum::{ extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, @@ -61,6 +62,16 @@ pub(crate) async fn load_config(state: &AppState) -> anyhow::Result) -> Result, (StatusCode, String)> { let doc = load_config(&state) .await @@ -189,6 +200,27 @@ async fn put_config_field( Ok(Json(doc).into_response()) } +#[utoipa::path( + put, + path = "/api/v1/config/presentation", + summary = "Replace the presentation block (branding, theme, landing copy).", + params( + ("actor_subject" = inline(Option), Query, description = "OIDC subject recorded on the audit/history entry."), + ("actor_username" = inline(Option), Query, description = "Operator name recorded on the audit/history entry."), + ("If-Match" = inline(Option), Header, description = "Optional optimistic-concurrency revision, as a weak ETag (`W/\"7\"`). A mismatch answers 409."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `serde_json::Value`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "The stored presentation block and its new revision."), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 409, description = "The record changed since the revision the caller presented.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn put_presentation( State(state): State, Query(actor): Query, @@ -213,6 +245,28 @@ fn config_section_key(section: &str) -> Option<&'static str> { } } +#[utoipa::path( + put, + path = "/api/v1/config/{section}", + summary = "Replace one settings section.", + params( + ("section" = inline(contract::ConfigSection), Path, description = "Settings section to replace."), + ("actor_subject" = inline(Option), Query, description = "OIDC subject recorded on the audit/history entry."), + ("actor_username" = inline(Option), Query, description = "Operator name recorded on the audit/history entry."), + ("If-Match" = inline(Option), Header, description = "Optional optimistic-concurrency revision, as a weak ETag (`W/\"7\"`). A mismatch answers 409."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `serde_json::Value`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "The stored section and its new revision."), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 409, description = "The record changed since the revision the caller presented.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// PUT /api/v1/config/{section} — identical in shape to put_presentation, /// just parameterized over which `payload.*` block it replaces. Backs the /// three settings.tsx admin panes that were previously entirely missing @@ -239,6 +293,15 @@ pub async fn put_config_section( put_config_field(&state, actor, expected, payload_key, value).await } +#[utoipa::path( + get, + path = "/api/v1/config/history", + summary = "The revision history the rollback picker reads (payloads excluded).", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] /// configHistoryView-equivalent: everything needed for review and rollback /// selection, without the retained payload snapshot itself. pub async fn history(State(state): State) -> Json { @@ -269,6 +332,25 @@ pub struct RollbackBody { actor_username: String, } +#[utoipa::path( + post, + path = "/api/v1/config/rollback", + summary = "Restore a past configuration revision.", + params( + ("If-Match" = inline(Option), Header, description = "Optional optimistic-concurrency revision, as a weak ETag (`W/\"7\"`). A mismatch answers 409."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `RollbackBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "The restored configuration and its new revision."), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 409, description = "The record changed since the revision the caller presented.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// Restores one retained revision's full payload as a NEW revision — /// history is append-only, rollback never rewrites the past. Accepts the /// same optional `If-Match: ` precondition as the PUT handlers. @@ -332,6 +414,16 @@ pub async fn rollback( Ok(Json(doc).into_response()) } +#[utoipa::path( + get, + path = "/api/v1/users", + summary = "Dashboard users, as the ES-side user store reports them.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn users(State(state): State) -> Result, (StatusCode, String)> { let result = state .es @@ -360,6 +452,19 @@ pub async fn users(State(state): State) -> Result, (Status Ok(Json(json!({"users": rows}))) } +#[utoipa::path( + post, + path = "/api/v1/config/validate", + summary = "Check a candidate configuration without storing it.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `serde_json::Value`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// POST /api/v1/config/validate — persist-nothing preview, mirroring Go's /// serveSettingsConfigValidate at the depth this Value-level tier can /// honestly claim: the body is an object of config sections (the same diff --git a/arcane/home/honeypot-dashboard/backend-service/src/contract.rs b/arcane/home/honeypot-dashboard/backend-service/src/contract.rs new file mode 100644 index 00000000..981d0898 --- /dev/null +++ b/arcane/home/honeypot-dashboard/backend-service/src/contract.rs @@ -0,0 +1,183 @@ +//! The parameter shapes the /api contract pins, as named types. +//! +//! # Why a type per shape rather than a schema literal at each use +//! +//! #3325's contract is generated by `utoipa` now, which means the schemas +//! reach the document through the types the handler annotations name. Most +//! of what the old hand-written table pinned was a handful of shapes +//! repeated: a non-negative integer, a page size, a hex subject id, an +//! enum of store names. Giving each one a name here means the shape is +//! written down once and the annotations say which shape they mean, and a +//! reviewer changing the page-size bound changes it in one file. +//! +//! Every one of these is a *contract* claim, not a validation: nothing in +//! the crate enforces them, and the `document_is_well_formed` test in +//! `openapi.rs` only checks that the document is loadable. A schema here +//! that the handler does not honour is a contract that lies, which is the +//! one failure mode the old table's module doc called out. They are +//! transcribed from the handler structs the parameters mirror, and each +//! type's doc comment names the struct it tracks so a reader can check it +//! in one jump. +//! +//! Each one is emitted *inline* into the operation that uses it -- hence +//! `inline(...)` at every use site -- so the document needs no +//! `components/schemas` section to be self-contained. + +use serde_json::json; +use utoipa::__dev::ComposeSchema; +use utoipa::openapi::RefOr; +use utoipa::openapi::schema::{Object, Schema, SchemaType}; +use utoipa::ToSchema; + +/// Builds a schema object from a JSON literal, so what the document carries +/// is exactly the literal and nothing this module forgot to say. +fn literal(raw: serde_json::Value) -> Schema { + // An empty object is the free-form schema: one that constrains nothing, + // which accepts any JSON value. utoipa models that as + // `SchemaType::AnyValue`, the one type it omits on the way out -- and + // the one shape that cannot come back in through `Object`, which + // requires its `type`. + if raw.as_object().is_some_and(serde_json::Map::is_empty) { + return Schema::Object(Object::with_type(SchemaType::AnyValue)); + } + let object: Object = + serde_json::from_value(raw).expect("every literal in this module is a valid schema"); + Schema::Object(object) +} + +/// Implements the three traits utoipa needs for a hand-written schema: the +/// object itself, its composition under generics, and the registration hook +/// (which stays empty because every use site asks for the schema inline). +macro_rules! contract_schema { + ($(#[$meta:meta])* $name:ident = $schema:expr) => { + $(#[$meta])* + pub struct $name; + + impl ComposeSchema for $name { + fn compose(_: Vec>) -> RefOr { + RefOr::T(literal($schema)) + } + } + + impl ToSchema for $name {} + }; +} + +contract_schema!( + /// `u64` page offsets. Traces `stores::PageQuery::offset` and the same + /// field on `events::EventsQuery` and `aggregates::PageQuery`. + NonNegativeInt = json!({"type": "integer", "format": "int64", "minimum": 0}) +); + +contract_schema!( + /// A count that cannot be zero. `stores::StoreQuery::size`, + /// `aggregates::PageQuery::size`, `llm_search`'s `limit`, and the + /// `limit` on `/api/v1/llm-search`. + PositiveInt = json!({"type": "integer", "format": "int64", "minimum": 1}) +); + +contract_schema!( + /// The page size the store family clamps to 100 in the handler. + /// `stores::StoreQuery::size` and `events::EventsQuery::size`. + PageSize = json!({"type": "integer", "format": "int64", "minimum": 1, "maximum": 100}) +); + +contract_schema!( + /// `/api/v1/audit`'s `limit`, which the handler clamps to `[1, 500]`. + /// `audit::AuditQuery::limit`. + AuditLimit = json!({"type": "integer", "minimum": 1, "maximum": 500}) +); + +contract_schema!( + /// `/api/v1/services/{name}/logs`'s `lines`, defaulted to 200 by the + /// handler. `services_control::LogsQuery::lines`. + LogLines = json!({"type": "integer", "format": "int32", "minimum": 1}) +); + +contract_schema!( + /// A count with no upper bound the contract states. The `limit` on + /// `/api/v1/sensors/{sensor}/events`, which the handler clamps but + /// `sensors::EventsQuery` does not bound. + PositiveCount = json!({"type": "integer", "minimum": 1}) +); + +contract_schema!( + /// The `sensor` path segment. `sensors::EventsQuery`/`OverviewQuery` + /// reject an empty value or one over 128 characters. + SensorName = json!({"type": "string", "maxLength": 128}) +); + +contract_schema!( + /// A 32- or 64-character hex payload id. `payload_detail` rejects + /// anything else with a 400. + PayloadHash = json!({"type": "string", "pattern": "^[0-9a-fA-F]{32}([0-9a-fA-F]{32})?$"}) +); + +contract_schema!( + /// The `{sha}` segment on the Ghidra, RevDeck, CAPE and GitHub-analysis + /// routes: 8 to 64 hex characters. + AnalysisSha = json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}) +); + +contract_schema!( + /// The attacker-cluster kinds. `zeek_proxy_attribution`'s cluster + /// resolver, and the same set the `/api/v1/clusters` store buckets by. + ClusterKind = json!({"type": "string", "enum": ["fingerprint", "payload", "asn", "provider"]}) +); + +contract_schema!( + /// The artifact families `/api/v1/artifacts/{kind}/...` exposes. The + /// analyser adapter answers anything else with a 404. + ArtifactKind = json!({"type": "string", "enum": ["ghidra", "sandbox"]}) +); + +contract_schema!( + /// The lifecycle actions `/api/v1/services/{name}/{action}` accepts. + ServiceAction = json!({"type": "string", "enum": ["start", "stop", "restart"]}) +); + +contract_schema!( + /// The child-run actions on + /// `/api/v1/workbench/runs/{id}/children/{analyzer_id}/{action}`. + ChildAction = json!({"type": "string", "enum": ["cancel", "retry"]}) +); + +contract_schema!( + /// The settings sections `PUT /api/v1/config/{section}` replaces. + ConfigSection = json!({"type": "string", "enum": ["honeypot", "behavior", "report-presets"]}) +); + +/// The allowlisted store names, mirroring `stores.rs`'s `store_config` +/// match. A copy rather than a reference because the contract is a +/// document, not a view of the binary -- but it is a copy with teeth: +/// anything outside this list is a 404 at runtime, so widening the +/// contract's enum without widening the allowlist would advertise a +/// route that cannot work. `the_store_enum_matches_the_handside_allowlist` +/// in `openapi.rs` is what keeps the copy honest. +pub const STORE_NAMES: &[&str] = &[ + "agent-campaigns", + "auth-events", + "canarytokens", + "cape", + "dead-letters", + "generated-reports", + "ghidra-runs", + "github-analysis", + "intelligence", + "llm-analysis", + "ml-anomalies", + "problem-reports", + "report-definitions", + "revdeck", + "sandbox-runs", + "static-analysis", + "workbench-runs", + "yara", +]; + +contract_schema!( + /// The `{name}` segment on `/api/v1/store/{name}` -- see + /// [`STORE_NAMES`], which this is generated from so the two cannot + /// disagree. + StoreName = json!({"type": "string", "enum": STORE_NAMES}) +); diff --git a/arcane/home/honeypot-dashboard/backend-service/src/correlations.rs b/arcane/home/honeypot-dashboard/backend-service/src/correlations.rs index 2a0c0880..cd29e24f 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/correlations.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/correlations.rs @@ -222,6 +222,20 @@ fn missing_community() -> (StatusCode, String) { (StatusCode::NOT_FOUND, "no such flow".into()) } +#[utoipa::path( + get, + path = "/api/v1/connections/{community_id}", + summary = "Every record that shares one community_id flow hash.", + params( + ("community_id" = inline(String), Path, description = "network.community_id flow hash, as computed independently by each sensor."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// One materialized link, by community_id. pub async fn flow_by_id( State(state): State, @@ -234,6 +248,21 @@ pub async fn flow_by_id( } } +#[utoipa::path( + get, + path = "/api/v1/event/{id}/connections", + summary = "The same-flow summary and re-used-wordlist edges for one event.", + params( + ("id" = inline(String), Path, description = "Event document id."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// The materialized form of one event's connection: resolves the event's /// own community_id first, then reads its link. Events whose flow never /// reached two families have no link — a 404 here means exactly that, not @@ -290,6 +319,16 @@ pub struct CredEdge { pub last: String, } +#[utoipa::path( + get, + path = "/api/v1/cred-reuse", + summary = "Credential pairs reused across more than one address.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// Most-shared credentials first — the re-used-wordlist signal at its most /// concentrated. pub async fn cred_reuse(State(state): State) -> Result>, (StatusCode, String)> { diff --git a/arcane/home/honeypot-dashboard/backend-service/src/credentials.rs b/arcane/home/honeypot-dashboard/backend-service/src/credentials.rs index 8465e364..cbe92ad4 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/credentials.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/credentials.rs @@ -84,6 +84,15 @@ async fn get(state: &AppState, id: &str) -> Option { state.es.get_doc(INDEX, id).await.ok().flatten() } +#[utoipa::path( + get, + path = "/api/v1/credentials", + summary = "HoneyFS implant credentials, with secrets redacted.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/credentials — list every provisioned credential, newest /// first. pub async fn list(State(state): State) -> Json { @@ -127,6 +136,22 @@ pub struct CreateBody { actor_username: String, } +#[utoipa::path( + post, + path = "/api/v1/credentials", + summary = "Provision a honeyfs-implant credential.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `CreateBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 500, description = "The handler failed in a way it does not model as a 4xx.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// POST /api/v1/credentials — provision a new credential (implants it /// live, then records it). Cowrie-honeyfs is the only implant target this /// pass wires up ("cowrie_honeyfs", matching the Go tier — Beelzebub's @@ -212,6 +237,25 @@ pub struct RotateBody { actor_username: String, } +#[utoipa::path( + post, + path = "/api/v1/credentials/{id}/rotate", + summary = "Rotate a honeyfs-implant credential's secret.", + params( + ("id" = inline(String), Path, description = "HoneyFS implant credential id."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `RotateBody`. The shape is left open here on purpose -- see the module doc.", extensions(("x-optional-body" = json!(true)))), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 500, description = "The handler failed in a way it does not model as a 4xx.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// POST /api/v1/credentials/{id}/rotate — rotate a credential's password /// (re-implants at the same path with a freshly rendered body; "Rotation = /// calling implant again with new content", no separate verb on the wire). @@ -277,6 +321,24 @@ pub struct LinkTokenBody { actor_username: String, } +#[utoipa::path( + post, + path = "/api/v1/credentials/{id}/link-token", + summary = "Mint a link token for a honeyfs-implant credential.", + params( + ("id" = inline(String), Path, description = "HoneyFS implant credential id."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `LinkTokenBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 500, description = "The handler failed in a way it does not model as a 4xx.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// POST /api/v1/credentials/{id}/link-token — associate/clear a /// canarytoken id. Bookkeeping only, per the #1487 design comment: "a /// dashboard-side data-model concern only ... no new backend mechanism." diff --git a/arcane/home/honeypot-dashboard/backend-service/src/dashboard.rs b/arcane/home/honeypot-dashboard/backend-service/src/dashboard.rs index 9e106139..368a9bf4 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/dashboard.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/dashboard.rs @@ -251,6 +251,20 @@ fn clean(value: &str) -> String { value.chars().filter(|c| !c.is_control()).collect::().replace("\\x00", "") } +#[utoipa::path( + get, + path = "/api/v1/overview/dashboard", + summary = "The one aggregation the overview page renders, sliced by ?parts=.", + params( + ("parts" = inline(Option), Query, description = "Comma-separated subset of slice names; absent or empty means every slice."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn dashboard( State(state): State, Query(query): Query, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/detail.rs b/arcane/home/honeypot-dashboard/backend-service/src/detail.rs index afddf35c..d72ed5fb 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/detail.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/detail.rs @@ -8,6 +8,7 @@ //! - POST /api/v1/ml-anomalies/ack — ack state keyed by the anomaly doc //! _id in dashboard-ml-anomaly-ack-v1 (#913 contract). +use crate::contract; use axum::{ extract::{Path, Query, State}, http::StatusCode, @@ -50,6 +51,20 @@ async fn one_doc( .ok_or((StatusCode::NOT_FOUND, "not found".to_string())) } +#[utoipa::path( + get, + path = "/api/v1/sandbox/{job}", + summary = "One sandbox run.", + params( + ("job" = inline(String), Path, description = "Sandbox job id."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn sandbox_run( State(state): State, Path(job): Path, @@ -65,6 +80,20 @@ pub async fn sandbox_run( .await } +#[utoipa::path( + get, + path = "/api/v1/ghidra/{sha}", + summary = "One Ghidra analysis run.", + params( + ("sha" = inline(contract::AnalysisSha), Path, description = "Payload/analysis subject id, lower-case hex."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn ghidra_run( State(state): State, Path(sha): Path, @@ -83,6 +112,20 @@ pub async fn ghidra_run( const GHIDRA_CALLGRAPH_MAX_NODES: usize = 200; +#[utoipa::path( + get, + path = "/api/v1/ghidra-callgraph/{sha}", + summary = "The call graph one Ghidra run produced.", + params( + ("sha" = inline(contract::AnalysisSha), Path, description = "Payload/analysis subject id, lower-case hex."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/ghidra-callgraph/{sha} — an interactive complement to the /// static graphviz SVG the detail page already embeds as an , built /// from the same per-function Callers/Callees cross-reference data @@ -202,6 +245,20 @@ fn build_ghidra_callgraph(functions: &[Value]) -> Value { json!({"nodes": graph.nodes, "edges": graph.edges, "truncated": graph.truncated}) } +#[utoipa::path( + get, + path = "/api/v1/revdeck/{sha}", + summary = "One RevDeck analysis run.", + params( + ("sha" = inline(contract::AnalysisSha), Path, description = "Payload/analysis subject id, lower-case hex."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/revdeck/{sha} — #1611 workstream E.8: revdeck-analysis-v1 had /// no detail endpoint at all, so an unconfigured-worker error state (the /// live audit's own example) rendered as a blank page rather than a @@ -222,6 +279,20 @@ pub async fn revdeck_run( Ok(Json(doc["revdeck"].clone())) } +#[utoipa::path( + get, + path = "/api/v1/cape/{sha}", + summary = "One CAPE analysis run.", + params( + ("sha" = inline(contract::AnalysisSha), Path, description = "Payload/analysis subject id, lower-case hex."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/cape/{sha} — one CAPE detonation result, ported from cape.go's /// capeData. `report` is CAPE's own raw report — tens of thousands of /// API-call entries per traced process is normal — so it's never shipped @@ -249,6 +320,20 @@ pub async fn cape_run( Ok(Json(result)) } +#[utoipa::path( + get, + path = "/api/v1/cape/{sha}/raw", + summary = "The raw CAPE report JSON for one run.", + params( + ("sha" = inline(contract::AnalysisSha), Path, description = "Payload/analysis subject id, lower-case hex."), + ), + responses( + (status = 200, description = "The stored report, verbatim.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/cape/{sha}/raw — the untouched result, full report included. /// A distinct route from cape_run above (not a query flag on it) so the /// page's own fetch never accidentally pulls the full report in — this is @@ -266,6 +351,20 @@ pub async fn cape_raw( Ok(Json(doc["cape"].clone())) } +#[utoipa::path( + get, + path = "/api/v1/github-analysis/{sha}", + summary = "One GitHub analysis run.", + params( + ("sha" = inline(contract::AnalysisSha), Path, description = "Payload/analysis subject id, lower-case hex."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/github-analysis/{sha} — one publication result, ported from /// github_analysis.go's githubAnalysisData. Adds two fields the producer /// scripts never write, computed here the same way Go's dashboard layer @@ -380,6 +479,21 @@ pub struct GraphQuery { pub id: String, } +#[utoipa::path( + get, + path = "/api/v1/attackers-graph", + summary = "The node/edge graph around one attacker entity.", + params( + ("id" = inline(Option), Query, description = "Attacker entity id."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn attackers_graph( State(state): State, Query(query): Query, @@ -422,6 +536,20 @@ pub struct VectorsQuery { pub sensor: String, } +#[utoipa::path( + get, + path = "/api/v1/attack-vectors", + summary = "Attack vectors for one sensor.", + params( + ("sensor" = inline(Option), Query, description = "A specific sensor. Empty, suricata and portbridge are all rejected: those ship to their own index families."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn attack_vectors( State(state): State, Query(query): Query, @@ -485,6 +613,20 @@ pub struct MlAckBody { pub actor: String, } +#[utoipa::path( + post, + path = "/api/v1/ml-anomalies/ack", + summary = "Acknowledge one ML anomaly.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `MlAckBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn ml_anomaly_ack( State(state): State, Json(body): Json, @@ -517,6 +659,16 @@ pub struct MlAckAllBody { pub actor: String, } +#[utoipa::path( + get, + path = "/api/v1/ml-anomalies/stats", + summary = "Ack statistics.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/ml-anomalies/stats — #2396's exact all-time backlog numbers. /// The frontend can only see the ack sidecar wholesale and the dispositioned /// population through paginated windows, so it cannot form the union the @@ -575,6 +727,19 @@ pub async fn ml_anomaly_stats(State(state): State) -> Result) -> Result, (StatusCode, String)> { @@ -658,6 +833,20 @@ pub struct MlDispositionBody { pub actor: String, } +#[utoipa::path( + post, + path = "/api/v1/ml-anomalies/disposition", + summary = "Record an analyst disposition for anomalies.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `MlDispositionBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// POST /api/v1/ml-anomalies/disposition — #1968's operator verdict, written /// ONTO the ml-anomalies document itself so the labelled corpus #1794/#1797 /// feed on lives beside the score it judges. Deliberately an `_update` diff --git a/arcane/home/honeypot-dashboard/backend-service/src/event_page.rs b/arcane/home/honeypot-dashboard/backend-service/src/event_page.rs index 8d6aad92..1eae69de 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/event_page.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/event_page.rs @@ -155,6 +155,21 @@ async fn relation(state: &AppState, key: &str, filter: Value, exclude_id: &str) } } +#[utoipa::path( + get, + path = "/api/v1/event/{id}", + summary = "One event, with the pivot groups its detail pane needs.", + params( + ("id" = inline(String), Path, description = "Event document id."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn get( State(state): State, Path(id): Path, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/events.rs b/arcane/home/honeypot-dashboard/backend-service/src/events.rs index d80852e3..24d33695 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/events.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/events.rs @@ -3,6 +3,7 @@ //! exactly the next batch, nothing loads on scroll) and the filter fields //! the Go explorer exposes (ip, sensor, country, port, proto, since). +use crate::contract; use axum::{ extract::{Query, State}, http::StatusCode, @@ -608,6 +609,46 @@ pub fn build_filters(q: &EventsQuery) -> Vec { filters } +#[utoipa::path( + get, + path = "/api/v1/events", + summary = "Event explorer page: the shared filter set, windowed.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size, clamped to 100 by the handler."), + ("ip" = inline(Option), Query, description = "Single source address."), + ("ips" = inline(Option), Query, description = "Comma-separated source addresses."), + ("sensor" = inline(Option), Query, description = "Sensor name (honeypot.dionaea, suricata, ...)."), + ("country" = inline(Option), Query, description = "ISO country code."), + ("city" = inline(Option), Query, description = "City name, as bucketed on the overview map."), + ("port" = inline(Option), Query, description = "Destination port."), + ("proto" = inline(Option), Query, description = "Transport protocol."), + ("kind" = inline(Option), Query, description = "honeypot.event kind (command, login, ...)."), + ("shasum" = inline(Option), Query, description = "Captured-payload hash."), + ("community_id" = inline(Option), Query, description = "One flow across every sensor that saw it."), + ("q" = inline(Option), Query, description = "Free-text query_string, passed to Elasticsearch as-is."), + ("since" = inline(Option), Query, description = "Go-style relative window (24h, 7d)."), + ("persona" = inline(Option), Query, description = "Decoy persona id."), + ("site" = inline(Option), Query, description = "Decoy site id."), + ("asset" = inline(Option), Query, description = "Decoy asset id."), + ("fingerprint" = inline(Option), Query, description = "Client fingerprint, matched across every field sensors record one in."), + ("cmd" = inline(Option), Query, description = "Exact command text."), + ("cred" = inline(Option), Query, description = "\"user / pass\" pair."), + ("path" = inline(Option), Query, description = "Request path."), + ("session" = inline(Option), Query, description = "Session id."), + ("asn" = inline(Option), Query, description = "Source AS number."), + ("org" = inline(Option), Query, description = "Source network organization."), + ("provider" = inline(Option), Query, description = "Provider class."), + ("sig" = inline(Option), Query, description = "IDS alert signature."), + ("cat" = inline(Option), Query, description = "Detection category (Suricata alert category or honeypot.category)."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn list( State(state): State, Query(q): Query, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/exports.rs b/arcane/home/honeypot-dashboard/backend-service/src/exports.rs index 3dd47921..c6f0d1ad 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/exports.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/exports.rs @@ -14,6 +14,7 @@ //! - events.csv omits Go's "provider" column (a classification this tier //! has no confirmed source field for) — every other column present. +use crate::contract; use axum::{ extract::{Query, State}, http::{header, StatusCode}, @@ -96,6 +97,46 @@ fn joined(value: &Value) -> String { value.as_array().into_iter().flatten().filter_map(|item| item.as_str()).collect::>().join(" ") } +#[utoipa::path( + get, + path = "/api/v1/export/events.csv", + summary = "The event explorer as CSV, same filters as /events.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size, clamped to 100 by the handler."), + ("ip" = inline(Option), Query, description = "Single source address."), + ("ips" = inline(Option), Query, description = "Comma-separated source addresses."), + ("sensor" = inline(Option), Query, description = "Sensor name (honeypot.dionaea, suricata, ...)."), + ("country" = inline(Option), Query, description = "ISO country code."), + ("city" = inline(Option), Query, description = "City name, as bucketed on the overview map."), + ("port" = inline(Option), Query, description = "Destination port."), + ("proto" = inline(Option), Query, description = "Transport protocol."), + ("kind" = inline(Option), Query, description = "honeypot.event kind (command, login, ...)."), + ("shasum" = inline(Option), Query, description = "Captured-payload hash."), + ("community_id" = inline(Option), Query, description = "One flow across every sensor that saw it."), + ("q" = inline(Option), Query, description = "Free-text query_string, passed to Elasticsearch as-is."), + ("since" = inline(Option), Query, description = "Go-style relative window (24h, 7d)."), + ("persona" = inline(Option), Query, description = "Decoy persona id."), + ("site" = inline(Option), Query, description = "Decoy site id."), + ("asset" = inline(Option), Query, description = "Decoy asset id."), + ("fingerprint" = inline(Option), Query, description = "Client fingerprint, matched across every field sensors record one in."), + ("cmd" = inline(Option), Query, description = "Exact command text."), + ("cred" = inline(Option), Query, description = "\"user / pass\" pair."), + ("path" = inline(Option), Query, description = "Request path."), + ("session" = inline(Option), Query, description = "Session id."), + ("asn" = inline(Option), Query, description = "Source AS number."), + ("org" = inline(Option), Query, description = "Source network organization."), + ("provider" = inline(Option), Query, description = "Provider class."), + ("sig" = inline(Option), Query, description = "IDS alert signature."), + ("cat" = inline(Option), Query, description = "Detection category (Suricata alert category or honeypot.category)."), + ), + responses( + (status = 200, description = "CSV of the matching events.", body = inline(serde_json::Value), content_type = "text/csv"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/export/events.csv pub async fn events_csv( State(state): State, @@ -163,6 +204,46 @@ pub async fn events_csv( )) } +#[utoipa::path( + get, + path = "/api/v1/export/commands.csv", + summary = "Matching commands as CSV.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size, clamped to 100 by the handler."), + ("ip" = inline(Option), Query, description = "Single source address."), + ("ips" = inline(Option), Query, description = "Comma-separated source addresses."), + ("sensor" = inline(Option), Query, description = "Sensor name (honeypot.dionaea, suricata, ...)."), + ("country" = inline(Option), Query, description = "ISO country code."), + ("city" = inline(Option), Query, description = "City name, as bucketed on the overview map."), + ("port" = inline(Option), Query, description = "Destination port."), + ("proto" = inline(Option), Query, description = "Transport protocol."), + ("kind" = inline(Option), Query, description = "honeypot.event kind (command, login, ...)."), + ("shasum" = inline(Option), Query, description = "Captured-payload hash."), + ("community_id" = inline(Option), Query, description = "One flow across every sensor that saw it."), + ("q" = inline(Option), Query, description = "Free-text query_string, passed to Elasticsearch as-is."), + ("since" = inline(Option), Query, description = "Go-style relative window (24h, 7d)."), + ("persona" = inline(Option), Query, description = "Decoy persona id."), + ("site" = inline(Option), Query, description = "Decoy site id."), + ("asset" = inline(Option), Query, description = "Decoy asset id."), + ("fingerprint" = inline(Option), Query, description = "Client fingerprint, matched across every field sensors record one in."), + ("cmd" = inline(Option), Query, description = "Exact command text."), + ("cred" = inline(Option), Query, description = "\"user / pass\" pair."), + ("path" = inline(Option), Query, description = "Request path."), + ("session" = inline(Option), Query, description = "Session id."), + ("asn" = inline(Option), Query, description = "Source AS number."), + ("org" = inline(Option), Query, description = "Source network organization."), + ("provider" = inline(Option), Query, description = "Provider class."), + ("sig" = inline(Option), Query, description = "IDS alert signature."), + ("cat" = inline(Option), Query, description = "Detection category (Suricata alert category or honeypot.category)."), + ), + responses( + (status = 200, description = "CSV of matching commands.", body = inline(serde_json::Value), content_type = "text/csv"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/export/commands.csv — see module doc: exports the same /// `events?kind=command` scope commands.tsx itself renders. pub async fn commands_csv( @@ -195,6 +276,21 @@ pub async fn commands_csv( Ok(csv_response("honeypot-commands.csv", csv_body(&["time", "sensor", "source_ip", "command", "session"], &rows))) } +#[utoipa::path( + get, + path = "/api/v1/export/ips.csv", + summary = "Every source address in the window as CSV.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ), + responses( + (status = 200, description = "CSV of source addresses.", body = inline(serde_json::Value), content_type = "text/csv"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/export/ips.csv — same aggregation aggregates::sources /// backs /ips with, just a higher cap (that endpoint's own 1000-row cap, /// already well above the page's 25-row pagination). @@ -227,6 +323,21 @@ pub async fn ips_csv(State(state): State) -> Result), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ), + responses( + (status = 200, description = "CSV of campaigns.", body = inline(serde_json::Value), content_type = "text/csv"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/export/campaigns.csv pub async fn campaigns_csv(State(state): State) -> Result { let result = state @@ -288,6 +399,20 @@ pub struct ClustersExportQuery { kind: String, } +#[utoipa::path( + get, + path = "/api/v1/export/clusters.csv", + summary = "Attacker clusters as CSV, by cluster kind.", + params( + ("kind" = inline(Option), Query, description = "Cluster kind to export."), + ), + responses( + (status = 200, description = "CSV of attacker clusters.", body = inline(serde_json::Value), content_type = "text/csv"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/export/clusters.csv — same ?kind= post-aggregation /// narrowing the /clusters page itself applies client-side, applied here /// server-side over the full result set. @@ -314,6 +439,46 @@ pub async fn clusters_csv( Ok(csv_response("honeypot-clusters.csv", csv_body(&["kind", "value", "sources", "events", "sensors"], &rows))) } +#[utoipa::path( + get, + path = "/api/v1/export/history.json", + summary = "The behaviour-search slice as JSON.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size, clamped to 100 by the handler."), + ("ip" = inline(Option), Query, description = "Single source address."), + ("ips" = inline(Option), Query, description = "Comma-separated source addresses."), + ("sensor" = inline(Option), Query, description = "Sensor name (honeypot.dionaea, suricata, ...)."), + ("country" = inline(Option), Query, description = "ISO country code."), + ("city" = inline(Option), Query, description = "City name, as bucketed on the overview map."), + ("port" = inline(Option), Query, description = "Destination port."), + ("proto" = inline(Option), Query, description = "Transport protocol."), + ("kind" = inline(Option), Query, description = "honeypot.event kind (command, login, ...)."), + ("shasum" = inline(Option), Query, description = "Captured-payload hash."), + ("community_id" = inline(Option), Query, description = "One flow across every sensor that saw it."), + ("q" = inline(Option), Query, description = "Free-text query_string, passed to Elasticsearch as-is."), + ("since" = inline(Option), Query, description = "Go-style relative window (24h, 7d)."), + ("persona" = inline(Option), Query, description = "Decoy persona id."), + ("site" = inline(Option), Query, description = "Decoy site id."), + ("asset" = inline(Option), Query, description = "Decoy asset id."), + ("fingerprint" = inline(Option), Query, description = "Client fingerprint, matched across every field sensors record one in."), + ("cmd" = inline(Option), Query, description = "Exact command text."), + ("cred" = inline(Option), Query, description = "\"user / pass\" pair."), + ("path" = inline(Option), Query, description = "Request path."), + ("session" = inline(Option), Query, description = "Session id."), + ("asn" = inline(Option), Query, description = "Source AS number."), + ("org" = inline(Option), Query, description = "Source network organization."), + ("provider" = inline(Option), Query, description = "Provider class."), + ("sig" = inline(Option), Query, description = "IDS alert signature."), + ("cat" = inline(Option), Query, description = "Detection category (Suricata alert category or honeypot.category)."), + ), + responses( + (status = 200, description = "Behaviour-search rows as JSON.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/export/history.json — the same honeypot-v2-*/suricata-v2-* /// query events::list serves, just forced to the export cap and marked as /// a download. Mirrors elastic.go's history(attachment=true). diff --git a/arcane/home/honeypot-dashboard/backend-service/src/fusion.rs b/arcane/home/honeypot-dashboard/backend-service/src/fusion.rs index aae54496..6957c665 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/fusion.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/fusion.rs @@ -68,6 +68,21 @@ const SIGNALS: &[(&str, &[&str], &str, &str)] = &[ ("TCP signature", &["huginn-v1-*"], "huginn.observation.sig", "source.ip"), ]; +#[utoipa::path( + get, + path = "/api/v1/charts/attacker-fusion", + summary = "How one attacker's signals fuse across sources.", + params( + ("id" = inline(Option), Query, description = "Attacker entity id."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn fusion( State(state): State, Query(query): Query, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/ghidra_submit.rs b/arcane/home/honeypot-dashboard/backend-service/src/ghidra_submit.rs index 59b97b2b..84c67ca7 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/ghidra_submit.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/ghidra_submit.rs @@ -22,6 +22,21 @@ pub struct SubmitBody { hash: String, } +#[utoipa::path( + post, + path = "/api/v1/ghidra/submit", + summary = "Queue a Ghidra analysis.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `SubmitBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn submit(Json(body): Json) -> (StatusCode, Json) { let hash = body.hash.to_lowercase(); if !crate::payload_paths::is_valid_hash(&hash) { diff --git a/arcane/home/honeypot-dashboard/backend-service/src/github_analysis_submit.rs b/arcane/home/honeypot-dashboard/backend-service/src/github_analysis_submit.rs index dd04d337..15f2a97c 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/github_analysis_submit.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/github_analysis_submit.rs @@ -42,6 +42,21 @@ fn audit(state: &AppState, body: &SubmitBody, hash: &str, result: &str) { }); } +#[utoipa::path( + post, + path = "/api/v1/github-analysis/submit", + summary = "Queue a GitHub analysis.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `SubmitBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn submit(State(state): State, Json(body): Json) -> (StatusCode, Json) { let hash = body.hash.to_lowercase(); if !crate::payload_paths::is_valid_hash(&hash) { diff --git a/arcane/home/honeypot-dashboard/backend-service/src/gpu_queue.rs b/arcane/home/honeypot-dashboard/backend-service/src/gpu_queue.rs index b5951b8e..dd54bf9c 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/gpu_queue.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/gpu_queue.rs @@ -38,6 +38,16 @@ pub struct GpuJob { pub result: serde_json::Value, } +#[utoipa::path( + get, + path = "/api/v1/gpu-queue", + summary = "The GPU analysis queue as it stands.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn list(State(state): State) -> Result>, (StatusCode, String)> { let body = json!({ "size": 500, @@ -75,6 +85,19 @@ pub async fn list(State(state): State) -> Result>, (S Ok(Json(jobs)) } +#[utoipa::path( + post, + path = "/api/v1/gpu-queue/{job_id}/abort", + summary = "Abort a queued or running GPU job.", + params( + ("job_id" = inline(String), Path, description = "GPU job to abort."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// POST /api/v1/gpu-queue/{job_id}/abort — request cancellation of a queued job. /// /// This is the exact equivalent of gpu_queue.py's `request_abort`: set diff --git a/arcane/home/honeypot-dashboard/backend-service/src/health.rs b/arcane/home/honeypot-dashboard/backend-service/src/health.rs index 55daddfb..b0c92239 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/health.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/health.rs @@ -34,6 +34,16 @@ pub struct Storage { pub store_bytes: u64, } +#[utoipa::path( + get, + path = "/api/v1/settings/storage", + summary = "Index sizes and document counts.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// /api/v1/settings/storage — the ES storage summary the legacy settings /// modal's storage pane shows. pub async fn storage(State(state): State) -> Result, (StatusCode, String)> { @@ -325,6 +335,16 @@ fn sensor_state(age_s: i64, recent_7d: u64) -> &'static str { } } +#[utoipa::path( + get, + path = "/api/v1/source-health", + summary = "Per-source ingestion health, the page behind \"Source & pipeline health\".", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn source_health(State(state): State) -> Result, (StatusCode, String)> { let body = json!({ "size": 0, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/investigate.rs b/arcane/home/honeypot-dashboard/backend-service/src/investigate.rs index 834795b4..47f1783e 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/investigate.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/investigate.rs @@ -28,6 +28,7 @@ //! first place, just re-run scoped to one kind+value instead of every //! cluster at once. +use crate::contract; use axum::{ extract::{Path, Query, State}, http::StatusCode, @@ -221,6 +222,21 @@ fn kv(result: &serde_json::Value, agg: &str) -> Vec { .collect() } +#[utoipa::path( + get, + path = "/api/v1/investigate/ip/{ip}", + summary = "Everything one source address did, across sensors.", + params( + ("ip" = inline(String), Path, description = "Source address to profile. A non-address is a 400."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn ip( State(state): State, Path(ip): Path, @@ -485,6 +501,20 @@ pub struct CidrCorrelation { pub correlation: Correlation, } +#[utoipa::path( + get, + path = "/api/v1/investigate/cidr/{cidr}", + summary = "Correlation across one CIDR block.", + params( + ("cidr" = inline(String), Path, description = "CIDR block to correlate. A malformed block is a 400."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/investigate/cidr/{cidr} — campaigns' "ES →" drill-down: /// everything Elasticsearch has correlated for a whole network at once, /// via the `ip` field type's native CIDR term matching (the same query @@ -558,6 +588,22 @@ fn parse_asn_value(value: &str) -> Option { } } +#[utoipa::path( + get, + path = "/api/v1/investigate/cluster", + summary = "The members of one attacker cluster.", + params( + ("kind" = inline(Option), Query, description = "Cluster kind."), + ("value" = inline(Option), Query, description = "The cluster's value, as /api/v1/clusters reports it."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/investigate/cluster?kind=&value= — clusters' "ES →" /// drill-down: everything Elasticsearch has correlated for a cluster's /// member IPs. Two ES round trips: first recomputes the member IP set for diff --git a/arcane/home/honeypot-dashboard/backend-service/src/ip_block.rs b/arcane/home/honeypot-dashboard/backend-service/src/ip_block.rs index cc78e7fc..b51e25ef 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/ip_block.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/ip_block.rs @@ -42,6 +42,20 @@ pub struct BlockBody { pub actor: String, } +#[utoipa::path( + post, + path = "/api/v1/ip-block", + summary = "Block or unblock an address.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `BlockBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn set_block( State(state): State, Json(body): Json, @@ -72,6 +86,20 @@ pub async fn set_block( Ok(Json(record)) } +#[utoipa::path( + get, + path = "/api/v1/ip-block/{ip}", + summary = "One address's block state.", + params( + ("ip" = inline(String), Path, description = "Address whose block state is wanted."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn get_block( State(state): State, Path(ip): Path, @@ -91,6 +119,16 @@ pub async fn get_block( Ok(Json(out)) } +#[utoipa::path( + get, + path = "/api/v1/ip-block-export", + summary = "The whole block list, for backup or review.", + responses( + (status = 200, description = "The blocked addresses, one per line.", body = inline(serde_json::Value), content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// Plain text list of actively blocked IPs, sorted — byte-compatible /// with the legacy /export/portbridge-manual-blackhole.txt body. pub async fn export(State(state): State) -> Result { diff --git a/arcane/home/honeypot-dashboard/backend-service/src/kill_chain.rs b/arcane/home/honeypot-dashboard/backend-service/src/kill_chain.rs index 866f0896..a5810f35 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/kill_chain.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/kill_chain.rs @@ -143,6 +143,16 @@ async fn coverage_counts(state: &AppState) -> Option> { Some(counts) } +#[utoipa::path( + get, + path = "/api/v1/charts/attck-coverage", + summary = "ATT&CK technique coverage as a grid.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn attck_coverage(State(state): State) -> Result, (StatusCode, String)> { // #2046: summed tech docs are the primary path; the plain terms // aggregation below remains the fall-through while coverage is missing. @@ -223,6 +233,16 @@ async fn sankey_from_rollup(state: &AppState) -> Option { Some(SankeyData { nodes, links }) } +#[utoipa::path( + get, + path = "/api/v1/charts/kill-chain-sankey", + summary = "Kill-chain stages as a sankey.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn sankey(State(state): State) -> Result, (StatusCode, String)> { // #2046: rolled links/touches are the primary path; the two-level // grouping aggregation below remains the fall-through while coverage @@ -301,6 +321,16 @@ pub struct TimelineRow { pub events: u64, } +#[utoipa::path( + get, + path = "/api/v1/charts/campaign-timeline", + summary = "Campaigns over time.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn campaign_timeline( State(state): State, ) -> Result>, (StatusCode, String)> { diff --git a/arcane/home/honeypot-dashboard/backend-service/src/lib.rs b/arcane/home/honeypot-dashboard/backend-service/src/lib.rs index 707f6f89..eb542042 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/lib.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/lib.rs @@ -26,7 +26,7 @@ use axum::{ http::{HeaderMap, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Response}, - routing::{delete, get, patch, post}, + Json, Router, }; use serde::Serialize; @@ -107,6 +107,7 @@ pub mod workbench_domain; pub mod workbench_es; pub mod workbench_orchestrator; pub mod openapi; +pub mod contract; #[derive(Clone)] pub struct AppState { @@ -260,12 +261,29 @@ fn readiness_verdict( Readiness { ready: true, reason: None, cluster, write_blocked: blocked } } +#[utoipa::path( + get, + path = "/livez", + summary = "Liveness. The same handler as /healthz under a second name (main.rs); public like it.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + ), +)] /// GET /livez, and GET /healthz — the same handler under two names. See /// `Liveness` for why the response carries no Elasticsearch signal. async fn livez() -> Json { Json(Liveness { live: true, built: build_stamp(), revision: git_revision() }) } +#[utoipa::path( + get, + path = "/healthz", + summary = "Liveness plus an Elasticsearch reachability flag. Public on purpose: the container healthcheck is the caller.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), +)] /// `/healthz` is the name the image's HEALTHCHECK, the port-test harness /// and the ops scripts already use, so it stays as an alias rather than /// being broken (killing a 2024-era name in a health-probe rename is how a @@ -277,6 +295,14 @@ async fn healthz() -> Json { livez().await } +#[utoipa::path( + get, + path = "/readyz", + summary = "Readiness: Elasticsearch reachable and this tier's write targets checked. Public like /healthz.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + ), +)] /// GET /readyz — see `Readiness`. Unauthenticated exactly like the liveness /// probes and /metrics, because the callers are infrastructure: the deploy /// verifier, diagnostics, and an operator on a jump host. Authentication @@ -470,220 +496,212 @@ pub fn git_revision() -> String { // route table that only `main.rs` can see. `main.rs` still owns state // construction and the listener. +/// The builder the route table is written in: an `axum::Router` that also +/// carries each route's OpenAPI operation. +/// +/// # Why the table names handlers and not paths +/// +/// This used to be `Router::new().route("/api/v1/events", get(events::list))` +/// -- 128 calls that each spelled out a path, a method and a handler. With +/// `ContractRouter` the path and the method live on the handler's +/// `#[utoipa::path]`, and `utoipa_axum::routes!(events::list)` reads them +/// from there to register the route in *both* the served router and the +/// #3325 document. +/// +/// So this table is now an index of the surface rather than a second copy of +/// it, which is the point: a table that spells the path out beside the +/// handler is a second thing to update, and the direction that rots is the +/// one where a new route never reaches it. Reading `events::list` should get +/// you to the path, and it does -- the annotation is on the handler, in +/// `events.rs`, next to the code that answers it. `contract_covers_every_` +/// `router_route` in `openapi.rs` is what keeps the index honest. +pub type ContractRouter = utoipa_axum::router::OpenApiRouter; + /// The /api/v1 surface: every route behind the BFF's service token. /// /// `state` is not a parameter because nothing here needs it: the token /// middleware is layered on by [`router`] below, so the same table serves /// the process and the contract generator. -pub fn api_router() -> Router { - Router::new() - .route("/api/v1/overview/kpis", get(overview::kpis)) - .route("/api/v1/overview/dashboard", get(dashboard::dashboard)) - .route("/api/v1/events", get(events::list)) - .route("/api/v1/export/events.csv", get(exports::events_csv)) - .route("/api/v1/export/commands.csv", get(exports::commands_csv)) - .route("/api/v1/export/ips.csv", get(exports::ips_csv)) - .route("/api/v1/export/campaigns.csv", get(exports::campaigns_csv)) - .route("/api/v1/export/clusters.csv", get(exports::clusters_csv)) - .route("/api/v1/export/history.json", get(exports::history_json)) - .route("/api/v1/live", get(live::stream)) - .route("/api/v1/mail/{session_id}", get(mail::get)) - .route("/api/v1/ml-health", get(ml_health::list)) - .route("/api/v1/gpu-queue", get(gpu_queue::list)) - .route("/api/v1/gpu-queue/{job_id}/abort", post(gpu_queue::abort)) - .route("/api/v1/sources", get(aggregates::sources)) - .route("/api/v1/filter-values", get(aggregates::filter_values)) - .route("/api/v1/investigate/ip/{ip}", get(investigate::ip)) - .route("/api/v1/investigate/cidr/{cidr}", get(investigate::cidr)) - .route("/api/v1/investigate/cluster", get(investigate::cluster)) - .route("/api/v1/source-health", get(health::source_health)) +pub fn api_router() -> ContractRouter { + ContractRouter::default() + .routes(utoipa_axum::routes!(overview::kpis)) + .routes(utoipa_axum::routes!(dashboard::dashboard)) + .routes(utoipa_axum::routes!(events::list)) + .routes(utoipa_axum::routes!(exports::events_csv)) + .routes(utoipa_axum::routes!(exports::commands_csv)) + .routes(utoipa_axum::routes!(exports::ips_csv)) + .routes(utoipa_axum::routes!(exports::campaigns_csv)) + .routes(utoipa_axum::routes!(exports::clusters_csv)) + .routes(utoipa_axum::routes!(exports::history_json)) + .routes(utoipa_axum::routes!(live::stream)) + .routes(utoipa_axum::routes!(mail::get)) + .routes(utoipa_axum::routes!(ml_health::list)) + .routes(utoipa_axum::routes!(gpu_queue::list)) + .routes(utoipa_axum::routes!(gpu_queue::abort)) + .routes(utoipa_axum::routes!(aggregates::sources)) + .routes(utoipa_axum::routes!(aggregates::filter_values)) + .routes(utoipa_axum::routes!(investigate::ip)) + .routes(utoipa_axum::routes!(investigate::cidr)) + .routes(utoipa_axum::routes!(investigate::cluster)) + .routes(utoipa_axum::routes!(health::source_health)) // #3330: the alert fan-out's own delivery outcomes. Also a field on // /api/v1/source-health; this route is what the Settings card // reads, so the operations page's one-snapshot design is not the // only way to get at it. - .route("/api/v1/webhook-delivery", get(webhook_delivery::health)) - .route("/api/v1/event/{id}", get(event_page::get)) + .routes(utoipa_axum::routes!(webhook_delivery::health)) + .routes(utoipa_axum::routes!(event_page::get)) // #2047: materialized cross-sensor correlations — the event page's // same-flow summary and the re-used-wordlist edges. - .route("/api/v1/event/{id}/connections", get(correlations::event_connections)) - .route("/api/v1/connections/{community_id}", get(correlations::flow_by_id)) - .route("/api/v1/cred-reuse", get(correlations::cred_reuse)) - .route("/api/v1/sensors", get(sensors::detail)) + .routes(utoipa_axum::routes!(correlations::event_connections)) + .routes(utoipa_axum::routes!(correlations::flow_by_id)) + .routes(utoipa_axum::routes!(correlations::cred_reuse)) + .routes(utoipa_axum::routes!(sensors::detail)) // #1856: /catalog is registered before /{sensor} so the literal // segment is not swallowed by the capture. A sensor genuinely // named "catalog" would be shadowed; none is, and the alternative // is a query parameter that reads worse for the common case. - .route("/api/v1/sensors/catalog", get(sensors::catalog)) - .route("/api/v1/sensors/{sensor}/events", get(sensors::events)) - .route("/api/v1/sensors/{sensor}/overview", get(sensors::overview)) - .route("/api/v1/sessions/{id}", get(session::detail)) - .route("/api/v1/search", get(search::search)) - .route("/api/v1/topology", get(topology::topology)) - .route("/api/v1/settings/storage", get(health::storage)) - .route("/api/v1/config", get(config::get_config)) - .route( - "/api/v1/config/presentation", - axum::routing::put(config::put_presentation), - ) - .route( - "/api/v1/config/{section}", - axum::routing::put(config::put_config_section), - ) - .route("/api/v1/config/history", get(config::history)) - .route("/api/v1/config/rollback", post(config::rollback)) - .route("/api/v1/config/validate", post(config::validate)) - .route("/api/v1/users", get(config::users)) - .route("/api/v1/audit", get(audit::list)) - .route( - "/api/v1/preferences", - get(preferences::get).put(preferences::put), - ) - .route("/api/v1/preferences/reset", post(preferences::reset)) - .route("/api/v1/reporter-stats", get(reporter_stats::stats)) - .route("/api/v1/services", get(services_control::list)) - .route("/api/v1/services/{name}/logs", get(services_control::logs)) - .route("/api/v1/services/{name}/{action}", post(services_control::action)) - .route("/api/v1/llm-search", get(llm_search::search)) - .route("/api/v1/vault-rag", get(vault_rag::ask)) - .route("/api/v1/ip-block", post(ip_block::set_block)) - .route("/api/v1/ip-block/{ip}", get(ip_block::get_block)) - .route("/api/v1/ip-block-export", get(ip_block::export)) - .route("/api/v1/sandbox/{job}", get(detail::sandbox_run)) - .route("/api/v1/ghidra/{sha}", get(detail::ghidra_run)) - .route("/api/v1/ghidra-callgraph/{sha}", get(detail::ghidra_callgraph)) - .route("/api/v1/revdeck/{sha}", get(detail::revdeck_run)) - .route("/api/v1/cape/{sha}", get(detail::cape_run)) - .route("/api/v1/cape/{sha}/raw", get(detail::cape_raw)) - .route("/api/v1/github-analysis/{sha}", get(detail::github_analysis_run)) - .route("/api/v1/attackers-graph", get(detail::attackers_graph)) - .route("/api/v1/attack-vectors", get(detail::attack_vectors)) - .route("/api/v1/ml-anomalies/ack", post(detail::ml_anomaly_ack)) - .route("/api/v1/ml-anomalies/ack-all", post(detail::ml_anomaly_ack_all)) - .route("/api/v1/ml-anomalies/acks", get(detail::ml_anomaly_acks)) - .route("/api/v1/ml-anomalies/stats", get(detail::ml_anomaly_stats)) - .route("/api/v1/ml-anomalies/disposition", post(detail::ml_anomaly_disposition)) - .route("/api/v1/reports/{id}/pdf", get(reports::pdf)) + .routes(utoipa_axum::routes!(sensors::catalog)) + .routes(utoipa_axum::routes!(sensors::events)) + .routes(utoipa_axum::routes!(sensors::overview)) + .routes(utoipa_axum::routes!(session::detail)) + .routes(utoipa_axum::routes!(search::search)) + .routes(utoipa_axum::routes!(topology::topology)) + .routes(utoipa_axum::routes!(health::storage)) + .routes(utoipa_axum::routes!(config::get_config)) + .routes(utoipa_axum::routes!(config::put_presentation)) + .routes(utoipa_axum::routes!(config::put_config_section)) + .routes(utoipa_axum::routes!(config::history)) + .routes(utoipa_axum::routes!(config::rollback)) + .routes(utoipa_axum::routes!(config::validate)) + .routes(utoipa_axum::routes!(config::users)) + .routes(utoipa_axum::routes!(audit::list)) + .routes(utoipa_axum::routes!(preferences::get, preferences::put)) + .routes(utoipa_axum::routes!(preferences::reset)) + .routes(utoipa_axum::routes!(reporter_stats::stats)) + .routes(utoipa_axum::routes!(services_control::list)) + .routes(utoipa_axum::routes!(services_control::logs)) + .routes(utoipa_axum::routes!(services_control::action)) + .routes(utoipa_axum::routes!(llm_search::search)) + .routes(utoipa_axum::routes!(vault_rag::ask)) + .routes(utoipa_axum::routes!(ip_block::set_block)) + .routes(utoipa_axum::routes!(ip_block::get_block)) + .routes(utoipa_axum::routes!(ip_block::export)) + .routes(utoipa_axum::routes!(detail::sandbox_run)) + .routes(utoipa_axum::routes!(detail::ghidra_run)) + .routes(utoipa_axum::routes!(detail::ghidra_callgraph)) + .routes(utoipa_axum::routes!(detail::revdeck_run)) + .routes(utoipa_axum::routes!(detail::cape_run)) + .routes(utoipa_axum::routes!(detail::cape_raw)) + .routes(utoipa_axum::routes!(detail::github_analysis_run)) + .routes(utoipa_axum::routes!(detail::attackers_graph)) + .routes(utoipa_axum::routes!(detail::attack_vectors)) + .routes(utoipa_axum::routes!(detail::ml_anomaly_ack)) + .routes(utoipa_axum::routes!(detail::ml_anomaly_ack_all)) + .routes(utoipa_axum::routes!(detail::ml_anomaly_acks)) + .routes(utoipa_axum::routes!(detail::ml_anomaly_stats)) + .routes(utoipa_axum::routes!(detail::ml_anomaly_disposition)) + .routes(utoipa_axum::routes!(reports::pdf)) // #1612 phase 4: Reports studio — template/element catalog, // definitions CRUD, and on-demand generate. See reports_store.rs's // module doc comment for the sandbox/payload/ghidra scope decision. - .route("/api/v1/reports/templates", get(reports_api::templates)) - .route( - "/api/v1/reports/definitions", - get(reports_api::list_definitions).post(reports_api::create_definition), - ) - .route( - "/api/v1/reports/definitions/{id}", - get(reports_api::get_definition) - .put(reports_api::replace_definition) - .delete(reports_api::delete_definition), - ) - .route("/api/v1/reports/definitions/{id}/generate", post(reports_api::generate)) - .route("/api/v1/reports/generated/{id}", delete(reports_api::delete_generated)) - .route("/api/v1/artifacts/{kind}/{key}", get(artifacts::list)) - .route("/api/v1/artifacts/{kind}/{key}/{filename}", get(artifacts::download)) - .route("/api/v1/charts/kill-chain-sankey", get(kill_chain::sankey)) - .route("/api/v1/charts/attck-coverage", get(kill_chain::attck_coverage)) - .route("/api/v1/charts/campaign-timeline", get(kill_chain::campaign_timeline)) - .route("/api/v1/charts/ml-backlog", get(charts::ml_backlog)) - .route("/api/v1/charts/netflow-bytes", get(charts::netflow_bytes)) - .route("/api/v1/charts/netflow-packets", get(charts::netflow_packets)) - .route("/api/v1/charts/anomaly-trend", get(charts::anomaly_trend)) - .route("/api/v1/charts/dionaea-cves", get(charts::dionaea_cves)) - .route("/api/v1/charts/os-distribution", get(charts::os_distribution)) + .routes(utoipa_axum::routes!(reports_api::templates)) + .routes(utoipa_axum::routes!(reports_api::list_definitions, reports_api::create_definition)) + .routes(utoipa_axum::routes!(reports_api::get_definition, reports_api::replace_definition, reports_api::delete_definition)) + .routes(utoipa_axum::routes!(reports_api::generate)) + .routes(utoipa_axum::routes!(reports_api::delete_generated)) + .routes(utoipa_axum::routes!(artifacts::list)) + .routes(utoipa_axum::routes!(artifacts::download)) + .routes(utoipa_axum::routes!(kill_chain::sankey)) + .routes(utoipa_axum::routes!(kill_chain::attck_coverage)) + .routes(utoipa_axum::routes!(kill_chain::campaign_timeline)) + .routes(utoipa_axum::routes!(charts::ml_backlog)) + .routes(utoipa_axum::routes!(charts::netflow_bytes)) + .routes(utoipa_axum::routes!(charts::netflow_packets)) + .routes(utoipa_axum::routes!(charts::anomaly_trend)) + .routes(utoipa_axum::routes!(charts::dionaea_cves)) + .routes(utoipa_axum::routes!(charts::os_distribution)) // #1727 §7: JA4T stack clusters, the successor to the p0f OS chart above. - .route("/api/v1/charts/tcp-stack-clusters", get(charts::tcp_stack_clusters)) + .routes(utoipa_axum::routes!(charts::tcp_stack_clusters)) // #1736/#1739: two surfaces for data that currently has no view at all. - .route("/api/v1/charts/ics-functions", get(charts::ics_functions)) - .route("/api/v1/charts/decoy-requests", get(charts::decoy_requests)) + .routes(utoipa_axum::routes!(charts::ics_functions)) + .routes(utoipa_axum::routes!(charts::decoy_requests)) // #1765: the wire-tuple join in use -- Traefik requests meeting the // ClientHello fingerprints only the passive sniffer can see. - .route("/api/v1/charts/decoy-client-fingerprints", get(charts::decoy_client_fingerprints)) + .routes(utoipa_axum::routes!(charts::decoy_client_fingerprints)) // #1729: the rest of the JA4+ family Zeek produces. - .route("/api/v1/charts/ja4h-fingerprints", get(charts::ja4h_fingerprints)) - .route("/api/v1/charts/ja4x-fingerprints", get(charts::ja4x_fingerprints)) - .route("/api/v1/charts/ja4l-fingerprints", get(charts::ja4l_fingerprints)) - .route("/api/v1/charts/tls-fingerprints", get(charts::tls_fingerprints)) - .route("/api/v1/charts/ssh-fingerprints", get(charts::ssh_fingerprints)) - .route("/api/v1/charts/endlessh-held-histogram", get(charts::endlessh_histogram)) - .route("/api/v1/charts/ml-anomaly-scores", get(charts::ml_anomaly_scores)) - .route("/api/v1/charts/attacker-fusion", get(fusion::fusion)) - .route("/api/v1/campaigns", get(stores::campaigns)) - .route("/api/v1/clusters", get(stores::clusters)) - .route("/api/v1/attackers", get(stores::attackers)) + .routes(utoipa_axum::routes!(charts::ja4h_fingerprints)) + .routes(utoipa_axum::routes!(charts::ja4x_fingerprints)) + .routes(utoipa_axum::routes!(charts::ja4l_fingerprints)) + .routes(utoipa_axum::routes!(charts::tls_fingerprints)) + .routes(utoipa_axum::routes!(charts::ssh_fingerprints)) + .routes(utoipa_axum::routes!(charts::endlessh_histogram)) + .routes(utoipa_axum::routes!(charts::ml_anomaly_scores)) + .routes(utoipa_axum::routes!(fusion::fusion)) + .routes(utoipa_axum::routes!(stores::campaigns)) + .routes(utoipa_axum::routes!(stores::clusters)) + .routes(utoipa_axum::routes!(stores::attackers)) // #2045: the raw evidence behind an attacker entity. - .route("/api/v1/attackers/{id}/events", get(attacker_identity::entity_events)) - .route("/api/v1/recordings", get(stores::recordings)) - .route("/api/v1/recordings/{shasum}", get(replay::replay)) + .routes(utoipa_axum::routes!(attacker_identity::entity_events)) + .routes(utoipa_axum::routes!(stores::recordings)) + .routes(utoipa_axum::routes!(replay::replay)) // #1711: the two download forms the Go tier served at // /tty/.cast and .raw, which the port dropped. - .route("/api/v1/recordings/{shasum}/cast", get(replay::replay_cast)) - .route("/api/v1/recordings/{shasum}/raw", get(replay::replay_raw)) - .route("/api/v1/alerts", get(stores::alerts)) - .route("/api/v1/alerts/{key}/ack", post(stores::acknowledge)) - .route("/api/v1/canarytokens/types", get(canarytokens::types)) - .route("/api/v1/canarytokens", get(canarytokens::list).post(canarytokens::create)) - .route("/api/v1/canarytokens/{id}/download", get(canarytokens::download)) + .routes(utoipa_axum::routes!(replay::replay_cast)) + .routes(utoipa_axum::routes!(replay::replay_raw)) + .routes(utoipa_axum::routes!(stores::alerts)) + .routes(utoipa_axum::routes!(stores::acknowledge)) + .routes(utoipa_axum::routes!(canarytokens::types)) + .routes(utoipa_axum::routes!(canarytokens::list, canarytokens::create)) + .routes(utoipa_axum::routes!(canarytokens::download)) // #1612 misc write paths: honeyfs-implant credential provisioning/ // rotation (credentials_manager.go/credentials_api.go). Plain HTTP // to a WireGuard-reachable URL, no host mount — same tier as // canarytokens.rs above, not the mounted-worker-role service. - .route( - "/api/v1/credentials", - get(credentials::list).post(credentials::create), - ) - .route("/api/v1/credentials/{id}/rotate", post(credentials::rotate)) - .route("/api/v1/credentials/{id}/link-token", post(credentials::link_token)) - .route("/api/v1/payloads", get(stores::payloads)) - .route("/api/v1/payloads/{hash}", get(payload_detail::detail)) - .route("/api/v1/payloads/{hash}/raw", get(payload_detail::raw)) + .routes(utoipa_axum::routes!(credentials::list, credentials::create)) + .routes(utoipa_axum::routes!(credentials::rotate)) + .routes(utoipa_axum::routes!(credentials::link_token)) + .routes(utoipa_axum::routes!(stores::payloads)) + .routes(utoipa_axum::routes!(payload_detail::detail)) + .routes(utoipa_axum::routes!(payload_detail::raw)) // #474 one-click payload PDF (hp-payload-report.js): ephemeral // payload-scoped report into the generated store, no saved // definition. See reports_api::generate_payload_report. - .route("/api/v1/payloads/{hash}/report", post(reports_api::generate_payload_report)) - .route("/api/v1/store/{name}", get(stores::generic).delete(stores::generic_delete)) - .route("/api/v1/problem-reports", post(problem_reports::submit)) - .route("/api/v1/problem-reports/{id}", patch(problem_reports::patch_status)) + .routes(utoipa_axum::routes!(reports_api::generate_payload_report)) + .routes(utoipa_axum::routes!(stores::generic, stores::generic_delete)) + .routes(utoipa_axum::routes!(problem_reports::submit)) + .routes(utoipa_axum::routes!(problem_reports::patch_status)) // #1612 mounted worker role (phase 3a): sandbox/ghidra/github- // analysis submission + golden-image status. Registered in the // same shared route table as everything else — which container // these are actually reachable/useful on depends entirely on // which compose service has the spool-dir mounts (backend-service- // mounted), not on route registration here. - .route("/api/v1/sandbox/submit", post(sandbox_submit::submit)) - .route("/api/v1/sandbox/golden-image-status", get(sandbox_submit::golden_image_status)) - .route("/api/v1/sandbox/vnc", get(sandbox_submit::vnc_status)) - .route("/api/v1/ghidra/submit", post(ghidra_submit::submit)) - .route("/api/v1/github-analysis/submit", post(github_analysis_submit::submit)) + .routes(utoipa_axum::routes!(sandbox_submit::submit)) + .routes(utoipa_axum::routes!(sandbox_submit::golden_image_status)) + .routes(utoipa_axum::routes!(sandbox_submit::vnc_status)) + .routes(utoipa_axum::routes!(ghidra_submit::submit)) + .routes(utoipa_axum::routes!(github_analysis_submit::submit)) // #1612 phase 3b: Payload Workbench orchestrator (recipes, run // creation/reconciliation, child cancel/retry). Same // shared-route-table posture as phase 3a — only useful on // backend-service-mounted, which has the write-capable spool mounts. - .route("/api/v1/workbench/analyzers", get(workbench_api::analyzers)) - .route( - "/api/v1/workbench/runs", - get(workbench_api::list_runs).post(workbench_api::create_run), - ) - .route("/api/v1/workbench/runs/{id}", get(workbench_api::get_run)) - .route( - "/api/v1/workbench/runs/{id}/children/{analyzer_id}/{action}", - post(workbench_api::child_action), - ) - .route( - "/api/v1/workbench/recipes", - get(workbench_api::list_recipes).post(workbench_api::save_recipe), - ) + .routes(utoipa_axum::routes!(workbench_api::analyzers)) + .routes(utoipa_axum::routes!(workbench_api::list_runs, workbench_api::create_run)) + .routes(utoipa_axum::routes!(workbench_api::get_run)) + .routes(utoipa_axum::routes!(workbench_api::child_action)) + .routes(utoipa_axum::routes!(workbench_api::list_recipes, workbench_api::save_recipe)) } /// The unauthenticated routes: the two liveness names, readiness, and the /// #1972 metrics scrape. They are a separate builder from [`api_router`] /// because they are the only routes the token middleware does not cover. -pub fn public_router() -> Router { - Router::new() - .route("/livez", get(livez)) - .route("/healthz", get(healthz)) - .route("/readyz", get(readyz)) +pub fn public_router() -> ContractRouter { + ContractRouter::default() + .routes(utoipa_axum::routes!(livez)) + .routes(utoipa_axum::routes!(healthz)) + .routes(utoipa_axum::routes!(readyz)) // #1972: same listener, same internal-network posture as /healthz. - .route("/metrics", get(obs::metrics_route)) + .routes(utoipa_axum::routes!(obs::metrics_route)) } /// The whole service surface: the public routes, the token-gated /api @@ -693,7 +711,7 @@ pub fn router(state: AppState) -> Router { let api = api_router() .layer(middleware::from_fn_with_state(state.clone(), require_service_token)); - let app = Router::new() + let app: ContractRouter = ContractRouter::default() // The public routes are declared first, exactly as `main.rs` // declared them before the move; the token-gated /api table is // merged in behind them. @@ -707,9 +725,13 @@ pub fn router(state: AppState) -> Router { // _with_state form (plain from_fn builds FromFn<(), ..> whose // Service bound never matches a state-taking extractor). .layer(middleware::from_fn_with_state(state.clone(), obs::observe)) - .layer(tower_http::trace::TraceLayer::new_for_http()) - .with_state(state); - app + .layer(tower_http::trace::TraceLayer::new_for_http()); + // The process serves a plain `axum::Router`. The OpenAPI half of + // `ContractRouter` is what `openapi::document()` reads instead, from + // the same two builders merged the same way; nothing here throws it + // away, because the serving router and the document are the same + // registration and there is no second one to keep in step. + app.with_state(state).into() } #[cfg(test)] diff --git a/arcane/home/honeypot-dashboard/backend-service/src/live.rs b/arcane/home/honeypot-dashboard/backend-service/src/live.rs index 8cc4906b..eab87565 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/live.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/live.rs @@ -124,6 +124,16 @@ async fn poll_loop(state: AppState, tx: broadcast::Sender>) { } } +#[utoipa::path( + get, + path = "/api/v1/live", + summary = "Server-sent event source: the explorer tailing contract.", + responses( + (status = 200, description = "An endless text/event-stream of event documents. Never terminates, which is why the fuzz job excludes this path.", body = inline(serde_json::Value), content_type = "text/event-stream"), + ), + security(("serviceToken" = [])), + extensions(("x-endless-stream" = json!(true))), +)] pub async fn stream( State(state): State, ) -> Sse>> { diff --git a/arcane/home/honeypot-dashboard/backend-service/src/llm_search.rs b/arcane/home/honeypot-dashboard/backend-service/src/llm_search.rs index 1981b609..d9867956 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/llm_search.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/llm_search.rs @@ -16,6 +16,7 @@ //! "session" so every existing caller's behavior is byte-for-byte //! unchanged. +use crate::contract; use axum::{ extract::{Query, State}, Json, @@ -155,6 +156,21 @@ pub(crate) async fn embed(base: &str, model: &str, text: &str) -> anyhow::Result Ok(vector) } +#[utoipa::path( + get, + path = "/api/v1/llm-search", + summary = "Natural-language search over the corpus, answered by the local model.", + params( + ("q" = inline(Option), Query, description = "The question."), + ("limit" = inline(Option), Query, description = "How many hits to summarise."), + ("source" = inline(Option), Query, description = "\"session\", \"vault\" or \"vault-note\"; an unknown value falls back to session."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn search(State(state): State, Query(query): Query) -> Json { let mut text = query.q.trim().to_string(); if text.is_empty() { diff --git a/arcane/home/honeypot-dashboard/backend-service/src/mail.rs b/arcane/home/honeypot-dashboard/backend-service/src/mail.rs index ede535fb..96789baf 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/mail.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/mail.rs @@ -125,6 +125,21 @@ fn parse_eml(raw: &[u8]) -> Option { }) } +#[utoipa::path( + get, + path = "/api/v1/mail/{session_id}", + summary = "Mail the SMTP honeypot captured for one session.", + params( + ("session_id" = inline(String), Path, description = "Session whose captured mail is wanted."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn get( State(state): State, Path(session_id): Path, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/ml_health.rs b/arcane/home/honeypot-dashboard/backend-service/src/ml_health.rs index a5bad809..e306e0f9 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/ml_health.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/ml_health.rs @@ -24,6 +24,16 @@ pub struct ModelHealth { pub train_samples: u64, } +#[utoipa::path( + get, + path = "/api/v1/ml-health", + summary = "Per-model ml-worker health.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn list(State(state): State) -> Result>, (StatusCode, String)> { let body = json!({ "size": 0, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/obs.rs b/arcane/home/honeypot-dashboard/backend-service/src/obs.rs index 8aa22eae..9d328937 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/obs.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/obs.rs @@ -357,6 +357,14 @@ pub async fn observe( response } +#[utoipa::path( + get, + path = "/metrics", + summary = "Prometheus exposition for the #1972 request metrics. Public on purpose, like /healthz.", + responses( + (status = 200, description = "Prometheus text exposition format.", body = inline(serde_json::Value), content_type = "text/plain"), + ), +)] /// GET /metrics — deliberately unauthenticated exactly like /healthz, /// /livez and /readyz: all of them are reachable only on LISTEN_ADDR, /// which is the internal docker network (Traefik publishes the BFF tier, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs b/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs index a4966e5d..be91bb6b 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs @@ -1,1110 +1,360 @@ //! The machine-readable contract for this service's HTTP surface (#3325). //! -//! # Why this is hand-maintained rather than derived +//! # Where the document comes from //! -//! #3325 offered two ways to get an OpenAPI document: derive it from the -//! Axum handlers with `utoipa`, or hand-maintain one. This is the second -//! one, deliberately. The handlers almost all return `Json` or a -//! typed struct with a field per surface concern, and the one thing the -//! issue wants the document to pin -- the auth tier and the request shape -//! -- lives in the *route table* and the *extractor signatures*, not in -//! the response bodies. Deriving would mean adding `#[utoipa::path]` to -//! 138 handlers to learn that `GET /api/v1/events` takes a `Query` -//! and answers 401 without a service token; a table plus the two drift -//! tests below states the same thing in one readable place. +//! From the `Router`. Every handler carries a `#[utoipa::path]` +//! annotation, and `utoipa_axum::routes!(handler)` reads it to register +//! the handler in the served router *and* the document in one call. There +//! is no second route table and no second copy of the surface. //! -//! # Why it cannot drift +//! The one way that stops being true is registering a route the old way -- +//! `OpenApiRouter` inherits `axum::Router`'s `.route(...)` as a pass-through +//! that reaches the process and not the document -- so +//! `contract_covers_every_router_route` names those three methods and fails +//! the build if the table contains one. In the other direction, an +//! annotation on a handler nothing routes publishes an operation the service +//! answers 404 for, and the same test catches that. //! -//! Two tests in `tests` below, both of which run inside `cargo test` (so in -//! both of quality.yml's backend-service twins): +//! This file used to hold an 1820-line hand-written table of the same +//! surface, kept honest by two drift tests. That had rotted twice, and +//! the reason is structural rather than a matter of care: a table beside +//! the router is a second thing to update, and the direction that rots is +//! the one where the new route never reaches it. The annotations are on +//! the handlers, so the surface is described once. //! -//! 1. `checked_in_contract_is_current` -- the committed `openapi.json` must -//! equal what this module renders. Regenerate with -//! `cargo run --bin openapi > openapi.json` after any edit here. -//! 2. `contract_covers_every_router_route` -- the (path, method) set this -//! module publishes must equal the (path, method) set `main.rs` -//! registers. A route added to the router without a row here fails the -//! build, which is the direction that actually rots. +//! # The transform, and what it is for +//! +//! `utoipa` gives the document its shape, and [`render`] applies a short, +//! documented set of adjustments to it. Each one exists because the +//! information is real and `utoipa` cannot see it -- not to make the output +//! prettier, and not to paper over an annotation that was forgotten: +//! +//! 1. **The middleware's 401.** `require_service_token` answers +//! `(StatusCode, String)` *before a route is resolved*, so no handler +//! can see the status it causes and no handler can annotate it. Every +//! operation that declares `security(("serviceToken" = []))` is +//! therefore given the `text/plain` 401 here. An operation that can +//! also answer a JSON 401 of its own -- the Workbench's +//! `require_actor` -- keeps it in its own annotation, and the two +//! media types are merged onto one status rather than overwriting each +//! other. +//! 2. **`operationId` and the operation's tag.** Both are pure functions +//! of the path, so they are computed once in [`operation_id`] and +//! [`tag_for`] rather than written out 141 times where a typo would +//! rename a published id. +//! 3. **`Option` is an absence, not a null.** A parameter declared +//! `Option` is an absent-or-`T` parameter, and `required: false` +//! already says so; `utoipa` renders a header's `Option` as +//! `["string", "null"]` anyway, and a header value is text on the wire, +//! never the JSON value null. So the `null` is dropped from the type. +//! A *body* is the other way round: `utoipa` does not derive `required` +//! for a request body at all, so the two routes that take an +//! `Option>` carry an `x-optional-body` marker in their +//! annotation and this file turns it into `required: false` and consumes +//! the marker. `document_is_well_formed` asserts no marker survives. +//! 4. **The operation description.** `utoipa` reads each handler's Rust +//! doc comment into the operation. Those comments are written for Rust +//! readers, and the contract carries a summary per operation, so the +//! description is dropped here rather than published as a second, +//! differently-worded summary of the same route. +//! 5. **The document-level literals** -- `info`'s title, version, summary +//! and description, `servers`, the `serviceToken` security scheme, and +//! the tag list. `OpenApiRouter::default()` starts empty on purpose +//! (`new()` would fill `info` in from utoipa's own Cargo.toml), and +//! `utoipa`'s `Info` does not model `summary` at all. These are prose +//! about the service, not facts about any one route, so they stay here +//! where a reader looks for them; the tag *descriptions* are a function +//! of the tag name in [`tag_description`]. +//! 6. **`parameters: []` on an operation that takes none.** `utoipa` +//! omits the key, and "omitted" and "none" are different answers to a +//! consumer walking the document. +//! +//! Every one of the four document-level facts the fuzz job checks -- the +//! auth tier, the request parameters, the declared statuses, and the +//! media type of every response -- is generator *input*, carried in the +//! annotations. The list above is the whole of what is added here, and +//! `document_is_well_formed` asserts each of them rather than trusting +//! this prose. //! //! # What the document deliberately does NOT claim //! //! **Every** success body is free-form (an empty schema, which accepts any //! JSON value) -- including `/healthz`'s two-field struct. Hand-transcribing -//! 138 response shapes would be 138 chances to assert something the code +//! 141 response shapes would be 141 chances to assert something the code //! does not enforce, and a contract that lies about a response is worse //! than one that admits it is open. The empty schema is the truthful //! statement: it is also what keeps `response_schema_conformance` from -//! failing the fuzz job on correct behaviour, which is why this document +//! failing the fuzz job on correct behaviour, which is why that job //! reports service findings like "API accepted schema-violating request" //! and treats them as a standing record rather than as a verdict. //! -//! What the document *does* pin is the part that has been wrong before: -//! the auth tier per operation, the request parameters (including the -//! enum-shaped ones the handlers actually validate), the declared status -//! codes, and the media type of every response. That is what -//! `.github/workflows/weekly-schemathesis.yml` checks, and the auth tier -//! is the property #3325 was filed for. -//! -//! # Statuses that no row has to remember +//! # Statuses that no annotation has to remember //! //! Three come from axum's extractors, before any handler runs, and the -//! builders add them so a row cannot forget: `body()` adds 415 and 422 for -//! `Json`, and `with_query()` adds 400 for `Query`. Running the fuzz +//! annotations that take such a body or a `Query` declare them: +//! `Json` adds 415 and 422, and `Query` adds 400. Running the fuzz //! job against a booted service is what found all three -- they are -//! unreachable from the router's source, since nothing in `main.rs` -//! mentions them, and every one of them was reported "undocumented" on -//! routes whose rows looked complete. +//! unreachable from the router's source, since nothing in `router()` or +//! the handlers mentions them, and every one of them was reported +//! "undocumented" on routes whose rows looked complete. //! //! One status carries two media types on the routes where the extractor's //! answer and the handler's own answer share a code: the Workbench's 400 //! is `text/plain` when `Query` refuses the query string and -//! `application/json` when the handler rejects the value. `render_operation` -//! merges content types for a repeated status rather than overwriting. +//! `application/json` when the handler rejects the value. -use serde_json::{json, Map, Value}; - -/// Which gate an operation sits behind. The names say what the *caller* -/// must present, because that is the only part of the auth model an -/// operator has to get right (the BFF is the only legitimate caller -- -/// see the crate doc in main.rs). -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Tier { - /// No service token: `/healthz` for the container healthcheck and - /// `/metrics` for the #1972 scrape. Both stay open on purpose and are - /// the only two routes in the binary that do. - Public, - /// `require_service_token` (main.rs). 401 is `text/plain`: the - /// middleware returns a bare `(StatusCode, String)`. - ServiceToken, - /// `require_service_token` *plus* the Workbench's own - /// `require_actor` (workbench_api.rs), which wants a forwarded - /// `X-Actor-Username` and answers a `Json` 401. Both media - /// types are declared on the one 401, because both are reachable: - /// no token gets the middleware's, a token without an actor gets the - /// Workbench's. - ServiceTokenAndActor, -} - -/// A query or header parameter. `in` is filled in by the renderer from -/// where the entry sits in the table, so a row cannot put a query -/// parameter in the header block. -pub struct Param { - pub name: &'static str, - pub description: &'static str, - pub schema: Value, - pub required: bool, -} +use serde_json::{json, Value}; -/// A path parameter. Kept apart from [`Param`] because `in: path` is -/// mandatory and always required -- a mistake there is a spec error, and -/// mixing the two makes it easy to write. -pub struct PathParam { - pub name: &'static str, - pub description: &'static str, - pub schema: Value, -} - -pub struct Response { - pub status: u16, - pub description: &'static str, - /// One entry per media type under `content`; empty for a bodiless - /// status. A list rather than a single media type because the - /// Workbench's one 401 is reachable as both `text/plain` and - /// `application/json` (see [`Tier::ServiceTokenAndActor`]). - pub schemas: Vec<(&'static str, Value)>, -} - -pub struct Op { - pub method: &'static str, - pub path: &'static str, - pub summary: &'static str, - pub tier: Tier, - pub query: Vec, - pub headers: Vec, - pub path_params: Vec, - pub body: Option, - pub responses: Vec, - /// Not free-form: for SSE (`/api/v1/live`) the success body is an - /// endless stream, so the media type is the whole contract. - pub success_media: Option<&'static str>, -} - -/// A JSON request body. The shape is left open -- see the module doc -- -/// but the description names the handler struct, so a reader can jump -/// straight to the fields the service actually deserializes. -pub struct Body { - pub handler_struct: &'static str, - pub required: bool, -} +use crate::{api_router, public_router}; -// --------------------------------------------------------------------- -// Small constructors. Every response body the service produces is JSON -// unless the table says otherwise, so `json` is the default and the -// error helpers are the ones that carry a media type. -// --------------------------------------------------------------------- +pub use crate::contract::STORE_NAMES; -fn free_form() -> Value { - // `type` is deliberately omitted rather than "object": most handlers - // answer an object, but several (sources, ml-health, gpu-queue, the - // chart family) answer a bare array, and a wrong `type` here would - // make schemathesis's response_schema_conformance fail on correct - // behaviour. An empty schema accepts any JSON value, which is the - // truthful statement. - json!({}) -} - -fn str_schema() -> Value { - json!({"type": "string"}) -} - -fn u64_schema() -> Value { - json!({"type": "integer", "format": "int64", "minimum": 0}) -} - -fn query(name: &'static str, description: &'static str, schema: Value) -> Param { - Param { name, description, schema, required: false } -} - -fn opt_query( - name: &'static str, - description: &'static str, - schema: Value, -) -> Param { - query(name, description, schema) -} - -fn path_param(name: &'static str, description: &'static str, schema: Value) -> PathParam { - PathParam { name, description, schema } +/// Renders the OpenAPI 3.1 document. +/// +/// Deterministic, and byte-stable across runs: `utoipa` emits sorted maps +/// for everything it models, and the round trip through [`Value`] sorts +/// what is left (and is also what puts the keys in the one order the +/// committed file has). Regenerating an unchanged source tree produces a +/// byte-identical file, so a reviewer can read a real diff. +pub fn document() -> Value { + let openapi = contract_router().into_openapi(); + let mut document = serde_json::to_value(openapi).expect("the OpenAPI model serializes"); + render(&mut document); + document } -fn op(method: &'static str, path: &'static str, summary: &'static str) -> Op { - Op { - method, - path, - summary, - tier: Tier::ServiceToken, - query: Vec::new(), - headers: Vec::new(), - path_params: Vec::new(), - body: None, - responses: Vec::new(), - success_media: None, - } +/// The document `utoipa` builds from the handlers, before the transform +/// above. `router()` serves this same builder, so the two cannot disagree +/// about what exists. +fn contract_router() -> utoipa_axum::router::OpenApiRouter { + utoipa_axum::router::OpenApiRouter::default() + .merge(api_router()) + .merge(public_router()) } -impl Op { - fn public(mut self) -> Self { - self.tier = Tier::Public; - self - } - - /// Marks the route as also requiring the BFF's forwarded actor - /// identity, with a required `X-Actor-Username` header so the fuzzer - /// exercises the route the way the BFF actually calls it. - fn actor(mut self) -> Self { - self.tier = Tier::ServiceTokenAndActor; - self.headers.push(Param { - name: "X-Actor-Username", - description: "Operator identity the BFF forwards; workbench_api.rs's \ - require_actor rejects a missing or blank value with a \ - JSON 401.", - schema: str_schema(), - required: true, - }); - self - } - - /// A 200 whose body is `application/json` of unconstrained shape. - fn ok(mut self) -> Self { - self.responses.push(Response { - status: 200, - description: "Success.", - schemas: vec![("application/json", free_form())], - }); - self - } - - /// A 200 with a specific media type -- CSV, PDF, octet-stream, an - /// SSE stream, Prometheus text. - fn ok_media(mut self, media: &'static str, description: &'static str) -> Self { - self.success_media = Some(media); - self.responses.push(Response { - status: 200, - description, - schemas: vec![(media, free_form())], - }); - self - } - - /// A bodiless success (`204 No Content`, and the `200` some submit - /// routes answer with an empty object). - fn ok_status(mut self, status: u16, description: &'static str) -> Self { - self.responses.push(Response { - status, - description, - schemas: Vec::new(), - }); - self - } - - /// A `text/plain` error status -- the shape of every - /// `Err((StatusCode, String))` in the crate, which is most of them. - fn err(mut self, status: u16) -> Self { - self.responses.push(Response { - status, - description: error_description(status), - schemas: vec![("text/plain", str_schema())], - }); - self - } - - /// A `application/json` error status -- the Workbench's - /// `Err((StatusCode, Json))` family. - fn err_json(mut self, status: u16) -> Self { - self.responses.push(Response { - status, - description: error_description(status), - schemas: vec![("application/json", free_form())], - }); - self - } - - /// Declares this operation's query parameters, and the 400 that - /// axum's `Query` extractor raises when the query string will not - /// deserialize -- a required field missing, or a value of the wrong - /// type (`?limit=false` on a `limit: usize`). It is `text/plain` and - /// it happens before the handler runs. - /// - /// Added here rather than per row because forgetting it is invisible: - /// an operation that lists query parameters and declares no 400 looks - /// complete, and only a fuzzer sending `?limit=false` finds out. On - /// a row that already declares a `text/plain` 400 this is a no-op - /// (the render merges same-key content), and on one whose handler - /// answers 400 in JSON the status ends up carrying both media types, - /// which is the truth. - fn with_query(mut self, params: Vec) -> Self { - self.query = params; - self = self.err(400); - self - } - - fn with_path(mut self, params: Vec) -> Self { - self.path_params = params; - self - } - - /// A JSON request body the handler deserializes into `handler_struct` - /// (axum's `Json` extractor). `required` is false for the - /// `Option>` handlers, which accept a missing body. - fn body(mut self, handler_struct: &'static str, required: bool) -> Self { - self.body = Some(Body { handler_struct, required }); - // Two rejections happen in the extractor, before the handler is - // entered, so no row can be forgotten and no handler's own error - // list has to remember them: axum's `Json` answers 415 for a - // wrong `Content-Type` and 422 for a body that does not - // deserialize into `handler_struct`. Both are `text/plain`. - // Found by running the weekly fuzz job against a booted - // service, which called them undocumented on all 25 body routes - // -- #3325's job earning its keep on the contract it ships with. - // `err` takes self by value, so hand it back and keep going. - self = self.err(415).err(422); - self - } +/// The documented transform. See the module doc for why each step is here. +fn render(document: &mut Value) { + // Collected first, while the document can still be read: one pass over + // the paths for the tag list, then one pass that rewrites them. + let tag_list = document_tag_list(document); + + // The document-level facts. `OpenApiRouter::default()` starts empty, so + // nothing here is fighting a default it has to clear. + let root = document.as_object_mut().expect("the model is an object"); + let info = root.entry("info").or_insert_with(|| json!({})).as_object_mut().expect("info is an object"); + info.insert("title".to_string(), json!("APIARY dashboard backend-service")); + info.insert("version".to_string(), json!(env!("CARGO_PKG_VERSION"))); + // `info.summary` is OpenAPI 3.1 and utoipa's `Info` does not model it, + // so it cannot be set on the model at all. + info.insert("summary".to_string(), json!(INFO_SUMMARY)); + info.insert("description".to_string(), json!(INFO_DESCRIPTION)); + + root.insert( + "servers".to_string(), + json!([{ + "url": "http://127.0.0.1:8081", + "description": + "The default LISTEN_ADDR. In compose this service is reachable \ + only over the internal honeynet, and only by the dashboard BFF." + }]), + ); + root.insert( + "components".to_string(), + json!({ + "securitySchemes": { + "serviceToken": { + "type": "apiKey", + "in": "header", + "name": "X-Service-Token", + "description": + "The shared secret the Nitro BFF presents on \ + every /api/v1 call (SERVICE_TOKEN; lib.rs's \ + require_service_token, #2183). Compared in \ + constant time, and a wrong or missing value \ + is a 401 before the route is even resolved. \ + Browsers never reach this service directly." + } + } + }), + ); + + root.insert("tags".to_string(), tag_list); + + // (1)-(4), per operation. + let paths = document["paths"] + .as_object_mut() + .expect("the model always has a paths object"); + for (path, item) in paths.iter_mut() { + let item = item.as_object_mut().expect("a path item is an object"); + for (method, operation) in item.iter_mut() { + let operation = operation.as_object_mut().expect("an operation is an object"); + + // (4) The Rust doc comment is not the contract's description. + operation.remove("description"); + + // (2) Pure functions of the path, so they cannot be mistyped. + operation.insert( + "operationId".to_string(), + json!(operation_id(method, path)), + ); + operation.insert("tags".to_string(), json!([tag_for(path)])); + + // The contract states `parameters` on every operation, empty + // list included. utoipa omits the key when there are none, + // which reads as "unknown" to a consumer rather than "none", + // and a fuzzer walking the document should not have to tell + // those apart. + operation + .entry("parameters".to_string()) + .or_insert_with(|| json!([])); + + // A query parameter is never required, whatever the handler + // struct says. The extractor's own rejection -- a missing + // required field -- is the 400 those operations declare, and + // the old table said `required: false` for every one of them. + // utoipa omits the key unless the annotation spells it out, so + // the default is filled in here rather than 40-odd times at + // the use sites. + // + // The schema is narrowed in the same pass. `Option` in a + // *query* position already renders as T -- utoipa-gen clears + // the nullable flag for queries, parameter.rs's + // `ParameterIn::Query` arm -- but in a header it renders as + // `["string", "null"]`. A header value is text on the wire, + // never the JSON value null: what `Option` means there is an + // absent header, and that is already carried by + // `required: false`. The two optional request bodies are the + // same reading, and they take the `x-optional-body` marker + // below instead, because `required` is not derived for a body + // and so there is nothing here to narrow. + if let Some(parameters) = operation.get_mut("parameters").and_then(Value::as_array_mut) + { + for parameter in parameters.iter_mut() { + let parameter = + parameter.as_object_mut().expect("a parameter is an object"); + if parameter.get("in") == Some(&json!("query")) { + parameter + .entry("required".to_string()) + .or_insert_with(|| json!(false)); + } + if let Some(schema) = + parameter.get_mut("schema").and_then(Value::as_object_mut) + { + let narrowed = match schema.get("type") { + Some(Value::Array(types)) + if types.len() > 1 + && types.iter().any(|t| t == "null") => + { + types.iter().filter(|t| *t != "null").cloned().collect::>() + } + _ => continue, + }; + match narrowed.as_slice() { + [only] => { + schema.insert("type".to_string(), only.clone()); + } + _ => { + schema.insert("type".to_string(), Value::Array(narrowed)); + } + } + } + } + } - /// An optional `If-Match` carrying the revision the caller believes it - /// is editing (config.rs's `expected_revision`, a weak ETag whose - /// numeric body is the revision). Optional because a missing header - /// means "no expectation", not "reject". - fn if_match(mut self) -> Self { - self.headers.push(Param { - name: "If-Match", - description: "Optional optimistic-concurrency revision, as a weak ETag \ - (`W/\"7\"`). A mismatch answers 409.", - schema: str_schema(), - required: false, - }); - self - } -} + // (3) The optional-body marker, consumed. + if let Some(body) = operation.get_mut("requestBody").and_then(Value::as_object_mut) { + if body.remove("x-optional-body").is_some() { + body.insert("required".to_string(), json!(false)); + } + } -fn error_description(status: u16) -> &'static str { - match status { - 400 => "Rejected: the request was understood but its input is not acceptable.", - 401 => "No valid service token (or, on the Workbench, no actor identity).", - 404 => "No such record, store, or route for the values given.", - 405 => "The store exists but exposes no delete side (only dead-letters does).", - 409 => "The record changed since the revision the caller presented.", - 413 => "The stored artifact is larger than this endpoint will serve.", - 415 => "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", - 422 => "Well-formed but unprocessable. Two causes, both text/plain: the Json \ - extractor refused the body before the handler ran, or the route's own domain \ - check rejected the reference it was asked to resolve (the reports store \ - answers this for an unresolvable scope or an unexpected storage failure).", - 500 => "The handler failed in a way it does not model as a 4xx.", - 501 => "The saved definition's template is not implemented by the renderer yet.", - 502 => "Elasticsearch (or a sibling it proxies) refused or failed the query.", - 503 => "A dependency this route needs is not configured or not reachable.", - _ => "Error.", + // (1) The 401 `require_service_token` raises, which no handler + // can see. Merged into whatever the handler declared, never + // over it: on the Workbench's routes both media types are + // reachable on the one status. + let secured = operation.contains_key("security"); + let responses = operation + .get_mut("responses") + .and_then(Value::as_object_mut) + .expect("an operation always declares responses"); + if secured { + let unauthorized = responses + .entry("401".to_string()) + .or_insert_with(|| json!({})); + let object = unauthorized.as_object_mut().expect("a response is an object"); + object.insert("description".to_string(), json!(unauthorized_description())); + let content = object + .entry("content".to_string()) + .or_insert_with(|| json!({})); + content + .as_object_mut() + .expect("content is an object") + .entry("text/plain".to_string()) + .or_insert_with(|| json!({"schema": str_schema()})); + } + } } } -// --------------------------------------------------------------------- -// Shared query shapes, transcribed from the handler structs they mirror. -// Each `fn` names the Rust type it tracks so a reader can check it -// against that struct in one jump; a drift test does not cover these -// field-by-field (nothing in the crate can), which is why each carries -// the type name. -// --------------------------------------------------------------------- +const INFO_SUMMARY: &str = "The dashboard's Rust service tier: Elasticsearch-backed \ + read APIs over the honeypot event corpus."; -/// `events::EventsQuery` -- the filter set /events and four of the CSV -/// exports share. -fn events_query() -> Vec { - let filters: [(&'static str, &'static str); 24] = [ - ("ip", "Single source address."), - ("ips", "Comma-separated source addresses."), - ("sensor", "Sensor name (honeypot.dionaea, suricata, ...)."), - ("country", "ISO country code."), - ("city", "City name, as bucketed on the overview map."), - ("port", "Destination port."), - ("proto", "Transport protocol."), - ("kind", "honeypot.event kind (command, login, ...)."), - ("shasum", "Captured-payload hash."), - ("community_id", "One flow across every sensor that saw it."), - ("q", "Free-text query_string, passed to Elasticsearch as-is."), - ("since", "Go-style relative window (24h, 7d)."), - ("persona", "Decoy persona id."), - ("site", "Decoy site id."), - ("asset", "Decoy asset id."), - ("fingerprint", "Client fingerprint, matched across every field sensors record one in."), - ("cmd", "Exact command text."), - ("cred", "\"user / pass\" pair."), - ("path", "Request path."), - ("session", "Session id."), - ("asn", "Source AS number."), - ("org", "Source network organization."), - ("provider", "Provider class."), - ("sig", "IDS alert signature."), - ]; - let mut params = vec![ - query("offset", "Result window start.", u64_schema()), - opt_query("size", "Page size, clamped to 100 by the handler.", json!({"type": "integer", "format": "int64", "minimum": 1, "maximum": 100})), - ]; - params.extend(filters.iter().map(|(name, description)| opt_query(name, description, str_schema()))); - params.push(opt_query("cat", "Detection category (Suricata alert category or honeypot.category).", str_schema())); - params -} - -/// `stores::StoreQuery` -- the generic store family's paging, shared by -/// the fixed store endpoints and `/api/v1/store/{name}`. -fn store_query() -> Vec { - vec![ - query("offset", "Result window start.", u64_schema()), - opt_query("size", "Page size.", json!({"type": "integer", "format": "int64", "minimum": 1})), - opt_query("q", "Free-text Lucene query string.", str_schema()), - opt_query("ip", "Narrow to one source address.", str_schema()), - opt_query("aggs", "`sources` adds the payload-inventory source buckets; anything else is ignored.", str_schema()), - ] -} +const INFO_DESCRIPTION: &str = "\ +The /api surface of `backend-service/` (`apiary-backend`), the Rust service \ +tier of the APIARY dashboard's modernization port (#1608). It is not a \ +public API: the Nitro BFF in `frontend-next/` is the only intended caller \ +and presents a shared service token on every /api/v1 route. /healthz and \ +/metrics are the two exceptions, open on purpose for the container \ +healthcheck and the #1972 metrics scrape. -/// `aggregates::PageQuery`. -fn page_query() -> Vec { - vec![ - query("offset", "Result window start.", u64_schema()), - opt_query("size", "Page size.", json!({"type": "integer", "format": "int64", "minimum": 1})), - ] -} +Response bodies are deliberately left unconstrained here. The handlers \ +return `Json` or a per-surface struct that nothing in this crate \ +enforces, so restating 138 shapes would be 138 chances to publish \ +something the code does not promise -- and a contract that lies about a \ +response is worse than one that admits the gap. What this document does \ +pin is the part that has been wrong before: the auth tier, the request \ +parameters (including the enum-valued ones the handlers actually \ +validate), the declared status codes, and the media type of every \ +response. That is what `.github/workflows/weekly-schemathesis.yml` \ +checks. -/// `config::ActorQuery` -- the audit attribution the write paths take. -fn actor_query() -> Vec { - vec![ - opt_query("actor_subject", "OIDC subject recorded on the audit/history entry.", str_schema()), - opt_query("actor_username", "Operator name recorded on the audit/history entry.", str_schema()), - ] -} +`arcane/home/honeypot-dashboard/backend-service/src/openapi.rs` is the \ +source of truth. Regenerate the committed copy with \ +`cargo run --bin openapi > openapi.json`; the drift tests in that module \ +and a diff step in `quality.yml` both fail if you forget."; -fn q(name: &'static str, description: &'static str) -> Param { - opt_query(name, description, str_schema()) +fn str_schema() -> Value { + json!({"type": "string"}) } -fn enum_schema(values: &[&str]) -> Value { - json!({"type": "string", "enum": values}) +/// The document's top-level `tags` list: one Tag Object per tag the +/// operations actually use, sorted, each with the description a reader +/// landing on a large group deserves. Collected from the paths rather than +/// written out, so a new route cannot land in a group nobody described. +fn document_tag_list(document: &Value) -> Value { + let mut tags: Vec<&str> = document["paths"] + .as_object() + .expect("paths is an object") + .keys() + .map(|path| tag_for(path)) + .collect(); + tags.sort_unstable(); + tags.dedup(); + Value::Array( + tags.into_iter() + .map(|tag| json!({"name": tag, "description": tag_description(tag)})) + .collect(), + ) } -// --------------------------------------------------------------------- -// The surface itself. Grouped and ordered the way main.rs registers it, -// so the two read side by side. -// --------------------------------------------------------------------- - -fn operations() -> Vec { - vec![ - // The /api surface, in main.rs's registration order. Every row is - // a route the router really registers -- `contract_covers_every_router_route` - // below fails the build if these two lists stop being the same list. - op("GET", "/api/v1/overview/kpis", "KPI counters behind the overview tiles.") - .ok() - .err(502), - op("GET", "/api/v1/overview/dashboard", "The one aggregation the overview page renders, sliced by ?parts=.") - .ok() - .err(502) - .with_query(vec![q("parts", "Comma-separated subset of slice names; absent or empty means every slice.")]), - op("GET", "/api/v1/events", "Event explorer page: the shared filter set, windowed.") - .ok() - .err(502) - .with_query(events_query()), - op("GET", "/api/v1/export/events.csv", "The event explorer as CSV, same filters as /events.") - .ok_media("text/csv", "CSV of the matching events.") - .err(502) - .with_query(events_query()), - op("GET", "/api/v1/export/commands.csv", "Matching commands as CSV.") - .ok_media("text/csv", "CSV of matching commands.") - .err(502) - .with_query(events_query()), - op("GET", "/api/v1/export/ips.csv", "Every source address in the window as CSV.") - .ok_media("text/csv", "CSV of source addresses.") - .err(502) - .with_query(page_query()), - op("GET", "/api/v1/export/campaigns.csv", "Campaigns as CSV.") - .ok_media("text/csv", "CSV of campaigns.") - .err(502) - .with_query(page_query()), - op("GET", "/api/v1/export/clusters.csv", "Attacker clusters as CSV, by cluster kind.") - .ok_media("text/csv", "CSV of attacker clusters.") - .err(502) - .with_query(vec![opt_query("kind", "Cluster kind to export.", enum_schema(&["fingerprint", "payload", "asn", "provider"]))]), - op("GET", "/api/v1/export/history.json", "The behaviour-search slice as JSON.") - .ok_media("application/json", "Behaviour-search rows as JSON.") - .err(502) - .with_query(events_query()), - op("GET", "/api/v1/live", "Server-sent event source: the explorer tailing contract.") - .ok_media("text/event-stream", "An endless text/event-stream of event documents. Never terminates, which is why the fuzz job excludes this path."), - op("GET", "/api/v1/mail/{session_id}", "Mail the SMTP honeypot captured for one session.") - .with_path(vec![path_param("session_id", "Session whose captured mail is wanted.", str_schema())]) - .ok() - .err(400) - .err(404) - .err(502), - op("GET", "/api/v1/ml-health", "Per-model ml-worker health.") - .ok() - .err(502), - op("GET", "/api/v1/gpu-queue", "The GPU analysis queue as it stands.") - .ok() - .err(502), - op("POST", "/api/v1/gpu-queue/{job_id}/abort", "Abort a queued or running GPU job.") - .with_path(vec![path_param("job_id", "GPU job to abort.", str_schema())]) - .ok() - .err(502), - op("GET", "/api/v1/sources", "Known source addresses with their event counts.") - .ok() - .err(502) - .with_query(page_query()), - op("GET", "/api/v1/filter-values", "Distinct values behind every explorer filter dropdown.") - .ok() - .err(502), - op("GET", "/api/v1/investigate/ip/{ip}", "Everything one source address did, across sensors.") - .with_path(vec![path_param("ip", "Source address to profile. A non-address is a 400.", json!({"type": "string"}))]) - .ok() - .err(400) - .err(404) - .err(502), - op("GET", "/api/v1/investigate/cidr/{cidr}", "Correlation across one CIDR block.") - .with_path(vec![path_param("cidr", "CIDR block to correlate. A malformed block is a 400.", str_schema())]) - .ok() - .err(400) - .err(502), - op("GET", "/api/v1/investigate/cluster", "The members of one attacker cluster.") - .ok() - .err(400) - .err(404) - .err(502) - .with_query(vec![opt_query("kind", "Cluster kind.", enum_schema(&["fingerprint", "payload", "asn", "provider"])), q("value", "The cluster's value, as /api/v1/clusters reports it.")]), - op("GET", "/api/v1/source-health", "Per-source ingestion health, the page behind \"Source & pipeline health\".") - .ok() - .err(502), - // The one ES-backed read in the API that does *not* 502. Every - // other row with a `.err(502)` gets there because the extractor - // turns a dead cluster into a status code; this handler catches - // the error and answers 200 with `available: false` and the - // reason in the body, because a delivery card that states why it - // has nothing is worth more than an operations page that fails to - // render. Declaring 502 here would be the exact kind of guess the - // rest of this file refuses to make. - op("GET", "/api/v1/webhook-delivery", "Delivery outcomes for the configured alert webhook.") - .ok(), - op("GET", "/api/v1/event/{id}", "One event, with the pivot groups its detail pane needs.") - .with_path(vec![path_param("id", "Event document id.", str_schema())]) - .ok() - .err(400) - .err(404) - .err(502), - op("GET", "/api/v1/event/{id}/connections", "The same-flow summary and re-used-wordlist edges for one event.") - .with_path(vec![path_param("id", "Event document id.", str_schema())]) - .ok() - .err(400) - .err(404) - .err(502), - op("GET", "/api/v1/connections/{community_id}", "Every record that shares one community_id flow hash.") - .with_path(vec![path_param("community_id", "network.community_id flow hash, as computed independently by each sensor.", str_schema())]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/cred-reuse", "Credential pairs reused across more than one address.") - .ok() - .err(502), - op("GET", "/api/v1/sensors", "Per-sensor counts, last-seen, and state.") - .ok() - .err(502), - op("GET", "/api/v1/sensors/catalog", "The sensor catalog the setup pages read.") - .ok() - .err(502), - op("GET", "/api/v1/sensors/{sensor}/events", "Recent events from one sensor.") - .with_path(vec![path_param("sensor", "Sensor name; the handler rejects an empty value or one over 128 characters.", json!({"type": "string", "maxLength": 128}))]) - .ok() - .err(400) - .err(502) - .with_query(vec![q("limit", "How many events to return; clamped by the handler.")]), - op("GET", "/api/v1/sensors/{sensor}/overview", "Protocols, ports and fingerprints for one sensor.") - .with_path(vec![path_param("sensor", "Sensor name; the handler rejects an empty value or one over 128 characters.", json!({"type": "string", "maxLength": 128}))]) - .ok() - .err(400) - .err(502), - op("GET", "/api/v1/sessions/{id}", "One session: its events, commands and credentials.") - .with_path(vec![path_param("id", "Session id.", str_schema())]) - .ok() - .err(400) - .err(404) - .err(502), - op("GET", "/api/v1/search", "Cross-surface search for the omnibox.") - .ok() - .err(502) - .with_query(vec![q("q", "What to search for.")]), - op("GET", "/api/v1/topology", "Decoy topology graph.") - .ok(), - op("GET", "/api/v1/settings/storage", "Index sizes and document counts.") - .ok() - .err(502), - op("GET", "/api/v1/config", "The whole operator-authored dashboard configuration.") - .ok() - .err(502), - op("PUT", "/api/v1/config/presentation", "Replace the presentation block (branding, theme, landing copy).") - .ok_status(200, "The stored presentation block and its new revision.") - .err(400) - .err(404) - .err(409) - .err(502) - .body("serde_json::Value", true) - .if_match() - .with_query(actor_query()), - op("PUT", "/api/v1/config/{section}", "Replace one settings section.") - .with_path(vec![path_param("section", "Settings section to replace.", enum_schema(&["honeypot", "behavior", "report-presets"]))]) - .ok_status(200, "The stored section and its new revision.") - .err(400) - .err(404) - .err(409) - .err(502) - .body("serde_json::Value", true) - .if_match() - .with_query(actor_query()), - op("GET", "/api/v1/config/history", "The revision history the rollback picker reads (payloads excluded).") - .ok(), - op("POST", "/api/v1/config/rollback", "Restore a past configuration revision.") - .ok_status(200, "The restored configuration and its new revision.") - .err(400) - .err(404) - .err(409) - .err(502) - .body("RollbackBody", true) - .if_match(), - op("POST", "/api/v1/config/validate", "Check a candidate configuration without storing it.") - .ok() - .err(400) - .body("serde_json::Value", true), - op("GET", "/api/v1/users", "Dashboard users, as the ES-side user store reports them.") - .ok() - .err(502), - op("GET", "/api/v1/audit", "The audit trail, newest first.") - .ok() - .with_query(vec![opt_query("limit", "How many entries; clamped to [1, 500] by the handler, default 100.", json!({"type": "integer", "minimum": 1, "maximum": 500})), q("action", "Only entries with this action.")]), - op("GET", "/api/v1/preferences", "One user's saved preferences.") - .ok() - .err(400) - .err(502) - .with_query(vec![q("subject", "OIDC subject. Required -- an empty value is a 400."), q("username", "Operator name."), q("role", "Operator role.")]), - op("PUT", "/api/v1/preferences", "Replace one user's saved preferences.") - .ok() - .err(400) - .err(404) - .err(502) - .body("PreferencesWriteBody", true), - op("POST", "/api/v1/preferences/reset", "Drop one user's saved preferences back to the defaults.") - .ok() - .err(400) - .err(404) - .err(502) - .body("PreferencesResetBody", true), - op("GET", "/api/v1/reporter-stats", "What the reporting loops produced and when.") - .ok() - .err(500) - .err_json(502), - op("GET", "/api/v1/services", "The compose services the operator can act on.") - .ok() - .err_json(503), - op("GET", "/api/v1/services/{name}/logs", "Recent log lines for one service, via the services adapter.") - .with_path(vec![path_param("name", "compose service name.", str_schema())]) - .ok() - .err_json(400) - .err_json(503) - .with_query(vec![opt_query("lines", "How many lines; the handler defaults to 200.", json!({"type": "integer", "format": "int32", "minimum": 1}))]), - op("POST", "/api/v1/services/{name}/{action}", "Start, stop or restart one service.") - .with_path(vec![path_param("name", "compose service name.", str_schema()), path_param("action", "Lifecycle action the services adapter accepts.", enum_schema(&["start", "stop", "restart"]))]) - .ok() - .err_json(400) - .err_json(503) - .with_query(actor_query()), - op("GET", "/api/v1/llm-search", "Natural-language search over the corpus, answered by the local model.") - .ok() - .with_query(vec![q("q", "The question."), opt_query("limit", "How many hits to summarise.", json!({"type": "integer", "minimum": 1})), q("source", "\"session\", \"vault\" or \"vault-note\"; an unknown value falls back to session.")]), - op("GET", "/api/v1/vault-rag", "Answer a question from the Vault corpus through the local model.") - .ok() - .with_query(vec![q("q", "The question.")]), - op("POST", "/api/v1/ip-block", "Block or unblock an address.") - .ok() - .err(400) - .err(502) - .body("BlockBody", true), - op("GET", "/api/v1/ip-block/{ip}", "One address's block state.") - .with_path(vec![path_param("ip", "Address whose block state is wanted.", str_schema())]) - .ok() - .err(400) - .err(502), - op("GET", "/api/v1/ip-block-export", "The whole block list, for backup or review.") - // Not JSON: ip_block::export answers the newline-joined IP - // list as text/plain, which is what makes it paste-able into a - // block list. The fuzzer called the declared application/json - // undocumented. - .ok_media("text/plain", "The blocked addresses, one per line.") - .err(502), - op("GET", "/api/v1/sandbox/{job}", "One sandbox run.") - .with_path(vec![path_param("job", "Sandbox job id.", str_schema())]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/ghidra/{sha}", "One Ghidra analysis run.") - .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/ghidra-callgraph/{sha}", "The call graph one Ghidra run produced.") - .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/revdeck/{sha}", "One RevDeck analysis run.") - .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/cape/{sha}", "One CAPE analysis run.") - .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/cape/{sha}/raw", "The raw CAPE report JSON for one run.") - .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) - .ok_media("application/json", "The stored report, verbatim.") - .err(404) - .err(502), - op("GET", "/api/v1/github-analysis/{sha}", "One GitHub analysis run.") - .with_path(vec![path_param("sha", "Payload/analysis subject id, lower-case hex.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{8,64}$"}))]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/attackers-graph", "The node/edge graph around one attacker entity.") - .ok() - .err(400) - .err(404) - .err(502) - .with_query(vec![q("id", "Attacker entity id.")]), - op("GET", "/api/v1/attack-vectors", "Attack vectors for one sensor.") - .ok() - .err(400) - .err(502) - .with_query(vec![q("sensor", "A specific sensor. Empty, suricata and portbridge are all rejected: those ship to their own index families.")]), - op("POST", "/api/v1/ml-anomalies/ack", "Acknowledge one ML anomaly.") - .ok() - .err(400) - .err(502) - .body("MlAckBody", true), - op("POST", "/api/v1/ml-anomalies/ack-all", "Acknowledge every open ML anomaly.") - .ok() - .err(502) - .body("MlAckAllBody", true), - op("GET", "/api/v1/ml-anomalies/acks", "The ack ledger.") - .ok() - .err(502), - op("GET", "/api/v1/ml-anomalies/stats", "Ack statistics.") - .ok() - .err(502), - op("POST", "/api/v1/ml-anomalies/disposition", "Record an analyst disposition for anomalies.") - .ok() - .err(400) - .err(502) - .body("MlDispositionBody", true), - op("GET", "/api/v1/reports/{id}/pdf", "One generated report, rendered to PDF.") - .with_path(vec![path_param("id", "Generated report id.", str_schema())]) - .ok_media("application/pdf", "The rendered PDF.") - .err(404) - .err(502), - op("GET", "/api/v1/reports/templates", "The report template and element catalog.") - .ok(), - op("GET", "/api/v1/reports/definitions", "Saved report definitions.") - .ok() - .err(502), - op("POST", "/api/v1/reports/definitions", "Save a new report definition.") - .ok_status(201, "The stored definition.") - .err(400) - .err(409) - .err(422) - .err(502) - .body("ReportDefinition", true), - op("GET", "/api/v1/reports/definitions/{id}", "One saved report definition.") - .with_path(vec![path_param("id", "Saved report-definition id.", str_schema())]) - .ok() - .err(404) - .err(502), - op("PUT", "/api/v1/reports/definitions/{id}", "Replace one saved report definition.") - .with_path(vec![path_param("id", "Saved report-definition id.", str_schema())]) - .ok() - .err(400) - .err(404) - .err(409) - .err(422) - .err(502) - .body("ReportDefinition", true), - op("DELETE", "/api/v1/reports/definitions/{id}", "Delete one saved report definition.") - .with_path(vec![path_param("id", "Saved report-definition id.", str_schema())]) - .ok() - .err(404) - .err(422) - .err(502), - op("POST", "/api/v1/reports/definitions/{id}/generate", "Run a saved definition now and store the result.") - .with_path(vec![path_param("id", "Saved report-definition id.", str_schema())]) - .ok_status(201, "The queued run.") - .err(400) - .err(404) - .err(409) - .err(502) - .body("GenerateBody", false), - op("DELETE", "/api/v1/reports/generated/{id}", "Delete one generated report.") - .with_path(vec![path_param("id", "Generated report id.", str_schema())]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/artifacts/{kind}/{key}", "Artifacts a run produced, one row per filename.") - .with_path(vec![path_param("kind", "Artifact family.", enum_schema(&["ghidra", "sandbox"])), path_param("key", "Run id the artifacts belong to (a sha256 for ghidra, a job id for sandbox).", str_schema())]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/artifacts/{kind}/{key}/{filename}", "Download one artifact of a run.") - .with_path(vec![path_param("kind", "Artifact family.", enum_schema(&["ghidra", "sandbox"])), path_param("key", "Run id the artifacts belong to.", str_schema()), path_param("filename", "Exact stored filename; the handler refuses a path separator or a name outside this key.", str_schema())]) - .ok_media("application/octet-stream", "The stored artifact bytes.") - .err(400) - .err(404) - .err(413) - .err(502) - .err(503), - op("GET", "/api/v1/charts/kill-chain-sankey", "Kill-chain stages as a sankey.") - .ok() - .err(502), - op("GET", "/api/v1/charts/attck-coverage", "ATT&CK technique coverage as a grid.") - .ok() - .err(502), - op("GET", "/api/v1/charts/campaign-timeline", "Campaigns over time.") - .ok() - .err(502), - op("GET", "/api/v1/charts/ml-backlog", "ML anomaly backlog over time.") - .ok() - .err(502), - op("GET", "/api/v1/charts/netflow-bytes", "Netflow bytes over time.") - .ok() - .err(502), - op("GET", "/api/v1/charts/netflow-packets", "Netflow packets over time.") - .ok() - .err(502), - op("GET", "/api/v1/charts/anomaly-trend", "Anomaly counts over time.") - .ok() - .err(502), - op("GET", "/api/v1/charts/dionaea-cves", "Dionaea exploit attempts by CVE.") - .ok() - .err(502), - op("GET", "/api/v1/charts/os-distribution", "Fingerprint-derived OS distribution.") - .ok() - .err(502), - op("GET", "/api/v1/charts/tcp-stack-clusters", "JA4T stack clusters.") - .ok() - .err(502), - op("GET", "/api/v1/charts/ics-functions", "ICS function codes seen.") - .ok() - .err(502), - op("GET", "/api/v1/charts/decoy-requests", "Requests per decoy.") - .ok() - .err(502), - op("GET", "/api/v1/charts/decoy-client-fingerprints", "Decoy requests joined against ClientHello fingerprints.") - .ok() - .err(502), - op("GET", "/api/v1/charts/ja4h-fingerprints", "JA4H fingerprint distribution.") - .ok() - .err(502), - op("GET", "/api/v1/charts/ja4x-fingerprints", "JA4X fingerprint distribution.") - .ok() - .err(502), - op("GET", "/api/v1/charts/ja4l-fingerprints", "JA4L fingerprint distribution.") - .ok() - .err(502), - op("GET", "/api/v1/charts/tls-fingerprints", "TLS fingerprint distribution.") - .ok() - .err(502), - op("GET", "/api/v1/charts/ssh-fingerprints", "SSH fingerprint distribution.") - .ok() - .err(502), - op("GET", "/api/v1/charts/endlessh-held-histogram", "How long endlessh held each connection.") - .ok() - .err(502), - op("GET", "/api/v1/charts/ml-anomaly-scores", "ML anomaly scores over time.") - .ok() - .err(502), - op("GET", "/api/v1/charts/attacker-fusion", "How one attacker's signals fuse across sources.") - .ok() - .err(400) - .err(404) - .err(502) - .with_query(vec![q("id", "Attacker entity id.")]), - op("GET", "/api/v1/campaigns", "Campaign store.") - .ok() - .err(502) - .with_query(store_query()), - op("GET", "/api/v1/clusters", "Attacker-cluster store.") - .ok() - .err(502) - .with_query(store_query()), - op("GET", "/api/v1/attackers", "Attacker-entity store.") - .ok() - .err(502) - .with_query(store_query()), - op("GET", "/api/v1/attackers/{id}/events", "The raw evidence behind one attacker entity.") - .with_path(vec![path_param("id", "Attacker entity id from /api/v1/attackers.", str_schema())]) - .ok() - .err(404) - .err(500) - .err(502) - .with_query(page_query()), - op("GET", "/api/v1/recordings", "TTY recording store.") - .ok() - .err(502) - .with_query(store_query()), - op("GET", "/api/v1/recordings/{shasum}", "One TTY recording.") - .with_path(vec![path_param("shasum", "Recording shasum to replay.", str_schema())]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/recordings/{shasum}/cast", "One TTY recording as asciicast.") - .with_path(vec![path_param("shasum", "Recording shasum to replay.", str_schema())]) - .ok_media("text/plain", "The asciicast body.") - .err(404) - .err(502), - op("GET", "/api/v1/recordings/{shasum}/raw", "One TTY recording as raw bytes.") - .with_path(vec![path_param("shasum", "Recording shasum to replay.", str_schema())]) - .ok_media("application/octet-stream", "The raw log bytes.") - .err(404) - .err(413) - .err(502), - op("GET", "/api/v1/alerts", "Alert-state store.") - .ok() - .err(502) - .with_query(store_query()), - op("POST", "/api/v1/alerts/{key}/ack", "Acknowledge one alert.") - .with_path(vec![path_param("key", "Alert-state document key (the hashified signature triple).", str_schema())]) - .ok() - .err(502) - .body("AckBody", true), - op("GET", "/api/v1/canarytokens/types", "The canarytoken types this build can mint.") - .ok(), - op("GET", "/api/v1/canarytokens", "Minted canarytokens, with their management token redacted.") - .ok() - .err(502), - op("POST", "/api/v1/canarytokens", "Mint a canarytoken.") - .ok() - .err(400) - .err(500) - .err(502) - .err(503) - .body("CreateBody", true), - op("GET", "/api/v1/canarytokens/{id}/download", "The canarytoken's landing URL, as a redirect.") - .with_path(vec![path_param("id", "Canarytoken id.", str_schema())]) - .ok_status(302, "Redirect to the token's landing URL.") - .err(400) - .err(404) - .err(500) - .err(502) - .err(503), - op("GET", "/api/v1/credentials", "HoneyFS implant credentials, with secrets redacted.") - .ok(), - op("POST", "/api/v1/credentials", "Provision a honeyfs-implant credential.") - .ok() - .err(400) - .err(500) - .err(502) - .err(503) - .body("CreateBody", true), - op("POST", "/api/v1/credentials/{id}/rotate", "Rotate a honeyfs-implant credential's secret.") - .with_path(vec![path_param("id", "HoneyFS implant credential id.", str_schema())]) - .ok() - .err(404) - .err(500) - .err(502) - .err(503) - .body("RotateBody", false), - op("POST", "/api/v1/credentials/{id}/link-token", "Mint a link token for a honeyfs-implant credential.") - .with_path(vec![path_param("id", "HoneyFS implant credential id.", str_schema())]) - .ok() - .err(400) - .err(404) - .err(500) - .body("LinkTokenBody", true), - op("GET", "/api/v1/payloads", "Captured-payload store.") - .ok() - .err(502) - .with_query(store_query()), - op("GET", "/api/v1/payloads/{hash}", "One captured payload and its analysis.") - .with_path(vec![path_param("hash", "Payload id: 32 or 64 lower-case hex characters.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{32}([0-9a-fA-F]{32})?$"}))]) - .ok() - .err(404) - .err(502), - op("GET", "/api/v1/payloads/{hash}/raw", "One captured payload's bytes.") - .with_path(vec![path_param("hash", "Payload id: 32 or 64 lower-case hex characters.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{32}([0-9a-fA-F]{32})?$"}))]) - .ok_media("application/octet-stream", "The payload bytes.") - .err(400) - .err(404) - .err(413) - .err(502), - op("POST", "/api/v1/payloads/{hash}/report", "One-click payload PDF into the generated store.") - .with_path(vec![path_param("hash", "Payload id: 32 or 64 lower-case hex characters.", json!({"type": "string", "pattern": "^[0-9a-fA-F]{32}([0-9a-fA-F]{32})?$"}))]) - .ok_status(201, "The queued report run.") - .err(400) - .err(404) - .err(422) - .err(501) - .err(502), - op("GET", "/api/v1/store/{name}", "One allowlisted store, through the generic passthrough.") - .with_path(vec![path_param("name", "Allowlisted generic store. Anything else is a 404 -- this route is not an arbitrary index read.", enum_schema(STORE_NAMES))]) - .ok() - .err(404) - .err(502) - .with_query(store_query()), - op("DELETE", "/api/v1/store/{name}", "Purge dead letters matching ?q= (dead-letters only).") - .with_path(vec![path_param("name", "Allowlisted generic store. Anything else is a 404 -- this route is not an arbitrary index read.", enum_schema(STORE_NAMES))]) - .ok() - .err(405) - .err(502) - .with_query(vec![q("q", "Lucene query string; absent or empty purges every retained dead letter.")]), - op("POST", "/api/v1/problem-reports", "File a problem report from the dashboard UI.") - .ok_status(201, "The stored report.") - .err(400) - .err(404) - .err(409) - .err(502) - .body("Submission", true) - .with_query(actor_query()), - op("PATCH", "/api/v1/problem-reports/{id}", "Move a problem report through open/triaged/closed.") - .with_path(vec![path_param("id", "Problem-report id.", str_schema())]) - .ok_status(204, "No content; the status was stored.") - .err(400) - .err(404) - .err(409) - .err(502) - .body("StatusPatch", true), - op("POST", "/api/v1/sandbox/submit", "Queue a sandbox detonation.") - .ok() - .err(400) - .err(404) - .err(503) - .body("SubmitBody", true), - op("GET", "/api/v1/sandbox/golden-image-status", "Whether the sandbox golden image is built.") - .ok(), - op("GET", "/api/v1/sandbox/vnc", "The VNC port the sandbox advertises, if any.") - .ok() - .err(404), - op("POST", "/api/v1/ghidra/submit", "Queue a Ghidra analysis.") - .ok() - .err(400) - .err(404) - .err(503) - .body("SubmitBody", true), - op("POST", "/api/v1/github-analysis/submit", "Queue a GitHub analysis.") - .ok() - .err(400) - .err(404) - .err(503) - .body("SubmitBody", true), - op("GET", "/api/v1/workbench/analyzers", "Analyzers available for one payload.") - .ok() - .err_json(400) - .err_json(404) - .with_query(vec![q("hash", "Payload id.")]), - op("GET", "/api/v1/workbench/runs", "Payload Workbench runs owned by the calling operator.") - .ok() - .err_json(502) - .actor() - .with_query(vec![q("hash", "Narrow to one payload id."), opt_query("limit", "How many runs to return.", json!({"type": "integer", "minimum": 1}))]), - op("POST", "/api/v1/workbench/runs", "Start a Workbench run over one payload.") - .ok() - .err_json(400) - .err_json(404) - .err_json(502) - .actor() - .body("CreateRunBody", true), - op("GET", "/api/v1/workbench/runs/{id}", "One Workbench run, with its children.") - .with_path(vec![path_param("id", "Workbench run id.", str_schema())]) - .ok() - .err_json(404) - .err_json(502) - .actor(), - op("POST", "/api/v1/workbench/runs/{id}/children/{analyzer_id}/{action}", "Cancel or retry one child of a run.") - .with_path(vec![path_param("id", "Workbench run id.", str_schema()), path_param("analyzer_id", "Analyzer entry on this run; the orchestrator looks the child up by it.", str_schema()), path_param("action", "Child lifecycle action.", enum_schema(&["cancel", "retry"]))]) - .ok() - .err_json(400) - .err_json(404) - .err_json(502) - .actor(), - op("GET", "/api/v1/workbench/recipes", "Saved Workbench recipes owned by the calling operator.") - .ok() - .err_json(502) - .actor(), - op("POST", "/api/v1/workbench/recipes", "Save a Workbench recipe.") - .ok() - .err_json(400) - .err_json(404) - .err_json(409) - .err_json(502) - .actor() - .body("SaveRecipeBody", true), - op("GET", "/healthz", "Liveness plus an Elasticsearch reachability flag. Public on purpose: the container healthcheck is the caller.") - .public() - .ok() - .err(502), - op("GET", "/livez", "Liveness. The same handler as /healthz under a second name (main.rs); public like it.") - .public() - .ok(), - op("GET", "/readyz", "Readiness: Elasticsearch reachable and this tier's write targets checked. Public like /healthz.") - .public() - .ok(), - op("GET", "/metrics", "Prometheus exposition for the #1972 request metrics. Public on purpose, like /healthz.") - .public() - .ok_media("text/plain", "Prometheus text exposition format."), - ] +/// The 401's description. The middleware answers before a route is +/// resolved, so this is the one status text `render` has to supply. +fn unauthorized_description() -> &'static str { + "No valid service token (or, on the Workbench, no actor identity)." } /// The tag an operation is filed under. Derived from the path so the -/// grouping is reviewable in one place rather than repeated 138 times. +/// grouping is reviewable in one place rather than repeated 141 times. fn tag_for(path: &str) -> &'static str { let rest = path.strip_prefix("/api/v1/").unwrap_or(path); // Group by the *first* segment, not the whole remainder. Matching @@ -1156,270 +406,11 @@ fn tag_description(tag: &str) -> &'static str { } } -/// The allowlisted store names, mirroring `stores.rs`'s `store_config` -/// match. A copy rather than a reference because the contract is a -/// document, not a view of the binary -- but it is a copy with teeth: -/// anything outside this list is a 404 at runtime, so widening the -/// contract's enum without widening the allowlist would advertise a -/// route that cannot work. -const STORE_NAMES: &[&str] = &[ - "agent-campaigns", - "auth-events", - "canarytokens", - "cape", - "dead-letters", - "generated-reports", - "ghidra-runs", - "github-analysis", - "intelligence", - "llm-analysis", - "ml-anomalies", - "problem-reports", - "report-definitions", - "revdeck", - "sandbox-runs", - "static-analysis", - "workbench-runs", - "yara", -]; - -/// Renders the OpenAPI 3.1 document. Deterministic: operations are -/// emitted in sorted (path, method) order and every map is built with -/// sorted keys, so regenerating an unchanged table produces a -/// byte-identical file and a reviewer can read a real diff. -pub fn document() -> Value { - let mut ops = operations(); - ops.sort_by(|a, b| (a.path, a.method).cmp(&(b.path, b.method))); - - let mut paths: Map = Map::new(); - for op in &ops { - let item = paths.entry(op.path.to_string()).or_insert_with(|| json!({})); - let object = item - .as_object_mut() - .expect("path items are always built as objects"); - assert!( - !object.contains_key(op.method), - "two operations claim {} {}", - op.method, - op.path, - ); - // A Path Item's method fields are lowercase in OpenAPI - // (`get:`, not `GET:`) -- the table above keeps the uppercase - // spelling because that is how the methods read in the source - // and in the drift test, so the case is folded here rather than - // in all 138 rows. - object.insert(op.method.to_ascii_lowercase(), render_operation(op)); - } - - let mut tag_names: Vec<&str> = ops.iter().map(|op| tag_for(op.path)).collect(); - tag_names.sort_unstable(); - tag_names.dedup(); - let tags: Vec = tag_names - .iter() - .map(|tag| json!({"name": tag, "description": tag_description(tag)})) - .collect(); - - json!({ - "openapi": "3.1.0", - "info": { - "title": "APIARY dashboard backend-service", - "version": env!("CARGO_PKG_VERSION"), - "summary": "The dashboard's Rust service tier: Elasticsearch-backed \ - read APIs over the honeypot event corpus.", - "description": INFO_DESCRIPTION - }, - "servers": [ - {"url": "http://127.0.0.1:8081", "description": - "The default LISTEN_ADDR. In compose this service is reachable \ - only over the internal honeynet, and only by the dashboard BFF."} - ], - "tags": Value::Array(tags), - "paths": Value::Object(paths), - "components": { - "securitySchemes": { - "serviceToken": { - "type": "apiKey", - "in": "header", - "name": "X-Service-Token", - "description": "The shared secret the Nitro BFF presents on \ - every /api/v1 call (SERVICE_TOKEN; main.rs's \ - require_service_token, #2183). Compared in \ - constant time, and a wrong or missing value \ - is a 401 before the route is even resolved. \ - Browsers never reach this service directly." - } - } - } - }) -} - -const INFO_DESCRIPTION: &str = "\ -The /api surface of `backend-service/` (`apiary-backend`), the Rust service \ -tier of the APIARY dashboard's modernization port (#1608). It is not a \ -public API: the Nitro BFF in `frontend-next/` is the only intended caller \ -and presents a shared service token on every /api/v1 route. /healthz and \ -/metrics are the two exceptions, open on purpose for the container \ -healthcheck and the #1972 metrics scrape. - -Response bodies are deliberately left unconstrained here. The handlers \ -return `Json` or a per-surface struct that nothing in this crate \ -enforces, so restating 138 shapes would be 138 chances to publish \ -something the code does not promise -- and a contract that lies about a \ -response is worse than one that admits the gap. What this document does \ -pin is the part that has been wrong before: the auth tier, the request \ -parameters (including the enum-valued ones the handlers actually \ -validate), the declared status codes, and the media type of every \ -response. That is what `.github/workflows/weekly-schemathesis.yml` \ -checks. - -`arcane/home/honeypot-dashboard/backend-service/src/openapi.rs` is the \ -source of truth. Regenerate the committed copy with \ -`cargo run --bin openapi > openapi.json`; the drift tests in that module \ -and a diff step in `quality.yml` both fail if you forget."; - -fn render_operation(op: &Op) -> Value { - let mut parameters: Vec = Vec::new(); - for param in &op.path_params { - parameters.push(json!({ - "name": param.name, - "in": "path", - "required": true, - "description": param.description, - "schema": param.schema, - })); - } - for param in &op.query { - parameters.push(json!({ - "name": param.name, - "in": "query", - "required": false, - "description": param.description, - "schema": param.schema, - })); - } - for param in &op.headers { - parameters.push(json!({ - "name": param.name, - "in": "header", - "required": param.required, - "description": param.description, - "schema": param.schema, - })); - } - - let mut responses: Map = Map::new(); - for response in &op.responses { - let mut content = Map::new(); - for (name, schema) in &response.schemas { - content.insert((*name).to_string(), json!({"schema": schema})); - } - let key = response.status.to_string(); - // Merge, do not overwrite. One status can be reachable with two - // media types on this API -- the Workbench's 400 is text/plain - // when axum's `Query` extractor refuses the query string and - // application/json when the handler rejects the value itself -- - // and an overwrite here would quietly publish only whichever was - // declared last, which is how the fuzzer ends up reporting a - // documented-but-unreachable media type. - match responses.get_mut(&key) { - Some(existing) => { - let existing = existing - .as_object_mut() - .expect("a rendered response is always an object"); - if let Some(existing_content) = existing.get_mut("content").and_then(Value::as_object_mut) - { - for (name, schema) in content { - existing_content.insert(name, schema); - } - } else { - existing.insert("content".to_string(), Value::Object(content)); - } - } - None => { - let mut rendered = Map::new(); - rendered.insert("description".to_string(), json!(response.description)); - if !content.is_empty() { - rendered.insert("content".to_string(), Value::Object(content)); - } - responses.insert(key, Value::Object(rendered)); - } - } - } - // The auth tier belongs in every operation, not in a note above the - // table: it is the property the fuzzer is pointed at, and an - // operation that quietly lost its `security` block would otherwise - // still validate. - match op.tier { - Tier::Public => {} - Tier::ServiceToken => { - responses.insert( - "401".to_string(), - json!({ - "description": error_description(401), - "content": {"text/plain": {"schema": str_schema()}} - }), - ); - } - Tier::ServiceTokenAndActor => { - // Both media types are reachable on one status: no token gets - // the middleware's text/plain 401, a valid token without a - // forwarded actor gets the Workbench's JSON one. - responses.insert( - "401".to_string(), - json!({ - "description": error_description(401), - "content": { - "text/plain": {"schema": str_schema()}, - "application/json": {"schema": free_form()} - } - }), - ); - } - } - - let mut rendered = Map::new(); - rendered.insert("tags".to_string(), json!([tag_for(op.path)])); - rendered.insert("summary".to_string(), json!(op.summary)); - rendered.insert("operationId".to_string(), json!(operation_id(op))); - rendered.insert("parameters".to_string(), Value::Array(parameters)); - if op.method != "GET" { - if let Some(body) = &op.body { - rendered.insert( - "requestBody".to_string(), - json!({ - "required": body.required, - "description": format!( - "Deserialized by the handler into `{}`. The shape is \ - left open here on purpose -- see the module doc.", - body.handler_struct - ), - "content": {"application/json": {"schema": free_form()}} - }), - ); - } - } - rendered.insert("responses".to_string(), Value::Object(responses)); - if op.tier != Tier::Public { - rendered.insert("security".to_string(), json!([{"serviceToken": []}])); - } - if op.success_media == Some("text/event-stream") { - // Not part of OpenAPI, and present on exactly one operation: a - // fuzzer that reads the document has to be able to find the - // route whose body never ends without hardcoding a path. The - // other non-JSON routes (CSV, PDF, octet-stream) terminate and - // need no marker -- a response too big to assert on is a - // finding, not a hang. - rendered.insert("x-endless-stream".to_string(), json!(true)); - } - Value::Object(rendered) -} - /// A stable, greppable operation id: the method, then the path with `/` -/// and `{}` folded away. Unique by construction, because `document()` -/// has already refused a repeated (path, method). -fn operation_id(op: &Op) -> String { - let path: String = op - .path +/// and `{}` folded away. Unique by construction, because a repeated +/// (path, method) cannot be registered on one `Router` in the first place. +fn operation_id(method: &str, path: &str) -> String { + let path: String = path .trim_start_matches('/') .chars() .map(|c| match c { @@ -1427,7 +418,7 @@ fn operation_id(op: &Op) -> String { other => other, }) .collect(); - format!("{}_{}", op.method.to_lowercase(), path) + format!("{}_{}", method.to_lowercase(), path) } #[cfg(test)] @@ -1441,7 +432,7 @@ mod tests { } /// #3325's drift gate, direction one: the committed document has to - /// be what this module renders. Compared parsed rather than + /// be what the generator renders. Compared parsed rather than /// byte-wise, so a reformat of the JSON is not a red build while a /// changed status code, parameter or tag is. #[test] @@ -1460,50 +451,95 @@ mod tests { ); } - /// #3325's drift gate, direction two: every `.route(...)` the service - /// registers is in the contract, and nothing in the contract is - /// missing from the router. The direction that matters is - /// router -> contract -- a new route with no contract row is how a - /// fuzz target quietly stops covering the thing it was added for. - /// The other direction catches a contract path that no longer routes, - /// which is how a fuzzer ends up measuring a 404. + /// The `axum::Router` methods `OpenApiRouter` inherits as pass-throughs: + /// each one puts a route in front of a caller and **nothing** in the + /// OpenAPI document, because there is no `#[utoipa::path]` to read. A + /// route registered with one of these is the one way the contract can + /// fall behind the service, so the drift gate below refuses them by + /// name rather than leaving the next reader to notice. + const BYPASSING_ROUTES: [&str; 3] = [".route(", ".route_service(", ".nest_service("]; + + /// #3325's drift gate, direction two: every route the service serves is + /// in the contract, and every operation in the contract is a route the + /// service serves. + /// + /// This test used to read the `.route(path, method(handler))` calls out + /// of the route table's source and compare the set against the + /// document. That comparison is gone, and deliberately so: with + /// `utoipa-axum` the table no longer spells paths out at all -- it reads + /// them from each handler's `#[utoipa::path]` and registers the handler + /// in the axum router and in the document in one call. There is no + /// second list left to disagree with the first, which was the whole + /// point of the migration and the reason the hand table rotted twice. + /// + /// What replaced it closes the two doors that *are* still open, and + /// both are in this crate's own source: /// - /// The route table was read out of `src/main.rs` while the binary - /// still owned it. It is read out of `src/lib.rs` now, because the - /// table moved there with the handler modules (#3325's utoipa - /// migration): the contract is generated from the same builder the - /// process serves, and a second binary cannot see a first binary's - /// modules. Every assertion below is unchanged -- only the file the - /// scan reads moved, because the code it scans moved. + /// 1. `OpenApiRouter` inherits `axum::Router`'s `.route(...)`, + /// `.route_service(...)` and `.nest_service(...)` as pass-throughs + /// that add a served route and **no** OpenAPI operation. A route + /// registered that way is invisible to the contract and to the + /// weekly fuzz job, so the table must contain none of them and the + /// test says so by name. + /// 2. The other direction is a dead annotation: a `#[utoipa::path]` on + /// a handler nothing routes, which publishes an operation that + /// answers 404. The fuzzer would chase it every week, so every + /// annotation in the crate has to be a live one. #[test] fn contract_covers_every_router_route() { - let main_rs = + let lib_rs = std::fs::read_to_string(crate_dir().join("src/lib.rs")).expect("src/lib.rs is readable"); - let registered: BTreeSet<(String, String)> = router_routes(&main_rs) - .into_iter() + + // (1) The bypass. Comment lines are skipped: `lib.rs`'s own module + // doc quotes the `.route("/api/v1/events", get(events::list))` form + // this test used to parse, and that quote is not a registration. + let bypasses: Vec<&str> = BYPASSING_ROUTES + .iter() + .copied() + .filter(|call| { + lib_rs + .lines() + .enumerate() + .any(|(number, line)| line.contains(call) && !is_comment(line, number, &lib_rs)) + }) .collect(); assert!( - registered.len() > 100, - "the route scan found only {} registrations -- the parser is broken, \ - not the router", - registered.len(), + bypasses.is_empty(), + "the route table registers {:?} on the OpenApiRouter, which serves the \ + route without putting an operation in the contract (#3325). Register it \ + with `.routes(utoipa_axum::routes!(handler))` instead, or the weekly \ + fuzz job stops covering it.", + bypasses, ); - let published: BTreeSet<(String, String)> = operations() + // (2) The dead annotation. The whole tree, because the annotations + // are on the handlers and the handlers are spread over 46 files. + let annotated = annotated_operations(); + assert!( + annotated.len() > 130, + "the annotation scan found only {} `#[utoipa::path]` attributes -- the \ + parser is broken, not the crate", + annotated.len(), + ); + + let published: BTreeSet<(String, String)> = document()["paths"] + .as_object() + .expect("paths is an object") .iter() - .map(|op| (op.path.to_string(), op.method.to_string())) + .flat_map(|(path, item)| { + item.as_object().expect("a path item is an object").keys().map(move |method| { + (path.clone(), method.to_ascii_uppercase()) + }) + }) .collect(); - let missing: Vec<_> = registered.difference(&published).collect(); - let extra: Vec<_> = published.difference(®istered).collect(); + let unrouted: Vec<_> = annotated.difference(&published).collect(); assert!( - missing.is_empty() && extra.is_empty(), - "the contract and the service's route table disagree about the /api \ - surface (#3325).\n\ - registered but not in openapi.json: {missing:#?}\n\ - in openapi.json but not registered: {extra:#?}\n\ - add the row to operations() in src/openapi.rs, then run \ - `cargo run --bin openapi > openapi.json`.", + unrouted.is_empty(), + "these handlers carry a `#[utoipa::path]` but are not routed (#3325), so \ + the contract advertises operations the service answers 404 for: \ + {unrouted:#?}\nregister them in src/lib.rs, or drop the annotation, then \ + run `cargo run --bin openapi > openapi.json`.", ); } @@ -1518,6 +554,7 @@ mod tests { assert_eq!(document["openapi"], json!("3.1.0")); assert!(document["info"]["title"].is_string()); assert!(document["info"]["version"].is_string()); + assert!(document["info"]["summary"].is_string()); assert_eq!( document["components"]["securitySchemes"]["serviceToken"]["name"], json!("X-Service-Token"), @@ -1535,6 +572,14 @@ mod tests { operation["summary"].is_string(), "{method} {path} has no summary" ); + // The transform strips the Rust doc comment's description; + // if one ever reaches the document this fails rather than + // publishing a second wording of the same route. + assert!( + operation.get("description").is_none(), + "{method} {path} carries a description; the contract carries \ + a summary per operation and the doc comment is for Rust readers" + ); let responses = operation["responses"] .as_object() @@ -1555,14 +600,21 @@ mod tests { "/healthz" | "/livez" | "/readyz" | "/metrics" ), "{method} {path}: /healthz, /livez, /readyz and /metrics are the only \ - routes outside the service-token tier (main.rs)" + routes outside the service-token tier (this crate's require_service_token)" ); + if secured { + let unauthorized = &responses["401"]; + assert!( + unauthorized["content"]["text/plain"]["schema"] + == json!({"type": "string"}), + "{method} {path}: the middleware's 401 is text/plain, because \ + require_service_token returns a bare (StatusCode, String)" + ); + } // Every `Json` body can be refused by the extractor // before the handler runs, and the fuzzer sends exactly - // the bodies that trip it. `body()` adds these, so this - // only fires if a row starts declaring a body some other - // way. + // the bodies that trip it. if operation.get("requestBody").is_some() { for status in ["415", "422"] { assert!( @@ -1571,13 +623,25 @@ mod tests { (axum's Json rejection)" ); } + let body = &operation["requestBody"]; + assert!( + body["content"]["application/json"]["schema"].is_object(), + "{method} {path}: its request body has no schema" + ); + // The optional-body marker is transform input, not + // output: one reaching the document means the + // transform stopped consuming it. + assert!( + body.get("x-optional-body").is_none(), + "{method} {path}: the x-optional-body marker reached the document" + ); } // Same for a `Query` extractor, which answers 400 as - // text/plain. `with_query` adds it. One direction only: - // a row may declare 400 for its handler's own reasons - // with no query parameters at all (the artifact download - // refuses a path separator that way). + // text/plain. One direction only: an operation may + // declare 400 for its handler's own reasons with no query + // parameters at all (the artifact download refuses a path + // separator that way). let takes_query = operation .get("parameters") .and_then(Value::as_array) @@ -1630,6 +694,28 @@ mod tests { assert!(operations_seen > 130, "only {operations_seen} operations"); } + /// The one operation whose body never ends has to be findable by a + /// reader of the document, not by someone who knows the path. The + /// marker is an extension, so it is not part of the OpenAPI spec, but + /// the weekly fuzz job excludes on exactly this key. + #[test] + fn the_endless_stream_is_marked_and_it_is_the_only_one() { + let document = document(); + let marked: Vec<&str> = document["paths"] + .as_object() + .expect("paths is an object") + .iter() + .filter(|(_, item)| { + item.as_object() + .expect("a path item is an object") + .values() + .any(|operation| operation.get("x-endless-stream") == Some(&json!(true))) + }) + .map(|(path, _)| path.as_str()) + .collect(); + assert_eq!(marked, ["/api/v1/live"]); + } + /// The enum the contract advertises for `/api/v1/store/{name}` has /// to be the allowlist the handler enforces, or the fuzzer is being /// told a route exists that answers 404 for every value it generates. @@ -1671,6 +757,26 @@ mod tests { ); } + /// Regenerating the document must not depend on anything that changes + /// between runs -- a timestamp, a set iteration order, a HashMap. The + /// weekly job re-generates and diffs, so a document that is equal + /// parsed but not equal as bytes is a red build for a reason nobody + /// can act on. + #[test] + fn the_document_is_byte_stable() { + let once = serde_json::to_string_pretty(&document()).expect("serializes"); + let twice = serde_json::to_string_pretty(&document()).expect("serializes"); + assert_eq!(once, twice, "two renders of one source tree disagree"); + let committed = std::fs::read_to_string(crate_dir().join("openapi.json")) + .expect("openapi.json is readable"); + assert_eq!( + once.trim_end(), + committed.trim_end(), + "openapi.json is not byte-identical to what the generator renders -- \ + regenerate with `cargo run --bin openapi > openapi.json`" + ); + } + fn capture_names(path: &str) -> BTreeSet { let mut names = BTreeSet::new(); let mut rest = path; @@ -1683,116 +789,123 @@ mod tests { names } - /// The `(path, method)` pairs the service registers, read out of the - /// source rather than a hand-kept list. A third list would be a - /// third thing to update, and the point of the gate is that the - /// router stays the thing that decides what exists. + /// The `(path, method)` pairs the crate's `#[utoipa::path]` attributes + /// declare, read out of the source rather than a hand-kept list. A + /// third list would be a third thing to update, and the point of the + /// gate is that the router stays the thing that decides what exists. /// - /// The scan tracks paren depth, so a method name is only read where - /// it sits in the route's own argument list. That is what keeps - /// `get(preferences::get).put(preferences::put)` from reading as - /// four methods instead of two, and `axum::routing::put(...)` -- - /// where the name arrives after `::` -- from reading as none. - fn router_routes(source: &str) -> Vec<(String, String)> { - const METHODS: [&str; 5] = ["get", "post", "put", "delete", "patch"]; - let bytes = source.as_bytes(); - let mut found: Vec<(String, String)> = Vec::new(); - let mut cursor = 0usize; - - while let Some(offset) = source[cursor..].find(".route(") { - let open = cursor + offset + ".route(".len(); - let mut depth = 1i32; - let mut end = open; - while depth > 0 { - match bytes[end] { - b'(' => depth += 1, - b')' => depth -= 1, - _ => {} + /// Each attribute is read to its matching close paren, so the nested + /// groups (`params(...)`, `responses(...)`, `request_body(...)`) do not + /// end the walk early, and the method and path are then picked out of + /// the attribute's own header -- the one line, before any group opens. + /// + /// The attribute has to open a line. Every one in this crate does, and + /// requiring it is what keeps this scan (and its own test module, which + /// quotes the attribute form in a string and a doc comment) from + /// reading its own text as an annotation. + fn annotated_operations() -> BTreeSet<(String, String)> { + let mut found = BTreeSet::new(); + for source in crate_sources() { + let mut offset = 0usize; + for line in source.text.split_inclusive('\n') { + if line.starts_with("#[utoipa::path(") { + let attribute = + attribute_at(&source.text, offset).expect("the line opens the attribute"); + found.insert(declared_operation(attribute, &source.name)); } - end += 1; - } - let body = &source[open..end - 1]; - cursor = end; - - let Some(first_quote) = body.find('"') else { continue }; - let after = &body[first_quote + 1..]; - let Some(closing) = after.find('"') else { continue }; - let path = &after[..closing]; - - for method in methods_in(&after[closing + 1..], &METHODS) { - // axum spells these lowercase (`get(handler)`); the - // contract spells them the way OpenAPI does (`GET`). - // Normalize here so the comparison below is two - // vocabularies rather than a wall of case mismatches. - found.push((path.to_string(), method.to_ascii_uppercase())); + offset += line.len(); } } found } - fn methods_in<'a>(arguments: &str, methods: &'a [&'a str]) -> Vec<&'a str> { - let bytes = arguments.as_bytes(); - let mut found: Vec<&'a str> = Vec::new(); - let mut depth = 0i32; - let mut index = 0usize; - while index < bytes.len() { - match bytes[index] { + /// The attribute's text, from just inside `#[utoipa::path(` to its + /// matching close paren. + fn attribute_at(source: &str, from: usize) -> Option<&str> { + let open = from + "#[utoipa::path(".len(); + let bytes = source.as_bytes(); + let mut depth = 1i32; + let mut end = open; + while depth > 0 { + match bytes[end] { b'(' => depth += 1, b')' => depth -= 1, _ => {} } - if depth == 0 { - for method in methods.iter().copied() { - if !arguments[index..].starts_with(method) { - continue; - } - // A method name is a whole token: the character in - // front of it is not part of a longer identifier. - let before = if index == 0 { b' ' } else { bytes[index - 1] }; - if before.is_ascii_alphanumeric() || before == b'_' { - continue; - } - let after = &arguments[index + method.len()..]; - let call = after.trim_start(); - if call.starts_with('(') { - found.push(method); - // Step over the call so its arguments -- and any - // identifier inside them that merely ends in a - // method name -- are not rescanned. `call` is a - // slice of `arguments` offset by the leading - // whitespace, so the `(` is this far in; the - // balance starts at zero and the `(` itself is - // counted, which stops the walk exactly after - // this method's own closing paren. That is what - // leaves a chained `.post(...)` to be read. - let open = index + method.len() + (after.len() - call.len()); - let mut inner = 0i32; - let mut scan = open; - // `open` is the `(`, so the balance only returns - // to zero once this method's own call is - // closed -- leaving a chained `.post(...)` at - // depth zero for the outer loop to read. An - // unbalanced tail stops on the end of the input - // rather than looping forever. - while scan < bytes.len() { - match bytes[scan] { - b'(' => inner += 1, - b')' => inner -= 1, - _ => {} - } - scan += 1; - if inner == 0 { - break; - } - } - index = scan; - } - break; + end += 1; + } + Some(&source[open..end - 1]) + } + + /// The `(path, method)` one attribute declares. + fn declared_operation(attribute: &str, name: &str) -> (String, String) { + const METHODS: [&str; 5] = ["get", "post", "put", "delete", "patch"]; + // The header runs from the opening paren to the first nested group, + // which is where the method and the path are and where every group + // key (`params`, `responses`, `request_body`, `security`, + // `extensions`) has not yet appeared. + let header_end = ["params(", "responses(", "request_body(", "security("] + .iter() + .filter_map(|group| attribute.find(group)) + .min() + .unwrap_or(attribute.len()); + let header = &attribute[..header_end]; + let method = METHODS + .iter() + .copied() + .find(|method| { + header + .split(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .any(|word| word == *method) + }) + .unwrap_or_else(|| panic!("a #[utoipa::path] in {name} names no HTTP method")); + let path = attribute + .split_once("path = \"") + .and_then(|(_, after)| after.split_once('"')) + .map(|(path, _)| path) + .unwrap_or_else(|| panic!("a #[utoipa::path] in {name} names no path")); + (path.to_string(), method.to_ascii_uppercase()) + } + + /// The crate's own `src/**/*.rs`, as (name, text). A third list of + /// handler files would be a third thing to keep in step, and the + /// annotations are on the handlers. + fn crate_sources() -> Vec { + fn walk(directory: &std::path::Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(directory) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|extension| extension == "rs") { + let name = path + .strip_prefix(crate_dir().join("src")) + .expect("the walk started at src") + .display() + .to_string(); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("{} unreadable: {error}", path.display())); + out.push(SourceFile { name, text }); } } - index += 1; } - found + let mut out = Vec::new(); + walk(&crate_dir().join("src"), &mut out); + assert!(out.len() > 50, "the scan found only {} source files", out.len()); + out + } + + struct SourceFile { + name: String, + text: String, + } + + /// Whether a line is inside a comment or a string. Used only to keep + /// the bypass scan off `lib.rs`'s own module doc, which quotes the + /// pre-`utoipa` registration form on purpose. + fn is_comment(line: &str, _number: usize, _source: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") } /// "Here is where they stop matching", so a stale contract names the diff --git a/arcane/home/honeypot-dashboard/backend-service/src/overview.rs b/arcane/home/honeypot-dashboard/backend-service/src/overview.rs index a79d46dc..fa86e783 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/overview.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/overview.rs @@ -110,6 +110,16 @@ async fn unique_ips_live(state: &AppState) -> Result Ok(result["aggregations"]["unique_ips"]["value"].as_u64().unwrap_or(0)) } +#[utoipa::path( + get, + path = "/api/v1/overview/kpis", + summary = "KPI counters behind the overview tiles.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn kpis(State(state): State) -> Result, (StatusCode, String)> { // #2046: the rolled fleet hours are the primary path; the aggregation // below runs only while the worker hasn't covered the window yet (fresh diff --git a/arcane/home/honeypot-dashboard/backend-service/src/payload_detail.rs b/arcane/home/honeypot-dashboard/backend-service/src/payload_detail.rs index 3ac762c1..187559cb 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/payload_detail.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/payload_detail.rs @@ -4,6 +4,7 @@ //! (the full artifact stays server-side; only the preview crosses the //! wire, mirroring the legacy static-analysis page's posture). +use crate::contract; use axum::{ extract::{Path, State}, http::{header, StatusCode}, @@ -48,6 +49,20 @@ fn hexdump(bytes: &[u8]) -> Vec { .collect() } +#[utoipa::path( + get, + path = "/api/v1/payloads/{hash}", + summary = "One captured payload and its analysis.", + params( + ("hash" = inline(contract::PayloadHash), Path, description = "Payload id: 32 or 64 lower-case hex characters."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn detail( State(state): State, Path(hash): Path, @@ -124,6 +139,22 @@ pub async fn detail( Ok(Json(PayloadDetail { hash, inventory, analysis, yara: yara_rows, size_bytes, hex_preview })) } +#[utoipa::path( + get, + path = "/api/v1/payloads/{hash}/raw", + summary = "One captured payload's bytes.", + params( + ("hash" = inline(contract::PayloadHash), Path, description = "Payload id: 32 or 64 lower-case hex characters."), + ), + responses( + (status = 200, description = "The payload bytes.", body = inline(serde_json::Value), content_type = "application/octet-stream"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 413, description = "The stored artifact is larger than this endpoint will serve.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/payloads/{hash}/raw — streams the full captured binary, /// ported from payloads_data.go's servePayload. Admin-gating happens at /// the BFF (frontend-next checks the session role before ever calling diff --git a/arcane/home/honeypot-dashboard/backend-service/src/preferences.rs b/arcane/home/honeypot-dashboard/backend-service/src/preferences.rs index f35aea84..9fe8d4d9 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/preferences.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/preferences.rs @@ -124,6 +124,22 @@ pub struct PreferencesQuery { timezone: String, } +#[utoipa::path( + get, + path = "/api/v1/preferences", + summary = "One user's saved preferences.", + params( + ("subject" = inline(Option), Query, description = "OIDC subject. Required -- an empty value is a 400."), + ("username" = inline(Option), Query, description = "Operator name."), + ("role" = inline(Option), Query, description = "Operator role."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn get( State(state): State, axum::extract::Query(query): axum::extract::Query, @@ -384,6 +400,21 @@ pub struct PreferencesWriteBody { patch: PreferencesPatch, } +#[utoipa::path( + put, + path = "/api/v1/preferences", + summary = "Replace one user's saved preferences.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `PreferencesWriteBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn put( State(state): State, Json(body): Json, @@ -433,6 +464,21 @@ pub struct PreferencesResetBody { timezone: String, } +#[utoipa::path( + post, + path = "/api/v1/preferences/reset", + summary = "Drop one user's saved preferences back to the defaults.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `PreferencesResetBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn reset( State(state): State, Json(body): Json, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/problem_reports.rs b/arcane/home/honeypot-dashboard/backend-service/src/problem_reports.rs index 4d904459..0758aa56 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/problem_reports.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/problem_reports.rs @@ -188,6 +188,26 @@ async fn button_enabled(state: &AppState) -> bool { .unwrap_or(false) } +#[utoipa::path( + post, + path = "/api/v1/problem-reports", + summary = "File a problem report from the dashboard UI.", + params( + ("actor_subject" = inline(Option), Query, description = "OIDC subject recorded on the audit/history entry."), + ("actor_username" = inline(Option), Query, description = "Operator name recorded on the audit/history entry."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `Submission`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 201, description = "The stored report."), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 409, description = "The record changed since the revision the caller presented.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// POST /api/v1/problem-reports — any authenticated operator; the BFF /// checks for a live session before ever calling this and passes the /// caller's identity along as query params (this tier has no session @@ -258,6 +278,25 @@ pub struct StatusPatch { const VALID_STATUSES: [&str; 3] = ["open", "triaged", "closed"]; +#[utoipa::path( + patch, + path = "/api/v1/problem-reports/{id}", + summary = "Move a problem report through open/triaged/closed.", + params( + ("id" = inline(String), Path, description = "Problem-report id."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `StatusPatch`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 204, description = "No content; the status was stored."), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 409, description = "The record changed since the revision the caller presented.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// PATCH /api/v1/problem-reports/{id} — admin-gated at the BFF. The only /// mutation an existing report ever gets is its status; captured content /// is never edited after submission, so this is a single-field diff --git a/arcane/home/honeypot-dashboard/backend-service/src/replay.rs b/arcane/home/honeypot-dashboard/backend-service/src/replay.rs index 38e122e9..ad0c1b94 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/replay.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/replay.rs @@ -169,6 +169,20 @@ fn attachment(shasum: &str, extension: &str) -> String { format!("attachment; filename=\"{shasum}.{extension}\"") } +#[utoipa::path( + get, + path = "/api/v1/recordings/{shasum}/cast", + summary = "One TTY recording as asciicast.", + params( + ("shasum" = inline(String), Path, description = "Recording shasum to replay."), + ), + responses( + (status = 200, description = "The asciicast body.", body = inline(serde_json::Value), content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn replay_cast( State(state): State, Path(shasum): Path, @@ -184,6 +198,21 @@ pub async fn replay_cast( )) } +#[utoipa::path( + get, + path = "/api/v1/recordings/{shasum}/raw", + summary = "One TTY recording as raw bytes.", + params( + ("shasum" = inline(String), Path, description = "Recording shasum to replay."), + ), + responses( + (status = 200, description = "The raw log bytes.", body = inline(serde_json::Value), content_type = "application/octet-stream"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 413, description = "The stored artifact is larger than this endpoint will serve.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn replay_raw( State(state): State, Path(shasum): Path, @@ -198,6 +227,20 @@ pub async fn replay_raw( )) } +#[utoipa::path( + get, + path = "/api/v1/recordings/{shasum}", + summary = "One TTY recording.", + params( + ("shasum" = inline(String), Path, description = "Recording shasum to replay."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn replay( State(state): State, Path(shasum): Path, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/reporter_stats.rs b/arcane/home/honeypot-dashboard/backend-service/src/reporter_stats.rs index f004f1fa..016f8858 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/reporter_stats.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/reporter_stats.rs @@ -17,6 +17,17 @@ use crate::AppState; const INDEX: &str = "reporter-metrics-v1"; +#[utoipa::path( + get, + path = "/api/v1/reporter-stats", + summary = "What the reporting loops produced and when.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 500, description = "The handler failed in a way it does not model as a 4xx.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn stats(State(state): State) -> (StatusCode, Json) { let result = match state.es.search_index(&[INDEX], json!({"size": 1, "sort": [{"updated_at": {"order": "desc", "unmapped_type": "date"}}]})).await { Ok(result) => result, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/reports.rs b/arcane/home/honeypot-dashboard/backend-service/src/reports.rs index 8d9b7ad6..07799626 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/reports.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/reports.rs @@ -12,6 +12,20 @@ use serde_json::json; use crate::AppState; +#[utoipa::path( + get, + path = "/api/v1/reports/{id}/pdf", + summary = "One generated report, rendered to PDF.", + params( + ("id" = inline(String), Path, description = "Generated report id."), + ), + responses( + (status = 200, description = "The rendered PDF.", body = inline(serde_json::Value), content_type = "application/pdf"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn pdf( State(state): State, Path(id): Path, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/reports_api.rs b/arcane/home/honeypot-dashboard/backend-service/src/reports_api.rs index fc3a6aba..fe4606d9 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/reports_api.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/reports_api.rs @@ -12,6 +12,7 @@ //! boundary is the BFF's service token, same posture as every other write //! path here (config.rs/preferences.rs). +use crate::contract; use axum::{ extract::{Path, State}, http::StatusCode, @@ -34,6 +35,15 @@ fn bad_gateway(error: anyhow::Error) -> (StatusCode, String) { (StatusCode::BAD_GATEWAY, error.to_string()) } +#[utoipa::path( + get, + path = "/api/v1/reports/templates", + summary = "The report template and element catalog.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn templates() -> Json { let templates: Vec = report_template_catalog() .into_iter() @@ -54,6 +64,16 @@ pub async fn templates() -> Json { Json(json!({"templates": templates, "elements": elements})) } +#[utoipa::path( + get, + path = "/api/v1/reports/definitions", + summary = "Saved report definitions.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn list_definitions( State(state): State, ) -> Result, (StatusCode, String)> { @@ -63,6 +83,20 @@ pub async fn list_definitions( Ok(Json(json!({"definitions": definitions}))) } +#[utoipa::path( + get, + path = "/api/v1/reports/definitions/{id}", + summary = "One saved report definition.", + params( + ("id" = inline(String), Path, description = "Saved report-definition id."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn get_definition( State(state): State, Path(id): Path, @@ -77,6 +111,21 @@ pub async fn get_definition( Ok(Json(json!({"definition": definition}))) } +#[utoipa::path( + post, + path = "/api/v1/reports/definitions", + summary = "Save a new report definition.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `ReportDefinition`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 201, description = "The stored definition."), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 409, description = "The record changed since the revision the caller presented.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn create_definition( State(state): State, Json(mut def): Json, @@ -91,6 +140,25 @@ pub async fn create_definition( Ok((StatusCode::CREATED, Json(json!({"definition": created})))) } +#[utoipa::path( + put, + path = "/api/v1/reports/definitions/{id}", + summary = "Replace one saved report definition.", + params( + ("id" = inline(String), Path, description = "Saved report-definition id."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `ReportDefinition`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 409, description = "The record changed since the revision the caller presented.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn replace_definition( State(state): State, Path(id): Path, @@ -106,6 +174,21 @@ pub async fn replace_definition( Ok(Json(json!({"definition": updated}))) } +#[utoipa::path( + delete, + path = "/api/v1/reports/definitions/{id}", + summary = "Delete one saved report definition.", + params( + ("id" = inline(String), Path, description = "Saved report-definition id."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn delete_definition( State(state): State, Path(id): Path, @@ -116,6 +199,20 @@ pub async fn delete_definition( Ok(Json(json!({"deleted": id}))) } +#[utoipa::path( + delete, + path = "/api/v1/reports/generated/{id}", + summary = "Delete one generated report.", + params( + ("id" = inline(String), Path, description = "Generated report id."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn delete_generated( State(state): State, Path(id): Path, @@ -146,6 +243,25 @@ fn default_origin() -> String { "manual".into() } +#[utoipa::path( + post, + path = "/api/v1/reports/definitions/{id}/generate", + summary = "Run a saved definition now and store the result.", + params( + ("id" = inline(String), Path, description = "Saved report-definition id."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `GenerateBody`. The shape is left open here on purpose -- see the module doc.", extensions(("x-optional-body" = json!(true)))), + responses( + (status = 201, description = "The queued run."), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 409, description = "The record changed since the revision the caller presented.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn generate( State(state): State, Path(id): Path, @@ -167,6 +283,23 @@ pub async fn generate( Ok((StatusCode::CREATED, Json(json!({"generated": meta})))) } +#[utoipa::path( + post, + path = "/api/v1/payloads/{hash}/report", + summary = "One-click payload PDF into the generated store.", + params( + ("hash" = inline(contract::PayloadHash), Path, description = "Payload id: 32 or 64 lower-case hex characters."), + ), + responses( + (status = 201, description = "The queued report run."), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + (status = 501, description = "The saved definition's template is not implemented by the renderer yet.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// POST /api/v1/payloads/{hash}/report — #474's one-click "Generate PDF" /// trigger on the payload detail page, ported from reports_api.go's /// generatePayloadReport: unlike the designer flow it never requires a diff --git a/arcane/home/honeypot-dashboard/backend-service/src/sandbox_submit.rs b/arcane/home/honeypot-dashboard/backend-service/src/sandbox_submit.rs index db3588ea..b3c646d9 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/sandbox_submit.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/sandbox_submit.rs @@ -72,6 +72,21 @@ pub struct SubmitBody { hash: String, } +#[utoipa::path( + post, + path = "/api/v1/sandbox/submit", + summary = "Queue a sandbox detonation.", + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `SubmitBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn submit(State(_state): State, Json(body): Json) -> Result, (StatusCode, Json)> { fn err(status: StatusCode, message: impl Into) -> (StatusCode, Json) { (status, Json(json!({"error": message.into()}))) @@ -97,6 +112,15 @@ pub async fn submit(State(_state): State, Json(body): Json Ok(Json(json!({"target": target, "queued": true}))) } +#[utoipa::path( + get, + path = "/api/v1/sandbox/golden-image-status", + summary = "Whether the sandbox golden image is built.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] /// goldenImageStatus (#86): win11-analysis.qcow2 staleness, written by a /// host-side timer into WINDOWS_SANDBOX_RESULTS_DIR — the same directory /// already mounted read-only for per-job results, no new mount needed. @@ -131,6 +155,16 @@ fn running_sha_from(name: &str) -> Option<&str> { (sha.len() == 64 && sha.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())).then_some(sha) } +#[utoipa::path( + get, + path = "/api/v1/sandbox/vnc", + summary = "The VNC port the sandbox advertises, if any.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/sandbox/vnc — read-only live-view status, ported from /// sandbox_vnc.go's serveSandboxVNC + windowsSandboxLiveJob. This tier /// never touches libvirt or the VNC stream itself — it only reports diff --git a/arcane/home/honeypot-dashboard/backend-service/src/search.rs b/arcane/home/honeypot-dashboard/backend-service/src/search.rs index c0b55d1b..dc661d69 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/search.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/search.rs @@ -83,6 +83,20 @@ const GROUPS: &[GroupSpec] = &[ GroupSpec { title: "Personas", agg: "personas", field: "honeypot.persona_id", url: |v| format!("/events?persona={}", crate::services_control::urlencode(v)) }, ]; +#[utoipa::path( + get, + path = "/api/v1/search", + summary = "Cross-surface search for the omnibox.", + params( + ("q" = inline(Option), Query, description = "What to search for."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn search( State(state): State, Query(query): Query, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/sensors.rs b/arcane/home/honeypot-dashboard/backend-service/src/sensors.rs index ba5ce72a..7c8b064c 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/sensors.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/sensors.rs @@ -4,6 +4,7 @@ //! classify.go deliberately collapses into one-line summaries). Same //! caps and 48h window as the Go loaders. +use crate::contract; use axum::{extract::State, http::StatusCode, Json}; use serde::Serialize; use serde_json::{json, Map, Value}; @@ -270,6 +271,16 @@ fn tanner_requests(hits: &[Value]) -> Vec { .collect() } +#[utoipa::path( + get, + path = "/api/v1/sensors", + summary = "Per-sensor counts, last-seen, and state.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn detail(State(state): State) -> Result, (StatusCode, String)> { let (mailoney, http, tanner) = tokio::try_join!( query_sensor_raw(&state, "mailoney", false), @@ -374,6 +385,16 @@ const CATALOG_WINDOW: &str = "now-14d"; const EVENT_LIMIT_DEFAULT: u64 = 200; const EVENT_LIMIT_MAX: u64 = 1000; +#[utoipa::path( + get, + path = "/api/v1/sensors/catalog", + summary = "The sensor catalog the setup pages read.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn catalog(State(state): State) -> Result, (StatusCode, String)> { let body = json!({ "size": 0, @@ -413,6 +434,21 @@ pub async fn catalog(State(state): State) -> Result), Query, description = "How many events to return; clamped by the handler."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn events( State(state): State, axum::extract::Path(sensor): axum::extract::Path, @@ -624,6 +660,20 @@ fn measures_for(sensor: &str) -> Vec<(&'static str, &'static str, &'static str)> const OVERVIEW_WINDOW: &str = "now-7d"; +#[utoipa::path( + get, + path = "/api/v1/sensors/{sensor}/overview", + summary = "Protocols, ports and fingerprints for one sensor.", + params( + ("sensor" = inline(contract::SensorName), Path, description = "Sensor name; the handler rejects an empty value or one over 128 characters."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn overview( State(state): State, axum::extract::Path(sensor): axum::extract::Path, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/services_control.rs b/arcane/home/honeypot-dashboard/backend-service/src/services_control.rs index 18a66c34..185b7e54 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/services_control.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/services_control.rs @@ -14,6 +14,7 @@ //! close`, bounded by max_body, which sidesteps needing a real //! Content-Length/chunked-transfer parser. +use crate::contract; use axum::{ extract::{Path, Query, State}, http::StatusCode, @@ -164,6 +165,16 @@ pub fn urlencode(value: &str) -> String { out } +#[utoipa::path( + get, + path = "/api/v1/services", + summary = "The compose services the operator can act on.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn list(State(_state): State) -> impl axum::response::IntoResponse { match load_services_status().await { Ok(services) => (StatusCode::OK, Json(json!({"available": true, "services": services}))), @@ -179,6 +190,21 @@ pub struct LogsQuery { lines: Option, } +#[utoipa::path( + get, + path = "/api/v1/services/{name}/logs", + summary = "Recent log lines for one service, via the services adapter.", + params( + ("name" = inline(String), Path, description = "compose service name."), + ("lines" = inline(Option), Query, description = "How many lines; the handler defaults to 200."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", content((String = "text/plain"), (inline(serde_json::Value) = "application/json"))), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn logs( State(_state): State, Path(name): Path, @@ -201,6 +227,23 @@ pub struct ActionQuery { actor_username: String, } +#[utoipa::path( + post, + path = "/api/v1/services/{name}/{action}", + summary = "Start, stop or restart one service.", + params( + ("name" = inline(String), Path, description = "compose service name."), + ("action" = inline(contract::ServiceAction), Path, description = "Lifecycle action the services adapter accepts."), + ("actor_subject" = inline(Option), Query, description = "OIDC subject recorded on the audit/history entry."), + ("actor_username" = inline(Option), Query, description = "Operator name recorded on the audit/history entry."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", content((String = "text/plain"), (inline(serde_json::Value) = "application/json"))), + (status = 503, description = "A dependency this route needs is not configured or not reachable.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn action( State(state): State, Path((name, action)): Path<(String, String)>, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/session.rs b/arcane/home/honeypot-dashboard/backend-service/src/session.rs index 563e231c..2fe7fde0 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/session.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/session.rs @@ -133,6 +133,21 @@ fn adb_recon_fingerprint(cmds: &[String]) -> bool { }) } +#[utoipa::path( + get, + path = "/api/v1/sessions/{id}", + summary = "One session: its events, commands and credentials.", + params( + ("id" = inline(String), Path, description = "Session id."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn detail( State(state): State, Path(id): Path, diff --git a/arcane/home/honeypot-dashboard/backend-service/src/stores.rs b/arcane/home/honeypot-dashboard/backend-service/src/stores.rs index 0066e55f..67fdde28 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/stores.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/stores.rs @@ -14,6 +14,7 @@ //! other endpoint in this crate; this comment is that decision on record //! so the census question doesn't reopen. +use crate::contract; use axum::{ extract::{Query, State}, http::StatusCode, @@ -164,6 +165,24 @@ async fn store_page_excluding( Ok(json!({"total": total, "rows": rows})) } +#[utoipa::path( + get, + path = "/api/v1/campaigns", + summary = "Campaign store.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ("q" = inline(Option), Query, description = "Free-text Lucene query string."), + ("ip" = inline(Option), Query, description = "Narrow to one source address."), + ("aggs" = inline(Option), Query, description = "`sources` adds the payload-inventory source buckets; anything else is ignored."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn campaigns( State(state): State, Query(q): Query, @@ -174,6 +193,24 @@ pub async fn campaigns( .map_err(bad_gateway) } +#[utoipa::path( + get, + path = "/api/v1/clusters", + summary = "Attacker-cluster store.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ("q" = inline(Option), Query, description = "Free-text Lucene query string."), + ("ip" = inline(Option), Query, description = "Narrow to one source address."), + ("aggs" = inline(Option), Query, description = "`sources` adds the payload-inventory source buckets; anything else is ignored."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn clusters( State(state): State, Query(q): Query, @@ -184,6 +221,24 @@ pub async fn clusters( .map_err(bad_gateway) } +#[utoipa::path( + get, + path = "/api/v1/attackers", + summary = "Attacker-entity store.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ("q" = inline(Option), Query, description = "Free-text Lucene query string."), + ("ip" = inline(Option), Query, description = "Narrow to one source address."), + ("aggs" = inline(Option), Query, description = "`sources` adds the payload-inventory source buckets; anything else is ignored."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn attackers( State(state): State, Query(q): Query, @@ -218,6 +273,24 @@ pub async fn attackers( /// attacked anything (#1714). const TUNNEL_IP: &str = "10.8.0.1"; +#[utoipa::path( + get, + path = "/api/v1/recordings", + summary = "TTY recording store.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ("q" = inline(Option), Query, description = "Free-text Lucene query string."), + ("ip" = inline(Option), Query, description = "Narrow to one source address."), + ("aggs" = inline(Option), Query, description = "`sources` adds the payload-inventory source buckets; anything else is ignored."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn recordings( State(state): State, Query(q): Query, @@ -282,6 +355,24 @@ pub async fn recordings( Ok(Json(json!({"total": total, "rows": rows}))) } +#[utoipa::path( + get, + path = "/api/v1/alerts", + summary = "Alert-state store.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ("q" = inline(Option), Query, description = "Free-text Lucene query string."), + ("ip" = inline(Option), Query, description = "Narrow to one source address."), + ("aggs" = inline(Option), Query, description = "`sources` adds the payload-inventory source buckets; anything else is ignored."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn alerts( State(state): State, Query(q): Query, @@ -292,6 +383,24 @@ pub async fn alerts( .map_err(bad_gateway) } +#[utoipa::path( + get, + path = "/api/v1/payloads", + summary = "Captured-payload store.", + params( + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ("q" = inline(Option), Query, description = "Free-text Lucene query string."), + ("ip" = inline(Option), Query, description = "Narrow to one source address."), + ("aggs" = inline(Option), Query, description = "`sources` adds the payload-inventory source buckets; anything else is ignored."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] pub async fn payloads( State(state): State, Query(q): Query, @@ -330,6 +439,22 @@ pub struct AckBody { pub ack: bool, } +#[utoipa::path( + post, + path = "/api/v1/alerts/{key}/ack", + summary = "Acknowledge one alert.", + params( + ("key" = inline(String), Path, description = "Alert-state document key (the hashified signature triple)."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `AckBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// POST /api/v1/alerts/{key}/ack — flip one alert's Acknowledged flag /// (dashboard-alert-state-v1 doc id == alert key), the ported /// alertManager.acknowledge. @@ -402,6 +527,26 @@ fn store_config(name: &str) -> Option { }) } +#[utoipa::path( + get, + path = "/api/v1/store/{name}", + summary = "One allowlisted store, through the generic passthrough.", + params( + ("name" = inline(contract::StoreName), Path, description = "Allowlisted generic store. Anything else is a 404 -- this route is not an arbitrary index read."), + ("offset" = inline(Option), Query, description = "Result window start."), + ("size" = inline(Option), Query, description = "Page size."), + ("q" = inline(Option), Query, description = "Free-text Lucene query string."), + ("ip" = inline(Option), Query, description = "Narrow to one source address."), + ("aggs" = inline(Option), Query, description = "`sources` adds the payload-inventory source buckets; anything else is ignored."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// Generic allowlisted store passthrough: /api/v1/store/{name}. Every /// remaining store-shaped page reads through here instead of growing its /// own handler; the allowlist keeps arbitrary index reads impossible. @@ -425,6 +570,22 @@ pub struct PurgeQuery { pub q: Option, } +#[utoipa::path( + delete, + path = "/api/v1/store/{name}", + summary = "Purge dead letters matching ?q= (dead-letters only).", + params( + ("name" = inline(contract::StoreName), Path, description = "Allowlisted generic store. Anything else is a 404 -- this route is not an arbitrary index read."), + ("q" = inline(Option), Query, description = "Lucene query string; absent or empty purges every retained dead letter."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 405, description = "The store exists but exposes no delete side (only dead-letters does).", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = String, content_type = "text/plain"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// DELETE /api/v1/store/{name} — allowlisted like `generic`'s GET side, /// but only dead-letters has a delete today (ported from elastic.go's /// purgeDeadLetters). Sharing the route with `generic` rather than diff --git a/arcane/home/honeypot-dashboard/backend-service/src/topology.rs b/arcane/home/honeypot-dashboard/backend-service/src/topology.rs index c30dd915..3325d0dd 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/topology.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/topology.rs @@ -693,6 +693,15 @@ pub struct ContainerRef { // --- Handler --------------------------------------------------------------- +#[utoipa::path( + get, + path = "/api/v1/topology", + summary = "Decoy topology graph.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/topology — static fleet shape; liveness joins live elsewhere. pub async fn topology() -> Json { let raw_index_of: HashMap<&str, &str> = SENSOR_RAW_INDEX.iter().copied().collect(); diff --git a/arcane/home/honeypot-dashboard/backend-service/src/vault_rag.rs b/arcane/home/honeypot-dashboard/backend-service/src/vault_rag.rs index 3e00bd56..3cfb8815 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/vault_rag.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/vault_rag.rs @@ -216,6 +216,19 @@ async fn generate(base: &str, model: &str, keep_alive: &str, context: &str, ques Ok(answer) } +#[utoipa::path( + get, + path = "/api/v1/vault-rag", + summary = "Answer a question from the Vault corpus through the local model.", + params( + ("q" = inline(Option), Query, description = "The question."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + ), + security(("serviceToken" = [])), +)] /// GET /api/v1/vault-rag?q=... — operator-invoked only (#2292), not linked /// from any nav yet. Retrieval failures and generation failures both report /// `available:false` with a reason rather than a broken page or a faked diff --git a/arcane/home/honeypot-dashboard/backend-service/src/webhook_delivery.rs b/arcane/home/honeypot-dashboard/backend-service/src/webhook_delivery.rs index 5fbfbece..9d55ac15 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/webhook_delivery.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/webhook_delivery.rs @@ -374,6 +374,15 @@ pub async fn summary(es: &Es) -> DeliveryHealth { } } +#[utoipa::path( + get, + path = "/api/v1/webhook-delivery", + summary = "Delivery outcomes for the configured alert webhook.", + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] /// `GET /api/v1/webhook-delivery` — the alert fan-out's own card, for /// Settings. The diagnostics page reads the same value as a field on /// `/api/v1/source-health` rather than making a second round trip. diff --git a/arcane/home/honeypot-dashboard/backend-service/src/workbench_api.rs b/arcane/home/honeypot-dashboard/backend-service/src/workbench_api.rs index 5327b0ad..de899493 100644 --- a/arcane/home/honeypot-dashboard/backend-service/src/workbench_api.rs +++ b/arcane/home/honeypot-dashboard/backend-service/src/workbench_api.rs @@ -19,6 +19,7 @@ //! token. An admin UI for another operator's runs gets added when it //! exists, with its own authorization. +use crate::contract; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::Json; @@ -53,6 +54,20 @@ pub struct AnalyzersQuery { hash: String, } +#[utoipa::path( + get, + path = "/api/v1/workbench/analyzers", + summary = "Analyzers available for one payload.", + params( + ("hash" = inline(Option), Query, description = "Payload id."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", content((String = "text/plain"), (inline(serde_json::Value) = "application/json"))), + (status = 404, description = "No such record, store, or route for the values given.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn analyzers( Query(query): Query, ) -> Result, (StatusCode, Json)> { @@ -84,6 +99,25 @@ pub struct CreateRunBody { analyzers: Vec, } +#[utoipa::path( + post, + path = "/api/v1/workbench/runs", + summary = "Start a Workbench run over one payload.", + params( + ("X-Actor-Username" = inline(String), Header, description = "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `CreateRunBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 401, description = "No valid service token (or, on the Workbench, no actor identity).", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn create_run( State(state): State, headers: HeaderMap, @@ -108,6 +142,22 @@ pub async fn create_run( } } +#[utoipa::path( + get, + path = "/api/v1/workbench/runs/{id}", + summary = "One Workbench run, with its children.", + params( + ("id" = inline(String), Path, description = "Workbench run id."), + ("X-Actor-Username" = inline(String), Header, description = "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 401, description = "No valid service token (or, on the Workbench, no actor identity).", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn get_run( State(state): State, headers: HeaderMap, @@ -132,6 +182,23 @@ pub struct ListRunsQuery { limit: usize, } +#[utoipa::path( + get, + path = "/api/v1/workbench/runs", + summary = "Payload Workbench runs owned by the calling operator.", + params( + ("hash" = inline(Option), Query, description = "Narrow to one payload id."), + ("limit" = inline(Option), Query, description = "How many runs to return."), + ("X-Actor-Username" = inline(String), Header, description = "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = String, content_type = "text/plain"), + (status = 401, description = "No valid service token (or, on the Workbench, no actor identity).", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn list_runs( State(state): State, headers: HeaderMap, @@ -144,6 +211,25 @@ pub async fn list_runs( Ok(Json(json!({"runs": runs}))) } +#[utoipa::path( + post, + path = "/api/v1/workbench/runs/{id}/children/{analyzer_id}/{action}", + summary = "Cancel or retry one child of a run.", + params( + ("id" = inline(String), Path, description = "Workbench run id."), + ("analyzer_id" = inline(String), Path, description = "Analyzer entry on this run; the orchestrator looks the child up by it."), + ("action" = inline(contract::ChildAction), Path, description = "Child lifecycle action."), + ("X-Actor-Username" = inline(String), Header, description = "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 401, description = "No valid service token (or, on the Workbench, no actor identity).", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn child_action( State(state): State, headers: HeaderMap, @@ -161,6 +247,20 @@ pub async fn child_action( } } +#[utoipa::path( + get, + path = "/api/v1/workbench/recipes", + summary = "Saved Workbench recipes owned by the calling operator.", + params( + ("X-Actor-Username" = inline(String), Header, description = "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401."), + ), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 401, description = "No valid service token (or, on the Workbench, no actor identity).", body = inline(serde_json::Value), content_type = "application/json"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn list_recipes( State(state): State, headers: HeaderMap, @@ -185,6 +285,26 @@ pub struct SaveRecipeBody { base_revision: i64, } +#[utoipa::path( + post, + path = "/api/v1/workbench/recipes", + summary = "Save a Workbench recipe.", + params( + ("X-Actor-Username" = inline(String), Header, description = "Operator identity the BFF forwards; workbench_api.rs's require_actor rejects a missing or blank value with a JSON 401."), + ), + request_body(content = inline(serde_json::Value), description = "Deserialized by the handler into `SaveRecipeBody`. The shape is left open here on purpose -- see the module doc."), + responses( + (status = 200, description = "Success.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 400, description = "Rejected: the request was understood but its input is not acceptable.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 401, description = "No valid service token (or, on the Workbench, no actor identity).", body = inline(serde_json::Value), content_type = "application/json"), + (status = 404, description = "No such record, store, or route for the values given.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 409, description = "The record changed since the revision the caller presented.", body = inline(serde_json::Value), content_type = "application/json"), + (status = 415, description = "The `Content-Type` is not `application/json`; the extractor refused the body before the handler ran.", body = String, content_type = "text/plain"), + (status = 422, description = "Well-formed but unprocessable. Two causes, both text/plain: the Json extractor refused the body before the handler ran, or the route's own domain check rejected the reference it was asked to resolve (the reports store answers this for an unresolvable scope or an unexpected storage failure).", body = String, content_type = "text/plain"), + (status = 502, description = "Elasticsearch (or a sibling it proxies) refused or failed the query.", body = inline(serde_json::Value), content_type = "application/json"), + ), + security(("serviceToken" = [])), +)] pub async fn save_recipe( State(state): State, headers: HeaderMap, From f852dfc9432541c5f41ad90a599f7e26db092845 Mon Sep 17 00:00:00 2001 From: Xore Date: Sun, 27 Sep 2026 14:00:59 +0200 Subject: [PATCH 5/6] fix(ci): close the artipacked advisory, and repoint the two gates the module move broke (#3325) The rebase onto #3400 brought main's fixes in beside #3325's move of the handler modules and the route table out of `src/main.rs` and into `src/lib.rs`. Three things were red as a result. None of them is a behaviour regression; all three are places where a gate reads a file, and the move changed which file. `zizmor` reported a blocking `artipacked` on this branch's own `weekly-schemathesis.yml`: an `actions/checkout` without `persist-credentials: false`. #3388 added that to every checkout in the tree and #3400 finished the job, but this workflow was added after that work and never met it. Fixed the way every other one is -- the `with:` block, and the version comment normalised from `# v7` to the `# v7.0.1` that the other 38 checkouts in the tree spell. No rule was added to the ADVISORY allowlist; the gate now reports only the allowlisted `dangerous-triggers` finding, which is what it reported on main. `scripts/tests/test_3316_image_boot_smoke.py` asserted that each path the boot smoke probes is registered by looking for `.route(""` in `src/main.rs`. Two moves broke that string, not one: the table moved to `src/lib.rs`, and then #3325's own commit replaced every `.route(path, method(handler))` with `.routes(utoipa_axum::routes!(handler))`, which takes the path off the handler's `#[utoipa::path]`. So there is no path literal in the table at all any more. It now reads the paths declared by the annotations, which is where they live, and keeps the literal form so a table written in plain axum is still checked rather than passing on an empty scan. The scan is guarded on `assertGreaterEqual(len(declared), 4)`, and both patterns require a leading `/`, because `openapi.rs`'s own `BYPASSING_ROUTES` holds the literal strings `".route("` and friends and a looser pattern reads that Rust string as a route registration. `scripts/tests/test_3315_image_revision.py` pinned the Rust normalizer to the shared revision corpus by asserting on `src/main.rs` too, and `normalize_revision`, `REVISION_UNKNOWN` and the test module that reads the corpus all moved to `src/lib.rs`. Repointed at the file they moved to. It reads one named file rather than scanning the tree: an assertion against all of `src/` dumps every module into the failure message, and if the next move relocates these the assertion should fail with the file it looked in. Both test repoints are verified to still fail for the right reason -- renaming the `/livez` annotation, dropping the `/readyz` annotation, and pointing the corpus `include_str!` at a PNG each fail with the intended message, so the gates are not weakened into passing. --- .github/workflows/weekly-schemathesis.yml | 4 +- scripts/tests/test_3315_image_revision.py | 25 ++++- scripts/tests/test_3316_image_boot_smoke.py | 109 +++++++++++++++++--- 3 files changed, 118 insertions(+), 20 deletions(-) diff --git a/.github/workflows/weekly-schemathesis.yml b/.github/workflows/weekly-schemathesis.yml index 7c0bdee6..debfa23b 100644 --- a/.github/workflows/weekly-schemathesis.yml +++ b/.github/workflows/weekly-schemathesis.yml @@ -119,7 +119,9 @@ jobs: # run block is code injection (audit: template-injection). MAX_EXAMPLES: ${{ inputs.max_examples }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install the pinned schemathesis run: | diff --git a/scripts/tests/test_3315_image_revision.py b/scripts/tests/test_3315_image_revision.py index 50a59d4b..b41fa57c 100644 --- a/scripts/tests/test_3315_image_revision.py +++ b/scripts/tests/test_3315_image_revision.py @@ -45,7 +45,21 @@ FRONTEND_ENV_EXAMPLE = ROOT / "arcane" / "home" / "honeypot-dashboard" / ".env.example" BACKEND_ENV_EXAMPLE = ROOT / "arcane" / "home" / "honeypot-dashboard-backend" / ".env.example" BUILD_RS = ROOT / "arcane" / "home" / "honeypot-dashboard" / "backend-service" / "build.rs" -MAIN_RS = ROOT / "arcane" / "home" / "honeypot-dashboard" / "backend-service" / "src" / "main.rs" +BACKEND_SRC = ROOT / "arcane" / "home" / "honeypot-dashboard" / "backend-service" / "src" +# #3325 moved every backend-service module and the route table out of +# `src/main.rs` and into `src/lib.rs`, so the crate root is `lib.rs` and +# `main.rs` keeps only what is genuinely a process: the environment, the +# #2183 boot gate, state construction, the listener. `normalize_revision`, +# `REVISION_UNKNOWN` and the test module that pins them to the shared corpus +# all moved with the rest, so that is the file this reads. +# +# One named file rather than a scan of the tree, deliberately: an assertion +# against the whole of `src/` dumps every module into the failure message +# when it trips, and a multi-thousand-line diff for "the include_str! moved" +# is the kind of report that gets skimmed rather than read. If the next move +# relocates these again, the assertion fails with the file it looked in -- +# which is the whole signal this test is for. +LIB_RS = BACKEND_SRC / "lib.rs" CORPUS_JSON = ROOT / "arcane" / "home" / "honeypot-dashboard" / "backend-service" / "src" / "revision-corpus.json" CONTAINERS_YML = ROOT / ".github" / "workflows" / "containers.yml" @@ -167,7 +181,7 @@ class NormalizerParity(unittest.TestCase): """The JS and Rust normalizers are one rule in two languages.""" def test_the_rust_side_is_held_to_the_same_table(self) -> None: - # backend-service/src/main.rs is a different CI lane with a different + # backend-service/src/lib.rs is a different CI lane with a different # toolchain, and its own test module is the only thing that pins # normalize_revision. What makes the two sides comparable is that both # read the same file: if the Rust test ever drifts to a literal list of @@ -177,13 +191,16 @@ def test_the_rust_side_is_held_to_the_same_table(self) -> None: # that is not one. self.assertIn( 'include_str!("revision-corpus.json")', - MAIN_RS.read_text(encoding="utf-8"), + LIB_RS.read_text(encoding="utf-8"), "the Rust normalizer is no longer pinned to the shared corpus", ) # A stubbed-out corpus would pass every assertion above it. self.assertGreaterEqual(len(CORPUS), 10, "the shared corpus has been hollowed out") self.assertEqual(UNKNOWN, "unknown", "both sides spell the sentinel the same way") - self.assertIn(f'pub const REVISION_UNKNOWN: &str = "{UNKNOWN}"', MAIN_RS.read_text(encoding="utf-8")) + self.assertIn( + f'pub const REVISION_UNKNOWN: &str = "{UNKNOWN}"', + LIB_RS.read_text(encoding="utf-8"), + ) # Internal coherence: every expectation is either the sentinel or a # value the rule accepts unchanged. Anything else is a case one side # could not produce and the table would be describing a third diff --git a/scripts/tests/test_3316_image_boot_smoke.py b/scripts/tests/test_3316_image_boot_smoke.py index 31b35cc3..38f90c87 100644 --- a/scripts/tests/test_3316_image_boot_smoke.py +++ b/scripts/tests/test_3316_image_boot_smoke.py @@ -40,12 +40,81 @@ DASHBOARD_DOCKERFILE = ( ROOT / "arcane" / "home" / "honeypot-dashboard" / "frontend-next" / "Dockerfile" ) -BACKEND_MAIN = ( - ROOT / "arcane" / "home" / "honeypot-dashboard" / "backend-service" / "src" / "main.rs" -) +BACKEND_SRC = ROOT / "arcane" / "home" / "honeypot-dashboard" / "backend-service" / "src" +BACKEND_MAIN = BACKEND_SRC / "main.rs" IMAGE_ID = "sha256:" + "ab" * 32 + +# ------------------------------------------------------- the route table -- + +def declared_backend_routes() -> set[str]: + r"""Every path backend-service's router registers, as a set of literals. + + Where this reads from moved twice, and both moves are invisible in a + diff, so they are recorded here rather than left to be rediscovered: + + - #3325's first commit put the route table in `src/main.rs` as + `Router::new().route("/livez", get(livez))` -- the path spelled out + beside the handler. + - #3325's second commit moved every module and the table itself into + `src/lib.rs`, because the OpenAPI document is generated from the same + `Router` the process serves and a second binary cannot see a first + binary's modules. `main.rs` kept only what is genuinely a process. + - #3325's third commit replaced each `.route(path, method(handler))` + with `.routes(utoipa_axum::routes!(handler))`, which takes the path + and the method off the handler's `#[utoipa::path]` annotation. So + after that commit there is no `path` literal in the table at all -- + `src/lib.rs` is an index of handlers, and the paths live next to the + handlers, in their own modules. + + So the answer is the set of `path = "..."` values declared by a + `#[utoipa::path(` attribute anywhere under `src/`, plus any literal + `.route("..."` registration still present. The second form is not + expected to match anything today: `contract_covers_every_router_route` + in `src/openapi.rs` fails if `.route(` appears in `src/lib.rs` at all, + because `OpenApiRouter` would inherit it as a pass-through that serves + a route with no OpenAPI operation. It is kept so that a future table + written in plain axum is still checked rather than silently passing on + an empty scan. + + Both patterns require the captured value to start with `/`, and that is + not decoration. `openapi.rs`'s own test module holds + `const BYPASSING_ROUTES: [&str; 3] = [".route(", ".route_service(", + ".nest_service("];` -- the very strings `contract_covers_every_router_route` + greps for -- so a looser `\.route\(\s*\"([^\"]+)\"` reads that Rust string + literal as a route registration and invents entries. Every real path in + this crate begins with `/`, so requiring it costs nothing and keeps the + crate from being evidence about itself. + + That Rust test is also what makes the annotation half sufficient on its + own: it fails if a `#[utoipa::path]` is not routed, so a declared path + here is a routed path there. + """ + paths: set[str] = set() + for source in sorted(BACKEND_SRC.rglob("*.rs")): + text = source.read_text(encoding="utf-8") + for match in re.finditer(r"\.route\(\s*\"(/[^\"]+)\"", text): + paths.add(match.group(1)) + # Each attribute's own text, from just inside `#[utoipa::path(` to + # its matching close paren, so a `path = "..."` belonging to some + # other attribute cannot be mistaken for this one's. + for start in (m.start() for m in re.finditer(r"#\[utoipa::path\(", text)): + open_at = start + len("#[utoipa::path(") + depth = 1 + end = open_at + while depth > 0 and end < len(text): + if text[end] == "(": + depth += 1 + elif text[end] == ")": + depth -= 1 + end += 1 + declared = re.search(r"path\s*=\s*\"(/[^\"]+)\"", text[open_at:end]) + if declared: + paths.add(declared.group(1)) + return paths + + # --------------------------------------------------------------- the stubs -- # One bash stub for both binaries, dispatched on argv[0] by the symlink the @@ -757,7 +826,7 @@ def test_backend_service_smokes_routes_the_binary_actually_registers(self) -> No """/healthz and /readyz are the two names #3317's liveness/readiness split left behind; if a rename ever removes one, this fails rather than letting a 404 read as a broken assertion.""" - main = BACKEND_MAIN.read_text(encoding="utf-8") + declared = declared_backend_routes() step = self.step("Boot-smoke backend-service (#3316)") # Anchored on the flag, past the quoted label, to the bare path -- # and required to find something, because a pattern that stops @@ -767,29 +836,39 @@ def test_backend_service_smokes_routes_the_binary_actually_registers(self) -> No self.assertGreaterEqual( len(paths), 2, f"no assertion paths parsed out of the step:\n{step}" ) + # The scan is the input to every comparison below, so an empty one + # would report each route as missing for a reason that has nothing to + # do with the route. Caught here instead, where the cause is legible: + # see declared_backend_routes() for where the paths live and why. + self.assertGreaterEqual( + len(declared), + 4, + "no route paths parsed out of backend-service/src -- the table moved " + "and this scan no longer knows where to look", + ) for path in paths: with self.subTest(path=path): - # assertTrue, not assertIn: a failure here would otherwise - # dump all 800 lines of main.rs into the job log instead of - # naming the one route that went missing. - self.assertTrue( - f'.route("{path}"' in main, - f"backend-service/src/main.rs no longer registers {path}", + self.assertIn( + path, + declared, + f"backend-service no longer registers {path} " + f"(declared: {sorted(declared)})", ) def test_the_backend_healthcheck_path_is_a_registered_route_too(self) -> None: """The healthcheck is the gate the smoke waits on, and nothing else checks that its path still exists -- a rename would leave the container permanently unhealthy with a green build.""" - main = BACKEND_MAIN.read_text(encoding="utf-8") + declared = declared_backend_routes() healthcheck = re.search(r"--start-period=\d+s \\\n\s+CMD (.*)", self.backend) self.assertIsNotNone(healthcheck) path = re.search(r"/(\w+)\"", healthcheck.group(1)) self.assertIsNotNone(path, healthcheck.group(1)) - self.assertTrue( - f'.route("/{path.group(1)}"' in main, - f"the backend HEALTHCHECK curls /{path.group(1)}, which main.rs " - f"no longer registers", + self.assertIn( + f"/{path.group(1)}", + declared, + f"the backend HEALTHCHECK curls /{path.group(1)}, which " + f"backend-service no longer registers (declared: {sorted(declared)})", ) def test_dashboard_next_smokes_the_redirect_the_bff_actually_issues(self) -> None: From c1b2e45c1cb8c02a37a139d1eb51be441dca8c79 Mon Sep 17 00:00:00 2001 From: Xore Date: Sun, 27 Sep 2026 14:00:59 +0200 Subject: [PATCH 6/6] docs(CI-CD): point the #3325 section at where the contract actually lives (#3325) The rebase's own conflict resolution made these three claims false, and the file is one this branch already owns, so they are corrected here rather than left for the next reader to trip over. "the source of truth is the operation table in src/openapi.rs" was true when the table was hand-written there and stopped being true when it was generated from the `#[utoipa::path]` annotations and the route table in src/lib.rs. "contract_covers_every_router_route reads src/main.rs" named a file that no longer holds a route table. "Fix those by editing the row in operations()" named a function that no longer exists -- the edit point is the annotation on the handler, or the shape it names in src/contract.rs. The counts were stale in the same way: the section claimed 129 registered /api paths and 138 operations, and the committed document has 132 paths and 141 operations -- 128 /api paths and 137 /api operations behind the token, plus the four public probes. Now stated as counted, with the public set named. No behaviour and no generated contract changes: `openapi.json` is byte-identical before and after, which `the_document_is_byte_stable` and `checked_in_contract_is_current` both assert. --- docs/CI-CD.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/CI-CD.md b/docs/CI-CD.md index 988baeba..66c06654 100644 --- a/docs/CI-CD.md +++ b/docs/CI-CD.md @@ -329,21 +329,30 @@ in the `scripts-and-compose` matrix's `scripts/tests suite` row. ## The `/api` contract and its weekly fuzz job (#3325) `backend-service` publishes an OpenAPI 3.1 contract at -`arcane/home/honeypot-dashboard/backend-service/openapi.json`, covering all -129 registered `/api` paths (138 operations). It is generated, not -hand-edited: the source of truth is the operation table in -`arcane/home/honeypot-dashboard/backend-service/src/openapi.rs`, rendered by +`arcane/home/honeypot-dashboard/backend-service/openapi.json`: 132 paths, +141 operations — 128 `/api` paths and 137 `/api` operations behind the +token, plus the four public probes `/healthz`, `/livez`, `/readyz` and +`/metrics`. It is generated, not hand-edited: the source of truth is the +`#[utoipa::path]` annotation on each handler plus the route table in +`arcane/home/honeypot-dashboard/backend-service/src/lib.rs`, rendered by +`src/openapi.rs` (whose `render()` reconciles the derived document with +the committed one — the six transform steps are documented in that +module) and emitted by ```sh cd arcane/home/honeypot-dashboard/backend-service cargo run --bin openapi > openapi.json ``` +A shared parameter or request-body shape goes in +`arcane/home/honeypot-dashboard/backend-service/src/contract.rs` behind +`contract_schema!`; the annotations reference it rather than restating it. + **Three gates keep it honest, and they fail in different directions on purpose:** - **`cargo test`** runs two drift tests in `src/openapi.rs`. - `contract_covers_every_router_route` reads `src/main.rs` and fails if the + `contract_covers_every_router_route` reads `src/lib.rs` and fails if the router and the contract disagree about which `(path, method)` pairs exist — a route added without a contract row is the direction that rots, since it silently drops a fuzz target. `checked_in_contract_is_current` fails @@ -380,8 +389,9 @@ purpose:** `422` from axum's `Json` rejection on all 25 body routes, `400` from its `Query` rejection, four `services` routes answering JSON errors declared as `text/plain`, and a `text/plain` export declared as JSON. - Fix those by editing the row in `operations()` and regenerating — never - by suppressing the output. + Fix those by editing the handler's `#[utoipa::path]` annotation (or the + shape it names in `src/contract.rs`) and regenerating — never by + suppressing the output. `/api/v1/live` is excluded from the fuzz passes and skipped by the auth script, and the contract marks it `x-endless-stream: true`. Its body never