Compare commits

..
6 Commits
Author SHA1 Message Date
Kris ColemanandClaude Opus 4.7 2192aecf40 fix(analyze/secret): guard nil deref when spec omits fail: outcome (#2053) (#2054)
* fix(analyze/secret): guard nil deref when spec omits fail: outcome (#2053)

When a Preflight spec defines only warn: and/or pass: outcomes and the
target Secret is missing, analyzeSecret dereferenced a nil failOutcome and
panicked. Reproduction from replicatedhq/troubleshoot#2053:

  outcomes:
    - warn:
        when: "notFound"
        message: "secret missing (warn)"
    - pass:
        message: "secret found"

This change:
- Collects fail, warn, and warn-when-notFound outcomes up front.
- Routes a missing secret (or missing key) through a single resolver:
  prefer warn(when=notFound), fall back to fail, then any warn,
  then synthesize a benign warn result. Never panics.
- Adds table-driven tests covering the three new shapes.

Same defect shape as #263 (imagePullSecret). The sibling analyzers
configmap.go and image.go have the same pattern but are out of scope
for this PR.

Refs: replicatedhq/troubleshoot#2053

* refactor(analyze/secret): drop warn handling per analyzer contract

Per review feedback (banjoh): the secret analyzer only supports fail
(not found) and pass (found) outcomes — `warn:` and `when:` are not
part of the analyzer's contract (https://troubleshoot.sh/docs/analyze/secrets).

Drop the warn / notFoundWarn branches added in the previous commit and
collapse the nil-fail fallback onto IsFail with a default message. The
core fix — guarding the nil deref at the previous secret.go:72 — stays
in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(analyze/secret): mirror image_pull_secret pattern; return nil when spec has neither outcome

Per banjoh review: collect failOutcome and passOutcome with non-nil
checks; when the spec contains neither, return nil and let the framework
surface the missing-outcome error rather than fabricating a result.

Structure now mirrors pkg/analyze/image_pull_secret.go: default to
IsFail with fail-outcome message (if set), flip to IsPass with
pass-outcome message when the secret/key check succeeds, fill default
messages at the end only when none were configured. Analyze() now
forwards a nil result through cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(analyze/secret): assign outcome messages inside their own branch

Cursor bugbot caught: when the spec defined only a fail outcome and the
secret was found, the result showed IsPass=true with the fail outcome's
message. Pre-assigning the fail message at the top before flipping
IsPass meant the stale message leaked through whenever no pass outcome
was configured.

Move both message assignments into their respective branches so a pass
result never carries a fail message. Default messages still fill in
when no outcome is set on the active branch. Added a regression test
covering fail-only spec + secret found.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(analyze/secret): address Greptile P1s — combined outcome + empty configured messages

Two fixes flagged by review on #2054:

1. Combined pass outcome dropped: the outcome loop used 'else if' for pass, so a
   single outcome object containing both fail and pass silently dropped the pass.
   Capture fail and pass independently.

2. Empty configured messages overwritten: the default-message fallback fired
   whenever result.Message was empty, clobbering a configured outcome that
   intentionally has an empty message (e.g. URI-only). Track whether the matched
   branch had a configured outcome and only fall back to a default when none was
   supplied, preserving the configured message and URI verbatim.

Adds tests: combined fail+pass captured on both found/not-found paths, and
URI-only pass/fail outcomes preserved without default-message overwrite.

* fix(analyze/secret): drop fabricated default messages; error on missing outcome

Per maintainer review: analyzers do not fabricate default messages — an empty
outcome message is intentional (e.g. a URI-only outcome), so remove the default
message fallback and preserve the configured outcome verbatim. When a matched
branch has no configured outcome, the message stays empty.

Also address the missing-outcome case: when a spec defines neither a pass nor a
fail outcome, analyzeSecret returned (nil, nil), which the Analyze wrapper
swallowed into an empty result slice — the user saw neither a result nor a
config error. Return an explicit error so the framework surfaces the
misconfiguration.

Tests updated: the no-fail-outcome and only-fail-outcome cases now assert an
empty message rather than a fabricated one, and the neither-outcome case asserts
an error.

* fix(analyze/secret): default message only when no outcome is configured for the matched branch

Distinguish an absent matching outcome from an intentionally empty configured
message. A configured outcome with an empty message (e.g. a URI-only outcome) is
still preserved verbatim. But when the matched branch has no configured outcome at
all — a pass-only spec that took the fail path, or a fail-only spec that passed —
fall back to a default diagnostic instead of emitting an empty message.

Addresses the greptile P1 (endorsed by banjoh): dropping the default entirely
conflated the two cases and left users with a pass/fail result and no context.
Tests restore the default-message expectations for the no-configured-outcome
branches; URI-only (configured-empty) and neither-outcome (error) cases unchanged.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-24 12:16:33 -04:00
Dmitriy Ivolgin 5bf008f7d2 Use all tags when building with securebuild (#2119) 2026-08-21 11:27:04 -07:00
Xav PaiceandElasticClaw Factory 0c6fb2177b fix(cluster-resources): stop emitting duplicate unredacted YAML copy (#2117)
* fix(cluster-resources): stop emitting duplicate unredacted YAML copy

storeCustomResource was writing both a JSON and a YAML file for every
custom resource. The built-in redactors are authored for JSON, so the
YAML copy was left unredacted. JSON is valid YAML, so analyzers that
expect YAML can still parse the JSON file.

Also convert the built-in kurl installer redactors from YAML-specific
paths to scoped JSON regex redactors so they continue to apply after the
YAML copy is removed.

* fix(cluster-resources): add YAML symlinks and cover cluster-scoped kurl installer

- Add a .yaml symlink for every custom-resource JSON file so existing
  analyzers that look for the old YAML copy keep working. The symlink
  points at the JSON file, so redaction of the JSON also redacts the YAML
  view.
- Fix the built-in kurl installer redactor to match both the cluster-scoped
  file (installers.cluster.kurl.sh.json) and the namespaced file pattern
  (installers.cluster.kurl.sh/*.json). The previous selector only matched
  the namespaced form.

---------

Co-authored-by: ElasticClaw Factory <factory@replicated.com>
2026-08-21 09:29:20 +12:00
replicated-software-factory[bot]andElasticClaw Factory bea9c2220d [deps] Dependency update: troubleshoot (#2112)
deps: update Go dependencies

- github.com/miekg/dns v1.1.72 => v1.1.73
- github.com/stretchr/testify v1.11.1 => v1.12.1
- github.com/stretchr/objx v0.5.2 => v0.5.3 (indirect)

Co-authored-by: ElasticClaw Factory <factory@replicated.com>
2026-08-20 13:16:18 +12:00
Xav PaiceandElasticClaw Factory 207cb90b53 feat(supportbundle): support secret/... redactor URIs (#2111)
Co-authored-by: ElasticClaw Factory <factory@replicated.com>
2026-08-20 08:40:11 +12:00
Dmitriy Ivolgin 0bb4a21f22 Remove dockerfile based release (#2115) 2026-08-19 12:07:36 -07:00
16 changed files with 487 additions and 182 deletions
+9 -4
View File
@@ -1,8 +1,8 @@
name: publish-securebuild
env:
SECUREBUILD_CLI_VERSION: v0.0.496
SECUREBUILD_CLI_SHA256: af016db0fa16d51f68f443dea31a63152ad23504bb296137b01f270102011f79
SECUREBUILD_CLI_VERSION: v0.0.498
SECUREBUILD_CLI_SHA256: d259aa99d85aef957fe1e40c6f26e1983cc7fd72277e08599a82adc6681d3b37
on:
workflow_call:
@@ -37,7 +37,6 @@ jobs:
build-package:
needs: validate-version
runs-on: ubuntu-22.04
continue-on-error: true
outputs:
version: ${{ steps.version.outputs.version }}
steps:
@@ -69,7 +68,6 @@ jobs:
needs: build-package
if: ${{ !cancelled() && needs.build-package.result == 'success' }}
runs-on: ubuntu-22.04
continue-on-error: true
strategy:
matrix:
image-name:
@@ -90,7 +88,14 @@ jobs:
SECUREBUILD_API_TOKEN: ${{ secrets.SECUREBUILD_API_TOKEN }}
VERSION: ${{ needs.build-package.outputs.version }}
run: |
IMAGE_VERSION="${VERSION#v}"
MAJOR_MINOR_VERSION="${IMAGE_VERSION%.*}"
MAJOR_VERSION="${IMAGE_VERSION%%.*}"
./securebuild build image \
--image-name "$IMAGE_NAME" \
--tag "$VERSION" \
--image-tag "$MAJOR_MINOR_VERSION" \
--image-tag "$MAJOR_VERSION" \
--image-tag latest \
--api-token "$SECUREBUILD_API_TOKEN"
-5
View File
@@ -18,11 +18,6 @@ jobs:
with:
fetch-depth: 0
- uses: azure/docker-login@v2
with:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- uses: actions/setup-go@v7
with:
go-version: '1.26'
-14
View File
@@ -197,20 +197,6 @@ release: export GITHUB_TOKEN = $(shell echo ${GITHUB_TOKEN_TROUBLESHOOT})
release:
curl -sL https://git.io/goreleaser | bash -s -- --rm-dist --config deploy/.goreleaser.yml
.PHONY: snapshot-release
snapshot-release:
curl -sL https://git.io/goreleaser | bash -s -- --rm-dist --snapshot --config deploy/.goreleaser.snapshot.yml
docker push replicated/troubleshoot:alpha
docker push replicated/preflight:alpha
.PHONY: local-release
local-release:
curl -sL https://git.io/goreleaser | bash -s -- --rm-dist --snapshot --config deploy/.goreleaser.yaml
docker tag replicated/troubleshoot:alpha localhost:32000/troubleshoot:alpha
docker tag replicated/preflight:alpha localhost:32000/preflight:alpha
docker push localhost:32000/troubleshoot:alpha
docker push localhost:32000/preflight:alpha
.PHONY: run-preflight
run-preflight: bin/preflight
./bin/preflight ./examples/preflight/sample-preflight.yaml
-49
View File
@@ -59,33 +59,6 @@ builds:
- -installsuffix=netgo
binary: support-bundle
- id: collect
main: ./cmd/collect/main.go
env: [CGO_ENABLED=0]
goos: [linux, darwin, windows]
goarch: [amd64, arm, arm64, riscv64]
ignore:
- goos: windows
goarch: arm
- goos: windows
goarch: riscv64
- goos: darwin
goarch: riscv64
ldflags:
- -s -w
- -X github.com/replicatedhq/troubleshoot/pkg/version.version={{ .Version }}
- -X github.com/replicatedhq/troubleshoot/pkg/version.gitSHA={{ .Commit }}
- -X github.com/replicatedhq/troubleshoot/pkg/version.buildTime={{ .Date }}
- -extldflags "-static"
flags:
- -tags=netgo
- -tags=containers_image_ostree_stub
- -tags=exclude_graphdriver_devicemapper
- -tags=exclude_graphdriver_btrfs
- -tags=containers_image_openpgp
- -installsuffix=netgo
binary: collect
archives:
- id: preflight
ids: [preflight]
@@ -160,28 +133,6 @@ archives:
dst: .
strip_parent: true
dockers:
- dockerfile: ./deploy/Dockerfile.troubleshoot
image_templates:
- "replicated/troubleshoot:latest"
- "replicated/troubleshoot:{{ .Major }}"
- "replicated/troubleshoot:{{ .Major }}.{{ .Minor }}"
- "replicated/troubleshoot:{{ .Major }}.{{ .Minor }}.{{ .Patch }}"
ids:
- support-bundle
- preflight
- collect
- dockerfile: ./deploy/Dockerfile.troubleshoot
image_templates:
- "replicated/preflight:latest"
- "replicated/preflight:{{ .Major }}"
- "replicated/preflight:{{ .Major }}.{{ .Minor }}"
- "replicated/preflight:{{ .Major }}.{{ .Minor }}.{{ .Patch }}"
ids:
- support-bundle
- preflight
- collect
universal_binaries:
- id: preflight-universal
ids: [preflight] # refers to the build id above
-13
View File
@@ -1,13 +0,0 @@
FROM debian:bookworm
WORKDIR /
RUN apt-get -qq update \
&& apt-get -qq -y install \
ca-certificates kmod
COPY support-bundle /troubleshoot/support-bundle
COPY preflight /troubleshoot/preflight
COPY collect /troubleshoot/collect
ENV PATH="/troubleshoot:${PATH}"
+3 -4
View File
@@ -31,7 +31,7 @@ require (
github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-isatty v0.0.24
github.com/microsoft/go-mssqldb v1.10.0
github.com/miekg/dns v1.1.72
github.com/miekg/dns v1.1.73
github.com/opencontainers/image-spec v1.1.1
github.com/pkg/errors v0.9.1
github.com/replicatedhq/termui/v3 v3.1.1-0.20200811145416-f40076d26851
@@ -40,7 +40,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/tj/go-spin v1.1.0
github.com/vishvananda/netlink v1.3.1
github.com/vishvananda/netns v0.0.5
@@ -172,7 +172,7 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/ulikunitz/xz v0.5.16 // indirect
github.com/vladimirvivien/gexe v0.5.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -185,7 +185,6 @@ require (
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
+6 -6
View File
@@ -420,8 +420,8 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY=
github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -547,14 +547,14 @@ github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjb
github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tj/go-spin v1.1.0 h1:lhdWZsvImxvZ3q1C5OIB7d72DuOwP4O2NdBg9PyzNds=
+54 -25
View File
@@ -30,6 +30,9 @@ func (a *AnalyzeSecret) Analyze(getFile getCollectedFileContents, findFiles getC
if err != nil {
return nil, err
}
if result == nil {
return nil, nil
}
result.Strict = a.analyzer.Strict.BoolOrDefaultFalse()
return []*AnalyzeResult{result}, nil
}
@@ -54,42 +57,68 @@ func (a *AnalyzeSecret) analyzeSecret(analyzer *troubleshootv1beta2.AnalyzeSecre
return nil, err
}
// The secret analyzer only supports fail (not found) and pass (found) outcomes
// per https://troubleshoot.sh/docs/analyze/secrets. If the spec contains
// neither, return an explicit error: returning (nil, nil) is swallowed by the
// Analyze wrapper into an empty result slice, so the misconfiguration would
// surface as neither a result nor an error.
// Capture fail and pass independently: a single outcome object may set both,
// so an else-if here would silently drop the second one.
var failOutcome, passOutcome *troubleshootv1beta2.SingleOutcome
for _, outcome := range analyzer.Outcomes {
if outcome.Fail != nil {
failOutcome = outcome.Fail
}
if outcome.Pass != nil {
passOutcome = outcome.Pass
}
}
if failOutcome == nil && passOutcome == nil {
return nil, fmt.Errorf("secret analyzer %s/%s must define at least one pass or fail outcome", analyzer.Namespace, analyzer.SecretName)
}
result := AnalyzeResult{
Title: a.Title(),
IconKey: "kubernetes_analyze_secret",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
}
var failOutcome *troubleshootv1beta2.Outcome
for _, outcome := range analyzer.Outcomes {
if outcome.Fail != nil {
failOutcome = outcome
}
secretFound := foundSecret.SecretExists
if secretFound && analyzer.Key != "" {
secretFound = foundSecret.Key == analyzer.Key && foundSecret.KeyExists
}
if !foundSecret.SecretExists {
// Use the matched branch's configured outcome verbatim, tracking whether one
// was actually present. A configured outcome with an intentionally empty
// message (e.g. a URI-only outcome) is preserved as-is. But when the matched
// branch has NO configured outcome at all — e.g. a pass-only spec that took
// the fail path, or a fail-only spec that passed — the empty message is not
// an intentional choice, so fall back to a default diagnostic. An absent
// outcome is not the same as an intentionally empty one.
outcomeConfigured := false
if secretFound {
result.IsPass = true
if passOutcome != nil {
result.Message = passOutcome.Message
result.URI = passOutcome.URI
outcomeConfigured = true
}
} else {
result.IsFail = true
result.Message = failOutcome.Fail.Message
result.URI = failOutcome.Fail.URI
return &result, nil
}
if analyzer.Key != "" {
if foundSecret.Key != analyzer.Key || !foundSecret.KeyExists {
result.IsFail = true
result.Message = failOutcome.Fail.Message
result.URI = failOutcome.Fail.URI
return &result, nil
if failOutcome != nil {
result.Message = failOutcome.Message
result.URI = failOutcome.URI
outcomeConfigured = true
}
}
result.IsPass = true
for _, outcome := range analyzer.Outcomes {
if outcome.Pass != nil {
result.Message = outcome.Pass.Message
result.URI = outcome.Pass.URI
if !outcomeConfigured {
switch {
case result.IsPass:
result.Message = fmt.Sprintf("Secret %s was found in namespace %s", analyzer.SecretName, analyzer.Namespace)
case analyzer.Key != "" && foundSecret.SecretExists:
result.Message = fmt.Sprintf("Key %s was not found in secret %s/%s", analyzer.Key, analyzer.Namespace, analyzer.SecretName)
default:
result.Message = fmt.Sprintf("Secret %s was not found in namespace %s", analyzer.SecretName, analyzer.Namespace)
}
}
+234
View File
@@ -166,6 +166,118 @@ func Test_analyzeSecret(t *testing.T) {
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
},
},
{
name: "not found with no fail outcome falls back to a default message (no configured outcome for this branch)",
analyzer: &troubleshootv1beta2.AnalyzeSecret{
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
CheckName: "Optional Secret",
},
Namespace: "default",
SecretName: "does-not-exist",
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "secret found",
},
},
},
},
mockFiles: map[string][]byte{
"secrets/default/does-not-exist.json": mustJSONMarshalIndent(t, collect.SecretOutput{
Namespace: "default",
Name: "does-not-exist",
SecretExists: false,
}),
},
want: &AnalyzeResult{
IsFail: true,
Message: "Secret does-not-exist was not found in namespace default",
Title: "Optional Secret",
IconKey: "kubernetes_analyze_secret",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
},
},
{
name: "key not found with no fail outcome falls back to a default message (no configured outcome for this branch)",
analyzer: &troubleshootv1beta2.AnalyzeSecret{
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
CheckName: "Optional Secret Key",
},
Namespace: "test-namespace",
SecretName: "test-secret",
Key: "missing-key",
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "key found",
},
},
},
},
mockFiles: map[string][]byte{
"secrets/test-namespace/test-secret/missing-key.json": mustJSONMarshalIndent(t, collect.SecretOutput{
Namespace: "test-namespace",
Name: "test-secret",
Key: "missing-key",
SecretExists: true,
KeyExists: false,
}),
},
want: &AnalyzeResult{
IsFail: true,
Message: "Key missing-key was not found in secret test-namespace/test-secret",
Title: "Optional Secret Key",
IconKey: "kubernetes_analyze_secret",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
},
},
{
name: "found with only fail outcome configured falls back to the default pass message, not the fail outcome's message",
analyzer: &troubleshootv1beta2.AnalyzeSecret{
Namespace: "test-namespace",
SecretName: "test-secret",
Outcomes: []*troubleshootv1beta2.Outcome{
{
Fail: &troubleshootv1beta2.SingleOutcome{
Message: "Not found",
},
},
},
},
mockFiles: map[string][]byte{
"secrets/test-namespace/test-secret.json": mustJSONMarshalIndent(t, collect.SecretOutput{
Namespace: "test-namespace",
Name: "test-secret",
SecretExists: true,
}),
},
want: &AnalyzeResult{
IsPass: true,
Message: "Secret test-secret was found in namespace test-namespace",
Title: "Secret test-secret",
IconKey: "kubernetes_analyze_secret",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
},
},
{
name: "spec with neither fail nor pass outcome returns an error so the framework surfaces the misconfiguration",
analyzer: &troubleshootv1beta2.AnalyzeSecret{
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
CheckName: "Misconfigured",
},
Namespace: "default",
SecretName: "does-not-exist",
Outcomes: []*troubleshootv1beta2.Outcome{},
},
mockFiles: map[string][]byte{
"secrets/default/does-not-exist.json": mustJSONMarshalIndent(t, collect.SecretOutput{
Namespace: "default",
Name: "does-not-exist",
SecretExists: false,
}),
},
wantErr: true,
},
{
name: "key not found secret not found",
analyzer: &troubleshootv1beta2.AnalyzeSecret{
@@ -190,6 +302,128 @@ func Test_analyzeSecret(t *testing.T) {
},
wantErr: true, // TODO: should this be a not found error? This will not work with selectors.
},
{
name: "combined fail and pass in a single outcome, secret found uses the pass outcome",
analyzer: &troubleshootv1beta2.AnalyzeSecret{
Namespace: "test-namespace",
SecretName: "test-secret",
Outcomes: []*troubleshootv1beta2.Outcome{
{
Fail: &troubleshootv1beta2.SingleOutcome{
Message: "Not found",
},
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "Found",
URI: "https://example.com/found",
},
},
},
},
mockFiles: map[string][]byte{
"secrets/test-namespace/test-secret.json": mustJSONMarshalIndent(t, collect.SecretOutput{
Namespace: "test-namespace",
Name: "test-secret",
SecretExists: true,
}),
},
want: &AnalyzeResult{
IsPass: true,
Message: "Found",
URI: "https://example.com/found",
Title: "Secret test-secret",
IconKey: "kubernetes_analyze_secret",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
},
},
{
name: "combined fail and pass in a single outcome, secret not found uses the fail outcome",
analyzer: &troubleshootv1beta2.AnalyzeSecret{
Namespace: "test-namespace",
SecretName: "test-secret",
Outcomes: []*troubleshootv1beta2.Outcome{
{
Fail: &troubleshootv1beta2.SingleOutcome{
Message: "Not found",
},
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "Found",
},
},
},
},
mockFiles: map[string][]byte{
"secrets/test-namespace/test-secret.json": mustJSONMarshalIndent(t, collect.SecretOutput{
Namespace: "test-namespace",
Name: "test-secret",
SecretExists: false,
}),
},
want: &AnalyzeResult{
IsFail: true,
Message: "Not found",
Title: "Secret test-secret",
IconKey: "kubernetes_analyze_secret",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
},
},
{
name: "secret found with URI-only pass outcome preserves the empty message and URI",
analyzer: &troubleshootv1beta2.AnalyzeSecret{
Namespace: "test-namespace",
SecretName: "test-secret",
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
URI: "https://example.com/pass",
},
},
},
},
mockFiles: map[string][]byte{
"secrets/test-namespace/test-secret.json": mustJSONMarshalIndent(t, collect.SecretOutput{
Namespace: "test-namespace",
Name: "test-secret",
SecretExists: true,
}),
},
want: &AnalyzeResult{
IsPass: true,
Message: "",
URI: "https://example.com/pass",
Title: "Secret test-secret",
IconKey: "kubernetes_analyze_secret",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
},
},
{
name: "secret not found with URI-only fail outcome preserves the empty message and URI",
analyzer: &troubleshootv1beta2.AnalyzeSecret{
Namespace: "test-namespace",
SecretName: "test-secret",
Outcomes: []*troubleshootv1beta2.Outcome{
{
Fail: &troubleshootv1beta2.SingleOutcome{
URI: "https://example.com/fail",
},
},
},
},
mockFiles: map[string][]byte{
"secrets/test-namespace/test-secret.json": mustJSONMarshalIndent(t, collect.SecretOutput{
Namespace: "test-namespace",
Name: "test-secret",
SecretExists: false,
}),
},
want: &AnalyzeResult{
IsFail: true,
Message: "",
URI: "https://example.com/fail",
Title: "Secret test-secret",
IconKey: "kubernetes_analyze_secret",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+17 -11
View File
@@ -294,7 +294,18 @@ func (c *CollectClusterResources) Collect(progressChan chan<- interface{}) (Coll
// crs
customResources, crErrors := crs(ctx, dynamicClient, client, c.ClientConfig, namespaceNames)
for k, v := range customResources {
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, k), bytes.NewBuffer(v))
jsonPath := path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, k)
output.SaveResult(c.BundlePath, jsonPath, bytes.NewBuffer(v))
// Keep a YAML symlink for backward compatibility. It points at the JSON
// file, so any analyzer that expects YAML can still read it, and the
// content is redacted through the JSON copy.
if strings.HasSuffix(k, ".json") {
yamlPath := path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, strings.TrimSuffix(k, ".json")+".yaml")
if err := output.SymLinkResult(c.BundlePath, yamlPath, jsonPath); err != nil {
klog.V(2).Infof("failed to create YAML symlink for %s: %v", jsonPath, err)
}
}
}
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES)), marshalErrors(crErrors))
@@ -2294,23 +2305,18 @@ func mutatingWebhookConfigurations(ctx context.Context, client kubernetes.Interf
return b, nil
}
// storeCustomResource stores a custom resource as JSON and YAML
// We use both formats for backwards compatibility. This way we
// avoid breaking existing tools and analysers that already rely on
// the YAML format.
// storeCustomResource stores a custom resource as JSON only.
// JSON is valid YAML, so any analyzer expecting YAML can consume the JSON
// file directly. We no longer write a separate YAML file because the
// built-in redactors are authored for JSON, and the duplicate YAML copy
// would otherwise be left unredacted.
func storeCustomResource(name string, objects any, m map[string][]byte) error {
j, err := json.MarshalIndent(objects, "", " ")
if err != nil {
return err
}
y, err := yaml.Marshal(objects)
if err != nil {
return err
}
m[fmt.Sprintf("%s.json", name)] = j
m[fmt.Sprintf("%s.yaml", name)] = y
return nil
}
+1 -11
View File
@@ -28,7 +28,6 @@ import (
"k8s.io/client-go/kubernetes"
testclient "k8s.io/client-go/kubernetes/fake"
k8stesting "k8s.io/client-go/testing"
"sigs.k8s.io/yaml"
)
func init() {
@@ -512,17 +511,8 @@ func TestCollectClusterResources_CustomResource(t *testing.T) {
// Fetch the CR from cluster
res, errs := crsV1(ctx, dynamicClient, apixClient.ApiextensionsV1(), []string{"default"})
assert.Empty(t, errs)
require.Equal(t, 2, len(res))
require.Equal(t, 1, len(res))
assert.Equal(t, fromJSON(t, res["supportbundles.troubleshoot.sh/default.json"]), sbObject)
assert.Equal(t, fromYAML(t, res["supportbundles.troubleshoot.sh/default.yaml"]), sbObject)
}
func fromYAML(t *testing.T, dat []byte) troubleshootv1beta2.SupportBundle {
sb := []troubleshootv1beta2.SupportBundle{}
err := yaml.Unmarshal(dat, &sb)
require.NoError(t, err)
require.Equal(t, 1, len(sb))
return sb[0]
}
func fromJSON(t *testing.T, dat []byte) troubleshootv1beta2.SupportBundle {
+31 -32
View File
@@ -35,6 +35,32 @@ func init() {
}
}
// kurlInstallerRedactors are built-in redactors scoped to the kurl installer
// custom resource. They previously applied only to the YAML copy of custom
// resources; now they target the JSON file (and the YAML symlink that points to it).
// The installer CRD can be either cluster-scoped (installers.cluster.kurl.sh.json)
// or namespaced (installers.cluster.kurl.sh/<namespace>.json), so both patterns are
// included.
var kurlInstallerRedactors = []*troubleshootv1beta2.Redact{
{
Name: "Redact kurl installer fields",
FileSelector: troubleshootv1beta2.FileSelector{
Files: []string{
fmt.Sprintf("%s/%s/%s.json", constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, "installers.cluster.kurl.sh"),
fmt.Sprintf("%s/%s/%s/*.json", constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, "installers.cluster.kurl.sh"),
},
},
Removals: troubleshootv1beta2.Removals{
Regex: []troubleshootv1beta2.Regex{
{Redactor: `(?i)("bootstrapToken"\s*:\s*")(?P<mask>[^"]*)(")`},
{Redactor: `(?i)("certKey"\s*:\s*")(?P<mask>[^"]*)(")`},
{Redactor: `(?i)("kubeadmToken"\s*:\s*")(?P<mask>[^"]*)(")`},
{Redactor: `(?i)("kubectl\.kubernetes\.io/last-applied-configuration"\s*:\s*")(?P<mask>(?:\\.|[^"\\])*)(")`},
},
},
},
}
// A regex cache to avoid recompiling the same regexes over and over
func compileRegex(pattern string) (*regexp.Regexp, error) {
regexCacheLock.Lock()
@@ -440,39 +466,12 @@ func getRedactors(path string) ([]Redactor, error) {
redactors = append(redactors, r)
}
customResources := []struct {
resource string
yamlPath string
}{
{
resource: "installers.cluster.kurl.sh",
yamlPath: "*.spec.kubernetes.bootstrapToken",
},
{
resource: "installers.cluster.kurl.sh",
yamlPath: "*.spec.kubernetes.certKey",
},
{
resource: "installers.cluster.kurl.sh",
yamlPath: "*.spec.kubernetes.kubeadmToken",
},
}
uniqueCRs := map[string]bool{}
for _, cr := range customResources {
fileglob := fmt.Sprintf("%s/%s/%s/*.yaml", constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, cr.resource)
redactors = append(redactors, NewYamlRedactor(cr.yamlPath, fileglob, ""))
// redact kubectl last applied annotation once for each resource since it contains copies of
// redacted fields
if !uniqueCRs[cr.resource] {
uniqueCRs[cr.resource] = true
redactors = append(redactors, &YamlRedactor{
filePath: fileglob,
maskPath: []string{"*", "metadata", "annotations", "kubectl.kubernetes.io/last-applied-configuration"},
})
}
// Add built-in redactors that are scoped to specific custom resource files.
scopedRedactors, err := buildAdditionalRedactors(path, kurlInstallerRedactors)
if err != nil {
return nil, err
}
redactors = append(redactors, scopedRedactors...)
return redactors, nil
}
+46
View File
@@ -1874,3 +1874,49 @@ func Test_redactMatchesPath(t *testing.T) {
})
}
}
func Test_RedactKurlInstallerJSON(t *testing.T) {
input := `[
{
"metadata": {
"name": "kurl",
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"cluster.kurl.sh/v1beta1\",\"kind\":\"Installer\",\"spec\":{\"kubernetes\":{\"bootstrapToken\":\"abc\",\"certKey\":\"def\",\"kubeadmToken\":\"ghi\"}}}"
}
},
"spec": {
"kubernetes": {
"bootstrapToken": "abc",
"certKey": "def",
"kubeadmToken": "ghi"
}
}
}
]`
cases := []string{
// Cluster-scoped installer (file directly under custom-resources)
"cluster-resources/custom-resources/installers.cluster.kurl.sh.json",
// Namespaced installer (file under a per-CRD directory)
"cluster-resources/custom-resources/installers.cluster.kurl.sh/default.json",
}
for _, path := range cases {
t.Run(path, func(t *testing.T) {
r, err := Redact(strings.NewReader(input), path, nil)
require.NoError(t, err)
out, err := ioutil.ReadAll(r)
require.NoError(t, err)
outStr := string(out)
require.NotContains(t, outStr, `"abc"`)
require.NotContains(t, outStr, `"def"`)
require.NotContains(t, outStr, `"ghi"`)
require.Contains(t, outStr, `"bootstrapToken": "***HIDDEN***"`)
require.Contains(t, outStr, `"certKey": "***HIDDEN***"`)
require.Contains(t, outStr, `"kubeadmToken": "***HIDDEN***"`)
require.Contains(t, outStr, `"kubectl.kubernetes.io/last-applied-configuration": "***HIDDEN***"`)
})
}
}
+26
View File
@@ -20,6 +20,9 @@ import (
"k8s.io/klog/v2"
)
// loadFromSecret is a package-level hook so tests can stub out cluster access.
var loadFromSecret = specs.LoadFromSecret
// GetSupportBundleFromURI downloads and parses a support bundle from a URI and returns a SupportBundle object
func GetSupportBundleFromURI(bundleURI string) (*troubleshootv1beta2.SupportBundle, error) {
collectorContent, err := LoadSupportBundleSpec(bundleURI)
@@ -165,6 +168,29 @@ func LoadSupportBundleSpec(arg string) ([]byte, error) {
}
func LoadRedactorSpec(arg string) ([]byte, error) {
if strings.HasPrefix(arg, "secret/") {
// format secret/namespace-name/secret-name[/data-key]
pathParts := strings.Split(arg, "/")
if len(pathParts) > 4 {
return nil, errors.Errorf("secret path %s must have at most 4 components", arg)
}
if len(pathParts) < 3 {
return nil, errors.Errorf("secret path %s must have at least 3 components", arg)
}
dataKey := "redactor-spec"
if len(pathParts) == 4 {
dataKey = pathParts[3]
}
spec, err := loadFromSecret(pathParts[1], pathParts[2], dataKey)
if err != nil {
return nil, errors.Wrap(err, "failed to get spec from secret")
}
return spec, nil
}
if strings.HasPrefix(arg, "configmap/") {
// format configmap/namespace-name/configmap-name[/data-key]
pathParts := strings.Split(arg, "/")
+60
View File
@@ -1,7 +1,9 @@
package supportbundle
import (
"fmt"
"reflect"
"strings"
"testing"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
@@ -92,3 +94,61 @@ spec:
})
}
}
func TestLoadRedactorSpec(t *testing.T) {
origLoadFromSecret := loadFromSecret
defer func() { loadFromSecret = origLoadFromSecret }()
loadFromSecret = func(namespace, secretName, key string) ([]byte, error) {
return []byte(fmt.Sprintf("namespace=%s,secret=%s,key=%s", namespace, secretName, key)), nil
}
tests := []struct {
name string
uri string
wantContent string
wantErr string
}{
{
name: "secret URI with default key",
uri: "secret/default/my-redactor",
wantContent: "namespace=default,secret=my-redactor,key=redactor-spec",
},
{
name: "secret URI with custom key",
uri: "secret/default/my-redactor/custom-key",
wantContent: "namespace=default,secret=my-redactor,key=custom-key",
},
{
name: "secret URI with too few components",
uri: "secret/default",
wantErr: "must have at least 3 components",
},
{
name: "secret URI with too many components",
uri: "secret/default/my-redactor/custom-key/extra",
wantErr: "must have at most 4 components",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := LoadRedactorSpec(tt.uri)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("LoadRedactorSpec() expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("LoadRedactorSpec() error = %q, want containing %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("LoadRedactorSpec() unexpected error = %v", err)
}
if string(got) != tt.wantContent {
t.Errorf("LoadRedactorSpec() = %q, want %q", string(got), tt.wantContent)
}
})
}
}
-8
View File
@@ -94,14 +94,6 @@ subpackages:
pipeline:
- runs: /usr/bin/collect --help
update:
enabled: true
github:
identifier: replicatedhq/troubleshoot
use-tag: true
strip-prefix: v
tag-filter: v${{vars.major-minor-version}}.
test:
environment:
contents: