feat: add opt-in flag to skip symlinks

This commit is contained in:
Lucas Nassif
2026-03-23 17:24:56 +01:00
committed by Thibault VINCENT
parent 375572c373
commit 85c97e3837
7 changed files with 123 additions and 4 deletions
+5 -2
View File
@@ -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
+2
View File
@@ -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() {
@@ -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) |
@@ -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 }}
@@ -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)
+46 -2
View File
@@ -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
+64
View File
@@ -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)