diff --git a/README.md b/README.md index d6015f8..c3ce0c3 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ rules: For advanced configuration, see the program's `--help`: ``` -Usage: x509-certificate-exporter [-hv] [-b value] [--debug] [-d value] [--exclude-label value] [--exclude-namespace value] [--expose-per-cert-error-metrics] [--expose-relative-metrics] [-f value] [--include-label value] [--include-namespace value] [--kubeconfig path] [-k value] [-l value] [--max-cache-duration value] [--profile] [-s value] [--trim-path-components value] [--watch-kube-secrets] [--web.config.file value] [--web.systemd-socket] [parameters ...] +Usage: x509-certificate-exporter [-hv] [-b value] [--debug] [-d value] [--exclude-label value] [--exclude-namespace value] [--expose-per-cert-error-metrics] [--expose-relative-metrics] [-f value] [--skip-symlinks] [--include-label value] [--include-namespace value] [--kubeconfig path] [-k value] [-l value] [--max-cache-duration value] [--profile] [-s value] [--trim-path-components value] [--watch-kube-secrets] [--web.config.file value] [--web.systemd-socket] [parameters ...] -b, --listen-address=value address on which to bind and expose metrics [:9793] --debug enable debug mode @@ -146,7 +146,10 @@ Usage: x509-certificate-exporter [-hv] [-b value] [--debug] [-d value] [--exclud namespace and randomized to avoid massive requests. --profile optionally enable a pprof server to monitor cpu and memory usage at runtime - -s, --secret-type=value + --skip-symlinks + skip symlinks when scanning files and directories. Does not + apply to Kubernetes secrets. + -s, --secret-type=value one or more kubernetes secret type & key to watch (e.g. "kubernetes.io/tls:tls.crt" --trim-path-components=value diff --git a/cmd/x509-certificate-exporter/main.go b/cmd/x509-certificate-exporter/main.go index 76f281e..2114079 100644 --- a/cmd/x509-certificate-exporter/main.go +++ b/cmd/x509-certificate-exporter/main.go @@ -37,6 +37,7 @@ func main() { exposeErrorMetrics := getopt.BoolLong("expose-per-cert-error-metrics", 0, "expose additionnal error metric for each certificate indicating wether it has failure(s)") exposeLabels := getopt.StringLong("expose-labels", 'l', "one or more comma-separated labels to enable (defaults to all if not specified)") profile := getopt.BoolLong("profile", 0, "optionally enable a pprof server to monitor cpu and memory usage at runtime") + skipSymlinks := getopt.BoolLong("skip-symlinks", 0, "skip symlinks when scanning files and directories (does not apply to Kubernetes secrets)") maxCacheDuration := durationFlag(0) getopt.FlagLong(&maxCacheDuration, "max-cache-duration", 0, "maximum cache duration for kube secrets. cache is per namespace and randomized to avoid massive requests.") @@ -157,6 +158,7 @@ func main() { KubeIncludeLabels: kubeIncludeLabels, KubeExcludeLabels: kubeExcludeLabels, KubeSecretLabels: kubeSecretLabels, + SkipSymlinks: *skipSymlinks, } if getopt.Lookup("expose-labels").Seen() { diff --git a/deploy/charts/x509-certificate-exporter/README.md b/deploy/charts/x509-certificate-exporter/README.md index be8d18e..e3abb4c 100644 --- a/deploy/charts/x509-certificate-exporter/README.md +++ b/deploy/charts/x509-certificate-exporter/README.md @@ -405,6 +405,7 @@ hostPathsExporter: | secretsExporter.env | list | `[]` | Additional environment variables for container | | hostPathsExporter.annotations | object | `{}` | Additional DaemonSet annotations | | hostPathsExporter.debugMode | bool | `false` | Should debug messages be produced by hostPath exporters (default for all hostPathsExporter.daemonSets) | +| hostPathsExporter.skipSymlinks | bool | `false` | Skip symlinks when scanning files and directories. Does not apply to Kubernetes secrets. | | hostPathsExporter.restartPolicy | string | `"Always"` | restartPolicy for Pods of hostPath exporters (default for all hostPathsExporter.daemonSets) | | hostPathsExporter.updateStrategy | object | `{}` | updateStrategy for DaemonSet of hostPath exporters (default for all hostPathsExporter.daemonSets) | | hostPathsExporter.revisionHistoryLimit | int | `nil` | Number of old ReplicaSets to retain for rollback (default for all hostPathsExporter.daemonSets) | diff --git a/deploy/charts/x509-certificate-exporter/templates/daemonset.yaml b/deploy/charts/x509-certificate-exporter/templates/daemonset.yaml index 5166dda..f3d0264 100644 --- a/deploy/charts/x509-certificate-exporter/templates/daemonset.yaml +++ b/deploy/charts/x509-certificate-exporter/templates/daemonset.yaml @@ -90,6 +90,9 @@ spec: {{- with default $.Values.hostPathsExporter.debugMode $dsDef.debugMode }} - --debug {{- end }} + {{- if default $.Values.hostPathsExporter.skipSymlinks $dsDef.skipSymlinks }} + - --skip-symlinks + {{- end }} {{- if $.Values.exposeRelativeMetrics }} - --expose-relative-metrics {{- end }} diff --git a/deploy/charts/x509-certificate-exporter/values.yaml b/deploy/charts/x509-certificate-exporter/values.yaml index 9f91583..ff4c46d 100644 --- a/deploy/charts/x509-certificate-exporter/values.yaml +++ b/deploy/charts/x509-certificate-exporter/values.yaml @@ -173,6 +173,8 @@ hostPathsExporter: annotations: {} # -- Should debug messages be produced by hostPath exporters (default for all hostPathsExporter.daemonSets) debugMode: false + # -- Skip symlinks when scanning files and directories. Does not apply to Kubernetes secrets. + skipSymlinks: false # -- restartPolicy for Pods of hostPath exporters (default for all hostPathsExporter.daemonSets) restartPolicy: Always # -- updateStrategy for DaemonSet of hostPath exporters (default for all hostPathsExporter.daemonSets) diff --git a/internal/exporter.go b/internal/exporter.go index 54d898f..22ec394 100644 --- a/internal/exporter.go +++ b/internal/exporter.go @@ -45,6 +45,7 @@ type Exporter struct { ExposeErrorMetrics bool ExposeLabels []string ConfigMapKeys []string + SkipSymlinks bool KubeSecretTypes []KubeSecretType KubeIncludeNamespaces []string KubeExcludeNamespaces []string @@ -294,14 +295,36 @@ func (exporter *Exporter) collectMatchingPaths(pattern string, format certificat continue } + filePath := path.Clean(path.Join(dir, file.Name())) + + // Check if symlink following is disabled and file is a symlink + if exporter.SkipSymlinks { + info, err := os.Lstat(filePath) + if err == nil && info.Mode()&os.ModeSymlink != 0 { + slog.Info("skipping symlink", "path", filePath) + continue + } + } + output = append(output, &certificateRef{ - path: path.Clean(path.Join(dir, file.Name())), + path: filePath, format: certificateFormatPEM, }) } } else { + fullPath := path.Clean(path.Join(basepath, filepath)) + + // Check if symlink following is disabled and file is a symlink + if exporter.SkipSymlinks { + info, err := os.Lstat(fullPath) + if err == nil && info.Mode()&os.ModeSymlink != 0 { + slog.Info("skipping symlink", "path", fullPath) + return nil + } + } + output = append(output, &certificateRef{ - path: path.Clean(path.Join(basepath, filepath)), + path: fullPath, format: format, yamlPaths: exporter.YAMLPaths, }) @@ -329,6 +352,16 @@ func (exporter *Exporter) collectMatchingPaths(pattern string, format certificat ) if err != nil { if errors.Is(err, doublestar.ErrPatternNotExist) { + // When skipping symlinks, a broken symlink can cause ErrPatternNotExist + // Check if the pattern itself is a symlink that should be skipped + if exporter.SkipSymlinks { + fullPath := path.Clean(pattern) + info, lstatErr := os.Lstat(fullPath) + if lstatErr == nil && info.Mode()&os.ModeSymlink != 0 { + slog.Info("skipping symlink", "path", fullPath) + return output, outputErrors + } + } return nil, []error{errors.New("no files match \"" + pattern + "\"")} } @@ -336,6 +369,17 @@ func (exporter *Exporter) collectMatchingPaths(pattern string, format certificat } if len(output) == 0 && len(outputErrors) == 0 { + // When skipping symlinks, the pattern might be a symlink that was skipped + // In that case, we should return empty output without error + if exporter.SkipSymlinks && !directories { + fullPath := path.Clean(pattern) + info, err := os.Lstat(fullPath) + if err == nil && info.Mode()&os.ModeSymlink != 0 { + slog.Info("skipping symlink", "path", fullPath) + return output, outputErrors + } + } + // the pattern evaluated to the opposite of what we want // i.e. we wanted files but the pattern only matched directories diff --git a/internal/exporter_test.go b/internal/exporter_test.go index 7998e16..0712edf 100644 --- a/internal/exporter_test.go +++ b/internal/exporter_test.go @@ -98,6 +98,70 @@ func TestSinglePEMBehindSymlink(t *testing.T) { }) } +func TestSkipSymlinksFlag(t *testing.T) { + _, filename, _, _ := runtime.Caller(0) + + // Test 1: Valid symlink to certificate file - should be skipped when SkipSymlinks=false + testRequest(t, &Exporter{ + Files: []string{path.Join(filepath.Dir(filename), "../test/link.pem")}, + SkipSymlinks: true, + }, func(metrics []model.MetricFamily) { + metric := getMetricsForName(metrics, "x509_cert_expired") + assert.Len(t, metric, 0, "symlink should be skipped when SkipSymlinks=false") + }) + + // Test 2: Broken symlink - should be skipped gracefully when SkipSymlinks=false (no error) + testRequest(t, &Exporter{ + Files: []string{path.Join(filepath.Dir(filename), "../test/badlink.pem")}, + SkipSymlinks: true, + }, func(metrics []model.MetricFamily) { + metric := getMetricsForName(metrics, "x509_cert_expired") + assert.Len(t, metric, 0, "broken symlink should be skipped when SkipSymlinks=false") + errors := getMetricsForName(metrics, "x509_read_errors") + assert.Len(t, errors, 1, "x509_read_errors metric should exist") + assert.Equal(t, 0., errors[0].GetGauge().GetValue(), "no read errors should occur when not following symlinks") + }) + + // Test 3: Broken relative symlink - should be skipped gracefully when SkipSymlinks=false + testRequest(t, &Exporter{ + Files: []string{path.Join(filepath.Dir(filename), "../test/badlink-relative.pem")}, + SkipSymlinks: true, + }, func(metrics []model.MetricFamily) { + metric := getMetricsForName(metrics, "x509_cert_expired") + assert.Len(t, metric, 0, "broken symlink should be skipped when SkipSymlinks=false") + errors := getMetricsForName(metrics, "x509_read_errors") + assert.Len(t, errors, 1, "x509_read_errors metric should exist") + assert.Equal(t, 0., errors[0].GetGauge().GetValue(), "no read errors should occur when not following symlinks") + }) +} + +func TestSkipSymlinksInDirectory(t *testing.T) { + _, filename, _, _ := runtime.Caller(0) + testDir := path.Join(filepath.Dir(filename), "../test") + + // Test 1: Directory scan with SkipSymlinks=true (default) - should include symlink + testRequest(t, &Exporter{ + Directories: []string{testDir}, + SkipSymlinks: false, + }, func(metrics []model.MetricFamily) { + metric := getMetricsForName(metrics, "x509_cert_expired") + // Should have 6 certs: badlink-relative.pem (1), badlink.pem (1), basic.pem (1), double.pem (2), and link.pem (1) + // Note: broken symlinks are resolved to their targets (badlink*.pem -> basic.pem) + assert.Len(t, metric, 6, "should include symlink when SkipSymlinks=true") + }) + + // Test 2: Directory scan with SkipSymlinks=false - should exclude symlink + testRequest(t, &Exporter{ + Directories: []string{testDir}, + SkipSymlinks: true, + }, func(metrics []model.MetricFamily) { + metric := getMetricsForName(metrics, "x509_cert_expired") + // Should have 3 certs: basic.pem (1) and double.pem (2) + // Note: all symlinks (link.pem, badlink.pem, badlink-relative.pem) are skipped + assert.Len(t, metric, 3, "should exclude symlink when SkipSymlinks=false") + }) +} + func TestMultiplePEM(t *testing.T) { notBefore := time.Now() notAfter := notBefore.Add(time.Hour)