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..debfa23b --- /dev/null +++ b/.github/workflows/weekly-schemathesis.yml @@ -0,0 +1,292 @@ +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 + # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - 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 "${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 "${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@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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/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 new file mode 100644 index 00000000..9224dfcc --- /dev/null +++ b/arcane/home/honeypot-dashboard/backend-service/openapi.json @@ -0,0 +1,10704 @@ +{ + "components": { + "securitySchemes": { + "serviceToken": { + "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" + } + } + }, + "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" + ] + } + }, + "/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", + "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" + ] + } + }, + "/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": [ + { + "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": "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" + }, + { + "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/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/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/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 new file mode 100644 index 00000000..eb542042 --- /dev/null +++ b/arcane/home/honeypot-dashboard/backend-service/src/lib.rs @@ -0,0 +1,989 @@ +//! The library half of the crate: the handler modules, the shared state, +//! the /api route table, and the machine-readable /api contract (#3325). +//! +//! # Why the service is a library +//! +//! 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}, + + 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; +pub mod contract; + +#[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 } +} + +#[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 +/// 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 +} + +#[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 +/// 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 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() -> 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. + .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. + .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. + .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. + .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. + .routes(utoipa_axum::routes!(charts::tcp_stack_clusters)) + // #1736/#1739: two surfaces for data that currently has no view at all. + .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. + .routes(utoipa_axum::routes!(charts::decoy_client_fingerprints)) + // #1729: the rest of the JA4+ family Zeek produces. + .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. + .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. + .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. + .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. + .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. + .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. + .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() -> 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. + .routes(utoipa_axum::routes!(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: 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. + .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()); + // 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)] +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/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/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/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 new file mode 100644 index 00000000..be91bb6b --- /dev/null +++ b/arcane/home/honeypot-dashboard/backend-service/src/openapi.rs @@ -0,0 +1,942 @@ +//! The machine-readable contract for this service's HTTP surface (#3325). +//! +//! # Where the document comes from +//! +//! 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. +//! +//! 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. +//! +//! 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. +//! +//! # 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 +//! 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 that job +//! reports service findings like "API accepted schema-violating request" +//! and treats them as a standing record rather than as a verdict. +//! +//! # Statuses that no annotation has to remember +//! +//! Three come from axum's extractors, before any handler runs, and the +//! 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 `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. + +use serde_json::{json, Value}; + +use crate::{api_router, public_router}; + +pub use crate::contract::STORE_NAMES; + +/// 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 +} + +/// 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()) +} + +/// 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)); + } + } + } + } + } + + // (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)); + } + } + + // (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()})); + } + } + } +} + +const INFO_SUMMARY: &str = "The dashboard's Rust service tier: Elasticsearch-backed \ + read APIs over the honeypot event corpus."; + +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 str_schema() -> Value { + json!({"type": "string"}) +} + +/// 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 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 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 + // 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.", + } +} + +/// A stable, greppable operation id: the method, then the path with `/` +/// 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 { + '{' | '}' | '.' | '-' | '/' => '_', + other => other, + }) + .collect(); + format!("{}_{}", 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 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] + 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), + ); + } + + /// 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: + /// + /// 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 lib_rs = + std::fs::read_to_string(crate_dir().join("src/lib.rs")).expect("src/lib.rs is readable"); + + // (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!( + 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, + ); + + // (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() + .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 unrouted: Vec<_> = annotated.difference(&published).collect(); + assert!( + 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`.", + ); + } + + /// 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!(document["info"]["summary"].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" + ); + // 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() + .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, + !matches!( + path.as_str(), + "/healthz" | "/livez" | "/readyz" | "/metrics" + ), + "{method} {path}: /healthz, /livez, /readyz and /metrics are the only \ + 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. + 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)" + ); + } + 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. 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) + .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 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. + /// 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)" + ); + } + + /// 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; + 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 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. + /// + /// 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)); + } + offset += line.len(); + } + } + found + } + + /// 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, + _ => {} + } + 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 }); + } + } + } + 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 + /// 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/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, diff --git a/docs/CI-CD.md b/docs/CI-CD.md index 942f4a7f..66c06654 100644 --- a/docs/CI-CD.md +++ b/docs/CI-CD.md @@ -326,6 +326,77 @@ 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`: 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/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 + 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 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 +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()) 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: