Skip to content
Closed
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
23 changes: 19 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,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 +71,21 @@ SPLUNK_TOKEN=
# FedRAMP stacks use https://admin.splunkcloudgc.com.
# SPLUNK_ACS_BASE_URL=

# Cloud writes are opt-in and narrow: create/update/delete for index, role, and
# hec-token only (never a CLI flag, so a saved command line cannot enable one
# by accident). Everything else on Cloud -- including enable/disable on those
# same three resources -- stays refused regardless.
# 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.
# VCT_SPLUNK_REDACT_TARGET=1

# --- Live test opt-ins -------------------------------------------------------

# Enables live read tests. Enterprise writes also require SPLUNK_WRITE_TEST=true.
Expand Down
53 changes: 53 additions & 0 deletions .github/scripts/scan-cloud-ci-leaks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Fail when Cloud CI artifacts appear to contain targets or credentials."""

from __future__ import annotations

import os
import re
import sys
from pathlib import Path

_ENV_NAMES = (
"SPLUNK_URL",
"SPLUNK_ACS_STACK",
"SPLUNK_ACS_BASE_URL",
"SPLUNK_ACS_TOKEN",
"SPLUNK_TOKEN",
)
_PATTERNS = (
re.compile(r"Bearer "),
re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"),
re.compile(r"admin\.splunk\.com/(?!<redacted>(?:/|$))[^/\s]+", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9-])(?!<redacted>\.)[A-Za-z0-9-]+\.splunkcloud\.com", re.IGNORECASE),
)


def _count_matches(line: str, literals: tuple[str, ...]) -> int:
"""Count leak signatures in one line without retaining their values."""
return sum(line.count(value) for value in literals) + sum(
len(pattern.findall(line)) for pattern in _PATTERNS
)


def main(argv: list[str]) -> int:
"""Scan each requested artifact and return nonzero when a leak is found."""
literals = tuple(value for name in _ENV_NAMES if len(value := os.environ.get(name, "")) >= 4)
found = False
for name in argv:
path = Path(name)
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except FileNotFoundError:
print(f"{path}:missing", file=sys.stderr)
continue
for line_number, line in enumerate(lines, start=1):
count = _count_matches(line, literals)
if count:
print(f"{path}:{line_number}:{count}", file=sys.stderr)
found = True
return int(found)


if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
88 changes: 56 additions & 32 deletions .github/workflows/cloud-read.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,6 @@ jobs:
name: Cloud / ACS reads
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 }}
# splunkd credential. Without it, every read Cloud does not serve stops at
# the credential check instead of reaching the dispatch layer, so the run
# covers far less than it appears to. The guard below says so out loud.
SPLUNK_TOKEN: ${{ secrets.SPLUNK_TOKEN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
Expand All @@ -39,6 +29,10 @@ jobs:
# step that reports what can be certified rather than a skipped job.
- name: Check for a configured Cloud stack
id: stack
env:
SPLUNK_URL: ${{ secrets.SPLUNK_URL }}
SPLUNK_ACS_TOKEN: ${{ secrets.SPLUNK_ACS_TOKEN }}
SPLUNK_TOKEN: ${{ secrets.SPLUNK_TOKEN }}
run: bash .github/scripts/detect-cloud-stack.sh

- if: steps.stack.outputs.ready == 'true'
Expand All @@ -56,28 +50,58 @@ jobs:

- name: Cloud reads (every catalogued read command)
if: steps.stack.outputs.ready == 'true'
run: >-
.venv/bin/pytest tests/integration/cloud/read/test_catalog.py
-m "integration and cloud and read"
-vv --color=yes --tb=short --junitxml=cloud-read.xml
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
run: |
set -o pipefail
set +e
.venv/bin/pytest tests/integration/cloud/read/test_catalog.py \
-m "integration and cloud and read" \
-q --tb=line -r N \
-o addopts='--strict-markers --import-mode=importlib' \
--junitxml=cloud-read.xml \
| tee cloud-read.log
pytest_status=${PIPESTATUS[0]}
set -e
scan_files=(cloud-read.log cloud-acs.log cloud-read.xml cloud-acs.xml)
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
scan_files+=("$GITHUB_STEP_SUMMARY")
fi
python .github/scripts/scan-cloud-ci-leaks.py "${scan_files[@]}"
exit "$pytest_status"

- name: Cloud ACS operations (below the CLI)
if: steps.stack.outputs.ready == 'true'
run: >-
.venv/bin/pytest tests/integration/cloud/read/test_acs_operations.py
-m "integration and cloud and read"
-vv --color=yes --tb=short --junitxml=cloud-acs.xml

- name: Publish test summary
if: always() && steps.stack.outputs.ready == 'true'
uses: test-summary/action@37b508cfee6d4d080eedd00b5bb240a6a784a6a5 # v2
with:
paths: cloud-*.xml

- name: Upload test report
if: always() && steps.stack.outputs.ready == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: cloud-read-${{ github.run_id }}
path: cloud-*.xml
if-no-files-found: ignore
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
run: |
set -o pipefail
set +e
.venv/bin/pytest tests/integration/cloud/read/test_acs_operations.py \
-m "integration and cloud and read" \
-q --tb=line -r N \
-o addopts='--strict-markers --import-mode=importlib' \
--junitxml=cloud-acs.xml \
| tee cloud-acs.log
pytest_status=${PIPESTATUS[0]}
set -e
scan_files=(cloud-read.log cloud-acs.log cloud-read.xml cloud-acs.xml)
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
scan_files+=("$GITHUB_STEP_SUMMARY")
fi
python .github/scripts/scan-cloud-ci-leaks.py "${scan_files[@]}"
exit "$pytest_status"
114 changes: 114 additions & 0 deletions .github/workflows/cloud-write.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Destructive ACS writes against a real Splunk Cloud stack, with undo.
#
# This never runs on pull_request or a schedule. A human must dispatch it and
# type WRITE. HEAD's commit subject must start with "tests: splunk cloud write".
# Use a non-production stack: the job creates and deletes indexes, roles, and
# HEC tokens.
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

cloud-write:
name: Cloud / ACS writes
needs: confirm
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 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

- if: steps.stack.outputs.ready == 'true'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.14"
cache: pip

- name: Install project
if: steps.stack.outputs.ready == 'true'
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
if: steps.stack.outputs.ready == 'true'
env:
SPLUNK_ACS_LIVE_TEST: "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: |
set -o pipefail
set +e
.venv/bin/pytest tests/integration/cloud/write \
-m "integration and cloud and write" \
-q --tb=line -r N \
-o addopts='--strict-markers --import-mode=importlib' \
--junitxml=cloud-write.xml \
| tee cloud-write.log
pytest_status=${PIPESTATUS[0]}
set -e
scan_files=(cloud-write.log cloud-write.xml)
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
scan_files+=("$GITHUB_STEP_SUMMARY")
fi
python .github/scripts/scan-cloud-ci-leaks.py "${scan_files[@]}"
exit "$pytest_status"
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,11 @@ 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
Cloud ACS API; everything else talks to splunkd REST. Cloud writes are
opt-in and narrow: `SPLUNK_CLOUD_WRITE=true` unlocks `create`/`update`/
`delete` for `index`, `role`, and `hec-token` only (never a CLI flag, and
enable/disable plus 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 Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,21 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
config get FILE STANZA`. The commands use the normal read namespace, accept a
file name with or without `.conf`, paginate collection results, and redact
secret-valued properties.
- Opt-in Splunk Cloud writes for `index`, `role`, and `hec-token`
create/update/delete via ACS. Default remains refuse-before-network; set
`SPLUNK_CLOUD_WRITE=true` (no CLI flag) and still pass `--yes` or confirm on
a TTY. `--dry-run` sends nothing. A separate `SPLUNK_ACS_WRITE_TOKEN` can
scope the write credential apart from the read token.
- GitHub Actions canaries for live Cloud reads (existing workflow, leak-scanned)
and destructive Cloud writes with undo (`workflow_dispatch` only, typed
`confirm=WRITE`).

### Changed

- `splunk inspect` no longer emits the Cloud stack name. It reports
`stack_configured` instead. Set `VCT_SPLUNK_REDACT_TARGET=1` to hide the
stack label in `meta.target` as well (CI does this). The audit log still
records a credential-stripped but host-honest target.
- 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
already uses only 3.9-compatible syntax and APIs. Declarations move
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,19 @@ 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** are opt-in and narrow: set `SPLUNK_CLOUD_WRITE=true` to unlock
`create`/`update`/`delete` for `index`, `role`, and `hec-token` only (enable and
disable, and every other resource, stay refused regardless). There is no CLI
flag for the opt-in -- only the environment variable, so a write is never
enabled by accident from a saved command line. `--dry-run` and `--yes` work the
same as they do against Enterprise. 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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.0
0.4.0
Loading