Repository: replicatedhq/troubleshoot
Pull request #887 (2022-12-02 17:49:24)

feat(redactors): Run redactors on an existing support bundle
## Description, Motivation and Context

Add a `redact` subcommand to `support-bundle` to allow running redactors on an existing bundle, creating a new redacted bundle as a result.

The command will be launched like so

```sh
support-bundle redact <redactors> --bundle support-bundle.tar.gz --output redacted-support-bundle.tar.gz
```

<!--- If it relates to an open issue, please link to the issue here.
e.g.
Fixes: #414
-->

Fixes: #705

## Checklist

- [x] New and existing tests pass locally with introduced changes.
- [x] Tests for the changes have been added (for bug fixes / features)
- [x] The commit message(s) are informative and highlight any breaking changes
- [x] Any documentation required has been added/updated. For changes to https://troubleshoot.sh/ create a PR [here](https://github.com/replicatedhq/troubleshoot.sh/pulls)

## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No

<!-- If this PR contains a breaking change, please describe the impact and migration path for existing applications below. -->

## Additional notes
- Documentation to troubleshoot.sh will be done when we address https://github.com/replicatedhq/troubleshoot.sh/issues/414

banjoh: > From the example, where is the new bundle written to? Are we overweighting the provided bundle?
> 
> I'd like to avoid commands being destructive (overwriting the input) if possible. I'd hate for a command to be run that can't be un-done. We could accept a new output name (probably should) and default to appending a suffix if not provided or something.

The output will definitely be a new file. I'm with you here. We should not re-write the old file. I hint this in the usage description [here](https://github.com/banjoh/troubleshoot/blob/em/redactors-on-existing-sb/cmd/troubleshoot/cli/redact.go#L18)
chris-sanders: Ah I see it now, great thanks!

Patch:
diff --git a/.github/workflows/require-pr-labels.yaml b/.github/workflows/require-pr-labels.yaml
index 0718f8d3..b4eefdf5 100644
--- a/.github/workflows/require-pr-labels.yaml
+++ b/.github/workflows/require-pr-labels.yaml
@@ -17,7 +17,7 @@ jobs:
   require-bug-labels:
     runs-on: ubuntu-latest
     steps:
-      - uses: mheap/github-action-required-labels@v2
+      - uses: mheap/github-action-required-labels@v3
         if: ${{ github.event.label.name == 'type::bug' }}
         with:
           mode: exactly
diff --git a/.gitignore b/.gitignore
index df59e932..af6bc42e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,4 @@ sbom/
 
 # Ignore generated support bundles
 *.tar.gz
+!testdata/supportbundle/*.tar.gz
diff --git a/Makefile b/Makefile
index 641fe9e4..e34dc3a2 100644
--- a/Makefile
+++ b/Makefile
@@ -35,7 +35,7 @@ endef
 
 BUILDFLAGS = -tags "netgo containers_image_ostree_stub exclude_graphdriver_devicemapper exclude_graphdriver_btrfs containers_image_openpgp" -installsuffix netgo
 
-all: test support-bundle preflight collect
+all: test support-bundle preflight collect analyze
 
 .PHONY: ffi
 ffi: fmt vet
@@ -202,8 +202,8 @@ scan:
 
 .PHONY: lint
 lint:
-	golangci-lint run -c .golangci.yaml
+	golangci-lint run --new -c .golangci.yaml pkg/... cmd/...
 
 .PHONY: lint-and-fix
 lint-and-fix:
-	golangci-lint run --fix -c .golangci.yaml
+	golangci-lint run --new --fix -c .golangci.yaml pkg/... cmd/...
diff --git a/README.md b/README.md
index d255932d..dcfc2528 100644
--- a/README.md
+++ b/README.md
@@ -12,13 +12,13 @@ To run a sample preflight check from a sample application, install the preflight
 ```
 curl https://krew.sh/preflight | bash
 ```
-and run, where https://preflight.replicated.com provides an **example** preflight spec:
- 
+ and run, where https://preflight.replicated.com provides an **example** preflight spec:
+
 ```
 kubectl preflight https://preflight.replicated.com
 ```
 
-**NOTE** this is an example. Do **not** use to validate real scenarios. 
+**NOTE** this is an example. Do **not** use to validate real scenarios.
 
 For more details on creating the custom resource files that drive preflight checks, visit [creating preflight checks](https://troubleshoot.sh/docs/preflight/introduction/).
 
@@ -31,13 +31,13 @@ To collect a sample support bundle, install the troubleshoot kubectl plugin:
 ```
 curl https://krew.sh/support-bundle | bash
 ```
-and run, where https://support-bundle.replicated.com provides an **example** support bundle spec:
- 
+ and run, where https://support-bundle.replicated.com provides an **example** support bundle spec:
+
 ```
 kubectl support-bundle https://support-bundle.replicated.com
 ```
 
-**NOTE** this is an example. Do **not** use to validate real scenarios. 
+**NOTE** this is an example. Do **not** use to validate real scenarios.
 
 For more details on creating the custom resource files that drive support-bundle collection, visit [creating collectors](https://troubleshoot.sh/docs/collect/) and [creating analyzers](https://troubleshoot.sh/docs/analyze/).
 
@@ -47,9 +47,9 @@ And see our other tool [sbctl](https://github.com/replicatedhq/sbctl) that makes
 
 For questions about using Troubleshoot, there's a [Replicated Community](https://help.replicated.com/community) forum, and a [#app-troubleshoot channel in Kubernetes Slack](https://kubernetes.slack.com/channels/app-troubleshoot).
 
-# Software Bill of Materials 
-A signed SBOM  that includes Troubleshoot dependencies is included in each release. 
-- **troubleshoot-sbom.tgz** contains a software bill of materials for Troubleshoot. 
+# Software Bill of Materials
+A signed SBOM  that includes Troubleshoot dependencies is included in each release.
+- **troubleshoot-sbom.tgz** contains a software bill of materials for Troubleshoot.
 - **troubleshoot-sbom.tgz.sig** is the digital signature for troubleshoot-sbom.tgz
 - **key.pub** is the public key from the key pair used to sign troubleshoot-sbom.tgz
 
diff --git a/cmd/collect/cli/run.go b/cmd/collect/cli/run.go
index d5388742..dc56f1ce 100644
--- a/cmd/collect/cli/run.go
+++ b/cmd/collect/cli/run.go
@@ -91,16 +91,15 @@ func runCollect(v *viper.Viper, arg string) error {
 	troubleshootclientsetscheme.AddToScheme(scheme.Scheme)
 	decode := scheme.Codecs.UniversalDeserializer().Decode
 
-	additionalRedactors := &troubleshootv1beta2.Redactor{}
-	for idx, redactor := range v.GetStringSlice("redactors") {
-		redactorObj, err := supportbundle.GetRedactorFromURI(redactor)
-		if err != nil {
-			return errors.Wrapf(err, "failed to get redactor spec %s, #%d", redactor, idx)
-		}
+	redactors, err := supportbundle.GetRedactorsFromURIs(v.GetStringSlice("redactors"))
+	if err != nil {
+		return errors.Wrap(err, "failed to get redactors")
+	}
 
-		if redactorObj != nil {
-			additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, redactorObj.Spec.Redactors...)
-		}
+	additionalRedactors := &troubleshootv1beta2.Redactor{
+		Spec: troubleshootv1beta2.RedactorSpec{
+			Redactors: redactors,
+		},
 	}
 
 	for i, additionalDoc := range multidocs {
diff --git a/cmd/troubleshoot/cli/redact.go b/cmd/troubleshoot/cli/redact.go
new file mode 100644
index 00000000..4031a670
--- /dev/null
+++ b/cmd/troubleshoot/cli/redact.go
@@ -0,0 +1,85 @@
+package cli
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+	"github.com/pkg/errors"
+	analyzer "github.com/replicatedhq/troubleshoot/pkg/analyze"
+	"github.com/replicatedhq/troubleshoot/pkg/collect"
+	"github.com/replicatedhq/troubleshoot/pkg/logger"
+	"github.com/replicatedhq/troubleshoot/pkg/supportbundle"
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+func Redact() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "redact [urls...]",
+		Args:  cobra.MinimumNArgs(1), // TODO
+		Short: "Redact information from a generated support bundle archive",
+		Long: `Redaction is the process of masking sensitive information from collected data in a support bundle.
+This is done using rules defined in the list of redactor manifests provided in the [urls...] command line
+argument. Default built in redactors will also be run, but these would have been run when the support
+bundle was generated. After redaction, the support bundle is archived once more. The resulting file will
+be stored in the current directory in the path provided by the --output flag.
+
+The [urls...] argument is a list of either oci://.., http://.., https://.. or local paths to yaml files.
+
+For more information on redactors visit https://troubleshoot.sh/docs/redact/
+		`,
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return viper.BindPFlags(cmd.Flags())
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			v := viper.GetViper()
+
+			logger.SetQuiet(v.GetBool("quiet"))
+
+			// 1. Decode redactors from provided URLs
+			redactors, err := supportbundle.GetRedactorsFromURIs(args)
+			if err != nil {
+				return err
+			}
+
+			// 2. Download the bundle and extract it
+			tmpDir, bundleDir, err := analyzer.DownloadAndExtractSupportBundle(v.GetString("bundle"))
+			if err != nil {
+				return err
+			}
+			defer os.RemoveAll(tmpDir)
+
+			// 3. Represent bundle as a CollectorResult
+			collectorResult, err := collect.CollectorResultFromBundle(bundleDir)
+			if err != nil {
+				return err
+			}
+
+			// 4. Perform redaction on the bundle
+			err = collect.RedactResult(bundleDir, collectorResult, redactors)
+			if err != nil {
+				return errors.Wrap(err, "failed to redact support bundle")
+			}
+
+			// 5. Compress the bundle once more after redacting
+			output := v.GetString("output")
+			if output == "" {
+				output = fmt.Sprintf("redacted-support-bundle-%s.tar.gz", time.Now().Format("2006-01-02T15_04_05"))
+			}
+			err = collectorResult.ArchiveSupportBundle(bundleDir, output)
+			if err != nil {
+				return errors.Wrap(err, "failed to create support bundle archive")
+			}
+			fmt.Println("Redacted support bundle:", output)
+			return nil
+		},
+	}
+
+	cmd.Flags().String("bundle", "", "file path of the support bundle archive to redact")
+	cmd.MarkFlagRequired("bundle")
+	cmd.Flags().BoolP("quiet", "q", false, "enable/disable error messaging and only show parseable output")
+	cmd.Flags().StringP("output", "o", "", "file path of where to save the redacted support bundle archive (default \"redacted-support-bundle-YYYY-MM-DDTHH_MM_SS.tar.gz\")")
+
+	return cmd
+}
diff --git a/cmd/troubleshoot/cli/root.go b/cmd/troubleshoot/cli/root.go
index 6e12fd5a..2bf8cfac 100644
--- a/cmd/troubleshoot/cli/root.go
+++ b/cmd/troubleshoot/cli/root.go
@@ -15,7 +15,7 @@ import (
 
 func RootCmd() *cobra.Command {
 	cmd := &cobra.Command{
-		Use:   "support-bundle [url]",
+		Use:   "support-bundle [urls...]",
 		Args:  cobra.MinimumNArgs(0),
 		Short: "Generate a support bundle",
 		Long: `A support bundle is an archive of files, output, metrics and state
@@ -40,6 +40,7 @@ from a server that can be used to assist when troubleshooting a Kubernetes clust
 	cobra.OnInitialize(initConfig)
 
 	cmd.AddCommand(Analyze())
+	cmd.AddCommand(Redact())
 	cmd.AddCommand(VersionCmd())
 
 	cmd.Flags().StringSlice("redactors", []string{}, "names of the additional redactors to use")
diff --git a/cmd/troubleshoot/cli/run.go b/cmd/troubleshoot/cli/run.go
index 00359739..e5ecce6a 100644
--- a/cmd/troubleshoot/cli/run.go
+++ b/cmd/troubleshoot/cli/run.go
@@ -208,16 +208,11 @@ func runTroubleshoot(v *viper.Viper, arg []string) error {
 		return errors.New("no collectors specified in support bundle")
 	}
 
-	for idx, redactor := range v.GetStringSlice("redactors") {
-		redactorObj, err := supportbundle.GetRedactorFromURI(redactor)
-		if err != nil {
-			return errors.Wrapf(err, "failed to get redactor spec %s, #%d", redactor, idx)
-		}
-
-		if redactorObj != nil {
-			additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, redactorObj.Spec.Redactors...)
-		}
+	redactors, err := supportbundle.GetRedactorsFromURIs(v.GetStringSlice("redactors"))
+	if err != nil {
+		return errors.Wrap(err, "failed to get redactors")
 	}
+	additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, redactors...)
 
 	var collectorCB func(chan interface{}, string)
 	progressChan := make(chan interface{}) // non-zero buffer can result in missed messages
diff --git a/cmd/troubleshoot/cli/version.go b/cmd/troubleshoot/cli/version.go
index a19d384a..7baf5805 100644
--- a/cmd/troubleshoot/cli/version.go
+++ b/cmd/troubleshoot/cli/version.go
@@ -7,6 +7,7 @@ import (
 	"path/filepath"
 
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
+	"github.com/replicatedhq/troubleshoot/pkg/constants"
 	"github.com/replicatedhq/troubleshoot/pkg/version"
 	"github.com/spf13/cobra"
 	"gopkg.in/yaml.v2"
@@ -26,8 +27,6 @@ func VersionCmd() *cobra.Command {
 	return cmd
 }
 
-const VersionFilename = "version.yaml"
-
 func writeVersionFile(path string) error {
 	version := troubleshootv1beta2.SupportBundleVersion{
 		ApiVersion: "troubleshoot.sh/v1beta2",
@@ -41,7 +40,7 @@ func writeVersionFile(path string) error {
 		return err
 	}
 
-	filename := filepath.Join(path, VersionFilename)
+	filename := filepath.Join(path, constants.VersionFilename)
 	err = ioutil.WriteFile(filename, b, 0644)
 	if err != nil {
 		return err
diff --git a/docs/preflight.md b/docs/preflight.md
index d34920bf..a43ddd7f 100644
--- a/docs/preflight.md
+++ b/docs/preflight.md
@@ -17,7 +17,7 @@ preflight [url] [flags]
       --as string                      Username to impersonate for the operation. User could be a regular user or a service account in a namespace.
       --as-group stringArray           Group to impersonate for the operation, this flag can be repeated to specify multiple groups.
       --as-uid string                  UID to impersonate for the operation.
-      --cache-dir string               Default cache directory (default "/Users/xavpaice/.kube/cache")
+      --cache-dir string               Default cache directory (default "$HOME/.kube/cache")
       --certificate-authority string   Path to a cert file for the certificate authority
       --client-certificate string      Path to a client certificate file for TLS
       --client-key string              Path to a client key file for TLS
@@ -48,4 +48,4 @@ preflight [url] [flags]
 
 * [preflight version](preflight_version.md)	 - Print the current version and exit
 
-###### Auto generated by spf13/cobra on 21-Nov-2022
+###### Auto generated by spf13/cobra on 22-Dec-2022
diff --git a/docs/preflight_version.md b/docs/preflight_version.md
index 2744ea16..a26d1ea9 100644
--- a/docs/preflight_version.md
+++ b/docs/preflight_version.md
@@ -35,4 +35,4 @@ preflight version [flags]
 
 * [preflight](preflight.md)	 - Run and retrieve preflight checks in a cluster
 
-###### Auto generated by spf13/cobra on 21-Nov-2022
+###### Auto generated by spf13/cobra on 22-Dec-2022
diff --git a/docs/support-bundle.md b/docs/support-bundle.md
index c0dba200..88a68a3a 100644
--- a/docs/support-bundle.md
+++ b/docs/support-bundle.md
@@ -8,7 +8,7 @@ A support bundle is an archive of files, output, metrics and state
 from a server that can be used to assist when troubleshooting a Kubernetes cluster.
 
 ```
-support-bundle [url] [flags]
+support-bundle [urls...] [flags]
 ```
 
 ### Options
@@ -17,7 +17,7 @@ support-bundle [url] [flags]
       --as string                      Username to impersonate for the operation. User could be a regular user or a service account in a namespace.
       --as-group stringArray           Group to impersonate for the operation, this flag can be repeated to specify multiple groups.
       --as-uid string                  UID to impersonate for the operation.
-      --cache-dir string               Default cache directory (default "/Users/xavpaice/.kube/cache")
+      --cache-dir string               Default cache directory (default "$HOME/.kube/cache")
       --certificate-authority string   Path to a cert file for the certificate authority
       --client-certificate string      Path to a client certificate file for TLS
       --client-key string              Path to a client key file for TLS
@@ -47,7 +47,8 @@ support-bundle [url] [flags]
 
 ### SEE ALSO
 
-* [support-bundle analyze](support-bundle_analyze.md)  - analyze a support bundle
-* [support-bundle version](support-bundle_version.md)  - Print the current version and exit
+* [support-bundle analyze](support-bundle_analyze.md)	 - analyze a support bundle
+* [support-bundle redact](support-bundle_redact.md)	 - Redact information from a generated support bundle archive
+* [support-bundle version](support-bundle_version.md)	 - Print the current version and exit
 
-###### Auto generated by spf13/cobra on 21-Nov-2022
+###### Auto generated by spf13/cobra on 22-Dec-2022
diff --git a/docs/support-bundle_analyze.md b/docs/support-bundle_analyze.md
index 535fd9b5..b0848b83 100644
--- a/docs/support-bundle_analyze.md
+++ b/docs/support-bundle_analyze.md
@@ -23,4 +23,4 @@ support-bundle analyze [url] [flags]
 
 * [support-bundle](support-bundle.md)	 - Generate a support bundle
 
-###### Auto generated by spf13/cobra on 21-Nov-2022
+###### Auto generated by spf13/cobra on 22-Dec-2022
diff --git a/docs/support-bundle_redact.md b/docs/support-bundle_redact.md
new file mode 100644
index 00000000..c6f3f22d
--- /dev/null
+++ b/docs/support-bundle_redact.md
@@ -0,0 +1,35 @@
+## support-bundle redact
+
+Redact information from a generated support bundle archive
+
+### Synopsis
+
+Redaction is the process of masking sensitive information from collected data in a support bundle.
+This is done using rules defined in the list of redactor manifests provided in the [urls...] command line
+argument. Default built in redactors will also be run, but these would have been run when the support
+bundle was generated. After redaction, the support bundle is archived once more. The resulting file will
+be stored in the current directory in the path provided by the --output flag.
+
+The [urls...] argument is a list of either oci://.., http://.., https://.. or local paths to yaml files.
+
+For more information on redactors visit https://troubleshoot.sh/docs/redact/
+
+
+```
+support-bundle redact [urls...] [flags]
+```
+
+### Options
+
+```
+      --bundle string   file path of the support bundle archive to redact
+  -h, --help            help for redact
+  -o, --output string   file path of where to save the redacted support bundle archive (default "redacted-support-bundle-YYYY-MM-DDTHH_MM_SS.tar.gz")
+  -q, --quiet           enable/disable error messaging and only show parseable output
+```
+
+### SEE ALSO
+
+* [support-bundle](support-bundle.md)	 - Generate a support bundle
+
+###### Auto generated by spf13/cobra on 22-Dec-2022
diff --git a/docs/support-bundle_version.md b/docs/support-bundle_version.md
index 30cf864e..bfa3b7a0 100644
--- a/docs/support-bundle_version.md
+++ b/docs/support-bundle_version.md
@@ -20,4 +20,4 @@ support-bundle version [flags]
 
 * [support-bundle](support-bundle.md)	 - Generate a support bundle
 
-###### Auto generated by spf13/cobra on 21-Nov-2022
+###### Auto generated by spf13/cobra on 22-Dec-2022
diff --git a/pkg/analyze/download.go b/pkg/analyze/download.go
index 6b5cbe7c..2c0b112a 100644
--- a/pkg/analyze/download.go
+++ b/pkg/analyze/download.go
@@ -4,7 +4,6 @@ import (
 	"archive/tar"
 	"compress/gzip"
 	"io"
-	"io/ioutil"
 	"os"
 	"path/filepath"
 
@@ -12,6 +11,7 @@ import (
 	"github.com/pkg/errors"
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 	troubleshootscheme "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme"
+	"github.com/replicatedhq/troubleshoot/pkg/constants"
 	"github.com/replicatedhq/troubleshoot/pkg/docrewrite"
 	"github.com/replicatedhq/troubleshoot/pkg/logger"
 	"k8s.io/client-go/kubernetes/scheme"
@@ -55,27 +55,13 @@ func AnalyzeLocal(localBundlePath string, analyzers []*troubleshootv1beta2.Analy
 }
 
 func DownloadAndAnalyze(bundleURL string, analyzersSpec string) ([]*AnalyzeResult, error) {
-	tmpDir, err := ioutil.TempDir("", "troubleshoot-k8s")
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to create temp dir")
-	}
-	defer os.RemoveAll(tmpDir)
-
-	if err := downloadTroubleshootBundle(bundleURL, tmpDir); err != nil {
-		return nil, errors.Wrap(err, "failed to download bundle")
-	}
-
-	rootDir, err := FindBundleRootDir(tmpDir)
+	tmpDir, rootDir, err := DownloadAndExtractSupportBundle(bundleURL)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to find root dir")
 	}
+	defer os.RemoveAll(tmpDir)
 
-	_, err = os.Stat(filepath.Join(rootDir, "version.yaml"))
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to read version.yaml")
-	}
-
-	analyzers := []*troubleshootv1beta2.Analyze{}
+	var analyzers []*troubleshootv1beta2.Analyze
 	hostAnalyzers := []*troubleshootv1beta2.HostAnalyze{}
 
 	if analyzersSpec == "" {
@@ -96,7 +82,34 @@ func DownloadAndAnalyze(bundleURL string, analyzersSpec string) ([]*AnalyzeResul
 	return AnalyzeLocal(rootDir, analyzers, hostAnalyzers)
 }
 
+func DownloadAndExtractSupportBundle(bundleURL string) (string, string, error) {
+	tmpDir, err := os.MkdirTemp("", "troubleshoot-k8s")
+	if err != nil {
+		return "", "", errors.Wrap(err, "failed to create temp dir")
+	}
+
+	if err := downloadTroubleshootBundle(bundleURL, tmpDir); err != nil {
+		os.RemoveAll(tmpDir)
+		return "", "", errors.Wrap(err, "failed to download bundle")
+	}
+
+	bundleDir, err := FindBundleRootDir(tmpDir)
+	if err != nil {
+		os.RemoveAll(tmpDir)
+		return "", "", errors.Wrap(err, "failed to find root dir")
+	}
+
+	_, err = os.Stat(filepath.Join(bundleDir, constants.VersionFilename))
+	if err != nil {
+		os.RemoveAll(tmpDir)
+		return "", "", errors.Wrap(err, "failed to read "+constants.VersionFilename)
+	}
+
+	return tmpDir, bundleDir, nil
+}
+
 func downloadTroubleshootBundle(bundleURL string, destDir string) error {
+	// TODO: Move to separate package support bundle utils package
 	if bundleURL[0] == os.PathSeparator {
 		f, err := os.Open(bundleURL)
 		if err != nil {
@@ -111,7 +124,7 @@ func downloadTroubleshootBundle(bundleURL string, destDir string) error {
 		return errors.Wrap(err, "failed to get workdir")
 	}
 
-	tmpDir, err := ioutil.TempDir("", "troubleshoot")
+	tmpDir, err := os.MkdirTemp("", "troubleshoot")
 	if err != nil {
 		return errors.Wrap(err, "failed to create tmp dir")
 	}
@@ -137,6 +150,8 @@ func downloadTroubleshootBundle(bundleURL string, destDir string) error {
 }
 
 func ExtractTroubleshootBundle(reader io.Reader, destDir string) error {
+	// TODO: Move to separate package e.g support bundle package, or sbutils
+	// if there are cyclic dependencies
 	gzReader, err := gzip.NewReader(reader)
 	if err != nil {
 		return errors.Wrap(err, "failed to create gzip reader")
@@ -159,14 +174,14 @@ func ExtractTroubleshootBundle(reader io.Reader, destDir string) error {
 				return errors.Wrap(err, "failed to mkdir")
 			}
 		case tar.TypeReg:
-			name := filepath.Join(destDir, header.Name)
+			destFileName := filepath.Join(destDir, header.Name)
 
-			dirName := filepath.Dir(name)
+			dirName := filepath.Dir(destFileName)
 			if err := os.MkdirAll(dirName, 0755); err != nil {
 				return errors.Wrapf(err, "failed to mkdir for file %s", header.Name)
 			}
 
-			file, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE, os.FileMode(header.Mode))
+			file, err := os.OpenFile(destFileName, os.O_RDWR|os.O_CREATE, os.FileMode(header.Mode))
 			if err != nil {
 				return errors.Wrap(err, "failed to open tar file")
 			}
@@ -175,6 +190,21 @@ func ExtractTroubleshootBundle(reader io.Reader, destDir string) error {
 			if err != nil {
 				return errors.Wrap(err, "failed to extract file")
 			}
+		case tar.TypeSymlink:
+			destFileName := filepath.Join(destDir, header.Name)
+
+			dirName := filepath.Dir(destFileName)
+			if err := os.MkdirAll(dirName, 0755); err != nil {
+				return errors.Wrapf(err, "failed to mkdir for symlink %s", header.Name)
+			}
+
+			// Symlink targets should be absolute paths after extraction
+			// for other parts of the code to work correctly e.g redaction, CollectorResult
+			targetPath := filepath.Join(filepath.Dir(destFileName), header.Linkname)
+			err = os.Symlink(targetPath, destFileName)
+			if err != nil {
+				return errors.Wrap(err, "failed to create symlink")
+			}
 		}
 	}
 
@@ -251,7 +281,7 @@ func FindBundleRootDir(localBundlePath string) (string, error) {
 
 	isInSubDir := true
 	for _, name := range names {
-		if name == "version.yaml" {
+		if name == constants.VersionFilename {
 			isInSubDir = false
 			break
 		}
@@ -265,7 +295,7 @@ func FindBundleRootDir(localBundlePath string) (string, error) {
 }
 
 func (f fileContentProvider) getFileContents(fileName string) ([]byte, error) {
-	return ioutil.ReadFile(filepath.Join(f.rootDir, fileName))
+	return os.ReadFile(filepath.Join(f.rootDir, fileName))
 }
 
 func excludeFilePaths(files, excludeFiles []string) []string {
@@ -306,7 +336,7 @@ func (f fileContentProvider) getChildFileContents(dirName string, excludeFiles [
 
 	fileArr := map[string][]byte{}
 	for _, filePath := range files {
-		bytes, err := ioutil.ReadFile(filePath)
+		bytes, err := os.ReadFile(filePath)
 		if err != nil {
 			return nil, errors.Wrapf(err, "read %q", filePath)
 		}
diff --git a/pkg/collect/redact.go b/pkg/collect/redact.go
index 376d94e4..aa6b027e 100644
--- a/pkg/collect/redact.go
+++ b/pkg/collect/redact.go
@@ -13,6 +13,7 @@ import (
 	"github.com/pkg/errors"
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 	"github.com/replicatedhq/troubleshoot/pkg/redact"
+	"k8s.io/klog/v2"
 )
 
 func RedactResult(bundlePath string, input CollectorResult, additionalRedactors []*troubleshootv1beta2.Redact) error {
@@ -32,11 +33,25 @@ func RedactResult(bundlePath string, input CollectorResult, additionalRedactors
 			}
 
 			// Redact the target file of a symlink
+			// There is an opportunity for improving performance here by skipping symlinks
+			// if a target has been redacted already, but that would require
+			// some extra logic to ensure that a spec filtering only symlinks still works.
 			if info.Mode().Type() == os.ModeSymlink {
-				file, err = os.Readlink(filepath.Join(bundlePath, file))
+				symlink := file
+				target, err := os.Readlink(filepath.Join(bundlePath, symlink))
 				if err != nil {
 					return errors.Wrap(err, "failed to read symlink")
 				}
+
+				// Get the relative path to the target file to conform with
+				// the path formats of the CollectorResult
+				file, err = filepath.Rel(bundlePath, target)
+				if err != nil {
+					return errors.Wrap(err, "failed to get relative path")
+				}
+				klog.V(4).Infof("Redacting %s (symlink => %s)\n", file, symlink)
+			} else {
+				klog.V(4).Infof("Redacting %s\n", file)
 			}
 			r, err := input.GetReader(bundlePath, file)
 			if err != nil {
@@ -53,8 +68,8 @@ func RedactResult(bundlePath string, input CollectorResult, additionalRedactors
 			reader = bytes.NewBuffer(v)
 		}
 
-		//If the file is .tar, .tgz or .tar.gz, it must not be redacted. Instead it is decompressed and each file inside the
-		//tar is decompressed, redacted and compressed back into the tar.
+		// If the file is .tar, .tgz or .tar.gz, it must not be redacted. Instead it is
+		// decompressed and each file inside the tar redacted and compressed back into the archive.
 		if filepath.Ext(file) == ".tar" || filepath.Ext(file) == ".tgz" || strings.HasSuffix(file, ".tar.gz") {
 			tmpDir, err := ioutil.TempDir("", "troubleshoot-subresult-")
 			if err != nil {
diff --git a/pkg/collect/result.go b/pkg/collect/result.go
index 3ee3f61f..d21c2007 100644
--- a/pkg/collect/result.go
+++ b/pkg/collect/result.go
@@ -65,7 +65,7 @@ func (r CollectorResult) SymLinkResult(bundlePath, relativeLinkPath, relativeFil
 	// Create the symlink
 	// NOTE: When creating an archive, relative paths are used
 	// to make the bundle more portable. That implementation
-	// lives in TarSupportBundleDir function. This path needs to
+	// lives in CollectorResultFromBundle function. This path needs to
 	// remain as-is to support memory only bundles e.g preflight
 	err = os.Symlink(filePath, linkPath)
 	if err != nil {
@@ -222,7 +222,7 @@ func (r CollectorResult) CloseWriter(bundlePath string, relativePath string, wri
 	return errors.Errorf("cannot close writer of type %T", writer)
 }
 
-func TarSupportBundleDir(bundlePath string, input CollectorResult, outputFilename string) error {
+func (r CollectorResult) ArchiveSupportBundle(bundlePath string, outputFilename string) error {
 	fileWriter, err := os.Create(outputFilename)
 	if err != nil {
 		return errors.Wrap(err, "failed to create output file")
@@ -235,7 +235,7 @@ func TarSupportBundleDir(bundlePath string, input CollectorResult, outputFilenam
 	tarWriter := tar.NewWriter(gzipWriter)
 	defer tarWriter.Close()
 
-	for relativeName := range input {
+	for relativeName := range r {
 		filename := filepath.Join(bundlePath, relativeName)
 		info, err := os.Lstat(filename)
 		if err != nil {
@@ -315,3 +315,44 @@ func TarSupportBundleDir(bundlePath string, input CollectorResult, outputFilenam
 
 	return nil
 }
+
+// CollectorResultFromBundle creates a CollectorResult from a bundle directory
+// The bundle directory is not necessarily a support bundle, it can be any directory
+// of collected files as part of other operations or files that are already on disk.
+func CollectorResultFromBundle(bundleDir string) (CollectorResult, error) {
+	// Check directory exists
+	if _, err := os.Stat(bundleDir); os.IsNotExist(err) {
+		return nil, errors.Wrap(err, "bundle directory does not exist")
+	}
+
+	// Walk the directory and add all files to the collector result
+	result := make(CollectorResult)
+	err := filepath.Walk(bundleDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if info.IsDir() {
+			return nil
+		}
+
+		rel, err := filepath.Rel(bundleDir, path)
+		if err != nil {
+			return err
+		}
+
+		result[rel] = nil
+		return nil
+	})
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to walk bundle directory")
+	}
+
+	return result, nil
+}
+
+// TarSupportBundleDir wraps ArchiveSupportBundle for backwards compatibility
+// Deprecated: Remove in a future version (v1.0)
+func TarSupportBundleDir(bundlePath string, input CollectorResult, outputFilename string) error {
+	// Is this used anywhere external anyway?
+	return input.ArchiveSupportBundle(bundlePath, outputFilename)
+}
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 4f140761..9a991465 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -7,4 +7,6 @@ const (
 	DEFAULT_CLIENT_BURST = 100
 	// DEFAULT_CLIENT_USER_AGENT is an field that specifies the caller of troubleshoot request.
 	DEFAULT_CLIENT_USER_AGENT = "ReplicatedTroubleshoot"
+	// VersionFilename is the name of the file that contains the support bundle version.
+	VersionFilename = "version.yaml"
 )
diff --git a/pkg/preflight/collect.go b/pkg/preflight/collect.go
index 93ff31aa..73382218 100644
--- a/pkg/preflight/collect.go
+++ b/pkg/preflight/collect.go
@@ -84,7 +84,9 @@ func (cr RemoteCollectResult) IsRBACAllowed() bool {
 // CollectHost runs the collection phase of host preflight checks
 func CollectHost(opts CollectOpts, p *troubleshootv1beta2.HostPreflight) (CollectResult, error) {
 	collectSpecs := make([]*troubleshootv1beta2.HostCollect, 0, 0)
-	collectSpecs = append(collectSpecs, p.Spec.Collectors...)
+	if p != nil && p.Spec.Collectors != nil {
+		collectSpecs = append(collectSpecs, p.Spec.Collectors...)
+	}
 
 	allCollectedData := make(map[string][]byte)
 
@@ -128,7 +130,9 @@ func Collect(opts CollectOpts, p *troubleshootv1beta2.Preflight) (CollectResult,
 	var foundForbidden bool
 
 	collectSpecs := make([]*troubleshootv1beta2.Collect, 0, 0)
-	collectSpecs = append(collectSpecs, p.Spec.Collectors...)
+	if p != nil && p.Spec.Collectors != nil {
+		collectSpecs = append(collectSpecs, p.Spec.Collectors...)
+	}
 	collectSpecs = collect.EnsureCollectorInList(collectSpecs, troubleshootv1beta2.Collect{ClusterInfo: &troubleshootv1beta2.ClusterInfo{}})
 	collectSpecs = collect.EnsureCollectorInList(collectSpecs, troubleshootv1beta2.Collect{ClusterResources: &troubleshootv1beta2.ClusterResources{}})
 	collectSpecs = collect.EnsureClusterResourcesFirst(collectSpecs)
@@ -267,7 +271,9 @@ func Collect(opts CollectOpts, p *troubleshootv1beta2.Preflight) (CollectResult,
 // Collect runs the collection phase of preflight checks
 func CollectRemote(opts CollectOpts, p *troubleshootv1beta2.HostPreflight) (CollectResult, error) {
 	collectSpecs := make([]*troubleshootv1beta2.RemoteCollect, 0, 0)
-	collectSpecs = append(collectSpecs, p.Spec.RemoteCollectors...)
+	if p != nil && p.Spec.RemoteCollectors != nil {
+		collectSpecs = append(collectSpecs, p.Spec.RemoteCollectors...)
+	}
 
 	allCollectedData := make(map[string][]byte)
 
diff --git a/pkg/preflight/run.go b/pkg/preflight/run.go
index 19ded90e..64841ed6 100644
--- a/pkg/preflight/run.go
+++ b/pkg/preflight/run.go
@@ -132,7 +132,7 @@ func RunPreflights(interactive bool, output string, format string, args []string
 					preflightSpec = ConcatPreflightSpec(preflightSpec, spec)
 				}
 			} else {
-				uploadResultSpecs = append(uploadResultSpecs, preflightSpec)
+				uploadResultSpecs = append(uploadResultSpecs, spec)
 			}
 		} else if spec, ok := obj.(*troubleshootv1beta2.HostPreflight); ok {
 			if i == 0 {
diff --git a/pkg/supportbundle/collect.go b/pkg/supportbundle/collect.go
index fc490e90..cb81b453 100644
--- a/pkg/supportbundle/collect.go
+++ b/pkg/supportbundle/collect.go
@@ -185,8 +185,6 @@ func findFileName(basename, extension string) (string, error) {
 	}
 }
 
-const VersionFilename = "version.yaml"
-
 func getVersionFile() (io.Reader, error) {
 	version := troubleshootv1beta2.SupportBundleVersion{
 		ApiVersion: "troubleshoot.sh/v1beta2",
diff --git a/pkg/supportbundle/load.go b/pkg/supportbundle/load.go
index e773d5b2..65988f73 100644
--- a/pkg/supportbundle/load.go
+++ b/pkg/supportbundle/load.go
@@ -105,24 +105,15 @@ func ParseSupportBundleFromDoc(doc []byte) (*troubleshootv1beta2.SupportBundle,
 }
 
 func GetRedactorFromURI(redactorURI string) (*troubleshootv1beta2.Redactor, error) {
-	decode := scheme.Codecs.UniversalDeserializer().Decode
-
 	redactorContent, err := LoadRedactorSpec(redactorURI)
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to load redactor spec %s", redactorURI)
 	}
 
-	redactorContent, err = docrewrite.ConvertToV1Beta2(redactorContent)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to convert to v1beta2")
-	}
-
-	obj, _, err := decode([]byte(redactorContent), nil, nil)
+	redactor, ok, err := toRedactGVK([]byte(redactorContent))
 	if err != nil {
-		return nil, errors.Wrapf(err, "failed to parse redactors %s", redactorURI)
+		return nil, errors.Wrapf(err, "failed to parse redactor from doc")
 	}
-
-	redactor, ok := obj.(*troubleshootv1beta2.Redactor)
 	if !ok {
 		return nil, fmt.Errorf("%s is not a troubleshootv1beta2 redactor type", redactorURI)
 	}
@@ -130,6 +121,22 @@ func GetRedactorFromURI(redactorURI string) (*troubleshootv1beta2.Redactor, erro
 	return redactor, nil
 }
 
+func GetRedactorsFromURIs(redactorURIs []string) ([]*troubleshootv1beta2.Redact, error) {
+	redactors := []*troubleshootv1beta2.Redact{}
+	for _, redactor := range redactorURIs {
+		redactorObj, err := GetRedactorFromURI(redactor)
+		if err != nil {
+			return nil, err
+		}
+
+		if redactorObj != nil {
+			redactors = append(redactors, redactorObj.Spec.Redactors...)
+		}
+	}
+
+	return redactors, nil
+}
+
 func LoadSupportBundleSpec(arg string) ([]byte, error) {
 	if strings.HasPrefix(arg, "secret/") {
 		// format secret/namespace-name/secret-name
@@ -251,25 +258,32 @@ func loadSpecFromURL(arg string) ([]byte, error) {
 func ParseRedactorsFromDocs(docs []string) ([]*troubleshootv1beta2.Redact, error) {
 	var redactors []*troubleshootv1beta2.Redact
 
-	decode := scheme.Codecs.UniversalDeserializer().Decode
-
 	for i, doc := range docs {
-		doc, err := docrewrite.ConvertToV1Beta2([]byte(doc))
-		if err != nil {
-			return nil, errors.Wrap(err, "failed to convert to v1beta2")
-		}
-
-		obj, _, err := decode(doc, nil, nil)
+		multidocRedactors, ok, err := toRedactGVK([]byte(doc))
 		if err != nil {
 			return nil, errors.Wrapf(err, "failed to parse redactor from doc %d", i)
 		}
-
-		multidocRedactors, ok := obj.(*troubleshootv1beta2.Redactor)
 		if !ok {
 			continue
 		}
+
 		redactors = append(redactors, multidocRedactors.Spec.Redactors...)
 	}
 
 	return redactors, nil
 }
+
+func toRedactGVK(doc []byte) (*troubleshootv1beta2.Redactor, bool, error) {
+	doc, err := docrewrite.ConvertToV1Beta2(doc)
+	if err != nil {
+		return nil, false, errors.Wrap(err, "failed to convert to v1beta2")
+	}
+
+	obj, _, err := scheme.Codecs.UniversalDeserializer().Decode([]byte(doc), nil, nil)
+	if err != nil {
+		return nil, false, err
+	}
+
+	multidocRedactors, ok := obj.(*troubleshootv1beta2.Redactor)
+	return multidocRedactors, ok, nil
+}
diff --git a/pkg/supportbundle/supportbundle.go b/pkg/supportbundle/supportbundle.go
index aca3b380..8061fe40 100644
--- a/pkg/supportbundle/supportbundle.go
+++ b/pkg/supportbundle/supportbundle.go
@@ -14,6 +14,7 @@ import (
 	analyzer "github.com/replicatedhq/troubleshoot/pkg/analyze"
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 	"github.com/replicatedhq/troubleshoot/pkg/collect"
+	"github.com/replicatedhq/troubleshoot/pkg/constants"
 	"github.com/replicatedhq/troubleshoot/pkg/convert"
 	"k8s.io/client-go/rest"
 	"k8s.io/klog/v2"
@@ -127,7 +128,7 @@ func CollectSupportBundleFromSpec(spec *troubleshootv1beta2.SupportBundleSpec, a
 		return nil, errors.Wrap(err, "failed to get version file")
 	}
 
-	err = result.SaveResult(bundlePath, VersionFilename, version)
+	err = result.SaveResult(bundlePath, constants.VersionFilename, version)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to write version")
 	}
@@ -155,7 +156,7 @@ func CollectSupportBundleFromSpec(spec *troubleshootv1beta2.SupportBundleSpec, a
 		return nil, errors.Wrap(err, "failed to write analysis")
 	}
 
-	if err := collect.TarSupportBundleDir(bundlePath, result, filename); err != nil {
+	if err := result.ArchiveSupportBundle(bundlePath, filename); err != nil {
 		return nil, errors.Wrap(err, "create bundle file")
 	}
 
@@ -188,17 +189,12 @@ func CollectSupportBundleFromURI(specURI string, redactorURIs []string, opts Sup
 		return nil, errors.Wrap(err, "could not bundle from URI")
 	}
 
-	additionalRedactors := &troubleshootv1beta2.Redactor{}
-	for _, redactor := range redactorURIs {
-		redactorObj, err := GetRedactorFromURI(redactor)
-		if err != nil {
-			return nil, errors.Wrapf(err, "failed to get redactor spec %s", redactor)
-		}
-
-		if redactorObj != nil {
-			additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, redactorObj.Spec.Redactors...)
-		}
+	redactors, err := GetRedactorsFromURIs(redactorURIs)
+	if err != nil {
+		return nil, err
 	}
+	additionalRedactors := &troubleshootv1beta2.Redactor{}
+	additionalRedactors.Spec.Redactors = redactors
 
 	return CollectSupportBundleFromSpec(&supportBundle.Spec, additionalRedactors, opts)
 }


Test patch:
diff --git a/examples/redact/e2e.yaml b/examples/redact/e2e.yaml
new file mode 100644
index 00000000..65b3833f
--- /dev/null
+++ b/examples/redact/e2e.yaml
@@ -0,0 +1,10 @@
+apiVersion: troubleshoot.sh/v1beta2
+kind: Redactor
+metadata:
+  name: e2e-redactor
+spec:
+  redactors:
+    - name: redact-static-text
+      removals:
+        values:
+        - static
diff --git a/internal/testutils/utils.go b/internal/testutils/utils.go
new file mode 100644
index 00000000..cbb0a0f9
--- /dev/null
+++ b/internal/testutils/utils.go
@@ -0,0 +1,24 @@
+package testutils
+
+import (
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func GetTestFixture(t *testing.T, path string) string {
+	t.Helper()
+	p := filepath.Join("../../testdata", path)
+	b, err := os.ReadFile(p)
+	require.NoError(t, err)
+	return string(b)
+}
+
+// FileDir returns the directory of the current source file.
+func FileDir() string {
+	_, filename, _, _ := runtime.Caller(0)
+	return filepath.Dir(filename)
+}
diff --git a/pkg/analyze/download_test.go b/pkg/analyze/download_test.go
new file mode 100644
index 00000000..2b76d636
--- /dev/null
+++ b/pkg/analyze/download_test.go
@@ -0,0 +1,49 @@
+package analyzer
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/replicatedhq/troubleshoot/internal/testutils"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestDownloadAndExtractSupportBundle(t *testing.T) {
+	// TODO: Add tests for web url downloads
+	tests := []struct {
+		name      string
+		bundleURL string
+		wantErr   bool
+	}{
+		{
+			name:      "extract a bundle from a local file path",
+			bundleURL: filepath.Join(testutils.FileDir(), "../../testdata/supportbundle/support-bundle.tar.gz"),
+			wantErr:   false,
+		},
+		{
+			name:      "extract a bundle from a non-existent file path",
+			bundleURL: "/home/someone/gibberish",
+			wantErr:   true,
+		},
+		{
+			name:      "extract an invalid support bundle which has no version file",
+			bundleURL: filepath.Join(testutils.FileDir(), "../../testdata/supportbundle/missing-version.tar.gz"),
+			wantErr:   true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			tmpDir, bundleDir, err := DownloadAndExtractSupportBundle(tt.bundleURL)
+			defer os.RemoveAll(tmpDir) // clean up. Ignore error
+
+			if err == nil {
+				assert.DirExists(t, bundleDir)
+				assert.FileExists(t, filepath.Join(bundleDir, "version.yaml"))
+			} else {
+				assert.Equal(t, "", tmpDir)
+				assert.Equal(t, "", bundleDir)
+			}
+		})
+	}
+}
diff --git a/pkg/collect/postgres_test.go b/pkg/collect/postgres_test.go
index d2ddb3e1..476666f0 100644
--- a/pkg/collect/postgres_test.go
+++ b/pkg/collect/postgres_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"testing"
 
+	"github.com/replicatedhq/troubleshoot/internal/testutils"
 	"github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -101,9 +102,9 @@ func TestCollectPostgres_createConnectConfigTLS(t *testing.T) {
 		Collector: &v1beta2.Database{
 			URI: "postgresql://user:password@my-pghost:5432/defaultdb?sslmode=require",
 			TLS: &v1beta2.TLSParams{
-				CACert:     getTestFixture(t, "db/ca.pem"),
-				ClientCert: getTestFixture(t, "db/client.pem"),
-				ClientKey:  getTestFixture(t, "db/client-key.pem"),
+				CACert:     testutils.GetTestFixture(t, "db/ca.pem"),
+				ClientCert: testutils.GetTestFixture(t, "db/client.pem"),
+				ClientKey:  testutils.GetTestFixture(t, "db/client-key.pem"),
 			},
 		},
 	}
diff --git a/pkg/collect/redis_test.go b/pkg/collect/redis_test.go
index debee832..e3a1c3e9 100644
--- a/pkg/collect/redis_test.go
+++ b/pkg/collect/redis_test.go
@@ -3,6 +3,7 @@ package collect
 import (
 	"testing"
 
+	"github.com/replicatedhq/troubleshoot/internal/testutils"
 	v1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -90,9 +91,9 @@ func TestCollectRedis_createTLSClient(t *testing.T) {
 		Collector: &v1beta2.Database{
 			URI: "redis://localhost:6379",
 			TLS: &v1beta2.TLSParams{
-				CACert:     getTestFixture(t, "db/ca.pem"),
-				ClientCert: getTestFixture(t, "db/client.pem"),
-				ClientKey:  getTestFixture(t, "db/client-key.pem"),
+				CACert:     testutils.GetTestFixture(t, "db/ca.pem"),
+				ClientCert: testutils.GetTestFixture(t, "db/client.pem"),
+				ClientKey:  testutils.GetTestFixture(t, "db/client-key.pem"),
 			},
 		},
 	}
diff --git a/pkg/collect/result_test.go b/pkg/collect/result_test.go
index d3cdb63a..b06f7026 100644
--- a/pkg/collect/result_test.go
+++ b/pkg/collect/result_test.go
@@ -1,8 +1,10 @@
 package collect
 
 import (
+	"path/filepath"
 	"testing"
 
+	"github.com/replicatedhq/troubleshoot/internal/testutils"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -16,3 +18,36 @@ func TestCollectorResult_AddResult(t *testing.T) {
 	assert.Equal(t, []byte("a"), r["a"])
 	assert.Equal(t, []byte("b"), r["b"])
 }
+
+func TestCollectorResultFromBundle(t *testing.T) {
+	tests := []struct {
+		name      string
+		bundleDir string
+		want      CollectorResult
+		wantErr   bool
+	}{
+		{
+			name:      "creates collector results from a bundle successfully",
+			bundleDir: filepath.Join(testutils.FileDir(), "../../testdata/supportbundle/extracted-sb"),
+			want: CollectorResult{
+				"cluster-resources/pods/logs/default/static-hi/static-hi.log": nil,
+				"static-hi.log": nil,
+			},
+			wantErr: false,
+		},
+		{
+			name:      "fails to create collector results from a missing directory",
+			bundleDir: "gibberish",
+			want:      nil,
+			wantErr:   true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := CollectorResultFromBundle(tt.bundleDir)
+			assert.Equal(t, tt.want, got)
+			assert.Equal(t, (err != nil), tt.wantErr)
+		})
+	}
+}
diff --git a/pkg/collect/util_test.go b/pkg/collect/util_test.go
index 80783193..beffbdef 100644
--- a/pkg/collect/util_test.go
+++ b/pkg/collect/util_test.go
@@ -3,10 +3,9 @@ package collect
 import (
 	"context"
 	"math/rand"
-	"os"
-	"path/filepath"
 	"testing"
 
+	"github.com/replicatedhq/troubleshoot/internal/testutils"
 	"github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 	troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
 	"github.com/stretchr/testify/assert"
@@ -130,18 +129,18 @@ func Test_createTLSConfig(t *testing.T) {
 		{
 			name: "complete tls params creates config successfully",
 			tlsParams: v1beta2.TLSParams{
-				CACert:     getTestFixture(t, "db/ca.pem"),
-				ClientCert: getTestFixture(t, "db/client.pem"),
-				ClientKey:  getTestFixture(t, "db/client-key.pem"),
+				CACert:     testutils.GetTestFixture(t, "db/ca.pem"),
+				ClientCert: testutils.GetTestFixture(t, "db/client.pem"),
+				ClientKey:  testutils.GetTestFixture(t, "db/client-key.pem"),
 			},
 		},
 		{
 			name: "complete tls params in secret creates config successfully",
 			tlsParams: v1beta2.TLSParams{
 				Secret: createTLSSecret(t, k8sClient, map[string]string{
-					"cacert":     getTestFixture(t, "db/ca.pem"),
-					"clientCert": getTestFixture(t, "db/client.pem"),
-					"clientKey":  getTestFixture(t, "db/client-key.pem"),
+					"cacert":     testutils.GetTestFixture(t, "db/ca.pem"),
+					"clientCert": testutils.GetTestFixture(t, "db/client.pem"),
+					"clientKey":  testutils.GetTestFixture(t, "db/client-key.pem"),
 				}),
 			},
 		},
@@ -155,7 +154,7 @@ func Test_createTLSConfig(t *testing.T) {
 			name: "tls params with CA cert only in secret creates config successfully",
 			tlsParams: v1beta2.TLSParams{
 				Secret: createTLSSecret(t, k8sClient, map[string]string{
-					"cacert": getTestFixture(t, "db/ca.pem"),
+					"cacert": testutils.GetTestFixture(t, "db/ca.pem"),
 				}),
 			},
 			caCertOnly: true,
@@ -163,7 +162,7 @@ func Test_createTLSConfig(t *testing.T) {
 		{
 			name: "tls params with CA cert only creates config successfully",
 			tlsParams: v1beta2.TLSParams{
-				CACert: getTestFixture(t, "db/ca.pem"),
+				CACert: testutils.GetTestFixture(t, "db/ca.pem"),
 			},
 			caCertOnly: true,
 		},
@@ -174,24 +173,24 @@ func Test_createTLSConfig(t *testing.T) {
 		{
 			name: "missing CA cert fails to create config with error",
 			tlsParams: v1beta2.TLSParams{
-				ClientCert: getTestFixture(t, "db/client.pem"),
-				ClientKey:  getTestFixture(t, "db/client-key.pem"),
+				ClientCert: testutils.GetTestFixture(t, "db/client.pem"),
+				ClientKey:  testutils.GetTestFixture(t, "db/client-key.pem"),
 			},
 			hasError: true,
 		},
 		{
 			name: "missing client cert fails to create config with error",
 			tlsParams: v1beta2.TLSParams{
-				CACert:    getTestFixture(t, "db/ca.pem"),
-				ClientKey: getTestFixture(t, "db/client-key.pem"),
+				CACert:    testutils.GetTestFixture(t, "db/ca.pem"),
+				ClientKey: testutils.GetTestFixture(t, "db/client-key.pem"),
 			},
 			hasError: true,
 		},
 		{
 			name: "missing client key fails to create config with error",
 			tlsParams: v1beta2.TLSParams{
-				CACert:     getTestFixture(t, "db/ca.pem"),
-				ClientCert: getTestFixture(t, "db/client.pem"),
+				CACert:     testutils.GetTestFixture(t, "db/ca.pem"),
+				ClientCert: testutils.GetTestFixture(t, "db/client.pem"),
 			},
 			hasError: true,
 		},
@@ -199,8 +198,8 @@ func Test_createTLSConfig(t *testing.T) {
 			name: "missing CA cert in secret fails to create config with error",
 			tlsParams: v1beta2.TLSParams{
 				Secret: createTLSSecret(t, k8sClient, map[string]string{
-					"clientCert": getTestFixture(t, "db/client.pem"),
-					"clientKey":  getTestFixture(t, "db/client-key.pem"),
+					"clientCert": testutils.GetTestFixture(t, "db/client.pem"),
+					"clientKey":  testutils.GetTestFixture(t, "db/client-key.pem"),
 				}),
 			},
 			hasError: true,
@@ -209,8 +208,8 @@ func Test_createTLSConfig(t *testing.T) {
 			name: "missing client cert in secret fails to create config with error",
 			tlsParams: v1beta2.TLSParams{
 				Secret: createTLSSecret(t, k8sClient, map[string]string{
-					"cacert":    getTestFixture(t, "db/ca.pem"),
-					"clientKey": getTestFixture(t, "db/client-key.pem"),
+					"cacert":    testutils.GetTestFixture(t, "db/ca.pem"),
+					"clientKey": testutils.GetTestFixture(t, "db/client-key.pem"),
 				}),
 			},
 			hasError: true,
@@ -219,8 +218,8 @@ func Test_createTLSConfig(t *testing.T) {
 			name: "missing client key in secret fails to create config with error",
 			tlsParams: v1beta2.TLSParams{
 				Secret: createTLSSecret(t, k8sClient, map[string]string{
-					"cacert":     getTestFixture(t, "db/ca.pem"),
-					"clientCert": getTestFixture(t, "db/client.pem"),
+					"cacert":     testutils.GetTestFixture(t, "db/ca.pem"),
+					"clientCert": testutils.GetTestFixture(t, "db/client.pem"),
 				}),
 			},
 			hasError: true,
@@ -287,11 +286,3 @@ func createTLSSecret(t *testing.T, client kubernetes.Interface, secretData map[s
 		Name:      secretName,
 	}
 }
-
-func getTestFixture(t *testing.T, path string) string {
-	t.Helper()
-	p := filepath.Join("../../testdata", path)
-	b, err := os.ReadFile(p)
-	require.NoError(t, err)
-	return string(b)
-}
diff --git a/test/validate-support-bundle-e2e.sh b/test/validate-support-bundle-e2e.sh
index f1a7941c..7c9300af 100755
--- a/test/validate-support-bundle-e2e.sh
+++ b/test/validate-support-bundle-e2e.sh
@@ -6,23 +6,23 @@ tmpdir="$(mktemp -d)"
 bundle_archive_name="support-bundle.tar.gz"
 bundle_directory_name="support-bundle"
 
+echo "====== Generating support bundle from k8s cluster ======"
 ./bin/support-bundle --debug --interactive=false examples/support-bundle/e2e.yaml --output=$tmpdir/$bundle_archive_name
 if [ $? -ne 0 ]; then
     echo "support-bundle command failed"
-    exit $EXIT_STATUS
+    exit $?
 fi
 
-EXIT_STATUS=0
 if ! tar -xvzf $tmpdir/$bundle_archive_name --directory $tmpdir; then
-echo "A valid support bundle archive was not generated"
-EXIT_STATUS=1
+    echo "A valid support bundle archive was not generated"
+    exit 1
 fi
 
 echo "$(cat $tmpdir/$bundle_directory_name/analysis.json)"
 
 if grep -q "No matching files" "$tmpdir/$bundle_directory_name/analysis.json"; then
-echo "Some files were not collected"
-EXIT_STATUS=1
+    echo "Some files were not collected"
+    exit 1
 fi
 
 EXIT_STATUS=0
@@ -37,7 +37,29 @@ jq -r '.[].insight.severity' "$tmpdir/$bundle_directory_name/analysis.json" | wh
         echo "Analyzers with severity of \"warn\" found"
     fi
 done
+if [ $EXIT_STATUS -ne 0 ]; then
+    echo "support-bundle command failed"
+    exit $EXIT_STATUS
+fi
 
-rm -rf "$tmpdir"
+echo "======= Redact an existing support bundle ======"
+redact_tmpdir="$(mktemp -d)"
+redacted_archive_name="$redact_tmpdir/redacted-support-bundle.tar.gz"
+./bin/support-bundle redact examples/redact/e2e.yaml --bundle=$tmpdir/$bundle_archive_name --output=$redacted_archive_name
+if [ $? -ne 0 ]; then
+    echo "support-bundle redact command failed"
+    exit $?
+fi
+
+if ! tar -xvzf $redacted_archive_name --directory $redact_tmpdir; then
+    echo "Failed to extract redacted support bundle archive"
+    exit 1
+fi
+
+if ! grep "\*\*\*HIDDEN\*\*\*" "$redact_tmpdir/$bundle_directory_name/static-hi.log"; then
+    echo "$(cat $redact_tmpdir/$bundle_directory_name/static-hi.log)"
+    echo "Hidden content not found in redacted static-hi.log file"
+    exit 1
+fi
 
-exit $EXIT_STATUS
+rm -rf "$tmpdir" "$redact_tmpdir"
diff --git a/testdata/supportbundle/extracted-sb/cluster-resources/pods/logs/default/static-hi/static-hi.log b/testdata/supportbundle/extracted-sb/cluster-resources/pods/logs/default/static-hi/static-hi.log
new file mode 100644
index 00000000..06459998
--- /dev/null
+++ b/testdata/supportbundle/extracted-sb/cluster-resources/pods/logs/default/static-hi/static-hi.log
@@ -0,0 +1,2 @@
+hi static!
+
diff --git a/testdata/supportbundle/extracted-sb/static-hi.log b/testdata/supportbundle/extracted-sb/static-hi.log
new file mode 120000
index 00000000..062624ba
--- /dev/null
+++ b/testdata/supportbundle/extracted-sb/static-hi.log
@@ -0,0 +1,1 @@
+cluster-resources/pods/logs/default/static-hi/static-hi.log
\ No newline at end of file