Skip to content
Draft
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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ The following obfuscation types are supported:
* [Domain name](#domain-name-obfuscation)
* [Keywords](#keywords)
* [Regex](#regex)
* [Azure Resources](#azure-resources-obfuscation)

### MAC address obfuscation

Expand Down Expand Up @@ -275,6 +276,40 @@ This however, is much more useful in the second example where we want to obfusca

There is currently no support for consistent replacement as in the built-in types, there is a feature upcoming for capture groups and individual replacements thereof.

### Azure Resources obfuscation

The `AzureResources` type detects and obfuscates Azure-specific identifiers that commonly appear in must-gathers from Azure-hosted OpenShift clusters (e.g. ARO, ARO-HCP). It handles:

* **ARM resource paths** — subscription IDs, resource group names, and resource names inside paths like `/subscriptions/{id}/resourceGroups/{name}/providers/...`
* **Azure identity UUIDs** — `clientId`, `principalId`, `tenantId`, `objectId`, and `appId` values in JSON
* **Kubernetes Azure labels** — UUIDs in `kubernetes.azure.com/*=UUID` node labels

```
config:
obfuscate:
- type: AzureResources
replacementType: Consistent
target: FileContents
```

Replacement uses consistent pet names (e.g. `subscription-artistic-walleye`, `resourcegroup-calm-reptile`, `identity-real-walrus`) so that the same identifier always maps to the same replacement across all files.

**Important:** Use `target: FileContents` rather than `target: All` to avoid renaming infrastructure directories like `service/` or `cluster/` that may collide with Azure resource names. To obfuscate Azure domain names (Key Vaults, container registries), use the `Domain` obfuscator:

```
config:
obfuscate:
- type: Domain
replacementType: Consistent
target: All
domainNames:
- "vault.azure.net"
- "azurecr.io"
- type: AzureResources
replacementType: Consistent
target: FileContents
```


#### Chaining obfuscators and side effects

Expand Down
9 changes: 8 additions & 1 deletion examples/openshift_default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@ config:
domainNames:
- "rhcloud.com"
- "dev.rhcloud.com"
- type: AzureResources
- type: Domain
replacementType: Consistent
target: All
domainNames:
- "vault.azure.net"
- "azurecr.io"
- "blob.core.windows.net"
- type: AzureResources
replacementType: Consistent
target: FileContents
omit:
- type: Kubernetes
kubernetesResource:
Expand Down
116 changes: 116 additions & 0 deletions pkg/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"

"github.com/openshift/must-gather-clean/pkg/cleaner"
"github.com/openshift/must-gather-clean/pkg/fsutil"
Expand Down Expand Up @@ -78,6 +81,11 @@ func Run(configPath string, inputPath string, outputPath string, deleteOutputFol
return fmt.Errorf("failed to create obfuscators via config at %s: %w", configPath, err)
}

// Seed Azure Resource Obfuscators with cluster token extracted from
// input file names. This enables compound discovery during prescan
// even when the dataset contains no ARM paths.
seedObfuscatorsFromInputDir(inputPath, obfuscator, prescanObfuscator)

// this pass allows obfuscators that first need to scan the input to determine what needs to be obfuscated to run before
// redactor actually happens. The empty input path signals a dry-run.
prescanCleaner := cleaner.NewFileCleaner(inputPath, "", prescanObfuscator, &omitter.NoopOmitter{})
Expand Down Expand Up @@ -194,3 +202,111 @@ func createObfuscatorsFromConfig(config *schema.SchemaJson) (finalObfuscator *ob
}
return obfuscator.NewMultiObfuscator(obfuscators), obfuscator.NewMultiObfuscator(prescanObfuscators), nil
}

// preferredSeedDirs are the must-gather filesystem subdirectory names most likely
// to contain files following the <env>-<clusterID>-<type>-<namespace>-<container>
// naming convention used for cluster token extraction. These are directory names,
// not semantic keywords — the overlap with genericSkipWords is incidental.
//
// "mgmt" is included because ARO-HCP must-gathers contain a separate management
// cluster log directory whose prefix (e.g. "hcp-underlay-cd-mgmt-1") is entirely
// different from the service cluster prefix and must be seeded independently.
var preferredSeedDirs = map[string]struct{}{
"service": {},
"cluster": {},
"mgmt": {},
}

// collectFileNames reads non-directory entries from each subdirectory in dirs
// and returns their base names.
func collectFileNames(inputPath string, dirs []string) []string {
var fileNames []string
for _, dir := range dirs {
dirPath := filepath.Join(inputPath, dir)
entries, err := os.ReadDir(dirPath)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() {
fileNames = append(fileNames, e.Name())
}
}
}
return fileNames
}

// seedObfuscatorsFromInputDir extracts cluster prefixes from must-gather file
// names and seeds them into all SeedableObfuscators. Each preferred directory
// is processed independently so that a must-gather containing both a "service"
// cluster and a "mgmt" cluster seeds both prefixes even when they share no
// common prefix. Falls back to all other subdirectories only if no preferred
// directory yields a valid prefix.
func seedObfuscatorsFromInputDir(inputPath string, obfuscators ...*obfuscator.MultiObfuscator) {
primaryDirs := make([]string, 0, len(preferredSeedDirs))
for dir := range preferredSeedDirs {
primaryDirs = append(primaryDirs, dir)
}
sort.Strings(primaryDirs)

seededAny := false
for _, dir := range primaryDirs {
fileNames := collectFileNames(inputPath, []string{dir})
prefix, token := obfuscator.ExtractClusterInfo(fileNames)
if prefix == "" {
continue
}
klog.Infof("discovered cluster prefix %q from input directory %q (token %q)", prefix, dir, token)
seedOnePrefix(prefix, token, obfuscators...)
seededAny = true
}

if seededAny {
return
}

// Fallback: no preferred dir yielded a prefix — try all other subdirs pooled together.
entries, err := os.ReadDir(inputPath)
if err != nil {
return
}
var fallbackDirs []string
for _, e := range entries {
if e.IsDir() {
if _, preferred := preferredSeedDirs[e.Name()]; !preferred {
fallbackDirs = append(fallbackDirs, e.Name())
}
}
}
fileNames := collectFileNames(inputPath, fallbackDirs)
prefix, token := obfuscator.ExtractClusterInfo(fileNames)
if prefix == "" {
return
}
klog.Infof("discovered cluster prefix %q from fallback directories (token %q)", prefix, token)
seedOnePrefix(prefix, token, obfuscators...)
}

// seedOnePrefix seeds a single cluster prefix (and optional token) into all
// SeedableObfuscators, deduplicating by pointer identity so that an obfuscator
// shared between the final and prescan multi-obfuscators is only seeded once
// per prefix.
func seedOnePrefix(prefix, token string, obfuscators ...*obfuscator.MultiObfuscator) {
concatenated := strings.ReplaceAll(prefix, "-", "")
seen := map[obfuscator.SeedableObfuscator]struct{}{}
for _, mo := range obfuscators {
for _, o := range mo.SeedableObfuscators() {
if _, dup := seen[o]; dup {
continue
}
seen[o] = struct{}{}
o.SeedCanonical(prefix)
if concatenated != prefix {
o.SeedCanonical(concatenated)
}
if token != "" {
o.SetClusterToken(token)
}
}
}
}
104 changes: 104 additions & 0 deletions pkg/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

"github.com/openshift/must-gather-clean/pkg/kube"
"github.com/openshift/must-gather-clean/pkg/obfuscator"
"github.com/openshift/must-gather-clean/pkg/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -201,6 +202,109 @@ config:
assert.Equal(t, "some IP 192.167.122.2 that should not to be obfuscated\nand some mac x-mac-0000000001-x\n", string(bytes))
}

// newSeedableMultiObfuscator builds a MultiObfuscator containing a single
// AzureResourceObfuscator so we can observe what seedObfuscatorsFromInputDir seeds.
func newSeedableMultiObfuscator(t *testing.T) *obfuscator.MultiObfuscator {
t.Helper()
seed := 42
tracker := obfuscator.NewSimpleTracker()
azureObf, err := obfuscator.NewAzureResourceObfuscator(schema.ObfuscateReplacementTypeConsistent, tracker, &seed)
require.NoError(t, err)
return obfuscator.NewMultiObfuscator([]obfuscator.ReportingObfuscator{azureObf})
}

// writeFiles creates empty files under dir/subdir for each name.
func writeFiles(t *testing.T, dir, subdir string, names []string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(dir, subdir), 0755))
for _, name := range names {
require.NoError(t, os.WriteFile(filepath.Join(dir, subdir, name), []byte{}, 0644))
}
}

// TestSeedObfuscatorsFromInputDir_UsesPreferredDirs verifies that when a preferred
// seed directory ("service" or "cluster") contains valid cluster-named files, its
// cluster prefix is seeded and the fallback directory is not used.
func TestSeedObfuscatorsFromInputDir_UsesPreferredDirs(t *testing.T) {
dir := t.TempDir()

// Preferred "service" dir: files yield prefix "dev-qm7v3npl-svc".
writeFiles(t, dir, "service", []string{
"dev-qm7v3npl-svc-default-pod.jsonl",
"dev-qm7v3npl-svc-kube-system-worker.jsonl",
})
// Fallback dir: different cluster ID — must NOT be seeded.
writeFiles(t, dir, "other", []string{
"stg-xyz9def2-svc-default-pod.jsonl",
"stg-xyz9def2-svc-kube-system-worker.jsonl",
})

mo := newSeedableMultiObfuscator(t)
seedObfuscatorsFromInputDir(dir, mo)

// "dev-qm7v3npl-svc" was seeded from the preferred dir and must be replaced.
out := mo.Contents("log line with dev-qm7v3npl-svc in it")
assert.NotContains(t, out, "dev-qm7v3npl-svc", "preferred-dir prefix should be obfuscated")

// The fallback dir's cluster token must NOT have been seeded.
out2 := mo.Contents("log line with stg-xyz9def2-svc in it")
assert.Contains(t, out2, "xyz9def2", "fallback dir should not be used when preferred dir yields a prefix")
}

// TestSeedObfuscatorsFromInputDir_FallsBackToOtherDirs verifies that when the
// preferred seed directories yield no valid cluster prefix, the seeder falls back
// to all other subdirectories in the input path.
func TestSeedObfuscatorsFromInputDir_FallsBackToOtherDirs(t *testing.T) {
dir := t.TempDir()

// Preferred "service" dir exists but contains no cluster-named files.
require.NoError(t, os.MkdirAll(filepath.Join(dir, "service"), 0755))

// Fallback dir has valid cluster-named files yielding prefix "stg-xyz9def2-svc".
writeFiles(t, dir, "fallback", []string{
"stg-xyz9def2-svc-default-pod.jsonl",
"stg-xyz9def2-svc-kube-system-worker.jsonl",
})

mo := newSeedableMultiObfuscator(t)
seedObfuscatorsFromInputDir(dir, mo)

// "stg-xyz9def2-svc" was seeded from the fallback dir and must be replaced.
out := mo.Contents("log line with stg-xyz9def2-svc in it")
assert.NotContains(t, out, "stg-xyz9def2-svc", "fallback-dir prefix should be obfuscated when preferred dirs yield nothing")
assert.NotEqual(t, "log line with stg-xyz9def2-svc in it", out, "output must differ from input — replacement must have occurred")
}

// TestSeedObfuscatorsFromInputDir_SeedsBothServiceAndMgmt verifies that when both
// a "service" and "mgmt" preferred directory contain cluster-named files, BOTH
// prefixes are seeded independently — the mgmt cluster must not be dropped just
// because the service cluster was found first.
func TestSeedObfuscatorsFromInputDir_SeedsBothServiceAndMgmt(t *testing.T) {
dir := t.TempDir()

// "service" dir: staging service cluster (tst-northeu-svc-1-*)
writeFiles(t, dir, "service", []string{
"tst-northeu-svc-1-default-pod.jsonl",
"tst-northeu-svc-1-kube-system-worker.jsonl",
})
// "mgmt" dir: management cluster with entirely different naming scheme
writeFiles(t, dir, "mgmt", []string{
"hcp-underlay-cd-mgmt-1-default-pod.jsonl",
"hcp-underlay-cd-mgmt-1-kube-system-worker.jsonl",
})

mo := newSeedableMultiObfuscator(t)
seedObfuscatorsFromInputDir(dir, mo)

// Service cluster prefix must be obfuscated.
out1 := mo.Contents("log line with tst-northeu-svc-1 in it")
assert.NotContains(t, out1, "tst-northeu-svc-1", "service cluster prefix must be obfuscated")

// Management cluster prefix must ALSO be obfuscated.
out2 := mo.Contents("log line with hcp-underlay-cd-mgmt-1 in it")
assert.NotContains(t, out2, "hcp-underlay-cd-mgmt-1", "management cluster prefix must be obfuscated")
}

func TestWaterMarkerNotCreatedOnFail(t *testing.T) {
testDir, err := os.MkdirTemp(os.TempDir(), "test-dir-*")
require.NoError(t, err)
Expand Down
Loading