Skip to content

Commit ca5e7d2

Browse files
dd-octo-sts[bot]NouemanKHALclaude
authored
Add security config validation based on configuration provider (#22226) (#23000)
* log source and provider and call pdb debugger * remove pdb call and source field * log both source and provider * log provider only * inject provider as property * add security module to read integration security agent configs * add secure_field property in the spec and the models * update SecurityConfig module, and improve security_field validation logic and model generation * rename secure_field to require_trusted_provider * improve models generation to be more concise * mark http and jmx filepath properties as protected * improve model generation to be more concise * generate new models * improve tests and fix security config default allowlist behavior * add fallback security validation in the base check * ddev validate config and models * add require_trusted_provider to the set of allowed value fields * code cleanup * ddev validate models -s * changelog * Fix path traversal and sibling-directory bypass in is_file_path_allowed Bare startswith allowed bypasses like /allowed-extra/ matching /allowed and /allowed/../etc/passwd traversing outside. Now resolves symlinks with os.path.realpath and enforces os.sep boundary checks. * Make DEFAULT_TRUSTED_PROVIDERS immutable Change from mutable list to tuple to prevent accidental mutation of shared module-level state across SecurityConfig instances. * Add type hints to core.py and utils.py Per AGENTS.md guidelines, new code should include type hints using modern syntax. * fix tests models/spec to include tls_cert param * Catch ValueError in GLOBAL_SECURE_FIELDS fallback ValueError from check_field_trusted_provider was escaping the except ValidationError handler. Add explicit ValueError catch to wrap it as ConfigurationError. * Remove phantom certificate_path from GLOBAL_SECURE_FIELDS certificate_path doesn't exist in any integration spec. * Add missing JMX fields to GLOBAL_SECURE_FIELDS Add java_bin_path, trust_store_path, key_store_path, and tools_jar_path for fallback coverage of JMX template fields. * Resolve allowlist paths in is_file_path_allowed Apply os.path.realpath() to allowlist entries so symlinks in the allowlist are resolved before comparison. * Remove dead branch in check_field_trusted_provider security_config cannot be None when the error raises, since validate_require_trusted_provider returns True for None. * Use list[str] in model_info.py Replace List[str] with modern list[str] syntax and remove unused typing import. * Add test coverage for fallback, allowlist, and excluded_checks Cover three security-critical code paths that had zero test coverage: GLOBAL_SECURE_FIELDS fallback blocking, allowlist bypass, and excluded_checks bypass. * Assert ConfigurationError instead of Exception in tests Use specific ConfigurationError in pytest.raises to avoid masking unexpected exceptions. * Match ConfigurationError in exception message instead of Exception dd_run_check wraps all errors as Exception, so match on ConfigurationError in the traceback string to verify the correct exception type is raised internally. * Fix regex patterns to match multiline exception messages Add (?s) dotall flag so ConfigurationError match works across newlines in dd_run_check's traceback output. * Add auth_token to test model and test for non-string secure field blocking Add an object-typed `auth_token` field with `require_trusted_provider: true` to the test spec/model and a test asserting that non-string secure fields are blocked from untrusted providers. * Enforce trusted-provider checks for non-string secure fields Remove the `isinstance(value, str)` early return that let non-string values bypass validation. Non-string values (e.g. object-typed auth_token) from untrusted providers are now blocked; the allowlist escape is applied only to string file paths. * fix validation for non-string fields such as auth_token, add more tests * ddev validate models -s * remove ValueError catch from base.py * fix fallback security validation placement * fix license headers year from 2024 to 2026 * address review: fix a bug where empty trusted_providers list would default to default providers * Revert all changes outside datadog_checks_base/ to origin/master Reset integration model regenerations, changelogs, and unrelated dependency changes so the branch only contains datadog_checks_base/ diffs. * revert datadog_checks_base models regeneration * address juanpe review --------- (cherry picked from commit 31eda08) Co-authored-by: NouemanKHAL <noueman.khalikine@datadoghq.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bfbebd7 commit ca5e7d2

File tree

9 files changed

+442
-7
lines changed

9 files changed

+442
-7
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for require_trusted_provider security validation

datadog_checks_base/datadog_checks/base/checks/base.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
from datadog_checks.base.utils.common import ensure_bytes, to_native_string
2828
from datadog_checks.base.utils.fips import enable_fips
2929
from datadog_checks.base.utils.format import json
30+
from datadog_checks.base.utils.models.validation.security import (
31+
DEFAULT_TRUSTED_PROVIDERS,
32+
SecurityConfig,
33+
check_field_trusted_provider,
34+
)
3035
from datadog_checks.base.utils.tagging import GENERIC_TAGS
3136
from datadog_checks.base.utils.tracing import traced_class
3237

@@ -75,6 +80,27 @@
7580
TYPO_SIMILARITY_THRESHOLD = 0.95
7681

7782

83+
# Global list of secure fields that require trusted provider validation.
84+
# This provides a fallback security check for integrations that haven't
85+
# regenerated their models with require_trusted_provider in the spec.
86+
GLOBAL_SECURE_FIELDS = frozenset(
87+
[
88+
'tls_cert',
89+
'tls_private_key',
90+
'tls_ca_cert',
91+
'kerberos_keytab',
92+
'kerberos_cache',
93+
'bearer_token_path',
94+
'auth_token',
95+
'private_key_path',
96+
'java_bin_path',
97+
'trust_store_path',
98+
'key_store_path',
99+
'tools_jar_path',
100+
]
101+
)
102+
103+
78104
@traced_class
79105
class AgentCheck(object):
80106
"""
@@ -195,6 +221,7 @@ def __init__(self, *args, **kwargs):
195221
instance = instances[0] if instances else None
196222

197223
self.check_id = ''
224+
self.provider = ''
198225
self.name = name # type: str
199226
self.init_config = init_config # type: InitConfigType
200227
self.agentConfig = agentConfig # type: AgentConfigType
@@ -299,6 +326,7 @@ def __init__(self, *args, **kwargs):
299326

300327
self.__formatted_tags = None
301328
self.__logs_enabled = None
329+
self.__security_config = None
302330
self.__persistent_cache_key_prefix: str = ""
303331

304332
if os.environ.get("GOFIPS", "0") == "1":
@@ -401,6 +429,28 @@ def logs_enabled(self):
401429

402430
return self.__logs_enabled
403431

432+
@property
433+
def security_config(self) -> SecurityConfig:
434+
"""
435+
Returns the integration security configuration, loaded once and cached.
436+
437+
The security config controls file path validation for untrusted providers.
438+
"""
439+
if self.__security_config is None:
440+
trusted_providers = datadog_agent.get_config('integration_trusted_providers')
441+
self.__security_config = SecurityConfig(
442+
check_name=self.name,
443+
provider=self.provider,
444+
ignore_untrusted_file_params=bool(datadog_agent.get_config('integration_ignore_untrusted_file_params')),
445+
file_paths_allowlist=datadog_agent.get_config('integration_file_paths_allowlist') or [],
446+
trusted_providers=trusted_providers
447+
if trusted_providers is not None
448+
else list(DEFAULT_TRUSTED_PROVIDERS),
449+
excluded_checks=datadog_agent.get_config('integration_security_excluded_checks') or [],
450+
)
451+
452+
return self.__security_config
453+
404454
@property
405455
def formatted_tags(self):
406456
# type: () -> str
@@ -604,10 +654,27 @@ def load_configuration_model(import_path, model_name, config, context):
604654

605655
raise ConfigurationError('\n'.join(message_lines)) from None
606656
else:
657+
# Fallback security validation for fields in GLOBAL_SECURE_FIELDS.
658+
# This catches secure fields in integrations that haven't regenerated
659+
# their models with require_trusted_provider in the spec.
660+
try:
661+
security_config = context.get('security_config')
662+
configured_fields = context.get('configured_fields', frozenset())
663+
for field_name in GLOBAL_SECURE_FIELDS & configured_fields:
664+
value = config.get(field_name)
665+
if value is not None:
666+
check_field_trusted_provider(field_name, value, security_config)
667+
except ValueError as e:
668+
raise ConfigurationError(str(e)) from None
607669
return config_model
608670

609671
def _get_config_model_context(self, config):
610-
return {'logger': self.log, 'warning': self.warning, 'configured_fields': frozenset(config)}
672+
return {
673+
'logger': self.log,
674+
'warning': self.warning,
675+
'configured_fields': frozenset(config),
676+
'security_config': self.security_config,
677+
}
611678

612679
def register_secret(self, secret: str) -> None:
613680
"""
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# (C) Datadog, Inc. 2021-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4-
from . import core, utils
4+
from . import core, security, utils
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
# (C) Datadog, Inc. 2021-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4-
def initialize_config(values, **kwargs):
4+
from __future__ import annotations
5+
6+
from typing import Any
7+
8+
9+
def initialize_config(values: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
510
# This is what is returned by the initial model validator of each config model.
611
return values
712

813

9-
def check_model(model, **kwargs):
14+
def check_model(model: Any, **kwargs: Any) -> Any:
1015
# This is what is returned by the final model validator of each config model.
1116
return model
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from __future__ import annotations
5+
6+
import os
7+
from dataclasses import dataclass, field
8+
9+
DEFAULT_TRUSTED_PROVIDERS: tuple[str, ...] = ('file', 'remote-config')
10+
11+
12+
@dataclass
13+
class SecurityConfig:
14+
"""Security configuration for integration file path validation."""
15+
16+
check_name: str = ''
17+
provider: str = ''
18+
ignore_untrusted_file_params: bool = False
19+
file_paths_allowlist: list[str] = field(default_factory=list)
20+
trusted_providers: list[str] = field(default_factory=lambda: list(DEFAULT_TRUSTED_PROVIDERS))
21+
excluded_checks: list[str] = field(default_factory=list)
22+
23+
def is_enabled(self) -> bool:
24+
"""Return whether file path security enforcement is enabled."""
25+
return self.ignore_untrusted_file_params
26+
27+
def is_provider_trusted(self, provider: str) -> bool:
28+
"""Return whether the given provider is in the trusted providers list."""
29+
return provider in self.trusted_providers
30+
31+
def is_file_path_allowed(self, path: str) -> bool:
32+
"""Return whether the resolved path falls under any allowed prefix directory."""
33+
resolved = os.path.realpath(path)
34+
return any(
35+
resolved == os.path.realpath(allowed) or resolved.startswith(os.path.realpath(allowed) + os.sep)
36+
for allowed in self.file_paths_allowlist
37+
)
38+
39+
def is_check_excluded(self, check_name: str) -> bool:
40+
"""Return whether the given check is excluded from security restrictions."""
41+
return check_name in self.excluded_checks
42+
43+
44+
def _get_auth_token_file_paths(value: dict) -> list[str]:
45+
"""Extract file paths from an auth_token object when reader.type is 'file'."""
46+
reader = value.get('reader')
47+
if not isinstance(reader, dict):
48+
return []
49+
if reader.get('type') != 'file':
50+
return []
51+
paths: list[str] = []
52+
for key in ('path', 'private_key_path'):
53+
path = reader.get(key)
54+
if isinstance(path, str):
55+
paths.append(path)
56+
return paths
57+
58+
59+
def validate_require_trusted_provider(
60+
field_name: str,
61+
value: object,
62+
security_config: SecurityConfig | None = None,
63+
) -> bool:
64+
"""Return True if the value is allowed based on security settings, False if it should be blocked."""
65+
if security_config is None:
66+
return True
67+
if not security_config.is_enabled():
68+
return True
69+
if security_config.is_check_excluded(security_config.check_name):
70+
return True
71+
if security_config.is_provider_trusted(security_config.provider):
72+
return True
73+
if isinstance(value, str):
74+
return security_config.is_file_path_allowed(value)
75+
# auth_token is the only non-string field with require_trusted_provider;
76+
# validate its reader.path when reader.type is 'file'
77+
if field_name == 'auth_token' and isinstance(value, dict):
78+
file_paths = _get_auth_token_file_paths(value)
79+
if file_paths:
80+
return all(security_config.is_file_path_allowed(p) for p in file_paths)
81+
return True
82+
return False
83+
84+
85+
def check_field_trusted_provider(
86+
field_name: str,
87+
value: object,
88+
security_config: SecurityConfig | None,
89+
) -> None:
90+
"""Raise ValueError if the field value is not allowed from an untrusted provider."""
91+
if not validate_require_trusted_provider(field_name, value, security_config):
92+
raise ValueError(f"Field '{field_name}' is not allowed from untrusted provider '{security_config.provider}'")

datadog_checks_base/datadog_checks/base/utils/models/validation/utils.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# (C) Datadog, Inc. 2021-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from __future__ import annotations
5+
46
from types import MappingProxyType
7+
from typing import Any, Callable
58

69

7-
def make_immutable(obj):
10+
def make_immutable(obj: Any) -> Any:
811
if isinstance(obj, list):
912
return tuple(make_immutable(item) for item in obj)
1013
elif isinstance(obj, dict):
@@ -13,8 +16,13 @@ def make_immutable(obj):
1316
return obj
1417

1518

16-
def handle_deprecations(config_section, deprecations, fields, context):
17-
warning_method = context['warning']
19+
def handle_deprecations(
20+
config_section: str,
21+
deprecations: dict[str, dict[str, str]],
22+
fields: set[str],
23+
context: dict[str, Any],
24+
) -> None:
25+
warning_method: Callable[..., Any] = context['warning']
1826

1927
for option, data in deprecations.items():
2028
if option not in fields:

datadog_checks_base/tests/models/config_models/instance.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
from . import defaults, deprecations, validators
2121

2222

23+
class AuthToken(BaseModel):
24+
model_config = ConfigDict(
25+
arbitrary_types_allowed=True,
26+
frozen=True,
27+
)
28+
reader: Optional[MappingProxyType[str, Any]] = None
29+
writer: Optional[MappingProxyType[str, Any]] = None
30+
31+
2332
class Obj(BaseModel):
2433
model_config = ConfigDict(
2534
arbitrary_types_allowed=True,
@@ -36,6 +45,7 @@ class InstanceConfig(BaseModel):
3645
frozen=True,
3746
)
3847
array: Optional[tuple[str, ...]] = None
48+
auth_token: Optional[AuthToken] = None
3949
deprecated: Optional[str] = None
4050
flag: Optional[bool] = None
4151
hyphenated_name: Optional[str] = Field(None, alias='hyphenated-name')
@@ -45,6 +55,7 @@ class InstanceConfig(BaseModel):
4555
pid: Optional[int] = None
4656
text: Optional[str] = None
4757
timeout: Optional[float] = None
58+
tls_cert: Optional[str] = None
4859

4960
@model_validator(mode='before')
5061
def _handle_deprecations(cls, values, info):

datadog_checks_base/tests/models/data/spec.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,20 @@ files:
7676
description: words
7777
value:
7878
type: string
79+
- name: tls_cert
80+
description: Path to TLS certificate
81+
value:
82+
type: string
83+
require_trusted_provider: true
84+
- name: auth_token
85+
description: Authentication token configuration
86+
value:
87+
type: object
88+
require_trusted_provider: true
89+
properties:
90+
- name: reader
91+
type: object
92+
properties: []
93+
- name: writer
94+
type: object
95+
properties: []

0 commit comments

Comments
 (0)