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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ SPLUNK_TOKEN=
# FedRAMP stacks use https://admin.splunkcloudgc.com.
# SPLUNK_ACS_BASE_URL=

# 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
32 changes: 32 additions & 0 deletions .github/scripts/run-cloud-suite.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Run one Cloud pytest suite, tee its output, and publish a JUnit report.
# Shared by the read and write canary workflows so the pipefail/tee/exit
# dance lives in one place instead of being copy-pasted per step.
#
# Usage: run-cloud-suite.sh <label> <pytest-target> [pytest-marker]
# label used for <label>.log / <label>.xml
# pytest-target path pytest should collect
# pytest-marker -m expression (default: "integration and cloud")
#
# Requires: a project already installed into .venv (the workflow's "Install
# project" step).
set -euo pipefail

label="${1:?usage: run-cloud-suite.sh <label> <pytest-target> [pytest-marker]}"
target="${2:?usage: run-cloud-suite.sh <label> <pytest-target> [pytest-marker]}"
marker="${3:-integration and cloud}"

log="${label}.log"
xml="${label}.xml"

set +e
.venv/bin/pytest "$target" \
-m "$marker" \
-q --tb=line -r N \
-o addopts='--strict-markers --import-mode=importlib' \
--junitxml="$xml" \
| tee "$log"
pytest_status=${PIPESTATUS[0]}
set -e

exit "$pytest_status"
14 changes: 8 additions & 6 deletions .github/workflows/cloud-read.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ jobs:
# 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 }}
# Untrusted output boundary: hide the Cloud stack name in prompts, JSON
# metadata, and error text.
VCT_SPLUNK_REDACT_TARGET: "1"
VCT_SPLUNK_AUDIT: ${{ runner.temp }}/vct-splunk-audit.log
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
Expand Down Expand Up @@ -57,16 +61,14 @@ 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
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)
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
bash .github/scripts/run-cloud-suite.sh cloud-acs
tests/integration/cloud/read/test_acs_operations.py "integration and cloud and read"

- name: Publish test summary
if: always() && steps.stack.outputs.ready == 'true'
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@ 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 redaction of the Splunk Cloud stack name at untrusted output
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.

### Changed

- `splunk inspect` no longer echoes the Cloud stack name in its report body. It
reports `stack_configured: bool` instead.

- 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
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ vct_splunk/

utils/
errors.py # Typed SplunkError hierarchy with exit codes
redact.py # Secret redaction by field name; safe_target for URLs
redact.py # Secret redaction by field name; safe_target for URLs;
# public_target additionally hides a Cloud stack name
# when VCT_SPLUNK_REDACT_TARGET=1
namespace.py # /servicesNS/<owner>/<app>/ path building + policy
path.py # Path-segment validation/encoding (traversal-safe)
validation.py # KEY=VALUE parsing
Expand Down
4 changes: 2 additions & 2 deletions src/vct_splunk/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from ..output import formatter as out
from ..utils.backends import deduce_backend
from ..utils.errors import SplunkError
from ..utils.redact import safe_target
from ..utils.redact import public_target

if TYPE_CHECKING:
from ..api.acs.client import AcsClient
Expand Down Expand Up @@ -126,7 +126,7 @@ def meta(self) -> dict[str, str | None]:
Right now this is just the target Splunk URL, so a piece of output can be
traced back to the instance it came from.
"""
return {"target": safe_target(self.base_url or "")}
return {"target": public_target(self.base_url or "")}


def command(fn: Callable) -> Callable:
Expand Down
5 changes: 4 additions & 1 deletion src/vct_splunk/utils/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ def inspect_report(url: str | None = None) -> dict[str, Any]:
backend = deduce_backend(url)
report: dict[str, Any] = {"backend": backend, "capabilities": CAPABILITIES[backend]}
if backend == "cloud":
report["stack"] = cloud_stack_from_url(url)
# Whether a stack is configured, not its name -- the name is never echoed
# in this offline report; see `commands.inspect` for the one place a
# live Cloud target's identity is shown, and only with consent to leak it.
report["stack_configured"] = cloud_stack_from_url(url) is not None
report["note"] = (
"Cloud/ACS coverage is read-only and not yet certified against a live stack."
)
Expand Down
29 changes: 29 additions & 0 deletions src/vct_splunk/utils/redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,22 @@

from __future__ import annotations

import os
import re
from typing import Any
from urllib.parse import urlsplit, urlunsplit

#: What a hidden value is replaced with. The key stays, so a caller can still
#: see that the field exists.
REDACTED = "<redacted>"

#: Set to hide a Splunk Cloud stack name at an untrusted output boundary (CI
#: logs). See :func:`public_target` and :func:`redact_exception_text`.
REDACT_TARGET_ENV = "VCT_SPLUNK_REDACT_TARGET"

_SECRET_MARKERS = ("pass4symmkey", "password", "passwd", "secret", "token")
_CLOUD_STACK_HOST_RE = re.compile(r"(?i)(?<![A-Za-z0-9-])[A-Za-z0-9-]+(?=\.splunkcloud)")
_ACS_STACK_PATH_RE = re.compile(r"(?i)(admin\.splunk\.com/)(?!<redacted>(?:/|$))[^/?#\s]+")


def is_secret_key(key: object) -> bool:
Expand Down Expand Up @@ -98,3 +106,24 @@ def safe_target(target: str) -> str:
pass
path = parsed.path if "@" not in parsed.path else f"/{REDACTED}"
return urlunsplit((parsed.scheme, host, path, "", ""))


def public_target(target: str) -> str:
"""Return :func:`safe_target`, optionally with its Cloud stack name hidden.

``safe_target`` remains suitable for the audit trail, where a Cloud stack
identifies the instance. Set :data:`REDACT_TARGET_ENV` to ``"1"`` for
untrusted output boundaries such as CI logs -- this applies the same
stack-label rewrite :func:`redact_exception_text` applies to free-form
error text, run once over the credential-safe target.
"""
target = safe_target(target)
if os.environ.get(REDACT_TARGET_ENV) != "1":
return target
return redact_exception_text(target)


def redact_exception_text(text: str) -> str:
"""Hide Cloud stack labels from a free-form exception message or target."""
text = _CLOUD_STACK_HOST_RE.sub(REDACTED, text)
return _ACS_STACK_PATH_RE.sub(rf"\g<1>{REDACTED}", text)
6 changes: 5 additions & 1 deletion tests/unit/test_acs.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,11 @@ def test_inspect_reports_deduced_cloud(monkeypatch):
result = CliRunner().invoke(cli, ["inspect", "--output", "json"])
assert result.exit_code == 0
assert '"backend": "cloud"' in result.output
assert '"stack": "acme"' in result.output
# The report body says whether a stack is configured, never its name --
# unlike `meta.target`, whose opt-in redaction is covered separately in
# test_public_target.py.
assert '"stack_configured": true' in result.output
assert '"stack"' not in result.output
assert "not yet certified" in result.output


Expand Down
76 changes: 76 additions & 0 deletions tests/unit/test_public_target.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""`public_target` hides a Cloud stack name only when explicitly asked.

`safe_target` always strips credentials -- that's unconditional. A Cloud stack
name is not a credential, but it does identify a specific customer's instance,
so hiding it is opt-in via `VCT_SPLUNK_REDACT_TARGET=1`, for untrusted output
boundaries such as CI logs. `redact_exception_text` is the same rewrite applied
to free-form text (a transport error message), not just a URL.
"""

from __future__ import annotations

import pytest

from vct_splunk.utils.redact import REDACTED, public_target, redact_exception_text


@pytest.fixture
def redact_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("VCT_SPLUNK_REDACT_TARGET", "1")


@pytest.fixture
def redact_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("VCT_SPLUNK_REDACT_TARGET", raising=False)


def test_without_the_env_var_the_stack_name_is_left_readable(redact_disabled) -> None:
assert public_target("https://acme.splunkcloud.com") == "https://acme.splunkcloud.com"


def test_with_the_env_var_a_cloud_host_label_is_hidden(redact_enabled) -> None:
assert public_target("https://acme.splunkcloud.com") == f"https://{REDACTED}.splunkcloud.com"


def test_with_the_env_var_a_port_survives_the_rewrite(redact_enabled) -> None:
assert (
public_target("https://acme.splunkcloud.com:8089")
== f"https://{REDACTED}.splunkcloud.com:8089"
)


def test_with_the_env_var_an_admin_splunk_com_path_segment_is_hidden(redact_enabled) -> None:
assert (
public_target("https://admin.splunk.com/acme/adminconfig/v2/indexes")
== f"https://admin.splunk.com/{REDACTED}/adminconfig/v2/indexes"
)


def test_an_enterprise_target_is_unaffected_either_way(redact_enabled) -> None:
assert public_target("https://sh.corp:8089") == "https://sh.corp:8089"


def test_url_credentials_are_still_stripped_under_redaction(redact_enabled) -> None:
target = public_target("https://admin:secret@acme.splunkcloud.com")
assert "secret" not in target
assert target == f"https://{REDACTED}.splunkcloud.com"


def test_redact_exception_text_hides_a_cloud_host_in_free_form_text() -> None:
text = redact_exception_text("Could not reach ACS at https://acme.splunkcloud.com: timeout")
assert "acme" not in text
assert f"{REDACTED}.splunkcloud.com" in text


def test_redact_exception_text_hides_an_acs_stack_path_segment() -> None:
text = redact_exception_text("ACS returned 404 for GET /acme/adminconfig/v2/indexes")
# No admin.splunk.com host present here, so the path regex (which anchors
# on that host) does not fire -- the stack label in a bare path is not this
# function's job; it only rewrites what it can identify unambiguously.
assert text == "ACS returned 404 for GET /acme/adminconfig/v2/indexes"


def test_redact_exception_text_is_idempotent() -> None:
once = redact_exception_text("https://acme.splunkcloud.com")
twice = redact_exception_text(once)
assert once == twice == f"https://{REDACTED}.splunkcloud.com"
Loading