Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ SPLUNK_TOKEN=
# $XDG_STATE_HOME/vct-splunk/audit.log, else ~/.local/state/vct-splunk/audit.log).
# VCT_SPLUNK_AUDIT=/path/to/audit.log

# Required for ANY real (non-dry-run) write, on every backend. Never a CLI
# flag -- a saved command line must not be able to enable one. --dry-run sends
# nothing and needs no opt-in either way.
# SPLUNK_ENABLE_WRITES=true

# Initial password for `splunk user create` — a secret, so it is read from the
# environment (or an interactive prompt), never accepted as a CLI flag.
# SPLUNK_USER_PASSWORD=
Expand All @@ -57,10 +62,10 @@ SPLUNK_TOKEN=
# VCT_SPLUNK_CONFIG=

# --- Splunk Cloud (ACS) ------------------------------------------------------
# Read-only this release. The backend is deduced from SPLUNK_URL: on a
# *.splunkcloud.com host, supported reads route via the ACS API automatically
# (there is no flag or variable to pick a backend). `splunk inspect` reports
# what the deduced backend supports.
# The backend is deduced from SPLUNK_URL: on a *.splunkcloud.com host,
# supported reads route via the ACS API automatically (there is no flag or
# variable to pick a backend). `splunk inspect` reports what the deduced
# backend supports.

# ACS authentication token (Bearer). The stack name is derived from SPLUNK_URL;
# set SPLUNK_ACS_STACK only to override it.
Expand All @@ -71,6 +76,17 @@ SPLUNK_TOKEN=
# FedRAMP stacks use https://admin.splunkcloudgc.com.
# SPLUNK_ACS_BASE_URL=

# Cloud writes need SPLUNK_ENABLE_WRITES=true (above) AND this, and only unlock
# create/update/delete for index, role, and hec-token -- enable/disable and
# every other resource stay refused regardless. Never a CLI flag. Checked even
# for a --dry-run preview, since it also proves the object is one of the three
# ACS-writable resources.
# SPLUNK_CLOUD_WRITE=true

# Optional: a separate ACS token scoped to writes only. Falls back to
# SPLUNK_ACS_TOKEN when unset; reads always use SPLUNK_ACS_TOKEN, never this one.
# SPLUNK_ACS_WRITE_TOKEN=

# Hide the Cloud stack name at untrusted output boundaries (e.g. CI logs) in
# the target shown by prompts, JSON metadata, and error text. The audit log is
# unaffected -- it always records the real host.
Expand All @@ -82,7 +98,8 @@ SPLUNK_TOKEN=
# SPLUNK_INTEGRATION_TEST=true
# SPLUNK_WRITE_TEST=true

# Enables the read-only Cloud ACS canary.
# Enables the Cloud ACS integration suites (read and write). Cloud write tests
# also require SPLUNK_CLOUD_WRITE=true, same as the CLI itself.
# SPLUNK_ACS_LIVE_TEST=true

# Server-side staging directory used only by disposable Enterprise write tests.
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/detect-cloud-stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ if [ -z "${SPLUNK_URL:-}" ] || [ -z "${SPLUNK_ACS_TOKEN:-}" ]; then
echo "full=false" >>"$GITHUB_OUTPUT"
note "No Splunk Cloud stack configured, so there is nothing to certify."
note "The Cloud read and write contracts still run on every pull request, in"
note "tests/unit/test_acs_loopback.py and tests/unit/test_cloud_write_refusal.py."
note "tests/unit/test_acs_loopback.py and tests/unit/test_write_gating.py."
exit 0
fi

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ jobs:
env:
SPLUNK_INTEGRATION_TEST: "true"
SPLUNK_WRITE_TEST: "true"
# The test suite itself also force-enables this for every pytest session
# (tests/conftest.py); set explicitly here too so the job documents its
# own intent without a reader having to know that.
SPLUNK_ENABLE_WRITES: "true"
SPLUNK_URL: https://localhost:8089
SPLUNK_USERNAME: admin
SPLUNK_PASSWORD: Ch4ng3d-CI-Pass!
Expand Down
159 changes: 159 additions & 0 deletions .github/workflows/cloud-write.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Certify the Cloud write path end-to-end against a real, non-production
# Splunk Cloud stack: a `read` job first (same secrets and steps as the
# scheduled Splunk Cloud Read Canary), then a destructive `write` job gated
# behind a protected `splunk-cloud-write` GitHub Environment with required
# reviewers. The approval pause sits in front of the destructive half only --
# a credential or connectivity problem surfaces in `read` before anyone is
# asked to approve anything. One dispatch is the full read-then-write
# certification.
#
# Never runs on pull_request or a schedule -- workflow_dispatch only, and even
# then the confirm job fails closed unless `confirm` is typed exactly `WRITE`
# and HEAD's commit subject starts with `tests: splunk cloud write`. The Cloud
# write contract itself (opt-in gating, the ACS-writable allowlist) is proved
# credential-free on every pull request in tests/unit/test_write_gating.py;
# what only a live stack can show is that a real one answers the way that
# contract expects.
name: Splunk Cloud Write Canary

on:
workflow_dispatch:
inputs:
confirm:
description: Type WRITE to run destructive ACS tests on the configured stack
required: true
type: string

permissions:
contents: read

concurrency:
group: splunk-cloud-write
cancel-in-progress: false

jobs:
confirm:
name: Confirm WRITE
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Fail closed unless confirm is WRITE
env:
CONFIRM: ${{ github.event.inputs.confirm }}
run: |
if [ "$CONFIRM" != "WRITE" ]; then
echo "confirm must be exactly WRITE"
exit 1
fi

read:
name: Cloud / ACS reads (pre-write readiness)
needs: confirm
runs-on: ubuntu-latest
timeout-minutes: 10
env:
SPLUNK_ACS_LIVE_TEST: "true"
SPLUNK_URL: ${{ secrets.SPLUNK_URL }}
SPLUNK_ACS_TOKEN: ${{ secrets.SPLUNK_ACS_TOKEN }}
SPLUNK_ACS_STACK: ${{ secrets.SPLUNK_ACS_STACK }}
SPLUNK_ACS_BASE_URL: ${{ secrets.SPLUNK_ACS_BASE_URL }}
SPLUNK_TOKEN: ${{ secrets.SPLUNK_TOKEN }}
VCT_SPLUNK_REDACT_TARGET: "1"
VCT_SPLUNK_AUDIT: ${{ runner.temp }}/vct-splunk-audit.log
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: Check for a configured Cloud stack
id: stack
run: bash .github/scripts/detect-cloud-stack.sh

- name: Require read secrets when dispatched
if: steps.stack.outputs.ready != 'true'
run: |
echo "SPLUNK_URL and SPLUNK_ACS_TOKEN are required to certify Cloud reads"
exit 1

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
cache: pip

- name: Install project
run: |
python -m venv .venv
.venv/bin/python -m pip install --require-hashes -r requirements-ci.txt
.venv/bin/python -m pip install -e . --no-deps

- name: Cloud reads (every catalogued read command)
run: >-
bash .github/scripts/run-cloud-suite.sh cloud-read
tests/integration/cloud/read/test_catalog.py "integration and cloud and read"

- name: Cloud ACS operations (below the CLI)
run: >-
bash .github/scripts/run-cloud-suite.sh cloud-acs
tests/integration/cloud/read/test_acs_operations.py "integration and cloud and read"

write:
name: Cloud / ACS writes
needs: read
environment: splunk-cloud-write
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: Require the write-canary commit prefix
run: |
subject=$(git log -1 --format=%s)
case "$subject" in
"tests: splunk cloud write"*) ;;
*)
echo "HEAD subject must start with: tests: splunk cloud write"
exit 1
;;
esac

- name: Check for a configured write-capable Cloud stack
id: stack
env:
SPLUNK_URL: ${{ secrets.SPLUNK_URL }}
SPLUNK_ACS_TOKEN: ${{ secrets.SPLUNK_ACS_WRITE_TOKEN }}
run: bash .github/scripts/detect-cloud-stack.sh

- name: Require write secrets when dispatched
if: steps.stack.outputs.ready != 'true'
run: |
echo "SPLUNK_URL and SPLUNK_ACS_WRITE_TOKEN are required for a WRITE run"
exit 1

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
cache: pip

- name: Install project
run: |
python -m venv .venv
.venv/bin/python -m pip install --require-hashes -r requirements-ci.txt
.venv/bin/python -m pip install -e . --no-deps

- name: Cloud writes (index, role, hec-token) with undo
env:
SPLUNK_ACS_LIVE_TEST: "true"
SPLUNK_ENABLE_WRITES: "true"
SPLUNK_CLOUD_WRITE: "true"
SPLUNK_URL: ${{ secrets.SPLUNK_URL }}
SPLUNK_ACS_WRITE_TOKEN: ${{ secrets.SPLUNK_ACS_WRITE_TOKEN }}
SPLUNK_ACS_TOKEN: ${{ secrets.SPLUNK_ACS_WRITE_TOKEN }}
SPLUNK_ACS_STACK: ${{ secrets.SPLUNK_ACS_STACK }}
SPLUNK_ACS_BASE_URL: ${{ secrets.SPLUNK_ACS_BASE_URL }}
VCT_SPLUNK_REDACT_TARGET: "1"
VCT_SPLUNK_AUDIT: ${{ runner.temp }}/vct-splunk-audit.log
run: >-
bash .github/scripts/run-cloud-suite.sh cloud-write
tests/integration/cloud/write "integration and cloud and write"
22 changes: 14 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,13 @@ Two cross-cutting ideas to know about:
require an explicit app and never silently default to `search`.
- **Transparent backend.** When `SPLUNK_URL` points at `*.splunkcloud.com`, a
few reads (`index list`, `role list`, `hec-token list`) route through the
Cloud ACS API and writes are refused; everything else talks to splunkd REST.
The backend is deduced from the URL — there is no flag to pick it. `splunk
inspect` reports what the deduced backend supports, offline.
Cloud ACS API; everything else talks to splunkd REST. Cloud writes need both
`SPLUNK_ENABLE_WRITES=true` (every write, every backend) and
`SPLUNK_CLOUD_WRITE=true`, and only unlock `create`/`update`/`delete` for
`index`, `role`, and `hec-token` (never a CLI flag; enable/disable and every
other resource stay refused regardless). The backend is deduced from the
URL — there is no flag to pick it. `splunk inspect` reports what the deduced
backend supports, offline.

## Conventions

Expand All @@ -85,11 +89,13 @@ Two cross-cutting ideas to know about:

## Safety

- Writes are gated. Every mutation — index lifecycle, saved-search CRUD and
`search cancel`, and all factory-generated create/update/delete/enable/
disable — funnels through one shared path (`commands/write.py`): `--dry-run`
previews the exact request and sends nothing; otherwise it confirms on a TTY
or requires `--yes` when non-interactive (it never hangs on a hidden prompt).
- Writes are gated and disabled by default. Every mutation — index lifecycle,
saved-search CRUD and `search cancel`, and all factory-generated
create/update/delete/enable/disable — funnels through one shared path
(`commands/write.py`): `--dry-run` previews the exact request and sends
nothing, needing no opt-in; a real write needs `SPLUNK_ENABLE_WRITES=true`
first (there is no CLI flag), then confirms on a TTY or requires `--yes`
when non-interactive (it never hangs on a hidden prompt).
- Each applied write is appended to a local audit log: `$VCT_SPLUNK_AUDIT` if
set, else `$XDG_STATE_HOME/vct-splunk/audit.log`, else
`~/.local/state/vct-splunk/audit.log`.
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,27 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
boundaries: `VCT_SPLUNK_REDACT_TARGET=1` hides it in prompts, JSON metadata,
and transport error text. The audit log is unaffected and always records the
real host. The `Splunk Cloud Read Canary` workflow sets this.
- Opt-in Splunk Cloud writes for `index`, `role`, and `hec-token`
create/update/delete via ACS. Two env-only gates, never a CLI flag:
`SPLUNK_ENABLE_WRITES=true` for any real write on any backend, and
additionally `SPLUNK_CLOUD_WRITE=true` on a Cloud target (checked even for a
`--dry-run` preview, since it also proves the object is one of the three
ACS-writable resources). `--dry-run` and `--yes` behave the same as on
Enterprise. A separate `SPLUNK_ACS_WRITE_TOKEN` can scope the write
credential apart from the read token.
- A `Splunk Cloud Write Canary` GitHub Actions workflow: `workflow_dispatch`
only, gated by a typed `confirm=WRITE` input, a `tests: splunk cloud write`
HEAD commit-subject requirement, and a protected `splunk-cloud-write`
environment with required reviewers around the destructive half. It runs the
Cloud read canary first, so one approved dispatch certifies both.

### Changed

- `splunk inspect` no longer echoes the Cloud stack name in its report body. It
reports `stack_configured: bool` instead.
- **Breaking:** every real (non-`--dry-run`) write, on every backend, now
requires `SPLUNK_ENABLE_WRITES=true`. There is no CLI flag, so a saved
command line cannot enable one. `--dry-run` is unaffected.

- Lower the supported Python floor to 3.9, so the CLI runs under the interpreter
bundled with Splunk Enterprise 9.x. Shipped code needed no change: the package
Expand Down
40 changes: 28 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,18 +129,24 @@ so an object is never created somewhere you did not intend.

### Changes are guarded

Every command that changes the server behaves the same way:
Writes are disabled by default, on every backend. Set `SPLUNK_ENABLE_WRITES=true`
to allow any real (non-preview) mutation -- there is no CLI flag, so a saved
command line can never turn this on by itself:

```bash
splunk index create payments --dry-run # show the exact request, send nothing
splunk index create payments # ask for confirmation, then do it
splunk index create payments --yes # skip the question (required in scripts)
splunk index create payments --dry-run # show the exact request, send nothing -- always works
export SPLUNK_ENABLE_WRITES=true
splunk index create payments # ask for confirmation, then do it
splunk index create payments --yes # skip the question (required in scripts)
```

In a script with no person watching, a change without `--yes` stops immediately
rather than waiting forever for an answer. Every applied change is appended to an
audit log — the first of `$VCT_SPLUNK_AUDIT`,
`$XDG_STATE_HOME/vct-splunk/audit.log`, or `~/.local/state/vct-splunk/audit.log`.
`--dry-run` sends nothing, so it needs no opt-in and always works. Once writes
are enabled, every command that changes the server behaves the same way: ask
for confirmation on a TTY, or require `--yes` in a script with no person
watching -- a change without `--yes` there stops immediately rather than
waiting forever for an answer. Every applied change is appended to an audit
log — the first of `$VCT_SPLUNK_AUDIT`, `$XDG_STATE_HOME/vct-splunk/audit.log`,
or `~/.local/state/vct-splunk/audit.log`.

## Output and exit codes

Expand Down Expand Up @@ -178,10 +184,20 @@ export SPLUNK_ACS_TOKEN="<your ACS token>"
export SPLUNK_ACS_BASE_URL="https://admin.splunkcloudgc.com" # only for FedRAMP
```

Cloud support is **read-only** today, and covers `index list`, `role list`, and
`hec-token list`. Anything else stops with a clear "not supported here" error
instead of guessing. Run `splunk inspect` to see which backend your address
resolves to and what it can do; it answers offline, without contacting anything.
Cloud reads cover `index list`, `role list`, and `hec-token list`. Anything else
stops with a clear "not supported here" error instead of guessing. Run
`splunk inspect` to see which backend your address resolves to and what it can
do; it answers offline, without contacting anything.

Cloud **writes** need both `SPLUNK_ENABLE_WRITES=true` (every write, every
backend) and `SPLUNK_CLOUD_WRITE=true`, and only unlock `create`/`update`/
`delete` for `index`, `role`, and `hec-token` -- enable/disable and every other
resource stay refused regardless. Neither is a CLI flag, so a saved command
line can never enable a write. `--dry-run` and `--yes` work the same as they do
against Enterprise, except a Cloud preview still needs `SPLUNK_CLOUD_WRITE=true`
(it also proves the object is one of the three ACS-writable resources). An ACS
write token can be scoped separately from the read token via
`SPLUNK_ACS_WRITE_TOKEN` (falls back to `SPLUNK_ACS_TOKEN` when unset).

## Security

Expand Down
22 changes: 14 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ vct_splunk/
server.py, search.py, jobs.py, saved_searches.py, kvstore.py,
hec.py, apps.py, cluster.py, license.py, deploy.py, lookups.py,
datamodel.py, health.py, raw.py
acs/ # Splunk Cloud ACS management-plane client (read-only)
acs/ # Splunk Cloud ACS management-plane client (reads +
# gated index/role/hec-token writes)
client.py, operations.py

auth/
Expand All @@ -31,7 +32,8 @@ vct_splunk/
registry.py # Declarative list of factory-generated resources
context.py # Shared `command` decorator + Ctx (builds clients)
write.py # The single gated write path (confirm + audit)
dispatch.py # Routes index/role/hec-token list to ACS on Cloud
dispatch.py # Routes index/role/hec-token reads (and gated
# writes) to ACS on Cloud
server.py, api.py, auth.py, search.py, saved_search.py, health.py,
kvstore.py, hec.py, apps.py, cluster.py, shcluster.py, license.py,
deploy.py, lookup.py, datamodel.py, inspect.py
Expand Down Expand Up @@ -148,12 +150,16 @@ CLI flags (--base-url, --profile, --app, ...)

### Write safety

Every mutation funnels through `commands/write.py:do_write()`: `--dry-run`
previews the exact request and sends nothing; otherwise it confirms on a TTY or
requires `--yes` when non-interactive, then appends a record to the local audit
log. Reads redact secret-named fields by default; only commands whose purpose
is to mint a credential reveal one. Splunk Cloud targets refuse writes and
route supported reads through ACS.
Every mutation funnels through `commands/write.py:do_write()`. A real write --
`--dry-run` sends nothing and needs neither gate below -- requires
`SPLUNK_ENABLE_WRITES=true` first, on every backend; there is no CLI flag, so a
saved command line cannot enable one. It then confirms on a TTY or requires
`--yes` when non-interactive, and appends a record to the local audit log.
Reads redact secret-named fields by default; only commands whose purpose is to
mint a credential reveal one. Splunk Cloud targets route supported reads
through ACS; writes there need `SPLUNK_CLOUD_WRITE=true` as well (checked even
for a `--dry-run` preview, since it also proves the object is one of the three
ACS-writable resources -- index, role, hec-token create/update/delete only).

### Error handling

Expand Down
Loading