Skip to content

Commit 6fa69b8

Browse files
authored
feat(windows_certificate): add filters.include / filters.exclude for tag-based cert collection scoping (#52441)
### What does this PR do? Adds `filters.include` and `filters.exclude` configuration to the `windows_certificate` check. Each filter maps a tag key (e.g. `certificate_thumbprint`, `subject_CN`, `subject_alt_name_dns`) to a Go regex pattern matched against the tag's value. ```yaml filters: include: <cert_tag_key>: <regex_pattern> exclude: <cert_tag_key>: <regex_pattern> ``` **Semantics:** - `include`: a certificate must satisfy **all** include rules to be reported. A cert missing a tag for an include key is excluded. - `exclude`: a certificate is dropped if **any** exclude rule matches one of its tag values. - Empty section = no filtering in that direction. - Invalid regex patterns are rejected at `Configure` time with a clear error. **A filter rule only ever matches a tag that is actually collected.** Opt-in tag keys (`subject_alt_name_dns`, `certificate_template_name`, `signature_algorithm`, etc.) require their corresponding `*_tag` flag to be enabled to be used as filter criteria. If a rule references a tag key that isn't collected under the current config (its flag is disabled, or the key — `certificate_store`/`server` — is never available as a filter key), `Configure` logs a warning and skips that rule instead of matching against a tag that can never be present. ### Motivation Addresses FRAGENT-3382 / tracked in TEEP-7268. Customers running Windows Certificate Store monitoring often have many certificates in a store (IIS, SQL Server, document encryption, etc.) and need to scope collection to specific ones — by thumbprint, SAN, CN, or template name — without creating separate check instances per certificate. Previously the only agent-side filter was `certificate_subjects` (CN substring match). With this change, operators can use any tag key emitted by the check (or available from the check's opt-in extensions, once the corresponding `*_tag` flag is enabled) as a filter key. The new opt-in tags from PR #49740 (SAN, friendly name, template, EKU) are usable as filter criteria as soon as their flag is turned on. ### Describe how you validated your changes - **Unit tests** (`windows_certificate_tags_test.go`): cover include-only, exclude-only, combined include+exclude, empty filters (pass-through), cert with no matching tag key, and `pruneUncollectedFilterKeys` dropping rules on disabled-flag tags, unsupported keys (`certificate_store`/`server`), and keeping rules whose flag is enabled. - **Configure tests** (`windows_certificate_test.go`): valid filter compiles without error; invalid regex returns error from `Configure`; a filter on a disabled opt-in tag is pruned rather than rejecting every certificate. - **Integration test** (`TestConfigureWithFiltersExcludesNonMatchingCerts`): runs against the live Windows `ROOT` store with an impossible thumbprint include-filter and asserts zero `Gauge` and `ServiceCheck` calls. - **Cross-compile**: `GOOS=windows GOARCH=amd64 go build ./pkg/collector/corechecks/system/windowscertificate/` — clean. - **Linter**: `dda inv linter.go --targets=./pkg/collector/corechecks/system/windowscertificate/...` — 0 issues. ### Additional Notes - Filtering is applied post-collection in `enumerateStoreContents`, after `StoreName` is stamped on each cert, covering both local and remote collection paths. - `applyTagFilters` evaluates rules against the same `Tags` a certificate emits, so a rule only matches values that are actually collected. `pruneUncollectedFilterKeys` runs once in `Configure` and drops any rule that references a tag whose `*_tag` flag is disabled (logging a warning), so such a rule simply has no effect rather than excluding every certificate. - `certificate_store` and `server` are not available as filter keys — they are appended to metrics in `Run()` after filtering. `pruneUncollectedFilterKeys` drops rules on these keys too. - Regex patterns are compiled once at `Configure` time (not per-run), so there is no performance overhead during collection. Co-authored-by: ian.bucad <ian.bucad@datadoghq.com>
1 parent 8bbb28c commit 6fa69b8

7 files changed

Lines changed: 505 additions & 14 deletions

File tree

cmd/agent/dist/assets/windows_certificate/spec.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,48 @@ files:
263263
Set to `true` to attach a `signature_algorithm` tag describing the
264264
certificate's signature algorithm (e.g. `signature_algorithm:sha256-rsa`).
265265
required: false
266+
- name: filters
267+
fleet_configurable: true
268+
value:
269+
type: object
270+
properties:
271+
include:
272+
type: object
273+
additionalProperties:
274+
type: string
275+
example: {"certificate_thumbprint": "^3a7b9c"}
276+
description: |
277+
A mapping of tag key to Go regexp pattern. A certificate must match ALL include rules
278+
to be reported. A certificate with no tag for a given include key is excluded.
279+
Note: `certificate_store` and `server` are not supported as filter keys.
280+
exclude:
281+
type: object
282+
additionalProperties:
283+
type: string
284+
example: {"subject_CN": "internal\\.example\\.com$"}
285+
description: |
286+
A mapping of tag key to Go regexp pattern. A certificate is dropped if ANY exclude
287+
rule matches one of its tag values.
288+
Note: `certificate_store` and `server` are not supported as filter keys.
289+
description: |
290+
Each rule maps a tag key to a Go regexp pattern matched against the tag's value.
291+
Invalid regexp patterns cause the check to fail at configuration time.
292+
293+
Supported filter keys:
294+
certificate_thumbprint SHA-1 hex thumbprint
295+
certificate_serial_number serial number in hex
296+
subject_CN Common Name
297+
subject_O Organization
298+
subject_alt_name_dns DNS SAN entry (requires subject_alternative_names_tag)
299+
subject_alt_name_ip IP SAN entry (requires subject_alternative_names_tag)
300+
subject_alt_name_email email SAN entry (requires subject_alternative_names_tag)
301+
friendly_name Windows friendly name (requires friendly_name_tag)
302+
enhanced_key_usage EKU short name or OID (requires enhanced_key_usage_tag)
303+
certificate_template_name template display name (requires certificate_template_tag)
304+
certificate_template_oid template OID (V2 certs) (requires certificate_template_tag)
305+
issuer_CN issuer Common Name (requires issuer_tag)
306+
signature_algorithm e.g. "SHA256-RSA" (requires signature_algorithm_tag)
307+
required: false
266308
- name: tags
267309
fleet_configurable: true
268310
value:

cmd/agent/dist/conf.d/windows_certificate.d/conf.yaml.example

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,59 @@ instances:
180180
#
181181
# signature_algorithm_tag: false
182182

183+
## @param filters - mapping - optional
184+
## Each rule maps a tag key to a Go regexp pattern matched against the
185+
## tag's value.
186+
##
187+
## `include`: a certificate must satisfy ALL include rules to be reported.
188+
## A certificate that has no tag for a given include key fails that rule.
189+
## `exclude`: a certificate is dropped if ANY exclude rule matches one of
190+
## its tag values.
191+
## When both are specified, include is evaluated first, then exclude.
192+
##
193+
## Supported filter keys:
194+
## certificate_thumbprint SHA-1 hex thumbprint
195+
## certificate_serial_number serial number in hex
196+
## subject_CN Common Name
197+
## subject_O Organization
198+
## subject_alt_name_dns DNS SAN entry (requires subject_alternative_names_tag)
199+
## subject_alt_name_ip IP SAN entry (requires subject_alternative_names_tag)
200+
## subject_alt_name_email email SAN entry (requires subject_alternative_names_tag)
201+
## friendly_name Windows friendly name (requires friendly_name_tag)
202+
## enhanced_key_usage EKU short name or OID (requires enhanced_key_usage_tag)
203+
## certificate_template_name template display name (requires certificate_template_tag)
204+
## certificate_template_oid template OID (V2 certs) (requires certificate_template_tag)
205+
## issuer_CN issuer Common Name (requires issuer_tag)
206+
## signature_algorithm e.g. "SHA256-RSA" (requires signature_algorithm_tag)
207+
##
208+
## `certificate_store` and `server` are not supported as filter keys; use
209+
## `certificate_store` / `certificate_store_regex` and `server` instead.
210+
##
211+
## Examples:
212+
##
213+
## # Report only certificates with a specific thumbprint:
214+
## filters:
215+
## include:
216+
## certificate_thumbprint: "^3a7b9c"
217+
##
218+
## # Exclude internal-only certificates by CN:
219+
## filters:
220+
## exclude:
221+
## subject_CN: "internal\\.example\\.com$"
222+
##
223+
## # Scope to IIS certificates and exclude staging:
224+
## filters:
225+
## include:
226+
## certificate_template_name: "WebServer"
227+
## exclude:
228+
## subject_CN: "staging"
229+
#
230+
# filters:
231+
# include:
232+
# <cert_tag_key>: <regex_pattern>
233+
# exclude:
234+
# <cert_tag_key>: <regex_pattern>
235+
183236
## @param tags - list of strings - optional
184237
## A list of tags to attach to every metric and service check emitted by this instance.
185238
##

pkg/collector/corechecks/system/windowscertificate/windows_certificate.go

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,20 @@ type certChainValidation struct {
105105
CertChainPolicyValidationFlags []string `yaml:"policy_validation_flags" json:"policy_validation_flags" nullable:"true"`
106106
}
107107

108+
// CertFilters holds include/exclude tag-filter rules. Each entry maps a tag
109+
// key (e.g. "certificate_thumbprint") to a Go regexp pattern matched against
110+
// the tag's value. An empty map means no filtering for that direction.
111+
type CertFilters struct {
112+
Include map[string]string `yaml:"include" json:"include" nullable:"true"`
113+
Exclude map[string]string `yaml:"exclude" json:"exclude" nullable:"true"`
114+
}
115+
116+
// compiledCertFilters holds the compiled regexp forms of CertFilters.
117+
type compiledCertFilters struct {
118+
include map[string]*regexp.Regexp
119+
exclude map[string]*regexp.Regexp
120+
}
121+
108122
// Config is the configuration options for this check
109123
// it is exported so that the yaml parser can read it.
110124
type Config struct {
@@ -127,13 +141,17 @@ type Config struct {
127141
SubjectAlternativeNamesTag bool `yaml:"subject_alternative_names_tag" json:"subject_alternative_names_tag" default:"false"`
128142
IssuerTag bool `yaml:"issuer_tag" json:"issuer_tag" default:"false"`
129143
SignatureAlgorithmTag bool `yaml:"signature_algorithm_tag" json:"signature_algorithm_tag" default:"false"`
144+
145+
// Filters controls which collected certificates are reported.
146+
Filters CertFilters `yaml:"filters" json:"filters" nullable:"true"`
130147
}
131148

132149
// WinCertChk is the object representing the check
133150
type WinCertChk struct {
134151
core.CheckBase
135152
config Config
136153
certStoreRegexes []*regexp.Regexp // non-nil when certificate_store_regex lists at least one pattern
154+
certFilters compiledCertFilters
137155
}
138156

139157
type crlInfoCopy struct {
@@ -148,7 +166,7 @@ type crlInfoCopy struct {
148166
type certInfo struct {
149167
SubjectString string
150168
NotAfter time.Time // certificate expiration
151-
Tags []string // cert-derived tags
169+
Tags []string // cert-derived tags (controlled by *_tag flags)
152170
Thumbprint string
153171
TrustStatusError uint32 // windows.TrustStatus.ErrorStatus
154172
ChainPolicyError uint32 // windows.CertChainPolicyStatus.Error
@@ -242,6 +260,16 @@ func (w *WinCertChk) Configure(senderManager sender.SenderManager, integrationCo
242260
}
243261
}
244262

263+
w.certFilters = compiledCertFilters{}
264+
if len(config.Filters.Include) > 0 || len(config.Filters.Exclude) > 0 {
265+
var filterErr error
266+
w.certFilters, filterErr = compileCertFilters(config.Filters)
267+
if filterErr != nil {
268+
return filterErr
269+
}
270+
pruneUncollectedFilterKeys(&w.certFilters, config)
271+
}
272+
245273
w.config = config
246274

247275
if w.config.DaysWarning < w.config.DaysCritical {
@@ -573,6 +601,9 @@ func (w *WinCertChk) enumerateStoreContents(storeHandle windows.Handle, store st
573601
for i := range crlInfo {
574602
crlInfo[i].StoreName = store
575603
}
604+
preFilterCount := len(certificates)
605+
certificates = applyTagFilters(certificates, w.certFilters)
606+
log.Debugf("Tag filters passed %d of %d certificates in store %s", len(certificates), preFilterCount, store)
576607
return certificates, crlInfo, nil
577608
}
578609

@@ -655,6 +686,9 @@ func buildCertInfo(certContext *windows.CertContext, storeHandle windows.Handle,
655686
}
656687

657688
// Build cert-derived tags here, while the cert bytes are still valid.
689+
// Filters (applied by the caller via applyTagFilters) evaluate against
690+
// these same tags, so filter rules only see values whose *_tag flag is
691+
// enabled — see pruneUncollectedFilterKeys.
658692
tags := getSubjectTags(cert)
659693
tags = append(tags, "certificate_thumbprint:"+thumbprint)
660694
tags = append(tags, "certificate_serial_number:"+cert.SerialNumber.Text(16))
@@ -972,3 +1006,52 @@ func netCancelConnection(name string) error {
9721006
log.Debugf("Canceling connection to %s", name)
9731007
return winutil.WNetCancelConnection2(name)
9741008
}
1009+
1010+
// compileCertFilters compiles the regexp patterns in a CertFilters into a
1011+
// compiledCertFilters. Returns an error if any pattern is invalid.
1012+
func compileCertFilters(filters CertFilters) (compiledCertFilters, error) {
1013+
compiled := compiledCertFilters{}
1014+
1015+
if len(filters.Include) > 0 {
1016+
compiled.include = make(map[string]*regexp.Regexp, len(filters.Include))
1017+
for key, pattern := range filters.Include {
1018+
re, err := regexp.Compile(pattern)
1019+
if err != nil {
1020+
return compiledCertFilters{}, fmt.Errorf("filters.include[%q]: invalid regular expression: %w", key, err)
1021+
}
1022+
compiled.include[key] = re
1023+
}
1024+
}
1025+
1026+
if len(filters.Exclude) > 0 {
1027+
compiled.exclude = make(map[string]*regexp.Regexp, len(filters.Exclude))
1028+
for key, pattern := range filters.Exclude {
1029+
re, err := regexp.Compile(pattern)
1030+
if err != nil {
1031+
return compiledCertFilters{}, fmt.Errorf("filters.exclude[%q]: invalid regular expression: %w", key, err)
1032+
}
1033+
compiled.exclude[key] = re
1034+
}
1035+
}
1036+
1037+
return compiled, nil
1038+
}
1039+
1040+
// pruneUncollectedFilterKeys warns and drops filters on uncollected tags.
1041+
func pruneUncollectedFilterKeys(f *compiledCertFilters, cfg Config) {
1042+
prune := func(rules map[string]*regexp.Regexp, direction string) {
1043+
for key := range rules {
1044+
if key == "certificate_store" || key == "server" {
1045+
log.Warnf("filters.%s: %q is not a supported filter key, ignoring", direction, key)
1046+
delete(rules, key)
1047+
continue
1048+
}
1049+
if flagName, requiresFlag := filterKeyTagFlag(key); requiresFlag && !tagFlagEnabled(cfg, flagName) {
1050+
log.Warnf("filters.%s: %q requires %q to be enabled, ignoring", direction, key, flagName)
1051+
delete(rules, key)
1052+
}
1053+
}
1054+
}
1055+
prune(f.include, "include")
1056+
prune(f.exclude, "exclude")
1057+
}

pkg/collector/corechecks/system/windowscertificate/windows_certificate_tags_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"encoding/asn1"
1414
"net"
1515
"net/url"
16+
"strings"
1617
"testing"
1718

1819
"github.com/stretchr/testify/assert"
@@ -306,3 +307,118 @@ func TestAppendOptionalTagsFriendlyNameSkippedWhenEmpty(t *testing.T) {
306307
require.NotContains(t, tag, "friendly_name:")
307308
}
308309
}
310+
311+
func TestCompileCertFiltersRejectsInvalidRegex(t *testing.T) {
312+
_, err := compileCertFilters(CertFilters{
313+
Include: map[string]string{"certificate_thumbprint": "["},
314+
})
315+
require.Error(t, err)
316+
}
317+
318+
func TestApplyTagFilters(t *testing.T) {
319+
certA := certInfo{Tags: []string{"certificate_thumbprint:abc123", "subject_CN:webserver"}}
320+
certB := certInfo{Tags: []string{"certificate_thumbprint:def456", "subject_CN:internal-db"}}
321+
certC := certInfo{Tags: []string{"certificate_thumbprint:aaa999", "subject_CN:webserver"}}
322+
323+
// include only thumbprints starting with "a"
324+
filters, err := compileCertFilters(CertFilters{
325+
Include: map[string]string{"certificate_thumbprint": "^a"},
326+
})
327+
require.NoError(t, err)
328+
got := applyTagFilters([]certInfo{certA, certB, certC}, filters)
329+
require.Len(t, got, 2)
330+
require.Equal(t, "abc123", tagValue(got[0].Tags, "certificate_thumbprint"))
331+
require.Equal(t, "aaa999", tagValue(got[1].Tags, "certificate_thumbprint"))
332+
333+
// exclude certs with CN matching "internal"
334+
filters2, err := compileCertFilters(CertFilters{
335+
Exclude: map[string]string{"subject_CN": "internal"},
336+
})
337+
require.NoError(t, err)
338+
got2 := applyTagFilters([]certInfo{certA, certB, certC}, filters2)
339+
require.Len(t, got2, 2)
340+
341+
// include + exclude combined: include "^a" thumbprints, exclude "internal" CN
342+
filters3, err := compileCertFilters(CertFilters{
343+
Include: map[string]string{"certificate_thumbprint": "^a"},
344+
Exclude: map[string]string{"subject_CN": "internal"},
345+
})
346+
require.NoError(t, err)
347+
got3 := applyTagFilters([]certInfo{certA, certB, certC}, filters3)
348+
require.Len(t, got3, 2) // certA and certC pass; certB excluded (thumbprint doesn't match include); no "internal" in remaining
349+
350+
// empty filters: all certs pass
351+
filters4, err := compileCertFilters(CertFilters{})
352+
require.NoError(t, err)
353+
got4 := applyTagFilters([]certInfo{certA, certB, certC}, filters4)
354+
require.Len(t, got4, 3)
355+
356+
// cert has no matching tag key for include rule → cert is excluded
357+
filters5, err := compileCertFilters(CertFilters{
358+
Include: map[string]string{"cert_san_dns": "example.com"},
359+
})
360+
require.NoError(t, err)
361+
got5 := applyTagFilters([]certInfo{certA, certB, certC}, filters5)
362+
require.Len(t, got5, 0)
363+
}
364+
365+
// TestPruneUncollectedFilterKeysDropsDisabledFlagRules verifies that a filter
366+
// rule on a tag key gated by a disabled *_tag flag is removed rather than
367+
// left in place to reject every certificate.
368+
func TestPruneUncollectedFilterKeysDropsDisabledFlagRules(t *testing.T) {
369+
f, err := compileCertFilters(CertFilters{
370+
Include: map[string]string{"signature_algorithm": "^sha256"},
371+
Exclude: map[string]string{"issuer_CN": "internal"},
372+
})
373+
require.NoError(t, err)
374+
375+
pruneUncollectedFilterKeys(&f, Config{})
376+
377+
require.Empty(t, f.include)
378+
require.Empty(t, f.exclude)
379+
380+
// With no rules left, filtering is a no-op.
381+
certs := []certInfo{{Tags: []string{"certificate_thumbprint:abc123"}}}
382+
got := applyTagFilters(certs, f)
383+
require.Len(t, got, 1)
384+
}
385+
386+
// TestPruneUncollectedFilterKeysKeepsEnabledFlagRules verifies that a filter
387+
// rule stays active once its controlling *_tag flag is enabled.
388+
func TestPruneUncollectedFilterKeysKeepsEnabledFlagRules(t *testing.T) {
389+
f, err := compileCertFilters(CertFilters{
390+
Include: map[string]string{"signature_algorithm": "^sha256"},
391+
})
392+
require.NoError(t, err)
393+
394+
pruneUncollectedFilterKeys(&f, Config{SignatureAlgorithmTag: true})
395+
396+
require.Len(t, f.include, 1)
397+
}
398+
399+
// TestPruneUncollectedFilterKeysDropsUnsupportedKeys verifies that
400+
// certificate_store and server rules — appended to metrics after filtering
401+
// runs — are always dropped, regardless of config.
402+
func TestPruneUncollectedFilterKeysDropsUnsupportedKeys(t *testing.T) {
403+
f, err := compileCertFilters(CertFilters{
404+
Include: map[string]string{"certificate_store": "^MY$"},
405+
Exclude: map[string]string{"server": "staging"},
406+
})
407+
require.NoError(t, err)
408+
409+
pruneUncollectedFilterKeys(&f, Config{})
410+
411+
require.Empty(t, f.include)
412+
require.Empty(t, f.exclude)
413+
}
414+
415+
// tagValue extracts the value for the first tag with the given key.
416+
func tagValue(tags []string, key string) string {
417+
prefix := key + ":"
418+
for _, t := range tags {
419+
if strings.HasPrefix(t, prefix) {
420+
return strings.TrimPrefix(t, prefix)
421+
}
422+
}
423+
return ""
424+
}

0 commit comments

Comments
 (0)